多年来,我逐渐倾向于采用两种互补的方式来构建强大的软件系统:逐步构建和逐步打磨。
逐步构建是指从一个小的核心开始,逐步添加功能。打磨则是指从一个非常粗略的想法开始,随着时间的推移不断完善。
两种方法本身并无优劣之分;这几乎是一种风格选择,取决于团队协作以及对问题领域的熟悉程度。此外,我对这个问题的看法并非独树一帜,但我还是想总结一下我多年来的经验。
逐步建立
在古埃及,人们正在一块坚固的石头上工作。来源:维基媒体公共领域
逐步构建的重点在于首先打好坚实的基础。我喜欢在处理我熟悉的系统或有明确的规范可以参考时使用这种方法。例如,我会用它来实现协议或模拟硬件,比如我的MOS 6502 仿真器。
我更倾向于使用“向上构建”而非“自下而上”,因为前者让人联想到建设和向上发展。“自下而上”则更抽象、更具方向性。此外,“自下而上”总感觉像是一种术语,而“向上构建”则更直观、更形象,因此更有助于向非技术利益相关者传达这一概念。
我在搭建房屋时尽量遵循以下几条规则:
- 专注于易于组合和测试的原子构建模块。
- 利用简单、可验证的属性构建强有力的保障。
- 注重正确性,而非表现。
- 请编写文档并附上代码以验证您的推理。
- 在进行下一层之前,先把抽象概念搞清楚。
当我与分析能力很强的人合作时,这种方法很有效。有形式化方法或数学背景的人倾向于用“构建模块”和证明的方式思考。我还发现函数式程序员也更喜欢这种方法。
在 Rust 等语言中,类型系统有助于强制执行不变式,并使从简单组件构建复杂系统变得更加容易。此外,Rust 的 trait 系统鼓励组合,这与这种思路非常契合。
“逐步构建”方法的缺点在于,在看到任何实际成果之前,你需要花费大量时间在基础层上。这种方法实现最小可行产品(MVP)的速度可能较慢。有些人还觉得这种方法过于僵化和缺乏灵活性,因为一旦确定了某种架构,就很难进行调整或改变方向。
例如,假设你正在构建一个Web框架。项目初期会遇到很多问题:
- 是同步的还是异步的?
- 请求路由将如何工作?
- 是否会有中间件?如何实现?
- 回复生成机制将如何运作?
- 错误处理将如何进行?
采用逐步构建的方法,首先要回答这些问题,并设计核心抽象。请求和响应类型、路由器和中间件系统等基础组件是框架的骨干,必须非常稳定可靠。
只有在确定了核心数据结构及其交互方式之后,才能着手构建公共 API。这可以构建出一个非常健壮且设计精良的系统,但同时也可能需要很长时间。
例如,以下是来自流行的http crate 的Request结构体:
# [ derive ( Clone ) ] pub struct Request < T > { head : Parts, body : T, } /// Component parts of an HTTP `Request` /// /// The HTTP request head consists of a method, uri, version, and a set of /// header fields. # [ derive ( Clone ) ] pub struct Parts { /// The request's method pub method : Method, /// The request's URI pub uri : Uri, /// The request's version pub version : Version, /// The request's headers pub headers : HeaderMap < HeaderValue > , /// The request's extensions pub extensions : Extensions, _priv : ( ) , }这段简短的代码中包含了不少巧妙的设计决策:# [ derive ( Clone ) ] pub struct Request < T > { head : Parts, body : T, } /// Component parts of an HTTP `Request` /// /// The HTTP request head consists of a method, uri, version, and a set of /// header fields. # [ derive ( Clone ) ] pub struct Parts { /// The request's method pub method : Method, /// The request's URI pub uri : Uri, /// The request's version pub version : Version, /// The request's headers pub headers : HeaderMap < HeaderValue > , /// The request's extensions pub extensions : Extensions, _priv : ( ) , }
-
Request结构体是通用的,其主体类型为T,允许灵活地表示主体(例如,字节流、字符串等)。 -
Parts结构体与Request结构体分离,从而可以轻松访问请求元数据,而无需处理请求体。 -
Extensions可用于存储从底层协议派生的额外数据。 -
_priv: ()字段是一个零大小的类型,用于防止外部代码直接构造Parts。它强制使用提供的构造函数,并确保Parts结构的不变式得到维护。
除了扩展功能之外,这种设计经受住了时间的考验。自 2017 年第一个版本发布以来,它基本保持不变。
打磨
雷赫米尔墓壁画局部素描来源:维基媒体公共领域
我发现另一种同样有效的方法是“打磨”。这种方法是先做一个粗糙的原型(或垂直切片),然后慢慢地进行精细加工。你需要反复“打磨”粗糙的边缘,直到对结果满意为止。这有点像木工,从一块粗糙的木头开始,逐渐将其打磨成一件艺术品。(我当然不知道木工是什么样的,但我猜大概就是这样。)
关键在于,这与原型设计类似但不完全相同。区别在于,你并不打算丢弃编写的代码。相反,你要利用问题的迭代特性,有意识地不断完善“草稿”,直到最终版本。如有需要,你可以在任何时候停止开发并发布当前版本。
我发现这种方法在需要实验和快速迭代的创意项目中非常有效。有游戏开发或脚本语言背景的人往往更喜欢这种方法,因为他们习惯于探索式的工作方式。
使用这种方法时,我尽量遵循以下规则:
- 关掉你内心的完美主义。
- 写初稿的时候不要修改。
- 严禁代码重复。
- 重构,重构,重构。
- 推迟测试,等到初稿完成后再进行。
- 首先专注于最外层的 API;把它做好,然后再完善内部细节。
这种方法很容易让人放弃旧代码,尝试新的东西。但我发现,对于那些喜欢提前计划、做事很有条理的人来说,这可能会令人沮丧。“混乱”似乎会让一些人望而却步。
举个例子,假设你正在用 Rust 编写一个游戏。你可能想要调整游戏的各个方面,并快速迭代游戏机制,直到它们感觉“恰到好处”。
为了实现这一点,你可以先从游戏循环的框架开始,仅此而已。然后添加一个可以在屏幕上移动的玩家角色。调整跳跃高度和移动速度,直到感觉合适为止。此时,你和游戏逻辑之间几乎没有任何抽象层。你可能会有很多重复的代码和硬编码的值,但目前来说这没关系。一旦核心游戏机制确定下来,你就可以开始重构代码了。
我认为,如果在游戏设计初期就使用Bevy或其他框架,Rust可能会成为阻碍。实体组件系统会显得相当笨重,不利于快速迭代。(至少我上次尝试Bevy时就是这种感觉。)
我用macroquad创建了自己的窗口和渲染循环,体验好得多。没错,所有代码都在一个文件中,而且没有测试。也没有任何架构可言。
然而……开发游戏的感觉真是太棒了!我知道以后总可以重构代码,但我当时只想专注于当下,先把游戏玩法做好。
这是我的游戏循环,它非常必要,而且不需要学习大型框架就能上手:
# [ macroquad :: main ( " Game " ) ] async fn main ( ) { let mut player = Player :: new ( ) ; let input_handler = InputHandler :: new ( ) ; clear_background ( BLACK ) ; loop { // Get inputs - only once per frame let movement = input_handler . get_movement ( ) ; let action = input_handler . get_action ( ) ; // Update player with both movement and action inputs player . update ( & movement , & action , get_frame_time ( ) ) ; // Draw player . draw ( ) ; next_frame ( ) . await } }你不需要成为 Rust 专家也能理解这段代码。# [ macroquad :: main ( " Game " ) ] async fn main ( ) { let mut player = Player :: new ( ) ; let input_handler = InputHandler :: new ( ) ; clear_background ( BLACK ) ; loop { // Get inputs - only once per frame let movement = input_handler . get_movement ( ) ; let action = input_handler . get_action ( ) ; // Update player with both movement and action inputs player . update ( & movement , & action , get_frame_time ( ) ) ; // Draw player . draw ( ) ; next_frame ( ) . await } }
在每次循环迭代中,我只需:
- 获取输入
- 更新玩家状态
- 抽到玩家
- 等待下一帧
这是这类作品中非常典型的设计。
如果我愿意,我现在可以精简代码,并将其重构为更模块化的设计,直到它达到生产就绪状态。我可以引入“监听器/回调”系统来将输入处理与玩家逻辑分离,或者引入场景图来管理多个游戏对象,或者引入本体系统来管理游戏实体及其组件。但何必呢?目前,我更关心游戏机制,而不是架构。
找到合适的平衡点
两种方案都能构建出正确、易于维护且高效的系统。没有孰优孰劣之分。
我发现大多数人倾向于采用其中一种方法。然而,熟悉两种方法并了解何时应用哪种模式很有帮助。务必谨慎选择,因为在两种方法之间切换相当棘手,毕竟它们是从问题的不同角度切入的。