协议是在通信层之上管理具体消息在应用之间如何传递和理解的逻辑规则,而协议生成器则是一种根据这些规则生成具体的消息定义的工具。协议生成器大大降低了维护协议的难度,是某些协议(例如大名鼎鼎的 Protobuf 和老牌一些的 Thrift 等)得以流行的关键。本文将假设读者对计算机通信和协议有一定了解,重点讲述生成器的设计和作用。
从最高层来看,一个通信协议必须具备三种要素:数据交互方式、消息描述和编解码规范。这里也许会有不同意见——将编解码规范列入到通信层。但是本文所持的观点是,编解码规范也是协议不可或缺的一部分,因为协议本身是为了通过媒介在多个程序实例之间通信而设计的。如果脱离了编解码,协议便成为了单纯的接口描述,并不能体现出“通信”的功能了。
设计协议时,一般会从需求入手,围绕上述的协议三个要素进行反复论证。本文想要探讨的协议是在网络协议栈里最顶层的应用层协议,其余层级的协议因为通用性较强、需要底层的软硬件支持,日常开发中针对其应用的场景较多,而修改甚至重写的可能性较小,因此不作讨论。
应用层协议通常会有广播式和应答式两大类,通常对应单向传输和信息交互两种类型的应用。例如 GPS 卫星信号就是一种广播式协议,设备只需要接收信号,并不需要向卫星发射信号;而我们常见的网络浏览器所使用的 HTTP 协议就是一种信息交互式的协议——服务器需要知道客户端想要什么,客户端也必须知道服务器给出的结果。这通常是协议设计的第一步。
接着则是选择合适的编码方式,通常会在编码效率、传输效率(即信息压缩比)、解码效率(例如是否允许局部解码)这三个维度之间进行权衡。
确定编解码方式后,大部分的协议制定工作也就完成了。剩下的就是根据业务来规定组件之间的交互方式——同步(如 HTTP )还是异步(如交易所)、如何管理客户端(会话管理)等。
如果抛开业务层,协议的本质就是将内存中的一段信息从一个组件通过网络等介质传递到另一个组件并还原成原本信息的过程。
当协议被设计出来之后,总会有各种需求变化要求协议进行一些更改或者扩充。例如维持编解码方式不变,在业务层多加了一个组件,该组件提供的功能需要加入新的消息来满足;或者某个组件升级了版本,在某个消息中添加或删除了某几个字段等。
协议的维护在日常开发中非常常见,一个便于维护、灵活的协议实现就变得非常重要了。
前面列举了协议的三个要素,如果从最不容易更改到最容易更改来排序的话,应该是编解码规范(例如规定了编解码为键值对形式的二进制编码后,便不会轻易更改)、信息交互方式、消息描述。因此我们应该构建一种体系,使得消息描述更改很容易,而利用编解码规范相对固定的特点将其固化到程序里。这样可以使得协议变更操作更高效,也降低了故障率。
同时,在较大型的应用中,通常希望协议的编解码流程静态化(如解码出来的结果应该是一个有明确定义的对象,各字段都由编译器检查拼写、类型是否正确;而不是一种通用的字典格式,由调用者来随意存取字段),以此降低维护成本。而且出于性能的考虑,这些代码将不会使用反射等机制,因此重复性很高(多数代码都一样,只是字段名字和类型不同),见下例:
某解码器代码片段,其作用是将 MongoDB 的对象结构转化成预先定义的对象格式
在前面描述的协议设计各个环节中,生成器都可以被使用。从本质上来说,生成器只是一种按照规则生成可编译代码的工具。
生成代码的方式可以多种多样, 例如在 Java 中有 jCodeModel 这样的代码构建工具,也可以用 velocity 这样的模板库(一般被用来生成网页)来生成代码,不拘一格。
一个基本的协议生成器至少包含协议描述、代码生成、代码编译三部分。其中代码编译比较简单,即生成出来的代码必须能通过编译。下面重点说前两部分。
在代码生成前,必须让生成器了解将要生成的协议是什么样子的。同时在决定开发或使用协议生成器的时候,也必须决定好生成的代码将会安插在协议的什么部分。例如 Protobuf 是一种序列化生成器,而 gRPC 则是从通信一直到回调生成了全套代码。使用者只需要编辑描述文件,其余的重复性工作由机器完成。
对于协议描述,一般来说我们会定义如下术语:
其中“消息”是协议中完整信息传递的最小单位,而“字段”则是构成消息的组成部分(单独的字段无法独立代表明确的业务信息,因此“消息”是最小单位而不是“字段”)。
具体到不同的协议类型,还有可能会定义诸如“操作”、“服务”等上层分类。例如在请求应答式的协议中,一次完整的信息传递依赖于一个请求和一个应答两条消息。
协议描述的格式不拘一格,Java 世界中更偏向用 XSD 定义的 XML 格式,Python 世界中可能直接使用源代码文件作为描述文件,而 Protobuf 及 gRPC 这类跨语言的工具则自定义了描述文件格式。直接使用源码作为描述文件的做法在开发时相对简单,但是无法跨语言。
有了描述文件后自然是根据描述文件按照一定规则生成代码了。这里并无统一的方法论,看各自的需求决定如何编写。这里只能提供一些编写建议,供参考。
在生成器读取描述文件后需要对其正确性进行验证,如果有错误则要提供明确的错误信息。因为内部使用的描述文件并非广为人知的文件格式,因此使用者能依靠的只有报错信息,无法在网上搜索资料。因此明确、友好的报错信息就显得特别重要了 —— 尤其是当描述文件比较复杂的时候。
先手写几个有代表性的消息代码,再设计代码生成器。这样做的好处是能直观地知道需要什么样的模板,生成目标代码后可以将其和手写的代码来对比,调试起来更容易。在选择试写消息时最好是选择一个相对复杂的消息,包含了每一种协议支持的数据类型,尤其是相对复杂的数组、对象数组、枚举、各种可变长度类型等。这些消息的编解码规则相对复杂,如果在早期阶段没有纳入考量,很容易在生成器编写的中后期出现大量重构的情况。
对于生成后的代码在性能和可读性之间选择性能。因为代码生成器的作用就是让机器帮助人做重复性的劳动,而生成之后的代码并不需要人类阅读(调试生成器时除外)。因此可以将生成后的代码视为编译结果,只要机器能读或执行即可,因此在可读性与性能冲突时选择性能。例如避免使用有已知性能问题的语法糖、尽可能使用位运算等。实际上代码生成器的最终目标是生成经该语言编译器编译后的机器码(如有此类机制,当然有很多例外,例如 Python ),生成器之所以生成该语言的源文件实际上是为了便于生成器的开发。如果不复用语言本身的编译器的话就得直接开发一个从描述语言到机器语言的编译器了,这是完全没有必要的。
米筐在创业初期就实现了一套针对 Java 语言的类似 gRPC 的全套编解码生成器(当时 gRPC 还在 beta 阶段,包括其依赖于某个支持 http2.0 的 netty 版本也在 beta 阶段)。之后米筐将后台架构改成了 Python,后台组件的通信协议也直接使用了 gRPC 。虽然自研的协议在性能上比起 gRPC 略有优势,但是研发投入非常大,遇到跨语言场景时就显得力不从心了。
自研的通信机制在对性能有极致要求的场景下会优于 gRPC 的方案,一般使用场景下还是推荐直接使用 gRPC 来作为底层通信框架。如果是小型项目,或者使用场景比较简单的项目,甚至不推荐使用 RPC 框架,而是通过自动化测试来保证应用的正确性,以灵活的协议来提高开发效率。例如 RQData 的选择就是如此,它只服务于金融数据调取这一种场景,非常容易做自动化测试,因此直接使用了类似 JSON 的 MessagePack 作为序列化格式。具体协议是如何设计的请见《如何设计 RQData 通讯协议》。
米筐邀请各位笔者共同创作更多技术类优质内容,欢迎联系米筐量化王老师微信 RicequantCS 。