前言
瞭解T-io
框架有些日子了,並且還將它應用於實戰,例如 tio-websocket-server
,tio-http-server
等。但是由於上述兩個server
已經封裝好,直接應用就可以。所以對於整個資料流通的過程不是很明朗,甚至對於hello-world
例子中的encode
,decode
作用並不理解。於是乎想寫一個更貼近實際應用的redis-client
來作為學習切入點,雖然編碼過程中困難重重,不過最後還是實現了一個粗糙的客戶端。由於程式碼中大量參考了Jedis
原始碼,所以,我給這個客戶端起名T-io
+Redis
=Tedis
.哈哈,這些都不重要,下文中將會記錄出我的學習和開發歷程。
Redis通訊協議
在開發之前,首先要去了解客戶端和服務端的通訊協議,那麼我們開發Redis
客戶端,就要去看看Redis協議了。所以,下面要做的就是:
- 明確客戶端傳送給服務端的訊息格式
- 明確服務端返回給客戶端的訊息格式
在此呢,我只簡單舉一個GET
,SET
的例子,其他的內容大家可以去看參考文件。
//SET命令
set mykey myvalue
//GET命令
get mykey
上述兩個簡單的命令,根據Redis
協議可以解析成如下內容
//SET命令
*3\r\n$3\r\nset\r\n$5\r\nmykey\r\n$7\r\nmyvalue\r\n
//GET命令
*2\r\n$3\r\nget\r\n$5\r\nmykey\r\n
其中 *3
代表有三段內容,即 SET
,mykey
,myvalue
.每一段內容之間由 CRLF(\r\n)
隔開.$
符號後邊跟的數字就是資料位元組數。引用官方的一個圖:
在Jedis
原始碼中,對於訊息體的構造比較麻煩,我看的也是雲裡霧裡的,所以在Tedis
的實現中我才用了最簡單的拼接方式。即StringBuilder
根據規則拼接字串,然後呼叫getBytes
方法獲取byte[]
。示例程式碼如下:
public static byte[] buildCommandBody(final ProtocolCommand cmd,String... args) {
StringBuilder builder = new StringBuilder();
//*[num]
builder.append('*')
//命令數(1) + 引數的個數
.append(1 + args.length);
appendCrLf(builder)
//命令長度 $[cmd_length]
.append("$")
.append(cmd.getName().length());
appendCrLf(builder)
//命令內容 cmd
.append(cmd.getName());
appendCrLf(builder);
//遍歷引數,按照 $[num]\r\n[content]\r\n的格式拼接
for (String arg : args) {
builder.append("$")
.append(arg.length());
appendCrLf(builder)
.append(arg);
appendCrLf(builder);
}
//最後轉換為 byte[],此處使用 Jedis 中的 SafeEncoder
return SafeEncoder.encode(builder.toString());
}
呼叫示例:
public static void main(String[] args){
Protocol.buildCommandBody(Protocol.Command.SET,"key","value");
}
列印結果:
*3
$3
SET
$3
key
$5
value
那麼到此為止,我們已經瞭解瞭如何構造傳送給服務端的訊息,那麼如何解析服務端返回的訊息呢?
Redis 命令會返回多種不同型別的回覆。
通過檢查伺服器發回資料的第一個位元組, 可以確定這個回覆是什麼型別:
- 狀態回覆(status reply)的第一個位元組是 "+"
- 錯誤回覆(error reply)的第一個位元組是 "-"
- 整數回覆(integer reply)的第一個位元組是 ":"
- 批量回復(bulk reply)的第一個位元組是 "$"
- 多條批量回復(multi bulk reply)的第一個位元組是 "*"
時間有限,我也只是完成了狀態回覆和批量回復的部分功能,下文中將以這兩種回覆作為講解示例。
T-io登場
由於只是客戶端的開發,所以這裡我們只會用到TioClient
。所以,我們先把Redis-Server
連線上。ClientAioHandler
,ClientAioListener
,ClientGroupContext
自然是少不了的啦,直接上程式碼吧。
- 初始化一個
ServerNode
Node serverNode = new Node("127.0.0.1",6379);
- 初始化一個
ClientGroupContext
,它依賴於ClientAioHandler
,ClientAioListener
ClientGroupContext clientGroupContext = new ClientGroupContext(tioClientHandler, aioListener, null);
- 初始化一個
TioClient
TioClient tioClient = new TioClient(clientGroupContext);
- 最後連線伺服器,如果沒有什麼異常列印的話,就連線成功啦
//返回的ClientChannelContext 用於傳送訊息使用
ClientChannelContext clientChannelContext = tioClient.connect(serverNode);
恭喜你,一個Redis
客戶端寶寶就此誕生,只不過它還不會說話。結合上文協議部分的內容,我們傳送一條訊息給伺服器。首先定義訊息包:
public class TedisPacket extends Packet {
private byte[] body;
//getter setter
}
然後呼叫Tio.send
方法就可以啦。
Tio.send(clientChannelContext, packet);
如果你已經看懂了上半部分,那麼你就會知道這裡 TedisPacket
中的body
的值就是通過Protocol.buildCommandBody(Protocol.Command.SET,"key","value");
來生成的。不要忘了 `ClientAioHandler.encode’方法哦。
@Override
public ByteBuffer encode(Packet packet, GroupContext groupContext, ChannelContext channelContext) {
TedisPacket tedisPacket = (TedisPacket) packet;
byte[] body = tedisPacket.getBody();
int bodyLen = 0;
if (body != null) {
bodyLen = body.length;
}
//只是簡單將 body 放入 ByteBuffer 。
ByteBuffer buffer = ByteBuffer.allocate(bodyLen);
buffer.put(body);
return buffer;
}
到此為止,客戶端向伺服器傳送訊息的內容已經寫完了。下面將介紹如何解析服務端的響應。
當伺服器正常,並且傳送到伺服器的訊息格式符合RESP
協議的話,那麼伺服器會返回你相應的內容,比如我們傳送SET
命令,伺服器的正常響應是+OK\r\n
.下面我們看ClientAioHandler.decode
方法。當我批量向伺服器傳送訊息時,伺服器給我的響應也是批量接收到的。列印結果如下:
那麼問題來了,我們只想要每一次傳送對應一個OK
.所以,原諒我這個菜鳥,我才明白decode
方法的目的。那麼,我們就去解析這個內容。解析過程有幾個需要關注的地方:
- 遇到第一個
\r
的時候,下一個位元組一定是'\n'否則,作為解析失敗處理。 \r\n
之後停止本輪解析,返回解析結果。
基於上述注意事項,解析程式碼如下:(應該會有更優秀的方法)
先獲取第一個位元組,它應該是+ - $ : *
的其中一個,如果不是的話,說明訊息可能是上一次不完整導致的,等待下次解析。
byte first = buffer.get();
以 +OK\r\n
舉例:
private TedisPacket readSingleLinePacket(ByteBuffer buffer,int limit,int position) throws AioDecodeException {
byte[] body = new byte[limit - position];
int i = 0;
//結束標誌
boolean endFlag = false;
while (buffer.position() <= limit) {
byte b = buffer.get();
//如果是\r
if (BufferReader.isCr(b)) {
byte c = buffer.get();
//如果不是\n丟擲異常
if (!BufferReader.isLf(c)) {
throw new AioDecodeException("unexpected redis server response");
}
//結束解析
endFlag = true;
break;
} else {
body[i++] = b;
}
}
//如果此次解析一直沒有遇到\r\n,則返回null,等待下次解析
if (!endFlag) {
return null;
}
TedisPacket packet = new TedisPacket();
packet.setBody(body);
return packet;
}
寫完解析程式碼之後,再一次除錯結果如下,可以看到資料以5個位元組減少,說明資料包被正確解析了。列印內容來自Tio:DecodeRunnable.java
.
到此為止,我們完成了訊息的傳送和接收,但是問題來了,由於訊息是非同步接收,那我們如何才能讓客戶端知道命令呼叫是否成功呢?注意,下文中的內容僅為個人理解,錯誤之處懇請指正
既然redis是單執行緒處理的,那麼我是否可以理解為,訊息的處理就是先到先處理,後到後處理呢?所以,我的解決方式是通過 LinkedBlockingQueue
。當解析完一個包之後,將這個包放入阻塞佇列中。
@Override
public void handler(Packet packet, ChannelContext channelContext) throws Exception {
TedisPacket responsePacket = (TedisPacket) packet;
if (responsePacket != null) {
QueueFactory.get(clientName).put(responsePacket);
}
}
同步接收返回訊息:
private String getReponse() {
for (; ; ) {
try {
TedisPacket packet = QueueFactory.get(clientName).take();
return packet.hasBody() ? SafeEncoder.encode(packet.getBody()) : null;
} catch (InterruptedException e) {
e.printStackTrace();
return null;
}
}
}
所以set程式碼就變成這樣:
@Override
public String set(String key, String value) {
client.set(key,value);
return client.getStatusCodeReply();
}
OK,訊息接收這塊是基於我的理解,我也不知道對不對,而且,其中的BUG肯定也是多的數不勝數,沒關係,抱著學習的心態慢慢去完善就好了。Jedis
也不是一次兩次就寫成的對吧。
Tedis 與 Jedis
在開發過程中,我閱讀了很多Jedis
的原始碼,大體思路能看懂,可是很多細節處理對我來說就比較難了,大神的程式碼只可膜拜。不過也給了我很多啟發。最後不知天高地厚的和人家做一下對比吧。
public static void main(String[] args) {
Jedis tedis = new Jedis("192.168.1.225", 6379);
long start = SystemTimer.currentTimeMillis();
for (int i = 0; i < 200; i++) {
tedis.set("tedis", "tedis");
}
tedis.get("tedis");
long end = SystemTimer.currentTimeMillis();
System.out.println("總共用時:" + (end - start) + "ms,平均用時:" + ((end - start) / 100) + "ms");
}
Jedis結果:總共用時:262ms,平均用時:2ms
Tedis結果:總共用時:390ms,平均用時:3ms
那麼這一毫秒差在哪裡呢?
總結
一篇部落格簡單介紹了Redis
客戶端的開發過程,當然對於成熟的客戶端Jedis
來說,也就是一個HelloWorld,不過這有什麼關係呢?知其然,更要知其所以然。看了大神的程式碼才知道自己有多渺小哦。繼續加油~~