用Netty解析Redis网络协议

根据Redis官方文档的介绍,学习了一下Redis网络通信协议。然后偶然在GitHub上发现了个用Netty实现的Redis服务器,很有趣,于是就动手实现了一下!

1.RESP协议

Redis的客户端与服务端采用一种叫做 RESP(REdis Serialization Protocol)的网络通信协议交换数据。RESP的设计权衡了实现简单、解析快速、人类可读这三个因素。Redis客户端通过RESP序列化整数、字符串、数据等数据类型,发送字符串数组表示参数的命令到服务端。服务端根据不同的请求命令响应不同的数据类型。除了管道和订阅外,Redis客户端和服务端都是以这种简单的请求-响应模型通信的。

具体来看,RESP支持五种数据类型。以”*”消息头标识总长度,消息内部还可能有”$”标识字符串长度,每行以\r\n结束

  • 简单字符串(Simple String):以”+”开头,表示正确的状态信息,”+”后就是具体信息。许多Redis命令使用简单字符串作为成功的响应,例如”+OK\r\n”。但简单字符串因为不像Bulk String那样有长度信息,而只能靠\r\n确定是否结束,所以 Simple String不是二进制安全的,即字符串里不能包含\r\n。
  • 错误(Error):以”-“开头,表示错误的状态信息,”-“后就是具体信息。
  • 整数(Integer):以”:”开头,像SETNX, DEL, EXISTS, INCR, INCRBY, DECR, DECRBY, DBSIZE, LASTSAVE, RENAMENX, MOVE, LLEN, SADD, SREM, SISMEMBER, SCARD都返回整数。
  • 批量字符串(Bulk String):以”$”开头,表示下一行的字符串长度,具体字符串在下一行中,字符串最大能达到512MB。”$-1\r\n”叫做Null Bulk String,表示没有数据存在。
  • 数组(Array):以”*”开头,表示消息体总共有多少行(不包括当前行),”*”是具体行数。客户端用RESP数组表示命令发送到服务端,反过来服务端也可以用RESP数组返回数据的集合给客户端。数组可以是混合数据类型,例如一个整数加一个字符串”*2\r\n:1\r\n$6\r\nfoobar\r\n”。另外,嵌套数组也是可以的。

例如,观察下面命令对应的RESP,这一组set/get也正是我们要在Netty里实现的:

set name helloworld
->
*3\r\n
$3\r\n
set\r\n
$4\r\n
name\r\n
$10\r\n
helloworld\r\n
<-
:1\r\n get name
->
*2\r\n
$3\r\n
get\r\n
$4\r\n
name\r\n
<-
$10\r\n
helloworld\r\n set name abc111
->
*3\r\n
$3\r\n
set\r\n
$4\r\n
name\r\n
$6\r\n
abc111\r\n
<-
:0\r\n get age
->
*2\r\n
$3\r\n
get\r\n
$3\r\n
age\r\n
<-
:-1\r\n

2.用Netty解析协议

下面就用高性能的网络通信框架Netty实现一个简单的Redis服务器后端,解析set和get命令,并保存键值对。

2.1 Netty版本

Netty版本,5.0还处于alpha,使用Final版里最新的。但即便是4.0.25.Final竟然也跟4.0的前几个版本有些不同,网上一些例子中用的API根本就找不到了。Netty的API改得有点太“任性”了吧?:)

        <dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.0.25.Final</version>
</dependency>

2.2 启动服务

Netty服务器启动代码,这套代码应该是Netty 4里的标准模板了,具体细节就不在本文赘述了。主要关注我们注册的几个Handler。Netty中Handler分为Inbound和Outbound,RedisCommandDecoder和RedisCommandHandler是Inbound,RedisCommandDecoder是Outbound:

  • RedisCommandDecoder:解析Redis协议,将字节数组转为Command对象。
  • RedisReplyEncoder:将响应写入到输出流中,返回给客户端。
  • RedisCommandHandler:执行Command中的命令。
public class Main {

    public static void main(String[] args) throws Exception {
new Main().start(6379);
} public void start(int port) throws Exception {
EventLoopGroup group = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap()
.group(group)
.channel(NioServerSocketChannel.class)
.localAddress(port)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ch.pipeline()
.addLast(new RedisCommandDecoder())
.addLast(new RedisReplyEncoder())
.addLast(new RedisCommandHandler());
}
}); // Bind and start to accept incoming connections.
ChannelFuture f = b.bind(port).sync(); // Wait until the server socket is closed.
f.channel().closeFuture().sync();
} finally {
// Shutdown the EventLoopGroup, which releases all resources.
group.shutdownGracefully();
}
} }

2.3 协议解析

RedisCommandDecoder开始时cmds是null,进入doDecodeNumOfArgs先解析出命令和参数的个数,并初始化cmds。之后就会进入doDecodeArgs逐一解析命令名和参数了。当最后完成时,会根据解析结果创建出RedisCommand对象,并加入到out列表里。这样下一个handler就能继续处理了。

public class RedisCommandDecoder extends ReplayingDecoder<Void> {

    /** Decoded command and arguments */
private byte[][] cmds; /** Current argument */
private int arg; /** Decode in block-io style, rather than nio. */
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
if (cmds == null) {
if (in.readByte() == '*') {
doDecodeNumOfArgs(in);
}
} else {
doDecodeArgs(in);
} if (isComplete()) {
doSendCmdToHandler(out);
doCleanUp();
}
} /** Decode number of arguments */
private void doDecodeNumOfArgs(ByteBuf in) {
// Ignore negative case
int numOfArgs = readInt(in);
System.out.println("RedisCommandDecoder NumOfArgs: " + numOfArgs);
cmds = new byte[numOfArgs][]; checkpoint();
} /** Decode arguments */
private void doDecodeArgs(ByteBuf in) {
for (int i = arg; i < cmds.length; i++) {
if (in.readByte() == '$') {
int lenOfBulkStr = readInt(in);
System.out.println("RedisCommandDecoder LenOfBulkStr[" + i + "]: " + lenOfBulkStr); cmds[i] = new byte[lenOfBulkStr];
in.readBytes(cmds[i]); // Skip CRLF(\r\n)
in.skipBytes(2); arg++;
checkpoint();
} else {
throw new IllegalStateException("Invalid argument");
}
}
} /**
* cmds != null means header decode complete
* arg > 0 means arguments decode has begun
* arg == cmds.length means complete!
*/
private boolean isComplete() {
return (cmds != null)
&& (arg > 0)
&& (arg == cmds.length);
} /** Send decoded command to next handler */
private void doSendCmdToHandler(List<Object> out) {
System.out.println("RedisCommandDecoder: Send command to next handler");
if (cmds.length == 2) {
out.add(new RedisCommand(new String(cmds[0]), cmds[1]));
} else if (cmds.length == 3) {
out.add(new RedisCommand(new String(cmds[0]), cmds[1], cmds[2]));
} else {
throw new IllegalStateException("Unknown command");
}
} /** Clean up state info */
private void doCleanUp() {
this.cmds = null;
this.arg = 0;
} private int readInt(ByteBuf in) {
int integer = 0;
char c;
while ((c = (char) in.readByte()) != '\r') {
integer = (integer * 10) + (c - '0');
} if (in.readByte() != '\n') {
throw new IllegalStateException("Invalid number");
}
return integer;
} }

因为我们只是简单实现set和get命令,所以只可能有一个参数或两个参数:

public class RedisCommand {

    /** Command name */
private final String name; /** Optional arguments */
private byte[] arg1;
private byte[] arg2; public RedisCommand(String name, byte[] arg1) {
this.name = name;
this.arg1 = arg1;
} public RedisCommand(String name, byte[] arg1, byte[] arg2) {
this.name = name;
this.arg1 = arg1;
this.arg2 = arg2;
} public String getName() {
return name;
} public byte[] getArg1() {
return arg1;
} public byte[] getArg2() {
return arg2;
} @Override
public String toString() {
return "Command{" +
"name='" + name + '\'' +
", arg1=" + Arrays.toString(arg1) +
", arg2=" + Arrays.toString(arg2) +
'}';
}
}

2.4 命令执行

RedisCommandHandler拿到RedisCommand后,根据命令名执行命令。这里用一个HashMap模拟数据库了,set就往Map里放,get就从里面取。除了执行具体操作,还要根据执行结果返回不同的Reply对象:

  • 保存成功:返回:1\r\n。
  • 修改成功:返回:0\r\n。说明之前Map中已存在此Key。
  • 查询成功:返回Bulk String。具体见后面BulkReply。
  • Key不存在:返回:-1\r\n。
@ChannelHandler.Sharable
public class RedisCommandHandler extends SimpleChannelInboundHandler<RedisCommand> { private HashMap<String, byte[]> database = new HashMap<String, byte[]>(); @Override
protected void channelRead0(ChannelHandlerContext ctx, RedisCommand msg) throws Exception {
System.out.println("RedisCommandHandler: " + msg); if (msg.getName().equalsIgnoreCase("set")) {
if (database.put(new String(msg.getArg1()), msg.getArg2()) == null) {
ctx.writeAndFlush(new IntegerReply(1));
} else {
ctx.writeAndFlush(new IntegerReply(0));
}
}
else if (msg.getName().equalsIgnoreCase("get")) {
byte[] value = database.get(new String(msg.getArg1()));
if (value != null && value.length > 0) {
ctx.writeAndFlush(new BulkReply(value));
} else {
ctx.writeAndFlush(BulkReply.NIL_REPLY);
}
}
} }

2.5 发送响应

RedisReplyEncoder实现比较简单,拿到RedisReply消息后,直接写入到ByteBuf中就可以了。具体的写入方法都在各个RedisReply的具体实现中。

public class RedisReplyEncoder extends MessageToByteEncoder<RedisReply> {

    @Override
protected void encode(ChannelHandlerContext ctx, RedisReply msg, ByteBuf out) throws Exception {
System.out.println("RedisReplyEncoder: " + msg);
msg.write(out);
} }
public interface RedisReply<T> {

    byte[] CRLF = new byte[] { '\r', '\n' };

    T data();

    void write(ByteBuf out) throws IOException;

}

public class IntegerReply implements RedisReply<Integer> {

    private static final char MARKER = ':';

    private final int data;

    public IntegerReply(int data) {
this.data = data;
} @Override
public Integer data() {
return this.data;
} @Override
public void write(ByteBuf out) throws IOException {
out.writeByte(MARKER);
out.writeBytes(String.valueOf(data).getBytes());
out.writeBytes(CRLF);
} @Override
public String toString() {
return "IntegerReply{" +
"data=" + data +
'}';
} } public class BulkReply implements RedisReply<byte[]> { public static final BulkReply NIL_REPLY = new BulkReply(); private static final char MARKER = '$'; private final byte[] data; private final int len; public BulkReply() {
this.data = null;
this.len = -1;
} public BulkReply(byte[] data) {
this.data = data;
this.len = data.length;
} @Override
public byte[] data() {
return this.data;
} @Override
public void write(ByteBuf out) throws IOException {
// 1.Write header
out.writeByte(MARKER);
out.writeBytes(String.valueOf(len).getBytes());
out.writeBytes(CRLF); // 2.Write data
if (len > 0) {
out.writeBytes(data);
out.writeBytes(CRLF);
}
} @Override
public String toString() {
return "BulkReply{" +
"bytes=" + Arrays.toString(data) +
'}';
}
}

2.6 运行测试

服务端跑起来后,用官方的redis-cli就能连上我们的服务,执行一些命令测试一下。看到自己实现的Redis“伪服务端”能够“骗过”redis-cli,还是很有成就感的!

127.0.0.1:6379> set name helloworld
(integer) 1
127.0.0.1:6379> get name
"helloworld"
127.0.0.1:6379> set name abc123
(integer) 0
127.0.0.1:6379> get name
"abc123"
127.0.0.1:6379> get age
(nil)

3.Netty 4中的那些“坑”

因为是初次使用Netty 4,好多网上的资料都是Netty 3或者Netty 4早期版本的,API都不一样了,所以碰到了不少问题,官方文档里也没找到答案,一点点调试、猜测、看源码才摸出点儿“门道”:

  • Handler的基础类:Netty 4里使用SimpleChannelInboundHandler就可以了,之前的API已经不适用了。
  • Inbound和Outbound处理器间的数据交换:Context对象是数据交换的接口,不同的是:Inbound之间是靠fireChannelRead()进行数据交换,但从Inbound到Outbound就要靠writeAndFlush()触发了。
  • Inbound和Outbound的顺序:fireChannelRead()会向后找下一个Inbound处理器,但writeAndFlush()会向前找前一个Outbound处理器。所以在ChannelInitializer中,Outbound要放在SimpleChannelInboundHandler前面才能进行数据交换。
  • @Sharable注解:如果Handler是无状态的话,可以标这个注解。

用Netty解析Redis网络协议的更多相关文章

  1. Netty开发redis客户端,Netty发送redis命令,netty解析redis消息

    关键字:Netty开发redis客户端,Netty发送redis命令,netty解析redis消息, netty redis ,redis RESP协议.redis客户端,netty redis协议 ...

  2. Redis网络协议

    Redis网络协议较为简单,易于阅读. 命令或数据已\r\n结尾,但除了状态回复,其他数据都是二进制安全的(包含长度) 头部如下: + 正确的状态信息,具体信息是当前行+后面的字符. -  一条错误信 ...

  3. Java 面试知识点解析(五)——网络协议篇

    前言: 在遨游了一番 Java Web 的世界之后,发现了自己的一些缺失,所以就着一篇深度好文:知名互联网公司校招 Java 开发岗面试知识点解析 ,来好好的对 Java 知识点进行复习和学习一番,大 ...

  4. 物联网架构成长之路(35)-利用Netty解析物联网自定义协议

    一.前言 前面博客大部分介绍了基于EMQ中间件,通信协议使用的是MQTT,而传输的数据为纯文本数据,采用JSON格式.这种方式,大部分一看就知道是熟悉Web开发.软件开发的人喜欢用的方式.由于我也是做 ...

  5. [转]网络协议-redis协议

    Redis 通信协议(protocol) 本文档翻译自: http://redis.io/topics/protocol . Redis 协议在以下三个目标之间进行折中: 易于实现 可以高效地被计算机 ...

  6. 网络协议之:redis protocol 详解

    目录 简介 redis的高级用法 Redis中的pipline Redis中的Pub/Sub RESP protocol Simple Strings Bulk Strings RESP Intege ...

  7. 《Netty Zookeeper Redis 高并发实战》 图书简介

    <Netty Zookeeper Redis 高并发实战> 图书简介 本书为 高并发社群 -- 疯狂创客圈 倾力编著, 高度剖析底层原理,深度解读面试难题 疯狂创客圈 Java 高并发[ ...

  8. 网络协议 17 - HTTPDNS:私人定制的 DNS 服务

    [前五篇]系列文章传送门: 网络协议 12 - HTTP 协议:常用而不简单 网络协议 13 - HTTPS 协议:加密路上无尽头 网络协议 14 - 流媒体协议:要说爱你不容易 网络协议 15 - ...

  9. 一文彻底理解Redis序列化协议,你也可以编写Redis客户端

    前提 最近学习Netty的时候想做一个基于Redis服务协议的编码解码模块,过程中顺便阅读了Redis服务序列化协议RESP,结合自己的理解对文档进行了翻译并且简单实现了RESP基于Java语言的解析 ...

随机推荐

  1. 常见Linux网卡配置范例

    一.RHEL/CentOS系 参考链接:RHEL6网络配置 RHEL7网络配置 文件路径:/etc/sysconfig/network-scripts/ifcfg-eth0 DEVICE=eth0 B ...

  2. 输出一个对象,会默认执行toString()方法

    今天在看编程思想时看到enum知识点时发现了这个小问题(可能我基础太差了) 如图 然后就一步一步的跟进源码发现了其中的奥秘,首先进入println()方法如下图 看图执行了valueOf()方法进行s ...

  3. C语言中关于运算符优先级别

    在一一个表达式中可能有多个不同的运算符结合起来,由于运算符的优先级别不一样,可能会形成得到的结果不同. 优先级从上到下依次递减,最上面具有最高的优先级,逗号操作符具有最低的优先级. 对于相同的优先级, ...

  4. 机器学习技法:16 Finale

    Roadmap Feature Exploitation Techniques Error Optimization Techniques Overfitting Elimination Techni ...

  5. codevs 搜索题汇总(黄金级)

    2801 LOL-盖伦的蹲草计划  时间限制: 1 s  空间限制: 256000 KB  题目等级 : 黄金 Gold   题目描述 Description 众所周知,LOL这款伟大的游戏,有个叫盖 ...

  6. [HAOI 2007]反素数ant

    Description 对于任何正整数x,其约数的个数记作g(x).例如g(1)=1.g(6)=4. 如果某个正整数x满足:g(x)>g(i) 0<i<x,则称x为反质数.例如,整数 ...

  7. [ZJOI2015]幻想乡战略游戏

    Description 傲娇少女幽香正在玩一个非常有趣的战略类游戏,本来这个游戏的地图其实还不算太大,幽香还能管得过来,但是不知道为什么现在的网游厂商把游戏的地图越做越大,以至于幽香一眼根本看不过来, ...

  8. 17.10.28&29

    28上午 骚猪选讲 28下午 BOZJ 1081 [SCOI2005]超级格雷码 感觉就是一个找规律,然后模拟输出.半天没找到一个比较简便的模拟方法,这份代码是学习网上一位大佬的,很巧妙. 代码: # ...

  9. [Educational Codeforces Round 7]F. The Sum of the k-th Powers

    FallDream dalao找的插值练习题 题目大意:给定n,k,求Σi^k (i=1~n),对1e9+7取模.(n<=10^9,k<=10^6) 思路:令f(n)=Σi^k (i=1~ ...

  10. ●BZOJ 4453 cys就是要拿英魂!

    题链: http://www.lydsy.com/JudgeOnline/problem.php?id=4453 题解: 后缀数组,离线询问,栈看了一堆题解才看懂,太弱啦 ~ 如果对于一个区间[l,r ...