引言

要解决的问题(背景/目的)

我最近在尝试基于 C++ STL 和一个叫做 Armadillo 的 C++ 模板库实现光线追踪算法,简单来说光线追踪算法的输入是一些几何对象的参数以及摄影机的参数,输出是图像,此算法可以应用于离线渲染,更通俗地说,假如我们已经知道了在一个三维空间中有哪些物体,我们想让计算机把这些物体在人眼中的视觉效果预览出来(画出来),就可以用到光线追踪算法。

光追算法运行时间一般都比较长,快则几分钟,慢则数小时以上(也可能是我的核心代码和算法逻辑优化得不够好),所以我们希望能够在光追算法运行的过程中,查看当前的渲染效果,于是我又基于 SwiftUI 和 XCode SDK 开发了一个运行在 macOS 上的桌面软件,这个桌面软件以某种方式和跑光追算法的那个进程进行通信,然后把图像画出来。

那么这时我们需要解决“前端(那个桌面软件)和后端(那个跑光追算法的进程)如何通信**”**的问题。

技术选型过程和遇到的问题 (concerns)

现成的方案其实有很多种:HTTP, WebSocket, gRPC 等,但是我一开始觉得用 C++ 做一个 HTTP/WebSocket Server 有些麻烦(哪怕是调库),用 gRPC 又太重了,所以我就选择用基于裸 POSIX Socket API 实现的 TCP 连接来进行前后端之间的通信。

然后直接用 TCP 进行通信有一些问题,首先,在代码层面,一个建立好的 TCP 连接是一个“流”(Stream),而且是一个无边界的流(在连接关闭之前),就好像一根石油管道,你只知道它什么时候会有东西从里面冒出来,或许也可以往里面输入东西,但是流本身没有“传输完成”的这个概念。

其次数据直接通过 TCP 连接进行传输,数据每次到达的长度是不确定的,举例来说,当你 recv 一个对应于 TCP 连接的 socket 时,有可能拿到 10021 bytes, 也有可能拿到 3000 bytes, 如果你只拿到了你要传输的数据的一半,那你还得继续 recv, 直到拼凑出完整的协议帧,才能向上层交付。

同样,对于写(write 函数调用)也是一样,不一定一次 write 调用就能把消息完整的发给另一端,有可能只发了一半或者三分之一等等,如果只发了一部分,你还要继续发送剩余的那些,直到完整地发送完一个请求或者响应协议帧。

这里我们说的“协议帧”相当于 HTTP 中的一个 Message 或者 WebSocket 中的一个 Frame, 它是我们自己设计的应用层协议的一个基本传输单元(最小通信单元),对于连接的两端中的每一端,都是至少要收到这样一段完整的数据,才能进行对其内部的状态机进行迭代(此即最小通信单元中“最小”的含义)。

因此,如果我们要应用自己设计的应用层协议进行两端的通信,那么就要处理好以下三个方面的 concens:

一是在接收时如何根据一次或多次收到的消息拼接成完整的协议帧交付给上层(应用层或者业务逻辑层);

二是在发送时如何把上层(业务逻辑层)要发送的协议帧打散成字节序列然后确保这些字节被完整地发送出去;

三是在以上发送、接收的过程中遇到任何问题时,自己处理问题,或者,处理不了的就把问题抛出来交给上层处理。

给定一个设计好的应用层通信协议,如果以上三点做到了,我们就说它是实现了,这是本文中我们说实现了 xx 协议的“实现”的定义。

协议的设计

与市面上前后端通信惯常采用的“通过 HTTP, WebSocket 来收/发 JSON 文本加上通过标准化的 JSON Parser/Serializer 来反序列化/序列化“方案不同,我们采样二进制协议的方案,No JSON, No XML, No YAML, not plaintext. 因为,我认为解析/序列化纯文本的过程对于计算密集型的应用来说是一个 burden, CPU 本来就很忙,现在还要挤出本来就不多的时间用来解析/序列化要收/发的 JSON,显然是不合理的。

在开始设计之前,首先,为什么是二进制的 (Why binary?)