#import "/typ/template/pages.typ": *

#show: blog-page.with(title: "Simple SE")

这篇博客是一篇随笔。每个小段之间的直接联系不大，但具有渐进性和隐含关联。

== 笨蛋如何进行架构设计

每个人都应该有追求其梦想的权利，但是梦想不是每个人都能实现的。f例如，每个程序员可能或多或少地拥有几个他们设计的软件，他们将以不成熟的经验接触艰难的软件架构。但当回顾最终软件以怎样意外的方式运行的时候，他可能便会认识到当初在软件架构上犯下了某些严重的错误。但也正和追逐梦想一样，只有最顶尖的程序员才能设计出一个很好的软件架构实际上不是一个严格成立的东西。我觉得，至少有一些值得分享的东西。

== 阅读和学习软件设计的方法

有一个我个人觉得很好的习惯，那就是会时常快速浏览一些可能有趣的仓库代码，并尝试总结这个仓库在控制流过程中以怎么样的方式展开他们的代码。你需要时常找一些项目，并尝试在数分钟或数十分钟内渐进地了解该软件的总体架构。这是一个需要锻炼的技能，并且是我认为一个好的程序员最需要掌握的基础技能之一。

有时候，一个人不尝试便总是高估他在这方面的能力。一个有趣的随堂测试是：当你在阅读一个超级项目的时候，是否曾经查找过它的程序入口在哪个文件里。这里，我用了程序入口这个更泛指的名词，因为“一段代码”可能没有main函数。“一段代码”可能还是某个大组件的小组件。这时候并没有一个明显的main，而只有一个constructor或者init函数供其他代码调用。

例如，在很多年前研究QEMU的某个虚拟设备的时候。首先我看到它的main函数首先解析了相当多的配置，并创建了一系列注入了配置的QObject。然后主线程会进入main loop。接着主线程还会区分IO线程和非IO线程（CPU线程）。当虚拟中断来临的时候，如果这个虚拟机以KVM Backend的方式启动的时候，一个回调会从KVM退出开始处理中断，并有可能发送事件给IO线程。最终虚拟设备会接受事件，模拟硬件并作出响应。

这里，你不需要多仔细，只需要快速浏览，把控制流简单梳理一遍，就能对你阅读虚拟设备的代码大有裨益。从虚拟设备视角来看，当虚拟设备所掌握的内存被分配出来后，它无论如何会挂在Machine设备树上的某处。这个设备树是在解析配置的时候形成的，有可能调用虚拟设备相关的初始化代码。接着在main loop里，在IO线程上下文中，以例如MMIO的方式，调用虚拟设备的处理函数代码。

如果你梳理了这些东西，你很容易就能判断一个代码中复杂的条件嵌套的代码之间的关联，在脑中模拟和思考代码背后的逻辑。同时，你也能更好地理解代码的软件结构，理解庞大宏伟的软件如何将组件井井有条地组织和使用起来。

== 最小状态设计和局部推理

我学习的第一个程序语言是`C++`。我从前辈学到的第一个诫言是：不要使用`using namespace std;`。紧接着我学到的第二个诫言是：如非必要，少使用全局变量。我在后来的学习过程中找到了一个合理诠释这些警世名言较好的总结：它不利于局部推理。

局部推理是一个比较老但有趣的哲学概念，并在程序分析领域（据我所知）中有所借鉴。它是指在某些假设下你可以根据一部分逻辑推断程序整体的性质。我无意详细介绍和展开局部推理这一概念，但是我会以不严谨但夸张的描述表述这一概念。

对于不要使用`using namespace std;`，从两例可以看出一些端倪。首先，如果你在全局引入了`std`中所有的对象，当你快速浏览到一些函数调用的时候，便很难快速思考调用的作用和影响。例如，一个`bind`函数，在引入`std`前，它可能是linux的bind系统调用，与网络相关；引入之后就变成了`std::bind`，一个抽象的c++内置函数。在引入`std`之后，阅读代码的时候便需要额外思考这个函数究竟是其中的哪一个；如果你忽略了这个语句，甚至会进行误判。如果你的代码让人相信没有使用`using namespace std;`，反之，你的代码就和`std`本身隐式无关了。其次，个人认为，引入`std`中所有的对象并非严格不可取。例如，你可以在某个函数的函数体中使用`using namespace std;`，节省反复使用`std::`引用标准库函数。这同样可以用局部推理解释。当我们在看这个函数实现的时候，首先被提醒引入了`std`的所有对象，我们便局部性让脑中的编译器记得加载`std`所有相关函数。

那么，少使用全局变量呢？这个问题的答案也是类似的。因为全局变量可能会在任何地方被修改。很夸张地举一个例子，你的代码对局部某个计数器加一的时候，都有可能导致全局变量内容的变化。比如，这个全局变量可能是一个桩，在虚拟机中运行的时候，虚拟机会知道你改了局部计数器，同步帮助修改全局变量的内容。

少使用全局变量在人类推理程序逻辑时是小问题，但在机器证明程序正确性的时候其严重性便被放大很多。不夸张地举一个例子，相比C++，Rust为了方便机器推断变量的借用，它对访问全局变量进行了限制。Safe Rust无法访问全局变量，所以Rust可以知道全局变量不会对Safe代码造成任何*借用*上的影响。Unsafe Rust可以访问全局变量，这个时候读代码的时候就很累了，Rust编译器则甚至摆烂不再进行严格的借用检查。

这同时还涉及到一个软件架构上的设计。为什么说我们推崇最小状态设计。因为，这样不仅人类和机器更有可能正确推理你的代码逻辑，条理性的背后还诱导我们编写更不易出错的代码。

== 最简抽象和单体对象架构

我理解你在软件架构上可能遇到了设计难点，所以为什么不选择摆烂呢。借用一句名言：没有设计，亦是设计（No Design is a Design，By me）。所谓：始终摆烂，亦是自律（划去）。

这句话是有道理的，抽象得越多就越难理解，没用的代码也随之增加。很多时候，将main函数分成很多个函数不如所有事情只在main函数中做完。在代码后期，这个规则可能被打破，但是一定不是在早期。因为我经常会考虑你对程序背后代表的逻辑认识还不够充分，这个时候，并没有必要抽象出接口，甚至是结构体来细化逻辑。

考虑对一个用户密码进行校验的C语言程序。按照直觉，应该先读取用户名，再读取用户密码。如果密码有误，就`return 1`。

```c
int main() {
  std::string username;
  std::string password;
  // Read Username
  // Read Password
  // Check Password
  return 0;
}
```

接下来，猜猜要怎么做。正常人可能会说，要继续完成注释所指示的代码。但是，夸张一点说，这个程序已经编写完了。因为不做任何校验的话，这个程序没有安全漏洞，并且“做了校验这一件事”。在main函数的最后，我们返回了`0`，表示程序认为用户密码没有问题。

我认为，你软件的第一步已经做到了，至少在这一步，从构建程序到程序运行，所有流程都走通了。甚至，你的代码里还包含了对该程序的宏伟设想，并让人觉得这真的是一个用户密码进行校验的C语言程序。

再接下来，我们思考，`username`应该会从stdin输入，`password`则是从一些特定权限的文件夹中读取。

```c
int main() {
  std::string username;
  std::string password;
  // Read Username
  {
    std::cin >> username;
  }
  // Read Password
  {
    std::ifstream password_file("<PASSWORD>");
    password_file >> password;
  }
  // Check Password
  {
    std::string password_input;
    std::cin >> password_input;
    if (password_input != password) {
      return 1;
    }
  }
  return 0;
}
```

再接下来，可能会有很多抽象，但是我觉得这一步也足够了。首先，它是一份面向过程代码，简明意赅，无法出错。虽然，它可能存在很多问题，甚至一点也不安全，但是这比直接返回`0`要更安全不是么？

我觉得到这里，两个重要的软件架构思想就已经足够易懂了。

所谓最简抽象，就是你的代码始终更专注具体的事情。这样代码会始终保持在众多提供该功能的可能性中，始终维持一个容易理解和修改的复杂度。举一个现实中的例子，著名的项目rsync在它的第一个Commit https://github.com/RsyncProject/rsync/blob/c627d61324e9dcd5df833ee6236dd10415f5bac4/main.c，就体现了这种思想。main函数思路清晰，展开了逻辑，并且甚至有部分逻辑沿用至今。

最简抽象的代码不是不作出抽象。例如，就以上代码，很容易作出正确的抽象。首先我们会有一个`Password Provider`抽象，它声明了某种获取密码的方式。然后，`password`和`// Check Password`可以抽象为`Password Provider`的一个方法，以防止密码明文出现在栈上。最后`username`可能不必抽象，在相当长的一段时间内维持从`cin`读入的代码。

所谓单体对象架构，就是有一个最主要的对象，它从`main`演化而来。众多抽象机制和对象栖居在这个对象上，生生灭灭。例如，Linux系统就保持了这样的架构，并获得了成功。

最简抽象的代码不一定遵守单体架构，但很容易整体或局部形成单体架构。我认为这样的代码是非常容易阅读和改进的。

== 模块化和敏捷开发思想

接着，我想将话题切入另一个极端，谈谈如何模块化。但是，我将要说的并不违背之前的逻辑。

我们不谈模块化具体如何实施。我想说的一件事是模块甚至接口是如何自然形成的。我们还是举一个极端例子。在巨型的项目中，我们该如何让一个功能具体而敏捷地落地。假设，我们已经说服了功能的必要性。根据最简抽象，我认为，最简单的方法就是首先引入一个flag，这个flag决定你写的简陋代码是否运行。然后在flag条件分支下，实现你第一份简陋代码。随着开发，直到项目的维护人员和你都觉得代码足够好了，再把flag移除或者默认打开。如果flag不移除，一个模块就自然诞生了。

这在某种程度上符合了敏捷开发思想，但是我认为却比敏捷开发要更直指核心。我们总是倾向于将代码逻辑以无争议的方式一步步引入项目并加以复杂化。如果你的代码完整实现预估有1万行，那么是否能拆分成20个PR。每个PR都只有500行，并恰当地将一部分代码复杂化，引入一些容易讨论的抽象。我想这将有可能比一次性PR所有内容要来得更快。因为，你可以中途还会额外写几万行自以为是的代码（至少未经讨论），可能都在最终的代码中移除。甚至，假设一个功能需要由1000行代码实现，如果我有一个方式允许将其拆分，并且先后都可以验证各自的正确性，由于*flag*技巧的存在性，我们不如将残缺功能的PR先提出来。首先，一部分代码在更早的时机开始被讨论。其次，还会减少完整功能PR在经历项目开发过程中遭到意外损坏而不得不修复的额外工作量。

我还想指出的是，移除抽象并非可耻或者令人尴尬的。为了敏捷开发，很多的时候我们会大致描摹可能的发展方向，并且适当引入复数的，相互合作的抽象。这个时候，如果有机会，在观察到一些可以合并的抽象的时候，根据最简抽象。别忘了我们还假设自己是笨蛋，那么便不总会作出正确的决定，那么这样的PR就应当会发生。这样，你的项目将更容易引入其他更合适的抽象或者实现。

不仅如此，一个成功的抽象，最终经常会被反复使用，甚至被滥用。这也符合最简抽象原则。在Linux系统中，一切皆文件是大家所熟知的。但是众多的文件实现中，有多少是真正无争议的文件也是大家心知肚明的。这其中的缘由就是，文件抽象是最详尽定义的，最广泛支持的。一旦你的对象实现了文件抽象，相当多的假设就引入了读你代码的人们的脑中。设想他们在《阅读和学习你的软件设计》，那么一旦读到你的对象支持文件抽象，那么这个对象可以如何在系统中被初始化和使用就变得不言而喻了。

== 后记

写得有点累了，为了防止博客难产（），我决定先将我的500行代码，以草稿的内容放出。后续还会对这篇博客分批多次进行更新。

// == 文档和类型标记

// == 大而粗放的集成测试

// == 小而精确的单元测试

// == 观察与测试程序的方法

// == 为你的软件组织一次报告会

// 接下来我会分为几点讲述软件工程方法：
// - 开发。作为一个软件项目，至少应该写点代码吧......
// - 测试。作为一个笨蛋写出的代码，不测试肯定会以很滑稽的方式运行把......
// - 运营。作为一个笨蛋写出的项目，不运营一下难道会主动有人来看你吗？

// 同时，我会按照时间顺序讲述，项目的各个阶段有哪些注意的点。

// - 起始阶段，这时我应该开始写项目或开始写项目的子项目。
// - 发展阶段，我要开始盘项目了，过程有可能惨不忍睹。
// - 优化阶段，项目写完了该是要改进一下。

// 同时，读者应该注意，接下来所有的策略都是可以灵活择取实施的。第一原则：无论做什么事情，都不如不做更简单，除非你觉得有什么用；第二原则：无论做什么，都应该以你开心为主。

// == 项目应该始终维持在稳定状态

// == 优先采用让项目更快恢复到稳定状态的方法

// == 优先采用更易定位问题的架构

// == 优先健康指标

// ==

// // == 在代码量为零的时候，我该如何起手

// // 个人有两种起手式，并且我推荐在不同的知识储备前提下使用不同的起手式。

// // 如果我对我的项目所使用的语言或框架很不熟悉，那么我应该先去找找有没有*任意*一个项目模板，甚至是成熟项目。对于流行语言或者流行框架，找一个模板是很容易的事情。如果能找到这样一个模板，我就避免了项目跑不起来的窘境。如果框架不是那么流行，那总该有至少有一个拿这个框架写的项目吧，找其中最多人用且较简单的项目起手则是较好的选择。

// // 这里还有一个重点。我应该先想办法不修改任何代码，把demo跑起来。如果demo跑不起来，说明我环境有问题。否则，应该检查环境。在环境有问题的情况下开始项目，有可能将来会为解决一些微妙的环境问题而花费一整天，甚至数天时间。

// // 如果我对语言或框架挺熟悉的，*而且我打算完成一个长期项目*，那么我应该从空项目或我完全掌握的模板开始，从编写我项目Build System脚本开始，一步步将一个最小可运行demo构建起来。

// // 这便是我们常说的跑demo阶段。

// // == 见微知著解决问题的能力

// // 从零开始，这也是非常重要的一点。我们可能不是高手，但仍然需要有对我将来项目中所能见的任何问题都有*见微知著*的能力。不然，糊里糊涂地开始将会为我们项目的发展埋下重大隐患。比如，将来可能发生耗费几天时间解决一个小问题的情况。

// // *见微知著*事实上是一个相对概念。说得哲学一点，究竟多微末的原因，才能反应一个显著的结论；究竟原因探究到何种程度，才能得到严格基础性的结论。这个程度，取决于我们的笨蛋值。拿米粒作比喻，一粒米不是一堆米，一千粒米应该是一堆米，但是具体到多少粒米算作一堆米，这个取决于客观米堆的形状和主观人的感受。真正的高手，恐怕能够不探究任何原因，就推断出可能问题所在。但很不幸，我们可能真的是笨蛋。

// // 那么，既然*见微知著*有这样的特性：我能把握项目的每个*层级*的细节就变得尤为重要。这些将会帮助我们更好地定位问题。同时还要知道，这个世界是残酷的，将来的问题有可能发生在任何*层级*上。如果定位错了*层级*，可能会灾难性地浪费漫长的时间解决简单的问题。项目是有*层级*的，所谓，环境上有构建系统，构建系统构建项目，项目包含组件，组件实现功能，功能由多个函数协作完成，函数内每个语句都负责一个*原子逻辑*。

// // 那么，*见微知著*的能力似乎也是可以后天改进的。想象当我遇到一个问题的时候，我应该*二分*地在大的层级上定位一个问题，然后在小的层级里捉到那导致的问题的寥寥几行代码。这就要求我们对是否在该层级下发生问题做出准确判断。

// == 为什么说从空项目开始是重要的

// 回到软件工程主题。这并非严格指一定要真的从空项目开始，这是指上手一个复杂的工程项目，你一定要对每一个步骤都非常熟悉。当然并非要一开始就掌握所有步骤的每个细节，但是若想成为高手，你最终仍需掌握每个细节。

// 这个掌握的过程，很有可能是分多层的广度优先搜索的过程。



// // - 天地未分，神写下了世界上第一个编译器，那个元编译器。
// // - 我用的编译器是由元编译器编译出来的。
// // - 我用的操作系统是由编译器编译出来的。
// // - 我的环境是由操作系统和Profile脚本构造出来的。
// // - 我的程序是由环境下的Build System构建出来的。
// // - 我的软件从一个程序入口开始，逐渐启动多个程序，为用户构建服务。
// // - 我的每个程序可能还有会话或状态，用户每次点击都要累计一个可能有问题的状态。

// // 用户在你写的酒吧软件，点了一盘炒饭，然后酒吧爆炸了。现在要探究问题了，问题又未必不是元编译器里的某个bug导致的。你可能会说：这怎么可能是高手代码导致的问题，肯定是我的代码有问题。不错，事实上，这句反驳已经反映了获得*见微知著*能力的方法。常规下只不过将第一个检查的点放在思考程序问题本身之上。

// // 设想你自己已经成功在你自己机器上成功用炒饭炸掉了酒吧，现在是解决问题时间。
// // - 如果曾经某个版本的酒吧软件不会有炒饭问题，那么我们可以定位到一个版本期间所作的事情导致了炒饭问题。
// // - 回退到版本：
// //   - 构建的软件复现了问题。很好，不是构建步骤及以前的问题。
// //   - 构建的软件没能复现问题。那么应该就是构建/发布脚本的问题，构建出了一个有问题的软件。
// // - 检查构建/发布脚本的修改情况。
// // - 检查构建机器。
// // - 一路检查到发电厂......


// == 起手式可能关乎到我们头发的数量
