如何设计一个通讯协议

最近给 TKeyboard 增加了不少新功能,其中最有意义的部分是,通过蓝牙和 Wifi,配合 protobuf,在 Mac 和 iOS 之间建立了两条便捷的数据交换通道,算是打好了未来产品开发的地基,网络通道建立好了,后续的想象空间也大。

比如,现在可以将 iPhone 上 safari 正在浏览的网页,一键分享到 Mac 的 safari 上,比 handoff 更方便可靠。还可以在 Mac 的 safari 浏览网页时,如果发现的有趣的图片,一键保存到 iPhone 上。总之,两个端之间现在已经畅通无阻,文字,url,图片,等等各类数据可以随意分享,关键在于怎么去分析场景,因地制宜了。

在实现整个应用层通讯协议的过程中,觉得有些知识点可以分享,特写一篇文章和大家交流下。

首先大家可以尝试自己思考下,如果让你来设计两个端之间的通讯协议,你打算如何动手?这并不是个罕见的应用场景,现在不光是手游,不少移动端 App 都有需要 rpc 调用的场景。

应该会有一些同学会想到基于 http 的通讯框架,这也是现在不少 App 所采用的方案,App 端只需要定义好 url 中的 query string 或者是 body 体中的 json 格式,就可以实现一套应用层可用的通讯协议。

这确实是一个选择,这种选择我们可以归纳为 text based protocol,其好处是,http 已经是个偏向于应用层的非常成熟的通讯协议,一来一往,一个 request 对应一个 response,简单易用,http 本身已经做好了包的切割,包内部结构的定义等,应用层只需要从中取数据即可。但其缺点也明显,http 出于使用简单的考虑,将协议设计成一来一回,response 发完之后即断开请求,节省服务器资源,是一种无状态的协议,http 协议的使用者如果要做进一步的定制,需要采取一些奇技淫巧。而且基于 text 的协议,在协议解析的时候对于包里面的 text 内容都有严格限制。

大家都知道 http url 里面传入参数时,需要经过 url encode,其原因就是由于 http 是基于文本的协议,在解析 url 中的 query string 时,是以一些特殊的文本符号来做切割的,比如 ?,=,& 等。如果不把内容做 encode,就有可能导致协议解析出错。当然 http 协议的设计者替我们解决了这些问题,我们做为使用者可以高枕无忧。

http 方案另一个弊端在于流量上面,短连接每次重复握手,每个 request 里面有重复的 header 设置等,都会造成流量的浪费,这也是不少游戏客户端会建立自己的 socket 长连接通道的原因,自己设计基于流的通讯协议,这就是我们的另一个选择方案,stream based protocol。

TKeyboard 就是选择了基于 stream 的协议方案,CoreBlueTooth 上并没有现成的基于 http 的通讯方案,所以只能自己动手做个简单的设计,基于流的协议设计复杂度可大可小,端看应用场景。

工作量主要在于两方面,一是数据的序列化,即将我们平时所用的 model 转化为二进制流,其二是定义好包的格式,在通讯框架里做好包的切割,解析,和传递。最后简化调用流程,提供一套简单的类似 http 的双向数据调用 API 即可。

TKeyboard 的应用场景比较简单,所以我在设计上也做了不少简化,大致想大家介绍下思路。

第一个序列化的问题好解决,已有 google 的成熟方案 protobuf 可以使用,而且还有基于 Objective C 的版本,model 的序列化和反序列化,一个 API 调用即可完成。

第二个问题是包的格式定义。学习 TCP/IP 的意义在这里就能体现了,无论是 TCP 包还是 IP 包,都有自己的包格式定义,而且往往是一个 header 配合一个 payload(类似于 http 的 body)。之所以要有包,是因为二进制流只完成 stream 的传输,并不知道一次数据请求与相应的起始和结束,我们要预先定义好包结构才能做解析。这一步其实就是一个实现 RPC 调用的过程,关于 RPC 现在虽然还没有什么官方的协议通过,但是我们完全可以自己设计一个简单可用的协议版本。

要能实现包的准确切割,我们需要明确包的长度。所以必须在 header 中留一个字段,表达整个包(header + payload)由多少 bytes 构成,两个字节的长度就可以描述 0~65535 个字节数,具体使用多少个字节就看协议的使用场景了。

因为是 RPC 调用协议,所以包体里必须有调用的名称,即 API name 字段,这个 name 是可变长度,所以也需要将其长度信息加入包体中,原则上,所有可变长度的内容都需记录其精确的长度信息,否则无法做信息的切割。另外,调用方还需要知道包是请求的回应(response)还是另一端的通知(notify),所以我们还需要定义 call type 信息,这种信息一个 8 比特位的枚举量就绰绰有余了,这种固定长度的信息就不需要记录其长度信息了。

一般固定长度的信息我们放在 header 中,可变长度的信息我们则放入 payload 中,当然,我们 RPC 调用的具体参数(经由 protobuf 序列化之后的 stream)也是放入 payload 中,接收方接收以后,只需读取固定长度的字节,即可通过反序列化,再在接收方还原成具体的应用层数据。

做完这些,就差不多有一个简单的通讯协议雏形了。

当然这其中还有不少细节需要处理,比如 iOS 上基于 LEBT 的蓝牙方案,基于能耗考虑,发送方每次能发送的字节数是有限制的,据我测试,一个 Central 每次向 Pheripheral 发送数据的时候,最多只能写 512 个字节,所以 Central 端要做一个 send buffer,发送一次就去 buffer 中取 512 个字节,多了就会发送失败。Pheripheral 端接收数据也是一样,每次只能收到 20 个 bytes,需要设计一个 receive buffer,将数据全部缓存起来,每次收到数据就做一次解析,看数据量是否够一个最小包的大小,够就取出,不够就继续等待。

因为我们是在设计应用层协议,所以还需要考虑传输层是可靠还是不可靠,CoreBlueTooth 实际上既提供了类似于 TCP 的可靠传输(CBCharacteristicPropertyIndicate),也有类似于 UDP 的不可靠传输(CBCharacteristicPropertyNotify),不明白这一点,必然会踩坑。

还有不少其他问题要处理,比如数据的加解密,增加 CRC 做包的完整性校验,协议的版本控制,远程调用的超时机制等,这里就不一一赘述了,大家可以自己去尝试实现下,光说不练假把式,动过手,个中滋味自会明了。

最近所写的 TCP/IP 系列文章多是理论方面的知识,大家在学习理论的时候,最好能辅以代码上的实践,纸上得来终觉浅,抓住一切可能的机会多实践,才能加深理解。

全文完。

欢迎关注公众号:MrPeakTech