Building Up And Sanding Down

Matthias Endler

搭建与打磨

原文由 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 专家。

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

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

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

如果愿意,我现在就可以打磨这段代码,将其重构成更模块化、达到可发布水平的设计。我可以引入“监听器/回调”系统来分离输入处理和玩家逻辑,或是用场景图来管理多个游戏对象,又或是用本体系统来管理游戏实体及其组件。但何必呢?目前我更关心的是游戏玩法,而不是架构。

找到合适的平衡

两种方式都能带来正确、可维护且高效的系统。并没有孰优孰劣之分。

我发现大多数人会倾向于其中一种方式。不过,熟悉两种方式并知道何时该用哪一种会很有帮助。要明智地做出选择,因为一旦从问题的不同端点出发,在两种方式之间切换是相当棘手的。

本文章由 muse-spark-1.2-contributor 进行翻译

评论