# 软件设计之美 ## 课前必读 ### 开篇词 - 设计是为了让软件在长期更容易适应变化。 — Kent Beck - Design is there to enable you to keep changing the software easily in the long term. - 算法对抗的是数据的规模,而软件设计对抗的是需求的规模。 - 软件设计学习的难度,不在于一招一式,而在于融会贯通。 - 软件设计,是一门关注长期变化的学问,因为只有长期的积累,需求才会累积,规模问题才会凸显出来。软件设计,实际上就是应对需求的 “算法”。 - “软件设计”的两个维度 - 1. “了解现有软件的设计” - 2. “自己设计一个软件” - 快速了解现有软件设计的方法,那就是抓住这个软件最核心的三个部分 - 1. 模型 - 2. 接口 - 3. 实现 ### 01 软件设计到底是什么 - 核心的模型 - 模型,是一个软件的骨架,是一个软件之所以是这个软件的核心 区别于解决简单的问题,软件的开发往往是一项长期的工作,会有许多人参与其中。在这种情况下,就需要建立起一个统一的结构,以便于所有人都能有一个共同的理解。这就如同建筑中的图纸,懂建筑的人看了之后,就会产生一个统一的认识。 - 模型的粒度可大可小 - 好的模型 - 有效地隐藏细节,让开发者易于理解 - “高内聚、低耦合” 指的就是对模型的要求 - 模型是分层的 - 可以不断地叠加,基于一个基础的模型去构建上一层的模型,计算机世界就是这样一点点构建出来的 - 约束的规范 - 规范,就是限定了什么样的需求应该以怎样的方式去完成。 - 常见的问题 - 1. 缺乏显式的、统一的规范 - 2. 规范不符合软件设计原则 - 模型与规范的关系 - 相辅相成 - 模型要符合一定的规范 - 规范的制定依赖于模型 - 做设计的关键是,找到不变的东西 - 流程是容易调整的,而模型如果变了,这个软件整个就变了 - 分层不是你的设计,而构建出你的模型才是设计 ### 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++ 的方式描述的 - 每个类是小的,简言之代码都不多 - 最终形成的对象是个大的,因为它把所有的小类组合了起来 - 说明 - 在运行时,用到的是对象 - 设计中,你用到的是接口,是类层面的东西。 - 设计的角度 - 1. 作抽象 (找共性,文档中的系统模型,代码详细设计的接口) - 2. 作分解 (找特性,实现) - 分解的目的就是将处理逻辑和数据的不同点突出出来 - 根据不同的差异将各种实现进行相应的组合,支持接口功能 - 分解做好了,代码重复性就降低了 - 两个方面着手 - 1. 数据角度 - 2. 数据处理角度 - 要求 - 是否能够快速学习一个新东西,就是程序员之间体现出差异的地方 ### 16 | 面向对象之多态 - 理解多态 - 概念 - 一个接口多种形态 - 基于对象和面向对象的分水岭 - 前提 - 理解接口的价值 - 谨慎的选择接口中的方法 - 做法 - 找出不同事物的共同点,建立起抽象 - 关键在分离关注点上 - 面向接口编程 - 在构建抽象上,接口扮演着重要的角色 - 1. 接口将变的部分和不变的部分隔离开来 - 2. 接口是一个边界 - 清晰界定不同模块的职责是很关键的 - 模块之间彼此通信最重要的就是通信协议 - 这种通信协议对应到代码层面上,就是接口。 - 实现多态 - 做法 - 将一种常见的编程结构升华为语法 - 作用 - 函数指针的使用就得到了限制 - 降低程序员犯错的几率 - 没有继承的多态 - 只要遵循相同的接口,就可以表现出多态 - 多态不一定依赖于继承 - 面向对象的编程,更重要的是封装和多态 - 面向对象编程的三个特点的定位 - 封装 - 面向对象的根基,软件就是靠各种封装好的对象逐步组合出来的 - 继承 - 给了继承体系内的所有对象一个约束,让它们有了统一的行为 - 多态 - 让整个体系能够更好地应对未来的变化 - 要求 - 对程序员来说,识别出变与不变,是一种很重要的能力 - 一句话 - 建立起恰当的抽象,面向接口编程 ### 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. - 丘吉尔在阿拉曼战役庆功宴上发表的演讲