《软件设计之美》
- 介绍:极客时间专栏《软件设计之美》读书笔记。
- 作者:Guide哥
- 评价 :很不错的一个专栏,干货很多,方法论比较实用,可以让你更加了解一个软件的设计过程、编程语言之间的本质区别、各种编程范式、设计原则和模式等等软件设计相关的内容。

前言
写软件的本质不仅仅是为了完成各种功能,还需要考虑软件的扩展性以及可维护
算法对抗的是数据的规模,而软件设计对抗的是需求的规模。
设计是为了让软件在长期更容易适应变化。
软件设计前置知识
软件设计基础知识
- 程序设计语言 :不再局限于某一种语言,而是择其善者而从之。
- 编程范式 :结构化编程、面向对象编程和函数式编程
- 设计原则 :SOLID、DRY、KISS等等
- 设计模式 :单例模式、策略模式等等常见的设计模式
- 设计方法 : 比如目前最实用的 DDD
- ......
软件设计在什么?
软件开发是为了解决由需求带来的各种问题,而解决的结果是一个可以运行的交付物。比如,我们在线购物的需求,是通过电商平台这个方案解决的。
那软件设计在这个过程中做的事情是什么呢? 答案是在需求和解决方案之间架设一个桥梁。

软件的开发往往是一项长期的工作,会有许多人参与其中。在这种情况下,就需要建立起一个统一的结构,以便于所有人都能有一个共同的理解。这就如同建筑中的图纸,懂建筑的人看了之后,就会产生一个统一的认识。
在软件的开发过程中,这种统一的结构就是模型,而 软件设计就是要构建出一套模型。
你可以将这个模型看作是整个软件的骨架。模型的粒度可大可小。我们所说的“高内聚、低耦合”指的就是对模型的要求,一个好的模型可以有效地隐藏细节,让开发者易于理解。模型是分层的,可以不断地叠加,基于一个基础的模型去构建上一层的模型,计算机世界就是这样一点点构建出来的。
除了模型之外,软件设计还要确定 规范 。规范限定了什么样的需求应该以怎样的方式去完成。比如
- 与业务处理相关的代码,应该体现在领域模型中;
- 与网络连接相关的代码,应该写在网关里;
- ......
没有一个统一的规范,每一个项目上的新成员都会痛斥一番前人的不负责任。
分离关注点-软件设计直观重要的一步
技术和业务被混在了一起,随之而来的就是无尽的后患。
把业务处理和技术实现混在一起,类似问题还有很多。比如我们经常问怎么处理分布式事务,怎么做分库分表等。其实,你更应该问的是,我的业务需要分布式事务吗?我是不是业务划分没有做清楚,才造成了数据库的压力?
在真实项目中,程序员最常犯的错误就是认为所有问题都是技术问题,总是试图用技术解决所有问题。任何试图用技术去解决其他关注点的问题,只能是陷入焦油坑之中,越挣扎,陷得越深。
在设计中,将一个模块的不同维度分开(比如功能需求和非功能需求、业务处理和技术实现),有一个专门的说法,叫 分离关注点。
软件设计要考虑“可测试性”
需求包括两大类:
- 功能性需求 :系统要实现哪些业务功能。
- **非功能性需求 ** :安全性、吞吐量、代码质量、可维护性、可测试性......

我们在开发过程中欠下的很多技术债,本质上都是因为忽略了“可测试性”这个需求。
可测试性为什么如此重要?
因为我们做设计,其实就是把一个软件拆分成一个一个的小模块。如果不尽可能地保证每个小模块的正确性,而只是从最外围的系统角度去验证系统的正确性,这将会是一个非常困难的过程。就和盖楼是一个道理,不保证钢筋、水泥、砖土质量合格,却想要盖出合格的大楼来,很荒唐吧!然而,很多团队的软件开发就是这么做的。
那么如何在设计中考虑可测试性呢?
在设计中考虑可测试性,就是在设计时问一下,这个函数 / 模块 / 系统怎么测。在软件开发中,只有把一个一个的小模块做了足够的测试,我们才会有稳定的构造块,才可以在集成测试的时候,只关注最终的结果。
了解一个软件的设计
如何了解一个软件的设计

你在讨论设计时应该遵循一个顺序:模型 -> 接口 -> 实现。同理,了解一个设计也应该遵循这样的顺序。
- 模型 :也可以称为抽象,是一个软件的核心部分,是这个系统与其它系统有所区别的关键,是我们理解整个软件设计最核心的部分。
- 接口 :是通过怎样的方式将模型提供的能力暴露出去,是我们与这个软件交互的入口。我们平常写web项目接触最多REST API就属于接口范畴。
- 实现 :就是软件提供的模型和接口在内部是如何实现的,是软件能力得以发挥的根基。
如何分析软件的模型?
理解一个模型的关键在于,要了解这个模型设计的来龙去脉,知道它是如何解决相应的问题。
比如 IOC 和 AOP 就可以理解为Spring 的模型。
- IoC (Inversion of control )控制反转/反转控制 能够降低对象之间的耦合度或者说依赖程度并且可以让对象资源变的更容易管理比如你用 Spring 容器默认就提供了对象的单例。
- AOP(Aspect-Oriented Programming:面向切面编程) 能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,提高系统可拓展性和可维护性。
如何分析软件的接口?
先找到一条功能主线,对项目建立起结构性的了解。有了主线之后,再沿着主线把相关接口梳理出来。查看接口,关键要看接口的风格,也就是项目作者引导人们怎样使用接口。在一个项目里,统一接口风格也是很重要的一个方面,所以,熟悉现有的接口风格,保持统一也是非常重要的。
如何分析软件的实现?
首先需要明确的是:你不太可能记住真实项目的所有细节,甚至到你离开项目的那一天,你依然会有很多细节不知道,可这并不妨碍你的工作。但是,如果你心中没有一份关于项目实现的地图,你就一定会迷失。
你需要找到两个关键点:软件的结构和关键的技术。
如何更好地分析软件的设计?
分析/学习一个软件的设计有一个很好的办法是带着问题上路。我们不妨假设自己就是这个软件的设计者,问问自己要怎么做。然后再去对比别人的设计,你就会发现,自己的想法和别人想法的相同或不同之处。对于理解 Kafka 而言,第一个问题就是如果你来设计一个消息队列,你会怎么做呢?
什么算是关键技术呢?
就是能够让这个软件的“实现”与众不同的地方。了解关键技术可以保证一点,就是我们对代码的调整不会使项目出现明显的劣化。幸运的是,大多数项目都会愿意把自己的关键技术讲出来,所以,找到这些信息并不难。
总结:理解实现,带着自己的问题,了解软件的结构和关键的技术。
程序设计语言
语言的模型:如何打破单一语言局限,让设计更好地落地?
程序设计语言之间没有那么泾渭分明的界限,程序员唯有多学习几门语言,才能打破语言的局限,让设计更好地落地。你可以根据项目特点选择合适的语言,也可以将其它语言一些优秀的地方借鉴过来。Andrew Hunt 和 David Thomas 在**《程序员修炼之道》**(The Pragmatic Programmer)中给程序员们提了一项重要的建议:每年至少学习一门新语言。
我们学习程序设计语言主要是为了学习程序设计语言提供的编程模型。 不同的编程模型会带给你不同的思考方式。
不提供新编程模型的语言是不值得刻意学习的。
一切语法都是语法糖。 新的语法通常是在既有的结构上不断添加出来的,为的是简化代码的编写。
语法糖(Syntactic sugar)是英国计算机科学家彼得·兰丁发明的一个术语,指的是那些为了方便程序员使用的语法,它对语言的功能没有影响。
程序设计语言发展史:
- 汇编
- 高级程序设计语言(Fortran,C,C++,Java)
- 函数式编程的程序设计语言(Scala 、Clojure)
简单补充一下,推荐一些相关的书籍。
《七周七语言》(书中介绍了Ruby、Io、Prolog、Scala、Erlang、Clojure和Haskell这七种语言,关注每一门语言的精髓和特性,重点解决如下问题:这门语言的类型模型是什么,编程范式是什么,如何与其交互,有哪些决策构造和核心数据结构,有哪些独特的核心特性)

什么是编程模型? 当你新接触一门语言的时候,有些问题是需要首先去思考的,比如“这门语言的编程模型是什么?” 模型是对事物共性的抽象,编程模型就是对编程的共性的抽象。
什么是编程的共性呢?
最重要的共性就是:程序设计时,代码的抽象方式、组织方式或复用方式。编程模型主要是方法与思想。
相关阅读:编程模型(范式)小结 。
语言的接口:语法和程序库,软件设计的发力点
标准库
写程序的一项主要日常工作就是定义各种函数。一旦你定义了大量的函数,就会发现有很多函数不仅仅在某个项目中是适用的,而且在很多项目中都是适用的。这时,作为一个“懒惰”的程序员,我们就会把这些在多个项目中使用的部分抽取出来,组成一个模块,这就是程序库的来源。
程序库(Library)就是为了消除重复而出现的。而消除重复,也是软件设计的初衷。
有一些程序库实在是太常用了,它们就会随着语言一起发布,成为标准库。
如果在实际工作中只使用标准库,有些代码写起来还是非常麻烦的。因为标准库提供的能力通常是很基础的。这时,我们就需要利用更多的第三方程序库,它们给提供了更丰富的选项,去完善标准库做得不够的地方。
第三方库
正是因为第三方库的兴起,怎样管理第三方库就成了一个问题。今天,这已经成了一个标准的问题,也有了标准的解决方案,那就是包管理器。很多语言都有了自己的包管理器,像 Java 的 Maven、Node 的 NPM、Ruby 的 RubyGems 等等,而像 Rust 这样年轻的语言,包管理器甚至变成了随语言的发行包一起发布的一项内容。
当你开始学习如何编写程序库,你对软件设计的理解就会踏上一个新的台阶。
语言设计就是程序库设计。程序库设计就是语言设计。
语言的实现:运行时,软件设计的地基
在学习不同的程序设计语言时,可以相互借鉴优秀的设计。但是要借鉴,除了模型和接口,还应该有实现。所以,这一讲,我们就来谈谈程序设计语言的实现。
做设计真正的地基,并不是程序设计语言,而是运行时,有了对于运行时的理解,我们甚至可以做出语言本身不支持的设计。 而且理解了运行时,我们可以成为一个更好的程序员,真正做到对自己编写的代码了如指掌。
理解运行时,可以将“程序如何运行”作为主线,将相关的知识贯穿起来。我们从了解可执行文件的结构开始,然后了解程序加载机制,知道加载器的运作和内存布局。然后,程序开始运行,我们要知道程序的运行机制,了解字节码,形成一个整体认识。最后,还可以根据需要展开各种细节,做深入的了解。
对于Java语言而言,理解运行时就是要了解 JVM 运行机制。如果站在字节码的角度思考问题,我们甚至可以创造出一些 Java 语言层面没有提供的能力,比如,有的程序库给 Java 语言扩展 AOP(Aspect-oriented programming,面向切面编程)的能力。这样一来,你写程序的极限就不再是语言本身了,而是变成了字节码的能力极限。并且,ASM、ByteBuddy 等第三方库的出现也让 Java 程序员操控字节码变得更容易,这使得他们可以借此做很多文章。
DSL:你也可以设计一门自己的语言
如果我们能把设计做到极致,它就能成为一门语言。一门解决一个特定问题的语言。
这种语言就是领域特定语言(Domain Specific Language,简称 DSL),它是一种用于某个特定领域的程序设计语言。这种特定于某个领域是相对于通用语言而言的,通用语言可以横跨各个领域,我们熟悉的大多数程序设计语言都是通用语言。
程序员最熟悉的一种 DSL 就是正则表达式了。
如果你觉得正则表达式有点复杂,还有一种更简单的 DSL,就是配置文件。你可能真的不把配置文件当作一种 DSL,但它确实是在实现某个特定领域的需求,而且可以根据你的需求对软件的行为进行定制。
一个典型的例子是 Ngnix。无论你是用它单独做 Web 服务器也好,做反向代理也罢,抑或是做负载均衡,只要通过 Ngnix 的配置文件,你都能实现。配合 OpenResty,你甚至可以完成一些业务功能。
SQL也是一种DSL,他屏蔽了计算机存储的底层实现,提供了易于操作数据的接口。
一些ORM框架对SQL这些DSL进行了进一步的封装提供了声明式注解,相当于构建在DSL之上的DSL翻译器。面向对象编程将面向关系的DSL进行更高层次的封装,使得在编程这个特定领域更加易于使用。
XML 也是一种 DSL。如果你是一个 Java 程序员,XML 就再熟悉不过了。从 Ant 到 Maven,从 Servlet 到 Spring,曾经的 XML 几乎是无处不在的。如果你有兴趣,可以去找一些使用 Ant 做构建工具的项目,项目规模稍微大一点,其 XML 配置文件的复杂程度就不亚于普通的源代码。因为它本质上就是一种用于构建领域的 DSL,只不过,它的语法是 XML 而已。正是因为这种 DSL 越来越复杂,后来,一种新的趋势渐渐兴起,就是用全功能语言(也就是真正的程序设计语言)做 DSL,这是后来像 Gradle 这种构建工具逐渐流行的原因,它们只是用内部 DSL 替换了外部 DSL。
闲谈:几种常见的编程语言
语言的流行通常需要一个杀手级的应用,比如RoR之于Ruby,Docker之于Go,Spring之于Java
C#
当年 Java 开始起势的时候,微软当然不能错过,微软也想做出一个自己的 Java,J++ 就出现了。
但是,这不是一个正常的 Java,引发了 SUN 的不满,将微软告上法庭。最终,双方庭外和解,微软不再祸害 Java,J++ 停止更新。
微软在 Windows 上的 JVM 性能是当时最好的,因为操刀 J++ 的是 Anders Hejlsberg,他是全世界最顶级的程序员。微软为了不与 Java 开启的受控(Managed)代码浪潮擦肩而过,于是,转身又推出了 C# 和.NET。
C# 的初版本简直和 Java 一模一样。不过,既然有 Anders Hejlsberg 在背后,事情当然不会就这么简单收尾。C# 在语言特性上开始一路狂奔,一个更强大的 C# 崭露头角。像 Lambda、类型推演这些特性早早就落户 C# 了。
C# 时运不济,它的上升期遇到了微软的下降期。越来越多的公司选择了 Java,越来越多的程序员拥抱了 Java,而语言模型上表现优秀的 C# 则遭遇了冷落。
如果出于学习的目的,C# 绝对是值得一学的程序设计语言,毕竟微软在语言设计上还是很有一套的。Java 语言的进化是非常缓慢的,尤其是 SUN 的衰退又耽误了很多年。所以,从语言特性上来看,说 C# 领先 Java 十年并不夸张。
Go
在系统编程方面,C 语言是当之无愧的霸主,然而,C 语言已经快 50 岁了。
C 的强项是对于计算机模型的适度抽象,弱项却是在程序的组织上。因为在 C 诞生那个年代,程序的规模还不算太大。然而 C 的成功却让程序的规模越来越大,大到超出了 C 语言的能力范畴。于是,有人想着把面向对象加到 C 语言里,扩大程序的组织规模。这方面的尝试,我们都熟悉的是 C++。
不过,C++ 只风光了一段时间,就被 Java 盖了过去。C++ 本身有一段时间变成了语言特性的试验田,泛型编程,尤其是模板元编程的出现,一度让人怀疑人生。它成了高手极度喜爱,普通人一脸懵硬着头皮写的程序语言。
无论是 C 还是 C++,都是在执行性能上无可挑剔,在代码编写上一地鸡毛,人们还是需要一门更有开发效率的系统编程语言。
时间来到新千年,又有人出手想代替 C 语言,这回出手的人物背景强大,他就是 Ken Thompson,C 语言的亲爹。2009 年,如日中天的 Google 推出了 Go 语言,再加上 Ken Thompson 和 Rob Pike 这样早期的 Unix 先驱站在它背后,Go 语言的前景给人无限的遐想。Go 语言的语法设计是简单的,基本上,你花一个晚上就可以把 Go 语言完整地学习一遍。它在接口设计和并发上的处理方式都给人眼前一亮的感觉。人们热切地期盼着它成为下一个系统编程语言的霸主。
随着 Docker 这套虚拟化软件登上历史舞台,Go 语言终于有了用武之地。人们开始意识到,原来云计算领域还有一些基础设施要写,用 C 的话,不好维护;用 Java 的话,浪费资源;Go 恰如其分地解决了大部分问题。
虽然在云计算基础设施中,Go 赢得了一席之地,这属于开辟了一片蓝海。在传统系统编程的红海中,Go 语言其实并没有做出什么特别的成绩,对于实时性和性能要求极高的领域,Go 语言有一个拿不出手的弱项,也就是它的 GC。
Rust
这本书内容为个人补充
Rust 由 Mozilla 主导开发,原本是 Mozilla 一名员工的私人项目,后来 Mozilla 才开始赞助这个项目的。
Rust 的定位是一个系统级编程语言,对标的是 C 和 C++ ,而不是 Java、NET。
Rust 的性能非常好,和 C++ 想必几乎没有太大差别,仅仅是在需要额外安全保证的代码会比 C++慢一些。并且,Rust 相比于 C 和 C ++在内存管理方面更加聪明,内存泄露的风险非常低。
内存管理方面,早期的 Rust 也是采用垃圾回收机制,类似但不完全等同于 Java 这门语言的垃圾回收机制。Rust 1.0 全面改用基于引用计数的智能指针来管理内存。
编程范式
主流的编程范式主要有三种:
- 结构化编程(structured programming) :结构化编程是最早普及的编程范式,现在最典型的结构化编程语言是 C 语言。
- 面向对象编程(object-oriented programming) :现在主流的程序设计语言几乎都提供面向对象编程能力,其中最典型的代表当属 Java。
- 函数式编程(functional programming) :函数式编程的代表性语言应该是 LISP。
越来越多的程序设计语言开始将不同编程范式的内容融合起来。 Java 从 Java 8 开始引入了 Lambda 语法,现在我们可以更优雅地写出函数式编程的代码了。同样,C++ 11 开始,语法上也开始支持 Lambda 了。
多范式编程会越来越多,是因为我们的关注点是做出好的设计,写出更容易维护的代码,所以,我们会尝试着把不同编程风格中优秀的元素放在一起。比如,我们采用面向对象来组织程序,而在每个类具体的接口设计上,采用函数式编程的风格,在具体的实现中使用结构化编程提供的控制结构。
我们需要学习不同的编程范式,将其中优秀的元素运用在日常工作中。
结构化编程
结构化编程中的顺序结构就是代码按照编写的顺序执行,选择结构就是 if/else,而循环结构就是 do/while。
我们今天使用的编程语言都是高级语言一般都是满足结构化编程的。但是,低级语言就不一定了。比如你使用汇编写代码,你面对的是各种寄存器和内存地址, if/else 和 do/while 都没有,随之而来的是一个 goto 关键字让你的代码可以跳转到另外一个地方继续执行。
你想 goto到哪里执行一段代码就到哪里,非常自由。很显然,这种自由会为代码维护带来严峻的挑战。
于是,有一个叫做 迪杰斯特拉(Dijkstra) 的老哥站了出来,提出编程要有结构,不能这么肆无忌惮,结构化编程的概念应运而生。
学习算法的时候,你肯定学过以他名字命名的Dijkstra算法(最短路径问题);学习操作系统时,你肯定学过 PV 原语,PV 原语这个名字之所以看起来有些奇怪,主要因为 Dijkstra 是荷兰人。
1972 年的图灵奖的获得者。
1968 年,他在 ACM 通讯上发表了一篇文章,题目叫做《Goto 是有害的》(Go To Statement Considered Harmful),这篇文章引起了轩然大波。
那些习惯了自由放纵的程序员对 Dijkstra 进行了无情的冷嘲热讽。他们认为,按照结构化的方法写效率太低。
C 语言初问世之际,遭到最大的质疑是效率低。对,你没听错,C 语言被质疑效率低,和 Java 面世之初遇到的挑战如出一辙。
现在的很多程序员其实对底层知识的了解并不多,但丝毫不妨碍他们完成基本的业务功能。只要使用的人足够多,人们就会有更强的驱动力去优化底层实现。时至今日,已经很少有人敢说自己手写的汇编性能一定优于编译器优化后的结果。
最终这场争论逐渐平息,新的结构逐渐普及,也证明了 Dijkstra 是对的。goto 语句的重要性逐渐降低,一些现代程序设计语言干脆在设计上就把 goto 语句拿掉了。
结构化编程是比汇编更高层次的抽象,程序员们有了更强大的工具,但人们从来不会就此满足,随之而来的是,程序规模越来越大。这时,结构化编程就显得力不从心了。用一个设计上的说法形容结构编程就是“抽象级别不够高”。
结构化编程存在的问题是 一旦需求变动,经常是牵一发而动全身,关联的模块由于依赖关系的存在都需要变动,无法有效隔离变化。 因此,结构化编程需要与其他编程范式配合使用。
面向对象编程
结构化编程有效地解决了过去的很多问题,它让程序员们解决问题的规模得以扩大。随着程序规模的逐渐膨胀,结构化编程在解决问题上的局限也越发凸显出来。**各模块的依赖关系太强,不能有效地将变化隔离开来。**这时候,面向对象编程登上了大舞台,它为我们提供了更好的组织程序的方式。
面向对象的三个特点:封装、继承和多态。
- 封装是面向对象的根基,软件就是靠各种封装好的对象逐步组合出来的;
- 继承给了继承体系内的所有对象一个约束,让它们有了统一的行为;
- 多态让整个体系能够更好地应对未来的变化。
在面向对象本身的体系之中,封装和多态才是重中之重,而继承则处于一个很尴尬的位置。
封装
封装,是面向对象的根基。面向对象编程就是要设计出一个一个可以组合,可以复用的单元。然后,组合这些单元完成不同的功能。
设计一个类的方法,先要考虑其对象应该提供哪些行为,然后,根据这些行为提供对应的方法,最后才是考虑实现这些方法要有哪些字段。getter 和 setter 是暴露实现细节的,尽可能不提供,尤其是 setter。
封装,除了要减少内部实现细节的暴露,还要减少对外接口的暴露。一个原则是最小化接口暴露。有了对封装的理解,即便我们用的是 C 语言这样非面向对象的语言,也可以按照这个思路把程序写得更具模块性。
举个例子,一个服务要停下来的时候,你可能要把一些任务都停下来,代码可能会这样写:
class Service {
public void shutdownTimerTask() {
// 停止定时器任务
}
public void shutdownPollTask() {
// 停止轮询服务
}
}别人调用的话需要同时这两个方法。如果把上面👆的代码修改成下面👇这样的话会更好:
class Service {
private void shutdownTimerTask() {
// 停止定时器任务
}
private void shutdownPollTask() {
// 停止轮询服务
}
public void shutdown() {
this.shutdownTimerTask();
this.shutdownPollTask();
}
}总结:基于行为进行封装,不要暴露实现细节,最小化接口暴露。
继承
继承并表示为了复用代码,如果要复用代码的话,组合是优于继承的。 并且,组合会让代码模块化更加清晰。
多态
理解多态,还要理解好接口。它是将变的部分和不变的部分隔离开来,在二者之间建立起一个边界。一个重要的编程原则就是面向接口编程,这是很多设计原则的基础。
对于Java这类面向对象编程语言来说多态依赖于继承,但是对于有些非面向对象编程语言也可以实现多态,但是不必要依赖继承。
函数式编程
函数式编程第一个需要了解的概念就是函数。在函数式编程中,函数是一等公民(first-class citizen)。
一等公民是什么意思呢?
- 它可以按需创建;
- 它可以存储在数据结构中;
- 它可以当作实参传给另一个函数;
- 它可以当作另一个函数的返回值。
对象,是面向对象程序设计语言的一等公民,它就满足所有上面的这些条件。在函数式编程语言里,函数就是一等公民。函数式编程语言有很多,经典的有 LISP、Haskell、Scheme 等,后来也出现了一批与新平台结合紧密的函数式编程语言,比如:Clojure、F#、Scala 等。
设计原则与模式
SOLID 原则
设计模式
学习设计模式,很多人的注意力都在模式的代码应该如何编写,却忽略了模式的使用场景。强行应用模式,就会有一种削足适履的感觉。
设计模式背后其实是各种设计原则,我们在实际的工作中,更应该按照设计原则去写代码,不一定要强求设计模式,而按照设计原则去写代码的结果,往往是变成了某个模式。
学习设计模式,我们也要抬头看路,比如,很多设计模式的出现是因为程序设计语言自身能力的不足,我们还要知道,随着时代的发展,一些模式已经不再适用了。比如 Singleton 模式,还有些模式有了新的写法,比如,Observer、Decorator、Command 等等。我们对于设计模式的理解,也要随着程序设计语言的发展不断更新。
简单设计
KISS 原则
“Keep it simple, stupid”的缩写,也就是保持简单、愚蠢的意思。它告诫我们,对于大多数系统而言,和变得复杂相比,保持简单能够让系统运行得更好。很多程序员都知道这条原则,然而,很少人知道这条原则其实是出自美国海军。无论是制定一个目标,还是设计一个产品,抑或是管理一个公司,我们都可以用 KISS 作为一个统一的原则指导自己的工作。
每个人都可以针对自己的工作场景给出自己的阐释,比如:
- 如果有现成的程序库,就不要自己写;
- 能用文本做协议就别用二进制;
- 方法写得越小越好;
- 能把一个基本的流程打通,软件就可以发布,无需那么多的功能;
- ……
YAGNI原则
“You aren’t gonna need it”的缩写,也就是,你用不着它。这个说法来自于极限编程社区(Extreme Programming,简称 XP),我们可以把它理解成:如非必要,勿增功能。
YAGNI 就告诫我们,其实很多需求是不需要做的。很多产品经理以为很重要的功能实际上是没什么用的。人们常说二八原则,真正重要的功能大约只占 20%,80% 的功能可能大多数人都用不到。做了更多的功能,并不会得到更多的回报,但是,做了更多的功能,软件本身却会不断地膨胀,变得越发难以维护。
YAGNI 是一种上游思维,就是尽可能不去做不该做的事,从源头上堵住。从某种意义上说,它比其他各种设计原则都重要。
DRY原则
“Don’t repeat yourself”的缩写,也就是,不要重复自己。这个说法源自 Andy Hunt 和 Dave Thomas 的《程序员修炼之道》(The Pragmatic Programmer)。这个原则的阐述是这样的:
在一个系统中,每一处知识都必须有单一、明确、权威地表述。Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.
对于代码来说,DRY 原则可以简单理解为“不要复制粘贴项目已有代码”
但 DRY 原则并不局限于写代码,比如:
- 注释和代码之间存在重复,可以尝试把代码写得更清晰;
- 内部 API 在不同的使用者之间存在重复,可以通过中立格式进行 API 的定义,然后用工具生成文档、模拟 API 等等;
- 开发人员之间做的事情存在重复,可以建立沟通机制降低重复;……
简单设计
简单设计上面说的这三个原则都是在偏思维方式的层面,而下面这个原则稍稍往实际的工作中靠了一些,它就是简单设计(Simple Design)原则。这个原则来自极限编程社区,它的提出者是 Kent Beck(这个名字在我的两个专栏中已经出现了很多次,由此可见,他对现代软件开发的影响很大)。
简单设计之所以叫简单设计,因为它只包含了 4 条规则:
- 通过所有测试 :保证系统能够按照预期工作
- 消除重复 :DRY原则
- 表达出程序员的意图
- 让类和方法的数量最小化。
设计方法
领域驱动设计:如何从零开始设计一个软件?
领域驱动设计(Domain Driven Design,简称 DDD),作为一个新的设计方法正式登上历史舞台,是从 Eric Evans 的著作《领域驱动设计》正式出版开始的。
随着微服务的兴起,人们越发认识到,微服务的难度并不在于将一个系统拆分成若干的服务,而在于如何有效地划分微服务。这个时候,人们发现,DDD 才是最恰当的指引。
学习 DDD,就要从理解 DDD 的根基入手:通用语言(Ubiquitous Language)和模型驱动的设计(Model-Driven Design),而领域驱动设计的过程,就是建立起通用语言和识别模型的过程。
通用语言
通用语言是什么呢?
通用语言,就是在业务人员和开发人员之间建立起的一套共有的语言。
就是这个业务中有哪些概念以及哪些操作。比如说,我要做一个电商平台,就要有产品、订单的概念。其中,产品就要有上架、下架、修改产品信息等操作,而订单就会有下单、撤单、修改订单等操作。
通用语言是从哪来的呢?也就是说,如何设计通用语言呢?
最简单的做法就是让业务人员和开发人员一起,找一块白板,把各种概念都写在上面。然后,双方重新进行分类整理。
这种做法很简单,但通常都不够系统,会存在各种遗漏。所以,有人探索出一种更正式的实践:事件风暴(Event Storming)。
事件风暴是一个工作坊,基本做法就是找一面很宽的墙,上面铺上大白纸,然后,用便利贴把识别出来的概念贴在上面。当然,前提依然是让业务人员和技术人员都参与其中。
事件风暴这个工作坊主要分成三步:
- 把领域事件识别出来,这个系统有哪些是人们关心的结果。有了领域事件,下面一个问题是,这些事件是如何产生的,它必然会是某个动作的结果。
- 找出这些动作,也就是引发领域事件的命令。比如:产品已上架是由产品上架这个动作引发的,而订单已下就是由下单这个命令引发的
- 找出与事件和命令相关的实体或聚合,比如,产品上架就需要有个产品(Product),下单就需要有订单(Order)。
模型驱动设计
因为在通常情况下,业务模型数量众多,所以在 DDD 的过程中,我们将设计分成了两个阶段:战略设计(Strategic Design)和战术设计(Tactical Design)。
战略设计是高层设计,是指将系统拆分成不同的领域。而领域驱动设计,核心的概念就是领域,也就是说,它给了我们一个拆分系统的新视角:按业务领域拆分。
比如,我把一个电商系统拆分成产品域、订单域、支付域、物流域等。拆分成领域之后,我们识别出来的各种业务对象就会归结到各个领域之中。然而,有时候,不同领域的业务对象会进行交互,比如,我要知道自己订单的物流情况。所以,要在不同的领域之间设计一些交互的方式。
而战术设计是低层设计,也就是如何具体地组织不同的业务模型。在这个层次上,DDD 给我们提供了一些标准的做法供我们参考。比如,哪种模型应该设计成实体,哪些应该设计成值对象。
战略设计:如何划分系统的模块?
战略设计,这个名字听上去有点高大上。而且,战略设计包含很多的概念,比如,子域、限界上下文和上下文映射图等等。这让很多人有些望而却步。虽然概念看似很多,但只要有一条主线将它们贯穿起来,这些概念也不难理解。
我们可以先把这些概念做一个划分,分为做业务的划分和落地成解决方案两个部分,也就是说,战略设计中的概念,一部分是为了将不同的业务区分开来,也就是要将识别出来的业务概念做一个划分,另一部分则是将划分出来的业务落实到真实的解决方案中。
业务概念的划分
领域驱动设计这个名字里面,排在第一位的是领域(Domain),它就对应着要解决的问题。正如我们一直说的,软件开发是解决问题,而解决问题要分而治之。所谓分而治之,就是要把问题分解了,对应到领域驱动设计中,就是要把一个大领域分解成若干的小领域,而这个分解出来的小领域就是子域(Subdomain)。
对于一个真实项目而言,划分出来的子域可能会有很多,但并非每个子域都一样重要。所以,我们还要把划分出来的子域再做一下区分,分成核心域(Core Domain)、支撑域(Supporting Subdomain)和通用域(Generic Subdomain)。
核心域是整个系统最重要的部分,是整个业务得以成功的关键。关于核心域,Eric Evans 曾提出过几个问题,帮我们识别核心域:
- 为什么这个系统值得写?
- 为什么不直接买一个?
- 为什么不外包?
如果你对这几个问题的回答能够帮你找到这个系统非写不可的理由,那它就是你的核心域。
什么是支撑域呢?**有一些子域不是你的核心竞争力,但却是系统不得不做的东西,市场上也找不到一个现成的方案,这种子域就是支撑域。**比如,我们要做一个排行榜功能,可能根据各种信息做排名,这种东西没有人会按照你的需要做出一个,对你来说,又是扩展自己系统的重要一步,它就是一个支撑域。
**还有一种子域叫通用域,就是行业里通常都是这么做,即便不自己做,也并不影响你的业务运行。**比如,很多 App 要给用户发通知,这样的功能完全可以买一个服务来做,丝毫不影响你的业务运行。它就是一个通用域。
业务概念的落地
通过划分子域,区分核心域、支撑域和通用域,我们把 DDD 在问题层面的概念已经说清楚了。接下来,就要进入到解决方案层面了。
我们现在有了切分出来的子域,怎样去落实到代码上呢?首先要解决的就是这些子域如何组织的问题,是写一个程序把所有子域都放在里面呢,还是每个子域做一个独立的应用,抑或是有一些在一起,有一些分开。这就引出了领域驱动设计中的一个重要的概念,限界上下文(Bounded Context)。
限界上下文,顾名思义,它形成了一个边界,一个限定了通用语言自由使用的边界,一旦出界,含义便无法保证。比如,同样是说“订单”,如果不加限制,你很难区分它是用在哪种场景之下。
限界上下文的重点在于,它是完全独立的,不会为了完成一个业务需求要跑到其他服务中去做很多事,而这恰恰是很多微服务出问题的点,比如,一个业务功能要调用很多其他系统的功能。
战术设计:如何像写故事一样找出模型?
战术设计同样也包含了很多概念,比如,实体、值对象、聚合、领域服务、应用服务等等。有这么多概念,我们该如何区分和理解他们呢?我们同样需要一根主线。
角色:实体、值对象
我们在战术设计中,要识别的名词包括了实体和值对象。
什么是实体呢?实体(Entity)指的是能够通过唯一标识符标识出来的对象。比如订单有唯一的标识符,也就是订单号,订单号作为它的标识符能将它标识出来。你可以通过订单号查询它的状态,可以修改订单的一些信息,比如,配送的地址。像这种通过唯一标识符标识出来的对象,就是实体。
还有一类对象称为值对象,它就表示一个值。比如,订单地址,它是由省、市、区和具体住址组成。它同实体的差别在于,它没有标识符。
关系:聚合和聚合根
选定了角色之后,接下来,我们就该考虑它们的关系了。
聚合(Aggregate)就是多个实体或值对象的组合,这些对象是什么关系呢?你可以理解为它们要同生共死。
聚合根(Aggregate Root),就是从外部访问这个聚合的起点。我还以上面的订单和订单项为例,在订单和订单项组成的这个聚合里,订单就是聚合根。因为你想访问它们,就要从订单入手,你要通过订单号找到订单,然后,把相关的订单项也一并拿出来。

互动:工厂、仓库、领域服务、应用服务
我们现在有角色了,也确定关系了。接下来,我们就要安排互动了,也就是说,我们要把故事的来龙去脉讲清楚了。
动作的结果会产生出各种事件,也就是领域事件,领域事件相当于记录了业务过程中最重要的事情。相对于 DDD 中的其他概念,领域事件加入 DDD 大家庭是比较晚的,但因为其价值所在,它迅速地就成了 DDD 中不可或缺的一个重要概念。
那各种动作又是什么呢?那就是我们在写作中常用到的动词。在战术设计中,**领域服务(Domain Service)**就是动词。只不过,它操作的目标是领域对象,更准确地说,它操作的是聚合根。
应用服务和领域服务之间最大的区别就在于,领域服务包含业务逻辑,而应用服务不包含。
推荐书籍
Vaughn Vernon 写过两本关于 DDD 的书,是现在市面上比较好的 DDD 学习材料。建议你先阅读《领域驱动设计精粹》,这本书可以帮你快速入门;然后你再看《实现领域驱动设计》,这本书很厚,但讲得要更细致一些。当然,想要真正想学会 DDD,还是需要你在实际项目中进行练习。
巩固
解决问题,发现问题
程序员不能只当一个问题的解决者,还应该经常抬头看路,做一个问题的发现者。
有了问题之后,需要把问题拆解成可以下手解决的需求,让自己有一个更明确的目标。然后,我们才是根据这个需求找到一个适当的解决方案。一个通用的解决方案需要不断地抽丝剥茧,抛开无关的部分,找到核心的部分,这同样根植于分离关注点。
如果最后的解决方案是一个程序库,那么,我们用测试把程序库要表达的内容写出来,就是最直接的。有了测试,就锁定了目标,剩下的就是让测试通过。
一个好的设计,应该找到一个最小的核心模型,所有其他的内容都是在这个核心模型上生长出来的,越小的模型越容易理解,相对地,也越容易保持稳定。
如何设计一个数据采集平台?
在金融系统中,有一个概念叫指数,用来表示金融市场的活动,比如有股票指数、期货指数等等。比较著名的指数有道琼斯指数、标准普尔指数。这个世界上的指数多得数不胜数,每个金融机构都会有自己的指数,而且,它们还会不断推出新的指数。
指数是怎么算出来的呢? 如果以股票为例,就是获取一堆股票的价格,然后根据一个公式算出一个结果。比如,我们有一个公式,A0.2+B0.3+C*0.5,我们把公式里的数据部分称为指标,也就是公式中的 A、B、C,这个公式表示这三种指标分别占比 20%、30% 和 50%。
价格是实时变化的,而公式是固定的。指数在问世之初,我们需要不断调整这个公式里面各个指标的参数,以便能更好地反映市场的变化。
问题来了,我们要怎样设计一个这样的指数系统呢?一个不假思索的设计就是,针对一个具体的指数进行开发。我们就要把指数计算中涉及的各种数据实时取过来,然后根据设置的公式去做计算。

如果我们有多个指数的话,为每一个指数单独进行开发设计就比较不灵活了。
一个好的做法就是,先做职责划分,把不同职责的部分划分出来 。
我们可以把指数的计算过程分成两个部分:
- 实时获取的数据,比如,前面说到的各种价格;
- 根据公式进行计算出最终的结果,也就是指数最终的值。

这个指数设计的关键就是这个指数的公式,比如 A0.2+B0.3+C*0.5。如果我们能够让业务人员在配置接口上这样配置,问题就解决了。在这里,A、B、C 分别代表一个指标,也就是说,我们只要能够让业务人员指定指标以及指定计算公式,剩下的问题就简单了,就是根据公式计算出相应的结果就好了。
说起来很简单,但怎么把 A0.2+B0.3+C*0.5 变成一个可执行的公式,对一些程序员来说,还是有一定难度的。
公式的解析是编译原理入门的知识,难度系数比设计一门程序设计语言要小多了。而且,现在有编译器前端的工具,比如Java 世界的 Antlr,它可以直接生成对应的语法树结构,我们只要负责去编写对应的执行部分就好了。
虽然我们这里讨论的是一个金融中会用到的指数系统,但当我们把模型经过一番整理之后,你会发现它不仅仅局限于指数系统中。比如,如果你在开发的是一个物联网系统,上报上来的数据,往往也要经过一些计算和聚合,那这个模型显然也是适用的。
再比如,你开发了一个 APM(Application Performance Management,应用性能管理)类的应用,采集上来的数据往往也要经过一番计算再展示出来,这个模型同样适用。
相关文章:Antlr4 简介 。
精选评论:说的非常好,和我现在做的风控预警系统类似,同样适用于你说的这个通用模型,开发人员一定要有抽象能力,设计模式是一种特定场景的模型,同类应用系统也有通用的模型,采集,计算,分析,监测,预警,报告,如果你做的系 统有这样的功能,那么就可以应用通用设计模型。
你说的编码实现,到开发人员配置,到业务人员配置,我们称为配置化,低代码,无代码化,这些目的都是快速响应需求,减少重复,减少开发投入的好方法,也是通用方法,我们同样应用于多个业务系统,我们有一个系统,要投入三分之二人力,支持开发,测试,我们做到了业务自主配置,开发完全解放出来了,因为这些工作就是低级重复,需要大量沟通,配置工作。开发人员要就是为解决问题的,一定避免低级,简单,重复工作,能自动化就不要手工,能自助化,就不要人工协作,对提升水平没有任何帮助,时间还浪费了,随着年龄增长,水平没有进步,本质上其实是在退步!
如何改进我们的软件设计?
改进一个软件的设计,首先,要确定改进的目标。改进的目标就是,重新设计这个软件,它应该设计成什么样子,让设计还原到它应有的本来面貌。寻找改进的起点,一部分可以从需求入手,还有一部分要从梳理接口入手。
设计改进的难点在于不要回到老路上,要做正常的设计,尤其是要把分解做好。
有了改进目标之后,接下来就是要找到一条改进路径,选择怎样的路径都是有道理的,但有两个关键点是非常重要的:
- 每步改进的动作要小;
- 要让相关利益人达成共识。
更新: 2022-02-18 13:05:23
原文: https://www.yuque.com/snailclimb/to3hqu/xs7u9y