优秀的软件设计往往显得平平无奇:浅谈软件设计的本质
sean goedecke
优秀的软件设计往往显得平平无奇
多年前,我花了很多时间来评估编程挑战。挑战本身非常直接——构建一个 CLI 工具,该工具访问 API,并允许用户分页和检查数据。我们允许使用任何语言,所以我看到了各种各样的方法1。 有一次,我遇到一个挑战,我认为它简直是完美的。它是一个单独的 Python 文件(总共可能只有三十行代码),以非常朴实的方式编写:满足挑战要求的最简单、最直接的方式。 当我把它发给另一位评审员,建议我们可以把它作为 10/10 分的参考点时,我真的震惊地听到他们说,他们不会让这个挑战通过进入面试。 根据他们的说法,它没有充分展示对复杂语言特性的理解。 它 太 简单了。 多年以后,我更加确信我是对的,而那位评审员是错的。 优秀的软件设计应该非常简单。 我想现在我终于可以开始阐明原因了。
消除风险
每个软件系统都有很多可能出错的地方。 有时这些被称为系统的“失效模式”。 这是一个示例:
- SSL 证书过期且未续订
- 数据库已满,变得太慢或内存不足
- 用户数据被覆盖或损坏
- 用户看到损坏的 UI 体验
- 核心用户流程(例如保存记录)无法工作
有两种方法可以围绕潜在的失效模式进行设计。 第一种是被动的:在有风险的代码块周围添加救援子句,确保重试失败的 API 请求,设置优雅降级,以便错误不会炸毁整个体验,添加日志记录和指标以便可以轻松识别错误,等等。 这是值得做的。 事实上,我认为这种(坦率地说偏执的)态度是经验丰富的软件工程师的标志。 但是以这种方式工作并不是良好设计的标志。 这通常表明你正在掩盖糟糕设计中的缺陷。 处理潜在失效模式的第二种方法是通过设计消除它们。 这在实践中意味着什么?
保护热路径
有时这意味着将组件移出热路径。 我曾经在一个目录端点上工作过,由于其他设计选择,该端点效率极低,每个记录大约需要 200 毫秒。 这使我们面临一些严重的失效模式:应用程序其余部分的资源不足、索引请求的代理超时,以及用户在等待 10 秒后放弃。 我们最终将端点构造代码移动到 cron 作业中,将结果放入 blob 存储中,并让目录端点为 blob 提供服务。 我们仍然有糟糕的每记录 200 毫秒的代码,但现在它在我们的控制之下:它不能被用户操作触发,如果它失败,最坏的情况是我们只会提供一个陈旧的 blob。
移除组件
有时这意味着完全减少组件的使用。 我工作的另一个服务是一个文档 CRM,它有一个非常定制的系统,用于从不同的存储库中提取各种文档片段,并将它们拼接成数据库条目(有时直接从代码注释中提取文档)。 这最初是一个不错的决定 - 当时,很难让团队编写任何类型的文档,因此系统必须具有最大的灵活性。 但是随着公司的发展,它已经非常明显地显示出它的年代。 同步作业将一些状态存储在数据库中,并将一些状态存储在磁盘上,并且当磁盘上的状态不同步或底层主机内存不足时,经常会触发奇怪的 git 错误。 我们最终完全删除了数据库,将所有文档转移到中央存储库中,并将文档页面重新设计为一个普通的静态站点2。 各种可能的运行时和操作错误都消失了,就像那样。
中心化状态
有时这意味着规范化你的状态。 最糟糕的失效模式之一是导致你的状态(例如,你的数据库行)处于不一致或损坏状态的错误:一个表说了一件事,但另一个表说了不同的事情。 这很糟糕,因为修复错误只是工作的开始。 你必须进去修复所有损坏的记录,这可能涉及一些侦查工作,以弄清楚正确的价值应该是什么(或者在最坏的情况下,猜测)。 这样设计,使你状态的关键部分具有单一的真理来源,通常值得承担很多其他的痛苦。
使用稳健的系统
有时这意味着依赖于经过实战考验的系统。 我最喜欢的例子是 Ruby webserver Unicorn。 这是你可能在 Linux 之上构建 webserver 的最直接、最不复杂的方式。 首先,你获取一个在套接字上侦听并一次处理一个请求的服务器进程。 一次处理一个请求不会扩展:传入的请求将在套接字上排队,速度快于服务器清除它们的速度。 那么你该怎么办? 你 fork 那个服务器进程很多次。 由于 fork 的工作方式,每个子进程都已经在原始套接字上侦听,因此标准的 Linux 套接字逻辑会处理在你服务器进程之间均匀地传播请求。 如果出现任何问题,你可以杀死子进程并立即 fork 另一个进程。 有些人认为喜欢 Unicorn 有点傻,因为它显然不如线程服务器可扩展。 但我喜欢它有两个原因。 首先,因为它将如此多的工作交给进程和套接字 Linux 原语。 这很聪明,因为它们非常可靠。 其次,因为 Unicorn 工作进程真的很难对另一个 Unicorn 工作进程做任何讨厌的事情。 进程隔离比线程隔离可靠得多。 这就是为什么 Unicorn 是大多数大型 Rails 公司选择的 webserver:Shopify、GitHub、Zendesk 等。 优秀的软件设计并不意味着你的软件具有超高性能。 这意味着它非常适合任务3。
总结
优秀的软件设计看起来很简单,因为它在设计阶段尽可能多地消除了失效模式。 消除失效模式的最佳方法是_不要_做一些令人兴奋的事情(或者如果可以,什么都不做)。 并非所有的失效模式都是一样的。 你要尽最大努力消除真正可怕的那些(如数据不一致),即使这意味着在其他地方做出稍微笨拙的选择。 这些都是相对枯燥、不吸引人的想法。 但优秀的软件设计是枯燥而不吸引人的。 很容易对 CQRS 或微服务或服务网格等大创意感到兴奋。 优秀的软件设计看起来不像大的、令人兴奋的想法。 大多数时候,它看起来根本不像任何东西。
- 顺便说一句,事后看来,我认为这是一个不公平的决定。 显然,审查员最熟悉公司使用的语言,因此以其他语言(例如 Java)提交的候选人处于劣势。 我们当时确实尝试减轻这种情况,但最好告诉人们用一些常见的语言来完成它。 ↩
- 该项目的另一个有趣的故事:我们使用数据库支持的会话来存储用户登录信息,但无法清理它们。 当我们尝试将原始应用程序移动到新的平台基础设施层时,我们发现了这一点,并且数据库转储包含了一千万个会话行。 ↩
- 在大多数情况下,是为了赚钱。 ↩
March 7, 2025 posts │ resume │ github │ linkedin │ rss │ what I'm working on now