QIAOPANG.COM · 陈天(TYR CHEN)2013–2026 精选技术与随笔文集 · 共 260 篇按时间顺序浏览(2013 → 2026)↗
程序人生 · 陈天文集QiaoPang · Systems, Craft & Agentic Era
时间轴 ↗

再谈抽象

再谈抽象

16 年我写过一篇文章,抽象的能力,没有读过的同学可以再看看。那文章算是一个引子,举了不少实战中对代码和对需求进行抽象的小李子。大家重点注意一下最后一个例子 —— feed parser 的抽象。feed 是好莱坞 studio 给我们提供的可供播放的短视频的清单,每家 studio 都有自己的 feed,结构上大同小异,细节上差别不小:格式上,有 xml 和 json 的区别,内容上,同为标题,有的 key 名是 title,有的是 name,同为视频的 url,有的放在顶层,有的缩在内部。之前我接手的时候,那是几十个 php 文件组成的工程,每个 php 文件对应一个 feed 的处理。因为 feed 细节上的各种差异,我很能理解前任的处理方式 —— 这样的差异在代码层级上并不好抽象,况且一个一个的处理 feed 的需求在不同时段到来,工程师在压力面前,往往只能着眼于眼前 —— 和产品出身的少爷少奶奶不同,我们光是活着完成需求就已经竭尽全力了呢。

最近我在升级一个不那么紧急的项目(merlin,见我去年的文章:停下来,歇口气,造轮子)。对于需求的种种思考,让我我回想起那篇文章。我现在愈来愈感到:代码上的对抽象的精益求精,或者说实现细节这个层次的抽象,固然非常重要,但对需求的抽象才是王道。前者是术,后者是道。二者的关系大家可以类比参考 agile manifesto。

同一个需求,有的人看到的是一颗星星,有的人看到星星背后的漫天银河。同一个程序员对二者的实现,有云泥之别 —— 不仅代码更加清晰,适用于更大的问题场景,写起来也更加心旷神怡。就像 feed 的处理,一个个吭哧吭哧写,是无聊透顶的重复劳动,而将其抽象出一个描述如何从 feed 中获取需要的信息的「语言」,进而处理这门「语言」的解释和执行,实现这样的需求,想想就如沐春风,战斗力满满。

今天我们延续这个关于抽象的讨论,探索一下怎样从需求层面更好地抽象问题。我会先就着过去一周我对于升级 merlin 的需求的思考过程,然后再将这些思维的活动抽象出来,总结一下。

升级 merlin 的思考经历

merlin 是 Tubi TV 内部的一个 release 构建工具。在造轮子那篇文章里我已经提到了为什么会有这个需求。自那时起,merlin 为我们内部的几个 elixir service 的 release 保驾护航几个月,总体表现不错。然而,当时在需求和设计上的一些缺陷,导致这款产品有这些问题:

  1. 太依赖 github release —— 如果不生成新的 release,就无法自动构建。这对依赖 staging 进行集成测试的服务不友好。使用者需要在 pull request 里升级版本(才能生成一个 build)。因此,多个开发者间需要在 pull request 中把版本错开,很不方便。开发频繁的时候,我们一天的 patch version(SemVar 里第三位),五个十个地狂跳,比旧金山 TaxiCab 的计价器跳动还要可怕。
  2. merlin 使用的两台机器都是 t2.medium,一次 release build(还包括一次 release upgrade build)花费十来分钟。当构建繁忙的时候,在队列后面的请求要很久才能排到(Latency 不友好)。

所以我想在下一个版本中,将这些问题解决。初步的考虑是,当构建请求来临时,启动一个强大的 spot instance,处理构建任务,构建完成并上传 S3 后,spot instance 自尽。构建请求可以是来自 github release message(兼容上一版本),也可以是 API —— 进而,我们可以制作 CLI 工具,让用户在 shell 下对任意 git commit 触发构建。有了粗浅的想法后,我们理一理需求:

  1. 用户(包括 github)可以通过 API 触发构建
  2. 构建被触发后,启动一个 spot instance(或 ECS fargate,不过 spot instance 实在太便宜了,如 C5.large $0.03/hour,所以 ECS 没有啥价格优势)
  3. spot instance 基于一个 prebuild 的 AMI(如果 ECS,则 docker)启动,AMI 里包含处理构建的软件。这样启动起来之后,就能自动处理构建任务
  4. 描述构建任务的 metadata 放置于 spot instance 启动时的 user data 中,构建软件通过 http://169.254.169.254/latest/user-data 访问之
  5. 构建过程中可能要发送一些 telemetry 到 merlin
  6. 构建完成,把状态和构建的信息(比如 tarball 在哪里)发回给 merlin,然后自尽

这个需求算是比较清晰,实现起来也没有什么难点,无非就是时间问题 —— 对于像我们这样的 startup 来说,它是可以立即撸起袖子干活,逢山开路遇水填桥的那种活儿。

merlin 之前的坑是我埋的,这个业务即不性感,也不紧急;backend 的队友们都扑在一些 visibility 高的,光是名字听起来就热血沸腾的项目上,腾不出手,且我也不舍得就这么浪费他们的时间 —— 所以我只能 eat my own dogshit。我这人白天瞎忙,晚上躲懒 —— 除非有什么能戳到 G 点让我不吃不喝不睡觉也要搞的创意,否则像 merlin 这种一眼就从头看到脚,没有太多挑战的项目,激发不出我的小宇宙。于是需求定下,反正也不着急,我就懒懒地,有一搭没一搭地在脑海中想着。

事实证明,这种懒散,而非全力以赴,促成了我更多,更深的思考,也让 merlin 这个项目首先要解决的问题不断迁移。

在上面的需求中,merlin 由一个服务被拆成了两个部分:control plane 和 data plane(请饶恕一个曾经的网络工程师对区分路径的这种骨子里的执着)。简单来说,control plane 负责派活,是个 scheduler,类似于老鸨;data plane 负责干活,是一堆 resource,就好像苏小小,柳如是,李师师们。而一个个构建任务,是要完成的 task,就是赵佶,柳永,阮郁等的不期而至。

把 merlin 的需求稍稍泛化一下:

  1. 调用者可以通过 API 触发一个 task
  2. Control plane 接到 task 后,分配到 data plane 上的某个 resource 上执行
  3. data plane 向 control plane 汇总 telemetry
  4. data plane 完成 task 之后,向 control plane 汇报结果,进入到 idle 状态等待下次调度

这样,merlin 就是这泛化后的需求上的一个特殊实现。我在 代码命名:僧敲月下门 一文中谈到了 pipe,stream,flow 这样的用生活中的场景来类比所取的名字的精妙之处,那么我们也把这泛化后的需求的各个部分形象化一下。上面的比喻虽然贴切但不好展开,我们换个比喻:

再谈抽象

在这个设计中,我把 scheduler 称为 Carrier(航空母舰),resource 称为 Fighter(舰载战斗机),task 称为 Mission(任务),而若干个 Carrier 组成一个 Fleet(舰队)。下面是我坐火车时,关于 Fleet 的一些随性的想法:

再谈抽象

以及围绕着这个想法的进一步思考:

再谈抽象

需求自 merlin 起,终于 fleet。merlin 从之前的一个单独的系统,变为 fleet 这样一个宏大构架下的一个应用。这个架构提供运行 control plane 和 data plane 代码的基础组件,使得有类似 merlin 需求的应用,只需要聚焦于业务逻辑,而不用关心 control plane 和 data plane 的组织。至于 fleet 具体的实现,我会撰文另行描述。

现在我们有了一个较为粗糙的 fleet 的需求,我们套上 merlin 的日常业务,根据上面的 sequential diagram 来脑补(dry run)一下系统运行的具体过程:

  1. 在 fleet UI 上,load carrier merlin。merlin 需要有两套代码,一个是 carrier 的 release tarball,一个是 worker 的 release tarball。
  2. fleet 在本机(也可以远程)启动一个 erlang node,把 merlin carrier 的 tarball 部署上去。本机的 fleet node 和 merlin carrier node 组成一个 cluster。
  3. fleet API 自动支持新的 endpoint: htts://fleet.tubi.tv/carriers/merlin/missions。发往这个 endpoint 的请求会被 RPC 到 merlin carrier node。
  4. 一个新的 mission 发送到了这个 endopint,merlin carrier 收到请求后按照创建时提供的 fighter tarball 及 instance type 启动新的 spot instance:eagle-1。
  5. eagle-1 的 AMI 里有一份最简单的 bootstrap 代码,启动 erlang node,然后根据 user data 里的 cookie,carrier node 等信息连接 merlin carrier。
  6. merlin carrier 把 fighter tarball 部署到 eagle-1,然后启动 eagle-1 的 mission。

再谈抽象

(fleet UI)

再谈抽象

(fleet UI)

一切都说得过去。

接下来我们发现,fleet,carrier 和 fighter 的处理流程中,有些共同的需求:

  1. fleet 需要启动 carrier,carrier 也需要启动 fighter。
  2. fleet 需要往 carrier 上动态加载代码,carrier 也需要在 fighter 上动态加载代码。

这两个需求在 fleet basic requirement 那张图里已经有些粗糙的想法,分别对应两个需要拆出来单独处理的 lib:fleet-connector 和 fleet-loader。

顾名思义,fleet-connector 负责启动新的 node,让新 node 加入 cluster 并执行某些工作;fleet-loader 负责在某个已经连接在 cluster 里的 node 里动态加载我们需要的执行代码。

细想下去,fleet-loader 本身跟 fleet 没有什么直接关系和依赖,它广义上可以被重新定义为这样一个需求:node A 把某个本地的 beam,app,甚至 release 加载到远程处在同一个 cluster 下面的 node B 中,确保其正确执行。fleet 只是使用 fleet-loader 的功能而已。所以,我们可以把 fleet-loader decouple 出来,独立成一个和 fleet 无关的 package,叫 ex_loader。

fleet-connector 和 fleet 也没依赖关系。根据上面的描述,我们可以把它和 Supervisor 的功能进行类比:

  • Supervisor 根据预置的 MFA(Module,Function,Args)启动新的 process;fleet-connector 根据预置的 ITA(Image,Task,Args)启动新的 node。
  • Supervisor 把 supervisor process 和 worker process link 起来,如果 worker process 挂了,supervisor process 用同样的 MFA 重启之;fleet-connector 把 carrier node 和 fighter node link 起来,如果fighter node 挂了, carrier node 用同样的 ITA 重启之。
  • Supervisor 支持 one-for-one,rest-for-one,one-for-all,simple-one-for-one 这几种重启策略,fleet-connector 目前只考虑了 simple-one-for-one,但不难扩展。

所以,我们理应把 fleet-connector 独立出来,成为一个单独的 package,为其他应用造福。我管它叫 ex_connector。

ex_loader 的功能很直接,甚至,interface 都可以定义出来:

  • ExLoader.load_beam(beam_file, node):把一个 BEAM file 加载到某个远程 node。
  • ExLoader.load_apps(tarball, apps, node):把一个 release 里的某个(某些)app 加载到远程 node。
  • ExLoader.load_release(tarball, node):把一个 release 全部加载到远程 node。

ex_connector 并没有这么简单,它涉及了 carrier (master) 和 fighter (slave) 两端,很多事件都是异步的,很难用一个 library 来描述。我们知道,在 erlang 里要封装这样的行为,一般用 OTP 里 GenServer 家族的成员,所以它不该叫 ex_connector,应该叫 gen_connector,甚至叫 gen_executor 可能更确切些。作为一个 gen server,它和 user module 的交互如下:

再谈抽象

OK,需求被泛化,重组,分解,最后抽象到这个程度,已经差不多了。至少,我们得到了完全和项目初衷无关的 ex_loader 和 gen_connector,不管 merlin 的需求还有什么变化,fleet 还要再怎么泛化,这两个 component 已经可以开工了。

我们稍稍总结一下,一周的懒散和拖延得到了什么样的结果?我们原本是要升级 merlin,让其产生 build 的速度更快(reduce latency),修复一些限制 ,结果,一行代码没写,把一个系统折腾成一个 library,一个 gen server,一个 framework 和一个 app:

  • lib: ex_loader
  • gen server: gen_connector
  • framework: fleet
  • app: merlin

且不论这样一整套的东西做下来有多少长期收益,单从抽象的角度来说,我们就获益不少 —— 原本,这些抽象应该发生在做 merlin 和其它系统的过程中,一步步实践出来的 —— 比如,我们做另一个类似 merlin 的任务系统,发现有同样的需求,出于 DRY(Don’t Repeat Yourself)的考虑,拆分 merlin 的代码,把里面和 gen_connector 相关的功能抽取出来,单独组织。进而也许会抽象出 fleet。至于 ex_loader 是否会被抽取出来,全凭运气,我原本考虑, data plane 的 release 整个放在 AMI 里,这样也无不妥,就是 data plane 一有变化,就需要重新构建 AMI 而已。如果是这种设计,那么 ex_loader 完全没有必要,它也不会被创造出来。然而,它们预先被构想出来,因而省却了这中间可能的诸多弯路。

同时,在需求阶段便对其泛化,重组和分解,抽象过程从自下而上变成自上而下,能够大大改善架构和设计,让系统更加适应未来的需求变化。ex_loader / gen_connector / fleet 从 merlin 中拆分出来,成为类似项目的根基这事,对 merlin 本身来说,短期内是负收益;但对这个项目的未来来说,则未必。况且,这三者有额外的收益 —— 如果实现得当,按照预期,它能够大大降低工程师开发和部署分布式环境下进行 control plane / data plane 分离,且 data plane 高度可伸缩的应用。这就好比以太坊打开 ICO 的潘多拉盒子一样,有些复杂的东西,当变得足够简单,便可以把原有的生态系统冲个七零八落。

对抽象过程的抽象

上面拉拉杂杂写了这么多,是为了把我的整个思考的过程原原本本记录下来。我觉得思考的方式,思考的过程,要远比思考之后得出的结论重要。

总结一下,在需求层面抽象,我们要做些什么:

  1. 对需求不断推敲,加深理解,了解 why,并问 why not;而不是大致清晰之后,撸起袖子开始设计和编码。在这个阶段,脑子里思考,行动上拖沓是有利无害的。Rich Hickey 讲 hammock driven development,大概就是这个意思(建议大家自备梯子去 youtube 仔细看看)。
  2. 将需求泛化,看看能找到多少通用的部分。寻找生活中和工作中类似的场景,套进去,看能不能自圆其说。这里,类比是必不可少的,它利于我们把经验进行平移。类比的能力是平时需要不断训练的一种能力,你看我的文章,总喜欢将相同的事物放在一起对比。当这种行为成为一种习惯后,你会不自觉地把新事物和你熟知的事物联系起来,找异同。
  3. 按捺住撸开袖子开干的冲动。在动手之前,先想象自己是需求场景中的一个事件(或者触发器),脑补(dry run)处理的整个流程,一步步推敲。脑子不够用就上手画(保证纸笔或者 pad/pencil 随时都在手边) —— 是的,手绘,而非借助工具,除非工具本身简单到不会影响到你大脑的 flow。你看我讲道理摆事实,把我这周的大部分手稿都贡献出来了。
  4. 多想几个替代场景。
  5. 别用大块的时间,别用正儿八经工作的时间去思考 —— 你越认真,就越想不到点子上。跑步的时候想一想,上厕所看程序人生之余,也可以想一想,工作累了想一想,睡前花半小时想一想。对,睡前。不要怕失眠,失眠了起来想一想,困了接着睡。我们要相信念念不忘必有回响 —— 一切的一切就是要激发大脑中那些闲散的异步线程,让它们除了抽烟喝酒烫头之外,还能干点正事。
  6. 整个思考过程横跨的时间宜长不宜短,太短,不利于建立零零落落的 dots,也无所谓乔帮主所说的 connecting dots;太长,其实倒不打紧,毕竟前有德艺双馨的高老师(Donald Knuth)花了十年写了个 TeX —— 当然前提是你自己(你老板)想得开就可以。一般一两周即可。
  7. 将所思所想所画文档化,在这个过程中去芜存菁。

谢谢你耐心看到这里。

MORE IN 编程语言与工程

同专栏延伸阅读

查看「编程语言与工程」全部文章 →

是时候认识 jj 了:Git 的下一个进化方向

Git 统治了版本控制二十年,但它的暂存区、脆弱的 rebase、动辄阻断的合并冲突,正在成为 AI 时代的绊脚石。jj 从底层重构了版本控制的心智模型,而且完全兼容 Git。这篇文章带你看清它为什么值得关注。

New Hope (2025–2026)5,588 字 · 12 分钟