本文是 《用 Golang 实现一个 Redis》系列文章第二篇,本文将分别介绍Redis 通信协议 以及 协议解析器 的实现,若您对协议有所了解可以直接阅读协议解析器部分。

Redis 通信协议

Redis 自 2.0 版本起使用了统一的协议 RESP (REdis Serialization Protocol),该协议易于实现,计算机可以高效的进行解析且易于被人类读懂。

RESP 是一个二进制安全的文本协议,工作于 TCP 协议上。客户端和服务器发送的命令或数据一律以 \r\n (CRLF)结尾。

RESP 定义了5种格式:

  • 简单字符串(Simple String): 服务器用来返回简单的结果,比如"OK"。非二进制安全,且不允许换行。
  • 错误信息(Error): 服务器用来返回简单的结果,比如"ERR Invalid Synatx"。非二进制安全,且不允许换行。
  • 整数(Integer): llenscard等命令的返回值, 64位有符号整数
  • 字符串(Bulk String): 二进制安全字符串, get 等命令的返回值
  • 数组(Array, 旧版文档中称 Multi Bulk Strings): Bulk String 数组,客户端发送指令以及lrange等命令响应的格式

RESP 通过第一个字符来表示格式:

  • 简单字符串:以"+" 开始, 如:"+OK\r\n"
  • 错误:以"-" 开始,如:"-ERR Invalid Synatx\r\n"
  • 整数:以":"开始,如:":1\r\n"
  • 字符串:以 $ 开始
  • 数组:以 * 开始

Bulk String有两行,第一行为 $+正文长度,第二行为实际内容。如:

$3\r\nSET\r\n

Bulk String 是二进制安全的可以包含任意字节,就是说可以在 Bulk String 内部包含 "\r\n" 字符(行尾的CRLF被隐藏):

$4
a\r\nb

$-1 表示 nil, 比如使用 get 命令查询一个不存在的key时,响应即为$-1

Array 格式第一行为 "*"+数组长度,其后是相应数量的 Bulk String。如, ["foo", "bar"]的报文:

*2
$3
foo
$3
bar

客户端也使用 Array 格式向服务端发送指令。命令本身将作为第一个参数,如 SET key value指令的RESP报文:

*3
$3
SET
$3
key
$5
value

将换行符打印出来:

*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n

协议解析器

我们在 实现TCP服务器 一文中已经介绍过TCP服务器的实现,协议解析器将实现其 Handler 接口充当应用层服务器。

协议解析器将接收 Socket 传来的数据,并将其数据还原为 [][]byte 格式,如 "*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\value\r\n" 将被还原为 ['SET', 'key', 'value']

本文完整代码: Github: HDT3213/godis

来自客户端的请求均为数组格式,它在第一行中标记报文的总行数并使用CRLF作为分行符。

bufio 标准库可以将从 reader 读到的数据缓存到 buffer 中,直至遇到分隔符或读取完毕后返回,所以我们使用 reader.ReadBytes('\n') 来保证每次读取到完整的一行。

需要注意的是RESP是二进制安全的协议,它允许在正文中使用CRLF字符。举例来说 Redis 可以正确接收并执行SET "a\r\nb" 1指令, 这条指令的正确报文是这样的:

*3
$3
SET
$4
a\r\nb
$7
myvalue

ReadBytes 读取到第五行 "a\r\nb\r\n"时会将其误认为两行:

*3
$3
SET
$4
a // 错误的分行
b // 错误的分行
$7
myvalue

因此当读取到第四行$4后, 不应该继续使用 ReadBytes('\n') 读取下一行, 应使用 io.ReadFull(reader, msg) 方法来读取指定长度的内容。

msg = make([]byte, 4 + 2) // 正文长度4 + 换行符长度2
_, err = io.ReadFull(reader, msg)

定义 Client 结构体作为客户端抽象:

type Client struct {
/* 与客户端的 Tcp 连接 */
conn net.Conn /*
* 带有 timeout 功能的 WaitGroup, 用于优雅关闭
* 当响应被完整发送前保持 waiting 状态, 阻止链接被关闭
*/
waitingReply wait.Wait /* 标记客户端是否正在发送指令 */
sending atomic.AtomicBool /* 客户端正在发送的参数数量, 即 Array 第一行指定的数组长度 */
expectedArgsCount uint32 /* 已经接收的参数数量, 即 len(args)*/
receivedCount uint32 /*
* 已经接收到的命令参数,每个参数由一个 []byte 表示
*/
args [][]byte
}

定义解析器:

type Handler struct {

    /*
* 记录活跃的客户端链接
* 类型为 *Client -> placeholder
*/
activeConn sync.Map /* 数据库引擎,执行指令并返回结果 */
db db.DB /* 关闭状态标志位,关闭过程中时拒绝新建连接和新请求 */
closing atomic.AtomicBool
}

接下来可以编写主要部分了:

func (h *Handler)Handle(ctx context.Context, conn net.Conn) {
if h.closing.Get() {
// 关闭过程中不接受新连接
_ = conn.Close()
} /* 初始化客户端状态 */
client := &Client {
conn: conn,
}
h.activeConn.Store(client, 1) reader := bufio.NewReader(conn)
var fixedLen int64 = 0 // 将要读取的 BulkString 的正文长度
var err error
var msg []byte
for {
/* 读取下一行数据 */
if fixedLen == 0 { // 正常模式下使用 CRLF 区分数据行
msg, err = reader.ReadBytes('\n')
// 判断是否以 \r\n 结尾
if len(msg) == 0 || msg[len(msg) - 2] != '\r' {
errReply := &reply.ProtocolErrReply{Msg:"invalid multibulk length"}
_, _ = client.conn.Write(errReply.ToBytes())
}
} else { // 当读取到 BulkString 第二行时,根据给出的长度进行读取
msg = make([]byte, fixedLen + 2)
_, err = io.ReadFull(reader, msg)
// 判断是否以 \r\n 结尾
if len(msg) == 0 ||
msg[len(msg) - 2] != '\r' ||
msg[len(msg) - 1] != '\n'{
errReply := &reply.ProtocolErrReply{Msg:"invalid multibulk length"}
_, _ = client.conn.Write(errReply.ToBytes())
}
// Bulk String 读取完毕,重新使用正常模式
fixedLen = 0
}
// 处理 IO 异常
if err != nil {
if err == io.EOF || err == io.ErrUnexpectedEOF {
logger.Info("connection close")
} else {
logger.Warn(err)
}
_ = client.Close()
h.activeConn.Delete(client)
return // io error, disconnect with client
} /* 解析收到的数据 */
if !client.sending.Get() {
// sending == false 表明收到了一条新指令
if msg[0] == '*' {
// 读取第一行获取参数个数
expectedLine, err := strconv.ParseUint(string(msg[1:len(msg)-2]), 10, 32)
if err != nil {
_, _ = client.conn.Write(UnknownErrReplyBytes)
continue
}
// 初始化客户端状态
client.waitingReply.Add(1) // 有指令未处理完成,阻止服务器关闭
client.sending.Set(true) // 正在接收指令中
// 初始化计数器和缓冲区
client.expectedArgsCount = uint32(expectedLine)
client.receivedCount = 0
client.args = make([][]byte, expectedLine)
} else {
// TODO: text protocol
}
} else {
// 收到了指令的剩余部分(非首行)
line := msg[0:len(msg)-2] // 移除换行符
if line[0] == '$' {
// BulkString 的首行,读取String长度
fixedLen, err = strconv.ParseInt(string(line[1:]), 10, 64)
if err != nil {
errReply := &reply.ProtocolErrReply{Msg:err.Error()}
_, _ = client.conn.Write(errReply.ToBytes())
}
if fixedLen <= 0 {
errReply := &reply.ProtocolErrReply{Msg:"invalid multibulk length"}
_, _ = client.conn.Write(errReply.ToBytes())
}
} else {
// 收到参数
client.args[client.receivedCount] = line
client.receivedCount++
} // 一条命令发送完毕
if client.receivedCount == client.expectedArgsCount {
client.sending.Set(false) // 执行命令并响应
result := h.db.Exec(client.args)
if result != nil {
_, _ = conn.Write(result.ToBytes())
} else {
_, _ = conn.Write(UnknownErrReplyBytes)
} // 重置客户端状态,等待下一条指令
client.expectedArgsCount = 0
client.receivedCount = 0
client.args = nil
client.waitingReply.Done()
}
}
}
}

实现 Redis 协议解析器的更多相关文章

  1. 【wireshark】协议解析

    1. 普通解析 Wireshark启动时,所有解析器进行初始化和注册.要注册的信息包括协议名称.各个字段的信息.过滤用的关键字.要关联的下层协议与端口(handoff)等.在解析过程,每个解析器负责解 ...

  2. Wireshark DTN解析器拒绝服务漏洞

    受影响系统:Wireshark Wireshark 2.2.0 - 2.2.1Wireshark Wireshark 2.0.0 - 2.0.7描述:CVE(CAN) ID: CVE-2016-937 ...

  3. Wireshark OpenFlow解析器拒绝服务漏洞

    受影响系统:Wireshark Wireshark 2.2.0 - 2.2.1Wireshark Wireshark 2.0.0 - 2.0.7描述:CVE(CAN) ID: CVE-2016-937 ...

  4. python 全栈开发,Day101(redis操作,购物车,DRF解析器)

    昨日内容回顾 1. django请求生命周期? - 当用户在浏览器中输入url时,浏览器会生成请求头和请求体发给服务端 请求头和请求体中会包含浏览器的动作(action),这个动作通常为get或者po ...

  5. dom解析器机制 web基本概念 tomcat

    0 作业[cn.itcast.xml.sax.Demo2]   1)在SAX解析器中,一定要知道每方法何时执行,及SAX解析器会传入的参数含义 1 理解dom解析器机制 1)dom解析和dom4j原理 ...

  6. 字符串处理(正则表达式、NSScanner扫描、CoreParse解析器)-备用

    搜索 在一个字符串中搜索子字符串 最灵活的方法 1 - (NSRange)rangeOfString:(NSString *)aString options:(NSStringCompareOptio ...

  7. Android_HTML解析器_jsoup

    jsoup 是一款Java 的HTML解析器,可直接解析某个URL地址.HTML文本内容.它提供了一套非常省力的API,可通过DOM,CSS以及类似于jQuery的操作方法来取出和操作数据. Jsou ...

  8. twemproxyRedis协议解析探索——剖析twemproxy代码正编

    这篇文章会对twemproxyRedis协议解析代码部分进行一番简单的分析,同时给出twemproxy目前支持的所有Redis命令.在这篇文章开始前,我想大家去简单地理解一下有限状态机,当然不理解也是 ...

  9. 在.NET Core中使用Irony实现自己的查询语言语法解析器

    在之前<在ASP.NET Core中使用Apworks快速开发数据服务>一文的评论部分,.NET大神张善友为我提了个建议,可以使用Compile As a Service的Roslyn为语 ...

随机推荐

  1. 详解Java8 Optional类{最全}

    1:Optional 1.1 概述 Optional 类主要解决的问题是臭名昭著的空指针异常(NullPointerException),提供了一些的方法代替过去的if-else处理逻辑,并与Stre ...

  2. 150行代码搭建异步非阻塞Web框架

    最近看Tornado源码给了我不少启发,心血来潮决定自己试着只用python标准库来实现一个异步非阻塞web框架.花了点时间感觉还可以,一百多行的代码已经可以撑起一个极简框架了. 一.准备工作 需要的 ...

  3. 函数基础(一)(day10整理)

    目录 昨日内容 文件的基本应用 什么是文件 操作文件的流程 打开文件的三种模式 文件打开的两种方式 绝对路径和相对路径 绝对路径 相对路径 with管理文件上下文 文件的高级应用 新的打开文件的模式 ...

  4. Java基础(二十八)Java IO(5)RandomAccessFile类与过滤器流(Filter Stream)

    一.RandomAccessFile类 使用RandomAccessFile类可以读取任意位置数据的文件. 1.构造方法 RandomAccessFile(String name, String mo ...

  5. 5、pytest -- 猴子补丁

    目录 1. 修改函数功能或者类属性 2. 修改环境变量 3. 修改字典 有时候,测试用例需要调用某些依赖于全局配置的功能,或者这些功能本身又调用了某些不容易测试的代码(例如:网络接入).fixture ...

  6. 使用pyquery

    简单举例 from pyquery import PyQuery as pq html = ''' <div> <ul> <li class="item-O&q ...

  7. 货物移动BAPI:BAPI_GOODSMVT_CREATE报错提示“不能执行功能模块 MB_CREATE_GOODS_MOVEMENT”的原因

    在开发过程中,我们调用BAPI:BAPI_GOODSMVT_CREATE进行货物移动生成物料凭证时,出现了报错提示:“不能执行功能模块 MB_CREATE_GOODS_MOVEMENT”,如下图所示: ...

  8. 第三十章 System V信号量(一)

    信号量 信号量和P.V原语由Dijkstra(迪杰斯特拉)提出 信号量: 互斥: P.V在同一进程中 同步: P.V在不同进程中 信号量值含义 S>0 : S表示可用资源个数 S=0 : 表示无 ...

  9. Web for pentester_writeup之File Upload篇

    Web for pentester_writeup之File Upload篇 File Upload(文件上传) Example 1 直接上传一句话木马,使用蚁剑连接 成功连接,获取网站根目录 Exa ...

  10. C++学习笔记13_操作MySql

    1. 链接Mysql #include <winsock.h>#include "mysql.h"#include <stdlib.h>#include & ...