用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\nset\r\n$4\r\nname\r\n$10\r\nhelloworld\r\n<-:1\r\nget name->*2\r\n$3\r\nget\r\n$4\r\nname\r\n<-$10\r\nhelloworld\r\nset name abc111->*3\r\n$3\r\nset\r\n$4\r\nname\r\n$6\r\nabc111\r\n<-:0\r\nget age->*2\r\n$3\r\nget\r\n$3\r\nage\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.Sharablepublic 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) 1127.0.0.1:6379> get name"helloworld"127.0.0.1:6379> set name abc123(integer) 0127.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是無狀態的話,可以標這個註解。