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

前言

  了解T-io框架有些日子了,并且还将它应用于实战,例如 tio-websocket-server,tio-http-server等。但是由于上述两个server已经封装好,直接应用就可以。所以对于整个数据流通的过程不是很明朗,甚至对于hello-world例子中的encode,decode作用并不理解。于是乎想写一个更贴近实际应用的redis-client来作为学习切入点,虽然编码过程中困难重重,不过最后还是实现了一个粗糙的客户端。由于代码中大量参考了Jedis源码,所以,我给这个客户端起名T-io+Redis=Tedis.哈哈,这些都不重要,下文中将会记录出我的学习和开发历程。

Redis通信协议

  Redis Protocol

  在开发之前,首先要去了解客户端和服务端的通信协议,那么我们开发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)隔开.$符号后边跟的数字就是数据字节数。引用官方的一个图:

image

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方法。当我批量向服务器发送消息时,服务器给我的响应也是批量接收到的。打印结果如下:

image

那么问题来了,我们只想要每一次发送对应一个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.

image

到此为止,我们完成了消息的发送和接收,但是问题来了,由于消息是异步接收,那我们如何才能让客户端知道命令调用是否成功呢?<font color=#A52A2A>注意,下文中的内容仅为个人理解,错误之处恳请指正</font>
既然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,不过这有什么关系呢?知其然,更要知其所以然。看了大神的代码才知道自己有多渺小哦。继续加油~~

源码地址:https://github.com/fanpan26/tedis
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,992评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,212评论 3 388
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,535评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,197评论 1 287
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,310评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,383评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,409评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,191评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,621评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,910评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,084评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,763评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,403评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,083评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,318评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,946评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,967评论 2 351

推荐阅读更多精彩内容

  • 重点参考链接: http://www.cnblogs.com/wangyuyu/p/3786236.html Re...
    Kevin_Junbaozi阅读 2,179评论 0 21
  • NOSQL类型简介键值对:会使用到一个哈希表,表中有一个特定的键和一个指针指向特定的数据,如redis,volde...
    MicoCube阅读 3,967评论 2 27
  • 1.1 资料 ,最好的入门小册子,可以先于一切文档之前看,免费。 作者Antirez的博客,Antirez维护的R...
    JefferyLcm阅读 17,041评论 1 51
  • 2. “铃~铃~铃~”闹钟准时响了起来,林潇脑袋还没完全清醒,想着先把这烦人的声音给关掉,手就顺着声音在床上摸索。...
    麻辣烫malater阅读 492评论 0 0
  • 翻着QQ列表,突然发现好友列表无增无减。多了一列大学同学,少了一列早已经没有交集的陌生人。QQ群有增无减,尽管退掉...
    梦中猊阅读 295评论 0 2