奇丁有术
《架构整洁之道》读书笔记

推荐序一

“无论是三种编程范式还是微服务架构,它们都在解决一个问题──分离控制和逻辑”

合格架构师需能明确区分:

  • 简单 vs 简陋

    简陋很好理解,也是简单,但是丑陋。前者是简单没毛病,隐含了优美。

  • 平衡 vs 妥协

    这个感觉最为微妙,现实大部分情况我们就是在妥协,因为时间和成本有限

  • 迭代 vs 半成品

    以汽车为例,最小可行性产品可能是一辆自行车,而半成品可能就是造了四个轮子,都无从使用

推荐序二

如果系统设计图里没有 “层次”,关系混乱,正是古老的 goto 陷阱再现

序言

“人类并不能全知全晓”

“软件架构是一个猜想,只有通过实际实现和测量才能证实。”

没有银弹(但是又永恒的原则)。

前言

“软件架构的规则是相同的!”

无论过去、现在、将来、你的、我的、他的。

第 1 部分: 概述

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

架构即设计

这里我个人并不同意作者关于 “架构图里实际上包含了所有的底层设计细节” 的论述。架构当然是分层递归的设计拆解,但是架构师负责的应该仍然是更上层的抽象表达。

以作者此处所举的建筑设计为例,实际上建筑设计图中一般只会表达此处安装照明设备,另一处安装照明设备的控制设备,也就是说仍然只关注接口。 至于照明设备是什么牌子,是白炽灯还是 LED 灯,为何种造型,这不在建筑设计图的表达关注点范围内(但是可能会有某种照明设备的规格描述:电压、照度等等)。

目标是什么

“软件架构的终极目标是,用最小的人力成本来满足构建和维护该系统的需求。”

如果按后面主题来看的话,这个关注视角可能过于局限。软件架构的目标是这个我赞同,但是放到全局,企业的目标就是盈利…

问题到底在哪里

工程师(有意无意地)自我欺骗:“我们可以未来再重构代码,产品上线最重要!”

大家应该都对此处有共鸣,看看现在代码里的 TODO 和 FixMe 有多少吧!

这可能需要制度的变化才能有所改观,指标需要重定义。

“无论是从短期还是长期来看,胡乱编写代码的工作速度其实比循规蹈矩更慢。”

TDD 不仅可以验证功能,还有助于重构,可以让重构充满信心。

“如果你觉得好架构的成本太高,那你可以试试选择差的架构加上返工重来的成本。” ── 序言 XII

“想要跑得快,先要跑得稳。”

这里可能道理大家都懂,但是缺乏具有实操性的指导原则:都知道要适应变化,但是又如何保证不过度设计呢?什么是过度设计,比如力求一切可变灵活,那肯定是过度设计。

举一个简单例子,VM 时代有很多基于 IP 不变假设的设计,如果一开始就试图适应可变 IP,需要花费相当可观的开发人力;但是如果不适应,一旦 IP 需要变更(K8s),将是一场灾难。

这里能给出的建议来自于《程序员修炼之道》:“不要把决定刻在石头上”,如果一个设计的决定未来变更的代价很大,那一定要慎之又慎,反之则不大有所谓,以能跑为优先。

如何识别未来的潜在变化是个永恒的议题。如果没有明确的指导方法,仅靠一切尽在不言中,就难以长久维持良好的架构(设计)。

其实很讽刺,需求是产品给出的,而需求未来的变化却要开发人员进行猜测。我觉得正确的方法应是产品有责任需求对需求,明确指导产品未来可能的修改,让开发在架构上提前做好准备,更好地设计架构

实践矩阵
开发快 开发慢
好架构 Linus 通常选项
坏架构 通常选项 渣渣

实践发现,我们的困扰正是来自于二者不可得兼。如何才能开发又快架构又好呢,目前看来,只能多读书多练习多总结,输出更多的经验模式,才更容易扩展复制成功的架构。

第 2 章: 两个价值维度

软件的实际价值由两个维度体现:行为和架构

行为价值

程序员不应该认为实现需求就是全部工作

架构价值

软件的 “软”,“不言而喻,是指软件的灵活性”

“软件应该容易被修改”

“变更实施的难度应该和变更的范畴(scope)成等比关系,而与变更的具体形状(shape)无关”

要尽量减少软件修改的代价。

哪个价值维度更重要

行为价值并非比架构价值更重要

作者的论据如下:

软件价值矩阵
正常工作 不正常工作
易于修改 价值满满 持续产生价值
无法修改 0 0

但是作者也坦然承认,绝对无法修改的软件时不存在的。

这个得看赛场,有些赛场可能就是 “天下武功,唯快不破”。比如创业公司甚至可能撑不到良好架构获取真正价值的那一天就已经死亡。但是要注意,本书作者也并没有下结论说架构价值比行为价值更重要。

艾森豪威尔矩阵

推荐排序

重要 不重要
紧急 1 3
不紧急 2 4

业务部门和研发人员经常犯的共同错误就是将第三优先级的事情提到第一优先级去做。

也就是说作者认为重要的事优先于紧急的事情。

一个策略:“让紧急的事情过期就不紧急了”

业务部门没有能力评估系统架构的重要程度

“平衡系统架构的重要性与功能的紧急程度这件事,是软件研发人员自己的职责。”

为好的软件架构而持续斗争

“公司内部的抗争本来就是无止境的”

这句断言本身没毛病,其实需要各方的尽力竞争才能形成最优的合作。内耗是固有存在的,就像做功永远会有能量损失。但是个人认为这个部分可以优化。这里可能业务部门和研发人员的目标可以有一定的融合。有点像 Google SRE 实践中,让运维与开发的目标适度统一,其实本来就不必对立。比方说如果运维的目标就是稳定,那就不能允许开发进行变更;但开发人员只关注功能发布,就需要频繁变更。那么 Google 的做法是设置错误预算来调和双方目标。

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

“直到今天,我们也一共只有三个编程范式,而且未来几乎不可能再出现新的”

“为什么要从编程范式开始谈起?在审阅完整本书过程中,我慢慢发现鲍勃大叔其实在传递一种设计理念:架构设计里,自顶向下的设计往往是不靠谱的。就连本书的目录也在言说同样的逻辑,从程序的基础构件,谈到组件,最后才谈到架构,这个涌现的过程非常符合《系统之美》中描述的系统的自组织特征。”

第 3 章: 编程范式总览

范式 限制和规范 关注点 手段
结构化编程 程序控制权的直接转移(goto 语句) 功能性 各模块算法实现基础
面向对象编程 程序控制权的间接转移(函数指针) 组件独立性 多态跨越架构边界
函数式编程 程序中的赋值(赋值语句) 数据管理 规范和限制数据存放位置与访问权限

其实在任何语言里,你都可以使用或借鉴其他范式;应该只是说不同范式的语言提供了针对其范式的便利设施,关注点不同,

“我们过去 50 年学到的东西主要是 ── 什么不应该做。”

代码是写给人看的,而人的认知能力有限(内存太小),因此需要保持尽量简单。不同范式其实表达能力是一样的(图灵等价),那么其实说三种范式的实质是限制和规范(从作者关注点的视角),就不难理解,其实是一种避免多样、混乱和复杂的方式。

这种约束和 KISS、“如无必要,勿增实体”、奥卡姆剃刀等原则的本质是一样的,力求熵减。

应该说良好的架构亦是如此,规范和限制,明确表达什么不能做。

“无论是三种编程范式还是微服务架构,它们都在解决一个问题 ── 分离控制和逻辑” ── 推荐序一 V

逻辑描述做什么,也就是不变的部分。 控制描述怎么做,也就是变化的部分。

如果不做分离,一个就是改起来很麻烦,没法独立。 另一个就是可能有多份逻辑定义,逻辑是约定(通常是声明),约定只能有一份。

编程范式的解耦方式

  • 面向对象:委托、策略、桥接、修饰、IoC/DIP、MVC
  • 函数式编程:修饰、管道、拼装
  • 逻辑推导式编程:Prolog

第 4 章: 结构化编程

可推导性

“goto 语句的某些用法会导致某个模块无法被递归拆分成更小的,可证明的单元,这会导致无法采用分解法来将大型问题进一步拆分成更小的、可证明的部分。”

goto 跨函数调用应该禁止,这会影响分治。

goto 是有害的

系统设计层次混乱,也可以看作一种 “goto” 陷阱 ── 推荐序二 VII

其实一个函数中多处 return 是否也可以看作是一种 goto?

实践中,单个开发者使用明确的标记进行 goto,一般还可以接受,但是多人协作就会开始产生混乱。

测试

“结构化编程范式中最有价值的地方就是,它赋予了我们创造可证伪程序单元的能力。”

功能性降解拆分仍然是架构设计领域最佳实践之一。

正是因为可递归降解,通过无法证伪(通过测试)子单元函数,从而可推导整个程序正确。

能使用单元测试的程序架构应该会更好:

  • 可以单元测试,证明易于替换
  • 如果证明程序正确的成本很高,说明很可能程序有问题

第 5 章: 面向对象编程

封装

封装的思想可以理解为屏蔽不必要的信息,这里又是熵减的目标。

“这个特性其实并不是面向对象编程所独有的。”

相比于 C,C++、Java、C# 这种 “面向对象” 语言反而削弱了封装性。 really?

“我们很难说强封装是面向对象编程的必要条件。”

继承

继承是一种复用实现(函数和变量)的方式。

C 也可以做继承(有些投机取巧)

但是面向对象编程对继承提供了易用便利

多态

C 也可以做多态,“归根到底,多态其实不过就是函数指针的一种应用。”

但是面向对象编程确实使多态更安全更易用

多态的强大性

多态解耦了声明与实现。这种解耦是通过函数指针间接找到目标函数完成。

多态可以实现插件式架构,而面向对象编程使得插件式架构可以在任何地方被安全地使用。

可以理解为面向对象编程规范了多态的实现。

依赖反转

依赖关系和控制流相反则称为依赖反转

通过利用面向对象编程语言所提供的安全便利的多态实现,可以完全控制所有源码依赖关系,不受系统控制流的限制

重点是依赖于抽象(更稳定的部分),无论高低层次。这样也就实现了控制与逻辑分离。抽象=逻辑,实现=控制。

完全控制源码依赖关系的好处是可以独立编译,进而推导出可以独立部署,进而推导出可以独立开发 切入角度刁钻。

小结

“面向对象编程就是以多态为手段来对源代码中的依赖关系进行控制的能力,这种能力让软件架构师可以构建出某种插件式架构,让高层策略性组件与底层实现性组件相分离,底层组件可以被编译成插件,实现独立于高层组件的开发和部署。”

第 6 章: 函数式编程

函数式编程不允许重复赋值。“Neal Ford在《函数式编程思想》(Functional Thinking)中提到面向对象编程是通过封装可变因素控制复杂性(makes code understandable),而函数式编程是通过消除可变因素控制复杂性的。”(解决不了问题就消灭问题)

“像 Scala,Clojure 这些基于 JVM 上的函数式编程语言大量使用了持久化数据结构(如:Persistent Vector),在不损失效率的前提下,实现了不可变的数据结构。这样的数据结构在高并发的环境下具有非常巨大的优势,尤其相对于面向对象编程中为人所诟病的临界区和竞态条件。”

函数式编程虽然实践中几乎很难接触到,但是其蕴含的思想值得参考。

可变性的隔离

“一个架构设计良好的应用程序应该将状态修改的部分和不需要修改状态的部分隔离成单独的组件,然后用合适的机制来保护可变量。”

“可变状态组件的逻辑应该越少越好。”

不好测试的部分都带副作用,应该进行隔离。

事件溯源

举了 binlog CRUD=>CR 的例子

可参考 日志:每个软件工程师都应该知道的有关实时数据的统一抽象

第 3 部分:设计原则

构建中层软件结构(模块级编程)的主要目标:

  • 易扩展

    可容忍被改动

  • 易复用

    组件可在多个软件中复用,节省开发时间

  • 易理解

    确保开发上下文可重现

这里个人的理解是 “易复用” 和 “易理解” 其实主要为了 “易扩展”,说这么多,还是为了──适应变化。

SOLID 原则:

  • SRP: 单一职责原则

    “康威定律的一个推论”,使得 “,每个软件模块都有且只有一个需要被改变的理由”

  • OCP: 开闭原则

    “允许新增代码来修改系统行为,而非只能靠修改原来的代码”

  • LSP: 里氏替换原则

    组件应遵守相同约定以便可以互相替换

  • ISP: 接口隔离原则

    “避免不必要的依赖”

  • DIP: 依赖反转原则

    底层细节代码应该依赖高层策略代码

SOLID 原则主要指导如何将数据和函数组织成类(分组),以及如何将类链接为程序

“SOLID 原则应该直接紧贴于具体的代码逻辑之上”,主要适用于模块级编程

OCP 开闭原则是最终目标。

第 7 章:SRP: 单一职责原则

面向底层实现细节的设计原则:“每个模块都应该只做一件事” 并非 SRP 的全部

“任何一个软件模块都应该有且仅有一个被修改的原因”

SRP 粒度可大可小,也可以体现在单条语句上,一个真实的例子:

sheet.column_dimensions['A'].width = 3.25
sheet.column_dimensions['B'].width = 1.38
sheet.column_dimensions['C'].width = 3.63
sheet.column_dimensions['D'].width = 5
sheet.column_dimensions['E'].width = 6
...

重构后

width_conf = dict(
    A=3.25,
    B=1.38,
    C=3.63,
    ...
)
for conf_key, conf_val in width_confs.items():
	sheet.column_dimensions[conf_key].width = conf_val

重构的原因可以理解为: sheet.column_dimensions['A'].width = 3.25 其实至少包含两条信息,一条是 ‘A’ 的宽度是 3.25,另一条是如何设置列的宽度,职责过多。

SRP 最终描述:

“任何一个软件模块都应该只对某一类行为者负责”

注意:同一个职责被分散也是不对的,约定只能一处。 一般来说,一旦相同职责被分散,额外承担了该职责的模块就不再 SRP。因此不分散职责也属于 SRP。

反面案例

作者举的一个例子是:

┌─────────┐                        ┌──────────┐
│FIN - CFO├──────────┐             │ HR - COO ├─────────┐
└─┬───────┘          │             └─┬────────┘         │
  │   calculatePay   │───┐       ┌───│    reportHours   │
  │                  │   │       │   │                  │
  └──────────────────┘   │       │   └──────────────────┘
                         │       │
                         │       │
                         ▼       ▼
                   ┌──────────────────┐
                   │                  │
                   │   regularHours   │
                   │                  │
                   └──────────────────┘

两个问题:

  • 如果因为财务部门需求改了 regularHours() 而人力资源部门不需要这个修改会产生问题
  • (更容易发生)合并代码冲突

违背 ”独立开发、独立编译、独立部署“ 的原则。

避免问题产生的方法就是 ”将服务不同行为者的代码进行切分“

解决方案

有三种解决方案:

TODO

这里的思想还是 ”独立开发、独立编译、独立部署“,如果修改实现会影响这三点,就不是最优的设计。

本章小结

两个层面的不同表现形式:

  • 组件层面: 共同闭包原则
  • 软件架构:用于奠定架构边界的变更轴心

这里是康威定律的另一种表达,容易理解的表现是软件高层架构会反映组织架构,好比说我们有交易服务和风控服务,就有交易团队和风控团队。这里强调的是即使同一个团队负责的模块内部结构,也会反映行为者的组织架构,在这里的案例就是要按财务部门和人力资源资源部门将模块进行拆分。

作者已经明确指出了复用的价值,但是这里仍然把 DRY 的优先级放在了 SRP 之后,也就是说即使 regularHours() 可以复用,我们也应该完全隔离对行为者的响应。也许存在编写重复逻辑的风险,但是可以在需求变更时更为轻松。

我觉得这里还是要辩证的来看,作者当然是想用一种更强烈的方式表达自己的观点,因此极为保守地看待需求,即不同行为者的一切业务逻辑终将发生分化。然而倘若 regularHours() 真的是一种跨业务的四海九州亘古不变(或者至少是大领域级别)的 “真理逻辑” 呢?这种场景如果在 A 模块进行了修改而未在 B 模块修改反而可能产生不一致问题。

另一个现实的问题是作者的探讨假设了不同行为者的业务逻辑是完美隔离并且一成不变的,如果实际假设不成立,比如确实业务上未进行完美隔离,那么这也会对应用 SRP 造成困扰。本文也完全没有解释如何定义 ”行为者“ 及划分需求边界,这个可能反而是最难的。

有些时候不同行为者可能就是微小差异,可能用配置就能进行区分,注意实现上使用多态而不是 if .. else。但是所有的配置都容易演化成某种脚本语言…

这也只能富有经验的设计者才能好好把握,如果用一言蔽之,简单的指导原则对开发者进行要求和管理,SRP 是合理可行的。

常见实践

SRP 要辩证地来看,就像分布式、微服务一样,切勿矫枉过正,够用就好。只有一个模块职责多到变更痛苦,容易有事故,那可能就是要拆分。否则可能也无伤大雅。 好比我们对代码的拆解,极端的拆解就是一个函数一行代码,但是此时函数之间的关联和组织将变得复杂,完美点一定是分解的粒度和数量达到某种平衡。

特别需要注意的是高层大粒度级别的 SRP 一定要慎重,因为不好改,两三天改不完,很要命。

我们日常常见的做法是先 DRY,以后有新需求的情况则进行分化,提出代码进行修改(也需要修改相关行为者的调用),不修改其他无关调用。这看起来似乎是一个更合适的实践,但是:

书中案例中没有提出代码去改,而是在原地改(结果出问题)的两种可能: A. 没有意识到该段代码还对其他“行为者”负责 B. 修改者知道需要对其他 “行为者” 负责,但是不知道其他 “行为者” 对应需求不同

但凡考虑到了,就根本不会出现改了其他 “行为者” 需求的情况。但是人会犯错。 作者提出的方式就是保证开发者没有犯这两种错误的机会。

这里如果使用先 DRY,修改时再单独提出来改的方式,保证不做错的两种方式:

  1. 一旦有 “行为者” 需求变更,就提出代码进行分化,保证不影响其他 “行为者”。注意这里还会违背另一个“尽量减少修改旧代码” 的原则,要改针对当前 “行为者” 的旧调用。如果一开始就知道需求显然会变,那么推论是可能不如一开始就做分化(因为无论是否更换开发维护者,对旧代码的理解能力是随着时间消逝的)。“保证不影响” 也可能无法轻易做到,这里的案例容易让人进入思维误区,以为改需求就是改一个函数就 OK 了。尤其是改动比较广的情况下,效应是累加的。
  2. 每次修改代码,要全面调查待修改代码是否否会影响其他 “行为者”,视情况是否继续沿用 DRY。但这样相当于每次做 “行为者” A 的需求,又要回过头去考虑 “行为者” B、C、D 的需求,违背了作者一贯推崇的独立开发(为啥子要独立,因为碳基生命沟通很低效、昂贵!)原则。而且同样,如果改动本身是复杂的,那么调查也会变得复杂。更何况这种做法仍然会保留犯错机会。

如果认同两条基本原理:

  1. 需求迟早会变
  2. 无法准确预测需求将如何变化
    1. 说完全不能预测也不对,因为需求和业务规划也有关,有些部分的逻辑本身也可以人为去决定怎么做

那么推理来看,早做分化切割可能更划算。

然而无论是否一开始是否优先 DRY,一开始就应该保持可以轻松分化的姿态: 调用抽象,针对各行为者的实现分离 也就是说,比如行为者 A 和 B、C 都要用调用 foobar_f1() 那么需要多一层抽象来表示到底哪些行为者需要用 foobar_f1() 那么可以抽象出 A 调 a_foobar(),B 调 b_foobar(),C 调 c_foobar() ,注意是依赖于抽象,a_foobar() 、b_foobar()、c_foobar() 都是接口,这一层分化 然后才是说 a_foobar() 、b_foobar()、c_foobar()用 foobar_f1() 实现,这一层复用 这样到时候要更改 C 的需求,只需要改 c_foobar() 的实现为 foobar_f2()

第 8 章:OCP: 开闭原则

“设计良好的计算机软件应该易于扩展,同时抗拒修改”

aka. “一个设计良好的计算机系统应该在不需要修改的前提下就可以轻易被扩展”

  • 开:对于扩展开放
  • 闭:对于修改封闭

这个原则纯粹是目标声明式的,十分缺乏指导意义。

思想实验

好的架构设计会使变更需求时旧代码的修改降至最低

只增不改的开发将更有信心,因为老代码经过了时间的检验。这里有个 MySQL 迁 TDSQL 的例子,迁移过程中系统会有一段双写的时间,一种做法就是修改代码,增加写 TDSQL 的逻辑。而更健壮的方法则是提供代理层,老代码不变,由代理层负责迁移双写的逻辑,符合 “只增不改” 的原则。

而常见的装饰器模式、Sidecar 模式也都是 “只增不改” 的典范,符合该原则的组件通常耦合极低,也更容易复用以及组合。

为了实现这个目标,可以应用 SRP 和 DIP

这里明确表达了 SOLID 这五个原则并非独立正交,那么哪一个才是第一性原理呢?

“如果 A 组件不想被 B 组件上发生的修改所影响,那么就应该让 B 组件依赖于 A 组件”

依赖方向的控制

又见 DIP 依赖反转。

信息隐藏

说白了就是都依赖于抽象,从而从各个方向屏蔽实现细节。

本章小结

“实现方式是通过将系统划分为一系列组件,并且将这些组件间的依赖关系按层次结构进行组织,使得高阶组件不会因低阶组件被修改而受到影响。”

这里倒是进行了具有指导意义的说明。

第 9 章:LSP: 里氏替换原则

子类的定义:

Let q(x) be a property provable about objects x of type T. Then q(y) should be true for objects y of type S where S is a subtype of T.

子类和泛型是多态的两种形式。

继承的使用指导

简单地说就是子类应在父类能出现的任何地方替换父类(可替换性)。

正方形/长方形问题

如果定义正方形为长方形的子类,将违背 LSP,因为:

  • 长方形类的高和宽独立
  • 正方形类的高宽不独立

因此正方形类无法在所有处理长方形类的地方替换长方形类。

这是一种建模或者说朴素面向对象思想带来的子类问题。实际使用中具体是否违反 LSP 还是得看类需要的行为和操作,不是说正方形一定不能是长方形的子类。

协变

LSP 另一个复杂性就得提到协变的概念,可替换并不是 trivial 的。

定义 A<:B 表示 A 是 B 的子类

泛型

若 C[T] 是以 T 为参数的泛型,有三种可能:

  • 协变:C[A]<:C[B]
  • 逆变:C[B]:>C[A]
  • 不变:C[A]、C[B] 互不为子类

对于数组 A[] 和 B[]:

  • 协变:只读

    可以把 Dog 放入 Animal[],但是不能放入 Cat[]

  • 逆变:只写

    Animal 中可能读出 Dog[]

  • 不变:读写

C++ 中指针和引用是协变的。

函数

定义 A=>B 表示输入 A 输出 B 的函数,那么有定理: 如果 A2<:A1 并且 B1<:B2,则 A1=>B1 <: A2=>B2

也就是说如果要一个函数是另一个函数的子类,那么返回值必须更严格(其实是不更宽松),而形参要更宽松(其实是不更严格)。因此如果泛型 C 是函数,输入应该逆变而输出是协变。

而我们常说的一个类,就即包含数据又包含函数,那么 LSP 意味着不仅子类的数据可以替换父类的数据,子类的函数也应该可以替换父类的函数。

这就要求子类函数的返回值应更严格,形参更宽松。

主流的面向对象语言中, Java和C++允许返回值协变,C#不支持。 允许参数逆变的面向对象语言并不多——C++和Java会把它当成一个函数重载。

更复杂的是这里有争论,Eiffel 语言明确拒绝了 LSP,允许形参协变。 维基的例子,“猫调用问题”:

父类函数版本

class AnimalShelter {
    void putAnimal(Object animal) {
       ...
    }
}

子类函数形参逆变的版本

class CatShelter extends AnimalShelter {
    void putAnimal(Object animal) {
       ...
    }
}

子类函数形参协变的版本

class CatShelter extends AnimalShelter {
        void putAnimal(Cat animal) {
           ...
        }
    }

协变的版本在 LSP 的角度来看,丧失了类型安全,因为 CatShelter.putAnimal 拿去替换 AnimalShelter.putAnimal 的话,会可能收到 Dog。 但是从另一个角度来看,CatShelter 本来就不应该有狗放进来,这样看来用类型来限制也似乎并无不可。当然也可以认为这里是正方形/长方形问题的另一种表现。

所以这里本质上应该说一开始面向对象的建模就出了问题,面向对象的直观性来源于对现实实体关系的直接映射,然而上例中我们发现面向对象的局限,或者说符合 LSP 的局限。根本就不应该从这个角度进行 LSP 的 “子类” 建模,这里符合 LSP 的子类应该是豪华庇护所和经济庇护所才对。

最安全的方法是,父类只是抽象接口,子类只负责具体实现,保持接口(输入输出)类型与父类一模一样。

LSP 与软件架构

强调依赖于接口,类之间有可替换性。

违反 LSP 的案例

本节主要说两件事:

  1. 违反 LSP 会很痛苦
  2. 违背 LSP 时我们用什么方式进行弥补:分离配置(变化的部分),这样保持接口是不变的

第 10 章: ISP: 接口隔离原则

还是说依赖于抽象,进行依赖反转可以实现独立编译和独立部署。

ISP 与编程语言

动态类型语言如 Ruby 和 Python 天然就是面向接口编程,不需要显式使用接口声明(契约直接分散在代码里),鸭子类型,天生多态,因此比静态语言更灵活,更松耦合。

当然 Ruby 和 Python 里也是需要 import 的,只是依赖声明的粒度极粗,从编译和部署的角度来看,仅修改实现细节确实不需要引起依赖方的重新编译或重新部署。

ISP 与软件架构

“任何层次的软件设计如果依赖于不需要的东西,都会是有害的。”

细节就属于不需要依赖的东西,接口、约定是需要依赖且必须依赖的。 这里还是重点强调编译和部署解耦。

本章小结

声明创造出源代码之间的依赖关系。而对这种关系的管理是软件架构设计的核心问题之一。

应该说当前应用广泛的微服务实践正是这种原则的极致表现,完全做到各模块:

  • 独立开发
  • 独立编译
  • 独立部署

而在模块内部,已不存在拆分编译和部署了,因此一般开发者也无需关注这个点。

但是对于面向客户的单体巨石软件,可能还是需要多考虑独立编译和独立部署的事情。

第 11 章:DIP: 依赖反转原则

主要关注易变模块,应多依赖抽象而非实现。还是老生常谈的依赖于抽象。

稳定的抽象层

“接口比实现更稳定。”

DIP 是本书中少见的给出了具体办法的原则:

  • 应在代码中多使用抽象接口
  • 不要在具体实现类上创建衍生类
  • 不要覆盖包含具体实现的函数
  • 避免在代码中写入与任何具体实现相关的名字,或者是其他容易变动的事物的名字

工厂模式

“源代码依赖方向永远是控制流方向的反转”

具体实现组件

“不可能完全消除违反 DIP 的情况,将其与系统的其他部分隔离即可”

比如示例中,隔离抽象层和实现层,实现层的修改不应该影响抽象层。

因为各模块需要一同工作,具有内在的关系性,所以完全解耦是不现实的,最终也需要一个控制者让他们协作起来。

本章小结

“在系统架构图里,DIP 通常是最显而易见的组织原则”

“跨越边界的、朝向抽象层的单向依赖关系则会成为一个设计守则──依赖守则”

第 4 部分: 组件构建原则

参考


最近修改于 2020-05-14 01:50