Building Up And Sanding Down

Matthias Endler

层层构建与细细打磨

多年来,我逐渐倾向于两种相辅相成的构建稳健软件系统的方式:层层构建与细细打磨。

层层构建(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 年的第一个版本以来,它基本保持不变。

细细打磨

Rekhmire(雷赫米雷)墓室壁画局部的摹绘图
Rekhmire(雷赫米雷)墓室壁画局部的摹绘图
来源: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 专家也能看懂这段代码。

在每一次循环迭代中,我只是:

  • 获取输入
  • 更新玩家状态
  • 绘制玩家
  • 等待下一帧

这是这类工作中非常典型的设计。

如果我愿意,现在可以打磨这段代码,把它重构为更模块化的设计,直到达到生产可用。我可以引入“监听器/回调”系统来把输入处理与玩家逻辑分离,或者用场景图来管理多个游戏对象,或者用本体系统来管理游戏实体及其组件。但何必呢?眼下我关心的是游戏机制,而不是架构。

找到恰当的平衡

两种方式都可以造就正确、可维护且高效的系统。没有哪种方法更好或更坏。

我发现,大多数人都会倾向于其中一种方法。然而,熟悉这两种方法并知道何时使用哪种模式是很有帮助的。请明智选择,因为在这两种方法之间切换相当棘手,毕竟你是从问题的不同两端出发的。

原文由 Matthias Endler 发布

本文章由 stealth/ox-alpha 进行翻译