软件设计之美¶
课前必读¶
开篇词¶
设计是为了让软件在长期更容易适应变化。 — Kent Beck
Design is there to enable you to keep changing the software easily in the long term.
算法对抗的是数据的规模,而软件设计对抗的是需求的规模。
软件设计学习的难度,不在于一招一式,而在于融会贯通。
软件设计,是一门关注长期变化的学问,因为只有长期的积累,需求才会累积,规模问题才会凸显出来。软件设计,实际上就是应对需求的 “算法”。
“软件设计”的两个维度
“了解现有软件的设计”
“自己设计一个软件”
快速了解现有软件设计的方法,那就是抓住这个软件最核心的三个部分
模型
接口
实现
01 软件设计到底是什么¶
核心的模型
模型,是一个软件的骨架,是一个软件之所以是这个软件的核心 区别于解决简单的问题,软件的开发往往是一项长期的工作,会有许多人参与其中。在这种情况下,就需要建立起一个统一的结构,以便于所有人都能有一个共同的理解。这就如同建筑中的图纸,懂建筑的人看了之后,就会产生一个统一的认识。
模型的粒度可大可小
好的模型
有效地隐藏细节,让开发者易于理解
“高内聚、低耦合” 指的就是对模型的要求
模型是分层的
可以不断地叠加,基于一个基础的模型去构建上一层的模型,计算机世界就是这样一点点构建出来的
约束的规范
规范,就是限定了什么样的需求应该以怎样的方式去完成。
常见的问题
缺乏显式的、统一的规范
规范不符合软件设计原则
模型与规范的关系
相辅相成
模型要符合一定的规范
规范的制定依赖于模型
做设计的关键是,找到不变的东西
流程是容易调整的,而模型如果变了,这个软件整个就变了
分层不是你的设计,而构建出你的模型才是设计
02 | 分离关注点¶
分离关注点
定义:将一个模块的不同维度分开
把业务处理和技术实现分离 在真实项目中,程序员最常犯的错误就是认为所有问题都是技术问题,总是试图用技术解决所有问题。任何试图用技术去解决其他关注点的问题,只能是陷入焦油坑之中,越挣扎,陷得越深。
业务与技术的区分
业务人员能理解的就是业务的,比如,订单
业务人员理解不了的就是技术的,比如,多线程。
不同的数据变动方向
高频和低频分离
动静分离,就是把变和不变的内容分开
读写分离,就是把读和写分开
重要性
一方面,不同的关注点混在一起会带来一系列的问题
另一方面,当分解得足够细小,你就会发现不同模块的共性,才有机会把同样的信息聚合在一起
说明
分离关注点是一种意识
这种意识是需要训练的 一旦自己在做设计时,出现纠结或者是觉得设计有些复杂,首先需要想想,是不是因为把不同的关注点混在了一起。
实践结合
在设计评审的 DoD 中,增加一条 “是否考虑了分离关注点”
需要从小事开始练习
实践
CQRS(Command Query Responsibility Segregation)
静态上
拆分了这两块的代码。使各自可以采用不同的技术栈,做针对性的调优
动态上
切分了流量,能够更灵活的做资源分配
循环依赖
产生的一个重要原因就是没有分清接口和实现。
03 | 可测试性¶
原因
软件开发要解决的问题是从需求而来。需求包括两大类
第一类是功能性需求
第二类是非功能性需求
执行质量(Execution qualities)
包括吞吐、延迟、安全
可以在运行时通过运维手段被观察到
演化质量(Evolution qualities)
包括可测试性、可维护性、可扩展性
内含于一个软件的结构之中
可测试性的视角
作为一个衡量标准来考察已有的设计
帮助我们理解软件的发展趋势
实践
private 方法怎么测?其实是一个伪命题
要测 private 方法,更多的是因为这个类承担了过多的职责,才会出现层层嵌套的方法,才会不好测。
实例 曾经开发过堆场应用,其中一个步骤是从远端服务器同步到本地服务器,然后再执行本地逻辑。如果每次测试本地逻辑都要从服务端拉取数据的话,就没法自动测了。当时采用的测试方法就是先抓取接口数据生成接口文件,测试就从文件中加载,再运行,最后销毁整个数据库。如果有接口相关的 bug,也同样抓取数据保存,构建一个 bug 号命名的测试方法测试 bug。后来做过系统高可用软件,采用的方法是将代码自动部署到多个 Docker 里,测试代码里依据场景(为了方便,场景还用 DSL 写)比如杀某个 Docker 来测试高可用逻辑是否正常。
了解一个软件的设计¶
04 | 三步走:如何了解一个软件的设计¶
模型
作用
模型是这个系统与其他系统有所区别的关键
设计最关键的就是构建出模型
理解一个设计中的模型,可以帮助我们建立起对这个软件整体的认知
示例说明
分布式计算: 需要考虑怎样在不同的节点上调度计算
MapReduce: 只要考虑如何把计算分开(Map)最后再汇总(Reduce)
Spark: 注意力就集中在要做怎样的计算上
接口
作用
决定了软件通过怎样的方式,将模型提供的能力暴露出去
是与这个软件交互的入口
示例
程序库的接口就是它的 API
工具软件一般会提供命令行接口
业务系统的接口,就是对外暴露的各种接口
实现
作用
软件提供的模型和接口在内部是如何实现的
是软件能力得以发挥的根基
说明
模型和接口的稳定度都要比实现高,实现则是要随着软件发展而不断调整
说明
设计应该遵循一个顺序,先模型,再接口,最后是实现 如果模型都还没有弄清楚,就贸然进入细节的讨论,你很难分清哪些东西是核心,是必须保留的,哪些东西是可以替换的。如果你清楚了解了模型,也就知道哪些内容在系统中是广泛适用的,哪些内容必须要隔离。简单地说,分清模型会帮助你限制实现的使用范围
实践
另一个角度
模型,通常包含两类要素
一是基本元素
二是这些元素之间的关系
05 | 如何分析一个软件的模型¶
理解一个模型的关键
要知道项目提供了哪些模型,这些模型都提供了怎样的能力。
要了解这个模型设计的来龙去脉,知道它是如何解决相应的问题 理解一个模型,需要了解在没有这个模型之前,问题是如何被解决的,这样,你才能知道新的模型究竟提供了怎样的提升
以Spring DI 容器为例
“依赖注入”(Dependency Injection,简称 DI)
总结:Martin Fowler《反转控制容器和依赖注入模式》
有效解决对象创建与组装的问题
很多人习惯性把对象的创建和组装写到了一个类里面
造成的结果就是,代码出现了大量的耦合
错误的做法 class ArticlService {
private ArticleRepository repository;public ArticlService(final Connection connection) {
this.repository = new DBArticleRepository(connection);
}
}正确的做法 // 创建
class ArticleService {
private ArticleRepository repository;public ArticleService(final ArticleRepository repository) {
this.repository = repository;
}
}
// 组装
ArticleRepository repository = new DBArticleRepository(connection);
AriticleService service = new ArticleService(repository);
可测试性是衡量设计优劣的一个重要标准
06 | 如何分析一个软件的接口¶
两个步骤 找主线看文章,看风格看接口。从上到下,从整体到局部。正是读源码的正确步骤。
找主线
方法
找到功能主线,对项目建立起结构性认知
沿着主线把相关接口梳理出来
工具
起步文档
看风格
含义
接口希望你怎么使用它,或怎么在上面继续开发
意义
看出设计者的品味
维护项目的一致性
以Ruby on Rails为例
模型
基于 MVC模型开发的 web 框架
设计理念
约定大于配置
三种接口
REST API
Web 应用对外暴露的接口
实用、更容易落地
API
程序员写程序时用到的接口
简单、表达性好
命令行
开发过程中用到的接口
融合软件工程的最佳实践
07 | 如何分析一个软件的实现¶
软件的结构
含义
软件的分层
工具
结构图
软件的结构其实也是软件的模型,只不过,它不是整体上的模型,而是展开实现细节之后的模型 对于每个软件来说,当你从整体的角度去了解它的时候,它是完整的一块。但当你打开它的时候,它就变成了多个模块的组合,这也是所谓 “分层” 的意义所在
关键的技术
定义
关键技术是能够让这个软件的 “实现” 与众不同的地方
以 Kafka 为例
模型和接口
以消息队列为核心
以生产和消费为接口
实现
软件结构
生产者、消费者、集群3部分组成
关键技术
软硬接合的思路
磁盘顺序读写的特性
一句话
理解实现,带着自己的问题,了解软件的结构和关键的技术。
设计一个软件 — 程序设计语言¶
08 _ 语言的模型¶
前言
每年至少学习一门新语言
Andrew Hunt 和 David Thomas 在《程序员修炼之道》(The Pragmatic Programmer)中给程序员们提了一项重要的建议
学习程序设计语言主要是为了学习程序设计语言提供的编程模型
程序设计语言本身也是一个软件,它也包含模型、接口和实现
程序设计语言发展简史
主流路径
汇编语言
引入了助记符
高级程序设计语言
Fortran
数据类型
控制结构
C
屏蔽了硬件诸多细节
C++
兼容 C
增加了面向对象和范型
Java
垃圾回收机制
次主流路径
函数式编程
LISP
探索
声明式编程
DSL
元编程
其他
动态语言
代表
PHP、Python、Ruby、Perl
优势
简单、轻巧、门槛低
一切都是语法糖
定义
语法糖(Syntactic sugar)
英国计算机科学家彼得・兰丁发明的一个术语
定义
是那些为了方便程序员使用的语法,它对语言的功能没有影响。
启示
学习语言就是学习其编程模型
不提供新编程模型的语言不值的学习
09 _ 语言的接口¶
软件设计的发力点
语法
程序库
目的
消除重复
类型
标准库
三方库
包管理器
编程思想
语言设计就是程序库设计
一个经过程序库验证的模式最终成为语言的一部分
new
synchronized
程序库设计就是语言设计
总结
语法和程序库是在解决同一个问题,二者之间是相互促进的关系
提升软件设计能力,可以从编写程序库入手
1、可以锻炼自己从日常工作中寻找重复
2、可以更好地理解程序设计语言提供的能力
实践
设计模式是缺失的语言特性
如在函数是一等公民的语言中,至少有半打的设计模式是不需要的
所有非平凡的抽象 (abstraction) 在某种程度上都是有漏洞的 (leaky)
封装的程序库只能工作在某个抽象层次上,总会遇到无法在该抽象层次上解决的问题,此时只能绕过这层抽象从更低的抽象层次上解决。
10 _ 语言的实现¶
运行时
程序设计语言的实现就是支撑程序运行的部分
目的:实现语言的执行模型
软件设计的地基
程序如何运行
对于 Java 来说
了解执行文件结构
class 文件结构
了解程序如何加载
加载器的运作和内存的布局
了解指令如何执行
运行机制和字节码的执行
了解更多信息
GC、语法、程序树
突破口就在于了解指令是如何执行的
运行时的编程接口
提供方式
以程序库的方式提供
getClass
Annotation
以标准的方式提供
ASM
ByteBuddy
作用
实现超过语言本身的能力
11 _ DSL:设计一门自己的语言¶
《领域特定语言》
Martin Fowler
语义模型(Semantic Model)
定义:一种用于某种特定领域的程序设计语言
作用:缩短问题和解决方案之间的距离,降低理解的门槛
领域特定语言
外部 DSL
宿主语言(Host Language)与 DSL 相同
内部 DSL
宿主语言(Host Language)与 DSL 不同
语言工作台(Language Workbench)
常见的 DSL
正则表达式
用于文本处理这个特定领域的 DSL
配置文件
根据需求对软件行为进行定制
以 Nginx 为原型
单独的Web 服务器
反向代理
负载均衡
SQL
markdown
Cron类
ansibe
pipefile
.drone.yml
.github/hook.yml
高级编程语言是低级编程语言的 DSL
代码的表达性
DSL 的 4 个关键元素
计算机程序设计语言(Computer programming language);
语言性(Language nature);
受限的表达性(Limited expressiveness);
针对领域(Domain focus)。
语言性强调的就是 DSL 要有连贯的表达能力
设计自己的 DSL 时,重点是要体现出意图
写代码时应该关注代码的表达能力
优秀程序员与普通程序员
普通程序员的关注点只在于功能如何实现
优秀的程序员会懂得将不同层次的代码分离开来,将意图和实现分离开来,而实现可以替换
程序设计语言的一个重要发展趋势
声明式编程
重要的设计原则
分离意图和实现
想写好代码,一定要懂得设计
加餐:几门语言¶
C#
微软
Anders Hejlsberg
最顶级的程序员
J++
庭外和解
C#
Lambda、类型推演
从语言特性上来看,说 C# 领先 Java 十年并不夸张
.NET
一个平台,多个语言
Java
一个语言,多个平台
JVM
多个语言,多个平台
Groovy、Scala、Clojure
JavaScript
JavaScript
Brendan Eich
为了应付工作,因为他当时供职的 Netscape 需要让网页上的元素动起来
仅仅用 10 天就设计出来
真正想做的是一门函数式编程的语言
向现实妥协的结果就是,借助了 C 风格的语法,函数式编程的底子却留在了 JavaScript 里
Node.js
归功于 V8 这个 JavaScript 引擎
V8 一开始就是一个独立的 JavaScript 引擎
引入了异步 IO 的模型
与 JavaScript 事件驱动的特点相吻合
NPM 这个包管理器
降低了众多开发者参与的门槛
React、Angular、Vue 等框架
历史包袱
包袱
JavaScript 作为一门语言,其问题之多也是由来已久的
沉重的历史包袱让很多人都想开发出新的语言去替代它
很多人把它看成了一种 Web 上的汇编语言
CoffeeScript
TypeScript
新一代的 JavaScript 标准
取 JavaScript 而代之
仅仅在语言层面屏蔽 JavaScript 是不够的
WebAssembly 就是想成为 Web 上真正的汇编
Go
C
C 诞生那个年代,程序的规模还不算太大
现在规模越来越大,大到超出了 C 语言的能力范畴
C++
语言特性的试验田,泛型编程,尤其是模板元编程
高手极度喜爱,普通人一脸懵
背负了 C 语言所有的历史负担
如,内存管理
必须对 C++ 极其了解,才能写好 C++
对于一个工程化的语言来说,要求实在是太高了
Go
作者
Ken Thompson
C 语言的作者
Rob Pike
Unix 先驱
特性
接口设计和并发上的处理方式优雅
成绩
系统编程领域并没有太多的机会留给它
实时性和性能要求极高的领域
GC差
云计算领域还有一些基础设施
Docker
Kubernetes
Rust
Mozilla
Graydon Hoare 的个人项目
特性
对初学者并不友好
“变” 量缺省是不变的
先要了解所有权的概念
LLVM
传统的工具链 GCC 太过沉重
LLVM 把编译器的前端和后端分离开来
语言开发者只要关注前端,设计好各种语言特性
可以利用 LLVM 的后端进步的优势
如,不断优化带来的性能提升
成绩
C 语言替代者的竞争中,Rust 值得期待
设计一个软件 — 编程范式¶
12 | 编程范式¶
定义
编程范式(Programming paradigm)
程序的编写模式
意味着主要使用的是什么样的代码结构
主流的编程范式
结构化编程(structured programming)
面向对象编程(object-oriented programming)
函数式编程(functional programming)
逻辑式编程
Prolog
意义
编程范式不仅仅是提供了一个个的概念
更重要的是,它对程序员的能力施加了约束
结构化编程,限制使用 goto 语句,它是对程序控制权的直接转移施加了约束
面向对象编程,限制使用函数指针,它是对程序控制权的间接转移施加了约束
函数式编程,限制使用赋值语句,它是对程序中的赋值施加了约束
当 Bob 大叔说出那句,“编程范式本质是从某方面对程序员编程能力的限制和规范” 时,真有些振聋发聩
吸取不同编程风格中的优秀元素
采用面向对象来组织程序中的各个模块
采用函数式编程指导类的接口设计
在具体的实现中使用结构化编程提供的控制结构
13 | 结构化编程¶
历史
结构化是相对非结构化编程而言的
goto 是有害的
goto 是一种对程序控制权的直接转移
Edgar Dijkstra
Go To Statement Considered Harmful
功能分解
将模块按功能进行拆分
促进各种结构化分析和结构化设计方法的兴起
不足之处
抽象级别不够高
模块间依赖关系过强
可测试性不够
扩展
基于统计思想设计的算法
hyperloglog
布隆过滤器
14 | 面向对象之封装¶
历史
“面向对象” 这个词是由 Alan Kay 创造的
2003 年图灵奖的获得者
“I made up the term “object-oriented,” and I can tell you I did not have C++ in mind.” Alan Kay
“Java and C++ make you think that the new ideas are like the old ones. Java is the most distressing thing to hit computing since MS-DOS.” Alan Kay
最初的构想
对象就是一个细胞
当细胞一点一点组织起来,就可以组成身体的各个器官
再一点一点组织起来,就构成了人体
当你去观察人的时候,就不用再去考虑每个细胞是怎样的
面向对象给了我们一个更宏观的思考方式
封装
封装是面向对象的根基
定义
把紧密相关的信息放在一起,形成一个单元
一层一层地逐步向上不断构建更大的单元
重点在于对象提供了哪些行为,而不是有哪些数据
函数是接口,而数据是内部的实现
接口是稳定的,实现是易变的
设计一个类步骤
先要考虑其对象应该提供哪些行为
然后根据行为提供对应的方法
最后才是考虑实现这些方法要有哪些字段
减少暴露接口
之所以我们需要封装,就是要构建一个内聚的单元
减少这个单元对外的暴露
减少内部实现细节的暴露
减少对外暴露的接口
统一的原则:最小化接口暴露
不局限于面向对象的封装
可运用于非面向对象的程序设计语言中
提高代码的模块性
一句话
基于行为进行封装,不要暴露实现细节,最小化接口暴露
15 | 面向对象之继承¶
类型
实现继承
站在子类的角度往上看,面对的是子类
接口继承
站在父类的角度往下看,面对的是父类
更多是与多态相关
误区
把实现继承当作一种代码利用的方式
很大程度上是受了语言的局限
合理方式
通用原则
组合优于继承
编程思想
面向组合编程
实践
DCI (Data,Context 和 Interaction)
是一种特别关注行为的模式 (可以对应 GoF 行为模式)
MVC 模式是一种结构性模式
https://www.jdon.com/dci.html
属性和组合的区别
属性就是一个类固有的性质,就像一个人的身高体重
组合讲的是与其它部分的关系,比如,车有两个轮胎
小类大对象
是用 C++ 的方式描述的
每个类是小的,简言之代码都不多
最终形成的对象是个大的,因为它把所有的小类组合了起来
说明
在运行时,用到的是对象
设计中,你用到的是接口,是类层面的东西。
设计的角度
作抽象 (找共性,文档中的系统模型,代码详细设计的接口)
作分解 (找特性,实现)
分解的目的就是将处理逻辑和数据的不同点突出出来
根据不同的差异将各种实现进行相应的组合,支持接口功能
分解做好了,代码重复性就降低了
两个方面着手
数据角度
数据处理角度
要求
是否能够快速学习一个新东西,就是程序员之间体现出差异的地方
16 | 面向对象之多态¶
理解多态
概念
一个接口多种形态
基于对象和面向对象的分水岭
前提
理解接口的价值
谨慎的选择接口中的方法
做法
找出不同事物的共同点,建立起抽象
关键在分离关注点上
面向接口编程
在构建抽象上,接口扮演着重要的角色
接口将变的部分和不变的部分隔离开来
接口是一个边界
清晰界定不同模块的职责是很关键的
模块之间彼此通信最重要的就是通信协议
这种通信协议对应到代码层面上,就是接口。
实现多态
做法
将一种常见的编程结构升华为语法
作用
函数指针的使用就得到了限制
降低程序员犯错的几率
没有继承的多态
只要遵循相同的接口,就可以表现出多态
多态不一定依赖于继承
面向对象的编程,更重要的是封装和多态
面向对象编程的三个特点的定位
封装
面向对象的根基,软件就是靠各种封装好的对象逐步组合出来的
继承
给了继承体系内的所有对象一个约束,让它们有了统一的行为
多态
让整个体系能够更好地应对未来的变化
要求
对程序员来说,识别出变与不变,是一种很重要的能力
一句话
建立起恰当的抽象,面向接口编程
17 | 函数式编程¶
函数式编程是一种编程范式
提供给我们的编程元素就是函数
这个函数是来源于数学的函数
Lambda函数
起源是数学家 Alonzo Church 发明的 Lambda 演算(Lambda calculus,也写作 λ-calculus)
可以简单地把它理解成匿名函数
Lambda 演算和图灵机是等价的
都是那个年代对 “计算” 这件事探索的结果
大多数程序设计语言都是从图灵机的模型出发的
也有少部分从 Lambda 演算出发。比如LISP
函数式编程概念
虽然说函数式编程语言早早地就出现了
这个概念却是 John Backus 在其 1977 年图灵奖获奖的演讲上提出来
一等公民(first-class citizen)
它可以按需创建;
它可以存储在数据结构中;
它可以当作实参传给另一个函数;
它可以当作另一个函数的返回值。
一句话
函数式编程的要素是一等公民的函数,如果语言不支持,可以自己模拟。
18 | 函数式编程之组合性¶
来源于函数式编程的概念
GC
Lambda
流(Stream)
Optional
惰性求值
函数式编程在设计上对我们帮助最大的两个特性
组合性
不变性
组合行为的高阶函数
高阶函数(High-order function)
定义
可以接收函数作为输入,或者返回一个函数作为输出
一个重要作用
可以用它去做行为的组合
组合性
函数式编程的组合性,就是一种好的设计方式
能把模型拆解成多个可以组合的构造块,这个过程非常考验人的洞察力,也是 “分离关注点” 的能力
这个过程可以让人得到一种智力上的愉悦
列表转换思维
map
把一组数据通过一个函数映射成另一组数据
filter
把一组数据通过一个条件过滤,只留下满足条件的数据
reduce
把一组数据按某个规则,归约为一个数据
组合性的区别
面向对象编程
元素是类和对象
对系统结构进行搭建
面向函数编程
元素是函数
对函数接口进行设计
总结
可以用面向对象编程的方式对系统的结构进行搭建,然后,用函数式编程的理念对函数接口进行设计
一句话
设计可以组合的函数接口
19 | 函数式编程之不变性¶
不变性的意义
函数之间的组合很好地体现出了函数式编程的巧妙之处
学习编程范式不仅要看它提供了什么,还要看它约束了什么
值
概念
一个初使化后就不再改变的量
当你使用一个值的时候,值是不变的
应用
编写不变类
所有字段只在构造函数中初使化
所有方法都是纯函数
有改变时返回一个新的对象,不修改原有字段
意义
保证不会显示改变一个量
纯函数
概念
对相同的输入有相同的输出
没有副作用
应用
不修改任何字段
不调用修改字段的方法
意义
不会隐式改变一个量
实践
变化与不变
变化是需求层面的不得已
不变是代码层面的努力控制
函数式编程的 “不变性” 也是 OCP 原则的一种体现
编程原则
尽可能编写不变类和纯函数
Java 就尽可能多使用 final
C/C++ 就多写 const
加餐 | 函数式编程拾遗¶
惰性求值
Lazy Evaluation
定义
一种求值策略,它将求值的过程延迟到真正需要这个值的时候
好处就在于可以规避一些不必要的计算,尤其是规模比较大,或是运行时间比较长的计算
Proxy 模式,它就是采用了惰性求值的策略
无限流
Infinite Stream
无限长集合真正预置进去的是,元素的产生规则
如:产生一个自然数的集合
Stream.iterate(1, number -> number + 1)
定义了这个集合的第一个元素,然后给出了后续元素的推导规则
记忆
Memoization
定义
在计算中,记忆是一种优化技术,主要用于加速计算机程序,其做法就是将昂贵函数的结果存储起来,当使用同样的输入再次调用时,返回其缓存的结果。
Optional
Optional 将对象封装起来的做法来自于函数式编程中一个叫 Monad 的概念
Option 的价值在于类型而非对象
参考
《计算机程序的构造与解释》
设计一个软件 — 设计原则与模式¶
20 | 单一职责原则¶
SOLID 原则
由 Robert Martin 提出并逐步整理和完善的
《敏捷软件开发:原则、实践与模式》
《架构整洁之道》
定义
误区
一个类只干一件事
初使
一个模块应该有且仅有一个变化的原因
将变化纳入了考量
这是与一个类只干一件事之间最大差别
进阶
一个模块应该对一类且仅对一类行为者(actor)负责
将变化的来源纳入了考量
本质
理解单一职责原则本质上就是要理解分离关注点
想理解好单一职责原则
需要理解封装
知道要把什么样的内容放到一起
需要理解分离关注点
知道要把不同的内容拆分开来
需要理解变化的来源
知道把不同行为者负责的代码放到不同的地方
一句话
应用单一职责原则衡量模块,粒度越小越好
21 | 开放封闭原则¶
定义
软件实体(类、模块、函数)应该对扩展开放,对修改封闭
Bertrand Meyer
《面向对象软件构造》(Object-Oriented Software Construction)
如何理解
对扩展开放
新的需求用新代码实现
对修改封装
不修改已有代码
给软件设计提出了一个极高的要求:不修改代码
构建扩展点
每个扩展点都是一个需要设计的模型
阻碍程序员们构造出稳定模块的障碍,其实是构建模型的能力
构建模型的难点
首先在于分离关注点
其次在于找到共性
更广泛的用途
提供机制,而不是策略
《Unix 编程艺术》
向提供足够扩展能力的软件接口学习
插件机制
帮助我们改进自己的系统
查看自己的源码控制系统,找出那些最经常变动的文件,它们通常都是没有满足开放封闭原则的,而这可以成为我们改进系统的起点
一句话
设计扩展点,迈向开放封闭原则
22 | Liskov 替换原则¶
起源
2008 年,图灵奖授予 Barbara Liskov
1988 年,定义了 LSP 这里需要如下替换性质:若每个类型 S 的对象 o1,都存在一个类型 T 的对象 o2,使得在所有针对 T 编程的程序 P 中,用 o1 替换 o2 后,程序 P 行为保持不变,则 S 是 T 的子类型。
如何理解
表述
子类型必须能够替代其父类型
是一个把继承体系设计好的设计原则
理解角度
站在父类角度
正确
站在子类角度
错误
关心子类是一种实现继承的表现
现象
出现 RTTI 代码
RTTI
示例
如:需要用instanceof先判断子类型是什么,再做相应的业务处理 void handle(final Handler handler) {
if (handler instanceof ReportHandler) {
// 生成报告
((ReportHandler)handler).report();
return;
}if (handler instanceof NotificationHandler) {
// 发送通知
((NotificationHandler)handler).sendNotification();
}
…
}虽然都是同一父类的子类,但它们没有统一的处理接口
以多态应用为荣,以分支判断为耻
如何满足
对象体系要有统一的接口
子类要满足 IS-A 的关系
IS-A
表述
如果A 是 B 的子类,需要满足A 是一个 B
关系
继承要符合 IS-A
判定是基于行为的
更广泛的用途
接口设计
公开接口是最宝贵的资源,不能随意添加
总结
LSP 的根基在于继承,但显然接口继承才是重点
23 | 接口隔离原则¶
如何理解
表述
不应强迫使用者依赖于它们不用的方法。
No client should be forced to depend on methods it does not use.
常见错误
分不清使用者和设计者
应用场景
接口过胖
接口内包含太多内容
应把大接口分解成一个个小接口
接口设计的 SRP
每个使用者面向的接口都是一种角色接口
角色接口(Role Interface)
Martin Fowler
每个人在实际生活中扮演着不同的角色一样
父亲、儿子、员工、公民
多个角色由一个人承担
在做接口设计时,应该关注不同的使用者
更广泛的用途
不要依赖于我们不需要的东西
指导我们在更高层次设计
24 | 依赖倒置原则¶
高层&低层
表述
高层模块不应该依赖于低层模块,二者依赖于抽象
High-level modules should not depend on low-level modules. Both should depend on abstractions.
关键点
依赖倒置
让高层模块不再依赖于低层模块
做法
引入一个中间层,也就是模型
细节&抽象
表述
抽象不应该依赖于细节,细节依赖于抽象
Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.
编码规则
任何变量都不应该指向一个具体类
任何类都不应继承自具体类
任何方法都不应该改写父类中已经实现的方法
组装代码与模型代码分开
分离关注点,将变化的与不变有效的区分开
实践
名言
计算机科学中的所有问题都可以通过引入一个间接层得到解决。
David Wheeler
All problems in computer science can be solved by another level of indirection
依赖倒置的关键点
高层模块和底层模块之间抽象出一个通用的稳定不变的公共接口
这个接口作为了一个隔板,将稳定部分和易变部分隔离开
用到开闭原则 - 分离关注点,找共性,对修改封闭,对扩展开放
当可变部分扩展业务功能时,只要实现接口方可
接口的粒度需要接口隔离原则和单一职责原则来指导
锻炼构建模型的能力
不理解 DIP 的程序员,就只能写功能,不能构建出模型
好莱坞规则
Don’t call us, we’ll call you
别调用我,我会调你的
这是一个框架才会有的说法
有了一个稳定的抽象,各种具体的实现都应该是由框架去调用
更广泛的用途
DI 容器负责将具体类组合起来
对于任何一个项目而言,了解不同模块的依赖关系是一件很重要的事
找一些工具去生成项目中的依赖关系图
用 DIP 作为评判标准,衡量项目中在依赖关系中的表现
找到项目的改造着力点
25 | 设计模式¶
定义
一种特定的解决方案
所谓模式,其实就是针对的就是一些普遍存在的问题给出的解决方案
历史
起源于建筑领域,建筑师克里斯托佛・亚历山大曾把建筑中的一些模式汇集成册
最早是 Kent Beck 和 Ward Cunningham 探索将模式这个想法应用于软件开发领域
之后,Erich Gamma 把这一思想写入了其博士论文
《设计模式》这本书出版
Erich Gamma
Richard Helm
Ralph Johnson
John Vlissides
误区
学习设计模式不要贪多求全,那注定会是一件费力不讨好的事
有一些模式,如果你不是编写特定的代码,你很可能根本就用不上
有效地学习设计模式
每一个模式都是一个特定的解决方案
学习设计模式不仅仅要学习代码怎么写,更重要的是要了解模式的应用场景
设计模式的意义
设计模式在某种意义上就是为了解决语言自身缺陷的一种权宜之计
Peter Norvig,Google 公司的研究总监,早在 1996 年就曾做过一个分享《动态语言的设计模式》
语言本身的局限造成了一些设计模式的出现
如何学习
了解模式的应用场景
设计模式只是设计原则在特定场景下的应用
每个模式都只是一个特定场景下的解决方案
认识到:设计模式背后是设计原则
开眼看模式
要看到语言的局限
设计模式本身也在随着语言演变
一句话
学习设计模式,从设计原则开始,不局限于模式。
26 | 简单设计¶
说明
下面这些原则并不是指导你具体如何编码的原则
更像是一种思考方法、一种行为准则
KISS
表述
这条原则其实是出自美国海军
保持简单、愚蠢
Keep it simple, stupid
保持简单能够让系统运行得更好
举例
如何有现成库,就不要自己写
能用文本做协议就别用二进制
意义
KISS 是一个很好的指引
这种级别的原则听上去很有吸引力,但问题是,你并不能用它指导具体的工作
YAGNI
表述
这个说法来自于极限编程社区(Extreme Programming,简称 XP)
如非必要,勿增功能
举例
Word 和 Markdown
意义
YAGNI 是一种上游思维,就是尽可能不去做不该做的事,从源头上堵住
从某种意义上说,它比其他各种设计原则都重要
DRY
表述
源自 Andy Hunt 和 Dave Thomas 的《程序员修炼之道》(The Pragmatic Programmer)
定义
在一个系统中,每一处知识都必须有单一、明确、权威地表述。
Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.
举例
消除重复的代码
意义
减少重复,减少后期维护的成本
DRY 针对的是你对知识和意图的复制
最浅层的理解就是 “不要复制粘贴代码”
两段代码不同,但表达相同也不符合 DRY
DRY 原则并不局限于写代码
简单设计
定义
简单设计(Simple Design)原则
原则来自极限编程社区
提出者是 Kent Beck
4 条规则
通过所有测试
保证系统能够按照预期工作
有配置的自动化测试,保证测试覆盖的大多数场景
Test Driven Development,简称 TDD
消除重复
需要你对分离关注点有着深刻的认识
表达出程序员的意图
指出重构的方向
需要编写有表达性的代码
让类和方法的数量最小化
不要过度设计
除非你已经看到这个地方必须要做一个设计
如,留下适当的扩展点
设计一个软件 — 设计方法¶
27 | 领域驱动设计¶
结构化编程的思路误区
一上来会先设计数据库
认为程序就是数据加函数
数据,存到数据库里
函数,对数据库表进行增删改查
领域驱动设计
DDD 的根基
通用语言(Ubiquitous Language)
模型驱动的设计(Model-Driven Design)
领域驱动设计的过程,就是建立起通用语言和识别模型的过程
作用
接近业务与开发之间的距离
提供一套标准的建模方法
应用场景
微服务
微服务的难度并不在于将一个系统拆分成若干的服务
而在于如何有效地划分微服务
DDD 才是最恰当的指引
通用语言
定义
在业务人员和开发人员之间建立起的一套共有的语言
软件设计是要在问题和解决方案架设一座桥梁,好的设计要更接近问题
开发人员熟悉解决方案,但是对业务一端理解则通常不够充分
通用语言所做的事情,就是把开发人员的思考起点拉到了业务上,也就是从问题出发
事件风暴(Event Storming)
让业务人员和开发人员在一起
目标:最后让两方都能听懂
它的关注点在于领域事件
领域事件是用来记录业务过程中发生过的重要事情
如,作为电商工作人员,想知道产品是不是已经上架了,领域事件就是产品已上架
如,作为消费者,会关心我的订单是不是下成功了,领域事件就是订单已下
主要分成三步
第一步就是把领域事件识别出来
第二步就是找出这些动作,也就是引发领域事件的命令
如:产品已上架是由产品上架这个动作引发的
如:订单已下单就是由下单这个命令引发的
第三步就是找出与事件和命令相关的实体或聚合
如,产品上架就需要有个产品(Product)
如,下单就需要有订单(Order)
模型驱动设计
将设计分成了两个阶段
战略设计(Strategic Design)
高层设计
指将系统拆分成不同的领域
给了我们一个拆分系统的新视角:按业务领域拆分
如,把一个电商系统拆分成产品域、订单域、支付域、物流域等
拆分成领域之后,我们识别出来的各种业务对象就会归结到各个领域之中
要在不同的领域之间设计一些交互的方式
不同领域的业务对象会进行交互,如,要知道订单的物流情况。
战术设计(Tactical Design)
低层设计
指如何具体地组织不同的业务模型
还要考虑模型之间的关系
如,哪些模型要一起使用,可以成为一个聚合
还需要考虑这些模型从哪来、怎样演变
DDD 提供了一些标准的设计概念,比如仓库、服务等
一句话
建立一套业务人员和开发人员共享的通用语言
28 | 战略设计¶
DDD概念分类标准
做业务的划分
一部分是为了将不同的业务区分开来,也就是要将识别出来的业务概念做一个划分
落地成解决方案
另一部分则是将划分出来的业务落实到真实的解决方案中
业务概念的划分
领域(Domain)
对应软件开发要解决的问题
分而治之
子域(Subdomain)
核心域(Core Domain)
是整个系统最重要的部分,是整个业务得以成功的关键
Eric Evans 曾提出过几个问题,帮我们识别核心域
为什么这个系统值得写?
为什么不直接买一个?
为什么不外包?
支撑域(Supporting Subdomain)
概念
不是你的核心竞争力,但却是系统不得不做的东西
市场上也找不到一个现成的方案
举例
排行榜功能
通用域(Generic Subdomain)
概念
行业里通常都是这么做
即便不自己做,也并不影响你的业务运行
举例
如, App 通知
投资策略
核心域要全力投入
支撑域次之
通用域甚至可以花钱买服务
业务概念的落地
首先要解决的就是这些子域如何组织的问题
是写一个程序把所有子域都放在里面呢
还是每个子域做一个独立的应用
抑或是有一些在一起,有一些分开
限界上下文(Bounded Context)
概念
它形成了一个边界,一个限定了通用语言自由使用的边界
一旦出界,含义便无法保证
与子域的关系
子域和限界上下文不一定是一一对应的
可能在一个限界上下文中包含了多个子域
也可能在一个子域横跨了多个限界上下文
示例
与微服务关系
最纠结的问题就是如何划分服务边界
限界上下文的出现刚好与微服务的理念契合,每个限界上下文都可以成为一个独立的服务
限界上下文的重点
完全独立的
不会为了完成一个业务需求要跑到其他服务中去做很多事
上下文映射图(Context Map)
定义
一种描述方式,将不同限界上下文之间交互的方式描述出来
DDD 给我们提供了一些描述这种交互的方式
合作关系(Partnership)
共享内核(Shared Kernel)
客户 - 供应商(Customer-Supplier)
跟随者(Conformist)
防腐层(Anticorruption Layer)
必须要记住
定义
指我们要在外部模型和内部模型之间建立起一个翻译层,将外部模型转化为内部模型
最具防御性的一种关系
开放主机服务(Open Host Service)
发布语言(Published Language)
各行其道(Separate Ways)
大泥球(Big Ball of Mud)
你要规避的
示例
意义
可以帮助我们理解系统的各个部分之间,是怎样进行交互的
帮我们建立一个全局性的认知,而这往往是很多团队欠缺的
29 | 战术设计¶
角色:实体、值对象
识别出一个一个的模型,其实,就是识别名词
要识别的名词包括了实体和值对象
实体
概念
实体(Entity)指的是能够通过唯一标识符标识出来的对象
举例
订单的订单号
值对象
概念
表示一个值,会有很多的属性
举例
如,订单地址
由省、市、区和具体住址组成
同实体的差别
没有标识符
之所以它叫值对象,是因为它表现得像一个值
误区
一个字符串表示电话号码
有格式规则
用一个 double 类型表示价格
要有精度的限制
值对象的意义
不用一个类将它封装起来,这种行为就将散落在代码的各处,变得难以维护
区分实体和值对象
目的
把变的对象和不变的对象区分开
分析
实体:在时间上有连续性,并且有唯一标识可以来区分的对象,具有生命周期和行为。
值对象:用来描述事物的,不区分谁是谁的,不可变的对象,不具有生命周期和行为。
启示
在 DDD 的对象设计中,对象是有行为的
只有数据的对象是封装没做好的结果,一个好的封装应该是基于行为的
关系:聚合和聚合根
聚合(Aggregate)
概念
就是多个实体或值对象的组合
这些对象之间是一个整体
同生共死的关系
实例
如,一个订单里有很多个订单项,如果这个订单作废了,这些订单项也就没用了
可以把订单和订单项看成一个单元,订单和订单项就是一个聚合
注意
聚合要保证事务(Transaction)一致性
简言之,就是要更新就一起更新,要删除就一起删除
聚合根(Aggregate Root)
概念
从外部访问这个聚合的起点
一个聚合的唯一根就是聚合根
注意
当你纠结于技术时,先想想自己是不是解错了问题
识别聚合
是聚合
可以一次都拿出来
不是聚合
靠标识符按需提取
互动:工厂、仓库、领域服务、应用服务
事件
领域事件
概念
相当于记录了业务过程中最重要的事情
作用
作为一条主线,帮我们梳理业务上的变化
还可帮助我们让系统达成最终一致的状态
运作
领域服务(Domain Service)
操作的目标
领域对象,更准确地说,它操作的是聚合根
工厂(Factory)
用途
创建对象的动作
仓库(Repository)
用途
保存变更的结果
可以简单地把它理解成持久化操作
应用服务
用途
可以扮演协调者的角色,协调不同的领域对象、领域服务等
完成客户端所要求的各种业务动作
所以,也有人把它称之为 “工作流服务”
核心业务逻辑之外杂七杂八的东西
如,用户要修改一个订单,但首先要保证这个订单是他的
一些与业务逻辑无关的内容都会放到应用服务中完成
如,监控、身份认证等
和领域服务之间最大的区别
领域服务包含业务逻辑,而应用服务不包含
扩展
Vaughn Vernon
先阅读《领域驱动设计精粹》
再看《实现领域驱动设计》
一句话
战术设计,就是按照模板寻找相应的模型
巩固篇¶
30 | 程序库的设计¶
问题
解释
实现一个什么样的程序库
从一个要解决的问题出发
以 Moco 为例
解决集成的问题
需求
解释
把问题变成一个可以下手解决的需求
以 Moco 为例
正常需求
支持配置
独立部署
通用方案
额外需求
有一个有表达性的 DSL
解决方案
解释
抽丝剥茧,拿掉无关的信息,找最核心的部分
根基
分离关注点
以 Moco 为例
核心问题
模拟服务到底是做什么的
单元测试
帮助锁定目标
设计
基础设计
考虑提供怎样的功能
组合相关元素,构建核心模型
以 Moco 为例
RequestMatcher
ResponseHandler
扩展设计
不断应对新的变化
以 Moco 为例
大部分需求不用改动基础模型
template 扩展基础模型
实践
https://github.com/dreamhead/moco
一句话
注意发现身边的小问题,用一个程序库或工具解决它
31 | 应用的设计¶
职责划分
划分出不同的职责部分
分离关注点
以指数系统为例
指标获取
公式计算
更进一步
区分问题和解决方案
业务人员关注问题
开发人员关注解决方案
千万别混淆问题和解决方案
重新审视解决方案
没有自动化
开发人员修改代码
开发人员修改配置
业务人员修改配置
以指数系统为例
给业务人员配置接口
解析文本执行
更广泛的用途
适用于物联网系统
适用于 APM 类应用
一句话
一个更好的设计从拒绝低水平重复开始,把工作做成有技术含量的事情
32 | 应用的改进¶
从目标开始
一个目标
要做设计上的改进
不能只改进实现
一个问题
如果有机会从头设计系统,它应该是什么样的
一个共识
设计系统和实施系统改进是两个不同的问题
可以分阶段地进行
入手的起点
为什么入手起点是接口
模型能力的体现
系统内部状态发生改变的原因
什么样的接口
传统意义的接口
各种后台服务
重新设计
难点
不要回到老路上
合理做法
积小胜为大胜
永远不要指望一个真实的项目停下来,一步到位地进行改进
既有项目的设计有一个很大的问题就是各种信息混在一起,而能够把不同的信息拆分开来,对于设计而言,就是一个巨大的进步
关键点
相关利益人达成共识
启示
可以运用在更小的模块
一句话
改进既有设计,从做一个正常的设计开始,小步向前
结束语¶
《10x 程序员工作法》
藏经阁的目录
已经结构化了的软件开发的各种最佳实践
《软件设计之美》
构建软件设计知识大厦的一种尝试
设计需要沟通
沟通确实是程序员成长过程中一个重要的阻碍
经验积累
普通程序员和高手之间的差别就在于此,普通程序员凭直觉做事,高手却是把专业的做法训练成直觉
结束
Now this is not the end. It is not even the beginning of the end. But it is perhaps the end of the beginning.
丘吉尔在阿拉曼战役庆功宴上发表的演讲