层层构建与细细打磨
多年来,我逐渐倾向于两种相辅相成的构建稳健软件系统的方式:层层构建与细细打磨。
层层构建(Building Up)是指从一个小小的核心开始,逐步添加功能。细细打磨(Sanding Down)是指从一个非常粗糙的想法开始,随着时间推移不断精炼。
两种方法没有孰优孰劣;这几乎是一个风格选择,取决于团队动态以及对问题领域的熟悉程度。此外,我在这方面的想法并不算特别新颖,但我想总结一下这些年来学到的东西。
层层构建

来源:Wikimedia 公有领域
层层构建注重先打造坚实的基础。在处理我非常熟悉的系统,或者有明确规范可以参照时,我喜欢使用这种方法。例如,我在实现协议或模拟硬件时会使用它,比如我的 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: (),
}这段简短的代码中包含不少精巧的设计决策:
Request结构体对 body 类型T进行泛型化,使 body 的表示方式具有灵活性(例如字节流、字符串等)。Parts结构体与Request结构体分离,可以轻松访问请求元数据而无需处理 body。Extensions可用于存储从底层协议派生的额外数据。_priv: ()字段是一个零大小类型,用于防止外部代码直接构造Parts。它强制使用提供的构造函数,并确保Parts结构体的不变量得以维持。
除了扩展之外,这一设计经受住了时间的考验。自 2017 年的第一个版本以来,它基本保持不变。
细细打磨

来源:Wikimedia 公有领域
另一种我发现同样行之有效的方法是“细细打磨”。在这种方法中,你从一个粗糙的原型(或垂直切片)开始,随时间推移不断精炼。你一遍又一遍地“打磨”掉毛边,直到对结果满意为止。这有点像木工活,从一块粗糙的木料开始,逐渐将其精炼成一件艺术品。(虽然我完全不知道木工活是什么样的,但我猜大概就是这样吧。)
至关重要的是,这类似于但不等同于原型开发。区别在于,你并不打算丢弃自己写的代码。相反,你是在利用问题的迭代特性,有意识地在“草稿”上工作,直到达到最终版本。在任何时间点,如有需要,你都可以停下来发布当前版本。
我发现,这种方法非常适合需要实验和快速迭代的创意项目。有游戏开发或脚本语言背景的人往往更喜欢这种方法,因为他们习惯以更具探索性的方式工作。
使用这种方法时,我尽量遵循以下规则:
- 关掉内心的完美主义。
- 写初稿时不要边写边改。
- 严格允许代码重复。
- 重构,重构,再重构。
- 把测试推迟到初稿完成之后。
- 先专注于最外层的 API;把它敲定,然后再打磨内部实现。
这种方法让丢弃代码、尝试新事物变得容易。我发现,对于喜欢提前规划、做事有条不紊、讲究方法的人来说,这可能会令人沮丧。这种“混乱”似乎会让一些人望而却步。
举个例子,假设你在用 Rust 编写一个游戏。你可能想调整游戏的方方面面,并快速迭代游戏机制,直到它们感觉“恰到好处”。
为此,你可以只从一个游戏循环的骨架开始,别的什么都没有。然后你添加一个可以在屏幕上移动的玩家角色。你不断调整跳跃高度和移动速度,直到它感觉良好。在这个阶段,你与游戏逻辑之间几乎没有任何抽象。你可能会有大量重复的代码和硬编码的值,但现在没关系。一旦核心游戏机制敲定下来,你就可以开始重构代码了。
我认为,如果在游戏设计过程的早期就使用 Bevy 或其他框架,Rust 可能会成为障碍。实体组件系统(ECS)可能感觉相当沉重,妨碍快速迭代。(至少我上次尝试 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 专家也能看懂这段代码。
在每一次循环迭代中,我只是:
- 获取输入
- 更新玩家状态
- 绘制玩家
- 等待下一帧
这是这类工作中非常典型的设计。
如果我愿意,现在可以打磨这段代码,把它重构为更模块化的设计,直到达到生产可用。我可以引入“监听器/回调”系统来把输入处理与玩家逻辑分离,或者用场景图来管理多个游戏对象,或者用本体系统来管理游戏实体及其组件。但何必呢?眼下我关心的是游戏机制,而不是架构。
找到恰当的平衡
两种方式都可以造就正确、可维护且高效的系统。没有哪种方法更好或更坏。
我发现,大多数人都会倾向于其中一种方法。然而,熟悉这两种方法并知道何时使用哪种模式是很有帮助的。请明智选择,因为在这两种方法之间切换相当棘手,毕竟你是从问题的不同两端出发的。
随机一篇博客