专栏首页Golang语言社区[译]使用 Go 语言读写Redis协议

[译]使用 Go 语言读写Redis协议

原文: Reading and Writing Redis Protocol in Go 翻译整理: smallnest, 译文连接: 使用 Go 语言读写Redis协议。

这篇文章使用两个简单的Reader和Writer实现了redis客户端的读写协议,通过这两个实现可以容易地学习Redis协议是如何工作的。

如果你想寻找一个全功能的、产品级的Redis client, 推荐你看看 Gary Burd的 redigo

开始之前,建议你先阅读一下 Redis协议的介绍。

官方的协议可以在其网站上找到: protocol。 Redis的协议叫做 RESP (REdis Serialization Protocol),客户端和服务器端通过基于文本的协议进行通讯。

所有的服务器和客户端之间的通讯都使用以下5中基本类型:

  • 简单字符串: 服务器用来返回简单的结果,比如"OK"或者"PONG"
  • bulk string: 大部分单值命令的返回结果,比如 GET, LPOP, and HGET
  • 整数: 查询长度的命令的返回结果
  • 数组: 可以包含其它RESP对象,设置数组,用来发送命令给服务器,也用来返回多个值的命令
  • Error: 服务器返回错误信息

RESP的第一个字节表示数据的类型:

  • 简单字符串: 第一个字节是 "+", 比如 "+OK\r\n"
  • bulk string: 第一个字节是 "$", 比如 "$6\r\nfoobar\r\n"
  • 整数: 第一个字节是 ":", 比如 ":1000\r\n"
  • 数组: 第一个字节是 "", 比如 "2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n"
  • Error: 第一个字节是 "-", 比如 "-Error message\r\n"

基本了解Redis的协议之后,我们就可以实现它的读写器了。

RESPWriter

假定我们要实现的client很简单,只会发送 bulk string给服务器,下面的代码是它的实现:

 1package redis
 2import (
 3  "bufio"
 4  "io"
 5  "strconv"     // for converting integers to strings
 6)
 7var (
 8  arrayPrefixSlice      = []byte{'*'}
 9  bulkStringPrefixSlice = []byte{'$'}
10  lineEndingSlice       = []byte{'\r', '\n'}
11)
12type RESPWriter struct {
13  *bufio.Writer
14}
15func NewRESPWriter(writer io.Writer) *RESPWriter {
16  return &RESPWriter{
17    Writer: bufio.NewWriter(writer),
18  }
19}
20func (w *RESPWriter) WriteCommand(args ...string) (err error) {
21  // 首先写入数组的标志和数组的数量
22  w.Write(arrayPrefixSlice)
23  w.WriteString(strconv.Itoa(len(args)))
24  w.Write(lineEndingSlice)
25  // 写入批量字符串
26  for _, arg := range args {
27    w.Write(bulkStringPrefixSlice)
28    w.WriteString(strconv.Itoa(len(arg)))
29    w.Write(lineEndingSlice)
30    w.WriteString(arg)
31    w.Write(lineEndingSlice)
32  }
33  return w.Flush()
34}

注意我们并没有直接写入net.Conn,而是写入一个io.Writer对象中,这样方便我们写测试代码,测试代码中不必以来net包。

例如,我们可以使用bytes.Buffer来测试我们发送的命令:

1var buf bytes.Buffer
2writer := NewRESPWriter(&buf)
3writer.WriteCommand("GET", "foo")
4buf.Bytes() // *2\r\n$3\r\nGET\r\n$3\r\nfoo\r\n

RESPReader

客户端使用 RESPWriter 来写命令,而RESPReader用来读取返回结果, 它尝试从net.Conn中一直读取数据,直到读取到一个完整的响应。

需要引入的包:

1package redis
2import (
3  "bufio"
4  "bytes"
5  "errors"
6  "io"
7  "strconv"
8)

定义常量和错误:

 1const (
 2  SIMPLE_STRING = '+'
 3  BULK_STRING   = '$'
 4  INTEGER       = ':'
 5  ARRAY         = '*'
 6  ERROR         = '-'
 7)
 8var (
 9  ErrInvalidSyntax = errors.New("resp: invalid syntax")
10)

RESPWriter一样, RESPReader并不关心它读取到的对象的细节,它所有的工作就是从连接中读取一个完整的RESP对象。 它需要传入一个io.Reade用来读取,并且在内部使用bufio.Reader包装了一下。RESPReader的定义很简单:

1type RESPReader struct {
2  *bufio.Reader
3}
4func NewReader(reader io.Reader) *RESPReader {
5  return &RESPReader{
6    Reader: bufio.NewReaderSize(reader, 32*1024),
7  }
8}

这个缓存大小只是在开发过程中拍脑袋定的,在实际使用中,你可以需要它是可配置的,并且在测试中进行调优。32KB在开发测试中应该没有问题。

RESPReader只有一个暴露的方法:ReadObject(),它返回这个RESP Object的字节slice。它会返回读取中的错误,以及解析命令的时候的错误。

RESP的第一个字节意味这我们只需读取第一个字节就可以直到如何处理接下来的数据,但是我们总是需要读取一行数据,所以我们还是先读取第一行再处理:

 1func (r *RESPReader) ReadObject() ([]byte, error) {
 2  line, err := r.readLine()
 3  if err != nil {
 4    return nil, err
 5  }
 6  switch line[0] {
 7  case SIMPLE_STRING, INTEGER, ERROR:
 8    return line, nil
 9  case BULK_STRING:
10    return r.readBulkString(line)
11  case ARRAY:
12    return r.readArray(line) default:
13    return nil, ErrInvalidSyntax
14  }
15}

如果我们读取的这一行是简单字符串、整数或者是Error,我们只需返回这完整的一行就可以了,因为这一行包含了完整的RESP对象。 在readLine方法中,我们一直读取直到读取到\n,并且检查它前一个字符是否是\r, 如果是返回这一行数据 (注意line结尾中包含\r\n):

 1func (r *RESPReader) readLine() (line []byte, err error) {
 2  line, err = r.ReadBytes('\n')
 3  if err != nil {
 4    return nil, err
 5  }
 6  if len(line) > 1 && line[len(line)-2] == '\r' {
 7    return line, nil
 8  } else {
 9    // Line was too short or \n wasn't preceded by \r.
10    return nil, ErrInvalidSyntax
11  }
12}

接下来我们在看看readBulkString的实现。我们需要解析长度值,以便我们接下来决定要读取多少字节。这样我们可以读取count字节的数据,并且再读取\r\n:

 1func (r *RESPReader) readBulkString(line []byte) ([]byte, error) {
 2  count, err := r.getCount(line)
 3  if err != nil {
 4    return nil, err
 5  }
 6  if count == -1 {
 7    return line, nil
 8  }
 9  buf := make([]byte, len(line)+count+2)
10  copy(buf, line)
11  _, err = io.ReadFull(r, buf[len(line):])
12  if err != nil {
13    return nil, err
14  }
15  return buf, nil
16}

其中getCount单独抽取出来了,因为解析数组的时候也需要它:

1func (r *RESPReader) getCount(line []byte) (int, error) {
2  end := bytes.IndexByte(line, '\r')
3  return strconv.Atoi(string(line[1:end]))
4}

为了处理数组,我们首先需要解析数组的数量,然后循环地调用ReadObject,将读取到的字节slice放入到结果buffer中:

 1func (r *RESPReader) readArray(line []byte) ([]byte, error) {
 2  // Get number of array elements.
 3  count, err := r.getCount(line)
 4  if err != nil {
 5    return nil, err
 6  }
 7  // Read `count` number of RESP objects in the array.
 8  for i := 0; i < count; i++ {
 9    buf, err := r.ReadObject()
10    if err != nil {
11      return nil, err
12    }
13    line = append(line, buf...)
14  }
15  return line, nil
16}

最后总结

上面的百行代码就实现了完整的读写Redis RESP对象,但是在应用到产品环境之前,还有一些东西需要补上:

  • 需要从RESP对象中读取实际的值。当前RESPReader只是返回整个的RESP字节slice,它并没有返回字符串或者整数,当然实现起来也很容易
  • RESPReader需要更好的错误处理

代码也没进行优化,对于内存分配和复制的优化也没有做,你可以看看成熟的产品级的redis库的实现。

如果想在服务器端读写Redis命令,可以考虑tidwall/redcon库。


版权申明:内容来源网络,版权归原创者所有。除非无法确认,我们都会标明作者及出处,如有侵权烦请告知,我们会立即删除并表示歉意。谢谢。

本文分享自微信公众号 - Golang语言社区(Golangweb)

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-04-19

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • go微服务系列之三

    在前两篇系列博文中,我已经实现了user-srv、web-srv、api-srv,在新的一篇博文中,我要讲解的是如何在项目中如何使用redis存储session...

    李海彬
  • 【Go 语言社区】Golang语言操作redis连接池的方法

    func newPool(server, password string) *redis.Pool { return &redis.Pool{ ...

    李海彬
  • go语言操作redis连接池的方法

    unc newPool(server, password string) *redis.Pool { return &redis.Pool{ ...

    李海彬
  • 主键,候选键,超键

    平时用设计数据库的时候只涉及到了选择主键,外键,也不知道个什么超键候选键的,第一次比较细的了解超键,候选键,主键的时候是在今年八期给我们讲课的时候,当时听完理...

    MickyInvQ
  • Python finally的用法

    try语句有一个可选finally子句,用于定义在所有情况下都必须执行的finally操作

    于小勇
  • 【ES】622- 九个超级实用的 ES6 特性

    剩余参数将剩余的参数收入数列。JavaScript 的特性是参数数目很灵活。通常会有一个 arguments 变量收集参数。

    pingan8787
  • MySQL进阶之索引

    索引是对数据库表中一个或多个列(例如,employee 表的姓名 (name) 列)的值进行排序的结构。如果想按特定职员的姓来查找他或她,则与在表中搜索所有的...

    测试小兵
  • 小程序 · 一周报

    2 月 26 日,支付宝小程序正式面向个人开发者开放公测,有开发能力的个人用户可访问支付宝小程序平台,扫码验证个人身份以后即可开始支付宝小程序账号申请并进行代码...

    极乐君
  • runC源码分析——cgroup

    runC项目中,与cgroups相关的代码,都在目录 runc/libcontainer/cgroups/下,下面是其源码目录结构分析: ? 我们关注的主要内...

    Walton
  • 从矩阵分解到GNN:社会化推荐的演化

    写在前面:社会化推荐(social recommendation)的简介和几篇相关论文,包含经典模型SoRec(CIKM, 2008),SocialMF(Soc...

    Houye

扫码关注云+社区

领取腾讯云代金券