Go工程化(一) 架构整洁之道阅读笔记

注:本文已发布超过一年,请注意您所使用工具的相关版本是否适用

本系列为 Go 进阶训练营 笔记,访问 博客: Go进阶训练营, 即可查看当前更新进度,部分文章篇幅较长,使用 PC 大屏浏览体验更佳。

其实这一篇文章不应该算在这里面,(PS: 毛老师课程上没讲这本书)但是恰好最近把这本书读完了,并且部门内推荐大家读这本书,毛老师在课上也推荐这本书,也和我们这次的主题有一些关系,一切都是最好的安排,那就放这系列吧。

阅读建议: 全文接近 2W 字,篇幅较长,采用书中重点摘录+不成熟的个人小结组成,桌面端可以点击右侧目录快速定位到你感兴趣的章节

读书笔记

前言

  • 今天的软件与过去的软件本质上仍然是一样的。都是由 if 语句、赋值语句以及 while 循环组成的
  • 软件架构的规则其实就是排列组合代码块的规则
    这说明什么呢,说明了可能我们以为过时的,古老的技术或者解决方案也是有用的

第一部分 概述

第 1 章 设计与架构究竟是什么

  • 架构图里实际上包含了所有的底层设计细节,这些细节信息共同支撑了顶层的架构设计,底层设计信息和顶层架构设计共同组成了整个房屋的架构文档。
  • 软件架构的终极目标是,用最小的人力成本来满足构建和维护该系统的需求。
  • 一个软件架构的优劣,可以用它满足用户需求所需要的成本来衡量。
  • 乱麻系统:这种系统一般都是没有经过设计,匆匆忙忙被构建起来的
    • 我们经常使用一句话来欺骗自己“我们可以未来再重构代码,产品上线最重要!”
    • 另外一个错误的观点:“在工程中容忍糟糕的代码存在可以在短期内加快该工程上线的速度,未来这些代码会造成一些额外的工作量,但是并没有什么大不了”
  • 研发团队最好的选择是清晰地认识并避开工程师们过度自信的特点,开始认真地对待自己的代码架构,对其质量负责
    软件的架构的终极目标,以及如何衡量一个架构的优劣,尤其是两个错误的观点非常感同身受,我也说过类似的话语,还有一句话是“当前的需求非常紧急,这只是一个临时的系统很快就会被替换掉,我们先完成它”。作为一个专业的技术人员我们需要有一些底线来保证我们的代码架构和质量,不能轻易妥协,这在 Bob 大叔整洁系列的另外一本书中也有提到。

第 2 章 两个价值纬度

  • 行为价值

    • 软件系统的行为是其最直观的价值维度。程序员的工作就是让机器按照某种指定方式运转,给系统的使用者创造或者提高利润。
    • 按照需求文档编写代码,并且修复任何 Bug。这真是大错特错。
    • 系统行为,是紧急的,但是并不总是特别重要。
      只有可以产生收入的代码才是有用的代码,技术是需要为业务服务的,但是我们的工作并不是说就按照需求文档写代码,修bug就行了
  • 架构价值

    • 为了达到软件的本来目的,软件系统必须够“软”——也就是说,软件应该容易被修改。
    • 当需求方改变需求的时候,随之所需的软件变更必须可以简单而方便地实现。
    • 变更实施的难度应该和变更的范畴(scope)成等比关系,而与变更的具体形状(shape)无关。
    • 系统架构,是重要的,但是并不总是特别紧急。
      架构价值主要就是为了能够应对变化,其实举个反面例子,我们之前有一个系统 A 是直接在 A 中调用接口获取数据,随着业务的发展我们拆分了一个应用 B 需要从 B 中获取对应的数据,这个时候我们发现代码变更非常严重,从里到外都需要进行重构修改,这就是典型了依赖了“具体的形状”导致的额外成本
  • 重要紧急的排序

    • 重要且紧急
    • 重要不紧急
    • 不重要但紧急
    • 不重要且不紧急
  • 业务/市场的同事往往是无法评估架构的重要性的,所以,平衡系统架构的重要性与功能的紧急程度这件事,是软件研发人员自己的职责。

    我们当前处在公共技术的部门,这也是一个经常困扰的一个例子,所有的业务方在提需求的时候都会表示需求非常紧急,但是这个功能的实现对我们来说重要吗?这个需要打上一个大大的问号,其他部门的同学其实是无法对评估需求对于我们的重要性的,这个需要我们自己来权衡。
  • 为好的软件架构而持续斗争

    • 软件架构师这一职责本身就应更关注系统的整体结构,而不是具体的功能和系统行为的实现。
    • 软件架构师必须创建出一个可以让功能实现起来更容易、修改起来更简单、扩展起来更轻松的软件架构。
    • 如果忽视软件架构的价值,系统将会变得越来越难以维护,终会有一天,系统将会变得再也无法修改。
      这不仅仅是架构师的职责,这是每一位开发同学的职责,忽略架构的价值会导致我们带来无休止的加班,领导的质疑,产品的argue

第二部分 从基础构件开始:编程范式

编程范式指的是程序的编写模式,与具体的编程语言关系相对较小。这些范式会告诉你应该在什么时候采用什么样的代码结构
当前的三种编程范式,结构化编程,面向对象,函数式编程

第 3 章 编程范式总览

  • 结构化编程(面向过程)
    • 结构化编程对程序控制权的直接转移进行了限制和规范。
    • 限制了 goto 语句的使用
  • 面向对象
    • 面向对象编程对程序控制权的间接转移进行了限制和规范。
    • 限制了函数指针的使用
  • 函数式编程
    • 函数式编程对程序中的赋值进行了限制和规范。
    • 限制了赋值语句的使用
      这个角度之前还没有看到过,对我而言还是比较新奇,从限制的角度来看不同的编程范式有着不同限制,可以减少在编程当中出错的可能

第 4 章 结构化编程

  • Bohm 和 Jocopini 刚刚证明了人们可以用顺序结构、分支结构、循环结构这三种结构构造出任何程序。
  • 证明了我们构建可推导模块所需要的控制结构集与构建所有程序所需的控制结构集的最小集是等同的。
  • 结构化编程范式可将模块递归降解拆分为可推导的单元,这就意味着模块也可以按功能进行降解拆分。
  • 测试只能展示 Bug 的存在,并不能证明不存在 Bug。
    • 换句话说,一段程序可以由一个测试来证明其错误性,但是却不能被证明是正确的。测试的作用是让我们得出某段程序已经足够实现当前目标这一结论。
      结构化编程可以让我们将一个大的模块按照功能进行拆分,变成小的功能模块,同时通过测试我们可以证明其错误性,无论是架构上还是实际的开发过程中,大模块拆小模块的思路的数不胜数,其实单体应用拆分为微服务应用也是这个范畴内的。

第 5 章 面向对象编程

  • 什么是面向对象?
    • 一种常见的回答是“数据与函数的组合”,这种不太贴切
    • 另一种常见的回答是“面向对象编程是一种对真实世界进行建模的方式”,这有点避重就轻
      • 面向对象理论是在 1966 年提出的,当时 Dahl 和 Nygaard 主要是将函数调用栈迁移到了堆区域中
    • 面向对象编程是封装(encapsulation)、继承(inheritance)、多态(polymorphism)这三项的有机组合
  • 封装
    • 通过采用封装特性,我们可以把一组相关联的数据和函数圈起来,使圈外面的代码只能看见部分函数,数据则完全不可见
    • C 语言也支持完整的封装特性,使用 C 语言的时候应用头文件 .h  的模块是无法知道结构体中的成员变量的,但是 C++ 的头文件中包含了成员信息。
    • 不是面向对象语言的 C 语言相对于面向对象语言 C++ 反而拥有更好的封装特性,所以我们很难说强封装是面向对象编程的必要条件
  • 继承
    • 继承的主要作用是让我们可以在某个作用域内对外部定义的某一组变量与函数进行覆盖
    • C 其实也可以实现继承,只是相对面向对象语言而言会更加困难。
  • 多态
    • 归根结底,多态其实不过就是函数指针的一种应用。但是函数指针非常危险,需要人为的遵守很多约定,容易出 bug。
    • 面向对象编程语言虽然在多态上并没有理论创新,但它们也确实让多态变得更安全、更便于使用了。
  • 依赖反转
    • 依赖关系(或者叫继承关系)的方向和控制流正好是相反的,我们称之为依赖反转
    • 依赖关系都可以通过引入接口的方式来进行反转。
    • 通过这种方法,软件架构师可以完全控制采用了面向对象这种编程方式的系统中所有的源代码依赖关系,
    • 而不再受到系统控制流的限制。不管哪个模块调用或者被调用,软件架构师都可以随意更改源代码依赖关系。
    • 当某个组件的源代码需要修改时,仅仅需要重新部署该组件,不需要更改其他组件,这就是独立部署能力。
  • 面向对象编程就是以多态为手段来对源代码中的依赖关系进行控制的能力,这种能力让软件架构师可以构建出某种插件式架构,让高层策略性组件与底层实现性组件相分离,底层组件可以被编译成插件,实现独立于高层组件的开发和部署。
    在刚学习编程的时候,学到面向对象一定会说到,封装、继承、和多态,但是通过这一章我们可以发现,面向对象语言的封装不一定比面向过程的 C 语言做的更好,这里强调的更重要的是使用多态的手段对源码的依赖关系进行控制,主要是指通过接口来实现依赖反转,这样就可以将组件进行分离,可以进行独立开发和部署。 我现在主要使用的语言是 Go,有一个常见的问题就是 Go 是不是一个面向对象语言,回答也是 Yes or no,是也不是,Go 不支持继承,也不支持函数重载,运算符重载等在面向对象语言非常常见的特性,但是 Go 的接口非常强大,不需要显示依赖接口的设计让我们在依赖反转的使用上更加游刃有余。

第 6 章 函数式编程

  • 函数式编程语言中的变量(Variable)是不可变(Vary)的。
  • 为什么软件架构师要操心变量的可变性呢?答案显而易见:所有的竞争问题、死锁问题、并发更新问题都是由可变变量导致的。
  • 一个架构设计良好的应用程序应该将状态修改的部分和不需要修改状态的部分隔离成单独的组件,然后用合适的机制来保护可变量。
  • 事件溯源体系下,我们只存储事务记录,不存储具体状态。当需要具体状态时,我们只要从头开始计算所有的事务即可。
  • 这种数据存储模式中不存在删除和更新的情况,我们的应用程序不是 CRUD,而是 CR。因为更新和删除这两种操作都不存在了,自然也就不存在并发问题。
    在我们刚刚结束的上一个系列,[Go并发编程](https://lailin.xyz/categories/Go%E8%BF%9B%E9%98%B6%E8%AE%AD%E7%BB%83%E8%90%A5/Go%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B/)中,我们讲到的大量手段来避免数据竞争,这些都是由于在并发时写入导致的,而函数式编程最重要的一个特性就是变量不可变,由于变量无法被修改所以自然而然就不存在数据竞争,也就不需要加锁,这样可以获得很高的性能。

第三部分 设计原则

软件构建中层结构的主要目标:

  • 使软件可容忍被改动。
  • 使软件更容易被理解。
  • 构建可在多个软件系统中复用的组件。
    在之前的[《Go设计模式》](https://lailin.xyz/post/go-design-pattern.html)系列文章当中也有提到 SOLID 原则,换个角度可以发现这些其实都是殊途同归的一些东西,SOLID 原则的历史已经非常悠久了,但是直到现在它仍然非常具有指导意义。

第 7 章 SRP:单一职责原则

  • 任何一个软件模块都应该有且仅有一个被修改的原因。
  • 任何一个软件模块都应该只对一个用户(User)或系统利益相关者(Stakeholder)负责。
  • 任何一个软件模块都应该只对某一类行为者负责。
  • 反例: 代码合并冲突
    • 多人为了不同的目的修改了同一份源代码,这很容易造成问题的产生。
    • 避免这种问题产生的方法就是将服务不同行为者的代码进行切分。
      单一职责原则非常容易被误认为“每个模块应该只做一件事”,没错之前我也是这么理解的,虽然这个描述没错,但是这并不是 SRP 的全部。

第 8 章 OCP:开闭原则

  • 设计良好的计算机软件应该易于扩展,同时抗拒修改。
    • 换句话说,一个设计良好的计算机系统应该在不需要修改的前提下就可以轻易被扩展。
  • 一个好的软件架构设计师会努力将旧代码的修改需求量降至最小,甚至为 0。
    • 可以先将满足不同需求的代码分组(即 SRP),然后再来调整这些分组之间的依赖关系(即 DIP)
  • 如果 A 组件不想被 B 组件上发生的修改所影响,那么就应该让 B 组件依赖于 A 组件。
  • 软件架构师可以根据相关函数被修改的原因、修改的方式及修改的时间来对其进行分组隔离,并将这些互相隔离的函数分组整理成组件结构,使得高阶组件不会因低阶组件被修改而受到影响。
  • OCP 是我们进行系统架构设计的主导原则,其主要目标是让系统易于扩展,同时限制其每次被修改所影响的范围。
    开闭原则在架构设计上非常常见,其中最常见的做法就是使用接口实现依赖反转,如果开闭原则实现的不好就有可能导致我们在进行后续功能扩展的时候牵一发而动全身,成本非常的高。

第 9 章 LSP:里氏替换原则

  • 如果对于每个类型是 S 的对象 o1 都存在一个类型为 T 的对象 o2,能使操作 T 类型的程序 P 在用 o2 替换 o1 时行为保持不变,我们就可以将 S 称为 T 的子类型。
  • 比较常见的一个违反 LSP 原则的例子,长方形与正方形
    • Square 类并不是 Rectangle 类的子类型,因为 Rectangle 类的高和宽可以分别修改,而 Square 类的高和宽则必须一同修改。
      这个反面例子对我的震撼比较大,依稀记得最开始在学习编程语言继承的例子的时候就常常用长方形正方形来举例,但是这个其实是违反了里式替换原则的。 在架构设计上这个原则也十分的重要,因为我们只有做到了 LSP 我们才可以在例如数据库类型切换,微服务拆分这种场景下做的游刃有余。

第 10 章 ISP:接口隔离原则

  • ISP 最初的成因:在一般情况下,任何层次的软件设计如果依赖于不需要的东西,都会是有害的。
  • 任何层次的软件设计如果依赖了它并不需要的东西,就会带来意料之外的麻烦。
    由于 Go 接口的隐式依赖的特性,让 ISP 在 Go 中处处可见,我们常常采用的方式就是在调用者处依赖接口,而不管实现,这样就可以做到,模块分离以及最小化依赖。

第 11 章 DIP:依赖反转原则

  • 如果想要设计一个灵活的系统,在源代码层次的依赖关系中就应该多引用抽象类型,而非具体实现。
  • 在应用 DIP 时,我们也不必考虑稳定的操作系统或者平台设施,因为这些系统接口很少会有变动。
  • 主要应该关注的是软件系统内部那些会经常变动的(volatile)具体实现模块,这些模块是不停开发的,也就会经常出现变更。
  • 编码规范
    • 应在代码中多使用抽象接口,尽量避免使用那些多变的具体实现类。
    • 不要在具体实现类上创建衍生类。我们对继承的使用应该格外小心。即使是在稍微便于修改的动态类型语言中,这条守则也应该被认真考虑
    • 不要覆盖(override)包含具体实现的函数
    • 应避免在代码中写入与任何具体实现相关的名字,或者是其他容易变动的事物的名字。
      通常来说,接口会比实现更加稳定,举个反例,如果接口变动实现是必须要跟着修改的,因为实现是依赖接口的,但是反过来确未必。DIP 原则指导我们无论是在架构设计还是在编码实现当中都应该尽量的依赖抽象而不是实现细节。

第四部分 组件构建原则

第 12 章 组件

  • 组件是软件的部署单元,是整个软件系统在部署过程中可以独立完成部署的最小实体
    • 例如:.jar, .gem, .dll 文件
  • 链接加载器让程序员们可以将程序切分成多个可被分别编译、加载的程序段
  • 组件化的插件式架构已经成为我们习以为常的软件构建形式了。

第 13 章 组件聚合

  • 构建组件相关的基本原则
    • REP:复用/发布等同原则
    • CCP:共同闭包原则
    • CRP:共同复用原则
  • REP:复用/发布等同原则
    • 软件复用的最小粒度应等同于其发布的最小粒度。
    • REP 原则就是指组件中的类与模块必须是彼此紧密相关的
    • 一个组件不能由一组毫无关联的类和模块组成,它们之间应该有一个共同的主题或者大方向。
  • CCP:共同闭包原则
    • 我们应该将那些会同时修改,并且为相同目的而修改的类放到同一个组件中,而将不会同时修改,并且不会为了相同目的而修改的那些类放到不同的组件中。
  • CRP:共同复用原则
    • 不要强迫一个组件的用户依赖他们不需要的东西。
    • 不要依赖不需要用到的东西。
  • 组件张力图

image.png

看到这三个原则会感到有点熟悉,像共同闭包原则就和 SOLID 中的单一职责原则类似,共同复用原则和接口隔离原则看上去也有那么几分相似,这些知识从不同的角度看待总结问题的不同术语。 最后这个组件张力图很有意思,这说明我们在进行架构设计的时候是不可能做到每一项都很完美的,这当中会有一个取舍的过程,书中讲到,一般而言会项目初期会从三角右侧开始,进行一段时间后会滑动到左边,是因为在初期为了效率我们可以牺牲一定的复用性,但是随着依赖关系越来越复杂,那么我们就要考虑复用和扩展了。

第 14 章 组件耦合

  • 组件依赖关系图中不应该出现环。

  • 当组件结构依赖图中存在循环依赖时,想要按正确的顺序构建组件几乎是不可能的。

  • 打破循环依赖

    • 应用依赖反转原则(DIP)
    • 创建一个新的组件,并让 Entities 与 Authorize 这两个组件都依赖于它。将现有的这两个组件中互相依赖的类全部放入新组件
  • 组件结构图是不可能自上而下被设计出来的。它必须随着软件系统的变化而变化和扩张,而不可能在系统构建的最初就被完美设计出来。

  • 组件依赖结构图并不是用来描述应用程序功能的,它更像是应用程序在构建性与维护性方面的一张地图

  • 组件结构图中的一个重要目标是指导如何隔离频繁的变更

  • 如果我们在设计具体类之前就来设计组件依赖关系,那么几乎是必然要失败的。因为在当下,我们对项目中的共同闭包一无所知,也不可能知道哪些组件可以复用,这样几乎一定会创造出循环依赖的组件。

    在 Go 中在编译器上就限制了我们不能出现循环依赖,所以我们大量的使用了 DIP 的方式,但是讲层次拔高一点,从微服务的角度来讲仍然不应该出现循环依赖,如果出现那么在版本发布的时候可能会导致灾难性的后果,架构的原则都是想通的,我们要时刻警惕循环依赖的出现,对于微服务来说可以在 api 网关进行判定是否成环
  • 稳定依赖原则

    • 依赖关系必须要指向更稳定的方向
    • 任何一个我们预期会经常变更的组件都不应该被一个难于修改的组件所依赖,否则这个多变的组件也将会变得非常难以被修改
    • 让软件组件难于修改的一个最直接的办法就是让很多其他组件依赖于它。
  • 稳定性指标

    • 其中一种方法是计算所有入和出的依赖关系。通过这种方法,我们就可以计算出一个组件的位置稳定性(positionalstability)。
      • Fan-in:入向依赖,这个指标指代了组件外部类依赖于组件内部类的数量。
      • Fan-out:出向依赖,这个指标指代了组件内部类依赖于组件外部类的数量。
      • I:不稳定性,I=Fan-out/(Fan-in+Fan-out)。该指标的范围是[0,1],I=0 意味着组件是最稳定的,I=1 意味着组件是最不稳定的。
    • 稳定依赖原则(SDP)的要求是让每个组件的 I 指标都必须大于其所依赖组件的 I 指标。也就是说,组件结构依赖图中各组件的 I 指标必须要按其依赖关系方向递减。
      这一部分提出了一个对我现阶段非常有用的一个原则,被大量依赖的组件应该是稳定的,依赖关系必须要指向更稳定的方向,我当前处在公共技术团队,我们的服务被外部大量的依赖,所以在变更的时候会非常的麻烦,我们 I 值非常的小,几乎可以说接近于 0,所以我们的服务在设计时一定要满足开闭原则,保证足够的扩展性。
  • 稳定抽象原则

    • 一个组件的抽象化程度应该与其稳定性保持一致。
    • 如何才能让一个无限稳定的组件(I=0)接受变更呢?开闭原则(OCP)为我们提供了答案。这个原则告诉我们:创造一个足够灵活、能够被扩展,而且不需要修改的类是可能的,而这正是我们所需
    • 假设 A 指标是对组件抽象化程度的一个衡量,它的值是组件中抽象类与接口所占的比例。那么:
      • Nc:组件中类的数量。
      • Na:组件中抽象类和接口的数量。
      • A:抽象程度,A=Na÷Nc
      • A 指标的取值范围是从 0 到 1,值为 0 代表组件中没有任何抽象类,值为 1 就意味着组件中只有抽象类。
    • image.png
      • 只有多变的软件组件落在痛苦区中才会造成麻烦
      • 现在我们来看看靠近(1,1)这一位置点的组件。该位置上的组件不会是我们想要的,因为这些组件通常是无限抽象的,但是没有被其他组件依赖,这样的组件往往无法使用。
      • 追求让这些组件位于主序列线上,或者贴近这条线即可。
    • D 指标[8]:距离 D=|A+I-1|,该指标的取值范围是[0,1]。值为 0 意味着组件是直接位于主序列线上的,值为 1 则意味着组件在距离主序列最远的位置。
    • 对于一个良好的系统设计来说,D 指标的平均值和方差都应该接近于 0
      稳定抽象原则说明了越稳定的组件应该越抽象,从代码的角度来讲,接口是最抽象的组件之一,因为接口一般不会有其他外部的依赖,而被大量依赖,同时还给出一个统计抽象程度的方法,这个可以用来统计一下我们现在的现状。

第五部分 软件架构

第 15 章 什么是软件架构

  • 软件架构师自身需要是程序员,并且必须一直坚持做一线程序员,绝对不要听从那些说应该让软件架构师从代码中解放出来以专心解决高阶问题的伪建议

  • 如果不亲身承受因系统设计而带来的麻烦,就体会不到设计不佳所带来的痛苦,接着就会逐渐迷失正确的设计方向。

    这个也是常常会遇到的问题,就现在我能观察到的为例,架构师级别的基本上没有看到过再做一线的程序开发工作,仅仅是平时的各种管理,规划上的事务就已经忙的不可开交,这其实不仅仅导致了架构师本身会脱节,同时也会导致下面的同学很少有机会学习到架构师们过往的经验。
  • 软件架构这项工作的实质就是规划如何将系统切分成组件,并安排好组件之间的排列关系,以及组件之间互相通信的方式。

  • 设计软件架构的目的,就是为了在工作中更好地对这些组件进行研发、部署、运行以及维护。

  • 如果想设计一个便于推进各项工作的系统,其策略就是要在设计中尽可能长时间地保留尽可能多的可选项。

  • 设计良好的架构可以让系统便于理解、易于修改、方便维护,并且能轻松部署。软件架构的终极目标就是最大化程序员的生产力,同时最小化系统的总运营成本。

  • 开发

    • 实现一键式的轻松部署应该是我们设计软件架构的一个目标
  • 运行

    • 几乎任何运行问题都可以通过增加硬件的方式来解决,这避免了软件架构的重新设计
    • 基于投入/产出比的考虑,我们的优化重心应该更倾向于系统的开发、部署以及维护
    • 一个设计良好的软件架构应该能明确地反映该系统在运行时的需求。
      人力成本往往会比机器的成本更高,所以这也就是我们在代码编写的过程当中对可读性和性能需要有一个权衡,如果不是差异过大往往代码的可读性需要更为重要
  • 维护

    • 在软件系统的所有方面中,维护所需的成本是最高的
  • 保持可选项

    • 软件有行为价值与架构价值两种价值。这其中的第二种价值又比第一种更重要
    • 软件的灵活性则取决于系统的整体状况、组件的布置以及组件之间的连接方式。
      • 软件的高层策略不应该关心其底层到底使用哪一种数据库
      • 开发的早期阶段也不应该选定使用的 Web 服务
      • 软件的高层策略压根不应该跟这些有关。
      • 在开发的早期阶段不应过早地采用依赖注入框架
    • 如果在开发高层策略时有意地让自己摆脱具体细节的纠缠,我们就可以将与具体实现相关的细节决策推迟或延后,因为越到项目的后期,我们就拥有越多的信息来做出合理的决策。
    • 一个优秀的软件架构师应该致力于最大化可选项数量
  • 优秀的架构师会小心地将软件的高层策略与其底层实现隔离开,让高层策略与实现细节脱钩,使其策略部分完全不需要关心底层细节,当然也不会对这些细节有任何形式的依赖。另外,优秀的架构师所设计的策略应该允许系统尽可能地推迟与实现细节相关的决策,越晚做决策越好

    这一点其实很容易被忽略掉,因为我们经常做的工作就是细节性的工作,在进行设计的时候很容易就不自觉的假定 Web UI,MySQL 数据库这些技术选型,在这本书的最后一个章节还会讲到,这些细节。

第 16 章 独立性

  • 用例
    • 软件的架构必须为其用例提供支持。
  • 任何一个组织在设计系统时,往往都会复制出一个与该组织内沟通结构相同的系统。
  • 一个设计良好的架构通常不会依赖于成堆的脚本与配置文件,也不需要用户手动创建一堆“有严格要求”的目录与文件
  • 如果我们按照变更原因的不同对系统进行解耦,就可以持续地向系统内添加新的用例,而不会影响旧有的用例。如果我们同时对支持这些用例的 UI 和数据库也进行了分组,那么每个用例使用的就是不同面向的 UI 与数据库,因此增加新用例就更不太可能会影响旧有的用例了。
  • 如果有两段看起来重复的代码,它们走的是不同的演进路径,也就是说它们有着不同的变更速率和变更缘由,那么这两段代码就不是真正的重复
  • 解耦模式
    • 源码层次:我们可以控制源代码模块之间的依赖关系,以此来实现一个模块的变更不会导致其他模块也需要变更或重新编译
    • 部署层次:我们可以控制部署单元(譬如 jar 文件、DLL、共享库等)之间的依赖关系,以此来实现一个模块的变更不会导致其他模块的重新构建和部署。
    • 服务层次:我们可以将组件间的依赖关系降低到数据结构级别,然后仅通过网络数据包来进行通信。
    • 一个设计良好的架构应该能允许一个系统从单体结构开始,以单一文件的形式部署,然后逐渐成长为一组相互独立的可部署单元,甚至是独立的服务或者微服务。最后还能随着情况的变化,允许系统逐渐回退到单体结构
      “如果两段看似重复的代码,如果有不同的变更速率和原因,那么这两段代码就不算是真正的重复”这有个非常典型的例子就是 API 接口的参数和最后我们模型数据虽然很多时候大部分字段是相同的,但是它们的变更速率和原因其实都是不一样的,如果把他们耦合在一起虽然前期可能可以减少一些代码的编写,但是到最后需要扩展时会发现变更会很困难。之前我还写了一篇文章 《[Go Web 小技巧(三)Gin 参数绑定 ](https://lailin.xyz/post/11996.html#2-%E7%94%A8-model-%E5%B1%82%E7%9A%84-struct-%E7%BB%91%E5%AE%9A%E5%8F%82%E6%95%B0)》总结这种埋坑的技巧 😂

第 17 章 划分边界

  • 软件架构设计本身就是一门划分边界的艺术。
  • 通过划清边界,我们可以推迟和延后一些细节性的决策,这最终会为我们节省大量的时间、避免大量的问题。
  • I/O 是无关紧要的
  • GUI 和 BusinessRules 这两个组件之间也应该有一条边界线
  • 插件式架构的好处
    • image.png
    • 将系统设计为插件式架构,就等于构建起了一面变更无法逾越的防火墙。换句话说,只要 GUI 是以插件形式插入系统的业务逻辑中的,那么 GUI 这边所发生的变更就不会影响系统的业务逻辑。
    • 边界线也应该沿着系统的变更轴来画。也就是说,位于边界线两侧的组件应该以不同原因、不同速率变化着。
      真正核心的是我们业务逻辑,而输入输出是细节

第 18 章 边界剖析

  • 跨边界调用指的是边界线一侧的函数调用另一侧的函数,并同时传递数据的行为
  • 最简单的跨边界调用形式,是由低层客户端来调用高层服务函数,这种依赖关系在运行时和编译时会保持指向一致,都是从低层组件指向高层组件
  • 在单体结构中,组件之间的交互一般情况下都只是普通的函数调用,迅速而廉价,这就意味着这种跨源码层次解耦边界的通信会很频繁
  • 服务之间的跨边界通信相对于函数调用来说,速度是非常缓慢的,其往返时间可以从几十毫秒到几秒不等。
    不同的边界的跨边界调用的成本是不同的,对于服务而言跨服务调用的成本非常高,这样我们在进行服务划分的时候一定要尽量的内聚减少频繁调用的情况。

第 19 章 策略与层次

  • 策略
    • 本质上,所有的软件系统都是一组策略语句的集合
    • 变更原因、时间和层次相同的策略应该被分到同一个组件中。反之,变更原因、时间和层次不同的策略则应该分属于不同的组件
    • 依赖关系的方向通常取决于它们所关联的组件层次。一般来说,低层组件被设计为依赖于高层组件
  • 层次
    • 一条策略距离系统的输入/输出越远,它所属的层次就越高。而直接管理输入/输出的策略在系统中的层次是最低的。
    • 数据流向和源码中的依赖关系并不总处于同一方向上
    • 我们希望源码中的依赖关系与其数据流向脱钩,而与组件所在的层次挂钩。
    • 低层组件应该成为高层组件的插件
      距离 I/O 越远的策略层次越高,也就是说我们常见的 Web UI 应该属于最低层次,我们不应该依赖 Web UI 这种输入输出设备。同时给出了组件的划分原则,变更的时间原因和层次相同的属于同一个组件。

第 20 章 业务逻辑

  • 业务逻辑就是程序中那些真正用于赚钱或省钱的业务逻辑与过程

  • “关键业务逻辑”是一项业务的关键部分,不管有没有自动化系统来执行这项业务,这一点是不会改变的。

  • 业务实体

    • 业务实体这个概念中应该只有业务逻辑,没有别的。
    • 业务实体这个概念只要求我们将关键业务数据和关键业务逻辑绑定在一个独立的软件模块内。
    • 业务实体不一定是类
  • 用例(usecase)

    • 用例本质上就是关于如何操作一个自动化系统的描述,它定义了用户需要提供的输入数据、用户应该得到的输出信息以及产生输出所应该采取的处理步骤。
    • 用例中包含了对如何调用业务实体中的关键业务逻辑的定义。简而言之,用例控制着业务实体之间的交互方式。
    • 用例除非正式地描述了数据流入/流出接口以外,并不详细描述用户界面。
    • 用例并不描述系统与用户之间的接口,它只描述该应用在某些特定情景下的业务逻辑,这些业务逻辑所规范的是用户与业务实体之间的交互方式,它与数据流入/流出系统的方式无关。
    • 业务实体并不会知道是哪个业务用例在控制它们,这也是依赖反转原则(DIP)的另一个应用情景
    • 为什么业务实体属于高层概念,而用例属于低层概念呢?因为用例描述的是一个特定的应用情景,这样一来,用例必然会更靠近系统的输入和输出。
      用例和业务实体应该是应用当中最重要的,所以我们的单元测试最低的要求就是要覆盖所有的 usecase 逻辑,这一部分应该保持纯净不依赖数据库,Web 等 I/O 方式
  • 选择直接在数据结构中使用对业务实体对象的引用。毕竟,业务实体与请求/响应模型之间有很多相同的数据。但请一定不要这样做!这两个对象存在的意义是非常、非常不一样的。随着时间的推移,这两个对象会以不同的原因、不同的速率发生变更。

  • 这些业务逻辑应该保持纯净,不要掺杂用户界面或者所使用的数据库相关的东西。在理想情况下,这部分代表业务逻辑的代码应该是整个系统的核心,其他低层概念的实现应该以插件形式接入系统中。业务逻辑应该是系统中最独立、复用性最高的代码。

    再次强调了不要偷懒,今天刚好看到之前写的一个反面例子的代码,代码里面有一个 GetA 函数,从数据库当中获取A对象数据和一些统计数据,这个函数中的统计数据部分其实只有在一个 Web 页面的接口中使用到,但是为了偷懒,在其他地方查询的时候也调用了这个函数,导致最后很多地方的接口性能都由于这个没用的统计数据多耗费了将近 1s 的时间。

第 21 章 尖叫的软件架构

  • 架构设计的主题
    • 软件的系统架构应该为该系统的用例提供支持。这就像住宅和图书馆的建筑计划满篇都在非常明显地凸显这些建筑的用例一样,软件系统的架构设计图也应该非常明确地凸显该应用程序会有哪些用例
  • 架构设计的核心目标
    • 一个良好的架构设计应该围绕着用例来展开,这样的架构设计可以在脱离框架、工具以及使用环境的情况下完整地描述用例
    • 良好的架构设计应该尽可能地允许用户推迟和延后决定采用什么框架、数据库、Web 服务以及其他与环境相关的工具
    • 良好的架构设计应该只关注用例,并能将它们与其他的周边因素隔离。
  • 可测试的架构设计
    • 我们在运行测试的时候不应该运行 Web 服务,也不应该需要连接数据库。我们测试的应该只是一个简单的业务实体对象,没有任何与框架、数据库相关的依赖关系。
  • 一个系统的架构应该着重于展示系统本身的设计,而并非该系统所使用的框架
    用例是架构设计当中最应该关注的部分,框架数据库Web服务的选择都是细节,这些细节应该延后选择,我们的用例不应该依赖这些细节,这样才能很好的测试

第 22 章 整洁架构

  • 按照不同关注点对软件进行切割。也就是说,这些架构都会将软件切割成不同的层,至少有一层是只包含该软件的业务逻辑的,而用户接口、系统接口则属于其他层。
  • 特点
    • 独立于框架:这些系统的架构并不依赖某个功能丰富的框架之中的某个函数。
    • 可被测试:这些系统的业务逻辑可以脱离 UI、数据库、Web 服务以及其他的外部元素来进行测试。
    • 独立于 UI:这些系统的 UI 变更起来很容易,不需要修改其他的系统部分。
    • 独立于数据库:我们可以轻易将这些系统使用的
    • 独立于任何外部机构:这些系统的业务逻辑并不需要知道任何其他外部接口的存在。

image.png

  • 依赖关系规则
    • 外层圆代表的是机制,内层圆代表的是策略。
    • 源码中的依赖关系必须只指向同心圆的内层,即由低层机制指向高层策略。
    • 外层圆中使用的数据格式也不应该被内层圆中的代码所使用,尤其是当数据格式是由外层圆的框架所生成时。
  • 业务实体
    • 业务实体这一层中封装的是整个系统的关键业务逻辑,一个业务实体既可以是一个带有方法的对象,也可以是一组数据结构和函数的集合。
  • 用例
    • 软件的用例层中通常包含的是特定应用场景下的业务逻辑,这里面封装并实现了整个系统的所有用例。这些用例引导了数据在业务实体之间的流入/流出,并指挥着业务实体利用其中的关键业务逻辑来实现用例的设计目标。
  • 接口适配器
    • 软件的接口适配器层中通常是一组数据转换器,它们负责将数据从对用例和业务实体而言最方便操作的格式,转化成外部系统(譬如数据库以及 Web)最方便操作的格式。
  • 层次越往内,其抽象和策略的层次越高,同时软件的抽象程度就越高,其包含的高层策略就越多。最内层的圆中包含的是最通用、最高层的策略,最外层的圆包含的是最具体的实现细节。
  • 这里最重要的是这个跨边界传输的对象应该有一个独立、简单的数据结构。
  • 不要投机取巧地直接传递业务实体或数据库记录对象。
    看过前面的部分再来看整洁架构这一章节会发现非常的自然

第 23 章 展示器和谦卑对象

  • 谦卑对象模式
    • 谦卑对象模式最初的设计目的是帮助单元测试的编写者区分容易测试的行为与难以测试的行为,并将它们隔离。
  • 展示器与视图
    • 视图部分属于难以测试的谦卑对象。这种对象的代码通常应该越简单越好,它只应负责将数据填充到 GUI 上,而不应该对数据进行任何处理。
    • 展示器则是可测试的对象。展示器的工作是负责从应用程序中接收数据,然后按视图的需要将这些数据格式化,以便视图将其呈现在屏幕上。
    • 展示器则是可测试的对象。展示器的工作是负责从应用程序中接收数据,然后按视图的需要将这些数据格式化,以便视图将其呈现在屏幕上。
    • 视图部分除了加载视图模型所需要的值,不应该再做任何其他事情。因此,我们才能说视图是谦卑对象
  • 数据库网关
    • 这些实现也应该都属于谦卑对象,它们应该只利用 SQL 或其他数据库提供的接口来访问所需要的数据。
    • 交互器尽管不属于谦卑对象,却是可测试的,
  • 数据映射器(ORM)
    • 这样的 ORM 系统应该属于系统架构中的哪一层呢?当然是数据库层。ORM 其实就是在数据库和数据库网关接口之间构建了另一种谦卑对象的边界。
  • 因为跨边界的通信肯定需要用到某种简单的数据结构,而边界会自然而然地将系统分割成难以测试的部分与容易测试的部分,所以通过在系统的边界处运用谦卑对象模式,我们可以大幅地提高整个系统的可测试性。
    这里主要是将很难进行单元测试的行为和容易测试的行为进行分离,很难被测试的行为常常会被分离成为一个谦卑对象,这个对象非常的简单,不会包含很多逻辑

第 24 章 不完全边界

  • 构建不完全边界的方式
    • 构建不完全边界的一种方式就是在将系统分割成一系列可以独立编译、独立部署的组件之后,再把它们构建成一个组件。
    • 单向边界
    • 门户模式
      架构是需要取舍的,我们不可能每一项都做的很完美,边界的划分也是这样,所以就有了不完全的边界

第 25 章 层次与边界

  • 过度的工程设计往往比工程设计不足还要糟糕
  • 现实就是这样。作为软件架构师,我们必须有一点未卜先知的能力。有时候要依靠猜测——当然还要用点脑子。软件架构师必须仔细权衡成本,决定哪里需要设计架构边界,以及这些地方需要的是完整的边界,还是不完全的边界,还是可以忽略的边界
  • 架构师必须持续观察系统的演进,时刻注意哪里可能需要设计边界,然后仔细观察这些地方会由于不存在边界而出现哪些问题。
    不要过度优化,但是也不要什么都不管的一把梭,架构师需要演进和取舍的,没有完美的架构只有不断持续演进优化的架构。

第 26 章 Main 组件

  • Main 是最细节化的部分
  • Main 组件的任务是创建所有的工厂类、策略类以及其他的全局设施,并最终将系统的控制权转交给最高抽象层的代码来处理。
  • Main 组件中的依赖关系通常应该由依赖注入框架来注入。
  • 我们在这里的重点是要说明 Main 组件是整个系统中的一个底层模块,它处于整洁架构的最外圈,主要负责为系统加载所有必要的信息,然后再将控制权转交回系统的高层组件。
    main 是一个程序的入口,这是最细节的部分,因为之前为了很多东西不被依赖,我们一般会采用接口来实现依赖反转,这时候就会导致我们所有的依赖关系的构建都需要在 main 中进行完成,所以一般而言我们会在 main 中引入依赖注入框架。

第 27 章 服务:宏观与微观

  • 所谓的服务本身只是一种比函数调用方式成本稍高的,分割应用程序行为的一种形式,与系统架构无关。
  • 服务所带来的好处?
    • 解耦合的谬论
    • 独立开发部署的谬论
    • 这种理念有一些道理——但也仅仅是一些而已。首先,无数历史事实证明,大型系统一样可以采用单体模式,或者组件模式来构建,不一定非得服务化。因此服务化并不是构建大型系统的唯一选择。
  • 横跨型变更(cross-cutting concern)问题,它是所有的软件系统都要面对的问题,无论服务化还是非服务化的。
  • 服务也可以按照 SOLID 原则来设计,按照组件结构来部署,这样就可以做到在添加/删除组件时不影响服务中的其他组件。
  • 系统的架构边界事实上并不落在服务之间,而是穿透所有服务,在服务内部以组件的形式存在
  • 服务边界并不能代表系统的架构边界,服务内部的组件边界才是。
  • 系统的架构是由系统内部的架构边界,以及边界之间的依赖关系所定义的,与系统中各组件之间的调用和通信方式无关。
    虽然现在微服务架构非常火热,基本上所有的服务都是拆分了服务,但是拆分了服务并不一定表示就解耦合了,也并不一定就真的能独立部署,想一想这是现在很常见的,一个应用必须要和另外一个应用一同上线,根本做不了独立部署。

第 28 章 测试边界

  • 可测试性设计
  • 如果测试代码与系统是强耦合的,它就得随着系统变更而变更。哪怕只是系统中组件的一点小变化,都可能会导致许多与之相耦合的测试出现问题,需要做出相应的变更。
  • 软件设计的第一条原则——不管是为了可测试性还是其他什么东西——是不变的,就是不要依赖于多变的东西。
  • 没有按系统组成部分来设计的测试代码,往往是非常脆弱且难以维护的。
    不变的组件不要依赖多变的东西,这样会导致非常难以测试

第 29 章 整洁的嵌入式架构

  • 虽然软件本身并不会随时间推移而磨损,但硬件及其固件却会随时间推移而过时,随即也需要对软件做相应改动
  • 虽然软件质量本身并不会随时间推移而损耗,但是未妥善管理的硬件依赖和固件依赖却是软件的头号杀手。
  • 但如果你在代码中嵌入了 SQL 或者是代码中引入了对某个平台的依赖的话,其实就是在写固件代码。
  • 软件构建过程中的三个阶段
    • “先让代码工作起来”——如果代码不能工作,就不能产生价值。
    • “然后再试图将它变好”——通过对代码进行重构,让我们自己和其他人更好地理解代码,并能按照需求不断地修改代码
    • “最后再试着让它运行得更快”——按照性能提升的“需求”来重构代码。
  • 整洁的嵌入式架构就是可测试的嵌入式架构
  • 软件与固件集成在一起也属于设计上的反模式(anti-pattern)
    软件并不会随着时间磨损但是硬件是会过时的,而且换的还非常频繁,这时候我们就必须要把硬件以及固件代码给隔离起来,对了不要认为我们不做嵌入式开发平时就很少接触到这个,SQL 语句其实也是一种固件代码

第六部门 实现细节

第 30 章 数据库只是实现细节

  • 就数据库与整个系统架构的关系打个比方,它们之间就好比是门把手和整个房屋架构的关系
  • 但当问题涉及数据存储时,这方面的操作通常是被封装起来,隔离在业务逻辑之外的
  • 数据本身很重要,但数据库系统仅仅是一个实现细节。
    数据很重要,但是数据库系统是一个细节,书上这一章用了一个例子说明有时候可能真的用不到数据库。换个常见的例子,我们可能系统刚开始的时候使用 SQlite 就可以,随着业务发展用上了 MySQL,然后随着并发的提高又会引入缓存组件,这些变化其实和业务逻辑都没有关系,数据库的变化是不应该影响到业务逻辑的

第 31 章 Web 是实现细节

  • GUI 只是一个实现细节。而 Web 则是 GUI 的一种,所以也是一个实现细节。作为一名软件架构师,我们需要将这类细节与核心业务逻辑隔离开来。

第 32 章 应用程序框架是实现细节

  • 我们可以使用框架——但要时刻警惕,别被它拖住
  • 毕竟 Main 组件作为系统架构中最低层、依赖最多的组件,它依赖于 Spring 并不是问题。
    框架的选择要慎重,我们业务逻辑本身不能依赖框架

第 33 章 案例分析:视频销售网站

  • 系统架构设计中的第一步,是识别系统中的各种角色和用例
    这一步看起来简单,但是非常考验一个人的功力

第 34 章 拾遗

  • 分层架构无法展现具体的业务领域信息。把两个不同业务领域的、但是都采用了分层架构的代码进行对比,你会发现它们的相似程度极高
  • 宽松的分层架构,允许某些层跳过直接相邻的邻居。
  • 一个架构设计原则——内容是“Web 控制器永远不应该直接访问数据层”。
  • 系统由一个或者多个容器组成(例如 Web 应用、移动 App、独立应用、数据库、文件系统),每个容器包含一个或多个组件,每个组件由一个或多个类组成。
  • 如果不考虑具体实现细节,再好的设计也无法长久。必须要将设计映射到对应的代码结构上,考虑如何组织代码树,以及在编译期和运行期采用哪种解耦合的模式。
  • 最好能利用编译器来维护所选的系统架构设计风格,小心防范来自其他地方的耦合模式,例如数据结构

image.png

这一章对比了四种架构风格,同时提出了,架构设计是需要考虑实现细节的,设计需要映射到代码结构和代码树上,这个其实和最开始的“软件架构师自身需要是程序员,并且必须一直坚持做一线程序员”交相呼应。 如果可以在编译时解决的问题,就不要放到运行时,编译的问题往往要比运行时的问题好解决,这也是为什么 Go 的依赖注入框架我更加推荐 wire 的原因,同理作者提出了 如果要防止直接中 web控制器调用数据层,那么我们就不应该将数据层(repo)暴露出来,只需要暴露 usecase 就好了。

总结

之前其实也大概了解过整洁架构,从最开始觉得它又臭又长,到现在工作两三年后觉得“不听老人言,吃亏在眼前”,当我们在对一个架构或者是事务进行批判的时候一定要了解它面对的场景以及它的理念,这是最重要的。当然软件领域是没有银弹的,我们需要做的是吸收每一种思想,在不同的场景下做不同的取舍,接下来会有几篇文章结合毛老师课上讲的 Go 工程化相关的内容,以及我在工作当中进行的一些总结最后提出一种当下我觉得的 Go 项目的组织方式,这种方式不是最好的,但是我觉得是现阶段最适合的。
推荐大家在仔细的阅读一下本书,期望你能有更多的收获。

参考文献

  1. 架构整洁之道-罗伯特·C·马丁-微信读书

关注我获取更新

wechat
知乎
github

猜你喜欢