开源应用架构 (第二卷)
GPSD

埃里克·雷蒙德

GPSD 是一套用于管理 GPS 设备和与导航和精密计时相关的其他传感器(包括海洋 AIS(自动识别系统)无线电和数字罗盘)集合的工具。主程序是一个名为 gpsd 的服务守护进程,它管理传感器集合,并使来自所有传感器的报告以 JSON 对象流的形式在众所周知的 TCP/IP 端口上可用。套件中的其他程序包括可用作代码模型的演示客户端和各种诊断工具。

GPSD 广泛部署在笔记本电脑、智能手机和自动驾驶车辆(包括自动驾驶汽车和机器人潜艇)上。它用于用于导航、精准农业、位置敏感的科学遥测和网络时间服务的嵌入式系统。它甚至用于包括 M1“艾布拉姆斯”主战坦克在内的装甲战斗车辆的识别敌我系统。

GPSD 是一个中等规模的项目——大约 43 KLOC,主要使用 C 和 Python 编写——其在当前负责人领导下的历史可以追溯到 2005 年,而其前史可以追溯到 1997 年。核心团队一直稳定在约三名开发人员,大约二十多名开发人员定期贡献,以及来自数百人的其他零星补丁。

GPSD 从历史上看缺陷率极低,这一点既可以通过 splintvalgrind 和 Coverity 等审计工具衡量,也可以通过其跟踪器和其他地方的错误报告发生率衡量。这并非偶然;该项目非常积极地整合了自动化测试技术,并且这项工作取得了丰硕的成果。

GPSD 在其所做的事情上足够出色,以至于它已经吸收或有效地淘汰了所有其近似的先驱,以及至少一次直接与其竞争的尝试。2010 年,GPSD 赢得了代码卓越联盟颁发的首个优秀代码奖。在您完成本章时,您应该明白为什么。

7.1. GPSD 存在的原因

GPSD 的存在是因为 GPS 和其他与导航相关的传感器附带的应用协议设计糟糕,文档不完善,并且根据传感器类型和型号差异很大。请参阅 [Ray] 以获取详细讨论;特别是,您将在那里了解 NMEA 0183(GPS 报告数据包的某种标准)的反复无常以及与其竞争的大量文档不完善的供应商协议。

如果应用程序必须自己处理所有这些复杂性,结果将是大量的脆弱且重复的代码,导致用户可见的缺陷率很高,并且随着硬件逐渐发生变化,应用程序会不断出现问题。

GPSD 通过了解所有协议(在撰写本文时,我们支持大约 20 种不同的协议),管理串行和 USB 设备,以便应用程序不必这样做,并以简单的设备无关的 JSON 格式报告传感器有效载荷信息,从而将位置感知应用程序与硬件接口细节隔离开来。GPSD 通过提供客户端库进一步简化了生活,因此客户端应用程序甚至不必了解该报告格式。相反,获取传感器信息变成了一个简单的过程调用。

GPSD 还支持精密计时;如果其任何连接的传感器具有 PPS(每秒脉冲)功能,它可以充当 ntpd(网络时间协议守护进程)的时间源。GPSD 开发人员与 ntpd 项目紧密合作,以改进网络时间服务。

我们目前(2011 年年中)正在努力完成对海洋导航接收器 AIS 网络的支持。将来,我们预计将支持新型的位置感知传感器——例如第二代飞机转发器的接收器——因为协议文档和测试设备变得可用。

总而言之,GPSD 设计中最重要的主题是将所有依赖于设备的丑陋隐藏在一个简单的客户端接口后面,该接口与零配置服务进行通信。

7.2. 外部视图

GPSD 套件中的主程序是 gpsd 服务守护进程。它可以通过 RS232、USB、蓝牙、TCP/IP 和 UDP 链接收集连接的一组传感器设备的数据。报告通常发送到 TCP/IP 端口 2947,但也可以通过共享内存或 D-BUS 接口发送。

GPSD 发行版附带了 C、C++ 和 Python 的客户端库。它包括 C、C++、Python 和 PHP 的示例客户端。可以通过 CPAN 获得 Perl 客户端绑定。这些客户端库不仅方便应用程序开发人员;它们也为 GPSD 的开发人员节省了麻烦,因为它们将应用程序与 GPSD 的 JSON 报告协议的细节隔离开来。因此,即使协议为新的传感器类型添加了新功能,公开给客户端的 API 也可以保持不变。

套件中的其他程序包括一个用于低级设备监控的实用程序(gpsmon)、一个生成错误统计信息和设备计时报告的分析器(gpsprof)、一个用于调整设备设置的实用程序(gpsctl)以及一个用于将传感器日志批量转换为可读 JSON 的程序(gpsdecode)。总之,它们帮助技术精通的用户深入了解所连接传感器的运行情况。

当然,这些工具也有助于 GPSD 自己的开发人员验证 gpsd 的正确操作。最重要的测试工具是 gpsfake,这是一个用于 gpsd 的测试工具,它可以将其连接到任意数量的传感器日志,就好像它们是实时设备一样。使用 gpsfake,我们可以重新运行与错误报告一起提供的传感器日志以重现特定问题。gpsfake 也是我们广泛的回归测试套件的引擎,它通过简化发现破坏事物更改的过程来降低修改软件的成本。

我们认为我们对未来项目的最大教训之一是,软件套件不仅要正确,还应该能够证明其自身正确性。我们发现,当正确追求这一目标时,它不是一件苦差事,而是一对翅膀——我们花费在编写测试工具和回归测试上的时间已经通过它赋予我们的自由多次得到了回报,使我们能够修改代码而不用担心我们会对现有功能造成微妙的破坏。

7.3. 软件层

GPSD 内部发生的事件比“插入传感器即可使用”的体验可能让人们假设的要多得多。gpsd 的内部结构自然地分为四个部分:驱动程序数据包嗅探器核心库多路复用器。我们将从下往上描述这些部分。

图 7.1:软件层

驱动程序本质上是我们支持的每种传感器芯片组的用户空间设备驱动程序。关键入口点是将数据包解析为时间-位置-速度或状态信息、更改其模式或波特率、探测设备子类型等的方法。辅助方法可能支持驱动程序控制操作,例如更改设备的串行速度。驱动程序的整个接口是一个充满数据和方法指针的 C 结构,故意模仿 Unix 设备驱动程序结构。

数据包嗅探器负责从串行输入流中提取数据包。它基本上是一个状态机,它监视任何看起来像我们 20 多种已知数据包类型之一的内容(其中大多数都有校验和,因此当我们认为我们已经识别出一个数据包时,我们可以有很高的信心)。因为设备可以热插拔或更改模式,所以从串行或 USB 端口传出的数据包类型不一定由第一个被识别的数据包永久固定。

核心库管理与传感器设备的会话。关键入口点是

核心库的一个关键特性是它负责根据嗅探器返回的数据包类型切换每个 GPS 连接以使用正确的设备驱动程序。这不是预先配置的,并且可能会随着时间的推移而发生变化,特别是如果设备在不同的报告协议之间切换。(大多数 GPS 芯片组支持 NMEA 和一个或多个供应商二进制协议,并且像 AIS 接收器这样的设备可以在同一条线上以两种不同的协议报告数据包。)

最后,多路复用器是守护进程中处理客户端会话和设备分配的部分。它负责将报告传递给客户端、接受客户端命令以及响应热插拔通知。它基本上全部包含在一个源文件中 gpsd.c 中,并且从未直接与设备驱动程序通信。

前三个组件(多路复用器除外)链接在一个名为 libgpsd 的库中,可以单独使用,而不是与多路复用器一起使用。我们其他直接与传感器通信的工具,例如 gpsmongpsctl,都是通过直接调用核心库和驱动程序层来实现的。

最复杂的单个组件是数据包嗅探器,大约有 2000 行代码。这是不可避免的;能够识别多种不同协议的状态机必然会很大且复杂。幸运的是,数据包嗅探器也很容易隔离和测试;其中的问题往往不会与代码的其他部分耦合。

多路复用器层的大小大致相同,但稍微不那么复杂。设备驱动程序构成了守护进程代码的大部分,大约 15 KLOC。所有其他代码——所有支持工具、库和测试客户端加起来——大约与守护进程的大小相同(某些代码,特别是 JSON 解析器,在守护进程和客户端库之间共享)。

这种分层方法的成功通过几种不同的方式得到了证明。一种是新的设备驱动程序非常容易编写,以至于一些驱动程序是由核心团队之外的人员贡献的:驱动程序 API 有文档记录,并且各个驱动程序仅通过主设备类型表中的指针与核心库耦合。

另一个好处是,系统集成商可以通过选择不编译未使用的驱动程序来大幅减少嵌入式部署中 GPSD 的占用空间。守护进程本身并不大,并且经过适当剥离的构建版本可以在低功耗、低速度、小内存 ARM 设备上愉快地运行。(ARM 是一种用于移动和嵌入式电子设备的 32 位 RISC 指令集架构。请参阅 http://en.wikipedia.org/wiki/ARM_architecture。)

分层的第三个好处是,守护进程多路复用器可以从核心库的顶部分离出来,并替换为更简单的逻辑,例如 gpsdecode 实用程序执行的传感器日志文件到 JSON 报告的直接批量转换。

GPSD 架构的这部分并没有什么新颖之处。它告诉我们,在 Unix 设备处理的设计模式上,有意识且严格地应用它,不仅对 OS 内核有利,而且对用户空间程序也有利,因为这些程序也需要处理各种硬件和协议。

7.4. 数据流视图

现在,我们将从数据流的角度来考虑 GPSD 的架构。在正常操作中,gpsd 在一个循环中旋转,等待来自以下其中一个来源的输入

  1. 一组通过 TCP/IP 端口发出请求的客户端。
  2. 一组通过串口或 USB 设备连接的导航传感器。
  3. 热插拔脚本和一些配置工具使用的特殊控制套接字。
  4. 一组服务器发出周期性的差分 GPS 校正更新(DGPS 和 NTRIP)。这些被视为导航传感器处理。

当一个 USB 端口与可能为导航传感器的设备一起激活时,一个热插拔脚本(与 GPSD 一起提供)会向控制套接字发送通知。这是多路复用器层将设备添加到其内部传感器列表的提示。相反,设备移除事件可以从该列表中删除设备。

当客户端发出 watch 请求时,多路复用器层会打开其列表中的导航传感器并开始接收来自它们的数据(通过将其文件描述符添加到主 select 调用中的集合中)。否则,所有 GPS 设备都会关闭(但保留在列表中),守护进程处于静止状态。停止发送数据的设备会从设备列表中超时。

图 7.2:数据流

当来自导航传感器的数据进入时,它会被馈送到数据包嗅探器,这是一个类似于编译器中词法分析器的有限状态机。数据包嗅探器的工作是从每个端口(分别)累积数据,识别何时累积了一个已知类型的数据包。

数据包可能包含来自 GPS 的位置修正、海洋 AIS 数据报、来自磁罗盘的传感器读数、DGPS(差分 GPS)广播数据包或其他一些内容。数据包嗅探器不关心数据包的内容;它所做的只是在累积一个数据包时告诉核心库,并传回有效载荷和数据包类型。

然后,核心库将数据包传递给与其类型关联的驱动程序。驱动程序的工作是从数据包有效载荷中提取数据到每个设备的会话结构中,并设置一些状态位,告诉多路复用器层它获得了哪种数据。

其中一位表示守护进程已累积足够的数据以向其客户端发送报告。当在从传感器设备读取数据后引发此位时,这意味着我们已经看到了数据包的结尾、数据包组的结尾(可能是一个或多个数据包),并且设备会话结构中的数据应该传递给其中一个导出器。

主要的导出器是“socket”导出器;它生成一个 JSON 格式的报告对象,并将其发送到所有正在监视该设备的客户端。还有一个共享内存导出器,它将数据复制到共享内存段。在这两种情况下,都期望客户端库将数据反序列化到客户端程序内存空间中的结构中。还提供了一个第三个导出器,它通过 DBUS 发送位置更新。

GPSD 代码在水平方向上的划分与在垂直方向上的划分一样谨慎。数据包嗅探器既不知道也不需要知道任何关于数据包有效载荷的信息,也不关心其输入源是 USB 端口、RS232 设备、蓝牙无线电链路、伪终端、TCP 套接字连接还是 UDP 数据包流。驱动程序知道如何分析数据包有效载荷,但不知道数据包嗅探器内部或导出器。导出器只查看驱动程序更新的会话数据结构。

这种功能分离对 GPSD 非常有益。例如,当我们在 2010 年初收到一个请求,要求调整代码以接受作为机器人潜艇机载导航系统 UDP 数据包形式传入的传感器数据时,我们很容易用几行代码实现了这一点,而不会干扰数据管道后面的阶段。

更一般地说,仔细的分层和模块化使添加新的传感器类型变得相对容易。我们每六个月左右就会合并新的驱动程序;有些是由非核心开发人员编写的。

7.5. 保卫架构

gpsd 这样的开源程序在发展过程中,一个反复出现的主题是,每个贡献者都会做一些事情来解决他或她特定的问题案例,这些案例会逐渐在最初设计时具有清晰分离的层或阶段之间泄漏更多信息。

我们在撰写本文时关注的一个问题是,输入源类型(USB、RS232、pty、蓝牙、TCP、UDP)的一些信息似乎需要传递到多路复用器层,例如,告诉它是否应该向未识别的设备发送探测字符串。此类探测有时需要唤醒 RS232C 传感器,但有充分的理由不向超过必要数量的设备发送它们。许多 GPS 和其他传感器设备的设计预算低且仓促;一些设备可能会因意外的控制字符串而变得混乱到僵化的程度。

出于类似的原因,守护进程有一个 -b 选项,可以防止它在数据包嗅探器搜索循环期间尝试更改波特率。一些制作粗糙的蓝牙设备对此处理得非常糟糕,以至于必须重新启动才能再次工作;在一个极端案例中,用户实际上不得不拆焊备用电池才能将其解除!

这两种情况都是项目设计规则的必要例外。不过,通常情况下,此类例外情况都是不好的。例如,我们收到了一些贡献的补丁,以使 PPS 时间服务更好地工作,这些补丁破坏了垂直分层,使得 PPS 无法与他们旨在帮助的以外的一个驱动程序一起正常工作。我们拒绝了这些补丁,转而更加努力地进行与设备类型无关的改进。

几年前的一次,我们收到了一个请求,要求支持一个 GPS,它具有一个奇怪的特性,即当设备没有位置修正时,其 NMEA 数据包中的校验和可能无效。为了支持此设备,我们必须要么 (a) 放弃验证任何看起来像 NMEA 数据包的传入数据的校验和,从而冒着数据包嗅探器将垃圾传递给 NMEA 驱动程序的风险,要么 (b) 添加一个命令行选项以强制传感器类型。

项目负责人(本章作者)拒绝执行任何一项操作。放弃 NMEA 数据包验证显然是一个坏主意。但切换到强制传感器类型将是懒于进行正确自动配置的邀请,这会导致从 GPSD 的客户端应用程序到其用户的各种问题。沿着这条铺满良好意图的道路走下去的下一步,肯定是在波特率开关上。相反,我们拒绝支持此损坏的设备。

项目首席架构师最重要的职责之一是保护架构免受权宜之计的“修复”的影响,这些修复会破坏架构并导致未来的功能问题或严重的维护难题。关于此的争论可能会变得非常激烈,尤其是在保护架构免受开发人员或用户认为是必备功能的影响时。但这些争论是必要的,因为最简单的选择通常对长期而言是错误的选择。

7.6. 零配置,零烦恼

gpsd 的一个极其重要的特性是它是一个零配置服务(对于固件有问题的蓝牙设备有一个小例外)。它没有点文件!守护进程通过嗅探传入数据来推断它正在与之通信的传感器类型。对于 RS232 和 USB 设备,gpsd 甚至可以自动设置波特率(即自动检测串行线速度),因此守护进程无需预先知道传感器以何种速度/奇偶校验/停止位发送信息。

当主机操作系统具有热插拔功能时,热插拔脚本可以将设备激活和停用消息发送到控制套接字,以通知守护进程其环境的变化。GPSD 发行版为 Linux 提供了这些脚本。结果是,最终用户可以将 USB GPS 插入他们的笔记本电脑,并期望它立即开始提供位置感知应用程序可以读取的报告——无需任何麻烦,无需任何操作,也无需编辑点文件或首选项注册表。

此带来的好处会一直向上扩展到应用程序堆栈。其中包括,这意味着位置感知应用程序不必具有专门用于调整 GPS 和端口设置的配置面板,直到整个混乱得到解决。这为应用程序编写者和用户节省了很多工作:他们可以将位置视为一项服务,就像系统时钟一样简单。

零配置理念的一个结果是,我们不赞成添加配置文件或其他命令行选项的提议。问题在于,可以编辑的配置,*必须*被编辑。这意味着为最终用户增加了设置麻烦,而这正是设计良好的服务守护进程应该避免的。

GPSD 开发人员是来自 Unix 传统深处的 Unix 黑客,在 Unix 传统中,可配置性和拥有大量旋钮几乎是一种宗教。然而,我们认为开源项目可以更加努力地抛弃它们的点文件并自动配置到正在运行的环境实际正在执行的操作。

7.7. 嵌入式约束被认为是有帮助的

自 2005 年以来,为嵌入式部署设计一直是 GPSD 的主要目标。最初是因为我们收到了来自使用单板计算机的系统集成商的大量兴趣,但后来它以意想不到的方式得到了回报:在支持 GPS 的智能手机上部署。(不过,我们最喜欢的嵌入式部署报告仍然来自机器人潜艇。)

为嵌入式部署设计影响了 GPSD 的重要方面。我们考虑了很多方法来保持内存占用和 CPU 使用率低,以便代码能够在低速、小内存、受电源限制的系统上良好运行。

解决此问题的一个重要方法,如前所述,是确保 gpsd 构建不必携带任何超出系统集成商需要支持的特定传感器协议集的额外负担。在 2011 年 6 月,在 x86 系统上进行的 gpsd 最小静态构建的内存占用约为 69K(即*包含*所有必需的标准 C 库链接)在 64 位 x86 上。相比之下,包含所有驱动程序的静态构建约为 418K。

另一种方法是我们以与大多数项目略有不同的侧重点对 CPU 热点进行分析。因为位置传感器倾向于以 1 秒左右的间隔仅报告少量数据,所以在正常意义上,性能不是 GPSD 的问题——即使效率极低的代码也不太可能引入足够的延迟以在应用程序级别可见。相反,我们的重点是降低处理器使用率和功耗。我们在这方面取得了相当大的成功:即使在没有 FPU 的低功耗 ARM 系统上,gpsd 的 CPU 使用率也降到了探查器噪声水平。

虽然目前为低占用空间和良好的功耗效率设计核心代码在很大程度上已经是一个解决的问题,但有一个方面,针对嵌入式部署的目标仍然会在 GPSD 架构中产生紧张关系:使用脚本语言。一方面,我们希望通过尽可能多地将代码从 C 中移出,来最大程度地减少由于低级资源管理导致的缺陷。另一方面,Python(我们首选的脚本语言)对于大多数嵌入式部署来说,实在太笨重且速度太慢。

我们以显而易见的方式进行了权衡:gpsd服务守护进程使用 C 语言编写,而测试框架和一些辅助实用程序则使用 Python 编写。随着时间的推移,我们希望将更多辅助代码从 C 迁移到 Python,但嵌入式部署使这些选择持续成为争议和困扰的来源。

尽管如此,总的来说,我们发现嵌入式部署带来的压力相当令人振奋。编写精简、紧凑且节省处理器资源的代码感觉很好。有人说,艺术源于约束下的创造力;如果这是真的,那么 GPSD 因为这种压力而变得更加艺术化。

这种感受并不能直接转化为对其他项目的建议,但另一点却绝对可以:不要猜测,要测量!没有什么比定期分析和测量占用空间更能警告你何时偏离了引入膨胀的道路——并让你确信你没有偏离。

7.8. JSON 和架构师

项目历史上最重要的转变之一是,我们从最初的报告协议切换到使用 JSON 作为元协议,并将报告作为 JSON 对象传递给客户端。最初的协议使用单个字母作为命令和响应的键,随着守护进程功能的逐渐增强,我们实际上用光了键空间。

切换到 JSON 取得了巨大的成功。JSON 将传统 Unix 纯文本格式的优点——易于用肉眼检查、易于使用标准工具编辑、易于程序化生成——与以丰富灵活的方式传递结构化信息的能力结合在一起。

通过将报告类型映射到 JSON 对象,我们确保任何报告都可以包含具有结构的字符串、数字和布尔数据混合(这是旧协议缺乏的功能)。通过使用"class"属性标识报告类型,我们保证了始终能够添加新的报告类型而不会影响旧的报告类型。

这个决定并非没有代价。JSON 解析器在计算上比它所取代的非常简单且有限的解析器稍微昂贵一些,并且肯定需要更多的代码行(这意味着出现缺陷的可能性更多)。此外,传统的 JSON 解析器需要动态存储分配才能处理 JSON 描述的可变长度数组和字典,而动态存储分配是臭名昭著的缺陷诱因。

我们通过多种方式解决了这些问题。第一步是为 JSON 的(足够大的)子集编写一个 C 解析器,该解析器完全使用静态存储。这需要接受一些小的限制;例如,我们方言中的对象不能包含 JSON 的null值,并且数组始终具有固定的最大长度。接受这些限制使我们能够将解析器放入 600 行 C 代码中。

然后,我们为解析器构建了一套全面的单元测试,以验证其无错误操作。最后,对于 JSON 开销可能过高的非常紧凑的嵌入式部署,我们编写了一个共享内存导出器,如果守护进程及其客户端可以访问公共内存,则完全绕过发送和解析 JSON 的需要。

JSON 不再仅仅用于 Web 应用程序。我们认为任何设计应用程序协议的人都应该考虑类似于 GPSD 的方法。当然,在标准元协议之上构建协议的想法并不新鲜;XML 粉丝多年来一直在推动它,对于具有文档式结构的协议来说,这是有意义的。JSON 具有比 XML 更低的开销以及更适合传递数组和记录结构的优点。

7.9. 设计零缺陷

由于 GPS 或其他位置传感器在导航系统中的使用,任何位于用户和 GPS 或其他位置传感器之间的软件都可能对生命至关重要,尤其是在海上或空中。开源导航软件往往试图通过附带免责声明来规避这个问题,声明“如果依赖它可能会危及生命,请勿依赖它”。

我们认为此类免责声明既无用又危险:无用是因为系统集成商很可能将其视为形式上的并忽略它们,危险是因为它们鼓励开发人员自欺欺人地认为代码缺陷不会造成严重后果,并且在质量保证方面偷工减料是可以接受的。

GPSD 项目开发人员认为,唯一可接受的策略是设计零缺陷。鉴于软件的复杂性,我们还没有完全实现这一点——但对于 GPSD 这样规模、年龄和复杂度的项目来说,我们已经非常接近了。

我们实现这一目标的策略是结合架构和编码策略,旨在排除发布代码中出现缺陷的可能性

一项重要的策略是:gpsd守护进程绝不使用动态存储分配——没有malloccalloc,也没有对任何需要它的函数或库的调用。此禁令立即消除了 C 编码中最臭名昭著的缺陷诱因。我们没有内存泄漏,也没有双重分配或双重释放错误,而且将来也不会出现。

我们之所以能够做到这一点,是因为我们处理的所有传感器都发出具有相对较小的固定最大长度的数据包,并且守护进程的工作是消化这些数据包并将其发送到客户端,同时进行最少的缓冲。尽管如此,禁止malloc需要编码纪律和一些设计折衷方案,我们之前在讨论 JSON 解析器时已经注意到其中的一些。我们心甘情愿地付出这些代价以降低缺陷率。

此策略的一个有用的副作用是它提高了静态代码检查器(如splintcppcheck和 Coverity)的有效性。这促使了另一个主要的策略选择;我们非常大量地使用这些代码审计工具和一个自定义的回归测试框架。(我们不知道有任何比 GPSD 更大的程序套件是完全使用splint注释的,并且强烈怀疑目前还没有这样的套件存在。)

GPSD 的高度模块化架构也帮助了我们。模块边界充当我们可以搭建测试工具的切入点,并且我们已经非常系统地做到了这一点。我们的常规回归测试检查从主机硬件的浮点数行为到 JSON 解析再到超过 70 种不同传感器日志上的正确报告行为的所有内容。

诚然,我们比许多应用程序更容易做到严格,因为守护进程没有面向用户的界面;它周围的环境只是一堆串行数据流,并且相对容易模拟。尽管如此,与禁止malloc一样,真正利用这种优势需要正确的态度,这具体意味着愿意在测试工具和工具上花费与生产代码一样多的设计和编码时间。我们认为其他开源项目可以也应该效仿这项策略。

在我撰写本文时(2011 年 7 月),GPSD 的项目错误跟踪器是空的。它已经空了几周了,根据过去提交错误的比率,我们可以预期它在未来很长一段时间内都会保持这种状态。我们六年来没有发布包含崩溃错误的代码。当我们确实遇到错误时,它们往往是那种在几分钟内就能轻松修复的次要缺失功能或与规范不符的问题。

这并不是说该项目一直都是一帆风顺的。接下来,我们将回顾我们的一些错误……

7.10. 经验教训

软件设计很困难;错误和死胡同是其中非常正常的一部分,GPSD 也不例外。该项目历史上最大的错误是最初用于请求和报告 GPS 信息的 JSON 之前的协议的设计。从错误中恢复花费了数年的努力,并且在最初的错误设计和恢复过程中都有教训。

最初的协议存在两个严重问题

  1. 可扩展性差。它使用由单个字母组成的请求和响应标签,不区分大小写。因此,例如,报告经度和纬度的请求为"P",响应类似于"P -75.32 40.05"。此外,解析器将类似于"PA"的请求解释为"P"请求后跟"A"(高度)请求。随着守护进程功能的逐渐扩展,我们实际上用光了命令空间。
  2. 协议隐式传感器行为模型与实际传感器行为之间的不匹配。旧协议是请求/响应:发送位置(或高度或其他任何内容)的请求,稍后获得报告。实际上,通常无法从 GPS 或其他与导航相关的传感器请求报告;它们会流式传输报告,请求能做的最好的事情就是查询缓存。这种不匹配鼓励应用程序进行草率的数据处理;它们经常在请求位置数据时不请求时间戳或任何关于定位质量的检查信息,这种做法很容易导致陈旧或无效的数据呈现给用户。

早在 2006 年,我们就发现旧协议设计不充分,但花了将近三年的时间进行设计草图和尝试错误才设计出新的协议。此后过渡又花了 2 年时间,给客户端应用程序的开发人员带来了一些痛苦。如果该项目没有发布客户端库来将用户与大多数协议细节隔离开来,那么代价会更高——但我们一开始也没有完全正确地设计这些库的 API。

如果我们当时知道现在所知道的事情,基于 JSON 的协议将在五年前引入,并且客户端库的 API 设计将需要更少的修订。但有些教训只有经验和实验才能教会我们。

未来服务守护进程可以牢记至少两个设计指南,以避免重蹈覆辙

  1. 设计可扩展性。如果您的守护进程的应用程序协议像我们的旧协议那样耗尽命名空间,那么您的设计就是错误的。高估 XML 和 JSON 等元协议的短期成本并低估其长期效益是一个非常常见的错误。
  2. 客户端库比公开应用程序协议细节更好。库可能能够将其内部适应应用程序协议的多个版本,与另一种方法相比,大大降低了接口复杂性和缺陷率,在这种方法中,每个应用程序编写者都需要开发一个临时绑定。这种差异将直接转化为项目跟踪器上的更少的错误报告。

对我们强调可扩展性的一种可能的回应,不仅在 GPSD 的应用程序协议中,而且在项目架构的其他方面(如数据包驱动程序接口)中,是将其斥为由任务蔓延带来的过度复杂化。接受“做好一件事”传统熏陶的 Unix 程序员可能会问,为什么 gpsd 的命令集在 2011 年真的需要比 2006 年更大,为什么 gpsd 现在处理非 GPS 传感器(如磁罗盘和海上 AIS 接收器),以及为什么我们考虑 ADS-B 飞机跟踪等可能性。

这些都是合理的问题。我们可以通过观察添加新设备类型带来的实际复杂性成本来寻找答案。出于一些非常好的原因,包括相对较低的数据量以及历史上与传感器串行线相关的较高电噪声水平,几乎所有 GPS 和其他导航相关传感器的报告协议都看起来大体相似:带有某种校验和的小数据包。此类协议处理起来比较麻烦,但实际上并不难区分和解析,并且添加新协议的增量成本往往小于 1KLOC。即使是我们支持的最复杂的协议(例如附带自身报告生成器的海事 AIS),每个协议的成本也仅约为 3 KLOC。总的来说,驱动程序加上数据包嗅探器及其关联的 JSON 报告生成器总共约为 18 KLOC。

将此与整个项目的 43 KLOC 进行比较,我们可以看到,GPSD 的大部分复杂性成本实际上在于驱动程序周围的框架代码——以及(重要的是)在用于验证守护进程正确性的测试工具和框架中。复制这些将是一个比编写任何单个数据包解析器更大的项目。因此,为 GPSD 未处理的数据包协议编写一个等效于 GPSD 的程序,将比向 GPSD 本身添加另一个驱动程序和测试集要困难得多。相反,最经济的结果(以及预期缺陷累积率最低的结果)是 GPSD 为许多不同的传感器类型增加数据包驱动程序。

GPSD 已经发展得很好地处理的“一件事”就是处理任何发送可区分校验和数据包的传感器集合。看似功能蔓延实际上防止了必须编写许多不同的重复处理守护程序。相反,应用程序开发人员获得了一个相对简单的 API,并受益于我们在越来越多的传感器类型设计和测试方面积累的宝贵经验。

将 GPSD 与仅仅是功能蔓延的一堆特性区分开来的,不是运气或魔法,而是精心应用软件工程中已知的最佳实践。这些带来的回报首先体现在当前的低缺陷率,并持续体现在能够轻松支持新功能,并且预计不会影响未来的缺陷率。

也许我们对其他开源项目最重要的教训是:将缺陷率渐近地降低到零是困难的,但并非不可能——即使对于像 GPSD 这样广泛且多种部署的项目也是如此。合理的架构、良好的编码实践以及真正专注于测试可以实现这一点——最重要的前提是坚持这三点。