我用十年给了它跌宕起伏的一生,回顾一个软件的十年技术变迁【让编程再次伟大#51】 Peter Pang 2026-01-09

早期探索:浏览器驱动的“Make it Work”

10年有多长?相信在座的观众大部分工作时间还不到10年,应该很难想象陪伴一个项目走过10年吧。我在2016年启动了一个新项目,到今天10岁了。它是一个物联网系统(IoT system),包含了部署在服务器上的云端系统(Cloud system)和安装在设备上的终端系统(Terminal system)。云端系统的设计其实没啥很特别的,什么异步架构(Asynchronous architecture)、GraphQL、PostgreSQL,都是我在之前视频里分享过的技术栈,这里就不再重复了。本期视频要回顾的是终端系统。在这十年里,它几乎每年都经历一次掀桌子级别的更新迭代,而它这跌宕起伏的十年,也见证了软件技术这十年的发展。对那些刚步入职场,还没有多少经验的萌新,你也能提前感受一下以年为单位长期运营的软件系统会遇到什么样的挑战。

这个终端系统其实只需要做一件很简单的事情:把云端发过来的素材显示在屏幕上。这里最难搞的是显示,因为它涉及到对UI的控制。所以2016年立项时,我遵循了“3M原则”中的“Make it Work”,寻找最简单的解决方案。那么有什么东西,可以很轻松地把文字、图片、视频等素材,按照指定的尺寸和比例排列组合在一起,同时显示在屏幕上呢?答案自然就是“浏览器”了。在云端系统上储存素材,然后生成一个HTML页面,把素材按照要求缝合起来,最后把这个网页的地址发送到终端,终端只需要调用浏览器,全屏打开这个网址,任务就完成了。够简单了吧。

所以在一开始,整个终端系统上就只有两个功能模块:一个是用来接收云端指令的Consumer,基于我最喜欢的消息队列(Message queue)框架RabbitMQ,它也是最经受住考验的框架,因为在接下来的10年里,它一直稳如泰山,没有出过任何问题。而另一个模块,就是控制浏览器的Controller,用到的是当时最成熟的浏览器自动化框架(Browser automation framework)Selenium。虽然Controller唯一的工作就是打开网址,但有了自动化框架之后,我们就能做一些更复杂的操作,比如说收到新的网址时,我们可以先在另一个Tab里把它打开,等它渲染结束后再切换到前台,这样就能避免画面出现长时间的loading白屏。从性能上来说,这不是最优解,毕竟浏览器自己就是一个不小的overhead。但在项目初期,性能不重要,重要的是能够尽快达成“把东西显示在屏幕上”这个终极目标。做到了这个,我们才有下一步可言。

架构演进:容器化与语言迁移

到了17年,我们陆陆续续地增加了很多模块,比如把素材文件提前下载到本地缓存的Cacher,记录系统运行状态的Conditioner,截取当前屏幕画面的Capturer,收集各种数据往云端回传的Collector,等等。它们之间会存在各种形态的依赖,有些还会通过消息队列进行异步交流。那么对这些模块的管理,就需要有一个更完善的机制了。恰好在那个时间,我对Docker容器技术(Container technology)【让编程再次伟大#26】的三年考察期刚刚结束。Docker提供了在单一主机上管理、调度和监控多个容器的能力,正是我需要的那种控制中心。所以我毫不犹豫地决定把所有模块都打包成容器镜像,让Docker统一管理。至于它带来的额外负担,就像浏览器一样,也是在项目初期可以接受的。即使在短短两年后,它就成为了要消灭的瓶颈,我也没有后悔在当初选择了Docker。别的不说,它至少给我带来了容器技术的实战经验,这样我在后来的项目里都敢大胆地使用容器架构、容器集群,并且获得了很大的成功。

因为在项目开始之初第一个选择的框架是Selenium,用的语言是Java,所以就着技术栈越少越好的原则【让编程再次伟大#2】,后面增加其它模块时,也是优先选择Java工具库,用Java来写。但这显然不是一个特别好的决定,因为大部分的应用逻辑要么是跟UI有关,要么是跟OS有关,这些在JavaScript和Bash上面都比Java做起来更轻松,毕竟Java Applets已经退位很多年了。要换掉它的最大问题是找不到Selenium的替代品,而我也不想同时维护Java模块和JavaScript模块。转折就发生在18年,Google发布了一个全新的浏览器自动化框架Puppeteer。这个Node.js框架原生态兼容了Chrome浏览器,无论是功能还是性能都能够吊打Selenium,这正合我的意。所以用Puppeteer加Node.js替换掉Selenium加Java,就是名正言顺的事情了。把Java代码全部用Node.js重写,听起来好像是很大的动静,其实并没有,因为复杂的工作大多都是工具库在做,而我们的代码其实有点像个搬运工,在数据库和工具库之间把数据搬来搬去。所以整个重写里最大的挑战,就是找到合适的Node.js工具库去替换现有的Java工具库。同样的事情在7年后会再次发生,也再次体现了,一个编程语言的生态才是它最大的财富。

解耦与原生:摆脱中心化束缚

时间来到19年,Docker成为整个系统的瓶颈,但并不是因为性能问题,毕竟在终端上不存在什么高并发、低延迟的需求,只要能正常跑起来,不把资源吃光就行了。问题出在和硬件的交流上。我们的终端系统在迭代中,开始越来越多地跟硬件打交道,而管理硬件的模块都是和底层OS深度绑定的,比如说 DRM/KMS、X11 这些显示控制模块,蓝牙模组bluez,控制时钟的RTC,等等。理论上你可以在容器里操作这些模块,但这需要很复杂的权限配置,尤其是涉及到显示会话(Display session)的,我们还得手动绕开一些安全机制。比如在容器外写一个小的程序负责操作屏幕,并且提供一个独立接口,容器内再通过socket调用这个特殊的接口,就得绕一大圈。到了这个时候,Docker带来的麻烦已经远远超过了它所带来的价值,毕竟它的核心价值——什么沙箱环境、标准化镜像、弹性扩容——这些我们都用不上。所以当前任务就是找到一个更轻量的模块管理框架,而答案其实就在眼前,那就是OS自己在用的systemd。这个时候距离systemd的八卦【让编程再次伟大#17】已经过去好几年了,Linux社区那些老顽固也都接受了它的存在,所有主流Linux发行版都已经默认使用systemd。所以我甚至都不需要加上什么安装步骤。

把代码从Docker迁移到systemd的工作也是相当轻松的。因为当初我留了一手,所有的容器相关代码都是Dockerfile、Compose file这类静态的配置文件,没有用任何的Docker API或者是SDK写动态逻辑。而systemd本身也是通过静态配置文件来控制的,所以迁移工作就只是将一个格式的文本转化成另一个格式的文本,非常直接。这次迁移还顺便让终端系统瘦身了不少,因为模块之间有很多共用的工具库,原本打包成为容器时,大家都得复制一份到自己的容器里,现在大家都在同一个文件系统里,直接共享就行了。

时间来到20年,用浏览器承载一切素材的架构开始变得吃力。一是因为素材的种类在持续扩大,比如滚动文字源、动态二维码、视频直播源,这些在网页上实现都需要先嵌入各种工具包,叠加起来会让这个网页越来越臃肿,运行起来也越来越迟缓。二是因为Chrome开始滥用垄断地位随便改规则,浏览器的功能和权限范围都变得非常不稳定,谁也料不到明天醒来收到的更新,又少了什么接口,又多了什么弹窗。为了摆脱这个局面,我选择了“去中心化”,就是不再把所有素材都塞到浏览器里,而是用OS上面的原生程序一对一控制。比如视频和直播就用VLC播放器,图片就用OS自带的相册。这里最大的挑战是:原生程序需要用命令行去控制,我们就得搞清楚有多少命令可以选,比如VLC就有接近2000个可以设置的参数,至今我都还没有搞懂其中的1/10。好消息是,砍掉内存吞噬者Chrome之后,终端上的内存从岌岌可危瞬间变得游刃有余,卡顿和延迟问题也立即灰飞烟灭。但我并不后悔当初选择浏览器为中心的架构方案,因为这在早期解放了我们,让我们能够把精力投入到完善整个系统、增加辅助模块、打磨硬件控制,等等。可以说站岗了5年,没闹出什么大问题,浏览器也算完美的完成了它的使命。

定制OS与语言革新

到了21年,优化的脚步终于来到OS面前。这六年来,我们看的这个Ubuntu系统从v14一路升级到v20,每次版本升级都是痛苦的。用过Ubuntu的应该都懂,一次更新往往会有几百上千个改动,我们根本没有时间去一一确认它们是否会对模块造成影响,只能先接受,等哪出了bug再去找根源。这个问题本质上和前面的浏览器是一样的:不可控的更新带来了不可控的风险。所以解决方法也一样:摆脱这些不可控的更新。我决定不再用现成的Ubuntu,而是从Linux内核开始,打包一个只属于自己的定制OS(Custom OS)。这样就能把主动权彻底掌握在自己手上。原本是要在Ubuntu上面做减法,而且只能够在runtime实时修改,还得防着官方的更新。现在则是在内核上做加法,要什么就加什么,而且在build的时候就已经配置好了,开箱即用。比如在很多模块里有不少代码是用来确认环境状态的,包括确认一些系统配置是否正确,一些系统功能是否已经启动。因为如果这些依赖有问题,模块就没办法正常运行了。现在这些代码全部都可以拿掉,因为OS是自己打包的,前置条件百分百正确,应用层只管用就行,让应用逻辑和系统逻辑进行解耦,也大大降低了不必要的复杂度。

在整个终端系统被从头到脚优化一遍之后,我也没能再找到什么新的突破口。就这样小修小补了4年,直到2025年,出现了上期视频提到的用Rust重写Node.js的方案。换掉Node.js其实是必然发生的事情。自从把Java换成Node.js已经过去了7年,对于迭代速度极快的前端技术,7年足以见证几个大时代的起起落落了。我们用到的很多Node.js工具库,要么因为跟不上技术的演变,已经被开发者弃坑,要么因为演变得太激烈,全是breaking change,导致我们跟不上,只能忍痛锁定版号。就这样下去的话,很快我们用到的大多数工具库都会凝固在某个版本上,让我们动弹不得。而在20年拿掉浏览器为中心的架构之后,JavaScript其实也没有什么必须存在的理由了。所以我决定用一个更稳重一点的语言来换掉它,并且趁这个机会,自我实现一些核心功能,摆脱对外部工具库的依赖。而之所以选择Rust作为那个替代品,纯属巧合,就像17年的Docker那样,我刚好完成了对它的三年考核,然后刚好想要找一个实战机会把它用一用。于是就这么天时地利人和,Rust赶上了这个项目。因为这个重启还在进行中,我就不做什么总结了,等下一个10年的回顾视频吧。(假装这里有一个2036年的视频预约)

十年回顾:技术选择的智慧

回顾这个系统十年的演化,可以说是一个非常经典的“3M原则”的案例。不要想着一步到位,因为技术一直在变,人的认知也一直在变。与其想半天下不了手,不如先动手把今年的想法做出来,至于等到明年它是不是最优解,无所谓,那是明年的你需要关心的事情。用伟人的一句名言来总结就是:一个技术的命运,固然要靠自身的水平,但也要考虑到历史的进程 🤓

📌 文中提及的人物和组织

公司/组织: Google

产品/模型: RabbitMQ, Selenium, Docker, Puppeteer, Node.js, systemd, Chrome, VLC, Ubuntu, Rust

关键字: software-evolution iot-systems system-architecture development-lifecycle technical-debt