使用golang写一个redis-cli的方法示例

0. redis通信协议

redis的客户端(redis-cli)和服务端(redis-server)的通信是建立在tcp连接之上, 两者之间数据传输的编码解码方式就是所谓的redis通信协议。所以,只要我们的redis-cli实现了这个协议的解析和编码,那么我们就可以完成所有的redis操作。

redis 协议设计的非常易读,也易于实现,关于具体的redis通信协议请参考:通信协议(protocol)。后面我们在实现这个协议的过程中也会简单重复介绍一下具体实现

1. 建立tcp连接

redis客户端和服务端的通信是建立tcp连接之上,所以第一步自然是先建立连接

package main

import (
 "flag"
 "log"
 "net"
)

var host string
var port string

func init() {
 flag.StringVar(&host, "h", "localhost", "hsot")
 flag.StringVar(&port, "p", "6379", "port")
}

func main() {
 flag.Parse()

 tcpAddr := &net.TCPAddr{IP: net.ParseIP(host), Port: port}
 conn, err := net.DialTCP("tcp", nil, tcpAddr)
 if err != nil {
 log.Println(err)
  }
  defer conn.Close()

 // to be continue
}

后续我们发送和接受数据便都可以使用conn.Read()和conn.Write()来进行了

2. 发送请求

发送请求第一个第一个字节是"*",中间是包含命令本身的参数个数,后面跟着"\r\n" 。之后使用"$"加参数字节数量并使用"\r\n"结尾,然后紧跟参数内容同时也使用"\r\n"结尾。如执行 SET key liangwt 客户端发送的请求为"*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$7\r\nliangwt\r\n"

注意:

  1. 命令本身也作为协议的其中一个参数来发送
  2. \r\n 对应byte的十进制为 13 10

我们可以使用telnet测试下

wentao@bj:~/github.com/liangwt/redis-cli$ telnet 127.0.0.1 6379
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
*3
$3
SET
$3
key
$7
liangwt
+OK

先暂时忽略服务端的回复,通过telnet我们可以看出请求协议非常简单,所以对于请求协议的实现不做过多的介绍了,直接放代码(如下使用基于字符串拼接,只是为了更直观的演示,效率并不高,实际代码中我们使用bytes.Buffer来实现)

func MultiBulkMarshal(args ...string) string {
 var s string
 s = "*"
 s += strconv.Itoa(len(args))
 s += "\r\n"

 // 命令所有参数
 for _, v := range args {
 s += "$"
 s += strconv.Itoa(len(v))
 s += "\r\n"
 s += v
 s += "\r\n"
 }

 return s
}

在实现了对命令和参数进行编码之后,我们便可以通过conn.Write()把数据推送到服务端

func main() {
  // ....
 req := MultiBulkMarshal("SET", "key", "liangwt")
 _, err = conn.Write([]byte(req))
 if err != nil {
 log.Fatal(err)
 }
 // to be continue
}

3. 获取回复

我们首先实现通过tcp获取服务端返回值,就是上面提到过的conn.Read()。

func main() {
  // ....
 p := make([]byte, 1024)
 _, err = conn.Read(p)
 if err != nil {
 log.Fatal(err)
 }
 // to be continue
}

4. 解析回复

我们拿到p之后我们就可以解析返回值了,redis服务端的回复是分为几种情况的

  • 状态回复
  • 错误回复
  • 整数回复
  • 批量回复
  • 多条批量回复

我们把前四种单独看作一组,因为他们都是单一类型的返回值

我们把最后的多条批量回复看成单独的一组,因为它是包含前面几种类型的混合类型。而且你可以发现它和我们的请求协议是一样的

也正是基于以上的考虑我们创建两个函数来分别解析单一类型和混合类型,这样在解析混合类型中的某一类型时就只需要调用单一类型解析的函数即可

在解析具体协议前我们先实现一个是读取到\r\n为止的函数

func ReadLine(p []byte) ([]byte, error) {
 for i := 0; i < len(p); i++ {
 if p[i] == '\r' {
  if p[i+1] != '\n' {
  return []byte{}, errors.New("format error")
  }
  return p[0:i], nil
 }
 }
 return []byte{}, errors.New("format error")
}

第一种状态回复:

状态回复是一段以 "+" 开始, "\r\n" 结尾的单行字符串。如 SET 命令成功的返回值:"+OK\r\n"

所以我们判断第一个字符是否等于 '+' 如果相等,则读取到\r\n

func SingleUnMarshal(p []byte) ([]byte, int, error) {
 var (
 result []byte
 err  error
 length int
 )
 switch p[0] {
 case '+':
 result, err = ReadLine(p[1:])
 length = len(result) + 3
 }

 return result, length, err
}

注:我们在返回实际回复内容的同时也返回了整个回复的长度,方便后面解析多条批量回复时定位下一次的解析位置

第二种错误回复:

错误回复的第一个字节是 "-", "\r\n" 结尾的单行字符串。如执行 SET key缺少参数时返回值:"-ERR wrong number of arguments for 'set' command\r\n"

错误回复和状态回复非常相似,解析方式也是一样到。所以我们只需添加一个case即可

func SingleUnMarshal(p []byte) ([]byte, int, error) {
 var (
 result []byte
 err  error
 length int
 )
 switch p[0] {
 case '+', '-':
 result, err = ReadLine(p[1:])
 length = len(result) + 3
 }
 return result, length, err
}

第三种整数回复:

整数回复的第一个字节是":",中间是字符串表示的整数,"\r\n" 结尾的单行字符串。如执行LLEN mylist命令时返回 ":10\r\n"

整数回复也和上面两种是一样的,只不过返回的是字符串表示的十进制整数

func SingleUnMarshal(p []byte) ([]byte, int, error) {
 var (
 result []byte
 err  error
 length int
 )
 switch p[0] {
 case '+', '-', ':':
 result, err = ReadLine(p[1:])
 length = len(result) + 3
 }
 return result, length, err
}

第四种批量回复:

批量回复的第一个字节为 "$",接下来是字符串表示的整数,它表示实际回复的长度,之后跟着一个 "\r\n",再后面跟着的是实际回复数据,最末尾是另一个 "\r\n"。如GET key 命令的返回值:"$7\r\nliangwt\r\n"

所以批量回复解析的实现:

  • 读取第一行得到实际回复的长度
  • 把字符串类型的长度转换成对应十进制整数
  • 从第二行开始位置往下读对应长度

但是对于某些不存在的key,批量回复会将特殊值 -1 用作回复的长度值, 此时我们不需要继续往下读取实际回复。例如GET NOT_EXIST_KEY 返回值:"$-1", 所以我们需要对此特殊情况判断,让函数返回一个空对象(nil)而不是空值("")

func SingleUnMarshal(p []byte) ([]byte, int, error) {
 // ....
 case '$':
 n, err := ReadLine(p[1:])
 if err != nil {
  return []byte{}, 0, err
 }
 l, err := strconv.Atoi(string(n))
 if err != nil {
  return []byte{}, 0, err
 }
 if l == -1 {
  return nil, 0, nil
 }
 // +3 的原因 $ \r \n 三个字符
 result = p[len(n)+3 : len(n)+3+l]
 length = len(n) + 5 + l
 }
 return result, length, err
}

思考:

为什么redis要使用提前告知字节数,然后往下读取指定长度的方式,而不是直接读取第二行到\r\n为止?

答案很明显:此方式可以让redis读取返回值时不受具体的返回内容影响,在按行读取的情况下,无论使用任何分割符都有可能导致redis在解析具体内容时把内容中的分割符当作时结尾,导致解析错误。

思考一下这种情况:我们SET key "liang\r\nwt" ,那么当我们GET key时,服务端返回值为"$9\r\nliang\r\nwt\r\n" 完全规避了value中的\r\n影响

第五种多条批量回复:

多条批量回复是由多个回复组成的数组,它的第一个字节为"*", 后跟一个字符串表示的整数值, 这个值记录了多条批量回复所包含的回复数量, 再后面是一个"\r\n"。如LRANGE mylist 0 -1的返回值:"*3\r\n$1\r\n3\r\n$1\r\n2\r\n$1\r\n1"。

所以多条批量回复解析的实现:

  • 解析第一行数据获得字符串类型的回复数量
  • 把字符串类型的长度转换成对应十进制整数
  • 按照单条回复依次逐个解析,一共解析成上面得到的数量

在这里我们用到了单条解析时返回的字节长度length,通过这个长度我们可以很方便的知道下次单条解析的开始位置为上一次位置+length

在解析多条批量回复时需要注意两点:

第一,多条批量回复也可以是空白的(empty)。例如执行LRANGE NOT_EXIST_KEY 0 -1 服务端返回值"*0\r\n"。此时客户端返回的应该空数组[][]byte

第二,多条批量回复也可以是无内容的(null multi bulk reply)。例如执行BLPOP key 1 服务端返回值"*-1\r\n"。此时客户端返回的应该是nil

func MultiUnMarsh(p []byte) ([][]byte, error) {
 if p[0] != '*' {
 return [][]byte{}, errors.New("format error")
 }
 n, err := ReadLine(p[1:])
 if err != nil {
 return [][]byte{}, err
 }
 l, err := strconv.Atoi(string(n))
 if err != nil {
 return [][]byte{}, err
 }
 // 多条批量回复也可以是空白的(empty)
 if l == 0 {
 return [][]byte{}, nil
 }

 // 无内容的多条批量回复(null multi bulk reply)也是存在的,
 // 客户端库应该返回一个 null 对象, 而不是一个空数组。
 if l == -1 {
 return nil, nil
 }
 result := make([][]byte, l)
 t := len(n) + 3
 for i := 0; i < l; i++ {
 ret, length, err := SingleUnMarshal(p[t:])
 if err != nil {
  return [][]byte{}, errors.New("format error")
 }
 result[i] = ret
 t += length
 }

 return result, nil
}

5. 命令行模式

一个可用的redis-cli自然是一个交互式的,用户输入指令然后输出返回值。在go中我们可以使用以下代码即可获得一个类似的交互式命令行

func main() {
 // ....
 for {
 fmt.Printf("%s:%d>", host, port)

 bio := bufio.NewReader(os.Stdin)
 input, _, err := bio.ReadLine()
 if err != nil {
  log.Fatal(err)
 }
 fmt.Printf("%s\n", input)
 }
}

我们运行以上代码就可以实现

localhost:6379>set key liang
set key liang
localhost:6379>get key
get key
localhost:6379>

结合上我们的redis发送请求和解析请求即可完成整个redis-cli

func main() {
  // ....
 for {
 fmt.Printf("%s:%d>", host, port)

 // 获取输入命令和参数
 bio := bufio.NewReader(os.Stdin)
 input, err := bio.ReadString('\n')
 if err != nil {
  log.Fatal(err)
 }
 fields := strings.Fields(input)

 // 编码发送请求
 req := MultiBulkMarshal(fields...)

 // 发送请求
 _, err = conn.Write([]byte(req))
 if err != nil {
  log.Fatal(err)
 }

 // 读取返回内容
 p := make([]byte, 1024)
 _, err = conn.Read(p)
 if err != nil {
  log.Fatal(err)
 }

 // 解析返回内容
 if p[0] == '*' {
  result, err := MultiUnMarsh(p)
 } else {
  result, _, err := SingleUnMarshal(p)
 }

  }
  // ....
}

6. 总结

到目前为止我们的cli程序已经全部完成,但其实还有很多不完美地方。但核心的redis协议解析已经完成,使用这个解析我们能完成任何的cli与服务器之间的交互

更详细的redis-cli实现可以参考我的github:A Simaple redis cli - Rclient

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持我们。

(0)

相关推荐

  • 在Golang中使用Redis的方法示例

    周五上班的主要任务是在公司老平台上用redis处理一个队列问题,顺便复习了一下redis操作的基础知识,回来后就想着在自己的博客demo里,用redis来优化一些使用场景,学习一下golang开发下redis的使用. Redis简单介绍 简介 关于Redis的讨论,其实在现在的后台开发中已经是个老生常谈的问题,基本上也是后端开发面试的基本考察点.其中 Redis的背景介绍和细节说明在这里就不赘述.不管怎么介绍,核心在于Redis是一个基于内存的key-value的多数据结构存储,并可以提供持久化

  • 手把手带你搭建一个node cli的方法示例

    前言 前端日常开发中,会遇见各种各样的 cli,使用 vue 技术栈的你一定用过 @vue/cli ,同样使用 react 技术栈的人也一定知道 create-react-app .利用这些工具能够实现一行命令生成我们想要的代码模版,极大地方便了我们的日常开发,让计算机自己去干繁琐的工作,而我们,就可以节省出大量的时间用于学习.交流.开发. cli 工具的作用在于它能够将我们开发过程中经常需要重复做的事情利用一行代码来解决,比如我们在写需求的时候每新增一个页面就需要相应的增加该页面的初始化代码,

  • 基于C# 写一个 Redis 数据同步小工具

    概念 Redis是一个开源的使用ANSI C语言编写.支持网络.可基于内存亦可持久化的日志型.Key-Value数据库,和Memcached类似,它支持存储的value类型相对更多,包括string(字符串).list(链表).set(集合).zset(sorted set --有序集合)和hash(哈希类型).在此基础上,redis支持各种不同方式的排序.与memcached一样,为了保证效率,数据都是缓存在内存中.区别的是redis会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文

  • 利用Java手写一个简易的lombok的示例代码

    目录 1.概述 2.lombok使用方法 3.lombok原理解析 4.手写简易lombok 1.概述 在面向对象编程中,必不可少的需要在代码中定义对象模型,而在基于Java的业务平台开发实践中尤其如此.相信大家在平时开发中也深有感触,本来是没有多少代码开发量的,但是因为定义的业务模型对象比较多,而需要重复写Getter/Setter.构造器方法.字符串输出的ToString方法.Equals/HashCode方法等.我们都知道Lombok能够替大家完成这些繁琐的操作,但是其背后的原理很少有人会

  • golang 字符串比较是否相等的方法示例

    golang字符串比较的三种常见方法 fmt.Println("go"=="go") fmt.Println("GO"=="go") fmt.Println(strings.Compare("GO","go")) fmt.Println(strings.Compare("go","go")) fmt.Println(strings.EqualFol

  • 使用golang写一个redis-cli的方法示例

    0. redis通信协议 redis的客户端(redis-cli)和服务端(redis-server)的通信是建立在tcp连接之上, 两者之间数据传输的编码解码方式就是所谓的redis通信协议.所以,只要我们的redis-cli实现了这个协议的解析和编码,那么我们就可以完成所有的redis操作. redis 协议设计的非常易读,也易于实现,关于具体的redis通信协议请参考:通信协议(protocol).后面我们在实现这个协议的过程中也会简单重复介绍一下具体实现 1. 建立tcp连接 redis

  • 写一个漂亮Rakefile的方法

    Rake 我就不再介绍了,Ruby 的 Make ,许多方面都比 Make 要更好用一些.和 Makefile 不同的是,Rakefile 本身其实就是一段 Ruby 代码,这样的好处有很多,一方面在 Rake 里面就可以很直接地做任何 Ruby 能做的事了,另一方面由于 Ruby 对 DSL 支持良好,所以 Rakefile 通常看起来也并不那么"代码". 不过,代码始终是代码,Makefile 尚且可以写得很乱,Rakefile 要写乱就更容易了,幸运地是,Rake 提供了一些功能

  • javascript如何用递归写一个简单的树形结构示例

    现在有一个数据,需要你渲染出对应的列表出来: var data = [ {"id":1}, {"id":2}, {"id":3}, {"id":4}, ]; var str="<ul>"; data.forEach(function(v,i){ str+="<li><span>"+v.id+"</span></li>&

  • PHP Swoole异步Redis客户端实现方法示例

    本文实例讲述了PHP Swoole异步Redis客户端实现方法.分享给大家供大家参考,具体如下: 使用版本:1.8.0及以上 使用条件: 1.开启async-redis php --ri swoole (如果没有开启,重新编译安装Swoole时加入 --enable-async-redis  相关文章:Linux下源码包安装Swoole及基本使用) 2.安装hiredis: CentOS yum install libhiredis-devel Ubuntu: apt install libhi

  • golang复用http.request.body的方法示例

    问题及场景 业务当中有需要分发http.request.body的场景.比如微信回调消息只能指定一个地址,所以期望可以复制一份消息发给其他服务.由服务B和接收微信回调的服务A一起处理微信回调信息. 本文将详细介绍golang复用http.request.body的相关内容,分享出来供大家参考学习,下面话不多说了,来一起看看详细的介绍吧 解决思路 最开始考虑的是直接转发http.request.使用ReverseProxy直接将http.request由服务A转发给服务B.但是微信涉及到验证等问题

  • Golang 使用接口实现泛型的方法示例

    在C/C++中我们可以使用泛型的方法使代码得以重复使用,最常见例如stl functions:vector<int> vint or vector<float> vfloat等.这篇文章将使用interface{...}接口使Golang实现泛型. interface{...}是实现泛型的基础.如一个数组元素类型是interface{...}的话,那么实现了该接口的实体都可以被放置入数组中.注意其中并不一定必须是空接口(简单类型我们可以通过把他转化为自定义类型后实现接口).为什么i

随机推荐