你好,我是何辉。今天我们深入研究Dubbo源码的最后一篇,协议编解码。
你已经学过了 Dubbo 框架的十大模块([01讲]),上一讲也掌握消费方调用的整体框架流程,基本把 Dubbo 框架纵向走了一遍,但依然在单进程中转悠。而 Dubbo 的本质就是网络通信,要想把数据发到网络送往提供方,默认用 Netty 网络通信框架完成的。
那发起接口调用时,你创建出来的一个普普通通的请求对象,到底是如何被编解码后发到网络中的呢?如果你平常不怎么接触底层,这个问题想回答好确实有点难。不过不用担心,我们也研究了不少源码,按套路,根据一些蛛丝马迹顺着研究,解答这个问题其实就是一层窗户纸的事。
什么是帧格式?
想回答清楚今天的问题,我们从一个概念开始,帧格式。
所谓的“帧格式”,就是指根据不同协议规定的数据传输的格式。举个常见的 TCP/IP 模型,分别有应用层、传输层、网络层、数据链路层,以及物理层,每一层都有着对应的数据帧格式。
我们以数据封装的过程为例。

比如,应用层的数据包+TCP头部就是传输层的帧格式,传输层的数据包+IP头部就是网络层的帧格式,网络层的数据包+MAC头部就是数据链路层的帧格式,最后,会把数据链路层的整个数据包,变成比特流,通过工作在物理层的网卡、网线或光纤发送出去。
这是我们从 TCP/IP 模型层面认识帧格式,那对于这样的数据传输格式,在客户端向服务端发送数据的实际交互过程中,又是如何体现的呢?
我们看一个日常开发的交互案例。

client 把数据发往 server,这种交互形式估计你是再熟悉不过了。我们模拟一下数据的收发过程。
1. 固定长度
现在我们有 100 字节的数据,需要通过 client 发送出去,可是 client 的发送缓冲区太小了,缓冲区每次只能容纳 50 字节大小。所以,client 利用 socket 进行了 2 次 write 操作,才把 100 字节发送出去。
但是对于收数据的 server 来说,什么时候才认为数据接收齐全了呢?

server 其实是很疑惑的,接收 client 第一次发来的数据后,到底该不该收 client 第二次发来的数据呢?若是不收的话,会不会造成少收数据?若是收,到底收多少数据才算完整呢?
为了解决种种疑惑,client 和 server 进行了双方的约定,按照固定长度直接进行收发。
在这个场景里约定 client 发起的一次短链接请求,server 收满 100 个字节就可以结束了。如果 client 发了 120 个字节,server 已经收下 100 个字节后,再继续收到 20 个字节时,发现 client 结束会话了,那么,server 会认为这20字节没有满 100 个字节,就干脆丢弃不要了。
2. 分隔符
看来长度的约定很重要,但是,这个长度到底该定义多少合适呢?
若短了,会增加通信的次数,增加双方的通信压力;若长了,比如定义为 1000 个字节,但是大多数情况下数据都是 1000 ~ 1100 个长度,岂不是 client 每次都要多发 900 ~ 1000 个没有意义的内容给到 server,不然 server 就会抛弃掉 client 第二次发送的数据。

所以 client 又和 server 进行了约定,约定按照特殊字符切割。比如 client 给 server 发了一堆的数据,server 先一股脑全接收,当 server 碰到特殊字符时就切割开,然后继续接收新的一个数据包。
这也不失为一种办法,总比数据被丢弃好得多。
3. 定长+变长
可是试行了一段时间后,client 还是觉得有问题,因为有些数据内容如果含有分隔符,也被切割了。如果能有一种特殊的前缀标识就好了,识别到这种标识,就读多少数据,貌似更可行。

于是 client 又和 server 进行了约定,约定数据格式由两部分组成,报文头、报文体。报文头是固定长度,里面有特殊的前缀标识、报文体的总长度;而报文体,长度是可变的,有多少数据就有多少长度。
好,我们从固定长度,到分隔符,到定长+变长,梳理了三种 client 与 server 的约定传输数据方式,你也应该发现了,其实这就是约定数据如何传输的协议格式,约定是怎样的格式,那就按照怎样的格式进行收发处理。而这种约定数据包格式的方式,就是我们俗称的“数据帧格式”。
那 Dubbo 框架把对象编解码后变成了什么样的帧格式呢?
Dubbo 帧格式
问题来了,偌大的 Dubbo 源码工程,我们该怎么从源码层面找到底层是如何编解码的呢?
其实之前我们已经见过了,“[源码框架]”中的 Serialize 模块属于哪个层次?“[调用流程]”中将对象进行序列化的代码入口在哪里?
没错,想必你也想到了,我们可以顺着 NettyCodecAdapter 这个类的 encode 方法看源码,应该就可以找到了。
实践是检验真理的唯一标准,我们打个断点去 Debug 调试一下。启动 ZooKeeper 和提供方,然后 Debug 启动消费方,一会就来到了 encode 方法:

我们单步调试进去,先经过了 DubboCountCodec 类,然后经过了 ExchangeCodec 类,结果发现了一段惊喜的代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
@Override public void encode(Channel channel, ChannelBuffer buffer, Object msg) throws IOException { if (msg instanceof Request) { encodeRequest(channel, buffer, (Request) msg); } else if (msg instanceof Response) { encodeResponse(channel, buffer, (Response) msg); } else { super.encode(channel, buffer, msg); } }
|
从这段代码中,确实发现了一段非常有亮点的代码,逻辑也不多,就 3 条分支逻辑,一条是针对请求对象进行编码的分支,一条是针对响应对象进行编码的分支,最后一条是调用了父类的 encode 方法进行编码的分支。
虽然分支逻辑不多,但是我们从方法名上已经发现了端倪,这不就是即将要对 Request 和 Response 对象进行编码操作么?继续前进,我们进入 encodeRequest 方法看个究竟。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93
|
protected static final int HEADER_LENGTH = 16;
protected static final short MAGIC = (short) 0xdabb; protected static final byte MAGIC_HIGH = Bytes.short2bytes(MAGIC)[0]; protected static final byte MAGIC_LOW = Bytes.short2bytes(MAGIC)[1];
protected static final byte FLAG_REQUEST = (byte) 0x80; protected static final byte FLAG_TWOWAY = (byte) 0x40; protected static final byte FLAG_EVENT = (byte) 0x20; protected static final int SERIALIZATION_MASK = 0x1f;
protected void encodeRequest(Channel channel, ChannelBuffer buffer, Request req) throws IOException { Serialization serialization = getSerialization(channel, req); byte[] header = new byte[HEADER_LENGTH]; Bytes.short2bytes(MAGIC, header); header[2] = (byte) (FLAG_REQUEST | serialization.getContentTypeId()); if (req.isTwoWay()) { header[2] |= FLAG_TWOWAY; } if (req.isEvent()) { header[2] |= FLAG_EVENT; } Bytes.long2bytes(req.getId(), header, 4); int savedWriteIndex = buffer.writerIndex(); buffer.writerIndex(savedWriteIndex + HEADER_LENGTH); ChannelBufferOutputStream bos = new ChannelBufferOutputStream(buffer); if (req.isHeartbeat()) { bos.write(CodecSupport.getNullBytesOf(serialization)); } else { ObjectOutput out = serialization.serialize(channel.getUrl(), bos); if (req.isEvent()) { encodeEventData(channel, out, req.getData()); } else { encodeRequestData(channel, out, req.getData(), req.getVersion()); } out.flushBuffer(); if (out instanceof Cleanable) { ((Cleanable) out).cleanup(); } } bos.flush(); bos.close(); int len = bos.writtenBytes(); checkPayload(channel, len); Bytes.int2bytes(len, header, 12); buffer.writerIndex(savedWriteIndex); buffer.writeBytes(header); buffer.writerIndex(savedWriteIndex + HEADER_LENGTH + len); }
|
对这个 encodeRequest 方法仔细研读后,恍然大悟,原来 Dubbo 协议帧格式如此简单,正好就是我们协议约定中“定长 + 变长”的实现版本。
光从代码上不好看出“定长 + 变长”的可视化效果,这里我也根据源码的编写逻辑,总结了下代码所表达的数据帧格式。

图中写的 0 ~ 7、8 ~ 15 这些数字表示的是一些 bit 位对应位置,也就是,各个方块占据了多少 bit,除以 8 得到的结果,就是占据了多少 byte(字节)。接下来,我们挨个认识一下每个小方块是什么含义:
- magic high:魔术高位,占用 8 bit,也就是 1 byte。该值固定为 0xda,是一种标识符。
- magic low:魔术低位,占用 8 bit,也就是 1 byte。该值固定为 0xbb,也是一种标识符。
魔术低位和魔术高位合并起来就是 0xdabb,代表着 dubbo 数据协议报文的开始。如果从通信 socket 收到的报文不是以 0xdabb 开始的,可以认为是非法报文。
- request flag and serialization id:请求类型和序列化方式,占用 8 bit,也就是 1 byte。前面 4 bit 是请求类型,后面 4 bit 是序列化方式,合起来用 1 个 byte 来表示。
- response status:响应码,占用 8 bit,也是 1 byte。因为已经明确是响应码了,所以一个请求发送出去的时候,不用填充这个值,响应回来的时候,这里就有值了。
但是这个响应码,并不是那些真实业务数据功能的响应码,而是 Dubbo 通信层面的错误码,比如通信响应成功码、消费方超时码、服务方超时码、请求格式错误码等等,都是一些 Dubbo 框架自己易于通信识别错误的码,并非那些真正上层业务功能的错误码。
- request id:请求唯一ID,占用 64 bit,也就是 8 byte。标识请求的唯一性,用来证明你收到的响应,就是你曾经发出去的请求返回来的数据。
- body length:报文体长度,占用 32 bit,也就是 4 byte。体现真正的业务报文数据到底有多长。
因为真实的业务数据有大有小,如果报文里不告知业务数据的长度,服务方就不知道要读取多长的字节,所以,就需要知道业务报文数据到底有多长。当客户端发送数据时,把要发送的业务数据报文计算一下长度后,放到这个位置,服务方看到该长度后,就会读取指定长度的字节,读完就结束,也就收到了一个完整的报文数据。
- body content:报文体数据,占用的 bit 未知,占用的 byte 字节个数也未知。这里是我们真正业务数据的内容,至于真正的业务数据的长度有多长,完全由报文体长度决定。
了解了 Dubbo 的协议帧格式,是不是并没有那么难,我们只需要按照上面的帧格式一步步构建出这样的字节数组,然后利用 socket,write 出去就可以了。服务方在接收数据的时候,也是一样,严格按照报文格式进行解析,不是 oxdabb 开头的就直接丢弃,是的话就继续往后读取,按照数据帧格式,直到读完整个报文为止。
好,到这里,我们详读了 encodeRequest 源码,反推出了 Dubbo 的协议帧格式,另外一个 encodeResponse 方法想必对你来说是小菜一碟了,课后你可以自己研究一下。
协议编解码的应用
Dubbo 协议的数据帧格式,是一个非常典型的协议格式案例,你以后在实际开发中,如果需要自定义数据传输报文格式,也可以参照 Dubbo 的协议数据格式,稍加改造一番,然后自行通过代码编码和解码就可以了。
除了 Dubbo 协议有这样的协议编解码的操作,其实市面上还有许许多多的应用案例,我们看 3 个常见的。
第一,HTTP 协议,是由请求行 + 请求头 + 请求体构成的,比如我们熟知的 Tomcat 容器就有这样一个 Http11Processor 的类,按照 HTTP 协议规范,来解析 HTTP 请求报文。
第二,RESP 协议,Redis 通信使用的协议,通过首字节的字符,来区分不同数据类型的序列化协议,甚至你都可以直接手写 Socket,把一段事先准备好的 RESP 报文调用 write 方法,就可以对 Redis 进行操作了。
第三,WebSocket 协议,是 HTML5 的一种新型协议,实现了浏览器与服务器的全双工通信,本质还是基于 HTTP 协议的基础之上,借助 HTTP 协议来完成握手工作的。
总结
今天,我们从已经学过的知识点中挑出一个从未深究的细节,一个接口调用时“所创建的请求对象”是如何编码发到网络中的?
针对这个问题,我们梳理了帧格式的概念,借助 client 向 server 发送数据的简单模型案例,引出了 client 与 server 约定了三种类型的协议格式,分别是固定长度、分隔符、定长+变长。
然后回忆所学的知识点,找到了 Dubbo 框架进行编码的入口,通过 Debug 模式步步深入,阅读 encodeRequest 方法后,挖掘出了把对象按照 Dubbo 协议进行编码的核心代码。

类似协议编解码的常见应用案例比如HTTP 协议、RESP 协议、WebSocket 协议等。
思考题
留个作业给你,我们已经分析了 encodeRequest 方法,你尝试自己研究一下 encodeResponse 方法的逻辑,总结一下与 encodeRequest 的异同。
期待看到你的思考,如果觉得今天的内容对你有帮助,也欢迎分享给身边的朋友一起讨论。我们下一讲见。
21 思考题参考
上一期留了个作业,研究下消费方进行泛化调用时会经历哪些流程,泛化调用的底层是如何实现的。
1.泛化调用流程
想要解答第一个问题,我们得首先有拥有一套消费方泛化调用的代码,这里我已经给你准备好了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
|
@Component public class GenericDemoService { public String invokeDemo() { return stringStyleRpcInvoke(); } public static String stringStyleRpcInvoke() { ReferenceConfig<GenericService> referenceConfig = buildReferenceConfig("com.hmilyylimh.cloud.facade.demo.DemoFacade"); referenceConfig.setGeneric("true"); GenericService genericService = referenceConfig.get(); Object rpcResult = genericService.$invoke( "sayHello", new String[]{"java.lang.String"}, new Object[]{"Generic Demo"}); return (String) rpcResult; } private static ReferenceConfig<GenericService> buildReferenceConfig(String interfaceName) { DubboBootstrap dubboBootstrap = DubboBootstrap.getInstance(); ApplicationConfig applicationConfig = new ApplicationConfig(); applicationConfig.setName(dubboBootstrap.getApplicationModel().getApplicationName()); String address = dubboBootstrap.getConfigManager().getRegistries().iterator().next().getAddress(); RegistryConfig registryConfig = new RegistryConfig(address); ReferenceConfig<GenericService> referenceConfig = new ReferenceConfig<>(); referenceConfig.setApplication(applicationConfig); referenceConfig.setRegistry(registryConfig); referenceConfig.setInterface(interfaceName); return referenceConfig; } }
|
代码其实非常简单,通过创建 ReferenceConfig 得到泛化引用对象,也就是泛化服务对象,然后调用泛化服务对象的 $invoke 方法,实现远程调用,并最终泛化调用的结果。
有了消费方的泛化调用代码后,学过了“[调用流程]”巧妙打断点的方式,想必你已经想到了,可以直接在 NettyClient 的 send 方法打个断点,然后启动提供方,Debug 启动消费方,静候断点的到来。

我们观察左侧的调用堆栈,很明显就找到了泛化实现类的过滤器,再细看其他环节的调用,可以发现,泛化调用和普通调用的区别就在于,泛化调用会经过一个泛化实现类过滤器(GenericImplFilter),其他环节的流程基本上没啥异样。
2.泛化调用内幕
既然要弄懂泛化调用的核心内幕逻辑,那就自然少不了去拜读一下源码的细节。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124
|
@Override public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException { String generic = invoker.getUrl().getParameter(GENERIC_KEY); if (isCallingGenericImpl(generic, invocation)) { RpcInvocation invocation2 = new RpcInvocation(invocation); invocation2.put("GENERIC_IMPL", true); String methodName = invocation2.getMethodName(); Class<?>[] parameterTypes = invocation2.getParameterTypes(); Object[] arguments = invocation2.getArguments(); String[] types = new String[parameterTypes.length]; for (int i = 0; i < parameterTypes.length; i++) { types[i] = ReflectUtils.getName(parameterTypes[i]); } Object[] args; if (ProtocolUtils.isBeanGenericSerialization(generic)) { args = new Object[arguments.length]; for (int i = 0; i < arguments.length; i++) { args[i] = JavaBeanSerializeUtil.serialize(arguments[i], JavaBeanAccessor.METHOD); } } else { args = PojoUtils.generalize(arguments); } if (RpcUtils.isReturnTypeFuture(invocation)) { invocation2.setMethodName("$invokeAsync"); } else { invocation2.setMethodName("$invoke"); } invocation2.setParameterTypes(new Class<?>[]{String.class, String[].class, Object[].class}); invocation2.setParameterTypesDesc("Ljava/lang/String;[Ljava/lang/String;[Ljava/lang/Object;"); invocation2.setArguments(new Object[]{methodName, types, args}); return invoker.invoke(invocation2); } else if (isMakingGenericCall(generic, invocation)) { Object[] args = (Object[]) invocation.getArguments()[2]; if (ProtocolUtils.isJavaGenericSerialization(generic)) { for (Object arg : args) { if (byte[].class != arg.getClass()) { error(generic, byte[].class.getName(), arg.getClass().getName()); } } } else if (ProtocolUtils.isBeanGenericSerialization(generic)) { for (Object arg : args) { if (!(arg instanceof JavaBeanDescriptor)) { error(generic, JavaBeanDescriptor.class.getName(), arg.getClass().getName()); } } } invocation.setAttachment( "generic", invoker.getUrl().getParameter("generic")); } return invoker.invoke(invocation); } ↓
private boolean isCallingGenericImpl(String generic, Invocation invocation) { return ProtocolUtils.isGeneric(generic) && (!"$invoke".equals(invocation.getMethodName()) && !"$invokeAsync".equals(invocation.getMethodName())) && invocation instanceof RpcInvocation; } ↓
private boolean isMakingGenericCall(String generic, Invocation invocation) { return (invocation.getMethodName().equals("$invoke") || invocation.getMethodName().equals("$invokeAsync")) && invocation.getArguments() != null && invocation.getArguments().length == 3 && ProtocolUtils.isGeneric(generic); } ↓
public static boolean isGeneric(String generic) { return StringUtils.isNotEmpty(generic) && ("true".equalsIgnoreCase(generic) || "nativejava".equalsIgnoreCase(generic) || "bean".equalsIgnoreCase(generic) || "protobuf-json".equalsIgnoreCase(generic) || "gson".equalsIgnoreCase(generic) || "raw.return".equalsIgnoreCase(generic)); }
|
翻看消费方泛化调用的源码,由于我们调用的是下游 sayHello 方法,但是通过泛化形式调用的话,从 invocation 中获取的 methodName 却是 $invoke,自然跳过了 isCallingGenericImpl 逻辑的判断,然后进入到 isMakingGenericCall 的分支逻辑中,只是赋值了一个属性就完事了,所以,我们可以得知设置 generic = true 进行泛化调用是最简单的泛化调用形式。
当然,我们从源码中了解了以前不知道的一些细节逻辑。
- 设置 generic 属性的值时,有六种设置方法,分别为 true、nativejava、bean、protobuf-json、gson、raw.return。
- 从 isCallingGenericImpl 分支逻辑中,我们发现进行普通的远程调用时,也可以在 @DubboReference 注解中加上 generic 属性值。
总的来说,消费方的泛化服务过滤器,也确实没有干什么非常特别的事情,关键就是将 generic 属性值传到了提供方那边,至于提供方拿到 generic 的不同属性值,会按照怎样的处理逻辑,有兴趣的话你可以自行研究 GenericFilter,这个类是专门处理提供方接收泛化请求的泛化过滤器。