我构建大型技术项目的方法
无论是从零开始搭建新项目、实现一个大型功能,还是着手一次大规模重构,保持动力并完成大型技术项目都可能很困难。对我来说非常有效的一个方法是不断看到实实在在的成果,并以此来安排工作的先后顺序。
我们都体验过开始新项目时的那种兴奋感。最初的几周,你迫不及待地想坐到电脑前开工。然后随着时间推移,你慢慢开始分心,或找各种借口,减少投入的时间。如果这是正式工作,你会强撑着艰难地熬到终点,但每一天都很痛苦。如果这只是出于兴趣,几年后再回首,你只会想起那些本可能实现的成果。
我发现,当我把大任务拆成能让人看到切实进展的小块时,我往往能完成工作,并在整个项目过程中保持热情。每个人受激励和驱动的方式都不同,所以这个方法也许不适合你,但作为一个大致的概括,我还没见过哪个工程师不会为一场精彩的演示而兴奋。而目标就是始终给自己一场精彩的演示。
我并不是说这篇文章里的任何内容有多新颖。它无疑与许多广为人知的软件工程或管理实践有共通之处。我只是想分享我处理大型技术工作的方式以及我为何这样做。
在本文中,我将以我的终端模拟器项目为例,以便分享真实、具体的经验。本来还有很多其他项目可以举例,但我选择这个,是因为它与我的本职工作无关,而且时间较近,记忆犹新。
我想非常明确地说明,我并不是在指责那些没有完成项目的人。只要你玩得开心、感到有成就感(或根本不在乎),那就很好,也祝你更有干劲。这篇博文是写给那些更想完成项目的人,或是单纯想了解我是如何努力更多地完成项目的人。
起点
起初,你手头有一个大型项目,必须想清楚如何开始。对我来说,这是最难的部分,我可能会花上几个小时——有时甚至几天——来纠结正确的起点。
以我的终端模拟器为例,我知道如果想最终完成这个项目,有许多大型组件是必不可少的:终端解析、运行和管理 shell 进程、字体渲染、网格渲染、输入处理(键盘/鼠标)等。在通往“完成”的路上,有成百上千个相对庞大的子项目。
如果我最初的目标是看到一个能运行 Neovim 的可启动终端,那我就麻烦大了。即便考虑到未知的未知,这个目标听起来也太大了。我能直观地意识到,要实现它需要很多组件:渲染图形界面、启动进程、终端解析与状态管理。这是个糟糕的目标,它太过庞大,我很可能一两个月后就会失去兴趣。
相反,我会去思考什么样的项目是现实的、能让我尽快看到成果。一旦加上这个筛选条件,可行的子项目数量就会急剧减少。以下是一些例子:
- VT Parsing(VT 解析) - 解析终端转义序列
- 空白窗口渲染 - 打开一个窗口并绘制一块空白画布
- 子进程启动 - 启动一个子 shell,如 bash、zsh、fish,配置 TTY 并能够读取其输出(即最初的 shell 提示符)
在这个阶段,我不会去试图列举所有大型子项目。我只是大致了解项目大概的轮廓,然后找出一个可以独立构建、并且能实际看到某种真实成果的子项目。
这是经验最能发挥作用的阶段。经验更丰富的工程师通常能更有效地描绘出项目的大致轮廓。他们能更准确地识别各种子组件,并看清各部分如何拼接在一起。如果经验较少,或是在我不熟悉的领域,我就只能尽力猜测,并预期在某个时候推翻重来的可能性会更高。
早期成果
早期的工作往往不太直观可见,这使得看到切实的成果似乎很困难。例如,如果我选择为终端做 VT 解析方面的开发,在没有接入某种形式的界面的情况下,我就无法看到它是否生效。或者,对于其他项目,如果我选择先做数据库结构和最小化 API,同样在没有编写客户端以及 CLI 或 GUI 的情况下,也无法看到这些工作的效果。
如果你最初选择的子项目是界面,那么当然可以很快看到一些成果!出于各种原因,我很少先从前端开始,通常先从后端开始。而无论如何,你最终都会做到后端,也会遇到类似的挑战。
度过这个阶段的最佳工具是自动化测试(在这个阶段通常是单元测试)。自动化测试让你能够实际运行一些代码并看到它在正常工作,同时也有利于养成良好的工程习惯。
这也为你挑选最初几个任务提供了另一个指引:如果是非图形化的工作,你会想挑一些无需太多周折就能测试的内容,以便看到一些成果。
对于我的终端,我决定先从 VT 解析开始,因为当时这是终端中我不太了解的部分,而且它感觉是非常容易测试的:给它一些作为字符串的示例输入,期望得到某个已解析的动作或事件作为输出。
看到“1 项测试通过”、“4 项测试通过”、“13 项测试通过”这样不断增长的过程让我非常兴奋。我正在运行自己写的代码,而且它能正常工作。我知道自己正在一个更大项目的关键子组件上取得进展。
冲刺演示
我做早期子项目的目标不是构建一个完整的子组件,而是构建一个足够好的子组件,以便我能继续推进到通往演示的下一步。 ✨
这种权衡不仅体现在功能上,也可能体现在算法或设计考量上。例如,你可能知道将来需要使用真正的数据库、精巧的数据结构或支持流式数据。但对于最初的一批工作,你完全可以只使用内存中的内容、字典等内置数据结构,并要求所有输入/输出一次性准备好。
我认为这种权衡非常重要,所以我要再重复一遍:不要让完美成为进步的敌人。更进一步,不要让那些你明知将来必须做的改进阻止你继续推进到下一件事。目标是做出演示。
无论我在做什么,我都会尝试每周做一到两个演示,并穿插上一节中提到的自动化测试反馈。
构建演示还能为你提供宝贵的产品反馈。即使功能尚未完善,你也能很快直觉地判断某些东西感觉好不好。这些算不上“最小可行产品”,因为它们其实还不可用,但已经足够让工程师进行有价值的自我审视。
这是我认为经验反而会带来负面影响的地方。我见过资深工程师执着于打造完美的东西,等到做出演示时才发现它很糟糕。糟糕的不是实现,而是产品或功能本身就很糟糕。
回想一下,对于终端,我选择的第一个任务是 VT 解析。在早期阶段,我只看到自动化测试在起作用。为了做出第一个演示,我写了一个 shell 脚本,它会运行某个命令、捕获其输出、喂给我的 VT 解析器,然后输出它解析到的所有内容(或未能解析的内容)。随着时间的推移,我把这个 CLI 作为我的第一个“界面”来迭代——我会用 ASCII 来渲染终端网格。
这给了我巨大的满足感,因为我可以运行像man或ls这样简单的程序,或像vim这样更复杂的程序,并看到我的解析器在工作(或崩溃,后者以另一种方式同样令人兴奋)。
在这种情况下,我编写的 CLI 长远来看相对无用(我很快就把它丢弃了)。但花上一两天把它作为演示来构建,让我产生了重要的进展感,而亲眼看到某些东西在工作,有助于让我保持动力。
为自己而构建
这一节更适用于个人项目,而非工作指派的项目。即使你希望发布软件给他人使用,也要只在需要时构建你需要的东西,并尽快用上自己的软件。
当我在解决自己亲身遇到的问题时,总是更有动力1。而且,如果一个为你设计的产品对你自己都不好用,那它很可能对其他人也不会好用。因此,我从演示到真正可用的产品的路径,就是找到一条最短路径,只构建我认为自己需要的功能。
对于我的终端,这意味着首先要能够加载我的 shell 配置(fish)并从中启动和使用 Neovim。所以我把所有工作都聚焦在仅实现这些程序所需的功能上:只实现这些程序用到的转义序列、只渲染我日常使用的字体等。最初省略的功能示例包括:滚动、鼠标选择、搜索、标签页/分屏等。
然后我开始把自己的终端作为日常主力使用。这一步通常会有几次失败的尝试;你会意识到其实还需要某个被省略或遗漏的功能。在我最初试用终端时,我发现方向键毫无反应,还有一些细微(但会严重影响工作流)的渲染错误等。于是我会暂时放弃使用,但它为我提供了下一步要处理的切实任务。
此外,使用包含自己编写代码的软件总会让我感到自豪,这通常也有助于激励我继续做下去。
总结
将大问题分解为小问题。重要的是,每个小问题都必须有某种清晰的方式让你看到工作的成果。
只在较小的问题上做到足以推动大问题的演示层面,然后就转向下一个小问题。
只解决足够数量的小问题,使你能够开始构建软件的可运行演示,然后继续迭代更多功能。尽可能频繁地做演示。
优先实现能让你用上自己软件的功能,如果适用的话(个人项目、解决你实际遇到的问题的工作项目等)。然后继续优先解决你自己的问题。
根据未来改进的需要,回过头来迭代各个组件,并根据需要重复这一过程。
结论
差不多就是这样了。我在个人项目、团队项目、工作项目、学校项目等各种项目中都遵循这一大致模式,这也是我保持动力的方式2。
注意,我有很多东西没提!我没有谈发布。我知道很多人觉得发布很有激励作用。我认为项目不一定要发布才算成功。对我而言,发布是太大的事件,无法长期激励我。我也没有谈工具(Git 工作流、CI 等)。我在多份工作中都使用这一流程,并将其融入既有的流程中。等等。
我认为这恰恰说明了这在多大程度上是个人化的流程。我想每个人都需要找到某种以健康方式强化自身动力的方法。我意识到看到成果对我有非常强的激励作用,于是围绕这一点构建了我的工作方式,到目前为止效果很好。
脚注
随机一篇博客