《架构整洁之道》—— 软件设计的思考

全文参考自马丁大叔的《架构整洁之道》,书中文笔清爽易懂,不过在后半段有点条理不清流于术而非道

在编程领域,问题就像一个生命体一样,是在不断繁殖和进化的。它甚至经常不会人们预期中一般发展。作为一名出色的软件工程师或架构师,你需要有超出普通程序员的视角,考虑系统宏观的未来的发展。你的使命是,在这种恶劣的开发环境下,绘制一幅相对最优的图纸,用最少的时间、人力、金钱构建和维护一个随时可能融化在熵增热汤里的软件系统。和现实物理世界里的架构师类似,你需要了解编程世界里的一般规律,帮助你挑选武器(技术),修炼秘籍(方法论),在不同的江湖里(业务)打造不同的门派(软件系统)。

开卷有益,祝你练武愉快~

你要做什么

简而言之,架构师的终极目标就是用最少的人力成本来满足构建和维护软件系统的需求。糟糕的架构设计会让软件在成功之前,就带来高昂的边际人力成本,即开发新需求的开支越来越大(因为程序员的时间都耗费在系统的修修补补上了)。然而,这种日益增长的边际人力成本现象并不少见。来源于类似龟兔赛跑中兔子的盲目自信,实际上,无论从短期还是长期看,胡乱编写代码的工作速度其实比循规蹈矩更慢

架构师存在的一个必要性就是,软件存在着两种价值维度:

  • 行为价值(现在时):即实现功能和弥补bug。这类价值是紧急却并不总是重要的
  • 架构价值(将来时):即软件是否足够“软”(易于被修改),这类价值是重要却并不总是紧急的

很现实的一点是,在公司中,团队之间的抗争本来就是无穷无尽的。你作为研发团队的一员,职责的一部分就是避免你的代码在抗争的风吹雨打下变成一坨没人爱的shit。

编程范式

没错,架构师们也有祖师爷。在1958到1968年期间,3大编程范式就已经陆续出现了。

  • 结构化编程,由Dijkstra在1968年提出,并发扬光大,它对程序控制权的直接转移(程序语句)进行了限制和规范
  • 面向对象编程,最早在1966年提出,Ole Johan Dahl和Kriste Nygaard注意到,函数调用堆栈可以被放到堆内存中,从而在函数返回后继续保留。它对程序控制权的间接转移(函数调用)进行了限制和规范
  • 函数式编程,启发自Alonzo Church于1936年发明的lambda演算,发扬于1958年的LISP语言。它对程序的赋值进行了限制和规范

值得思考是,三大范式做的都是限制和规范,即告诉我们不能做什么,而不是可以做什么。另外,多态带来的架构边界飞跃,函数式编程带来的数据访问限制,结构化编程带来的算法拆解为我们架构软件提供了强大武器。这也与软件架构的三个关注点所契合:

  • 功能性,即完整的功能实现
  • 组件独立性,即合适的耦合度与细粒度
  • 数据管理,即良好的数据结构设计

结构化编程

Dijkstra在1950年代思考编程时,得出过一个结论:编程是一项难度很大的活动。他倾向于把编程类比为数学推导过程,并发现goto某些使用会导致模块无法被递归拆解成更小的单元。然而,去掉这些使用的goto可以被顺序结构、分支结构、循环结构这三种最小集等价表示出来。从而,大问题可以被逐步拆解为小问题。

不过,事情也并非这么理想,当程序复杂后,我们不可能像Dijkstra一样,用严格的数学推导形式化证明编程的正确性。相反,类似实验学科的无法被证伪即正确,我们现今依旧使用着Dijkstra的结构化编程思路将大问题拆解为小问题。

有趣的是,“无法被证伪即正确”和Dijkstra的一个观点“测试只能展示bug的存在,并不能证明不存在bug”不谋而合

简而言之,去掉goto糟粕诞生的结构化编程中,最有价值的地方就是,它赋予我们创造可证伪程序单元的能力,从而架构起大程序。在架构设计领域,功能性拆解仍然是最佳实践之一

什么?什么叫做可证伪?你应该写过单元测试吧。

面向对象编程

什么是面向对象?有人说面向对象是“数据和函数的组合”,也有人说是“对真实世界的一种建模方式”。但这两种理解要么片面,要么虚无缥缈。为了总结这种范式,我们先从它的3大特征入手:

  • 封装,即将一组关联数据和函数圈起来。然而这种特性,从C语言起就支持(struct + 头文件),很难说它是面向对象编程的必要条件
  • 继承,即可以在某个作用域对外部定义的一组变量与函数进行覆盖。不过C语言也能模拟出这种能力,看起来也比较勉强。
  • 多态,即在同一接口描述下的不同具体实现形式,C语言起也做了支持(STDOUT),然而使用函数指针显式实现多态问题就在于指针的危险性。而面向对象编程对这种程序间接控制权的转移做了约束。

传统的函数调用树中,系统行为决定了自上而下的控制流,而控制流决定了源代码依赖(代码实现)是自上而下的,比如在C中会使用#include引入依赖。此时不论是代码实现还是代码执行都是自上而下的。然而在多态的帮助下,底层函数需要依赖高层接口实现,作为高层函数的插件引入,从而将这种依赖关系和控制流反向,即依赖反转。实际上,借助安全便利的多态实现,可以轻松将依赖关系反转。

从而架构师可以完全控制这种方式下,系统中所有的源代码依赖关系,进而随意更改源代码依赖关系。让每个组件都有独立部署独立开发能力。好了,我们现在可以说明面向对象编程的含义了:

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

函数式编程

函数式编程依据的原理早在编程之前就已出现,相对前两种范式,函数式编程的风格可能相对陌生一点。在这类风格中,变量都是不可变的。从而让竞争问题、死锁问题、并发更新问题不复存在。一个架构良好的系统,需要将状态修改的部分和不需要修改的部分隔离开,然后用类似事务型内存的方式来保护可变量。另外,架构师应该着力于将大部分逻辑归于不可变组件中,可变组件的逻辑越少越好。

书中还提到了只包含CR的事件溯源存储逻辑,即通过事务日志的方式保存当前状态。因为不存在更改和删除,从而不存在并发问题。也是一种很新颖的思路。

回顾上面的三种编程范式,都在说什么不应该做。也即编程好似在充满死路的熵增旷野中,只有沿着相对安全的几个大方向才可拨开迷雾。

设计原则

软件的中层需要具有良好的可读性、可扩展性。这里就不得不提到SOLID原则:

  • SRP:单一职责原则,每个模块有且只有一个需要被改变的理由
  • OCP:开闭原则,对扩展开放,对修改封闭
  • LSP:里氏替换原则,子类型应该能够无无缝替换类型使用
  • ISP:接口隔离原则,依赖的模块不要包含不需要的接口
  • DIP:依赖反转原则,高层策略性代码不要依赖底层实现性代码

SRP

任何一个软件模块(一组紧密相关的函数和数据结构)都应该只对一个行为者负责。实际上,代码和数据就是靠着和某一类行为者的相关性组合起来的。我们需要将服务不同行为者的代码进行切分

OCP

设计良好的软件应该易于扩展,同时抗拒修改。实现方式可以通过将系统划分为一系列组件,并且将依赖关系按层次组织,使得高阶组件不会因为低阶组件修改受到影响。

LSP

里氏替换原则表示,子类型应该能够被当做父类型使用。它实际上表示了一种面向接口设计的设计原则。一旦违背了这种可替换性,就会不得不在系统架构中增加大量复杂的应对机制。

ISP

ISP告诉我们任何软件设计如果依赖了不需要的东西,都是不好的迹象,很容易带来不必要的麻烦。

DIP

DIP告诉我们,应该多引用抽象类型,而不是具体实现。因为软件是经常变动的,而抽象出共性的接口则是较少变化的。从而可以衍生出一些守则,譬如:

  • 应该多使用抽象接口,避免使用多变的实现类
  • 不要在实现类上创建衍生类
  • 不要覆盖具体实现的函数

不过当然了,还是得有人做实现的脏活累活的

组件构建

组件

组件是构建软件的最小单元,同时也是源代码的集合。在早期会使用链接技术将程序和库函数链接起来,而后随着机器性能的增长,我们会在程序运行中插入动态链接文件,如今这种组件化插件式架构是最常见的软件构建形式。

组件聚合

和类相似,组件也有一些原则指引我们的构建工作。

  • REP:复用/发布原则,即可以一起发布的最小粒度就是复用的最小粒度,也即按可以同时发布聚合
  • CCP:共同闭包原则,即因为同一原因修改的放在一起,反之不要放在一起,也即按变更原因聚合
  • CRP:共同复用原则,即会被一起复用的放在一起,反之不要放在一起,也即按减少无用耦合聚合

这三大原则相互牵制,在项目的不同阶段,某一原则重要性也会不同;比如在项目早期CCP就会更重要,而后REP会比较重要。

组合耦合

本节提出了一些可以定量衡量耦合健康度的指标,比较新颖。

  • 无依赖环原则:依赖关系中不能有环,会不利于厘清依赖关系;可以通过依赖反转创建第三方依赖组件解决。循环依赖关系务必持续监控。
  • 稳定依赖原则:依赖关系必须指向稳定的方向,简单点说就是让经常变更的组件依赖于不经常变更的组件。一个组件的位置稳定性可以通过入向和出向依赖算出,它要能和组件的实际稳定性匹配。
  • 稳定抽象原则:抽象化程度需要和稳定性程度一直,即经常变更的组件要容易变更,即更具体实现;反之,稳定的组件要不容易变更,即更抽象。结合上条看,依赖关系应该指向更抽象的方向。

使用位置稳定性指标I和抽象程度A,可以绘制一个坐标系。在主序列上的是最健康的,相反的两块痛苦区和无用区则是不健康的表现。用偏离主序列线的距离可以大致衡量依赖关系的健康程度。结合发布版本的变化来看,还可以得到变化趋势。

软件架构

软件架构目的就是方便在工作中更好地对组件进行研发、部署、运行和维护。其中的策略就是保留尽可能多的可选项。让系统最大化程序员的生产力,同时最小化系统运营成本:

  • 开发:系统架构需要方便开发团队对它的开发,不同的团队结构应该采用不同的架构设计,比如团队的大小就会影响架构的选择
  • 部署:一键式部署
  • 运行:几乎任何运行问题都可以通过增加硬件来解决
  • 维护:减小新功能和系统缺陷占用的人力资源

保持可选项,忽略那些无关紧要的实现细节。任何软件系统都可以拆解成策略(业务的宏观逻辑和流程)和细节(具体操作行为)。而策略才是系统的真正价值所在。细节是指那些和策略交互的东西,包括:

  • I/O设备
  • 数据库
  • Web系统
  • 服务器
  • 框架
  • 交互协议

在设计时,可以尽量拖延上面这些的设计,这样我们做出的决策才不会依赖各种很容易变化的信息。另一方面,也可以增加实现底层的可替换性。举个具体例子:设备无关性

独立性

一个良好的架构应支持下面几点:

  • 系统用例:设计良好的架构需要能够看起来就可以反映系统的设计意图,比如一个购物车应用架构应该看起来就该是用来实现购物车的
  • 系统运行:可以解耦出多个独立服务,然后通过某种网络协议通信,这种架构即微服务
  • 系统维护
  • 系统开发
  • 系统部署:理想的独立部署应该能够做到热更新

要注意留意表面的重复和实际的重复,如果两段代码变更速率和缘由不同,那么就不算是真正的重复。

划分边界

  • 设计良好的系统架构不应该依赖细节,而应该尽可能推迟细节性的决策。通过划清边界,可以推迟和延后细节性的决策,从而节省大量时间,避免问题。
  • 边界线应该画在不相干的事情中间,譬如GUI和业务逻辑
  • 针对核心业务逻辑的插件式架构可以提高可维护性和可扩展性

边界剖析

简言之,应该尽可能从底层组件指向高层组件。

策略和层次

  • 变更原因、时间和层次不同的策略应该属于不同的组件
  • 按距离系统输入、输出距离的远近,可以确定策略的层次
  • 源码间的依赖关系,应该主要和组件所在的层次挂钩
  • 低层组件应该以插件的方式依赖高层组件

业务逻辑

业务逻辑是程序中真正用于或者体现赚钱/省钱的逻辑与过程。其中关键逻辑和关键数据紧密组合成为业务实体。业务实体应该只有高层逻辑,没有具体实现。而用例是业务实体在不同侧面的具体体现。通过用例可以规范用户和业务实体的交互方式。

“尖叫”的软件架构

“尖叫”即所见即所得。软件架构本身就足以能够体现其用途。一个良好的架构设计应该围绕用例展开,推迟和延后框架的选择,不要过度拘泥于框架。框架只是一个可选项,是一个工具,而不是一种信念,更不是一种架构。

整洁架构

一些常见的系统架构通常具有以下特点:

  • 独立于框架
  • 可被测试
  • 独立于UI
  • 独立于数据库
  • 独立于外部接口

Main组件

  • Main组件包含了系统中最细节化最底层的策略,它应该在做完脏活累活后,将程序的控制权交给最高抽象层的代码去执行
  • 针对不同系统可以配置不同的Main组件,即将Main组件视为应用程序的一个插件

服务:微观和宏观

  • 系统的架构边界事实上并不落在服务之间,而是穿透所有服务,在服务内以组件形式存在
  • 服务可以提升系统的可扩展性和可开发性,不过服务却并不能代表整个系统的架构设计

整洁的嵌入式架构

  • 固件即对平台或硬件的强依赖代码,在固件和软件之间可以设置HAL(硬件抽象层),为它上层的软件提供服务,它可以帮助软件脱离目标硬件平台来测试
  • 类似地,我们还可以引入OSAL(操作系统抽象层)来减少软件对操作系统的依赖

实现细节

那么什么算是实现细节呢?

  • 数据库,数据的组织结构和模型都是系统架构的一部分,但是从磁盘中存储/读取数据的机制或手段则没那么重要,就比如数据库或静态文件
  • Web,Web只是UI,只是一种I/O设备
  • 应用框架,框架被创造的目的是解决作者遇到的问题,它要求我们去阅读文档,按照作者的要求整合到我们的应用中,可以使用但是不要被框架绑定

案例:视频销售网站

  • 系统架构设计的第一步是识别系统中的各种角色和用例

–END–