搭建与打磨
原文由 Matthias Endler 于 发布,订阅该博客
多年来,我逐渐形成了构建健壮软件系统的两种互补思路:搭建与打磨。
搭建是指从一个极小的核心出发,逐步添加功能。打磨则是指从一个非常粗糙的想法出发,随着时间不断精炼。
两种方式本身并无优劣,几乎可以说是一种风格上的选择,取决于团队协作方式以及对问题领域的熟悉程度。此外,我在这方面的想法也算不上新颖,只是想把这些年学到的东西做个总结。
搭建

来源:Wikimedia 公有领域
“搭建”注重先打下坚实的基础。我喜欢在自己非常熟悉的系统上,或是有明确规范可循时采用这种方式。例如,实现各类协议,或是做硬件仿真时,比如我的 MOS 6502 模拟器。
比起“自底向上”,我更喜欢“搭建”这个说法,前者让人联想到建造和向上生长。“自底向上”则更抽象、更偏向方位描述。而且“自底向上”总让人觉得是行话,而“搭建”更直观、更有画面感,也更容易向非技术背景的人解释。
采用“搭建”时,我会尽量遵循以下几条规则:
- 关注原子化的构建块,确保它们易于组合和测试。
- 从简单、可验证的特性出发,构建出强大的保障。
- 关注正确性,而非性能。
- 边写代码边写文档,以此检验自己的思路。
- 先把抽象打磨到位,再进入下一层。
与分析能力很强的人合作时,这种方式效果很好。有形式化方法或数学背景的人往往习惯于用“构建块”和证明来思考。我也发现函数式程序员通常更偏好这种方式。
在 Rust 这类语言中,类型系统有助于强制保证不变量,让从简单组件搭建复杂系统变得更容易。同时,Rust 的 trait 系统鼓励组合,这与这种思路非常契合。
“搭建”方式的缺点是,你会在基础层上花费大量时间,很久才能看到实实在在的成果。用这种方式做出最小可行产品往往很慢。有些人也会觉得这种方式过于死板、不够灵活,一旦确定了某种架构,就很难转向或调整方向。
比如,假设你在构建一个 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结构体对主体类型T做了泛型化,让主体的表示方式(比如字节流、字符串等)更加灵活。Parts结构体与Request分离,无需处理主体即可轻松访问请求元数据。Extensions可用于存储从底层协议派生出的额外数据。_priv: ()字段是一个零大小类型,用于防止外部代码直接构造Parts,它强制使用提供的构造函数,从而保证Parts结构体的不变量得以维持。
除了 extensions 之外,这一设计经受住了时间的考验。自 2017 年的最初版本以来,它基本保持不变。
打磨

来源:Wikimedia 公有领域
另一种同样有效的方式是“打磨”。在这种方式中,你从一个粗糙的原型(或垂直切片)开始,随着时间不断精炼。你一遍又一遍地“磨掉”毛边,直到对结果满意。这有点像木工——从一块粗糙的木料出发,慢慢将其打磨成一件艺术品。(当然,我对木工其实一窍不通,只是想象大概是这样。)
关键在于,这与原型设计相似,但并不完全相同。区别在于,你并不打算丢弃所写的代码。相反,你是有意利用问题的迭代特性,在“草稿”上持续打磨,直到得到最终版本。在任何时刻,如果需要,你都可以停下来交付当前版本。
我发现这种方式在需要尝试和快速迭代的创意项目中效果很好。有游戏开发或脚本语言背景的人往往更偏好这种方式,因为他们习惯于以更具探索性的方式工作。
采用这种方式时,我会尽量遵循以下几条规则:
- 关掉内心的完美主义者。
- 写第一稿时不要边写边改。
- 允许代码重复。
- 重构,重构,再重构。
- 等第一稿完成后再考虑测试。
- 先聚焦最外层的 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 专家。
在每一次循环中,我只是:
- 获取输入
- 更新玩家状态
- 绘制玩家
- 等待下一帧
这是这类工作非常典型的设计。
如果愿意,我现在就可以打磨这段代码,将其重构成更模块化、达到可发布水平的设计。我可以引入“监听器/回调”系统来分离输入处理和玩家逻辑,或是用场景图来管理多个游戏对象,又或是用本体系统来管理游戏实体及其组件。但何必呢?目前我更关心的是游戏玩法,而不是架构。
找到合适的平衡
两种方式都能带来正确、可维护且高效的系统。并没有孰优孰劣之分。
我发现大多数人会倾向于其中一种方式。不过,熟悉两种方式并知道何时该用哪一种会很有帮助。要明智地做出选择,因为一旦从问题的不同端点出发,在两种方式之间切换是相当棘手的。
随机一篇博客
评论
登录后参与讨论