使用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

转发:https://my.oschina.net/liangwt/blog/2231557

使用golang写一个redis-cli的更多相关文章

  1. 用 C# 写一个 Redis 数据同步小工具

    用 C# 写一个 Redis 数据同步小工具 Intro 为了实现 redis 的数据迁移而写的一个小工具,将一个实例中的 redis 数据同步到另外一个实例中.(原本打算找一个已有的工具去做,找了一 ...

  2. 写一个Redis封装类

    打算自己封装一个Redis操作类,方便使用,且有一定log记录.Redis的封装思路:基于Redis类进一步封装 一般属性 单例 (配置参数从配置文件中读取还是写死?考虑多配置之间切换) 常规操作根据 ...

  3. 学习T-io框架,从写一个Redis客户端开始

    前言   了解T-io框架有些日子了,并且还将它应用于实战,例如 tio-websocket-server,tio-http-server等.但是由于上述两个server已经封装好,直接应用就可以.所 ...

  4. GoWorld – 用Golang写一个分布式可扩展、可热更的游戏服务器

    GoWorld代码:https://github.com/xiaonanln/goworld Golang具有运行效率高.内存安全等优良特性,因此是非常适合用来进行服务器开发.使用Golang开发游戏 ...

  5. Golang 写一个端口扫描器

    前话 最近痴迷于Golang这个新兴语言,因为它是强类型编译型语言,可以直接编译成三大平台的二进制执行文件,可以直接运行无需其他依赖环境.而且Golang独特的goroutine使得多线程任务执行如n ...

  6. 参考MySQL Internals手册,使用Golang写一个简单解析binlog的程序

    GreatSQL社区原创内容未经授权不得随意使用,转载请联系小编并注明来源. MySQL作为最流行的开源关系型数据库,有大量的拥趸.其生态已经相当完善,各项特性在圈内都有大量研究.每次新特性发布,都会 ...

  7. golang写一个简单的爬虫

    package main import( "fmt" "io/ioutil" "net/http" ) func gethtml(url s ...

  8. 徒手用 Go 写个 Redis 服务器(Godis)

    作者:HDT3213 今天给大家带来的开源项目是 Godis:一个用 Go 语言实现的 Redis 服务器.支持: 5 种数据结构(string.list.hash.set.sortedset) 自动 ...

  9. python_way ,day11 线程,怎么写一个多线程?,队列,生产者消费者模型,线程锁,缓存(memcache,redis)

    python11 1.多线程原理 2.怎么写一个多线程? 3.队列 4.生产者消费者模型 5.线程锁 6.缓存 memcache redis 多线程原理 def f1(arg) print(arg) ...

随机推荐

  1. kafka压测

    原文并未提及kafka的版本 并且测试的消息大小都偏小  测试数据供参考 原文还测试了broker等    原文请移步文章末尾 4.1 producer测试 4.1.1 batch-size 测试结果 ...

  2. 5行代码带你实现一个js的打字效果

    (转载)原文链接:https://juejin.im/post/5ddf55835188257313541581 前言 有次看电影

  3. IScroll Unable to preventDefault inside passive event listener due to target being treated as passive

    最近两天企业微信下IScroll突然无法滚动了,特别慢,之前好好的,发现主要是有红色的Unable to preventDefault inside passive event listener du ...

  4. Task 小记

    1. 注意 Task 执行的顺序. 如下代码: IQueryable<T> querySet; if (preConditionExpression == null) { querySet ...

  5. Spring Boot 运行原理 - 核心注解

    https://www.jianshu.com/p/23f504713b94 核心注解 打开上面任意一个AutoConfiguration文件,一般都有下面的条件注解,在spring-boot-aut ...

  6. leetcode求峰值,js实现

    原题: 最开始是照着提示的思路进行,中规中矩,用时64ms  /** * @param {number[]} nums * @return {number} */var findPeakElement ...

  7. C++中的Mat, const Mat, Mat &,Mat &, const Mat &的区别

    Mat, copy传递,不会改变外部变量的Mat. Mat &, reference传递,函数内部修改将会改变外部. const Mat, copy传递,在函数内,不会被修改,也不会影响到外部 ...

  8. Mac OS中的”任务管理器“

    在开发使用过程中,经常需要通过任务管理器来查看进程的一些情况以及杀掉一些进程,Mac中也有类似于Windows的”资源管理器“. 启动台->其他 找到”活动监视器“ 活动监视器即是”任务管理器“ ...

  9. Windows 10 蓝牙管理页面"添加蓝牙或其他设备"选项点击无响应的解决方案

    解决方案1(简单,但不解决根本问题): 通过"控制面板→设备和打印机→添加设备"进行添加. 解决方案2: 造成这种现象的原因应该是因为启用了 Administrator 账户,在其 ...

  10. 竟然有人在群里谈交钱培训PI。。。。等哥哥有时间,断了你们的财路

    PI是工具,很不错的工具.统一管理接口,这点对大公司来说还是有必要的.而且PI的日志记录很详细,用的好的话,绝对物超所值.