实现 Redis 协议解析器
本文是 《用 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):
llen
、scard
等命令的返回值, 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 协议解析器的更多相关文章
- 【wireshark】协议解析
1. 普通解析 Wireshark启动时,所有解析器进行初始化和注册.要注册的信息包括协议名称.各个字段的信息.过滤用的关键字.要关联的下层协议与端口(handoff)等.在解析过程,每个解析器负责解 ...
- Wireshark DTN解析器拒绝服务漏洞
受影响系统:Wireshark Wireshark 2.2.0 - 2.2.1Wireshark Wireshark 2.0.0 - 2.0.7描述:CVE(CAN) ID: CVE-2016-937 ...
- Wireshark OpenFlow解析器拒绝服务漏洞
受影响系统:Wireshark Wireshark 2.2.0 - 2.2.1Wireshark Wireshark 2.0.0 - 2.0.7描述:CVE(CAN) ID: CVE-2016-937 ...
- python 全栈开发,Day101(redis操作,购物车,DRF解析器)
昨日内容回顾 1. django请求生命周期? - 当用户在浏览器中输入url时,浏览器会生成请求头和请求体发给服务端 请求头和请求体中会包含浏览器的动作(action),这个动作通常为get或者po ...
- dom解析器机制 web基本概念 tomcat
0 作业[cn.itcast.xml.sax.Demo2] 1)在SAX解析器中,一定要知道每方法何时执行,及SAX解析器会传入的参数含义 1 理解dom解析器机制 1)dom解析和dom4j原理 ...
- 字符串处理(正则表达式、NSScanner扫描、CoreParse解析器)-备用
搜索 在一个字符串中搜索子字符串 最灵活的方法 1 - (NSRange)rangeOfString:(NSString *)aString options:(NSStringCompareOptio ...
- Android_HTML解析器_jsoup
jsoup 是一款Java 的HTML解析器,可直接解析某个URL地址.HTML文本内容.它提供了一套非常省力的API,可通过DOM,CSS以及类似于jQuery的操作方法来取出和操作数据. Jsou ...
- twemproxyRedis协议解析探索——剖析twemproxy代码正编
这篇文章会对twemproxyRedis协议解析代码部分进行一番简单的分析,同时给出twemproxy目前支持的所有Redis命令.在这篇文章开始前,我想大家去简单地理解一下有限状态机,当然不理解也是 ...
- 在.NET Core中使用Irony实现自己的查询语言语法解析器
在之前<在ASP.NET Core中使用Apworks快速开发数据服务>一文的评论部分,.NET大神张善友为我提了个建议,可以使用Compile As a Service的Roslyn为语 ...
随机推荐
- JVM学习记录1--JVM内存布局
先上个图 这是根据<Java虚拟机规范(第二版)>所画的jvm内存模型. 程序计数器:程序计数器是用来记录当前线程方法执行顺序的,对应的就是我们编程中一行行代码的执行顺序,如分支,跳转,循 ...
- 使用Wireshark成功解决JavaWeb项目的页面一直加载中的问题
现象 打开 服务器页面 10.2.155.100,然后发现页面JS显示 加载中..F12浏览器看起来像是发起css等静态资源时卡死.一时定位还以为时 前端的问题. 解决过程 上服务器抓包: tcpdu ...
- 实用Linux控制台命令
实用Linux控制台命令 screen 例如用Xshell连接 服务器 screen -ls 列出当前用户所有的screen screen 回车直接创建新的screen screen -S scree ...
- vue-cli3没有config文件解决方案,在根目录加上vue.config.js文件
module.exports = { /** 区分打包环境与开发环境 * process.env.NODE_ENV==='production' (打包环境) * process.env.NODE_E ...
- fenby C语言
P1框架 1#include <stdio.h> 2 3int main(){ 4 printf(“C语言我来了”); 5 return 0; 6} P2main()门 P3计 ...
- Jenkins流水线获取提交日志
写在前 之前使用Jenkins pipeline的时候发现拿不到日志,使用multiple scms插件对应是日志变量获取日志的方式失效了, 但是查看流水线Pipeline Syntax发现check ...
- idea 2019 1 spring boot 启动报错 An incompatible version [1.2.12] of the APR based Apache Tomcat Native library is installed, while Tomcat requires version [1.2.14]
1.构建一个简单springboot工程,日志打印报错内容如下: 15:38:28.673 [main] DEBUG org.springframework.boot.devtools.setting ...
- 第四十章 POSIX条件变量
条件变量 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了 例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中.这种情况就需要用到条件 ...
- Java 干货之深入理解Java内部类
可以将一个类定义在另一个类或方法中,这样的类叫做内部类 --<Thinking in Java> 说起内部类,大家并不陌生,并且会经常在实例化容器的时候使用到它.但是内部类的具体细节语法, ...
- TensorFlow如何提高GPU训练效率和利用率
前言 首先,如果你现在已经很熟悉tf.data+estimator了,可以把文章x掉了╮( ̄▽ ̄””)╭ 但是!如果现在还是在进行session.run(..)的话!尤其是苦恼于GPU显存都塞满了利用 ...