+++ title = '整洁架构之道' slug = 'clean-architecture' tags = [ "软件", ] authors = ['Robert C. Martin'] rating = 3 date = 2026-04-21 10:43:27 cover = 'https://img.eltr.ac/geedeapro/library/2026/clean-architecture/cover.png' draft = false toc = true toot = 'https://akk.eltr.ac/objects/d74803e3-8194-4fcc-b0de-30ab0ef07955' +++ 由于很不喜欢软件架构课老师的授课风格,所以决定完全自学。选这本书当教材可能不太合适,但的确收获颇多。眼看 Coding Agents 的趋势只增不减,就算在个人项目和自由软件项目中拒绝使用,工作时也无处可躲,我想每个程序员都需要做架构师了。如果缺乏整洁清晰的架构设计,那 Coding Agents 就只是在高速产出屎山代码而已,后续的维护只会是一场接一场燃烧 Token(或者说燃烧美元)的灾难。总之有必要关注架构问题。 --- ## 对翻译的批评 丑话先说在前头。书很好,但翻译是真的烂,只给这本书三分也完全是因为翻译问题。尽管我理解翻译技术类书籍的人都是从业者而非专业译者,对翻译质量有一定容忍度,但本书的四位译者实在是缺乏最基本的素养和**素质**,我读着非常恼火。 在第 27 章《服务:宏观与微观》中,图 27.1 的文字标注全是英文,比如 Taxi Selector,这没问题,因为编写程序时就会用这些单词命名。有问题的是,在几个段落过后的图 27.2 中,只有 Taxi UI 是英文,其他的全都变成了「出租车匹配」之类的词。就算抛开出现在同一章节的两张图前后不一致不谈,同一张图里也是中英文混杂,明明是相同的元素,有的翻译了,有的却没翻译。更不谈图表之外的文字段落中使用的还是 Taxi Selector 等英文单词,读者要怎么把图表中的元素和文字段落中描述的对象联系起来?文字段落里有的是英文原文 Taxi Selector,有的又用的是译名,比如「出租车分配」(原文是 Dispatcher,居然不是「分派器」吗?)。真不知道要干嘛。 在图 27.3 中,原本还被翻译成「小猫」的 Kitty 原样出现在图中,这次更好笑,图里写的是「kitty 查找」「kitty 来源」「kitty 匹配」。难道这个 Kitty 和前文的「小猫」是不同概念吗?做到术语统一对专业软件工程师来说不难吧?做到语言统一对译者来说也不难吧?在图 27.4 中,又出现了「小猫匹配」这个词。 最让我感到气愤的还不是翻译问题。前面的只能说是「缺乏译者素养」,接下来要提的问题就纯粹是「没素质」了。 同样是第 27 章,这一章的最后一节是「本章小结」,原文只有两段话,如下: > As useful as services are to the scalability and develop-ability of a system, they are not, in and of themselves, architecturally significant elements. The architecture of a system is defined by the boundaries drawn within that system, and by the dependencies that cross those boundaries. That architecture is not defined by the physical mechanisms by which elements communicate and execute. > > A service might be a single component, completely surrounded by an architectural boundary. Alternatively, a service might be composed of several components separated by architectural boundaries. In rare2 cases, clients and services may be so coupled as to have no architectural significance whatever. 然而在译本中,这一节有五段话。在原本应该结束的地方,译者毫无预警、肆无忌惮地把自己想写的东西紧跟着写在了译文的后面,看起来就像那些文字本来就在那个地方一样: > Bob 大叔的案例非常精彩,确实,……*(此处省略三四段极其平庸的论述)* 在本就不应该出现在这里的三段话的最后,译者恬不知耻地写上了「——译者注」。我想这位译者不仅英文翻译水平值得怀疑,中文理解能力也需要打一个问号。因为我非常确定,「译者注」一词中的「注」表示「注释」,而不是「正文」。在我的理解里,译者注存在的意义是**辅助读者理解文本**,如果译文没有跨文化理解问题,或者不需要交代什么背景信息,那译者注就不该写;译者注绝对不是译者对同一话题的理解或对文本的解读,更不是**对原文的入侵**。 倘若译者真的觉得自己有什么值得一读的见解,完全可以写在译者序或者后记里(*这样方便我直接跳过*),或者自己写一本书,而不是在别人的书里「借楼打扰」。 我读的译本是机械工业出版社出版的,我找到了由电子工业出版社出版,由孙宇聪翻译的译本,后者的第 27 章小结只有原文出现过的两段,没有多余的内容。孙译本也没有翻译插图,文字段落中使用的名称也是英文原文,术语是统一的。此外,机械工业出版社的译本中其他的很多大大小小的问题,在孙译本里都没有出现,比如第 34 章《The Missing Chapter》被我手上的译本译作「细节决定成败」,我真的哭笑不得,孙译本的翻译是「拾遗」,不一定是最好的,但至少和原文不至于毫无关联。 总之,如果你想读这本书,我推荐直接读《Clean Architecture》英文原本;如果要读译本,请选择电子工业出版社的孙宇聪的译本,绝对不要读机械工业出版社这四个译者译出来的垃圾译本。 需要注意的是,我不幸读到的译本叫作《整洁架构之道》,而孙译本叫作《架构整洁之道》。前者发行年份更晚,是 2024 年发行的,更推荐的那本是 2018 年发行的,不知道是不是为了故意混淆才这样命名。我记得 Bob 大叔的另一本《Clean Code》的译名是《代码整洁之道》。 --- ## 架构是永恒的 > 当你仔细观察编程实践时,你会意识到在过去 50 年里几乎没有什么变化。编程语言变得更好了,工具也变得更加出色了,但计算机程序的基本构建块并没有变化。 编程语言在更新迭代,编程范式和开发模式也在推陈出新,但组织软件系统的方式、定义组件边界的方式、软件设计的基本原则、管理依赖关系的方式,几乎都没有改变。架构是永恒的,此外,良好的架构也能让延长软件的生命周期。 Bob 大叔认为软件有两种价值维度:行为价值和架构价值。前者是大多数人更关心的「软件能做什么」,非开发人员希望软件能更快地推出新功能,也就是允许更多的行为。然而,架构价值可能比行为价值更重要,因为架构决定了开发效率、维护难度和变更成本。如果急着满足需求,实现行为价值,而使用了不合理的架构,那么后续增加新功能时要修改的代码就会越来越多,还有可能出现修改了某一段代码导致另一段代码不可用,需要继续修改的情况。我想这有点像一些人说的「慢就是快」,一开始求快只会在整体上拖慢速度,甚至最终不得不重头来过。 书本还用艾森豪威尔矩阵解释了两种价值维度的关系,这个矩阵就是生产力爱好者常说的「重要且紧急」「重要不紧急」「不重要但紧急」和「不重要不紧急」的划分。架构属于「重要不紧急」的范畴,而软件行为是普通人更关注的「不重要但紧急」的事项。至于另外两个象限是什么:紧急的事情永远不重要,重要的事情永远不紧急。 架构往往和变更成本联系在一起,如果一开始使用了不好的架构,那么变更就会变得很难,添加新代码会很难,重构的成本也很高。如果一开始使用了良好的架构,那么整体而言软件开发的速度会更快,变更的成本也更低。好的架构往往是灵活的架构,而坏的架构很难改变,在这个意义上,架构也是永恒的。 ## 编程范式、设计原则和组件原则 本书的第 3、4、5、6 章讲的是编程范式,主要有三种:结构化编程、面向对象编程和函数式编程。前两种大多数程序员应该都很熟悉,而函数式编程通常是 Lisp 实现的编程范式。三种范式并没有给编程增加什么新东西(可能有一些新概念,但实际上没有范式也能做到这些概念做的事情),相反,他们限制了某些东西。 1. 结构化编程限制了对程序控制权的直接转移,也就是 `goto` 语句的使用,认为程序由顺序、分叉和循环几种基本结构组成,这样就能把程序分成可证伪、可测试的基本单元;在以前,`goto` 语句有害还不是共识,那个时候人们对 `goto` 的反对者 Dijkstra 冷嘲热讽。 2. 面向对象编程限制了对程序控制权的间接转移,主要是限制函数指针的使用,因为指针很危险;所谓的封装、继承和多态,在 Bob 大叔眼里都不是 OOP 的新东西,这些都可以通过操作函数指针实现;OOP 的另一个好处是可以很方便地实现依赖反转,让其他组件成为业务规则的插件,而不让核心的业务规则知道其他技术细节的存在。 3. 函数式编程限制了赋值操作,变量通常是不可变的(不过我印象里像 Fennel 这样的 Lisp 方言并不是纯血函数式编程,可以正常声明和操作变量,只是有括号的 Lua 语言而已);函数式编程的一个思想是,如果有足够的空间和储存能力,就可以用不可变、纯函数的方式实现程序,只存储事件记录而不储存状态,Git 就是这样的。 --- 第 7 到 11 章讲的是 SOLID 设计原则,即: 1. Single Responsiblity Principle(单一职责原则) 2. Open Close Principle(开闭原则) 3. Liskov Substitution Principle(里氏替换原则) 4. Interface Segregation Principle(接口隔离原则) 5. Dependency Inversion Principle(依赖反转原则) 所谓单一职责,并不是说一个模块只做一件事情,因为「一件事情」其实很难定义。准确的定义是:一个模块只对一个行为主体负责,或者说,一个模块只因为一个主体发生变更。具体来讲,要分离不同主体相关的代码,不同的主体不应该依赖同一个模块,这样一个主体的需求变更而引发的代码更改就不会影响到另一个主体。 所谓的开闭原则,是指软件应当对拓展开放,对修改关闭。所有组件的依赖关系都应该是单向的,最终指向不会经常更改的组件。这个没有任何依赖的组件往往是核心业务逻辑,与任何技术细节都无关,而数据库和用户界面等技术细节应该在外围,去依赖业务逻辑。如此一来,用户界面就作为「插件」存在,是对业务逻辑的拓展,可以随时拓展更多的其他用户界面,比如分出桌面端应用和 Web UI,这都不会导致核心业务逻辑的修改。 所谓的里氏替换原则,主要应用在 OOP 编程,用来指导「继承」的使用。比如,Square(正方形)不能作为 Rectangle(矩形)的子类,因为不符合 LSP。这两个类若是相互替换,软件的行为就会发生改变,参考一下代码: ```java Rectangle r = ... r.setW(5); r.setH(2); assert(r.area() == 10); ``` 如果 r 是 Square 类型,因为是 Rectangle 的子类,所以能通过类型检测,但最终 assert 会失败。 所谓的接口隔离原则,是为了避免依赖不需要的东西。假设 `User1` `User2` `User3` 分别依赖 `OPS` 对象里的 `op1()` `op2()` `op3()` 方法,更好的做法是创建 `U1Ops` `U2Ops` `U3Ops` 这三个接口,由 `OPS` 实现这三个接口。三个 `User` 去依赖各自的接口,避免直接依赖 `OPS`,因为它提供了许多自身不需要的东西。如果不这么做,`OPS` 的更改就会导致三个 `User` 都需要重新部署,若不直接依赖 `OPS`,就不会有这个问题。 最后是依赖反转原则,这是说源代码应该多依赖抽象类型而不是具体实现,这有点像前面的开闭原则和接口隔离原则,代码的依赖关系应该最终指向最抽象、最不容易发生变更的核心业务逻辑,同时避免依赖自身不需要的东西。这能避免不必要的代码变动,以及因为所依赖的组件发生变更而不得不重新编译和部署多个组件的繁琐工作。 常见于 Java 等 OOP 语言里的工厂设计模式也是为了依赖反转而服务的,也有了先写 `XXXService.java` 接口,再写 `XXXServiceImpl.java` 实现类的做法,这样可以使源代码依赖于抽象工厂和抽象的服务接口,而不直接依赖易变的具体实现;而 `ServiceImpl` 和 `ServiceFactoryImpl` 等类由于是对接口的实现,依赖关系也是从具体实现指向抽象接口的,保证依赖关系从具体指向抽象。 以前我总觉得这种编程模式很繁琐,如今也算是理解了一些背后的原因。这么说来,我的 Java 老师还真是一点没讲清楚。我想这样的 Java 开发者应该不占少数,这才是 Java 这门语言令我厌烦的根本原因吧。无脑复用某种软件组织方式,不明白这么做的目的,违反了某些原则也不自知,最终的软件架构照样很乱。 --- 第 12、13 和 14 章讲的是组件原则,包括组件的历史、重定位和链接器等技术,以及组件内聚和组件耦合相关的原则,不只是「高内聚低耦合」那么简单。 组件内聚主要要保证复用/发布等价原则(REP)、共同闭合原则(CCP)和共同复用原则(CRP)。REP 如今已经成为事实标准了,它说的是「没有通过发布流程获得版本号的组件是无法复用的」,主流编程语言几乎都有发布组件的标准流程,锁定版本号以保证组件行为可预测也很重要。CCP 是单一职责原则的组件版本,即「将相同变更原因而需同时更改的东西放在一起,将不同变更原因或无需同时更改的东西分开」。CRP 简单来说就是「不要依赖不需要的东西」,如果依赖某个组件,最好是依赖该组件的每一个类,否则无关的代码变更可能影响自身。 组件内聚的三个原则构成了一个「不可能三角」,需要架构师权衡取舍,一般来说只能同时实现两个。如果牺牲 REP,就要面临组件难以复用的后果;如果牺牲 CCP,就可能会出现太多不必要的版本发布;如果牺牲 CRP,就会出现太多的组件变更。 组件耦合的三个原则是无依赖环原则(ADP)、稳定依赖原则(SDP)和稳定抽象原则(SAP)。ADP 就是不应该出现循环依赖,这个很好理解。SDP 提出了一个稳定性指标,依赖的组件越少、被越多组件依赖的组件越稳定,否则越不稳定,而组建的稳定性指标应该沿着依赖关系递减,即依赖关系最终指向最稳定的组件(往往是核心业务逻辑)。SAP 是说,稳定的组件应该是抽象的,有多稳定就有多抽象。 稳定但具体的组件维护起来是痛苦的,这部分应该是数据库 Scheme 和 `string` 之类的少数易变组件;非常抽象但不稳定的一般是那些没有被实现的抽象类,是无用的;而中间的那部分、稳定且高度抽象,或者不稳定但十分具体的组件,应该占据大多数。 --- 以上各种原则和概念名称都是深入理解软件架构的基础,我个人感觉 Bob 大叔非常强调依赖反转原则,它的确也串联起了很多其他的设计原则和组件原则。如果上面的内容太多太杂,只要记住核心业务逻辑应当不依赖任何组件,程序的其他部分应当作为业务逻辑的插件存在,去依赖业务逻辑而不影响它。这和「软件应该与设备无关」的思想是同源的。 ## 尽可能推迟细节决策 软件分为策略和实现细节。细节是软件中最「脏」的部分,包括如何操作数据库(使用 SQL 还是 NoSQL,编写什么 SQL 语句,甚至直接使用文件系统而不用数据库)、如何呈现用户界面(怎么把数据传送给前端、前端怎么展示数据)等等,甚至包含如何与硬件打交道(不过这可能是操作系统和固件的活儿了)。软件最脏的部分一般是 `Main` 组件,它是程序的入口,调度和加载所有必要的资源。 策略往往是高度抽象的,包含实体和用例。实体一般是指核心业务逻辑实体,比如一个员工管理系统里可能就有 `Employee` 实体,这里包含最核心的数据和操作。所谓核心业务逻辑,就是没有计算机、没有自动化系统,也能为企业产生价值的操作,比如银行没有自动系统,也会请专人来做必要的计算。而用例,就是有了软件之后才产生的东西,一般是站在用户视角的,比如「用户应当能通过系统提交工时数据」就是一个用例。 编写软件时,最先写的应该是最抽象的核心业务逻辑,其次是用例,定义用户如何与实体交互,最后才决定要不要用数据库、用什么数据库系统、用什么应用程序框架、要用 Web 还是桌面端 GUI——换句话说,应该尽可能推迟细节决策。 为什么?首先,业务逻辑是最重要的,是给软件创造价值、给企业赚钱的东西,而技术细节可以随时更换,并不重要,使用什么类型的数据库并不会显著影响软件行为。其次,尽量推迟决策可以让架构师有更多的时间收集信息,决定这个系统更适合用什么技术。如果架构足够灵活,技术细节就仅仅作为业务逻辑的「插件」存在,可以随时更换。 在开发初期如果没有数据库和用户界面,要怎么调试程序呢?很简单,让实体和用例使用抽象接口,比如编写 `Storage` 接口,然后实现一个 `InMemoryStorage` 或 `MockStorage` 类,等决定要好用什么数据库了,再编写 `MySQLStorage` 等具体实现就好,业务逻辑和用例的代码完全不需要修改。如果要给软件添加更多的存储方案,还可以再写 `FileStorage` `PostgreSQLStorage` 等等,这就是技术细节作为「插件」的思想。 此外,由于最抽象的业务逻辑不应该依赖比他更具体的组件,这时候依赖反转和依赖注入就派上用场了,使用这种编程模式就能让更具体的组件依赖业务逻辑。 总而言之,先编写最抽象、最高层的组件,忽略技术细节,尽可能推迟细节决策。 ## 良好的架构是灵活且可测试的 我原本以为各种测试方法是为了保证软件质量而不得不采取的措施,通过让测试编写者和开发者慢下来,更仔细地思考如何写代码。可 Bob 大叔在书里表示,使用测试驱动开发,在短期和长期都会让开发速度更快。我想这是因为写好测试用例之后就了解输入和预期结果是怎么样的,编写算法实现也就更符合直觉。 我读技术类书籍往往很慢,一方面我需要更多的时间消化信息,另一方面,尽管这类书读起来也有醍醐灌顶的感觉,但整体上不如文学作品吸引我。所以,这本书我花了一两个月来读,这期间我也有开发一些玩具软件,每往后读一点,我就觉得我的软件架构问题越来越多,首当其冲的就是「不可测试性」。 大概是被 Lisp 系语言的 REPL 宠坏了,我从不编写单元测试(甚至有段时间会让 Coding Agent 帮我写测试),在本地测试的时候能跑通就行,导致我很多时候是把代码推送到生产环境之后才发现问题。最近我想要给 [Weepinbell](https://codeberg.org/eltrac/weepinbell) 加上单元测试,Clojue 和 Leinigen 提供了很好用的测试工具,但我发现我的架构不允许我对单个组件进行测试。 首先,我的大部分组件都依赖 `config` 组件,它直接从配置文件里读取配置。 ```clojure (defn load-config [] (let [default-config (when-let [resource (io/resource "config.edn")] (-> resource slurp edn/read-string)) external-file (io/file "config.edn") external-config (when (.exists external-file) (-> external-file slurp edn/read-string))] (merge default-config external-config))) (def config (delay (load-config))) (defn get-config [key & keys] (get-in @config (cons key keys))) ``` 我想先从最简单的 Webhook 组件下手,这个组件只提供 `trigger` 方法,从配置里读取 Webhook URL 然后向它们发送 HTTP 请求。 ```clojure (defn trigger [keyw] ;; keyw = on-receive, on-create, on-update, on-delete (when-let [hooks (get (get-config :webhook) keyw)] (->> hooks (filter string?) (filter valid-http-url?) (map #((client/post %) (timbre/info "Webhook triggered: " %))) doall))) ``` 假设我要测试 Webhook 能否成功发送,那我要做得就是在测试代码里启动一个接收 Webhook 的测试用服务器,让 `webhook/trigger` 给测试服务器发送请求,测试服务器判断有没有收到请求。问题来了,我要怎么改变 `(get-config :webhook)` 的取值呢?它只能从配置文件里读取配置。这个组件和 `config` 组件是耦合的,而我没有提供其他注入配置的方法。 如果我要解决这个问题,我就必须重构,其他调用 `get-config` 的组件大概率也要做修改。这就是不好的软件架构带来的问题,软件变得难以测试,重构又会导致大量的代码变更。 很快我又发现了更多的问题,那就是用于处理 Webmention 请求的 `processor` 应该是抽象的核心业务逻辑,然而他却依赖更具体、更脏的 `webhook`,我在一开始没有考虑到依赖反转原则。 `config` 的问题也可以用依赖反转解决,应该想办法让配置被「注入」到业务逻辑中,而不是让业务逻辑调用 `get-config`、依赖这个更具体的组件。我的代码里,业务逻辑依赖了太多细节,导致架构变得凌乱,不够整洁,也增加了维护难度,还让软件变得难以测试、容易出现缺陷。 而这样的代码是大部分人(包括 Coding Agent)都很容易写出来的代码,如果一开始不从宏观层面考虑架构问题,就只会在开发中后期才意识到问题所在,那个时候就已经晚了。一开始就应该考虑架构的灵活性、可测试性和软件的可维护性。 ## 依赖关系和可维护性 整洁架构的目的是让软件系统变得灵活(易于变更)、容易测试(证伪软件缺陷)、保证软件质量、保证可维护性。看起来架构的关键要素之一就是依赖关系,之前一直在谈依赖反转、无循环依赖等原则。我想这是因为,如果软件没有被分成多个相互依赖的组件,就没有架构存在。架构(archtecture)的本意就是建筑,是把软件一个一个有序地堆叠在一起的方法。 自己编写的软件内部的组件相互依赖关系很重要,软件依赖的第三方库也很重要。前面提到组件耦合的共同复用原则(CRP),要求如果某个组件依赖另一个组件,最好要使用其中所有的类和方法,否则依赖的组件只要发生了一点改变,哪怕自己没有依赖更改的这一部分,也需要升级依赖、重新编译和部署软件,有时候甚至要变更代码。这会提升工作量。 读到这里的时候我立马想到了 JavaScript 开发者的现状:大部分的工作都是在维护依赖,软件几乎没有真正「稳定」的时候,如果几个月都不升级就会被淘汰。我想很大一部分原因是 JavaScript 开发者没有遵循共同复用原则,依赖了大量不需要的代码。 对比 Go 语言这边,Rob Pike 说:[A little copying is better than a little dependency.](https://farside.link/invidious/watch?v=PAAkCSZUG1c&t=9m28s) 比起依赖会动态变化的软件库,直接复制粘贴自己需要的那一小部分代码更好,因为写在源代码里的东西是静态的、稳定的,不会改变。保持依赖树的整洁和精简有助以提高软件的可维护性。 ## 微服务不是架构、应用程序框架不是架构 许多人觉得使用了微服务就默认拥有了一套架构,因为服务是独立开发、独立部署的,所以一定符合「高内聚,低耦合」的原则,但实际上并非如此。微服务架构本质上是把软件组件之间的函数调用变成了网络通讯,数据通过序列化的对象在网络之间传递。Bob 大叔举了某个产品的例子,它们一开始使用这种通信方式,可后来发现客户根本没有独立部署服务的需求,软件在本地的一台机器上通过网络互相传递数据。因为边界已经确定,修改起来很困难。 由于改变的只是组件之间的通信方式,并没有改变依赖关系,所以组件之间仍然有可能是耦合的。微服务架构并没有提供一套默认的整洁架构。 不少应用程序框架(比如 Spring)都有一套架构思想,并期望开发者使用,并将他们的代码完全与框架耦合。然而,这些框架尽管方便,但并不一定适合所有软件系统。应用程序框架不是架构,相反,它只是实现细节,和数据库一样,应该作为业务逻辑的插件存在。也因此,应该推迟「使用什么程序框架」的决策。 这让我反思,我在编写前端项目时做的第一件事就是选择框架,用框架自带的脚手架搭好项目结构才开始写代码。后端也是,在学校里教的 Java Web 开发,几乎就是 Spring Boot 的框架知识,都是先选择了 Spring Boot 框架之后才开始写代码、才开始考虑架构,而此时的架构就是完全与框架这个技术细节耦合的。似乎很少有人意识到,框架不是架构,使用框架不代表有良好的架构这个事实。甚者,和 Java 开发者离开了 IDEA 不会写 Java 代码一样,离开了框架一些人也不会开发程序了。 前端开发或许有其特殊性(因为按照书里的说法,前端作为 GUI,本来就是技术细节),但如果要考虑整个软件的架构,应该先从业务逻辑开始,再考虑组件之间如何通信、要不要用微服务架构,以及要不要用 Web、要不要用框架的问题。 --- ## 最后 1. 去读电子工业出版社出版、孙宇聪翻译的《架构整洁之道》,或者直接读《Clean Architecture》英文原本,不要读机械工业出版社的这本《整洁架构之道》。 2. 软件的依赖关系应该指向最抽象、最稳定的组件,即核心业务逻辑。 3. 数据库只是技术细节、Web 只是技术细节、应用程序框架只是技术细节,应该尽可能推迟细节决策。 4. 好的架构是灵活且可测试的,测试和文档一样也是软件的一部分。