mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 06:03:45 +08:00
mod
This commit is contained in:
108
极客时间专栏/软件设计之美/了解一个软件的设计/04 | 三步走:如何了解一个软件的设计?.md
Normal file
108
极客时间专栏/软件设计之美/了解一个软件的设计/04 | 三步走:如何了解一个软件的设计?.md
Normal file
@@ -0,0 +1,108 @@
|
||||
<audio id="audio" title="04 | 三步走:如何了解一个软件的设计?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/35/a9/354365bd7e0c0d056867ef72d02a87a9.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
经过了前面几讲的铺垫,我们已经对软件设计是什么,以及要考虑哪些因素有了一个初步的了解。热身之后,就该开启正式的旅程了。
|
||||
|
||||
作为一个程序员,我们在职业生涯中免不了要接手新项目,承担维护该项目的职责。如果一个新项目摆在面前,你会怎么去研究它呢?
|
||||
|
||||
很多人的第一反应就是去看源代码。但是,一头扎入代码中,很快你就会迷失其中,最初那股子探索精神,也会逐渐被迷茫所代替。回想一下,有多少次你满怀希望地打开一个开源项目,结果多半都是坚持不了多久就放弃了。你有没有想过,问题出在哪里呢?
|
||||
|
||||
你的迷茫在于缺少对这个软件整体的了解,这就如同不带地图指南针闯入密林一般,迷路只是早晚的事。所以,虽然阅读源码是必经的一步,却不应该是你的第一步。我们应该先从了解软件的设计开始。那我们该如何了解一个软件的设计呢?
|
||||
|
||||
## 模型、接口和实现
|
||||
|
||||
了解一个软件的设计可以从三个部分着手:**模型、接口和实现**。这三者的关系就好比你去看代码,你会先去看有哪些类以及它们之间的关系,这就是看模型;然后你会打开一个具体的类,看它提供了哪些方法,这就相当于看接口;最后,你再来打开一个具体的方法,去看它的代码是怎么写的,这就是看实现。
|
||||
|
||||
好,接下来,我们具体地分析一下每一个部分。
|
||||
|
||||
首先是**模型**,它是一个软件的核心部分。在其它的材料里,也有人称之为抽象,为了统一,我这里就都叫模型了。我们在前面的课程里也说过,设计最关键的就是构建出模型。而理解一个设计中的模型,可以帮助我们建立起对这个软件整体的认知。
|
||||
|
||||
比如,你在编写分布式计算代码时,需要考虑怎样在不同的节点上调度计算;而使用MapReduce时,只要考虑如何把计算分开(Map)最后再汇总(Reduce);而到了Spark,注意力就集中在要做怎样的计算上。它们在解决同样的问题,只是抽象层次逐步提高了,越来越接近要解决的问题,越来越少地考虑计算在不同的机器上是如何执行的,由此,降低了理解的门槛。
|
||||
|
||||
当你知道了模型的重要性,目光甚至可以不局限在某一个软件上。如果把同一个领域不同阶段的多个模型联系起来看,你还能看到软件发展的趋势。
|
||||
|
||||
其次是**接口**,它决定了软件通过怎样的方式,将模型提供的能力暴露出去。它是我们与这个软件交互的入口。如何理解这句话呢?我给你举几个具体的例子。
|
||||
|
||||
- 一个程序库的接口就是它的API,但对于同样的模型,每个人会设计出不同的API,而不同的API有着不同的表达能力。比如:Google的Guava对JDK的一些API重新做了封装,其目的就是简化开发,而很多优秀的做法后来又被JDK学了回去。
|
||||
- 一个工具软件一般会提供命令行接口,比如,每个程序员必备的基本技能——Unix命令行工具就是典型的命令行接口。
|
||||
- 一个业务系统的接口,就是对外暴露的各种接口,比如,它提供的各种REST API,也可能是提供了RPC给其它系统的调用。
|
||||
- ……
|
||||
|
||||
如果你想深入源码,去了解一个软件,接口是一个很好的指向标。你可以从一个接口进入到软件中,看它是怎样完成各种基本功能的。
|
||||
|
||||
最后是**实现**,就是指软件提供的模型和接口在内部是如何实现的,这是软件能力得以发挥的根基。这么说可能比较抽象,我再来举些例子。
|
||||
|
||||
- 一个业务系统收到一个请求之后,是把信息写到数据库,还是转发给另外的系统。
|
||||
- 一个算法的实现,是选择调用与别人已有的程序库,还是需要自己实现一个特定的算法。
|
||||
- 一个系统中的功能,哪些应该做成分布式的,哪些应该由一个中央节点统一处理。
|
||||
- 一段业务处理,是应该做成单线程,还是多线程的。
|
||||
- 当资源有竞争,是每个节点自己处理,还是交由一个中间件统一处理。
|
||||
- 不同系统之间的连接,该采用哪种协议,是自己实现,还是找一个中间件。
|
||||
- ……
|
||||
|
||||
讲到这,相信你一定发现了,“实现”里面的内容很多。所以,做每一个技术决策都应该结合自己所开发应用的特点,并不存在一个通用的解决方案。在实际的工作中,我发现许多人以为的设计其实是这里所讲的实现。
|
||||
|
||||
我也知道,“实现”很重要,但是,它必须建立在模型和接口的基础之上。因为在一个系统的设计中,模型是最核心的部分。如果模型变了,这个软件便不再是这个软件了,而接口通常反映的就是模型。所以,模型和接口的稳定度都要比实现高,实现则是要随着软件发展而不断调整。
|
||||
|
||||
举个例子,很多人都知道Redis这个键值对存储性能非常好,他们学习Redis时,对其单线程模型印象深刻,因为它简单高效。但随着人们使用Redis的增多,对Redis有了进一步的需求。所以,从6.0开始,它开始支持多线程版本,以便于更好地满足人们的需求。但即便Redis改成了多线程,它还是那个Redis,它的模型和接口还是一如既往,只是实现变了而已。
|
||||
|
||||
## 了解设计三步走
|
||||
|
||||
之所以要把模型、接口和实现区分开来,是因为这三者的关注点是不同的,而很多人在讨论所谓的“设计”时,经常会把它们混在一起。
|
||||
|
||||
如果你在讨论的时候连“讨论的内容到底是什么”都没弄清楚,就很难得出一个清晰的结果。我参与过很多类似的讨论,经常有一种很混乱的感觉。我思考了很长时间才发现,问题就在于他们把不同层面的内容混在了一起。
|
||||
|
||||
所以正确的做法是什么呢?就是你在讨论设计时应该遵循一个顺序,**先模型,再接口,最后是实现**,同理,了解一个设计也应该遵循这样的顺序。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b1/84/b1279d9a81d8b271c01270d3da8f0684.jpg" alt="">
|
||||
|
||||
如果模型都还没有弄清楚,就贸然进入细节的讨论,你很难分清哪些东西是核心,是必须保留的,哪些东西是可以替换的。如果你清楚了解了模型,也就知道哪些内容在系统中是广泛适用的,哪些内容必须要隔离。简单地说,分清模型会帮助你限制实现的使用范围。
|
||||
|
||||
下面是一张简化过的架构图,在这幅图里,订单模块完成处理之后,通过一个Kafka队列把消息发给支付模块,支付模块处理之后,再通过一个Kafka队列把消息发给物流模块。很多人都应该在自己的项目中见过类似的,但是更复杂的架构图。你能看出这张图的问题在哪吗?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/eb/3e/ebc3bf3cb03421de4b2a0f642940bd3e.jpg" alt="">
|
||||
|
||||
这张架构图的问题就在于,它把模型和实现混淆在一起了。图中的订单、支付和物流,说的都是模型层面的东西,但Kafka的出现,就把实现层面的东西拉了进来。Kafka只是实现这个功能时的一个技术选型,这也就意味着,如果随着业务的发展,它不能很好地扮演它的角色,你就可以替换掉它,而整个设计是不用变的。
|
||||
|
||||
所以,实现这段代码的时候,必须把Kafka相关的代码进行封装,不能在系统各处随意地调用,因为它属于实现,是可能被替换的。
|
||||
|
||||
我还要强调一点,在了解设计时,要按层次去了解,因为设计常常是分层的。每当我们打开一个层次,需要了解它的内部时,我们还要按照模型、接口和实现的顺序解读这个层次。
|
||||
|
||||
我用大家比较熟悉的操作系统来举个例子,如果你去了解它的内部,就知道它有内存管理、进程调度、文件系统等模块。我们可以按照模型、接口和实现去理解每个模块,就以进程管理为例:
|
||||
|
||||
- 进程管理的核心模型就包括进程模型和调度算法;
|
||||
- 它的接口就包括,进程的创建、销毁以及调度算法的触发等;
|
||||
- 不同调度算法就是一个个具体的实现。
|
||||
|
||||
操作系统课程难以学习,很大程度上就在于,很多人没有搞清楚其中各个概念之间的关系。
|
||||
|
||||
即便层层展开到最后,到了一个具体类,甚至是一个具体的数据结构,我们依然可以按照模型、接口和实现这个结构来理解,比如很多Java面试题常问到的HashMap:
|
||||
|
||||
- 它的模型就是我们在数据结构中学习的HashMap;
|
||||
- 它定义了一些接口,比如,get、put等;
|
||||
- 它的实现原来是用标准的HashMap实现,后来则借鉴了红黑树。
|
||||
|
||||
实际上,当你能够一层一层地去理解设计,就像一棵知识树逐渐展开一样,每一个知识节点在展开的时候,都会有下面一级更具体的内容。当你的头脑中有了这样一棵设计树,你也就掌握了整个系统的地图,再有新需求到来时,你就不会再盲目地去改代码了。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们学习了如何了解一个软件设计,可以从三个部分入手:模型、接口和实现。
|
||||
|
||||
- 模型,也可以称为抽象,是一个软件的核心部分,是这个系统与其它系统有所区别的关键,是我们理解整个软件设计最核心的部分。
|
||||
- 接口,是通过怎样的方式将模型提供的能力暴露出去,是我们与这个软件交互的入口。
|
||||
- 实现,就是软件提供的模型和接口在内部是如何实现的,是软件能力得以发挥的根基。
|
||||
|
||||
了解设计的顺序应该是,**先模型,再接口,最后是实现**。了解设计,需要一层一层地展开,在每个层次都按照模型、接口和实现进行理解,在头脑中形成一棵设计树。
|
||||
|
||||
现在你已经有了一个了解设计的基本方法,接下来几讲,我会用几个开源项目带你再进一步,去看看如何去了解模型、接口和实现。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**了解设计,先模型,再接口,最后是实现**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c3/37/c33374c66f20f52ce6119e64b53ae137.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
现在的开源项目越来越多,每个开源项目都会提供一些不同的特点,请你找一些自己感兴趣的开源项目,看看它们分别提供了什么,是新的模型、是新的接口,还是新的实现?欢迎在留言区分享你的思考。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
217
极客时间专栏/软件设计之美/了解一个软件的设计/05 | Spring DI容器:如何分析一个软件的模型?.md
Normal file
217
极客时间专栏/软件设计之美/了解一个软件的设计/05 | Spring DI容器:如何分析一个软件的模型?.md
Normal file
@@ -0,0 +1,217 @@
|
||||
<audio id="audio" title="05 | Spring DI容器:如何分析一个软件的模型?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5c/c7/5c330753ebb11f1414f523ab124c53c7.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
在上一讲中,我们讨论了如何了解一个软件的设计,主要是从三个部分入手:模型、接口和实现。那么,在接下来的三讲中,我将结合几个典型的开源项目,告诉你如何具体地理解一个软件的模型、接口和实现。
|
||||
|
||||
今天这一讲,我们就先来谈谈了解设计的第一步:模型。如果拿到一个项目,我们怎么去理解它的模型呢?
|
||||
|
||||
**我们肯定要先知道项目提供了哪些模型,模型又提供了怎样的能力。**这是所有人都知道的事情,我并不准备深入地去探讨。但如果只知道这些,你只是在了解别人设计的结果,这种程度并不足以支撑你后期对模型的维护。
|
||||
|
||||
在一个项目中,常常会出现新人随意向模型中添加内容,修改实现,让模型变得难以维护的情况。造成这一现象的原因就在于他们对于模型的理解不到位。
|
||||
|
||||
我们都知道,任何模型都是为了解决问题而生的,所以,理解一个模型,需要了解在没有这个模型之前,问题是如何被解决的,这样,你才能知道新的模型究竟提供了怎样的提升。也就是说,**理解一个模型的关键在于,要了解这个模型设计的来龙去脉,知道它是如何解决相应的问题。**
|
||||
|
||||
今天我们以Spring的DI容器为例,来看看怎样理解软件的模型。
|
||||
|
||||
## 耦合的依赖
|
||||
|
||||
Spring在Java世界里绝对是大名鼎鼎,如果你今天在做Java开发而不用Spring,那么你大概率会被认为是个另类。
|
||||
|
||||
今天很多程序员都把Spring当成一个成熟的框架,很少去仔细分析Spring的设计。但作为一个从0.8版本就开始接触Spring的程序员,我刚好有幸经历了Spring从渺小到壮大的过程,得以体会到Spring给行业带来的巨大思维转变。
|
||||
|
||||
如果说Spring这棵参天大树有一个稳健的根基,那其根基就应该是 Spring的DI容器。DI是Dependency Injection的缩写,也就是“依赖注入”。Spring的各个项目都是这个根基上长出的枝芽。
|
||||
|
||||
那么,DI容器要解决的问题是什么呢?它解决的是**组件创建和组装**的问题,但是为什么这是一个需要解决的问题呢?这就需要我们了解一下组件的创建和组装。
|
||||
|
||||
在前面的课程中,我讲过,软件设计需要有一个分解的过程,所以,它必然还要面对一个组装的过程,也就是把分解出来的各个组件组装到一起,完成所需要的功能。
|
||||
|
||||
为了叙述方便,我采用Java语言来进行后续的描述。
|
||||
|
||||
我们从程序员最熟悉的一个查询场景开始。假设我们有一个文章服务(ArticleService)提供根据标题查询文章的功能。当然,数据是需要持久化的,所以,这里还有一个ArticleRepository,用来与持久化数据打交道。
|
||||
|
||||
熟悉DDD的同学可能发现了,这个仓库(Repository)的概念来自于DDD。如果你不熟悉也没关系,它就是与持久化数据打交道的一层,和一些人习惯的Mapper或者DAO(Data Access Object)类似,你可以简单地把它理解成访问数据库的代码。
|
||||
|
||||
```
|
||||
class ArticleService {
|
||||
//提供根据标题查询文章的服务
|
||||
Article findByTitle(final String title) {
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
interface ArticleRepository {
|
||||
//在持久化存储中,根据标题查询文章
|
||||
Article findByTitle(final String title);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在ArticleService处理业务的过程中,需要用到ArticleRepository辅助它完成功能,也就是说,ArticleService要依赖于ArticleRepository。这时你该怎么做呢?一个直接的做法就是在 ArticleService中增加一个字段表示ArticleRepository。
|
||||
|
||||
```
|
||||
class ArticleService {
|
||||
private ArticleRepository repository;
|
||||
|
||||
public Article findByTitle(final String title) {
|
||||
// 做参数校验
|
||||
return this.repository.findByTitle(title);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
目前看起来一切都还好,但是接下来,问题就来了,这个字段怎么初始化呢?程序员一般最直接的反应就是直接创建这个对象。这里选用了一个数据库版本的实现(DBArticleRepository)。
|
||||
|
||||
```
|
||||
class ArticleService {
|
||||
private ArticleRepository repository = new DBArticleRepository();
|
||||
|
||||
public Article findByTitle(final String title) {
|
||||
// 做参数校验
|
||||
return this.repository.findByTitle(title);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
看上去很好,但实际上DBArticleRepository并不能这样初始化。正如这个实现类的名字所表示的那样,我们这里要用到数据库。但在真实的项目中,由于资源所限,我们一般不会在应用中任意打开数据库连接,而是会选择共享数据库连接。所以,DBArticleRepository需要一个数据库连接(Connection)的参数。在这里,你决定在构造函数里把这个参数传进来。
|
||||
|
||||
```
|
||||
class ArticlService {
|
||||
private ArticleRepository repository;
|
||||
|
||||
public ArticlService(final Connection connection) {
|
||||
this.repository = new DBArticleRepository(connection);
|
||||
}
|
||||
|
||||
public Article findByTitle(final String title) {
|
||||
// 做参数校验
|
||||
return this.repository.findByTitle(title);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
好,代码写完了,它看上去一切正常。如果你的开发习惯仅仅到此为止,可能你会觉得这还不错。但我们并不打算做一个只写代码的程序员,所以,我们要进入下一个阶段:测试。
|
||||
|
||||
一旦开始准备测试,你就会发现,要让ArticleService跑起来,那就得让ArticleRepository也跑起来;要让ArticleRepository跑起来,那就得准备数据库连接。
|
||||
|
||||
是不是觉得太麻烦,想放弃测试。但有职业素养的你,决定坚持一下,去准备数据库连接信息。
|
||||
|
||||
然后,真正开始写测试时,你才发现,要测试,你还要在数据库里准备各种数据。比如,要测查询,你就得插入一些数据,看查出来的结果和插入的数据是否一致;要测更新,你就得先插入数据,测试跑完,再看数据更新是否正确。
|
||||
|
||||
不过,你还是没有放弃,咬着牙准备了一堆数据之后,你突然困惑了:我在干什么?我不是要测试服务吗?做数据准备不是测试仓库的时候应该做的事吗?
|
||||
|
||||
那么,问题出在哪儿呢?其实就在你创建对象的那一刻,问题就出现了。
|
||||
|
||||
## 分离的依赖
|
||||
|
||||
为什么说从创建对象开始就出问题了呢?
|
||||
|
||||
因为当我们创建一个对象时,就必须要有一个具体的实现类,对应到我们这里,就是那个 DBArticleRepository。虽然我们的ArticleService写得很干净,其他部分根本不依赖于 DBArticleRepository,只在构造函数里依赖了,但依赖就是依赖。
|
||||
|
||||
与此同时,由于要构造DBArticleRepository的缘故,我们这里还引入了Connection这个类,这个类只与DBArticleRepository的构造有关系,与我们这个ArticleService的业务逻辑一点关系都没有。
|
||||
|
||||
所以,你看到了,只是因为引入了一个具体的实现,我们就需要把它周边配套的东西全部引入进来,而这一切与这个类本身的业务逻辑没有任何关系。
|
||||
|
||||
这就好像,你原本打算买一套家具,现在却让你必须了解树是怎么种的、怎么伐的、怎么加工的,以及家具是怎么设计、怎么组装的,而你想要的只是一套能够使用的家具而已。
|
||||
|
||||
这还只是最简单的场景,在真实的项目中,构建一个对象可能还会牵扯到更多的内容:
|
||||
|
||||
- 根据不同的参数,创建不同的实现类对象,你可能需要用到工厂模式。
|
||||
- 为了了解方法的执行时间,需要给被依赖的对象加上监控。
|
||||
- 依赖的对象来自于某个框架,你自己都不知道具体的实现类是什么。
|
||||
- ……
|
||||
|
||||
所以,即便是最简单的对象创建和组装,也不像看起来那么简单。
|
||||
|
||||
既然直接构造存在这么多的问题,那么最简单的办法就是把创建的过程拿出去,只留下与字段关联的过程:
|
||||
|
||||
```
|
||||
class ArticleService {
|
||||
private ArticleRepository repository;
|
||||
|
||||
public ArticleService(final ArticleRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public Article findByTitle(final String title) {
|
||||
// 做参数校验
|
||||
return this.repository.findByTitle(title);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这时候,ArticleService就只依赖ArticleRepository。而测试ArticleService也很简单,只要用一个对象将ArticleRepository的行为模拟出来就可以了。通常这种模拟对象行为的工作用一个现成的程序库就可以完成,这就是那些Mock框架能够帮助你完成的工作。
|
||||
|
||||
或许你想问,在之前的代码里,如果我用Mock框架模拟Connection类是不是也可以呢?理论上,的确可以。但是想要让ArticleService的测试通过,就必须打开DBArticleRepository的实现,只有配合着其中的实现,才可能让ArticleService跑起来。显然,你跑远了。
|
||||
|
||||
现在,对象的创建已经分离了出去,但还是要要有一个地方完成这个工作,最简单的解决方案自然是,把所有的对象创建和组装在一个地方完成:
|
||||
|
||||
```
|
||||
...
|
||||
ArticleRepository repository = new DBArticleRepository(connection);
|
||||
AriticleService service = new ArticleService(repository);
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
相比于业务逻辑,组装过程并没有什么复杂的部分。一般而言,纯粹是一个又一个对象的创建以及传参的过程,这部分的代码看上去会非常的无聊。
|
||||
|
||||
虽然很无聊,但这一部分代码很重要,最好的解决方案就是有一个框架把它解决掉。在Java世界里,这种组装一堆对象的东西一般被称为“容器”,我们也用这个名字。
|
||||
|
||||
```
|
||||
Container container = new Container();
|
||||
container.bind(Connection.class).to(connection);
|
||||
container.bind(ArticleReposistory.class).to(DBArticleRepository.class);
|
||||
container.bind(ArticleService.class).to(ArticleService.class)
|
||||
|
||||
ArticleService service = container.getInstance(ArticleService.class);
|
||||
|
||||
```
|
||||
|
||||
至此,一个容器就此诞生。因为它解决的是依赖的问题,把被依赖的对象像药水一样,注入到了目标对象中,所以,它得名“依赖注入”(Dependency Injection,简称 DI)。这个容器也就被称为DI容器了。
|
||||
|
||||
至此,我简单地给你介绍了DI容器的来龙去脉。虽然上面这段和Spring DI容器长得并不一样,但其原理是一致的,只是接口的差异而已。
|
||||
|
||||
事实上,这种创建和组装对象的方式在当年引发了很大的讨论,直到最后Martin Fowler写了一篇《[反转控制容器和依赖注入模式](http://www.martinfowler.com/articles/injection.html)》的文章,才算把大家的讨论做了一个总结,行业里总算是有了一个共识。
|
||||
|
||||
那段时间,DI容器也得到了蓬勃的发展,很多开源项目都打造了自己的DI容器,Spring是其中最有名的一个。只不过,Spring并没有就此止步,而是在这样一个小内核上面发展出了更多的东西,这才有了我们今天看到的庞大的Spring王国。
|
||||
|
||||
讲到这里,你会想,那这和我们要讨论的“模型”有什么关系呢?
|
||||
|
||||
正如我前面所说,很多人习惯性把对象的创建和组装写到了一个类里面,这样造成的结果就是,代码出现了大量的耦合。时至今日,很多项目依然在犯同样的错误。很多项目测试难做,原因就在于此。这也从另外一个侧面佐证了可测试性的作用,我们曾在[第3讲](https://time.geekbang.org/column/article/241094)中说过:可测试性是衡量设计优劣的一个重要标准。
|
||||
|
||||
由此可见,在没有DI容器之前,那是怎样的一个蛮荒时代啊!
|
||||
|
||||
有了DI容器之后呢?你的代码就只剩下关联的代码,对象的创建和组装都由DI容器完成了。甚至在不经意间,你有了一个还算不错的设计:至少你做到了面向接口编程,它的实现是可以替换的,它还是可测试的。与之前相比,这是一种截然不同的思考方式,而这恰恰就是DI容器这个模型带给我们的。
|
||||
|
||||
而且,一旦有了容器的概念,它还可以不断增强。比如,我们想给所有与数据库相关的代码加上时间监控,只要在容器构造对象时添加处理即可。你可能已经发现了,这就是 AOP(Aspect Oriented Programming,面向切面编程)的处理手法。而这些改动,你的业务代码并无感知。
|
||||
|
||||
Spring的流行,对于提升Java世界整体编程的质量是大有助益的。因为它引导的设计方向是一个好的方向,一个普通的Java程序员写出来的程序只要符合Spring引导的方向,那么它的基本质量就是有保障的,远超那个随意写程序的年代。
|
||||
|
||||
不过,如果你不能认识到DI容器引导的方向,我们还是无法充分利用它的优势,更糟糕的是,我们也不能太低估一些程序员的破坏力。我还是见过很多程序员即便在用了Spring之后,依然是自己构造对象,静态方法满天飞,把原本一个还可以的设计,打得七零八落。
|
||||
|
||||
你看,通过上面的分析,我们知道了,只有理解了模型设计的来龙去脉,清楚认识到它在解决的问题,才能更好地运用这个模型去解决后面遇到的问题。如果你是这个项目的维护者,你才能更好地扩展这个模型,以便适应未来的需求。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们学习了如何了解设计的第一部分:看模型。**理解模型,要知道项目提供了哪些模型,这些模型都提供了怎样的能力**。但还有更重要的一步就是,**要了解模型设计的来龙去脉**。这样,一方面,可以增进了我们对它的了解,但另一方面,也会减少我们对模型的破坏或滥用。
|
||||
|
||||
我以Spring的DI容器为例给你讲解了如何理解模型。DI容器的引入有效地解决了对象的创建和组装的问题,让程序员们拥有了一个新的编程模型。
|
||||
|
||||
按照这个编程模型去写代码,整体的质量会得到大幅度的提升,也会规避掉之前的许多问题。这也是一个好的模型对项目起到的促进作用。像DI这种设计得非常好的模型,你甚至不觉得自己在用一个特定的模型在编程。
|
||||
|
||||
有了对模型的了解,我们已经迈出了理解设计的第一步,下一讲,我们来看看怎样理解接口。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**理解模型,要了解模型设计的来龙去脉。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/50/a2/50983d3d104c811f33f02db1783d4da2.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你思考一个问题,DI容器看上去如此地合情合理,为什么在其他编程语言的开发中,它并没有流行起来呢?欢迎在留言区写下你的思考。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
256
极客时间专栏/软件设计之美/了解一个软件的设计/06 | Ruby on Rails:如何分析一个软件的接口?.md
Normal file
256
极客时间专栏/软件设计之美/了解一个软件的设计/06 | Ruby on Rails:如何分析一个软件的接口?.md
Normal file
@@ -0,0 +1,256 @@
|
||||
<audio id="audio" title="06 | Ruby on Rails:如何分析一个软件的接口?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/85/db/85e4c18ba7a8b59c5c8a9d500c0142db.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
在上一讲中,我以Spring的DI容器为例,给你讲解了如何理解一个项目的模型。在模型之后,下一步就该是接口了。
|
||||
|
||||
在任何一个项目中,接口的数量都不是一个小数目。仅仅一个普通的程序库,里面的接口少则几十个,多则成百上千。难道我们理解接口,就是要一个一个地读这些接口吗?
|
||||
|
||||
显然,你不太可能把所有的接口细节都记住。我写Java程序差不多 20 年了,但很多JDK里的类我都不了解。甚至有时候,还没有等我去了解这个类,它就过时了。
|
||||
|
||||
那么,如何才能从纷繁复杂的接口中,披荆斩棘而出呢?我给你个方法:**找主线,看风格**。
|
||||
|
||||
**找主线**的意思是,你需要找到一条功能主线,建立起对这个项目结构性的认知,而不是一上来就把精力放在每一个接口的细节上。你对细节部分的了解会随着你对项目的深入而逐渐增加。而有了主线后,你就有了着力点,就可以不断深入了。
|
||||
|
||||
但是,我们要学习的不只是这些接口的用法,要想从项目的接口设计上学到更多,这就需要你关注它所引导的**风格**,换句话说,就是它希望你怎样使用它,或是怎样在上面继续开发。
|
||||
|
||||
从一个项目的接口风格中,我们不难看出设计者的品位。我们常把编程视为一种艺术,而在接口的设计上就能窥见一二。这些内容是我们在学习软件设计时,应该慢慢品味的。
|
||||
|
||||
为什么要看风格,还有一个很重要的原因,就是你要维护项目的一致性,必须有一个统一的风格。有不少项目,里面会共存多种不同风格的接口,就是每个人都在设计自己习惯的接口,那势必会造成混乱。
|
||||
|
||||
这一讲,我们就来一起来学习怎样看接口,我选择的项目是Ruby on Rails,因为它的接口设计风格是带给我最多震撼的,无论是编程接口的优雅,还是开发过程接口的顺畅。不过,正如我在[第4讲](https://time.geekbang.org/column/article/241114)所说,看设计要先看模型。所以,我们还是先快速地了解一下Ruby on Rails的模型。
|
||||
|
||||
## Ruby on Rails模型
|
||||
|
||||
如果你是一个比较年轻的程序员,Ruby on Rails这个名字可能会让你有一些陌生。但是在十多年前,它初出茅庐之际,可是给行业带来了极大的冲击。只是后来时运不济,编程模型发生了大的改变,使它失去了行业领导者的地位。这个故事还是要从你最熟悉的Web开发说起。
|
||||
|
||||
自从互联网兴起,人们对于Web开发的探索就从未停止过。
|
||||
|
||||
最早期的Web开发只是静态页面的开发,那时候,你只要熟悉HTML,就可以说自己懂Web开发了。后来,人们不再满足于静态页面,开始尝试编写有动态效果的页面。
|
||||
|
||||
一方面,浏览器开始支持JavaScript,让页面本身有了动态效果;另一方面,有人开始制作后台服务,在页面之间切换的时候,也可以有动态的效果。那个时候出现了像CGI(Common Gateway Interface)这样的编程规范。
|
||||
|
||||
当Java世界里出现了Servlet、JSP这些规范,Web开发也逐渐由小打小闹变成了企业开发的主力,越来越多的公司开始正视Web开发。因为这些规范很沉重,一些号称要简化Web开发的框架开始出现,比如:Struts、Webwork以及Spring MVC等等。
|
||||
|
||||
这些框架的出现,让Web开发摆脱了Servlet的初级阶段,使MVC模式成为了Web开发的主流。但即便如此,那个时候的Java Web开发依然是沉重的,比如写一个Web应用,光是配置文件就足以把人逼疯。
|
||||
|
||||
Ruby on Rails正是在这样的背景下横空出世的。为了叙述方便,后面我就把Ruby on Rails简称Rails了。
|
||||
|
||||
从模型上讲,Rails是标准的**基于MVC模型进行开发的Web框架**。在这一点上,它没有什么特殊的,它给行业带来巨大冲击的是它的接口设计。
|
||||
|
||||
Rails一个重要的设计理念就是**约定优于配置**,无需配置,按照缺省的风格就可以完成基本的功能,这样的理念贯穿在Rails各个接口的设计中。
|
||||
|
||||
接下来,我们就来看Rails的接口。
|
||||
|
||||
前面我提到过理解接口应该先找主线,**找到项目主线的一个方法就是从起步走文档开始,因为它会把项目最基本的用法展现给你,你可以轻松地找到主线**。
|
||||
|
||||
Rails的起步走文档做得就非常好,主线可以说是一目了然。它用了一个Web项目帮你介绍了Rails开发的基本过程,通过这个过程,你就对Rails有了初步的印象。
|
||||
|
||||
有了主线之后,我们就要开始从中了解接口的风格。Rails给我们提供的三种接口,分别是:
|
||||
|
||||
- Web应用对外暴露的接口:REST API;
|
||||
- 程序员写程序时用到的接口:API;
|
||||
- 程序员在开发过程中用到的接口:命令行。
|
||||
|
||||
接下来,我们就一个个地深入其中,了解它们的风格,以及它们给行业带来的不同思考。
|
||||
|
||||
## REST 接口
|
||||
|
||||
先说应用对外暴露的接口:REST API。REST如今已经成为很多人耳熟能详的名词,它把Web 的各种信息当作资源。既然是资源,它就可以对这些Web信息做各种操作,这些操作对应着HTTP的各种动词(GET、POST、PUT、DELETE等)。
|
||||
|
||||
REST当年的问世是Roy Fielding博士为了纠正大家对HTTP的误用。 REST刚出来的时候,开发者普遍觉得这是一个好的想法,但怎么落地呢?没有几个人想得清楚。
|
||||
|
||||
Rails恰逢其时地出现了。Rails对REST的使用方式做了一个约定。只要你遵循Rails的惯用写法,写出来的结果基本上就是符合REST结构的,也就是说,Rails把REST这个模型用一种更实用的方式落地了。
|
||||
|
||||
```
|
||||
Rails.application.routes.draw do
|
||||
...
|
||||
resources :articles
|
||||
...
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
在用Rails写程序的时候,你只要添加一个resource进去,它就会替你规划好这个资源应该如何去写、怎么设计URL、用哪些HTTP动词,以及它们对应到哪些方法。
|
||||
|
||||
```
|
||||
$ bin/rails routes
|
||||
Prefix Verb URI Pattern Controller#Action
|
||||
articles GET /articles(.:format) articles#index
|
||||
POST /articles(.:format) articles#create
|
||||
new_article GET /articles/new(.:format) articles#new
|
||||
edit_article GET /articles/:id/edit(.:format) articles#edit
|
||||
article GET /articles/:id(.:format) articles#show
|
||||
PATCH /articles/:id(.:format) articles#update
|
||||
PUT /articles/:id(.:format) articles#update
|
||||
DELETE /articles/:id(.:format) articles#destroy
|
||||
root GET / welcome#index
|
||||
|
||||
```
|
||||
|
||||
看了Rails给你的这个映射关系后,你就知道自己该怎么写代码了。这就是一种约定,不需要你费心思考,因为这是人家总结出来的行业中的最佳实践。只要按照这个规范写,你写的就是一个符合REST规范的代码,这就是Rails引导的外部接口风格。
|
||||
|
||||
## API 接口
|
||||
|
||||
我们再来看API接口。当年我接触Rails时,最让我感到震惊的是它的数据库查询方式,与传统开发的风格截然不同,就这么简单的一句:
|
||||
|
||||
```
|
||||
Article.find_by_title("foo")
|
||||
|
||||
```
|
||||
|
||||
要知道,那个时候用Java写程序,即便是想做一个最简单的查询,写的代码也是相当多的。我们不仅要创建一个对象,还要写对应的SQL语句,还要把查询出来的结果,按照一定的规则组装起来。
|
||||
|
||||
而 Rails用一句轻描淡写find_by就解决了所有的问题,而且,这个find_by_title方法还不是我实现的,Rails会替你自动实现。当我们需要有更多的查询条件时,只要一个一个附加上去就可以了。
|
||||
|
||||
```
|
||||
Article.find_by_title_and_author("foo", "bar")
|
||||
|
||||
```
|
||||
|
||||
同样的事,如果放到Java里去做,还需要把前面说的事再做一遍,差别只是查询语句不一样。
|
||||
|
||||
虽然我说的是当年的场景,但时至今日,在这些简单问题上,很多使用Java的团队所付出的工作量并不比当年少。
|
||||
|
||||
从功能的角度说,这样的查询在功能上是完全一样的,但显然Rails程序员和Java程序员的工作量是天差地别的。这其中的差异就是不同的编程接口所造成的。
|
||||
|
||||
所以你看,一个好的接口设计会节省很多工作量,会减少犯错的几率。因为它会在背后帮你实现那些细节。
|
||||
|
||||
而设计不好的接口,则会把其中的细节暴露出来,让使用者参与其中。写程序库和写应用虽然都是写代码,但二者的要求确实相差极大。把细节暴露给所有人,显然是一个增加犯错几率的事情。
|
||||
|
||||
Rails的API接口给行业带来的另一个影响是,它让人们开始关注API的表达性。比如,每篇文章可以有多个评论,用Rails的方式写出来是这样的:
|
||||
|
||||
```
|
||||
class Article < ApplicationRecord
|
||||
has_many :comments
|
||||
...
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
而如果用传统Java风格,你写出来的代码,可能是这个样子的:
|
||||
|
||||
```
|
||||
class Article {
|
||||
private List<Comment> comments;
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
很明显,“有多个”这种表示关系的语义用has_many表示更为直白,如果用List ,你是无法辨别它是一个属性,还是一个关系的。
|
||||
|
||||
Rails里面类似的代码有很多,包括我们前面提到的find_by。所以,如果你去读Rails写成的应用,会觉得代码的可读性要好得多。
|
||||
|
||||
由于Rails的蓬勃发展,人们也开始注意到好接口的重要性。Java后期的一些开源项目也开始向Rails学习。比如,使用Spring Data JPA的项目后,我们也可以写出类似Rails的代码。声明一对多的关系,可以这样写:
|
||||
|
||||
```
|
||||
class Article {
|
||||
@OneToMany
|
||||
private List<Comment> comments;
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
而查询要定义一个接口,代码可以这样写:
|
||||
|
||||
```
|
||||
interface ArticleRepository extends JpaRepository<Article, Long> {
|
||||
Article findByTitle(String title);
|
||||
Article findByTitleAndAuthor(String title, String author);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当你需要使用的时候,只要在服务里调用对应的接口即可。
|
||||
|
||||
```
|
||||
class ArticleService {
|
||||
private ArticleRepository repository;
|
||||
...
|
||||
public Article findByTitle(final String title) {
|
||||
return repository.findByTitile(title);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
显然,Java无法像Rails那样不声明方法就去调用,因为这是由Ruby的动态语言特性支持的,而Java这种编译型语言是做不到的。不过比起从前自己写SQL、做对象映射,已经减少了很多的工作量。
|
||||
|
||||
顺便说一下,Spring Data JPA之所以能够只声明接口,一个重要的原因就是它利用了Spring提供的基础设施,也就是上一讲提到的依赖注入。它帮你动态生成了一个类,不用你自己手工编写。
|
||||
|
||||
简单,表达性好,这就是Rails API的风格。
|
||||
|
||||
## 命令行接口
|
||||
|
||||
作为程序员,我们都知道自动化的重要性,但Rails 在“把命令行的接口和整个工程配套得浑然一体”这个方面做到了极致。Rails的自动化不仅会帮你做一些事情,更重要的是,它还把当前软件工程方面的最佳实践融合进去,这就是Rails的命令行风格。
|
||||
|
||||
如果要创建一个新项目,你会怎么做呢?使用Rails,这就是一个命令:
|
||||
|
||||
```
|
||||
$ rails new article-app
|
||||
|
||||
```
|
||||
|
||||
这个命令执行的结果生成的不仅仅是源码,还有一些鼓励你去做的最佳实践,比如:
|
||||
|
||||
- 它选择了Rake作为自动化管理的工具,生成了对应的Rakefile;
|
||||
- 它选择了RubyGem作为包管理的工具,生成了对应的Gemfile;
|
||||
- 为防止在不同的人在机器上执行命令的时间不同,导致对应的软件包有变动,生成了对应的Gemfile.lock,锁定了软件包的版本;
|
||||
- 把对数据库的改动变成了代码;
|
||||
- ……
|
||||
|
||||
而这仅仅是一个刚刚生成的工程,我们一行代码都没有写,它却已经可以运行了。
|
||||
|
||||
```
|
||||
$ bin/rails server
|
||||
|
||||
```
|
||||
|
||||
这就启动了一个服务器,访问 [http://localhost:3000/](http://localhost:3000/) 这个 URL,你就可以访问到一个页面。
|
||||
|
||||
如果你打算开始编写代码,你也可以让它帮你生成代码骨架。执行下面的命令,它会帮你生成一个controller类,生成对应的页面,甚至包括了对应的测试,这同样是一个鼓励测试的最佳实践。
|
||||
|
||||
```
|
||||
$ bin/rails generate controller Welcome index
|
||||
|
||||
```
|
||||
|
||||
在Rails蓬勃发展的那个时代,人们努力探索着Web开发中各种优秀的做法,而在这个方面走在最前沿的就是Rails。所以,那个时候,我们经常会关注Rails的版本更新,看看又有哪些好的做法被融入其中。
|
||||
|
||||
Rails中那些优秀的实践逐步地被各种语言的框架学习着。语言编写者们在设计各种语言框架时,也都逐步借鉴了Rails中的那些优秀实践。比如,今天做Java开发,我们也会用到数据库迁移的工具,比如Flyway。
|
||||
|
||||
当然,另一个方面,即便到了今天,大部分项目的自动化整合程度也远远达不到Rails的高度,可能各方面的工具都有,但是如此浑然一体的开发体验,依然是Rails做得最好。
|
||||
|
||||
最后,你可能会问,Rails这么优秀,为什么今天却落伍了呢?
|
||||
|
||||
在Web开发领域,Rails可谓成也MVC,败也MVC。MVC是那个时代Web开发的主流,页面主要在服务端进行渲染。然而,后来风云突变,拜JavaScript虚拟机V8所赐,JavaScript能力越来越强,Node.js兴起,人们重新认识了JavaScirpt。它从边缘站到了舞台中心,各种组件层出不穷,前端页面的表现力大幅度增强。
|
||||
|
||||
Web开发的模式由原来的MVC,逐渐变成了前端提供页面,后端提供接口的方式。Java的一些框架和服务也逐步发展了起来,Spring系列也越来越强大,重新夺回了Web后端开发的关注。
|
||||
|
||||
**总结时刻**
|
||||
|
||||
今天,我们学习如何了解设计的第二部分:看接口。看接口的一个方法是**找主线,看风格**。先找到一条功能主线,对项目建立起结构性的了解。有了主线之后,再沿着主线把相关接口梳理出来。
|
||||
|
||||
查看接口,关键要看接口的风格,也就是项目作者引导人们怎样使用接口。在一个项目里,统一接口风格也是很重要的一个方面,所以,熟悉现有的接口风格,保持统一也是非常重要的。
|
||||
|
||||
我还介绍了一个曾经火爆的Web开发框架:Ruby on Rails。借着它的起步走文档,我给你介绍了它的一些接口,包括:
|
||||
|
||||
- Web应用对外暴露的接口:REST API;
|
||||
- 程序员写程序时用到的接口:API;
|
||||
- 程序员在开发过程中用到的接口:命令行。
|
||||
|
||||
从Rails的接口设计中,我们可以看到,一个好的接口设计,无论是最佳实践的引入,抑或是API设计风格的引导,都可以帮助我们建立起良好的开发习惯。
|
||||
|
||||
当我们理解了模型和接口,接下来就该看实现了,这就是我们下一讲要讲的内容。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**理解一个项目的接口,先找主线,再看风格。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/11/a6/11a50512d7ff450654c9eb5977de33a6.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你来分享一下,你在哪个项目的设计中学到了一些好的开发习惯呢?欢迎在留言区分享你的经历。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
141
极客时间专栏/软件设计之美/了解一个软件的设计/07 | Kafka:如何分析一个软件的实现?.md
Normal file
141
极客时间专栏/软件设计之美/了解一个软件的设计/07 | Kafka:如何分析一个软件的实现?.md
Normal file
@@ -0,0 +1,141 @@
|
||||
<audio id="audio" title="07 | Kafka:如何分析一个软件的实现?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/42/3f/42b364b6837233a14b8987d18ac91c3f.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
上一讲,我们学习了如何看接口,今天我们进入第三个部分——看实现。在一个系统中,模型和接口是相对稳定的部分。但是,同样的模型和接口,如果采用不同的实现,稳定性、可扩展性和性能等诸多方面相差极大。而且,只有了解实现,你才有改动代码的基础。
|
||||
|
||||
但是,不得不说,“看实现”是一个很大的挑战,因为有无数的细节在那里等着你。所以,在很多团队里,一个新人甚至会用长达几个月的时间去熟悉代码中的这些细节。
|
||||
|
||||
面对这种情况,我们该怎么办呢?
|
||||
|
||||
首先,你要记住一件事,你不太可能记住真实项目的所有细节,甚至到你离开项目的那一天,你依然会有很多细节不知道,可这并不妨碍你的工作。但是,如果你心中没有一份关于项目实现的地图,你就一定会迷失。
|
||||
|
||||
像我前面所说的新人,他们用几个月的时间熟悉代码,就是在通过代码一点点展开地图,但是,这不仅极其浪费时间,也很难形成一个整体认知。所以我建议,你应该直接把地图展开。怎么展开呢?**你需要找到两个关键点:软件的结构和关键的技术。**
|
||||
|
||||
可能你还不太理解我的意思,下面我就以开源软件Kafka为例,给你讲一下如何把地图展开,去看一个软件的实现。按照我们之前讲过的思路,了解一个软件设计的步骤是“先模型,再接口,最后看实现”。所以,我们要先了解Kafka的模型和接口。
|
||||
|
||||
## 消息队列的模型与接口
|
||||
|
||||
Kafka是这么自我介绍的:Kafka是一个分布式流平台。这是它现在的发展方向,但在更多人的心目中,Kafka的角色是一个消息队列。可以说,消息队列是Kafka这个软件的核心模型,而流平台显然是这个核心模型存在之后的扩展。所以,我们要先把焦点放在Kafka的核心模型——消息队列上。
|
||||
|
||||
简单地说, 消息队列(Messaging Queue)是一种进程间通信的方式,发消息的一方(也就是生产者)将消息发给消息队列,收消息的一方(也就是消费者)将队列中的消息取出并进行处理。
|
||||
|
||||
站在看模型的角度上,消息队列是很简单的,无非是生产者发消息,消费者消费消息。而且消息队列通常还会有一个topic的概念,用以区分发给不同目标的消息。
|
||||
|
||||
消息队列的基本接口也很简单。以Kafka为例,生产者是这样发消息的:
|
||||
|
||||
```
|
||||
producer.send(new KafkaRecord<>("topic", new Message()));
|
||||
|
||||
```
|
||||
|
||||
而消费者收消息是这样的:
|
||||
|
||||
```
|
||||
ConsumerRecords<String, Message> records = consumer.poll(1000);
|
||||
|
||||
```
|
||||
|
||||
有了对模型和接口的基本了解,我们会发现,消息队列本身并不难。
|
||||
|
||||
但我们都知道,消息队列的实现有很多,Kafka只是其中一种,还有诸如ActiveMQ、RabbitMQ等的实现。为什么会有这么多不同的消息队列实现呢?因为每个消息队列的实现都会有所侧重,不同的消息队列有其适用的场景。
|
||||
|
||||
消息队列还有一个最常见的特性是,它会提供一定的消息存储能力。这样的话,当生产者发消息的速度快于消费者处理消息的速度时,消息队列可以起到一定的缓冲作用。所以,有一些系统会利用消息队列的这个特性做“削峰填谷”,也就是在消息量特别大时,先把消息收下来,慢慢处理,以减小系统的压力。
|
||||
|
||||
Kafka之所以能从一众消息队列实现中脱颖而出,一个重要的原因就是,它针对消息写入做了优化,它的生产者写入速度特别快。从整体的表现上看,就是吞吐能力特别强。
|
||||
|
||||
好,我们已经对Kafka的能力有了一个初步的认识。显然,介绍接口和模型不足以将它与其他消息队列实现区分开来。所以,我们必须拉开大幕,开始去了解它的实现。
|
||||
|
||||
## 软件的结构
|
||||
|
||||
前面我提到,**当我们想去看一个软件的实现时,有两件事特别重要:软件的结构和关键的技术**。
|
||||
|
||||
我们先来看软件的结构。软件的结构其实也是软件的模型,只不过,它不是整体上的模型,而是展开实现细节之后的模型。我在[第1讲](https://time.geekbang.org/column/article/240177)也说过,模型是分层的。
|
||||
|
||||
对于每个软件来说,当你从整体的角度去了解它的时候,它是完整的一块。但当你打开它的时候,它就变成了多个模块的组合,这也是所谓“分层”的意义所在。而上一层只要使用下一层提供给它的接口就好。
|
||||
|
||||
所以,当我们打开了一个层次,了解它的实现时,也要先从大处着手。最好的办法就是我们能够**找到一张结构图**,准确地了解它的结构。
|
||||
|
||||
如果你能够找到这样一张图,你还是很幸运的。因为在真实的项目中,你可能会碰到各种可能性:
|
||||
|
||||
- 结构图混乱:你找到一张图,上面包含了各种内容。比如,有的是模块设计,有的是具体实现,更有甚者,还包括了一些流程;
|
||||
- 结构图复杂:一个比较成熟的项目,图上画了太多的内容。确实,随着项目的发展,软件解决的问题越来越多,它必然包含了更多的模块。但对于初次接触这个项目的我们而言,它就过于复杂了;
|
||||
- 无结构图:这是最糟糕的情况,你最好先想办法画出一张图来。
|
||||
|
||||
无论遇到上述的哪种情况,你了解项目都不会很顺利。所以,你还是要先了解模型和接口,因为它们永远是你的主线,可以帮你从混乱的局面中走出来。
|
||||
|
||||
那么,假设现在你有了一张结构图,在我们继续前进之前,我想先问一个问题:现在你有了一张结构图,你打算做什么?你可能会问,难道不是了解它的结构吗?是,但不够。我们不仅要知道一个设计的结果,最好还要推断出**设计的动因**。
|
||||
|
||||
所以,一种更好的做法是,带着问题上路。我们不妨假设自己就是这个软件的设计者,问问自己要怎么做。然后再去对比别人的设计,你就会发现,自己的想法和别人想法的相同或不同之处。对于理解Kafka而言,第一个问题就是**如果你来设计一个消息队列,你会怎么做呢?**
|
||||
|
||||
如果在网上搜索Kafka的架构图,你会搜到各种各样的图,上面包含了不同的信息。有的告诉你分区(Partition)的概念,有的告诉你Zookeeper。根据前面对模型的介绍,我特意挑了一张看上去最简单的架构图,因为它最贴近消息队列的基础模型:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ee/79/ee05f6c6446600da97d824591e5a4d79.jpg" alt="">
|
||||
|
||||
那么,从这个图中,你能看到什么呢?你能看到,Kafka的生产者一端将消息发送给Kafka集群,然后,消费者一端将消息取出来进行处理。这样的结构和你想的是不是一样的呢?如果让你负责进一步设计,你会怎么做呢?
|
||||
|
||||
- 生产者端封装出一个 SDK,负责消息的发送;
|
||||
- 消费者端封装出一个 SDK,负责消息的接收;
|
||||
- 设计一个集群系统,作为生产者和消费者之间的连接。
|
||||
|
||||
然后,你就可以问自己更多的问题:
|
||||
|
||||
- 生产端如果出现网络抖动,消息没有成功发送,它要怎么重试呢?
|
||||
- 消费端处理完的消息,怎样才能保证集群不会重复发送呢?
|
||||
- 为什么要设计一个集群呢?要防止出现单点的故障,而一旦有了集群,就会牵扯到下一个问题,集群内的节点如何保证消息的同步呢?
|
||||
- 消息在集群里是怎么存储的?
|
||||
- 生产端也好,消费端也罢,如果一个节点彻底掉线,集群该怎么处理呢?
|
||||
- ……
|
||||
|
||||
你有了更多的问题之后,你就会在代码里进行更深入地探索。你可以根据需要,打开对应模块,进一步了解里面的实现。比如,消息重发的问题,你就可以看看生产端是怎么解决这些问题的。当问题细化到具体实现时,我们就可以打开对应的源码,去里面寻找答案。
|
||||
|
||||
从结构上来说,Kafka不是一个特别复杂的系统。所以,如果你的项目更复杂,层次更多,我建议你把各个层次逐一展开,先把整体结构放在心中,再去做细节的探索。
|
||||
|
||||
## 关键的技术
|
||||
|
||||
我们再来看看理解实现的另一个重要方面:关键技术。
|
||||
|
||||
什么算是关键技术呢?就是能够让这个软件的“实现”与众不同的地方。了解关键技术可以保证一点,就是我们对代码的调整不会使项目出现明显的劣化。幸运的是,大多数项目都会愿意把自己的关键技术讲出来,所以,找到这些信息并不难。
|
||||
|
||||
以Kafka为例,前面说过,它针对写入做了优化,使得它的整体吞吐能力特别强。那它是怎么做到的呢?
|
||||
|
||||
消息队列实现消息存储的方式通常是把它写入到磁盘中,而Kafka的不同之处在于,它利用了**磁盘顺序读写**的特性。对于普通的机械硬盘而言,如果是随机写,需要按照机械硬盘的方式去寻址,然后磁头做机械运动,写入速度就会慢得多。但顺序写的话,会大幅度减少磁头的运动,效率自然就得到了大幅度的提高。
|
||||
|
||||
之所以可以这样实现,是充分利用了消息队列本身的特性:有序。它是技术实现与需求完美结合的产物。有了这个基础,就可以有进一步的优化。比如,利用内存映射文件减少用户空间到内核空间复制的开销。
|
||||
|
||||
如果站在了解实现的角度,你会觉得非常地自然。但要想从设计的角度学到更多,我们还是应该带着问题上路,多问自己一个问题,为什么其他的消息队列之前不这么做呢?这是一个值得深思的问题。**Kafka这个实现到底是哪里不容易想到呢?答案是软硬结合。**
|
||||
|
||||
之前的消息队列实现也会把消息写入到文件里,但文件对它们来说,只是一个通用的接口。开发者并没有想过利用硬件的特性做开发。而Kafka的开发者突破了这个限制,把硬件特性利用了起来,从而取得了更好的结果。
|
||||
|
||||
一旦理解了这一点,我们再来看其他的一些设计,就能学到更多的东西。比如,有一个著名的开源项目[LMAX Disruptor](http://lmax-exchange.github.io/disruptor/),它号称是最强劲的线程通信库。它有一段非常奇怪的代码,类似这样:
|
||||
|
||||
```
|
||||
protected long p1, p2, p3, p4, p5, p6, p7;
|
||||
|
||||
```
|
||||
|
||||
以正常程序员的标准,这简直是无厘头的低劣代码。而想要理解这段代码,你必须理解CPU缓存行的机制,这也是一种软硬结合的思路。
|
||||
|
||||
对于习惯写“软”件的程序员而言,在软件上投入的努力到达极限时,软硬结合是一种思路上的突破。当然,这种突破的前提是要对硬件的机制有所了解,这往往是很多程序员在基本功上欠缺的,可以学习一下计算机组成原理之类的课程。如果你有时间去学习,《[深入理解计算机系统](http://book.douban.com/subject/26912767/)》一书值得一读。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天是了解设计的第三部分:看实现。理解一个实现,是以对模型和接口的理解为前提的。
|
||||
|
||||
每个系统的实现都有非常多的细节,我们不可能一上来就把所有的细节吃透。如果想了解一个系统的实现,应该从**软件结构**和**关键技术**两个方面着手。无论是软件结构,还是关键技术,我们都需要带着自己的问题入手,而问题的出发点就是我们对模型和接口的理解。
|
||||
|
||||
了解软件的结构,其实,就是把分层的模型展开,看下一层的模型。一方面,你要知道这个层次给你提供了怎样的模型,另一方面,你要带着自己的问题去了解这些模型为什么要这么设计。
|
||||
|
||||
最后,我借着Kafka的关键技术还给你讲了**软硬结合**的思路,在系统优化之路上寻求突破时,可以增加你选择的道路。不过,实现都是有约束的,比如,Kafka的实现主要是针对机械硬盘做的优化,现在的SSD硬盘越来越多,成本越来越低,这个立意的出发点已经不像以前那样稳固了。
|
||||
|
||||
至此,了解设计的三步我们已经全部走完了。接下来,我们就要开始自己的设计历程了。首先,我们需要掌握一些关于设计的基础知识。下一讲,我们就从最基础的部分入手,我们来谈谈程序设计语言。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**理解实现,带着自己的问题,了解软件的结构和关键的技术。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/29/ef/29c6a18e1e1313ff0e6c7aad3642f3ef.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你来思考一下,在项目上学习的哪些东西对你个人在实现思路上有了一个极大的突破。欢迎在留言区分享你的经历。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
214
极客时间专栏/软件设计之美/巩固篇/30 | 程序库的设计:Moco是如何解决集成问题的?.md
Normal file
214
极客时间专栏/软件设计之美/巩固篇/30 | 程序库的设计:Moco是如何解决集成问题的?.md
Normal file
@@ -0,0 +1,214 @@
|
||||
<audio id="audio" title="30 | 程序库的设计:Moco是如何解决集成问题的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d8/06/d8cc875aeb02c81be064e4c7907b1806.mp3"></audio>
|
||||
|
||||
你好,我是郑晔!
|
||||
|
||||
经过前面内容的讲解,我终于把软件设计的基础知识交付给你了,如果你有一定的经验,相信有很多东西你已经可以借鉴到日常工作中了。
|
||||
|
||||
但是对于一些同学来说,这些知识恐怕还是有些抽象。那在接下来的几讲中,我会给你讲几个例子,让你看看如何在日常的工作中,运用学到的这些知识,巩固一下前面所学。
|
||||
|
||||
我在[第9讲](https://time.geekbang.org/column/article/245878)说过,学习软件设计,可以从写程序库开始。所以,我们的巩固篇就从一个程序库开讲。这是我自己维护的一个开源项目 [Moco](https://github.com/dreamhead/moco),它曾经获得 2013 年的 Oracle Duke 选择奖。
|
||||
|
||||
Moco 是用来做模拟服务器的,你既可以把它当作一个程序库用在自动化测试里,也可以把它单独部署,做一个独立的服务器。我们先来看一个用 Moco 写的测试,感受一下它的简单吧!
|
||||
|
||||
```
|
||||
public void should_return_expected_response() {
|
||||
// 设置模拟服务器的信息
|
||||
// 设置服务器访问的端口
|
||||
HttpServer server = httpServer(12306);
|
||||
// 访问/foo 这个 URI 时,返回 bar
|
||||
server.request(by(uri("/foo"))).response("bar");
|
||||
|
||||
// 开始执行测试
|
||||
running(server, new Runnable() {
|
||||
// 这里用了 Apache HTTP库访问模拟服务器,实际上,可以使用你的真实项目
|
||||
Content content = Request.Get("http://localhost:12306/foo")
|
||||
.execute()
|
||||
.returnContent();
|
||||
|
||||
// 对结果进行断言
|
||||
assertThat(content.asString(), is("bar"));
|
||||
});
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这一讲,我就来说说它的设计过程,让你看看一个程序库是如何诞生以及成长的。
|
||||
|
||||
## 集成的问题
|
||||
|
||||
不知道你有没有发现,阻碍一个人写出一个程序库的,往往是第一步,也就是**要实现一个什么样的程序库**。因为对于很多人来说,能想到的程序库,别人都写了,再造一个轮子意义并不大。
|
||||
|
||||
但是,这种思路往往是站在理解结果的角度。其实,**程序库和所有的应用一样,都是从一个要解决的问题出发。**所以,在日常的繁忙工作中,我们需要偶尔抬头,想想哪些问题正困扰着我们,也许这就是一个程序库或者一个工具的出发点。
|
||||
|
||||
曾经有一个问题困扰了我好久,就是**集成**。还记得在我初入职场时,有一次,我们开发的系统要与第三方厂商的系统进行集成。可是,怎样才能知道我们与第三方集成的效果呢?我们想到的办法就是模拟一个第三方服务。
|
||||
|
||||
于是,作为当时的新人,我就承担起编写这个模拟服务的任务。那个时候还真是年少无知,居然自己写了一个 HTTP 服务器,然后又继续在上面写了应用协议。那时候的我完全没有编写程序库的意识,只是有人要求我返回什么样的应答,我就改代码,返回一个什么应答。
|
||||
|
||||
在我的职业生涯中,集成并不少见,只是后来我的经验多了,这种编写模拟服务的事就交到了别人的手上,我就成了那个让别人改来改去的人。
|
||||
|
||||
2012 年,我加入到一个海外合作的项目中,这个项目也有一个模拟的 HTTP 服务。开发人员根据自己的需要去改动代码,让这个模拟服务返回不同的应答。之后,他们再打出一个包,部署到一个 Web 服务器上。显然,这比我当年一个人维护模拟服务器要进步很多了,至少它不用考虑 HTTP 协议层面的问题了。
|
||||
|
||||
不过,依旧要自己部署模拟服务这一点,让我突然想起当年开发模拟服务时的景象。这么多年过去了,模拟服务却依然如此麻烦,没有得到任何好转,也许我可以做点什么。比起当年做软件开发的懵懂的我,工作了十多年的我,显然已经有了更多的知识储备。
|
||||
|
||||
## 从问题到需求,再到解决方案
|
||||
|
||||
那问题有了,我要怎么解决这个问题呢?我需要先把它变成一个可以下手解决的需求。首先,我要考虑的是,我希望这个模拟服务做成什么样子呢?
|
||||
|
||||
- 它可以支持配置,这样的话,我就不用每次都调整代码了;
|
||||
- 它可以独立部署,因为部署到应用服务器上的方式实在不够轻量级;
|
||||
- 它可以是一个通用的解决方案,因为我已经在多个不同的场景下遇到类似的问题。
|
||||
|
||||
除了这些正常的需求之外,我还有一个额外的小需求,就是希望它**有一个有表达性的 DSL**。因为我当时刚刚翻译完《领域特定语言》,特别想找个机会练练手。
|
||||
|
||||
以我当时的知识水平来看,配置肯定不是问题,这是任何一个程序员都可以做到的。独立部署,应该也可行,虽然当时还不流行嵌入式的 Web 服务器,但我还知道有 Netty 这样的网络编程框架,我稍微做了一点调研就发现,用它实现一个简单的 Web 服务器并不难。
|
||||
|
||||
问题就是,我怎样能把它做成一个通用的方案?
|
||||
|
||||
在设计中,其实最难的部分就在这里。一个特定的问题总有一个快速的解决方案,而要**想做成一个通用方案,它就必须是一个通用的模式。这就需要我们把问题抽丝剥茧,把无关的信息都拿掉,才可能看到最核心的部分。**而进行这种分析的的根基,同样是我们在前面说过的分离关注点。
|
||||
|
||||
我找到的核心问题就是,模拟服务到底是做什么的呢?其实,它就是按照我预期返回相应的应答。对,一方面,我要表达出预期;另一方面,它要给出返回的结果。
|
||||
|
||||
当我想明白这一点之后,一段代码浮现在我的脑海中:
|
||||
|
||||
```
|
||||
server.request("foo").response("bar");
|
||||
|
||||
```
|
||||
|
||||
对,这就是这个模拟服务器最简单的样子。当请求是“foo”的时候,它就给出对应的应答“bar”,这个结构非常适用于 HTTP 这种请求应答的结构。这段代码简直太合我的胃口了,因为它还是一段内部 DSL,声明出这个模拟服务器的行为,我的额外需求也得到了满足。
|
||||
|
||||
如果代码真的可以做成这个样子,那它应该就可以写在单元测试里了。和现在一比,动辄需要启动整个应用,做人工的集成测试,这简直是一个巨大的飞跃。而且,从开发效率上看,这简直就是数量级的提升。
|
||||
|
||||
不过,上面只是给出了设置服务器的样子,如果我们要把它写到单元测试里,还要考虑到如何去启动和关闭服务器。于是,一段单元测试的代码就浮现了出来:
|
||||
|
||||
```
|
||||
public void should_return_expected_response() {
|
||||
HttpServer server = httpServer(12306);
|
||||
server.request("foo").response("bar");
|
||||
running(server, new Runnable() {
|
||||
Content content = Request.Post("http://localhost:12306")
|
||||
.bodyString("foo", ContentType.TEXT_PLAIN)
|
||||
.execute()
|
||||
.returnContent();
|
||||
assertThat(content.asString(), is("foo"));
|
||||
});
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这就是 Moco 的第一个测试了。有了测试,我就该考虑如何让测试通过了。同时,测试帮我锁定了具体的目标,我还知道了可用的技术,剩下的就是把它实现出来了。
|
||||
|
||||
对于程序员而言,实现反而是最简单的。就这样,我花了一个周末的时间,翻着各种文档,让第一个测试通过了。如此一来,Moco 在实现上的技术难度就被突破了。
|
||||
|
||||
## 基础设计的诞生
|
||||
|
||||
接下来,我就要考虑 Moco 可以提供怎样的功能了。Moco 首先是一个 HTTP 的模拟服务器,所以,它需要对各种 HTTP 的元素进行支持。HTTP 的元素有哪些呢?其实,无非就是 HTTP 协议中可以看到的HTTP 协议版本、 URI、HTTP 方法、HTTP 头和HTTP 内容等等这些东西。
|
||||
|
||||
问题来了,如果我们要把 Moco 实现成一个通用的解决方案,我们就需要任意地组合这些元素,我们该如何设计呢?
|
||||
|
||||
你可能已经想到了,在前面我们讲函数式编程的组合性时,已经提到了要设计可以组合的接口。是的,Moco 就是这么做的。下面是一个例子,如果我们请求 /foo 这个 URI,请求的内容是 foo,那就返回一个 bar,我们还要把这个应答的状态码设置成 200。
|
||||
|
||||
```
|
||||
server
|
||||
.request(and(by("foo"), by(uri("/foo"))))
|
||||
.response(and(with(text("bar")), status(200)));
|
||||
|
||||
```
|
||||
|
||||
在这里,传给 request 和 response 的就不再是一个简简单单的文本,而是一个元素的组合。
|
||||
|
||||
所以,传给 request 的,我称之为 RequestMatcher,也就是对请求进行匹配,匹配成功则返回 true,反之返回 false。而传给 response 的,我称之为 ResponseHandler,也就是对应答进行处理,在这里面设置应答中的各种元素。
|
||||
|
||||
这就是 Moco 最核心的两个模型。从 Moco 的第一个版本形成开始,一直没有变过。
|
||||
|
||||
```
|
||||
interface RequestMatcher {
|
||||
boolean match(Request request);
|
||||
}
|
||||
|
||||
interface ResponseHandler {
|
||||
void writeToResponse(Response response);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从这段代码上,你还可以看到用来组合各个元素的and。学过前面函数式编程的内容,想必你也知道了该如何实现它。除了 and,我还提供了 or 和 not 这样的元素,方便你更好地进行表达。
|
||||
|
||||
## 扩展设计
|
||||
|
||||
有了基础设计之后,其实 Moco 已经是一个可用的程序库了。从理论上来说,它已经能够完成HTTP 模拟服务器所有的需求了。事实上,当我拿出了 Moco 的第一个版本,就有同事在实际的项目中用了起来。
|
||||
|
||||
如同所有开源项目一样,只要有人用,就会有人给出反馈,你就需要去解决它。Moco 就这样,不经意间开启了自己的生命周期。
|
||||
|
||||
我在开篇词就说过,软件设计是一门关注长期变化的学问。长期意味着会有需求源源不断地扑面而来。每当有新问题的到来,软件就要去应对这个新的变化,这也是考验软件设计的时候。
|
||||
|
||||
第一个变化就是,有人提出要有一个外部的配置文件。Moco 所要做的调整,就是增加一个配置文件,然后要在配置文件和核心模型之间做一个映射。这个变化其实在核心模型上没有任何的改变。学了前面的课程,你也知道,这就相当于给 Moco 增加了一种外部 DSL,只不过,这个 DSL 的语法我采用了 JSON。
|
||||
|
||||
正是因为 JSON 配置文件的出现,Moco 有了一个全新的用法,就是把 Moco 当作了一个独立的模拟服务器。后来的很多人其实更熟悉的反而是这种用法,而把 Moco 用在单元测试的这种场景比例就要低一些。也是因为这个独立模拟服务器的用法,Moco 也不再局限于 Java,不同的程序设计语言编写的应用都可以与之进行交互,Moco 的使用范围得到了扩展。
|
||||
|
||||
随后,还有人提出了更多功能性上的需求,让 Moco 的能力也得到了极大的提升:
|
||||
|
||||
- 有些被模拟的服务不稳定,Moco 支持了一个 proxy 功能,将请求转发给被模拟服务。如果这个服务失效了,就使用本地缓存的信息;
|
||||
- 有些应答里的字段是根据请求的内容来的,Moco 支持了 template 功能,让使用者自己决定怎样使用哪个信息;
|
||||
- 有时还要对请求的内容,进行各种匹配。比如,URI 在同一个根目录下,就进行一样的处理,Moco 支持了 match 功能,让使用者自己可以写正则表达式,对请求进行匹配;
|
||||
- 有人为了方便管理,希望把所有的应答内容放到一个目录下,Moco 支持了 mount 功能,把一个目录挂载在一个 URI ;
|
||||
- 现在的 REST 开发是主流,Moco 支持了 REST 能力,能够定义资源,更方便地将同一资源的内容定义在一起;
|
||||
- ……
|
||||
|
||||
所有这些内容都是在基础的模型上扩展出来的,基本上都不需要去改动基础模型。不过,有一个功能的拓展影响了基础模型,就是 template。因为它需要根据请求的内容来决定应答的内容,这让原本各自独立的 request 和 response 开始有了关联。
|
||||
|
||||
为了适应 template 的需求,我在 ResponseHandler 的接口上增加了 Request,把请求信息带了进来:
|
||||
|
||||
```
|
||||
class SessionContext {
|
||||
private final Request request;
|
||||
private final Response response;
|
||||
...
|
||||
}
|
||||
|
||||
interface ResponseHandler {
|
||||
void writeToResponse(SessionContext context);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
也是由于这个调整,让 Moco 后来有了可以支持录制回放的能力:
|
||||
|
||||
```
|
||||
server
|
||||
.request(by(uri("/record")))
|
||||
.response(record(group("foo")));
|
||||
|
||||
server
|
||||
.request(by(uri("/replay")))
|
||||
.response(replay(group("foo")));
|
||||
|
||||
```
|
||||
|
||||
在这个设置中,我们发给 /record 这个地址的内容就可以记录下来,然后,访问 /replay 这个地址的时候,我们就可以得到刚才记录的内容。由此,Moco 由原来只提供静态设置的模拟服务器,变成了一个能够动态配置的模拟服务器,能力得到了进一步提升。
|
||||
|
||||
至此,你已经看到了 Moco 是怎么一点一点长大的。与 2012 年刚刚起步时相比,今天的 Moco 的能力已经强大了许多,但它的内核依然很小,代码量也不大。如果你希望研究一个有设计的代码,不妨从 Moco 入手,这个专栏讲到的不少内容都可以在 Moco 中看到影子。
|
||||
|
||||
**Moco 就是根据请求给出应答,只要理解了这么一个简单的逻辑,你就完全可以理解 Moco 在做的事情**,其他的东西都是在这个基础上生长出来的。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我给你讲了 Moco 的设计过程。一个好的软件也好,程序库也罢,都是从实际的问题出发的。阻碍一个程序员写出好的程序库的原因,往往是没有找到一个好问题去解决。**程序员不能只当一个问题的解决者,还应该经常抬头看路,做一个问题的发现者。**
|
||||
|
||||
有了问题之后,**需要把问题拆解成可以下手解决的需求**,让自己有一个更明确的目标。然后,我们才是根据这个需求找到一个适当的解决方案。**一个通用的解决方案需要不断地抽丝剥茧,抛开无关的部分,找到核心的部分**,这同样根植于分离关注点。
|
||||
|
||||
如果最后的解决方案是一个程序库,那么,我们用测试把程序库要表达的内容写出来,就是最直接的。有了测试,就锁定了目标,剩下的就是让测试通过。
|
||||
|
||||
一个好的设计,应该找到一个最小的核心模型,所有其他的内容都是在这个核心模型上生长出来的,越小的模型越容易理解,相对地,也越容易保持稳定。
|
||||
|
||||
这一讲,我讲了一个程序库的设计。下一讲,我们再来看看如何设计一个应用。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**注意发现身边的小问题,用一个程序库或工具解决它。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ce/8f/ce398c521cbc2793c0a8522b468f7a8f.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你抬头看一下路,看看你在开发的过程中,发现过哪些阻碍研发过程的问题呢?欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
136
极客时间专栏/软件设计之美/巩固篇/31 | 应用的设计:如何设计一个数据采集平台?.md
Normal file
136
极客时间专栏/软件设计之美/巩固篇/31 | 应用的设计:如何设计一个数据采集平台?.md
Normal file
@@ -0,0 +1,136 @@
|
||||
<audio id="audio" title="31 | 应用的设计:如何设计一个数据采集平台?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3f/bc/3fee6fdf9f72eb00275f8df78b0fe2bc.mp3"></audio>
|
||||
|
||||
你好,我是郑晔!
|
||||
|
||||
上一讲,我给你讲了 Moco 的设计,这是一个程序库级别的设计。除了开发一个程序库,在日常工作中,还有一种工作是程序员们非常熟悉的,就是开发一个应用。与之对应的设计就是应用设计。
|
||||
|
||||
也许你会说,应用设计不就是按照之前讲的 DDD 的方法,先通过事件风暴建立通用语言,然后,再找出子域和划分出限界上下文,最后,再按照模板找出各种对象吗?
|
||||
|
||||
是的,设计的基本过程确实是这样的。不过,DDD 的方法只能保证我们设计出一个可以接受的方案。**如果你想有一个更有扩展性的设计方案,就需要多花一点时间去构建一个更好的模型**。
|
||||
|
||||
这一讲,我就以一个金融指数系统为例,给你讲一下如何更好地设计一个应用。
|
||||
|
||||
## 一个指数系统
|
||||
|
||||
在金融系统中,有一个概念叫指数,用来表示金融市场的活动,比如有股票指数、期货指数等等。比较著名的指数有道琼斯指数、标准普尔指数。这个世界上的指数多得数不胜数,每个金融机构都会有自己的指数,而且,它们还会不断推出新的指数。
|
||||
|
||||
那指数是怎么算出来的呢?如果以股票为例,就是获取一堆股票的价格,然后根据一个公式算出一个结果。比如,我们有一个公式,A**0.2+B**0.3+C*0.5,我们把公式里的数据部分称为指标,也就是公式中的 A、B、C,这个公式表示这三种指标分别占比20%、30%和50%。
|
||||
|
||||
这个公式就是三个不同的指标按照不同的占比进行求和。假设A指标的价格是5元、B指标是2元、C指标是1元,按照公式可以算出5**20%+2**30%+1*50%=2.1,这个算出来的2.1就是指数的值。
|
||||
|
||||
价格是实时变化的,而公式是固定的。指数在问世之初,我们需要不断调整这个公式里面各个指标的参数,以便能更好地反映市场的变化。问题来了,我们要怎样设计一个这样的指数系统呢?
|
||||
|
||||
一个不假思索的设计就是,针对一个具体的指数进行开发。我们就要把指数计算中涉及的各种数据实时取过来,然后根据设置的公式去做计算。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/37/28/37dyy618b2c70cc30b32cc93ff0f3d28.jpg" alt="">
|
||||
|
||||
如果我们只有一个指数,这么做也许是可以接受的。但我们要开发的是一个指数系统,这意味着我们会有很多个指数。两个不同的指数可能会用到同样的指标,如果我们按照开发一个指数的方法,不同的指标数据要获取好多遍,从某种意义上来说,这就是一种重复。
|
||||
|
||||
所以,一个好的做法就是,**先做职责划分,把不同职责的部分划分出来**。正如我在这个专栏中一直说的,我们不能把各种不同的关注点混在一起,这是很多系统出问题的根源所在。
|
||||
|
||||
那从上面的需求描述中,我们可以把指数的计算过程分成两个部分:
|
||||
|
||||
- 一部分是需要实时获取的数据,比如,前面说到的各种价格;
|
||||
- 一部分是根据公式进行计算出最终的结果,也就是指数最终的值。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/73/8b/73224c9763993abc6665d6980d75418b.jpg" alt="">
|
||||
|
||||
这种拆分解决了前面设计中存在的问题,使得指标数据获取和公式计算分开了,同样的数据就可以用在多个公式中,数据的获取和公式的计算就不用同步进行了。
|
||||
|
||||
而且,把计算过程拆成了两个部分之后,我们就可以针对这两个部分,分别进行细化了:
|
||||
|
||||
- 对于指标数据获取的部分,我们要解决数据获取可能出现的问题,比如,不同的数据来源如何管理、不同数据源的数据格式是怎样的、如果数据源不可用,我们该怎么办等等;
|
||||
- 对于公式计算的部分,我们关心的问题则是计算要用到哪些指标、每个指标当前可用的值是多少、如果公式中有不可用的指标数据时,系统该怎么处理等等。
|
||||
|
||||
既然我们把系统拆分成了两个部分,还有一个问题就是,如何把这两个部分连接起来。其实,指标数据获取部分的输出,就是公式计算部分的输入,那指标数据获取部分的输出是什么呢?
|
||||
|
||||
我们在前面分析过,指标数据获取要实时获取,无论采用轮询的方式,还是采用数据上报的方式。这种数据的特点就是,它有一个值,还有一个时间。正是因为这种特点,数据会形成一个序列,所以,我们将这种数据称为**时序数据**。
|
||||
|
||||
指标数据获取部分的输出其实就是这种时序数据,只不过,针对每一种指标都会产生一个时序数据序列,而这些不同的时序数据也正是公式计算部分的输入。
|
||||
|
||||
既然是时序数据,也就有了时间的信息,我们的公式计算部分就可以根据时序数据的时间做一些处理了。比如,怎么判定一个指标不可用呢?如果判断一个指标最新的数据与当前时间的差值过大,我们就可以判断在这次计算中,该指标的数据不可用。
|
||||
|
||||
有了对于时序数据的认识,结合前面所说的数据获取和公式计算不再是同步进行的这一点,指标数据获取和公式计算两个部分就完全解耦了,二者之间可以只通过时序数据进行交互。
|
||||
|
||||
## 更上一层楼
|
||||
|
||||
现在,我们已经把数据获取和公式计算分成了两个部分,这应该是常规设计中都可以想到的。很多设计者做设计也可能就此打住,开始动手写代码了。但是,有时候我们还可以更进一步。
|
||||
|
||||
我们可以继续分析一下,看看还有什么可以进一步改进的地方。
|
||||
|
||||
我先问你一个问题,公式计算你打算怎么做?你可能会想,这难道不是业务人员给我什么样的公式,我就用写代码的方式把它实现出来吗?
|
||||
|
||||
这么做肯定是可以把公式实现出来,这一点是毋庸置疑的。但是,正如我前面所说,指数往往要经过一个调整的过程。因为业务人员自己也常常不确定设置的参数是否合理。
|
||||
|
||||
用写代码的方式实现公式,也就意味着,每次业务人员要调整一个参数,你都需要去改代码。在可以预见的未来,你的工作基本上都会与调整参数相关,而这件事一点技术含量都没有。
|
||||
|
||||
对我们程序员而言,**一件事是不是有技术含量往往不取决于事情本身,而取决于我们怎么做它**。换言之,问题是一样的,但不同的解决方案却会带来不同的效果。**业务人员提出的是问题,解决方案是由技术人员给出的,千万别混淆问题和解决方案**。
|
||||
|
||||
当你可以预见一件事将来会很繁琐、会不断重复,而且会持续相当长的时间,这时候我们就需要重新审视我们的解决方案了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1e/7a/1eed9cb9d3595170473316b07fe3677a.jpg" alt="">
|
||||
|
||||
最原始的解决方案是没有自动化的方案,对于任何一个系统而言,我们最好要知道没有自动化的时候,这个问题是如何解决的。这和我们前面说到的,理解一个模型来龙去脉的思路是一致的。
|
||||
|
||||
当然,我们现在大多数情况下接触的都是一个已经自动化的方案,但方案之间还是存在着差别。在自动化方案中,最原始的做法是开发人员自己修改代码的方案,这种做法会导致开发人员大量的时间投入,属于严重消耗时间的做法。
|
||||
|
||||
其次是开发人员修改配置,虽然这种做法只修改配置,但通常还会涉及到重新打包发布的过程,只能说它比修改代码要强一点。
|
||||
|
||||
比较好的做法是,业务人员修改配置,开发人员完全不参与其中。一方面,业务人员自己最知道自己想要什么;另一方面,没有开发人员的参与,反馈周期就缩短了。
|
||||
|
||||
虽然这几种方法在业务的角度是越来越好的,但在设计上,却是要求越来越高的。比起没有自动化的方案,自动化的方案需要投入一些力量去做设计。相比于修改代码,修改配置就意味着要留下扩展的接口。而能够做到让业务人员而不是开发人员修改配置,配置的接口就应该是一个业务的接口,比如,要有一个配置界面。
|
||||
|
||||
如果我们用这几个标准评估一下我们现在的方案,显然,我们现在的方案还处于开发人员修改代码的阶段,这说明我们还有向上努力的空间。不过,我们给出的只是一个衡量标准,并不意味着这个台阶要一步一步上,因为我们可以一步就提升到最高标准,一步到位给业务人员提供一个配置的接口。
|
||||
|
||||
问题来了,我们要给业务人员提供一个配置接口,它应该长啥样呢?
|
||||
|
||||
我们知道,这个指数设计的关键就是这个指数的公式。在前面的那个例子里面,它的公式是 A**0.2+B**0.3+C*0.5。如果我们能够让业务人员在配置接口上这样配置,问题就解决了。
|
||||
|
||||
在这里,A、B、C 分别代表一个指标,也就是说,我们只要能够让业务人员指定指标以及指定计算公式,剩下的问题就简单了,就是根据公式计算出相应的结果就好了。
|
||||
|
||||
说起来很简单,但怎么把A**0.2+B**0.3+C*0.5变成一个可执行的公式,对一些程序员来说,还是有一定难度的。解析文本执行这件事是编译原理的基本功,不过只要你能理解这里需要一点编译原理的知识就很可以了,如果欠缺知识,就去学习相关知识好了。
|
||||
|
||||
实际上,公式的解析是编译原理入门的知识,难度系数比设计一门程序设计语言要小多了。而且,现在有编译器前端的工具,比如,Java 世界的 [Antlr](https://www.antlr.org/),它可以直接生成对应的语法树结构,我们只要负责去编写对应的执行部分就好了。
|
||||
|
||||
也许你发现了,我们实际上已经构建出了一门 DSL,一门属于指数计算这个特定领域的外部 DSL。前面讲 DSL 的时候,我们就说过,把设计做到极致就可以构建出一门 DSL。在这里,我们也看到了,了解 DSL,实际上也给我们增添了一个可以前进的方向。
|
||||
|
||||
把公式构建出来之后,我们仔细分析,还会有一个有趣的发现。你可以想一下,公式计算的结果是什么?因为我们说,它是在利用多个指标的时序数据做计算,所以它得到的结果,其实也是一个时序数据。
|
||||
|
||||
这样,我们发现了另一个有趣的事,公式计算的得到其实也是一个指标。如此一来,公式计算的结果也可以作为另外一个公式的输入,形成更为复杂的复合公式。显然,由于复合公式的出现,这个系统的处理能力又上了一个台阶。
|
||||
|
||||
不知道你是否想起了什么,没错,它和设计模式中的组合模式如出一辙。你看,我们学习到的基础知识在这里都用上了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/16/70/16fbf23d1663442f46c54c275dc51070.jpg" alt="">
|
||||
|
||||
虽然我们这里讨论的是一个金融中会用到的指数系统,但当我们把模型经过一番整理之后,你会发现它不仅仅局限于指数系统中。比如,如果你在开发的是一个物联网系统,上报上来的数据,往往也要经过一些计算和聚合,那这个模型显然也是适用的。
|
||||
|
||||
再比如,你开发了一个 APM(Application Performance Management,应用性能管理)类的应用,采集上来的数据往往也要经过一番计算再展示出来,这个模型同样适用。
|
||||
|
||||
所以,当我们可以构建出一个好的模型时,它本身就有着更大的适用范围。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我通过一个指数系统的应用给你讲了一个应用的设计过程。在这里,你知道了想要做好设计,目标就不能局限于只把功能实现出来,而是我们要去不断发现可能存在的各种问题。
|
||||
|
||||
简言之,只要你认为会出现重复,它就是一个值得我们去思考解决的问题。
|
||||
|
||||
我还给你讲了如何衡量应用的设计水平,就是看它符合下面哪个标准:
|
||||
|
||||
- 没有自动化;
|
||||
- 开发员修改代码实现;
|
||||
- 开发员修改配置实现;
|
||||
- 业务员修改配置实现。
|
||||
|
||||
程序员常常给人写代码,实现自动化,却常常忽略了自己工作中可以自动化的部分。作为[一个懒惰的程序员](https://time.geekbang.org/column/article/86210),我们需要发现日常工作中繁琐的地方,让自己从低水平的重复中解脱出来。**一件事是不是有技术含量往往不取决于事情本身,而取决于我们怎么做它**。
|
||||
|
||||
这两讲我们讲的都是怎么去设计一个新东西,但在实际工作中,有时候,我们还会面对一个既有的系统,这样的系统该如何改进呢?我们下一讲来谈。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**一个更好的设计从拒绝低水平重复开始,把工作做成有技术含量的事情**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a1/5f/a1493ea6b7afba5e9827c4caef5fb45f.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你回想一下,参照今天的内容,在你现在的工作中,有哪些可以从设计上改进的内容呢?欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
111
极客时间专栏/软件设计之美/巩固篇/32 | 应用的改进:如何改进我们的软件设计?.md
Normal file
111
极客时间专栏/软件设计之美/巩固篇/32 | 应用的改进:如何改进我们的软件设计?.md
Normal file
@@ -0,0 +1,111 @@
|
||||
<audio id="audio" title="32 | 应用的改进:如何改进我们的软件设计?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0d/cd/0d2289759462ae8a78db371d43ddf3cd.mp3"></audio>
|
||||
|
||||
你好,我是郑晔!
|
||||
|
||||
前面两讲,我们分别讲了如何从头开始设计一个程序库和应用。但是在实际工作中,有很多时候,我们的工作并不是从头设计一个应用,而是改进一个既有项目的代码。既有项目的代码意味着什么呢?意味着各种问题。
|
||||
|
||||
我们一直在说,软件设计是一门关注长期变化的学问。越是在商业上成功的软件,存续的时间往往越长。存续的时间越长,往往就会有更多的麻烦。
|
||||
|
||||
我们先不说有些项目一开始就没有设计,一路混乱向前。即便是一个最初有着还算不错设计的项目,随着时间的积累、人员的更替、把前人的做法当作惯例等等事情的发生,项目的设计就会逐渐变得不堪重负。
|
||||
|
||||
我在前面的课程中也举过一些例子,虽然每人只改了一点点,最后却是积重难返。这就是一个项目缺乏设计守护的结果。好的守护可以使设计更持久,遗憾的是,大多数项目做得并不好。
|
||||
|
||||
除了上面这几点,还有一点就是,新的技术和框架会不断涌现,旧代码往往是不能有效使用这些新东西的。比如,Java 世界今天开发的主流是 Spring Boot,然而十年前,它还不存在。
|
||||
|
||||
虽然那时候已经有了 Spring,但那时候的主流开发方式还是打出一个 WAR 包,再部署到 Tomcat 上。所以,新出现的很多技术会提供更简单的做法,替换掉旧代码中笨拙的部分。
|
||||
|
||||
所以,到底怎么才能让自己的项目在设计上不断地演进,跟上时代发展的步伐,不断焕发新的活力呢?对于任何一个开发团队而言,这都是一个值得考虑的问题。
|
||||
|
||||
那么,这一讲,我们就来谈谈如何改进既有项目的设计。
|
||||
|
||||
## 从目标开始
|
||||
|
||||
在我的另外一个专栏《[10x 程序员工作法](https://time.geekbang.org/column/intro/148)》中,我讲过一个类似的主题,[如何面对遗留系统](https://time.geekbang.org/column/article/90231)。那里面的主要观点就是我们应该找到一个目标,然后小步改进,逐步向这个目标接近。
|
||||
|
||||
在那一讲中,我讲的重点主要在于改进的过程,而在这里,我打算从设计的角度再来审视一下这个问题。既然都是我的专栏,所以二者在解决问题上的思路一致的,都要先从找到目标开始。
|
||||
|
||||
大多数团队一说起改进,一般想的都是功能性方面的目标。比如,我原来的系统能支持100万的用户,现在要支持 1000 万的用户。这种改进固然是我们需要考虑的,甚至是迫不得已的。
|
||||
|
||||
但这种改进解决的是实现,因为[不同量级的系统根本就不是一个系统](https://time.geekbang.org/column/article/88764),承载的用户量发生了变化,其实是一种需求的变化。但是,这种改变并不会让你的设计变好。
|
||||
|
||||
既然我们已经决定要改进了,就应该好好地把设计改进一下,而不只是把功能重新实现一遍。因为功能实现是你无论如何都必须做的,都是为别人做的,而设计的改进才是你为了自己做的,因为在未来的一段日子里维护这些代码的人是你。如果我们要做设计的改进,设定好改进设计的目标就显得尤为重要。
|
||||
|
||||
那设计改进的目标应该是什么呢?你可以先问一下自己这样一个问题,**如果有机会从头设计这个系统,它应该是什么样子呢?**
|
||||
|
||||
这个问题可能会让很多程序员一下子愣住,因为他们每天都陷于忙碌的工作中,做的工作都是各种微调、各种打补丁,眼中只有一个具体微观的世界,却不曾有一个整体的思考。
|
||||
|
||||
是的,从头来过,它应该是什么样子。这是一个简单的问题,也是一个困难的问题。简单在于,它的字面意思很好理解。困难却在于,很多人一听到这个问题,直觉就要回避:
|
||||
|
||||
- 我的系统已经这么沉重了,怎么可能重来?
|
||||
- 我有那么多的需求要做,哪有时间重做一遍?
|
||||
- 我的系统那么复杂,重做一遍,出了问题谁来负责?
|
||||
|
||||
我承认,这些都是很现实的问题。但是,我的意思并不是让你真的一上来就动手,从零开始把系统重写一遍。这里的重点在于,**我们要找到改进的目标,也就是一个系统本来应有的面貌**。
|
||||
|
||||
这就是为什么我们前面要学习那么多设计一个系统的知识,否则,我们没有一个设计知识的沉淀,所谓的“重新设计”,弄不好我们就会回到原来的老路上去。
|
||||
|
||||
这时候,或许你突然想到一个严重的问题了,开启一次系统改进,如何处理人们的共识好像也是一件困难的事情,但这根本不是一个设计问题。想要真正地开启一次改进,就要让人们意识到,**设计一个系统和实施一次系统改进是两个完全不同的问题,可以分阶段地进行**。
|
||||
|
||||
我们只有把系统设计成它应有的样子,才算是确定了我们的目标。有了目标之后,接下来,我们才能制定改进路径,而把现有的系统一点一点从旧有的样子改动成新的样子,这是实施的过程。
|
||||
|
||||
好!我假设你已经搞定了周边人的共识,准备着手进行改进了。
|
||||
|
||||
## 改进的过程
|
||||
|
||||
现在你要重新设计这个系统了,或许你会想,这有什么难的?不就是照着原来的需求,重新来一遍吗?如果你真的还有原来的需求,能让你照着设计一遍。我真的只能说,你太幸运了。
|
||||
|
||||
在大部分真实的项目中,一个既有系统的情况是,没有人能够说出它到底承载了哪些需求。当然,主干部分是人人都知道的,但主干常常是九牛一毛,而更多的细节隐藏在代码中了。
|
||||
|
||||
一个长期存在的系统,开发者可能已经换了好几拨。了解当年那些需求的人可能早已不知所踪了,导致的结果就是,每一个工作在这个项目上的人都是只见树木不见森林。
|
||||
|
||||
在这种情况下,我们该怎么办呢?我给你**一个入手的起点,就是接口**。
|
||||
|
||||
在[第4讲](https://time.geekbang.org/column/article/241114)学习怎样理解一个系统的设计时,我们曾经说过,想要理解一个系统的设计,可以按照模型、接口和实现的这个框架去理解,其中,接口是模型能力的体现。
|
||||
|
||||
对于一个系统而言,接口也是使系统内部状态发生改变的原因,系统中的所有变化必然都是从某个接口开始的。既然没有人能够清楚地说明系统的现状,那么,我们从接口入手,了解系统的现状是一个非常现实的做法。毕竟,接口是不会骗人的。
|
||||
|
||||
不过,这里的接口不仅包括我们传统意义上的接口,也包括各种后台服务。前面我们讲了很多构建模型的内容,有了这个基础,我们再看后台服务,就会发现,后台服务只不过是按照某种规则触发模型的接口。比如,定时服务,就是定时地去调用模型的接口。所以,我们也要把这种接口梳理出来。
|
||||
|
||||
有了对于这些接口的了解,我们就对这个系统呈现哪些能力有一个认识了,就相当于获得了一份需求描述。基于这个认识, 我们来构建我们新的设计。
|
||||
|
||||
接下来,我们就要重新设计了,**这个改进设计的难点就是不要回到老路上**。我们需要按照一个正常设计的思路去走,该分离关注点的分离关注点,该重新组合的要重新组合。
|
||||
|
||||
之所以我要提示这一点,就是因为思维的惯性实在是太大了。比如说,在原有的系统内有一个叫订单的概念,我们就会习惯性地使用订单,而不是把商品订单、支付订单等概念分开。
|
||||
|
||||
一般而言,既有项目的设计有一个很大的问题就是各种信息混在一起,而能够把不同的信息拆分开来,对于设计而言,就是一个巨大的进步。
|
||||
|
||||
做好了新的设计,也就为我们后续的行动找到了新的方向。接下来,我们要做的是,对比新旧设计,找到一条改进路径。
|
||||
|
||||
**永远不要指望一个真实的项目停下来,一步到位地进行改进。**我们能够做的,唯有小心翼翼,一步一步向着目标前进。
|
||||
|
||||
对于不同的项目,选择的路径可能是不同的,有人会选择关键路径上的关键模块进行改进,也有人会选择影响较小的模块先进行探索,无论是哪种方案都是可以的。一个关键点就在于,动作要小。
|
||||
|
||||
学习过我的两个专栏的同学可能已经充分理解了我对小步前行的喜爱了。任何一个大动作,往往都意味着很长时间无法完成。在这个过程中,所有人都会提心吊胆。如果不能看到成果,很多人的信心都会随时间流失。所以,**在软件设计的改进过程中,积小胜为大胜才是一个合理的选项**。
|
||||
|
||||
还有一个关键点,要让所有相关利益人有一个共识。我又一次说到了共识,软件开发虽然是一个技术活,但归根结底还是一项团队活动,是一项人的活动。
|
||||
|
||||
既然涉及到诸多参与者,就一定要让大家形成一个共识。所以,系统改进,尤其是一个规模比较大的系统改进,一定要让所有人有共识。无论是开会也好,宣讲也罢,让大家对于改进的原因和改进的计划有个共同的预期是至关重要的。
|
||||
|
||||
更加具体的改进过程,我在《10x 程序员工作法》中有更细节的讨论,有兴趣的话,可以去参考一下。
|
||||
|
||||
虽然我在这里讲的是一个系统的改进过程,其实,同样的思路也可以运用在更小的模块中。只不过,更小模块意味着更少的接口、更低的复杂度以及更少的相关利益人。事实上,我反而鼓励你从小模块入手,一步到位去改进整个系统,难度系数是更大的,而小模块可以帮助你积累更多改进的经验,无论是设计,还是与人打交道。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我给你讲了如何改进一个既有软件的设计。一个软件放在时间长河中会有很多东西发生改变,即便是当初还算不错的设计,随着时间的累积,也可能积重难返。
|
||||
|
||||
改进一个软件的设计,首先,要确定改进的目标。改进的目标就是,重新设计这个软件,它应该设计成什么样子,让设计还原到它应有的本来面貌。寻找改进的起点,一部分可以从需求入手,还有一部分要从梳理接口入手。
|
||||
|
||||
设计改进的难点在于不要回到老路上,要做正常的设计,尤其是要把分解做好。
|
||||
|
||||
有了改进目标之后,接下来就是要找到一条改进路径,选择怎样的路径都是有道理的,但有两个关键点是非常重要的,一个是每步改进的动作要小;一个是要让相关利益人达成共识。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**改进既有设计,从做一个正常的设计开始,小步向前**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9d/2f/9df0d3f24dcbebafc3fce6d26628eb2f.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你回想一下,你的系统在设计上存在着哪些问题,你打算怎么改进它呢?欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
113
极客时间专栏/软件设计之美/开篇词/开篇词 | 软件设计,应对需求规模的“算法”.md
Normal file
113
极客时间专栏/软件设计之美/开篇词/开篇词 | 软件设计,应对需求规模的“算法”.md
Normal file
@@ -0,0 +1,113 @@
|
||||
<audio id="audio" title="开篇词 | 软件设计,应对需求规模的“算法”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f3/be/f3193e9820d589205f23a0520af0f9be.mp3"></audio>
|
||||
|
||||
你好,我是郑晔!
|
||||
|
||||
作为一个能把基本功能实现出来的程序员,偶尔仰望天空时,你是否这样问过自己,“我写过的代码还有没有更好的写法呢?如果有一天,我能够把它重写一遍,我该怎么做呢?”
|
||||
|
||||
这就是我当年问过自己的问题,因为我对自己的代码不满意:
|
||||
|
||||
- 我厌倦了,把各种代码堆砌在一起,然后,在出现Bug时,犹如“大家来找茬”一样在其中定位问题;
|
||||
- 我厌倦了,仅仅为了一个小需求,要在无数的地方小心翼翼地做着各种微调,还被产品经理嫌弃改得慢;
|
||||
- 我厌倦了,自己辛辛苦苦写好的代码,被别人在其他地方不经意地修改,给弄崩溃了;
|
||||
- ……
|
||||
|
||||
我四处寻找答案,直到后来,我找到了一个东西,叫做“软件设计”。在如饥似渴地学习了软件设计之后,我对做软件这件事有了全新的认识:
|
||||
|
||||
- 我知道了,写软件不仅要追求如何实现功能,还要考虑未来的维护和扩展;
|
||||
- 我知道了,代码不应该毫无目的地堆在那里,而是要考虑如何组织更为恰当;
|
||||
- 我知道了,原来后期遇到很多问题,只是因为前期缺乏设计而留下了隐患;
|
||||
- ……
|
||||
|
||||
如果你也曾有同样的迷茫,想破茧成蝶,欢迎你与我一起探索“软件设计”!
|
||||
|
||||
## 关注长期变化的软件设计
|
||||
|
||||
>
|
||||
设计是为了让软件在长期更容易适应变化。
|
||||
|
||||
|
||||
>
|
||||
Design is there to enable you to keep changing the software easily in the long term.
|
||||
|
||||
|
||||
>
|
||||
—— Kent Beck
|
||||
|
||||
|
||||
软件设计,是一门关注长期变化的学问。它并不是程序员的入门第一课,因为初窥编程门径的程序员首先追求的是把一个功能实现出来,他无法看到一个软件长期的变化。
|
||||
|
||||
或许未曾学过软件设计,但这并不妨碍你凭借一股蛮力把软件写出来。但如果你在做的是一个有生命力的软件,长期意味着什么呢?意味着会有源源不断的需求扑面而来。面对一拨又一拨的需求,你该如何应对呢?
|
||||
|
||||
你可以想象一下,小米加步枪的解决方案,在敌人不多的时候,你还可以应付得游刃有余,但当敌人已经漫山遍野时,你需要的是大规模杀伤性武器。而软件设计就是这个大规模杀伤性武器。
|
||||
|
||||
用程序员们更熟悉的排序算法为例,快速排序的平均复杂度是O(nlogn),而插入排序是O(n^2)。所以,一般我们说快速排序比插入排序有优势。但是,这种优势只有在一定规模下才能体现出来。当数据规模很小的时候,二者差别并不明显,更有甚者,插入排序在某些情况下表现得会更好。但当数据规模很大时,快速排序的优势就非常明显了。对比两个算法的优劣,关键在于数据规模。
|
||||
|
||||
所以你会发现,算法和软件设计其实是一样的,二者对抗的都是规模问题,只不过,**算法对抗的是数据的规模,而软件设计对抗的是需求的规模**。
|
||||
|
||||
你现在应该理解了,为什么软件设计是一门关注长期的学问了,因为只有长期的积累,需求才会累积,规模问题才会凸显出来。**软件设计,实际上就是应对需求的“算法”**。
|
||||
|
||||
## 如何学习软件设计?
|
||||
|
||||
软件设计的相关知识有很多,你可能听说过一些,比如,设计模式、领域驱动设计等等。但是,分别学习这些知识时,总有一些令人困惑的地方。比方说,学了那么多设计模式,你能用上的却没有几个;领域驱动设计中的概念那么多,你都不知道该从哪学起。
|
||||
|
||||
你的困惑我也有过,我花了很长时间才知道,我们困惑的,并不是这些知识本身,而是在于缺乏一个整体结构将它们贯穿起来。比如,多态,这个词在你看来只是一个普通的语法规则,但只有你懂得,它是把变的部分和不变的部分隔离开来,你才能理解开放封闭、接口隔离和依赖倒置这些设计原则的价值所在,理解了这些原则,才能知道某些设计模式只是这些原则在具体场景下的应用。
|
||||
|
||||
也就是说,**软件设计学习的难度,不在于一招一式,而在于<strong><strong>融会贯通**</strong>。</strong>
|
||||
|
||||
所以,在这个课程中,我会尝试将软件设计的相关知识贯通起来,帮你建立起对软件设计的整体认知。
|
||||
|
||||
那具体要从哪里入手呢?其实对于“软件设计”,我们可以将其划分为两个维度:**“了解现有软件的设计”和“自己设计一个软件”。**
|
||||
|
||||
了解现有软件的设计,是能够在这个软件上继续添砖加瓦的前提。事实上,无论是初入职场,还是加入一个新公司,在工作初期,我们能做的往往也只是添砖加瓦,这时候如果能快速了解现有软件的设计,就可以尽快投入工作中去。此外,当你想从一个开源项目上汲取养分时,了解其背后的设计,也是一种不可或缺的能力。
|
||||
|
||||
大多数人在理解一个软件时,总会出现一个问题,就是眉毛胡子一把抓,直奔代码细节而去。这样不仅增加了我们的时间成本,还会迷失在细节之中,只见树木不见森林。所以在这个课程中,我会教你一个快速了解现有软件设计的方法,那就是抓住这个软件最核心的三个部分:**模型、接口和实现**。
|
||||
|
||||
同时我会以一些开源项目为案例,教你如何用这个方法去解读它们的设计,比如:
|
||||
|
||||
- 我们怎样理解Spring DI容器模型,它的模型怎样有效解决了其面对的问题;
|
||||
- 如何理解Ruby on Rails的接口,我们可以从其接口设计中借鉴哪些内容;
|
||||
- Kafka的实现有哪些独特之处,实现的诸多细节中,我们应该关注哪些内容。
|
||||
|
||||
通过对这些案例的解读,你会切实地感受到融会贯通的好处,真正做到快速了解一个软件的设计。
|
||||
|
||||
慢慢地,当你在业务和技能上有了一定的积累,你将有机会做属于自己的设计。你负责的工作内容也将会从一个小功能到一个完整的小业务,从到一个模块到一个系统。随着你的能力不断提升,你负责的内容会逐渐增多,复杂度逐步升级,对你设计能力的要求也会随之攀升。
|
||||
|
||||
这时,你就需要掌握一些软件设计的基础知识。我会把软件设计中最重要的部分交付给你,包括:
|
||||
|
||||
- 程序设计语言;
|
||||
- 编程范式;
|
||||
- 设计原则;
|
||||
- 设计模式;
|
||||
- 设计方法。
|
||||
|
||||
**程序设计语言**,是软件设计落地的基础。任何设计都依赖程序设计语言来实现。但任何语言也都有自己的局限,**我将带领你横跨语言学语言,让你不再局限于某一种语言,<strong><strong>而是**</strong>择其善者而从之,更好地落地你的设计。</strong>
|
||||
|
||||
**编程范式**,是代码编写的风格,决定着你在设计时可以用到哪些元素:是模块、是对象,还是函数。在不同层次的设计中,选择不同的编程范式已经成为今天开发的主流。在这个主题下,我选择了几个最主流的编程范式,包括结构化编程、面向对象和函数式编程,帮你建立起软件设计的根基。
|
||||
|
||||
**设计原则**,是你在进入到具体设计的层面时,可以用来评判自己工作结果的一个衡量标准。我会给你介绍面向对象的主流设计原则:SOLID原则。一来面向对象是当今的主流开发方式,二来SOLID原则也是比较成体系的设计原则,它本身也在不断发展。
|
||||
|
||||
**设计模式**,是设计原则在具体场景下的应用。不过,这个话题展开之后,内容会非常多,而且有很多书和课程都讲到了,所以,我并不准备把它当作重点。但我会和你分享一些学习设计模式的心得,帮助你将设计模式的相关知识贯穿起来。
|
||||
|
||||
当你手里有了诸多工具之后,接下来就需要用这些工具去做自己的设计了。这就轮到**设计方法**登场了。
|
||||
|
||||
我会用[领域驱动设计](https://time.geekbang.org/column/intro/100037301)(也就是 DDD,Domain Driven Design)进行讲解,这是目前最为完整、有效的应对复杂业务场景的设计方法,包括了从如何识别概念到如何建立模型。在这个课程中,我准备将DDD的基础知识贯穿起来,做一个结构性的介绍。你会发现,有了前面知识的铺垫,DDD理解起来一点都不困难。
|
||||
|
||||
有了基础知识,在课程最后,我们还会在**巩固篇**中操练一下,将学到的软件知识应用起来。在这个模块中,我会结合自己的开源项目Moco,来讲讲如何设计一个程序库;还会借着一个数据采集的项目,谈谈如何构建起一个可扩展的模型。另外,因为大多数人在实际工作中面对的都是一个既有的项目,所以,我还会讲讲,如何对既有项目做设计上的改进。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/62/31/62edbd37324ea5b7af4da7c15b4d9431.jpg" alt="">
|
||||
|
||||
## 写在最后
|
||||
|
||||
最后,再自我介绍一下。我叫郑晔,一个从业近二十年的程序员,《[10x程序员工作法](https://time.geekbang.org/column/intro/100022301)》专栏作者。很高兴又回到极客时间,和你分享我对软件设计的理解。
|
||||
|
||||
如果说《10x 程序员工作法》这门课是在告诉你要做正确的事,做有价值的需求,别把时间浪费在不该做的事情上,那《软件设计之美》这门课就是告诉你如何把事做对,如何建立有效的模型,划清模块之间的边界,所以,二者可谓一脉相承。
|
||||
|
||||
不想当将军的士兵不是一个好士兵,不想做设计的程序员不是一个好程序员。写程序的人谁不想操刀一个大型的系统,但不懂软件设计的人能摆弄的代码规模是有限的,而这也限定了一个人的成长高度。
|
||||
|
||||
学习软件设计,是让你的把控能力从一段代码扩展到一个模块,再扩展到一个子系统,再扩展到一个大系统的必备手艺,是程序员从“家常菜厨师”到“大厨”的进阶之路。
|
||||
|
||||
不过,你也不必把软件设计想象得过于高大上,很多设计理念既可以用来设计一个系统,也可以运用于日常开发之中,它就在你的身边。今天多学习一点设计,明天就能多发现一个问题。
|
||||
|
||||
如果你曾与我一样,走入过软件开发的迷途;如果你希望自己的软件开发能力再上层楼;或者你只是对软件设计充满好奇,那么,欢迎加入我的课程。也欢迎你把自己的现状和预期写在留言区,当课程结束时,让我们共同见证你的成长!
|
||||
|
||||
你准备好了吗?让我们正式开启软件设计之旅,一起领略软件设计的美妙!
|
||||
35
极客时间专栏/软件设计之美/结束语/第三季回归 | 我们一起来写好代码!.md
Normal file
35
极客时间专栏/软件设计之美/结束语/第三季回归 | 我们一起来写好代码!.md
Normal file
@@ -0,0 +1,35 @@
|
||||
<audio id="audio" title="第三季回归 | 我们一起来写好代码!" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/01/43/013ffe3361243b5cfde19bae89274743.mp3"></audio>
|
||||
|
||||
你好,我是郑晔,我又回来了!
|
||||
|
||||
在《[10x 程序员工作法](https://time.geekbang.org/column/intro/100022301)》中,我们讲了工作原则,在《[软件设计之美](https://time.geekbang.org/column/intro/100052601)》,我们讲了设计原则。有不少同学通过各种途径和我表示,这两个专栏让他们受益匪浅。但也有人和我提出,虽然觉得有收获,但还不过瘾。
|
||||
|
||||
这些原则虽然很好,但怎么应用到自己的实际工作中,完全取决于个人的理解,经验丰富的人或许可以直接改变自己的行为,而经验少的人,从中的获得就完全取决于个人的悟性了。
|
||||
|
||||
比如,我在两个专栏中都讲到了单一职责原则,最终得出的结论都是要把代码写短小。但什么叫写短小,不同的人理解起来就是有差异的。
|
||||
|
||||
有一次,我在一些人面前演示了如何将一段代码重构成小函数,然后,我问听众,你们可以接受一个函数代码行数的上限是多少?一个听众很认真地说,100 行。我默默地看了看被我重构掉的那个“不好”的函数,好像也没有 100 行,按照他的标准,那个函数根本不需要改。
|
||||
|
||||
还有一次,一个颇有经验的前辈在我面前说自己写代码的要求很高,函数要求写得很短。我不明就里地问了一句,你要求一个函数不得超过多少呢?他说 50 行。
|
||||
|
||||
50 行也好,100 行也罢,在我看来,这简直是一个天文数字。我通常对自己的要求是,像 Java 语言这种表达能力一般的语言尽可能 10 行之内搞定,而像 Python、Ruby 这类动态语言,5 行代码就可以解决大多数问题,而且很多代码一行就够了。
|
||||
|
||||
在自己实际的项目中,考虑到团队的协作,我在静态检查中配置的参数是 20 行。换言之,一个函数超过 20 行,连构建都是无法通过的。
|
||||
|
||||
从这些例子中你可以看到,虽然大家都遵循了同样的原则,但具体体现在代码上,却是千差万别的。
|
||||
|
||||
也正是因为理解的差异,造成的结果是,虽然许多人懂得了很多道理,依然不能很好地完成自己的本职工作。许多人日夜辛苦地调试的代码,其实在写出来的那一刻就已经漏洞百出了。
|
||||
|
||||
如果能够知道这些代码是有问题的,在写代码之初就把这些问题消灭在萌芽中,日后的辛苦就可以节省出不少。
|
||||
|
||||
Martin Fowler 在《[重构](https://book.douban.com/subject/30468597/)》这本书里给这种有问题的代码起了一个很有特点的名字:代码的坏味道。
|
||||
|
||||
有追求的程序员都希望自己能够写出整洁的代码,而这一切的出发点就是坏味道。只有拥有对于坏味道的嗅觉,才有机会对代码进行重构,也才有机会写出整洁的代码。
|
||||
|
||||
所以我做了第三个专栏,在这个专栏里,我们就从代码的坏味道出发。我会给你提供一些非常直观的坏味道,让你看一眼就知道代码有问题。在这些坏味道中,有一些是你已经深恶痛绝的,比如,长函数和大类;有一些则是在挑战你的编程习惯,比如,else 语句和循环语句。这些坏味道的知识即学即用,对照你的代码,你立刻就能发现很多问题。
|
||||
|
||||
按照我们专栏一贯的风格,我不仅仅会告诉你一段代码是坏味道,也会告诉你这些坏味道之所以为坏味道背后的道理,还会和你讨论如何去重构这段代码。
|
||||
|
||||
有了《[10x 程序员工作法](https://time.geekbang.org/column/intro/100022301)》或《[软件设计之美](https://time.geekbang.org/column/intro/100052601)》这两个专栏的积淀,当你再去学习新专栏的时候,之前学习的这些原则就实打实地体现在对于代码的改进上,让你修炼的内功有了更好的用武之地。
|
||||
|
||||
来吧,欢迎加入《[代码之丑](https://time.geekbang.org/column/intro/100068401)》!请再次和我一起踏上程序员精进之路,我们一起修炼,不断打磨自己编写代码的手艺!
|
||||
127
极客时间专栏/软件设计之美/结束语/结束语|那些没讲的事儿.md
Normal file
127
极客时间专栏/软件设计之美/结束语/结束语|那些没讲的事儿.md
Normal file
@@ -0,0 +1,127 @@
|
||||
<audio id="audio" title="结束语|那些没讲的事儿" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/df/21/df40b2e5210f438b1a717bff40fb8c21.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
我又一次写到了结束语,也许你也是又一次来到我的结束语。当初写《10x 程序员工作法》结束语时的感觉,依然历历在目。这一次,《软件设计之美》也写到了尾声。
|
||||
|
||||
学习了两个专栏的小伙伴对我的风格可能有一些了解,我总是试图把尽可能多的知识以结构化的方式呈现在你面前。因为,就我自己的学习而言,我也总是可以很快学会细节的东西,但知识结构却不是一朝一夕就可以建立的。
|
||||
|
||||
我曾把《10x 程序员工作法》比作藏经阁的目录,因为它是我已经结构化了的软件开发的各种最佳实践,而《软件设计之美》则是我构建软件设计知识大厦的一种尝试。我总是努力提供尽可能大的知识密度,保证看到这些内容的你,在学了我的专栏之后,还可以有继续扩展学习的空间。
|
||||
|
||||
我在下面附上了总结图,把专栏里提到的知识做了一个整理,方便你更好地进行复习。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ec/87/ec88a3149f09d3401c4f8ce8054b8287.jpg" alt="" title="课前必读、了解设计和巩固篇导图">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/6a/8fd79bed397ff55e89afa02483146a6a.jpg" alt="" title="程序设计语言导图">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/df/a3/df639be33ab9f65303d14233bc682ba3.jpg" alt="" title="编程范式导图">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ea/b5/eaa90b45b051007d26124e4a03d650b5.jpg" alt="" title="设计方法导图">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b3/d6/b3fe4a6071a7798c6f7cec257e1689d6.jpg" alt="" title="设计原则与模式导图">
|
||||
|
||||
但是,在这个专栏中,虽然我把软件设计相关的核心知识都讲了一遍,可还有一些内容是我没有在专栏中呈现出来的。那么,在这个专栏的结束语中,我们就来说说那些没讲的事儿。
|
||||
|
||||
## 设计需要沟通
|
||||
|
||||
你能感受到,我在这个专栏里给你讲的大部分内容都是技术性的,然而,在真实的软件开发过程中,软件设计工作有很大一部分内容却是非技术性的,比如,沟通。
|
||||
|
||||
也许你会觉得,设计不就是改改代码吗,跟沟通有什么关系啊?
|
||||
|
||||
我在专栏之初就说过,软件设计是要构建模型,打造规范。但是那时候,我没说的是,模型的理解需要沟通,规范的执行同样也需要沟通。
|
||||
|
||||
因为无论是模型还是规范,软件设计最终是要落实到代码上的。具体落实成什么样,是依赖于人的,依赖于人的理解和人的执行。那如何才能让人的理解达成一致?唯有不断反复地沟通。
|
||||
|
||||
为什么有些人并不觉得沟通很重要,或许只是因为他们所做的工作是局部代码的调整,涉及到人的比较少,沟通的重要性没有那么凸显。但只要我们在成长,我们负责的模块规模就会变大,牵扯到的人就会增多。如果你想要让其他人能够理解你的设计,就需要靠沟通了。
|
||||
|
||||
我们都知道,好的设计一定是易于理解的,而这个理解指的就是别人如何理解。所以,一个好的设计只做到自己心知肚明是不够的,酒香也怕巷子深。
|
||||
|
||||
一个好的设计是需要讲给别人、取得别人认同的。虽然我们说,条条大路通罗马,达到同一个目标的路径有很多,设计没有一条标准的路径。可是,当人数多起来,思想不一致几乎是一种必然。所以,只有通过沟通,我们才有可能让大家对某一种路径达成一致。
|
||||
|
||||
这样的话,设计才能保持一致,否则,你写结构化编程的 for 循环,我写函数式编程的 map、reduce;你留扩展点,我来硬编码,代码就注定是无法维护的。
|
||||
|
||||
不过,如果我把沟通放在软件设计的课表上,估计很多人看到课表就直接放弃了。因为沟通这件事太不够“硬核”。而我在《10x 程序员工作法》中,把沟通反馈作为了一项重要的工作原则。后来,我从同学们的反馈中也看出,**沟通确实是程序员成长过程中一个重要的阻碍**。
|
||||
|
||||
其实,在软件设计中,确实有一个工具是关于沟通的,那就是UML,叫作统一建模语言。不过,现在很多程序员更习惯随手画个图,因为这种表达方式更简单,这让 UML 的用武之地就少了许多。
|
||||
|
||||
所以,有空的时候,我还是建议你去了解一下 UML,至少你要知道有几种类型的图。这样,以后你在随手画图时,不至于把静态结构和动态交互画在一起。
|
||||
|
||||
很多程序员会习惯性地把自己的职业只当作一个技术工种,认为只要技术足够深厚,便能够通行天下。但实际上,只要我们是在一个组织中工作,沟通能力就是非常重要的,而且,随着职位的上升,沟通能力的重要性会越发显现。
|
||||
|
||||
所以,**如果你在实际工作中发现别人很难理解你的美妙设计,一种可能是你的设计没有你以为的那么好;而另一种可能就是,你的沟通还不够好,其他人并没有理解你**。
|
||||
|
||||
## 经验的积累
|
||||
|
||||
关于软件设计,我还有一件事是没法教给你的,那就是经验的积累。
|
||||
|
||||
如果你有机会和我一起写代码,我可能会在一些地方要求你去增加一些类、把类写小;在另外一些地方要求你把一些类合并、减少类的数量;有时候,我还会告诉你这个地方要留一个扩展点,把变化隔离开来;还有的时候,我会让你别考虑扩展,先把功能实现了。
|
||||
|
||||
你可能会困惑,这里的一对对操作完全是相反的,到底哪个才是软件设计该做的呢?但是,如果你和我在一起工作,有了相应的上下文,你就会理解这些要求的合理之处。
|
||||
|
||||
所以说,即便我们掌握了相同的软件设计知识,但是在一个具体的场景下,该做怎样的判断、如何作出判断,都是需要我们经验积累的。
|
||||
|
||||
软件设计的基础,无论是设计模式,还是设计微调的技巧,你都可以通过课程去学习,甚至可以通过短期的训练营去锻炼。但是,如果你想要把这些内容熟练地运用到实际的工作中,那就需要你有大量经验的积累,需要你经历或者见过许多不同的使用场景。
|
||||
|
||||
**不过,一切经验积累的前提条件是,先有软件设计的意识**。
|
||||
|
||||
对于大多数人而言,软件设计是知与不知的差别。知道的人就会有意识地积累经验,而不知道的人即使做过再多的项目,也无非是不断地在重复增删改查。《软件设计之美》这个专栏首先帮助你解决了知的问题,只有知道了,你才能开启积累的道路,踏上个体成长的阶梯。
|
||||
|
||||
我在专栏中反复在说,软件设计关注的是长期变化。可是,实际上,没有任何一个专栏或是一个训练营可以让你真正地感受到一个软件的长期变化,唯有真实的项目可以。
|
||||
|
||||
每当来了一个新的需求,我们就会有一个对应的解决方案。但是,我们最好先问自己一个问题,这种实现方案是不是一个好的设计呢?这么做就可以给自己的直觉思维加上一个缓冲。
|
||||
|
||||
**普通程序员和高手之间的差别就在于此,普通程序员凭直觉做事,高手却是把专业的做法训练成直觉**。所以,你能看到很多人在不经意间写出的代码就非常漂亮,而漂亮的背后,其实是一次又一次的思考和训练。
|
||||
|
||||
我有幸在职业生涯之初就接触了软件设计,当时我所在的部门正面临着对系统的调整,所以,当时的技术负责人每天研读《设计模式》,然后就在部门里做分享,分享他的读书心得。
|
||||
|
||||
虽然我当时还不能完全理解他在说什么,但本着对他的信任,我整个过程都听得很认真。我竟然从中听出了一些美感,我发觉软件设计是个好东西,这种印象深深地印在了我的脑海中。
|
||||
|
||||
从那之后,我也会有意识地去找设计的书去读,会有意识地去反复思考设计的优劣。我经常问自己的一个问题就是,**如果我把这段代码重写一遍,我该怎么做**。久而久之,我几乎每次都能发现自己代码写得不够好的地方,找到那些值得改进的地方。
|
||||
|
||||
也正是因为这样,我的代码风格每隔一段时间,就会发生一些变化。尽管在外人眼中,我实现的功能都是差不多的功能。但我知道,它的设计已经变得更好了。因为它更容易测试了,也更容易扩展了。
|
||||
|
||||
对于一个好程序员来说,品味是尤为重要的。要想有一个好的品味,就一定要见过好东西。遗憾的是,大多数人在日常工作见到的代码都很难称得上有品味,唯一的优点就是可以运行。
|
||||
|
||||
所以,我们要多向好的开源项目学习,这是一种帮助我们打破限制的好方法。
|
||||
|
||||
开源项目有很多,但是很多人的关注点一般都是这些项目如何实现了一个功能,却少有人关注它的设计,这会让我们错过很多风景。我们可以先从一些不那么复杂的项目入手,关注它的设计。
|
||||
|
||||
如果让我推荐,我会推荐我熟悉的,而且让我能从中感受到美感的几个项目:
|
||||
|
||||
- Moco;
|
||||
- Google Guava;
|
||||
- Spring 系列的项目。
|
||||
|
||||
编写代码也是一门手艺,手艺是要不断打磨锤炼的。鸟巢的每个焊接处都镌刻着焊工的名字,因为主事者希望记录下来他们对这件历史工程做出的贡献。
|
||||
|
||||
程序员的工作天生也会被源码控制工具记录下来。所以,作为一个程序员,我希望自己写下的代码能成为自己的骄傲,而不是别人的槽点。唯有不断精进的手艺才能成为我们努力过的证明。
|
||||
|
||||
## 结束了吗?
|
||||
|
||||
结束了吗?
|
||||
|
||||
丘吉尔在阿拉曼战役庆功宴上发表的演讲中,说过这样一段话:这不是结束,甚至不是结束的开始,而可能是开始的结束。(Now this is not the end. It is not even the beginning of the end. But it is perhaps the end of the beginning.)
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cb/a6/cbaf02c2c0eaf0a6116810baf61497a6.jpg" alt="">
|
||||
|
||||
没错,我想说的是,这个专栏只是你学习软件开始的结束。
|
||||
|
||||
《软件设计之美》这个专栏只是帮助你开启了软件设计的大门,但能真正让软件设计成为自己的一部分,对每个人来说,都有很长的路要走。即便我写程序已经二十多年了,我依然不敢说,自己的程序已经写到无懈可击的地步,偶尔的新需求依然会让我陷入思索。
|
||||
|
||||
我对《软件设计之美》的预期,就是我可以把我体验到的思考乐趣告诉你,让你产生对于软件设计的兴趣。如果你希望学了这个专栏之后,还能更进一步地学习,不妨找一找我在专栏中推荐的书,几乎每一本都值得你去深入学习,而这个专栏已经给了你一张地图,保证你不会在茂盛的软件设计丛林中迷失。
|
||||
|
||||
与此同时,我也知道,《软件设计之美》在给你开启了新的知识大门之后,也让你产生了许多困惑。不要紧,即便这个专栏结束了,并不等于我为大家的服务结束了,你依然可以在专栏里留言提问,我会努力回复你的留言。
|
||||
|
||||
关于软件设计,我其实还有很多想说的东西。也许在后面,我还会把自己的一些思考与你分享。
|
||||
|
||||
在这个专栏的最后,我要特别感谢一下我的催稿小分队。疫情期间,我赋闲在家写作这个专栏。我自认为自己不是一个行动力超强的人,于是,我组织了一个催稿小分队,邀请了《10x 程序员工作法》的一些读者以及专栏相关的工作人员,一起监督我写稿。
|
||||
|
||||
每天向催稿小分队汇报进度,成了那段时间我的一大乐趣。催稿小分队的各路神仙每天的插科打诨也给我无聊的写稿生活平添了许多乐趣,有你们真好!
|
||||
|
||||
如果以后有机会,我会再来与你分享我对软件开发的理解,这次的《软件设计之美》之旅就暂告一段落了!
|
||||
|
||||
再见!
|
||||
|
||||
《软件设计之美》课程结束了,这里有一份[毕业问卷](https://jinshuju.net/f/kCNvrq),题目不多,希望你能花两分钟填一下。期待能听到你说一说,你对这个课程的想法和建议。
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/0a/3f/0a9f82115b1040c7497bb4e85417a03f.jpg" alt="">](https://jinshuju.net/f/kCNvrq)
|
||||
10
极客时间专栏/软件设计之美/结束语/结课测试|这些软件设计的知识你都掌握了吗?.md
Normal file
10
极客时间专栏/软件设计之美/结束语/结课测试|这些软件设计的知识你都掌握了吗?.md
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
你好,我是郑晔。
|
||||
|
||||
到这里,《软件设计之美》这门课程已经全部结束了。我给你准备了一个结课小测试,来帮助你检验自己的学习效果。
|
||||
|
||||
这套测试题共有 20 道题目,包括8道单选题和12道多选题,满分 100 分,系统自动评分。
|
||||
|
||||
还等什么,点击下面按钮开始测试吧!
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=212&exam_id=640)
|
||||
122
极客时间专栏/软件设计之美/设计一个软件—程序设计语言/08 | 语言的模型:如何打破单一语言局限,让设计更好地落地?.md
Normal file
122
极客时间专栏/软件设计之美/设计一个软件—程序设计语言/08 | 语言的模型:如何打破单一语言局限,让设计更好地落地?.md
Normal file
@@ -0,0 +1,122 @@
|
||||
<audio id="audio" title="08 | 语言的模型:如何打破单一语言局限,让设计更好地落地?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bb/cd/bb92f2e08fc2d2d6301fd8426e36c9cd.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
经过前面几讲,我们已经学习了如何去了解一个现有软件的设计。从这一讲开始,我们就进入到新的模块,讨论如何设计一个软件。做设计之前,我们要先知道手边有哪些工具。所以在这个模块开启之初,我们先来讨论程序设计语言。
|
||||
|
||||
或许你会觉得,程序设计语言有啥好讨论的?哪个程序员没有一门看家的程序设计语言呢?不知道你是否遇到过这样的问题:
|
||||
|
||||
- 面向对象用来组织程序是好,但我用的是C语言;
|
||||
- 我用的是C++,函数式编程的好,跟我有什么关系;
|
||||
- 动态语言那些特性很好,可惜我用的是Java;
|
||||
- ……
|
||||
|
||||
如果你这么想,说明你被自己的看家本事给局限住了,这种思维方式会让你即便学到了更多的好东西,也只能无可奈何。
|
||||
|
||||
其实,程序设计语言之间没有那么泾渭分明的界限,程序员唯有多学习几门语言,才能打破语言的局限,让设计更好地落地。你可以根据项目特点选择合适的语言,也可以将其它语言一些优秀的地方借鉴过来。Andrew Hunt和David Thomas在《程序员修炼之道》(The Pragmatic Programmer)中给程序员们提了一项重要的建议:**每年至少学习一门新语言。**
|
||||
|
||||
可是,语言那么多,我要一个一个都学过去吗?学语言到底在学什么呢?
|
||||
|
||||
其实,程序设计语言本身也是一个软件,它也包含模型、接口和实现。而**我们学习程序设计语言主要是为了学习程序设计语言提供的编程模型**,比如:不同的程序组织方式,不同的控制结构等等。因为不同的编程模型会带给你不同的思考方式。
|
||||
|
||||
既然要学习编程模型,我们就要先知道编程模型设计的来龙去脉,所以,今天我先带你领略一下程序设计语言的发展历程。
|
||||
|
||||
## 程序设计语言发展简史
|
||||
|
||||
我们今天接触到的程序设计语言都是图灵完备的。这里的“图灵完备”指的是语言指定的数据操作规则能够实现图灵机的全部功能(图灵机的概念是由阿兰·图灵提出的,图灵机为计算机能够解决的问题划定了一个边界)。所以,图灵机是所有程序设计语言最底层的模型,程序设计语言都是在这个基础上生长出来的,包括众所周知的计算机基础:用0和1编码。
|
||||
|
||||
我们今天的计算机能够识别的都是0和1,但真正用0和1直接写代码的人少之又少,因为实在太麻烦了。所以,早在计算机诞生之初,就产生了**汇编语言**,它可以将那些0101的操作符变成更容易记住的ADD、MOV之类的指令。
|
||||
|
||||
相比于01串,汇编虽然进步了一些,但人们很快就发现,用汇编写程序也是非常痛苦的事情,因为只有对计算机了如指掌,才能写好汇编。更可怕的是,即便你熟练掌握了一种计算机的汇编语言,换成另外一种计算机,你也必须从头学过。
|
||||
|
||||
这时,就轮到**高级程序设计语言**登场了。
|
||||
|
||||
第一门被广泛使用的高级程序设计语言是Fortran,它为程序设计语言的发展奠定了基础。比如,一些基本控制结构出现了,数据开始拥有了类型(类型就是一种对内存数据的解释方式)。虽然这些东西在今天看来非常简单,但和那个年代使用的汇编相比,简直是一个巨大的飞跃。
|
||||
|
||||
Fortran对于计算机的发展起到了巨大的推动作用,人们也逐渐认识到高级程序设计语言对于开发效率的提高。接下来,人们开发了各种高级程序设计语言,不断地探索怎样写好程序。
|
||||
|
||||
早期程序设计语言探索的集大成者就是**C语言**,它提供了对于计算机而言最为恰当的抽象,屏蔽了计算机硬件的诸多细节。时至今日,C语言依然受众广泛。
|
||||
|
||||
随着高级程序设计语言的发展,门槛逐步降低,人们可以开发的程序规模也逐渐膨胀。这时候,**如何组织程序**成了新的挑战。有一种语言搭着C语言的便车将面向对象的程序设计风格带入了主流视野,这就是C++。很长一段时间内,C++成为了行业中最主流的选项,既兼容C语言,又提供了很好的程序组织方式。
|
||||
|
||||
虽然各种高级程序设计语言已经屏蔽了很多细节,但有一个问题始终没有得到很好的解决,也由此引发了更多的问题,这就是**内存管理**。其实,人们早就在尝试各种屏蔽内存管理的方式,但因为早期计算机硬件性能有限,所以没有任何一种方式能够成为行业主流。
|
||||
|
||||
后来,计算机硬件的能力得到了大幅度提升,这让那些在故纸堆里的技术又焕发了新的活力。这个阶段的胜利者是Java,一方面,它支持面向对象编程;另一方面,它还有垃圾回收机制——一种内存管理的方式。
|
||||
|
||||
Java的路其实也很坎坷,因为它早期在个人电脑上的尝试并不算成功。后来选择了企业级开发的赛道,才有机会展现自己的优势。因为企业级服务器本身性能优于个人电脑,对Java有更高的容忍度,它才得到了机会,不断进行自身的优化。
|
||||
|
||||
当硬件不再是程序设计语言的发展障碍之后,程序设计语言又该如何发展呢?
|
||||
|
||||
从前面的历程不难看出,程序设计语言的发展就是一个“逐步远离计算机硬件,向着待解决的问题靠近”的过程。所以,程序设计语言接下来的发展方向就是**探索怎么更好地解决问题**了。
|
||||
|
||||
前面说的这些只是程序设计语言发展的主流路径,其实还有一条不那么主流的路径也一直在发展,就是**函数式编程的程序设计语言**,这方面的代表就是LISP。
|
||||
|
||||
在这条路上,刚开始,很多人都是偏学术风格的,他们更关心的是解决方案是否优雅,也就是说,如何解决问题,如何一层层构建抽象。他们也探索更多的可能,垃圾回收机制就是从这里来的。但同样受限于当时硬件的性能,这条路上的探索在很长一段时间之内都只是一个小众游戏。
|
||||
|
||||
当硬件的性能不再成为阻碍,如何解决问题开始变得越来越重要时,函数式编程终于和程序设计语言发展的主流汇合了。促进函数式编程引起广泛重视也还有一个硬件因素:**多核**。
|
||||
|
||||
多核的出现,本身是IT行业应对CPU发展进入瓶颈期的一个解决方案,但它却打破了很多程序员只习惯于利用一个CPU写程序的传统方式。
|
||||
|
||||
为了利用多核的优势,人们探索了各种方案,今天看到的各种并发模型、异步模型等解决方案都从那时开始得到了蓬勃的发展。函数式编程在这个方面的探索就是利用自己声明式的表达方式屏蔽了硬件差异。让人们注意到函数式编程的价值的就是著名的MapReduce。
|
||||
|
||||
函数式编程的兴起,让那些在函数式编程社区的探索随之兴起,比如,声明式编程、DSL、元编程等等。一些后出现的程序设计语言开始将面向对象和函数式编程二者融合起来,比如Scala。而像Java和C++这些“老战士”则逐渐地将函数式编程的支持加入到语言之中。
|
||||
|
||||
相比于这些“正规军”,还有一股力量也逐渐从边缘走上了舞台,这就是**动态语言**,代表语言有 Perl、Python、Ruby、PHP等等。以前,人们更喜欢用“脚本语言”称呼这类程序设计语言,这个名字表明,它就是为了简单地解决一些特定的问题而出现的。所以,在人们心目中,它们显得并不那么正式。但它们简单、轻巧的特性有效地降低了入门的门槛,也赢得了一大批拥趸。
|
||||
|
||||
**语言的发展就是一个互相学习和借鉴的过程**。以前,动态语言的弱项在于不适用于规模比较大的工程,而近些年来,随着动态语言用户的增多,配套的工具也逐渐多了起来,动态语言项目的规模也逐渐增大。而在主航道的程序设计语言,也纷纷向动态语言学习,努力地简化代码编写的难度,比如,Java和C++都开始支持类型推演(Type Inference),目的就是让程序员少敲几个字符。
|
||||
|
||||
至此,我简单地带你回顾了一下程序设计语言的发展历程,梳理了程序设计语言的发展脉络。从中不难看出,如果把程序设计语言当作一个软件,它的发展历程就是一个逐渐添加新模型的过程,而其发展的结果就是如今的开发门槛越来越低,能够开发的程序规模越来越大。
|
||||
|
||||
## 一切语法都是语法糖
|
||||
|
||||
现在,你已经能更好地理解我们在前面提出的说法,**学习程序设计语言其实就是要学习语言提供的编程模型。**
|
||||
|
||||
以我学过的一些程序设计语言为例:
|
||||
|
||||
- C语言提供了对汇编指令直接的封装。
|
||||
- C++先是提供了面向对象,后来又提供了泛型编程。
|
||||
- Java把内存管理从开发者面前去掉了,后来引入的Annotation可以进行声明式编程。
|
||||
- Ruby提供了动态类型,以及由Ruby on Rails引导出的DSL风格。
|
||||
- Scala和Clojure提供了函数式编程。
|
||||
- Rust提供了新的内存管理方式,而Libra提供的Move语言则把它进一步抽象成了资源的概念。
|
||||
|
||||
既然学习新的程序设计语言是为了学习新的编程模型,反过来也可以说,**不提供新编程模型的语言是不值得刻意学习的**。如果你已经学会了一两门程序设计语言,学习一门新的语言其实并不困难,因为每种语言提供的新模型是有限的,基本的元素是类似的,无非是用了不同的关键字。
|
||||
|
||||
所以,学习新语言,只是在做增量的学习,思维负担并没有那么沉重。一旦对于程序设计语言的模型有了新的认识,你就能理解一件事:**一切语法都是语法糖**。
|
||||
|
||||
语法糖(Syntactic sugar)是英国计算机科学家彼得·兰丁发明的一个术语,指的是那些为了方便程序员使用的语法,它对语言的功能没有影响。
|
||||
|
||||
懂得了语法糖的道理,要想更好地理解程序设计语言,一种好的做法就是打开语法糖,了解一下语法是怎么实现的:
|
||||
|
||||
- 类型是一种对内存的解释方式。
|
||||
- class/struct是把有相关性的数据存放到一起的一种数据组织方式。
|
||||
- Groovy、Scala、Kotlin、Clojure等JVM上的新语言,提供了一种不同于Java的封装JVM的方式。
|
||||
- ……
|
||||
|
||||
通过前面的介绍,你也看到了,语言的发展并非一蹴而就,而是一个渐进式的发展历程。一些新的尝试总会在一些不起眼的地方冒出来,而且语言之间也在相互借鉴。
|
||||
|
||||
如果你能每年学习一门新语言,起初,你可以了解不同的编程模型。当你的积累足够多了,学习语言就是在跟踪程序设计语言的最新发展了。
|
||||
|
||||
**当你手里有了足够多的“武器”时,你就可以打开思路,运用不同的方式解决问题了**,甚至把其它语言的好东西,借鉴到自己使用的语言中。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们谈到了程序设计语言。学习不同的程序设计语言可以帮助我们更好地落地设计,也可以让我们向不同的语言借鉴优秀的方面。
|
||||
|
||||
我们简要地了解了程序设计语言的发展历史,从最开始的对机器模型的封装,到今天不断降低的开发门槛,程序设计语言的演化从未停止。我们也看到各种不同的编程风格在经历了最初各自独立的发展之后,开始慢慢融合。
|
||||
|
||||
对程序设计语言发展的了解,可以帮助我们理解一件事:**一切语法都是语法糖**。新的语法通常是在既有的结构上不断添加出来的,为的是简化代码的编写。
|
||||
|
||||
《程序员修炼之道》鼓励程序员们每年至少学习一门新语言,主要是为了让我们去学习新的编程模型,而不提供新编程模型的语言不值得刻意去学习。
|
||||
|
||||
不过,这就需要你对程序设计语言有着更深的理解。下一讲,我们来看程序设计语言的接口,看看更具体的语言演化是如何发生的。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**每年至少学习一门能够提供新编程模型的程序设计语言。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e9/ad/e9b1ccd6cdb75768cf8070fa60e2ecad.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你分享一下,你最近打算学习哪门新的程序设计语言呢?为什么?欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
135
极客时间专栏/软件设计之美/设计一个软件—程序设计语言/09 | 语言的接口:语法和程序库,软件设计的发力点.md
Normal file
135
极客时间专栏/软件设计之美/设计一个软件—程序设计语言/09 | 语言的接口:语法和程序库,软件设计的发力点.md
Normal file
@@ -0,0 +1,135 @@
|
||||
<audio id="audio" title="09 | 语言的接口:语法和程序库,软件设计的发力点" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3d/ba/3d91fcf5ebaef4df21e69fa99be6e3ba.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
在上一讲中,我们学习了程序设计语言模型的演变过程。学习不同的程序设计语言,实质上就是学习不同的编程模型。谈完了模型,接下来,就该说说接口了。
|
||||
|
||||
这一讲,我们就来谈谈程序设计语言的接口,一说起程序设计语言的接口,你的直观印象肯定是程序设计语言的语法,那是一个你已经很熟悉的话题了,但程序设计语言还有一个你可能都不曾留意的接口:程序库。
|
||||
|
||||
如果你已经能够完成基本功能的开发,想让自己在编程水平上再进一步,成为一个更好的程序员,你就可以从封装程序库开始练习。因为想封装出一个好的程序库所需的能力,就是软件设计所需的能力。封装程序库,可以成为你软件设计进阶的发力点。
|
||||
|
||||
## 消除重复的程序库
|
||||
|
||||
我们写程序的时候,只要规模稍微大一点,你就会发现同样的模式经常出现,差别无非是几个地方的参数不一样,这就是重复。
|
||||
|
||||
最开始的重复是指令级别的重复,程序员们会把同样的指令序列放到一起,通过传入不同的参数进行识别。你发现了吗?我说的就是函数。函数已经成了今天的主流编程方式,是几乎所有的程序设计语言都有的基础设施,人们甚至忘了它的由来。
|
||||
|
||||
写程序的一项主要日常工作就是定义各种函数。一旦你定义了大量的函数,就会发现有很多函数不仅仅在某个项目中是适用的,而且在很多项目中都是适用的。这时,作为一个“懒惰”的程序员,我们就会把这些在多个项目中使用的部分抽取出来,组成一个模块,这就是程序库的来源。
|
||||
|
||||
所以,程序库就是为了消除重复而出现的。而消除重复,也是软件设计的初衷。
|
||||
|
||||
程序库(Library)是程序员最熟悉的一项内容。学习一门新语言,首先是学习语法(Syntax),然后学习程序库(Library),之后再学习运行时(Runtime),这样,你就具备一门语言的基础了。再往后,你需要了解的就是各种惯用法(Idiom),以及如何运用到实际的工作中。
|
||||
|
||||
有一些程序库实在是太常用了,它们就会随着语言一起发布,成为标准库。比如,程序员熟知的第一个程序“Hello, world”的做法来自《C程序设计语言》,其中用到的printf就是来自C的标准库。再比如,Java程序员无人不知的JDK,里面包含了大量的程序库,一个Java程序员如果不能说出几个容器,简直就不好意思和人打招呼。
|
||||
|
||||
当然,如果在实际工作中只使用标准库,有些代码写起来还是非常麻烦的。因为标准库提供的能力通常是很基础的。这时,我们就需要利用更多的第三方程序库,它们给提供了更丰富的选项,去完善标准库做得不够的地方。
|
||||
|
||||
也就是说,它们会在标准库的基础上,再做一次封装,提供一个新的编程模型,或是新的接口,甚至修正一些标准库的bug,让开发变得更简单。只要是人气足够的语言,在这个方面做得都非常好,它们会提供大量的第三方库。
|
||||
|
||||
正是因为第三方库的兴起,**怎样管理第三方库**就成了一个问题。今天,这已经成了一个标准的问题,也有了标准的解决方案,那就是**包管理器**。很多语言都有了自己的包管理器,像Java的Maven、Node的NPM、Ruby的RubyGems等等,而像Rust这样年轻的语言,包管理器甚至变成了随语言的发行包一起发布的一项内容。
|
||||
|
||||
## 语言设计就是程序库设计
|
||||
|
||||
### new
|
||||
|
||||
虽然程序库受限于特定的程序语言,但其表达的思想却不受语言限制。我给你举个例子,在软件开发中,我们最常做的一个操作就是初始化。如果采用C这样比较早期的语言,通常的做法就是,分配一块内存,然后给这块内存赋值,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/65/37/659ef6ccbf186611e5ebe07618d5f237.jpeg" alt="">
|
||||
|
||||
类似的代码会反复出现,成为一个固定的模式。
|
||||
|
||||
而一些新的语言索性就将二者合二为一,成了一个固定的语法,这就是很多人熟悉的new。无论是C++程序员,还是Java程序员,对new一定不陌生。
|
||||
|
||||
当我们调用new时会发生什么呢?首先,它会申请一块内存,然后调用对应类的构造函数。类也好,构造函数也罢,这些内容都是C语言没有的,但这个初始化的模式与C语言是如出一辙的。
|
||||
|
||||
只不过,在C语言里面,这个操作通常是通过程序库实现的,而到了C++和Java中,它成了语法。一旦变成了语法,它就成了语言的一部分,成为了一个特定的模型。
|
||||
|
||||
对于使用者而言,这个模型就是一个接口,只要接口的行为不变,我的代码就不用变。
|
||||
|
||||
但对于接口另一面的实现者而言,它就可以做一些特定的工作了。比如,插入不同的内存分配算法,这就是C++的allocator所做的事情;再比如,把内存完全管控起来,这就是Java做的事情。没错,Java之所以能够让程序员忽略内存管理,new功不可没。
|
||||
|
||||
一个经过验证的模式最终变成了语言的一部分,而它的起点只是一个常见的用法:一个程序库。
|
||||
|
||||
### synchronized
|
||||
|
||||
我再用Java中的synchronized给你举个例子。我们都知道,并发编程是程序员的一门必修课。学习并发编程,一方面要学习各种概念,比如“锁”;另一方面还要学习不同语言相应的程序库。因为这些概念太常用了。所以,Java干脆把它变成了一个语法,也就是synchronized。
|
||||
|
||||
成为语法固然是一个巨大的进步,但是在一些场景下,语法反而会显得僵化。这时候,又轮到程序库登场了。我还以前面所说的new和synchronized为例给你讲解一下。
|
||||
|
||||
## 程序库设计就是语言设计
|
||||
|
||||
### new
|
||||
|
||||
new虽然帮我们解决了一些问题,但与new配合使用的构造函数往往有一个致命伤,那就是它只有一个名字,也就是类的名字。
|
||||
|
||||
当我们需要表达多种不同的构造逻辑时,各村就出现了各村的高招。我曾经见过有人利用重载(overload)来解决问题的,不同的构造逻辑用不同类型的参数。比如,一个用HashMap,一个用TreeMap。作为一个新加入项目的程序员,你很难想到这是两种不同的构造逻辑,它们与这里不同类型的数据结构其实一点关系都没有。
|
||||
|
||||
一个更好的解决方案是利用**工厂模式**解决这个问题,也就是说,用一个名字更能表意的函数,代替构造函数作为构造入口。
|
||||
|
||||
还是以Java为例,ArrayList是Java程序员很熟悉的一个数据结构。如果我要创建一个包含两个元素的ArrayList,同时,还要创建一个初始容量为10的ArrayList。用JDK原生的做法,我可以这么做:
|
||||
|
||||
```
|
||||
// 创建有两个元素的数组
|
||||
ArrayList<String> listWithElements = new ArrayList();
|
||||
listWithElements.add("foo");
|
||||
listWithElements.add("bar");
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
// 创建一个初始容量为10的数组。
|
||||
ArrayList<String> listWithCapacity = new ArrayList(10);
|
||||
|
||||
```
|
||||
|
||||
这种做法可行,但代码表意并不清晰。那有没有更好的做法呢?Google的Guava程序库,对于同样的场景,给出了一个不同的做法:
|
||||
|
||||
```
|
||||
ArrayList<String> listWithElements = newArrayList("foo", "bar");
|
||||
ArrayList<String> listWithCapacity = newArrayListWithCapacity(10);
|
||||
|
||||
```
|
||||
|
||||
显然,从语义上来说,这种做法更清晰。Java领域的行业名著《Effective Java》(第三版)的第一个条款是“用静态工厂方法代替构造函数”,讨论的就是这种做法。
|
||||
|
||||
### synchronized
|
||||
|
||||
再来看synchronized。它虽然解决了一部分并发的问题,但是,这个解决问题的方式粒度太大了。程序员只要稍微深入一些,就会感到synchronized的掣肘。于是,Java的开发者在Java 5中开始了新一轮的编程模型探索,这次探索的成果就是后来的并发编程库。这也为面试官们提供了新的素材。
|
||||
|
||||
你看到了吧,解决同一个问题,它可以用一个语法,也可以用一个程序库,二者之间是等价的。
|
||||
|
||||
Andrew Koenig和Barbara Moo写过一本书《C++沉思录》,书里记录了C++早期开发者在设计各种C++特性时的思考,这是一本编程思想之作。当年读这本书时,有两章的标题让我陷入了沉思,分别是**“语言设计就是程序库设计”和“程序库设计就是语言设计”**。
|
||||
|
||||
通过今天的学习,这两句话对你来说就不难理解了。因为语法和程序库是在解决同一个问题,二者之间是相互促进的关系。通常是先有程序库,再有语法;如果语法不够好,新的程序库就会出现,新一轮的编程模型就开始孵化。
|
||||
|
||||
一切有生命力的语言都会不断改善自己的语法,某些好用的程序库就可以转正成为语法。比如,Java引入Lambda,支持函数式编程;C++引入类型推演,简化了代码编写。
|
||||
|
||||
同样地,程序库的发展也在推动着语言的不断进步,有一部分语法就是为了让程序库表现得更好而存在的。比如说:
|
||||
|
||||
- C里面的宏,虽然很多人用它来定义常量,但只有编写程序库才能让它更好地发挥出自身的价值;
|
||||
- Java中的Annotation,很多人都在用,但用它做过设计的人却很少,因为它的使用场景是以程序库居多;
|
||||
- Scala中的隐式转换,如果你没有设计过DSL,很可能根本就不知道它有什么具体的作用。
|
||||
|
||||
至此,你已经能够理解程序设计语言的接口不只包含语法,还有程序库。而且,学习一种程序设计语言提供的模型时,不仅仅要看语法本身有什么,还要了解有语言特性的一些程序库。
|
||||
|
||||
所以,对于程序员而言,想要自己的编程水平上一个台阶,学习编写程序库是一个很好的路径。一方面,我们可以锻炼自己从日常工作中寻找重复;另一方面,我们可以更好地理解程序设计语言提供的能力。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们的学习主题是程序库,程序库最初只是为了消除重复。后来,逐渐有了标准库,然后有了大量的第三方库,进而发展出包管理器。
|
||||
|
||||
如果通用性足够好,一些经过大量实践验证过的程序库甚至会变成语言的语法,而一些语法解决得不够好的地方,又会出现新的程序库去探索新的解决方案。所以,**语言设计就是程序库设计,程序库设计就是语言设计**。二者相互促进,不断发展。
|
||||
|
||||
当你开始学习如何编写程序库,你对软件设计的理解就会踏上一个新的台阶。
|
||||
|
||||
我们说过,学习不同程序设计语言一个重要的原因是为了相互借鉴。理解了模型和接口,你就知道该借鉴什么,但具体如何借鉴呢?我们还需要了解这些模型是如何实现的,所以下一讲,我们就来谈谈程序设计语言的实现。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**提升软件设计能力,可以从编写程序库入手。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/91/d0/910d852b08bc45f94acb879cf3080cd0.jpeg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
今天我们讲到了程序库和语法之间的互相促进,最后,我想请你分享一下,你还能找出哪些语法和程序库互相促进的例子呢?欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
89
极客时间专栏/软件设计之美/设计一个软件—程序设计语言/10 | 语言的实现:运行时,软件设计的地基.md
Normal file
89
极客时间专栏/软件设计之美/设计一个软件—程序设计语言/10 | 语言的实现:运行时,软件设计的地基.md
Normal file
@@ -0,0 +1,89 @@
|
||||
<audio id="audio" title="10 | 语言的实现:运行时,软件设计的地基" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/39/3d/395ce527f5e6b8eff09c195adc9da53d.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
通过前两讲的学习,相信你已经对程序设计语言有了全新的认识。我们知道了,在学习不同的程序设计语言时,可以相互借鉴优秀的设计。但是要借鉴,除了模型和接口,还应该有实现。所以,这一讲,我们就来谈谈程序设计语言的实现。
|
||||
|
||||
程序设计语言的实现就是支撑程序运行的部分:运行时,也有人称之为运行时系统,或运行时环境,它主要是为了实现程序设计语言的执行模型。
|
||||
|
||||
相比于语法和程序库,我们在学习语言的过程中,对运行时的关注较少。因为不理解语言的实现依然不影响我们写程序,那我们为什么还要学习运行时呢?
|
||||
|
||||
因为**运行时,是我们做软件设计的地基。**你可能会问,软件设计的地基不应该是程序设计语言本身吗?并不是,一些比较基础的设计,仅仅了解到语言这个层面是不够的。
|
||||
|
||||
我用个例子来进行说明,我曾经参与过一个开源项目:在JVM上运行Ruby。这种行为肯定不是 Java语言支持的,为了让Ruby能够运行在JVM上,我们将Ruby的代码编译成了Java的字节码,而字节码就属于运行时的一部分。
|
||||
|
||||
你看,**做设计真正的地基,并不是程序设计语言,而是运行时,有了对于运行时的理解,我们甚至可以做出语言本身不支持的设计**。而且理解了运行时,我们可以成为一个更好的程序员,真正做到对自己编写的代码了如指掌。
|
||||
|
||||
不过,运行时的知识很长一段时间内都不是显学,我初学编程时,这方面的资料并不多。不过,近些年来,这方面明显得到了改善,各种程序设计语言运行时的资料开始多了起来。尤其在Java社区,JVM相关的知识已经成为很多程序员面试的重要组成部分。没错,JVM就是一种运行时。
|
||||
|
||||
接下来,我们就以JVM为例,谈谈怎样了解运行时。
|
||||
|
||||
## 程序如何运行
|
||||
|
||||
首先,我们要澄清一点,对于大部分普通程序员来说,学习运行时并不是为了成为运行时的开发者,我们只是为了更好地理解自己写的程序是如何运行的。
|
||||
|
||||
运行时的相关知识很多,而**“程序如何运行”**本身就是一条主线,将各种知识贯穿起来。程序能够运行,前提条件是,它是一个可执行的文件,我们就从这里开始。
|
||||
|
||||
一般来说,可执行的程序都是有一个可执行文件的结构,对应到JVM上,就是类文件的结构。然后,可执行程序想要执行,需要有一个加载器将它加载到内存里,这就是JVM类加载器的工作。
|
||||
|
||||
加载是一个过程,那么加载的结果是什么呢?就是按照程序运行的需求,将加载过来的程序放到对应的位置上,这就需要了解JVM的内存布局,比如,程序动态申请的内存都在堆上,为了支持方法调用要有栈,还要有区域存放我们加载过来的程序,诸如方法区等等。
|
||||
|
||||
到这里,程序完成了加载,做好了运行的准备,但这只是静态的内容。接下来,你就需要了解程序在运行过程中的表现。一般来说,执行过程就是设置好程序计数器(Program Counter,PC),然后开始按照指令一条一条地开始执行。所以,重点是要知道这些指令到底做了什么。
|
||||
|
||||
在Java中,我们都知道,程序会编译成字节码,对于Java来说,字节码就相当于汇编,其中的指令就是Java程序执行的基础。所以,突破口就在于**了解指令是如何执行的**。
|
||||
|
||||
其实,大部分JVM指令理解起来都很简单,尤其在你了解内存布局之后。比如,加法指令就是在栈上取出两个数,相加之后,再放回栈里面。
|
||||
|
||||
我要提一个看上去很简单的指令,它是一根拴着牛的绳子,这就是new,没错,就是创建对象的指令。那头牛就是内存管理机制,也就是很多人熟悉的GC,这是一个很大的主题,如果展开来看的话,也是一个庞杂的知识体系。
|
||||
|
||||
有了对指令的理解,就对Java程序执行有了基本的理解。剩下的就可以根据自己的需要,打开一些被语法和程序库隐藏起来的细节。比如,synchronized是怎样实现的,顺着这条线,我们可以走到内存模型(Java Memory Model,JMM)。
|
||||
|
||||
当然,这里的内容并不是为了与你详细讨论JVM的实现,无论是哪个知识点真正展开后,实际上都还会有很多的细节。
|
||||
|
||||
这里只是以JVM为例进行讲解,学习其他语言的运行时也是类似的,带着“程序如何运行”这个问题去理解就好了。只不过,每种语言的执行模型是不同的,需要了解的内容也是有所差异的。比如,理解C的运行时,你需要知道更多计算机硬件本身的特性,而理解一些动态语言的运行时,则需要我们对语法树的结构有一定认识。
|
||||
|
||||
有了对运行时的理解,我们就可以把一些好的语言模型借鉴到自己的语言中,比如,使用C语言编程时,我们可以实现多态,做法就是自己实现一个虚拟表,这就是面向对象语言实现多态的一种方案。
|
||||
|
||||
**运行时的编程接口**
|
||||
|
||||
我们前面说过,做软件设计的地基是运行时,那怎样把我们的设计构建在运行时之上呢?这就要依赖于运行时给我们提供的接口了。所以,我们学习运行时,除了要理解运行时本身的机制之外,还要掌握运行时提供的编程接口。
|
||||
|
||||
在Java中,最简单的运行时接口就是运行时类型识别的能力,也就是很多人熟悉的getClass。通过这个接口,我们可以获取到类的信息,一些程序库的实现就会利用类自身声明的信息。比如,之前说过,有些程序库会利用Annotation进行声明式编程,这样的程序库往往会在运行的过程中,以getClass为入口进行一系列操作将Annotation取出来,然后做相应的处理。
|
||||
|
||||
当然,这样的接口还有很多,一部分是以标准库的方式提供的,比如,动态代理。通过阅读JDK的文档,我们很容易学会怎么去运用这些能力。还有一部分接口是以规范的方式提供的,需要你对JVM有着更好的理解才能运用自如,比如,字节码。
|
||||
|
||||
前面我们说了,通过了解指令的执行方式,可以帮助我们更好地理解运行时的机制。有了这些理解,再来看字节码,理解的门槛就大幅度地降低了。
|
||||
|
||||
如果站在字节码的角度思考问题,我们甚至可以创造出一些Java语言层面没有提供的能力,比如,有的程序库给Java语言扩展AOP(Aspect-oriented programming,面向切面编程)的能力。这样一来,你写程序的极限就不再是语言本身了,而是变成了字节码的能力极限。
|
||||
|
||||
给你举个例子,比如,Java 7发布的时候,字节码定义了InvokeDynamic这个新指令,当时语言层面上并没有提供任何的语法。如果你需要,就可以自己编写字节码使用这个新指令,像JRuby、Jython、Groovy 等一些基于JVM的语言,开发者就可以利用这个指令改善自己的运行时实现。当然InvokeDynamic的诞生,本身就是为了在JVM上更好地支持动态语言。
|
||||
|
||||
好消息是,操控字节码这件事的门槛也在逐渐降低。最开始,操作字节码是一件非常神秘的事情,在许多程序员看来,那是只有 SUN 工程师才能做的事情(那时候,Java还属于 SUN)。
|
||||
|
||||
后来,出现了一个叫[ASM](https://asm.ow2.io/)的程序库,把字节码拉入了凡间,越来越多的程序员开始拥有操作字节码的能力。不过,使用ASM,依然要对类文件的结构有所理解,用起来还是比较麻烦。后来又出现了各种基于ASM的改进,现在我个人用得比较多的是[ByteBuddy](https://bytebuddy.net/)。
|
||||
|
||||
有了对于字节码的了解,在Java这种静态的语言上,就可以做出动态语言的一些效果。比如,Java语言的一些Mock框架,为什么可以只声明接口就能够执行,因为背后常常是动态生成了一个类。
|
||||
|
||||
一些动态语言为了支持自己的动态特性,也提供了一些运行时的接口给开发者。比如,Ruby里面很著名的method_missing,很多框架用它实现了一些效果,即便你未定义方法也能够执行的。你也许想到了,我们提到过的Ruby on Rails中各种find_by方法就可以用它来实现。
|
||||
|
||||
method_missing其实就是一个回调方法,当运行时在进行方法查找时,如果找不到对应方法时就调用语言层面的这个方法。所以,你看出来了,这就是运行时和语言互相配合的产物。如果你能够对于方法查找的机制有更具体的了解,使用起来就可以更加地得心应手,就能实现出一些非常好的设计。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们讨论了程序设计语言的实现:运行时。**对于运行时的理解,我们甚至可以做出语言本身不支持的设计。所以,做设计真正的地基,并不是程序设计语言,而是运行时。**
|
||||
|
||||
理解运行时,可以将**“程序如何运行”**作为主线,将相关的知识贯穿起来。我们从了解可执行文件的结构开始,然后了解程序加载机制,知道加载器的运作和内存布局。然后,程序开始运行,我们要知道程序的运行机制,了解字节码,形成一个整体认识。最后,还可以根据需要展开各种细节,做深入的了解。
|
||||
|
||||
有一些语言的运行时还提供了一些语言层面的编程接口,程序员们可以与运行时进行交互,甚至拥有超过语言本身的能力。这些接口有的是以程序库的方式提供,有的则是以规范的方式提供。如果你是一个程序库的开发者,这些接口可以帮助你写出更优雅的程序。
|
||||
|
||||
关于程序设计语言的介绍,我用了三讲分别从模型、接口和实现等不同的角度给你做了介绍。目的无非就是一个,想做好设计,不仅仅要有设计的理念,更要有实际落地的方式。下一讲,我们来讲一个你可以在项目中自己设计的语言:DSL。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**把运行时当作自己设计的地基,不受限于语言。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0a/d9/0ac9b789f401a370ff91b4bd495417d9.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你分享一下,你知道哪些程序库的哪些特性是利用运行时交互的接口实现的?欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
121
极客时间专栏/软件设计之美/设计一个软件—程序设计语言/11 | DSL:你也可以设计一门自己的语言.md
Normal file
121
极客时间专栏/软件设计之美/设计一个软件—程序设计语言/11 | DSL:你也可以设计一门自己的语言.md
Normal file
@@ -0,0 +1,121 @@
|
||||
<audio id="audio" title="11 | DSL:你也可以设计一门自己的语言" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ed/d0/ed9d00486f582a72c093adf55b37d7d0.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
在前面,我们花了三讲的篇幅探讨程序设计语言,一方面是为了增进我们对程序设计语言的理解,另一方面,也希望从中学习到软件设计方面做得好的地方。除了借鉴一些语言特性之外,我们还能怎样应用程序语言,来帮我们做设计呢?
|
||||
|
||||
讲到程序设计语言模型时,我说过,程序设计语言的发展趋势,就是离计算机本身越来越远,而离要解决的问题越来越近。但通用程序设计语言无论怎样逼近要解决的问题,它都不可能走得离问题特别近,因为通用程序设计语言不可能知道具体的问题是什么。
|
||||
|
||||
这给具体的问题留下了一个空间,**如果我们能把设计做到极致,它就能成为一门语言**,填补这个空间。注意,我这里用的并不是比喻,而是真的成为一门语言,一门解决一个特定问题的语言。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fd/c6/fd861a726b8a85d1d22ca56b78515cc6.jpg" alt="">
|
||||
|
||||
这种语言就是领域特定语言(Domain Specific Language,简称 DSL),它是一种用于某个特定领域的程序设计语言。这种特定于某个领域是相对于通用语言而言的,通用语言可以横跨各个领域,我们熟悉的大多数程序设计语言都是通用语言。
|
||||
|
||||
我在[第8讲](https://time.geekbang.org/column/article/245868)说过,它们都是图灵完备的,但DSL不必做到图灵完备,它只要做到满足特定领域的业务需求,就足以缩短问题和解决方案之间的距离,降低理解的门槛。
|
||||
|
||||
虽然大多数程序员并不会真正地实现一个通用程序设计语言,但实现一个DSL,我们还是有机会的。这一讲我们就来谈谈DSL,看看我们可以怎样设计自己的语言。
|
||||
|
||||
## 领域特定语言
|
||||
|
||||
不过,一说起设计一门语言,很多人直觉上会有畏惧心理。但实际上,你可能已经在各种场合接触过一些不同的DSL了。程序员最熟悉的一种DSL就是正则表达式了,没错,也许已经习惯使用正则表达式的你都不知道,但它确实就是一种DSL,一种用于文本处理这个特定领域的DSL。
|
||||
|
||||
如果你觉得正则表达式有点复杂,还有一种更简单的DSL,就是配置文件。你可能真的不把配置文件当作一种DSL,但它确实是在实现某个特定领域的需求,而且可以根据你的需求对软件的行为进行定制。
|
||||
|
||||
一个典型的例子是Ngnix。无论你是用它单独做Web服务器也好,做反向代理也罢,抑或是做负载均衡,只要通过Ngnix的配置文件,你都能实现。配合OpenResty,你甚至可以完成一些业务功能。
|
||||
|
||||
这么一说,你是不是觉得DSL的门槛不像听上去那么高了。
|
||||
|
||||
经过前面几讲的学习,你应该知道了,语法只是一种接口。很多人说到设计DSL,脑子里实际想的也只是设计一种语法。所以,从软件设计的角度看,DSL最终呈现出来的语法只是一种接口,但最重要的是它包裹的模型。
|
||||
|
||||
Martin Fowler在他的《领域特定语言》这本书中,将这个模型称为语义模型(Semantic Model)。不过,在我看来,Martin Fowler起这个名字是站在语言开发的角度,毕竟语义这个词,只有学过编译原理的人才好理解。所以,这里真正的重点是模型。
|
||||
|
||||
想要实现一个DSL,可以这么说,DSL的语法本身都是次要的,模型才是第一位的。当你有了模型之后,所谓的构建DSL,就相当于设计一个接口,将模型的能力暴露出来。
|
||||
|
||||
当把DSL理解成接口,我们接受DSL的心理负担就小了很多。你可以想一想,它和你熟悉的REST API其实没有什么本质的不同。
|
||||
|
||||
既然是接口,形式就可以有很多种,我们经常能接触到的DSL主要有两种:外部DSL和内部 DSL。Martin Fowler在他的书中还提到了语言工作台(Language Workbench),不过,这种做法在实际工作中用到的不多,我们暂且忽略。
|
||||
|
||||
外部DSL和内部DSL的区别就在于,DSL采用的是不是宿主语言(Host Language)。你可以这么理解,假设你的模型主要是用Java写的,如果DSL用的就是Java语言,它就是内部DSL,如果DSL用的不是Java,比如,你自己设计了一种语法,那它就是外部DSL。
|
||||
|
||||
把概念说清楚了,一些问题便迎刃而解了。这也可以解释为什么DSL让有些人畏惧了,原因就是说起 DSL,这些人想到的就是自己设计语法的外部的 DSL。其实,即便是外部DSL,也不一定要设计一门语法,我们甚至可以借助已有的语法来完成。比如,很多程序员熟悉的一种语法:XML。
|
||||
|
||||
如果你是一个Java程序员,XML就再熟悉不过了。从Ant到Maven,从Servlet到Spring,曾经的XML几乎是无处不在的。如果你有兴趣,可以去找一些使用Ant做构建工具的项目,项目规模稍微大一点,其XML配置文件的复杂程度就不亚于普通的源代码。
|
||||
|
||||
因为它本质上就是一种用于构建领域的DSL,只不过,它的语法是XML而已。正是因为这种DSL越来越复杂,后来,一种新的趋势渐渐兴起,就是用全功能语言(也就是真正的程序设计语言)做DSL,这是后来像Gradle这种构建工具逐渐流行的原因,它们只是用内部DSL替换了外部DSL。
|
||||
|
||||
从复杂度而言,自己设计一种外部DSL语法,大于利用一种现有语法做外部DSL,二者之间的差别在于谁来开发解析器。而外部DSL的复杂度要大于内部DSL,因为内部DSL连解析的过程都省略了。从实用性的角度,更好地挖掘内部DSL的潜力对我们的实际工作助益更多。
|
||||
|
||||
## 代码的表达性
|
||||
|
||||
你或许会有一个疑问,内部DSL听上去就是一个程序库啊!你这个理解是没错的。我们前面说过,语言设计就是程序库设计,程序库设计就是语言设计。当一个程序库只能用在某个特定领域时,它就是一个内部DSL,这个内部DSL的语法就是这个程序库的用法。
|
||||
|
||||
我先用一个例子让你感受一下内部DSL,它来自Martin Fowler的《领域特定语言》。我们要创建一个Computer的实例,如果用普通风格的代码写出来,应该是这个样子:
|
||||
|
||||
```
|
||||
Processor p = new Processor(2, 2500, Processor.Type.i386); Disk d1 = new Disk(150, Disk.UNKNOWN_SPEED, null);
|
||||
Disk d2 = new Disk(75, 7200, Disk.Interface.SATA);
|
||||
return new Computer(p, d1, d2);
|
||||
|
||||
```
|
||||
|
||||
而用内部 DSL 写出来,则是这种风格:
|
||||
|
||||
```
|
||||
computer()
|
||||
.processor()
|
||||
.cores(2)
|
||||
.speed(2500)
|
||||
.i386()
|
||||
.disk()
|
||||
.size(150)
|
||||
.disk()
|
||||
.size(75)
|
||||
.speed(7200)
|
||||
.sata()
|
||||
.end();
|
||||
|
||||
```
|
||||
|
||||
如果这是一段普通的Java代码,我们看到一连串的方法调用,一定会说,这段代码糟糕至极!但在这个场景下,和前面的代码相比,这段代码省去了好多变量,反而是清晰了。这其中的差别在哪里呢?
|
||||
|
||||
之所以我们会觉得这种一连串的方法调用可以接受,一个重要的原因是,这段代码并不是在做动作,而是在进行声明。做动作是在说明怎么做(How),而声明的代码则是在说做什么(What)。
|
||||
|
||||
二者的抽象级别是不同的,“怎么做”是一种实现,而“做什么”则体现着意图。**将意图与实现分离开来**,是内部DSL与普通的程序代码一个重要的区别,同样,这也是一个好设计的考虑因素。
|
||||
|
||||
Martin Fowler在讨论DSL定义时,提到了DSL的4个关键元素:
|
||||
|
||||
- 计算机程序设计语言(Computer programming language);
|
||||
- 语言性(Language nature);
|
||||
- 受限的表达性(Limited expressiveness);
|
||||
- 针对领域(Domain focus)。
|
||||
|
||||
其中,语言性强调的就是DSL要有连贯的表达能力。也就是说,你设计自己的DSL时,重点是要体现出意图。抛开是否要实现一个DSL不说,的确,**程序员在写代码时应该关注代码的表达能力**,而这也恰恰是很多程序员忽略的,同时也是优秀程序员与普通程序员拉开差距的地方。
|
||||
|
||||
普通程序员的关注点只在于功能如何实现,而优秀的程序员会懂得将不同层次的代码分离开来,将意图和实现分离开来,而实现可以替换。
|
||||
|
||||
说到这里,你就不难理解学习内部DSL的价值了,退一步说,你不一定真的要自己设计一个内部DSL,但学会将意图与实现分离开,这件事对日常写代码也是有极大价值的。
|
||||
|
||||
有了这个意识,你就可以很好地理解程序设计语言的一个重要发展趋势:声明式编程。现在一些程序设计语言的语法就是为了方便进行声明式编程,典型的例子就是Java的Annotation。正是它的出现,Spring原来基于XML的外部DSL就逐步转向了今天常用的内部DSL了,也就是很多人熟悉的Java Config。
|
||||
|
||||
你会发现,虽然我在这说的是写代码,但分离意图和实现其实也是一个重要的设计原则,是的,**想写好代码,一定要懂得设计**。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们讨论了领域特定语言,这是针对某个特定领域的程序设计语言。DSL在软件开发领域中得到了广泛的应用。要实现一个DSL,首先要构建好模型。
|
||||
|
||||
常见的DSL主要是外部 DSL和内部DSL。二者的主要区别在于,DSL采用的是不是宿主语言。相对于外部 DSL,内部DSL的开发成本更低,与我们的日常工作结合得更加紧密。
|
||||
|
||||
内部DSL体现更多的是表达能力,相对于传统的代码编写方法而言,这种做法很好地将作者的意图体现了出来。即便我们不去设计一个内部DSL,这种写代码的方式也会对我们代码质量的提高大有助益。
|
||||
|
||||
关于语言,已经讲了四讲,我们先告一段落。下一讲,我们要来讨论编程范式,也就是做设计的时候,我们可以利用的元素有哪些。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**好的设计要迈向DSL,我们可以从编写有表达性的代码起步**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/75/33/7516280b19aacd35a2235e54d14b0833.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你分享一下,你还能举出哪些DSL的例子呢?欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
119
极客时间专栏/软件设计之美/设计一个软件—程序设计语言/加餐 | 再八卦几门语言!.md
Normal file
119
极客时间专栏/软件设计之美/设计一个软件—程序设计语言/加餐 | 再八卦几门语言!.md
Normal file
@@ -0,0 +1,119 @@
|
||||
<audio id="audio" title="加餐 | 再八卦几门语言!" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/62/59/62f575fe54ca640e584888569358c959.mp3"></audio>
|
||||
|
||||
你好,我是郑晔!
|
||||
|
||||
软件设计是一个比较烧脑的话题,对于一些同学来说,学起来还是有一些辛苦的。所以,我准备了这次加餐,让大家在前面高密度地狂奔了一段时间之后,稍微休息一下。
|
||||
|
||||
我在第8讲中讲了程序设计语言的发展,有同学觉得不过瘾,想了解其他语言的发展过程。那好,我们就来谈谈几门比较吸引眼球的程序设计语言。
|
||||
|
||||
## C#
|
||||
|
||||
当年Java开始起势的时候,微软还处于自己的巅峰,它当然不想错过Java这么有前景的东西。但是,微软从来就不会老老实实按照标准做事,所以,你会看到微软手中的Basic已经很不像Basic了,微软的C++也有着自己的扩展。
|
||||
|
||||
于是,微软也想做出一个自己的Java,J++就出现了。但是,这不是一个正常的Java,引发了SUN的不满,将微软告上法庭。最终,双方庭外和解,微软不再祸害Java,J++停止更新。
|
||||
|
||||
但有一点不得不承认,微软在Windows上的JVM性能是当时最好的,因为操刀J++的是Anders Hejlsberg,他是全世界最顶级的程序员。微软为了不与Java开启的受控(Managed)代码浪潮擦肩而过,于是,转身又推出了C#和.NET。
|
||||
|
||||
C#的初版本简直和Java一模一样,一个Java程序员几乎不用培训就可以成为一个C#程序员。所以,从语言的角度来说,最初的C#并没有对行业做出什么贡献。
|
||||
|
||||
不过,既然有Anders Hejlsberg在背后,事情当然不会就这么简单收尾。C#在语言特性上开始一路狂奔,一个更强大的C#崭露头角。像Lambda、类型推演这些特性早早就落户C#了。
|
||||
|
||||
然而,C#时运不济,它的上升期遇到了微软的下降期。越来越多的公司选择了Java,越来越多的程序员拥抱了Java,而语言模型上表现优秀的C#则遭遇了冷落。
|
||||
|
||||
当年Java号称“一个语言,多个平台”,而.NET则是“一个平台,多个语言”。结果呢,.NET的“一个平台”并不足以吸引更多的公司和程序员的投入,除了微软自己,其他在上面开发语言的尝试通常都是浅尝辄止。而JVM虽然目标不是为了多语言,但丝毫不妨碍很多人在上面开发新语言,比如,Groovy、Scala、Clojure等等。
|
||||
|
||||
最终,**JVM成了“多个语言,多个平台”**。随着微软的逐步开放,.NET也开始迈向了多平台,**C#也成了一门跨平台的语言**,遗憾的是,为时已晚,Java 已经成就了一番霸业。
|
||||
|
||||
如果出于学习的目的,C#绝对是值得一学的程序设计语言,毕竟微软在语言设计上还是很有一套的。Java语言的进化是非常缓慢的,尤其是SUN的衰退又耽误了很多年。所以,从语言特性上来看,说C#领先Java十年并不夸张。
|
||||
|
||||
## JavaScript
|
||||
|
||||
JavaScript从诞生之日起就扮演着一个不受待见的角色,Brendan Eich发明JavaScript完全是为了应付工作,因为他当时供职的Netscape需要让网页上的元素动起来。
|
||||
|
||||
雷锋和雷锋塔有什么关系?Java和JavaScript有什么关系?这是一个经常被人提起的段子,但实际上,JavaScript和Java真的有关系,关系就是蹭热度。当时的Java给世界描绘了一个美好的未来,让无数人心潮澎湃,JavaScript就想借一下Java的东风。
|
||||
|
||||
JavaScript仅仅用10天就设计出来,所以,在它的实现中,包含了各种奇怪的问题。不过,它还是体现出了BrendanEich的功底,比如,JavaScript提供了对各种编程范式的支持。其实,他真正想做的是一门函数式编程的语言,但向现实妥协的结果就是,借助了C风格的语法,函数式编程的底子却留在了JavaScript里。
|
||||
|
||||
虽然在今天看来,在浏览器上JavaScript一枝独秀,但当年它也是有竞争对手的,那个年代无处不在的微软,出手做了一个VBScript。但是,如同微软错过了互联网时代一样,与Windows结合更加紧密的VBScript也在这场竞争中败下阵来。
|
||||
|
||||
当年,与JavaScript联系在一起的,更多的是像走马灯之类的页面特效。让JavaScript真正第一次得到重视是Ajax这门技术。Ajax的出现,让页面的元素可以与远程的服务器进行交互,JavaScript开始由一个小玩具变成了一门值得研究的技术,前端的表现力得到了大幅度的提升。
|
||||
|
||||
但很长一段时间里,JavaScript一直都不是一门正式的语言,对于很多人来说,它只是要做前端时顺便学习的语言。这种现象一直持续到Node.js的诞生。
|
||||
|
||||
Node.js其实是一个集成商,它之所以能有良好的表现要归功于V8这个JavaScript引擎。而V8的出现则要归因于Google对于网络应用前景的格局。
|
||||
|
||||
想当年的浏览器大战,Netscape和IE拼得你死我活,最终IE凭借Windows的优势成了赢家,Netscape也退出了历史舞台。然而,胜利后的微软认为天下已平,竟然解散了IE的团队,导致了程序员们要在很长时间内忍受IE这个既不标准又慢的浏览器。
|
||||
|
||||
这就给后来居上者留下了空间。最有名的两个后来者,一个是Netscape的转世Firefox,另外一个就是Google出品的Chrome。
|
||||
|
||||
**Chrome认为未来的页面一定要有更强的表现力,所以,一个高效强大的浏览器是必需的**。既然慢是个大问题,Chrome就着力解决慢这个问题,甚至不惜开发了一个新的JavaScript引擎,也就是V8,它的重点就是解决JavaScript执行慢的问题。可等微软看懂Google的操作,幡然悔悟,重新投入浏览器的开发之时,大势已去。Chrome成了新的霸主。
|
||||
|
||||
Chrome有一点做得很好,V8一开始就是一个独立的JavaScript引擎。所以Node.js才可以很方便地把它借鉴过去。除了V8的性能优势,Node.js还引入了异步IO的模型,这刚好与JavaScript事件驱动的特点相吻合。
|
||||
|
||||
Node.js刚一登场便赢得了满堂喝彩。因为,人们认识到,JavaScript原来不只能在浏览器中运行,也可以跑在服务器端。很快,**NPM这个包管理器登场,降低了众多开发者参与的门槛,JavaScript迎来了属于自己的爆发**,各种各样的程序库让人眼花缭乱。
|
||||
|
||||
前端开发也由少数人的爱好,成为了一个专属的职位,像React、Angular、Vue等框架的出现,更是让前端开发有了工程的味道,而不再是小打小闹了。
|
||||
|
||||
一旦JavaScript突破了浏览器的限制,给人们的想象空间就大了许多。除了服务器端,有人想把JavaScript用在嵌入式开发中,有人想把它用在手机开发中。**JavaScript成了一门全平台覆盖的语言**,大有一统天下的架势。
|
||||
|
||||
不过,JavaScript作为一门语言,其问题之多也是由来已久的。虽然JavaScript本身也在不断进化,但沉重的历史包袱让很多人都想开发出新的语言去替代它。所以,在JavaScript社区中,很多人把它看成了一种Web上的汇编语言,把新的语言编译成JavaScript,这样,就可以在浏览器上运行了。从早先的CoffeeScript到现在的TypeScript,甚至新一代的JavaScript标准都是以这种方式进行开发的。
|
||||
|
||||
当然还有人有更高的追求,他们认为仅仅在语言层面屏蔽JavaScript是不够的。**WebAssembly就是想成为Web上真正的汇编**,真正取JavaScript而代之,事实上,它也得到了很多人的支持。不过,这种努力至今仍在继续中,还有很长的路要走。
|
||||
|
||||
JavaScript就是这样,从一出生就不受待见,到今天,很多人仍想把它干掉。但这并不妨碍它在软件开发的历史中写下浓墨重彩的一笔。
|
||||
|
||||
## Go和Rust
|
||||
|
||||
在系统编程方面,C语言是当之无愧的霸主,然而,C语言已经快50岁了。在计算机这个快速变化的行业里,50年长得令人发指。在这50年中,C从被人质疑发展到如日中天,再到应用开发的地位逐步被取代。如今,它只在系统编程有着无可替代的作用。事实上,人们也一直想着替代它。
|
||||
|
||||
C的强项是对于计算机模型的适度抽象,弱项却是在程序的组织上。因为在C诞生那个年代,程序的规模还不算太大。然而C的成功却让程序的规模越来越大,大到超出了C语言的能力范畴。于是,有人想着把面向对象加到C语言里,扩大程序的组织规模。这方面的尝试,我们都熟悉的是C++。
|
||||
|
||||
不过,C++只风光了一段时间,就被Java盖了过去。C++本身有一段时间变成了语言特性的试验田,泛型编程,尤其是模板元编程的出现,一度让人怀疑人生。它成了高手极度喜爱,普通人一脸懵硬着头皮写的程序语言。
|
||||
|
||||
但更重要的是,C++背负了C语言所有的历史负担。所以,很多C的问题在C++里面依然存在,比如,内存管理。虽然C++有各种补丁方案,但你必须对C++极其了解,才能写好C++,然而,这个要求对于一个工程化的语言来说,实在是太高了。
|
||||
|
||||
所以,无论是C还是C++,都是在执行性能上无可挑剔,在代码编写上一地鸡毛,人们还是需要一门更有开发效率的系统编程语言。
|
||||
|
||||
时间来到新千年,又有人出手想代替C语言,这回出手的人物背景强大,他就是Ken Thompson,C语言的亲爹。2009年,如日中天的Google推出了Go语言,再加上Ken Thompson和Rob Pike这样早期的Unix先驱站在它背后,Go语言的前景给人无限的遐想。
|
||||
|
||||
Go语言的语法设计是简单的,基本上,你花一个晚上就可以把Go语言完整地学习一遍。**它在接口设计和并发上的处理方式都给人眼前一亮的感觉**。人们热切地期盼着它成为下一个系统编程语言的霸主。
|
||||
|
||||
但事实并没有像人们想象地那样发生,除了初生之时引起了一片欢呼,Go语言很长一段时间都在低位徘徊。比较有趣的是,中国有很多开发者对于Go的喜爱程度极高,一度让Go语言在中国的热度远远超过了全球的平均水平。之所以Go没有很快赢得人们的关注,因为它关注的系统编程领域并没有太多的机会留给它,人们嘴上喊着热爱,手里还依然用C写着代码。
|
||||
|
||||
不过,机会总是留给做好准备的人,语言也不例外。随着Docker这套虚拟化软件登上历史舞台,Go语言终于有了用武之地。人们开始意识到,**原来云计算领域还有一些基础设施要写,用C的话,不好维护;用Java的话,浪费资源;Go恰如其分地解决了大部分问题**。
|
||||
|
||||
一批新生代的基础设施纷纷出炉,除了Docker之外,还有帮助人们实现容器部署的Kubernetes,也就是k8s,还有辅助Service Mesh的istio等等。
|
||||
|
||||
虽然在云计算基础设施中,Go赢得了一席之地,这属于开辟了一片蓝海。在传统系统编程的红海中,Go语言其实并没有做出什么特别的成绩,对于实时性和性能要求极高的领域,Go语言有一个拿不出手的弱项,也就是它的GC。
|
||||
|
||||
自动的内存管理固然是简化程序员工作的一项重要手段,但对于系统编程这个领域而言,GC显然还没有表现得能够赢得大家的信任,而且,在可见的未来,也不会有明显的起色。
|
||||
|
||||
所以,在系统编程领域替代C的征程上,大家都还有机会。这条赛道上目前最有力的竞争者是Rust。
|
||||
|
||||
Rust出自Mozilla,这是浏览器Firefox背后的公司,它原本是Mozilla员工Graydon Hoare的个人项目,后来得到了公司赞助,由一个练手的项目成为了一个正式的项目。
|
||||
|
||||
Rust对初学者并不友好,对于习惯“少废话、先动手”的程序员而言,Rust的初体验可能一点都不好,按照习惯方式写出来的代码很可能是无法编译的。比如,Rust的“变”量缺省是不变的,再有,想写好Rust程序,先要了解所有权的概念。不过,也恰恰是因为这些限制,让Rust写出来的程序犯下低级错误的概率大大降低了。
|
||||
|
||||
**如果你理解系统编程面临的问题,以及现代软件开发的趋势,你会发现,Rust提供的选项很好地规避了许多问题**。比如,之所以要用不变性,是因为它可以规避掉很多因为“变”带来的问题,这是函数式编程给软件开发贡献的一个重要思路。再比如,所有权的概念也是为了防止一块内存不同的人去改,造成各种问题,同时,也给内存管理提供了新的思路。
|
||||
|
||||
内存不能让程序员管,这已经成了共识,但主流的GC方案又不能满足系统编程的需要,Rust则给出了第三种方案,把内存当作一种资源,申请下来就初始化好,出了生命周期就销毁掉。之所以能够做到这点,还是要拜Rust强大的编译器所赐,因为所有权的存在,编译器可以很好地分析出内存到底该什么时候释放。
|
||||
|
||||
Rust成为系统编程语言的有力竞争者还有一个原因,它背靠着LLVM。LLVM是一套编译器的基础设施,它的出现是因为传统的工具链GCC太过沉重。LLVM把编译器的前端和后端分离开来,语言开发者只要关注前端,设计好各种语言特性,就可以利用LLVM的后端进步的优势,比如,不断优化带来的性能提升。对系统编程语言来说,一个重点就是可移植性。
|
||||
|
||||
系统编程一个重要的战场就是各种嵌入式设备,而绝大多数设备都只支持C/C++语言。一个重要的原因就是谁来移植编译器,C/C++的后端常常是厂商提供支持的,而其他语言则多半无人理睬。现在有了LLVM的基础设施,一个芯片厂商只要支持了LLVM的后端,用LLVM前端开发出的语言也就都得到了支持。这对于新兴语言来说,绝对是一个巨大的好消息。
|
||||
|
||||
Rust在语言层面表现出来的安全特性,帮它赢得了像微软、亚马逊这样大厂的注意;占用资源少的内存管理方式,让一些人开始尝试使用它编写Linux驱动;更多的移植可能,也让它成为了嵌入式开发的一种考虑。在这场C语言替代者的竞争中,Rust值得期待!
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天的内容主要是为了让大家放松一下,所以,我们也不做内容上的总结了。
|
||||
|
||||
每个程序员除了学习当下要用到的知识之外,一般都会对自己的未来做一些技术储备,其中,判断技术趋势就是我们在投资未来时的一个重要参考。
|
||||
|
||||
如何才能更好地判断未来技术发展趋势呢?就是去知道一些技术的发展历史。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你分享一下,你看好哪门语言未来的发展?为什么?欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
118
极客时间专栏/软件设计之美/设计一个软件—编程范式/12 | 编程范式:明明写的是Java,为什么被人说成了C代码?.md
Normal file
118
极客时间专栏/软件设计之美/设计一个软件—编程范式/12 | 编程范式:明明写的是Java,为什么被人说成了C代码?.md
Normal file
@@ -0,0 +1,118 @@
|
||||
<audio id="audio" title="12 | 编程范式:明明写的是Java,为什么被人说成了C代码?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e8/33/e8692dd5903a84d1c6061b1c4f161f33.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
在上一个小模块,我给你讲了程序设计语言,帮助你重新审视一下自己最熟悉的日常工具。但是,使用程序设计语言是每个程序员都能做到的,可写出的程序却是千差万别的。这一讲,我们就来看看这些差异到底是怎样造成的。
|
||||
|
||||
在开始之前,我先给你讲一个小故事。
|
||||
|
||||
在一次代码评审中,小李兴致勃勃地给大家讲解自己用心编写的一段代码。这段代码不仅实现了业务功能,还考虑了许多异常场景。所以,面对同事们提出的各种问题,小李能够应对自如。
|
||||
|
||||
在讲解的过程中,小李看到同事们纷纷点头赞许,心中不由得生出一丝骄傲:我终于写出一段拿得出手的代码了!讲解完毕,久久未曾发言的技术负责人老赵站了起来:“小李啊!你这段代码从功能上来说,考虑得已经很全面了,这段时间你确实进步很大啊!”
|
||||
|
||||
要知道,老赵的功力之深是全公司人所共知的。能得到老赵的肯定,对小李来说,那简直是莫大的荣耀。还没等小李窃喜的劲过去,老赵接着说了,“但是啊,写代码不能只考虑功能,你看你这代码写的,虽然用的是Java,但写出来的简直就是C代码。”
|
||||
|
||||
正在兴头上的小李仿佛被人当头泼了一盆冷水,我用的是Java啊!一门正经八百的面向对象程序设计语言,咋就被说成写的是C代码了呢?
|
||||
|
||||
“你看啊!所有的代码都是把字段取出来计算,然后,再塞回去。各种不同层面的业务计算混在一起,将来有一点调整,所有的代码都得跟着变。”老赵很不客气地说。还没缓过神来的小李虽然想辩解,但他知道老赵说得是一针见血,指出的问题让人无法反驳。
|
||||
|
||||
在实际的开发过程中,有不少人遇到过类似的问题。老赵的意思并不是小李的代码真就成了C代码,而是说用Java写的代码应该有Java的风格,而小李的代码却处处体现着C的风格。
|
||||
|
||||
那这里所谓代码的风格到底是什么呢?它就是编程范式。
|
||||
|
||||
## 编程范式
|
||||
|
||||
编程范式(Programming paradigm),指的是程序的编写模式。使用了什么编程范式,通常意味着,你主要使用的是什么样的代码结构。从设计的角度说,编程范式决定了你在设计的时候,可以使用的元素有哪些。
|
||||
|
||||
现在主流的编程范式主要有三种:
|
||||
|
||||
- 结构化编程(structured programming);
|
||||
- 面向对象编程(object-oriented programming);
|
||||
- 函数式编程(functional programming)。
|
||||
|
||||
**结构化编程**,是大部分程序员最熟悉的编程范式,它通过一些结构化的控制结构进行程序的构建。你最熟悉的控制结构应该就是if/else这样的选择结构和do/while这样的循环结构了。
|
||||
|
||||
结构化编程是最早普及的编程范式,现在最典型的结构化编程语言是C语言。C语言控制结构的影响极其深远,成为了很多程序设计语言的基础。
|
||||
|
||||
**面向对象编程**,是现在最主流的编程范式,它的核心概念就是对象。用面向对象风格写出的程序,本质上就是一堆对象之间的交互。面向对象编程给我们提供了一种管理程序复杂性的方式,其中最重要的概念就是多态(polymorphism)。
|
||||
|
||||
现在主流的程序设计语言几乎都提供面向对象编程能力,其中最典型的代表当属Java。
|
||||
|
||||
**函数式编程**,是近些年重新崛起的编程范式。顾名思义,它的核心概念是函数。但是,它的函数来自于数学里面的函数,所以,和我们常规理解的函数有一个极大的不同:不变性。也就是说,一个符号一旦创建就不再改变。
|
||||
|
||||
函数式编程的代表性语言应该是LISP。我们在[第8讲](https://time.geekbang.org/column/article/245868)曾经提到过它。之所以要把这位老祖宗搬出来,因为确实还没有哪门函数式编程语言能够完全独霸一方。
|
||||
|
||||
编程范式不仅仅是提供了一个个的概念,更重要的是,它对程序员的能力施加了约束。
|
||||
|
||||
- 结构化编程,限制使用goto语句,它是对程序控制权的**直接**转移施加了约束。
|
||||
- 面向对象编程,限制使用函数指针,它是对程序控制权的**间接**转移施加了约束。
|
||||
- 函数式编程,限制使用赋值语句,它是对程序中的**赋值**施加了约束。
|
||||
|
||||
之后讲到具体的编程范式时,我们再来展开讨论,这些约束到底是什么意思。
|
||||
|
||||
与其说这些编程范式是告诉你如何编写程序,倒不如说它们告诉你**不要**怎样做。理解这一点,你才算是真正理解了这些编程范式。
|
||||
|
||||
如果你去搜索编程范式的概念,你可能会找到更多的编程范式,比如,逻辑式编程,典型的代表是Prolog语言。但这些编程范式的影响力和受众面都相当有限。如果你想扩展自己的知识面,可以去了解一下。
|
||||
|
||||
## 多范式编程
|
||||
|
||||
从道理上讲,编程范式与具体语言的关系不大,这就好比你的思考与用什么语言表达是无关的。但在实际情况中,每一种语言都有自己的主流编程范式。比如,C语言主要是结构化编程,而 Java主要是面向对象编程。
|
||||
|
||||
不过,虽然每种语言都有自己的主流编程范式,但丝毫不妨碍程序员们在学习多种编程范式之后,打破“次元壁”,将不同编程范式中的优秀元素吸纳进来。这里的重点是“优秀”,而非“所有”。
|
||||
|
||||
举个例子,在Linux的设计中,有一个虚拟文件系统(Virtual File System,简称 VFS)的概念,你可以把它理解成一个文件系统的接口。在所有的接口中,其中最主要的是file_operations,它就对应着我们熟悉的各种文件操作。
|
||||
|
||||
下面是这个[结构的定义](https://github.com/torvalds/linux/blob/master/include/linux/fs.h),这个结构很长,我从中截取了一些我们最熟悉的操作:
|
||||
|
||||
```
|
||||
struct file_operations {
|
||||
loff_t (*llseek) (struct file *, loff_t, int);
|
||||
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
|
||||
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
|
||||
int (*open) (struct inode *, struct file *);
|
||||
int (*flush) (struct file *, fl_owner_t id);
|
||||
int (*release) (struct inode *, struct file *);
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果你要开发一个自己的文件系统,只需要把支持的接口对应着实现一遍,也就是给这个结构体的字段赋值。
|
||||
|
||||
我们换个角度看,这个结构体主要的字段都是函数指针,文件系统展现的行为与这些函数的赋值息息相关。只要给这个结构体的字段赋值成不同的参数,也就是把不同的函数关联上,这个文件系统就有了不同的行为。如果熟悉面向对象编程,你会发现,这不就是多态吗?
|
||||
|
||||
C是一门典型的结构化编程语言,而VFS的设计展现出来的却是面向对象编程的特点,编程范式的“次元壁”在这里被打破了。
|
||||
|
||||
事实上,类似的设计还有很多,比如,Java里有一个著名的基础库,Google出的Guava。它里面就提供了函数式编程的基础设施。在Java 8之前,Java在语法上并不支持函数式编程,但这并不妨碍我们通过类模拟出函数。
|
||||
|
||||
配合着Guava提供的基础设施,我很早就开始把函数式编程的方式运用在Java中了。同样,C++有一个functor的概念,也就是函数对象,通过重载 () 这个运算符,让对象模拟函数的行为。
|
||||
|
||||
无论是在以结构化编程为主的语言中引入面向对象编程,还是在面向对象为主的语言中引入函数式编程,在一个程序中应用多种编程范式已经成为了一个越来越明显的趋势。
|
||||
|
||||
不仅仅是在设计中,现在越来越多的程序设计语言开始将不同编程范式的内容融合起来。Java从Java 8开始引入了Lambda语法,现在我们可以更优雅地写出函数式编程的代码了。同样,C++ 11开始,语法上也开始支持Lambda了。
|
||||
|
||||
之所以多范式编程会越来越多,是因为我们的关注点是做出好的设计,写出更容易维护的代码,所以,我们会尝试着把不同编程风格中优秀的元素放在一起。比如,**我们采用面向对象来组织程序,而在每个类具体的接口设计上,采用函数式编程的风格,在具体的实现中使用结构化编程提供的控制结构**。
|
||||
|
||||
让我们回过头,看看开篇故事小李的委屈吧!老赵之所以批评小李,关键点就是小李并没有把各种编程范式中优秀的元素放到一起。Java是提供对面向对象的支持,面向对象的强项在于程序的组织,它归功的设计元素应该是对象,程序应该是靠对象的组合来完成,而小李去把它写成了平铺直叙的结构化代码,这当然是不值得鼓励的。
|
||||
|
||||
对于今天的程序员来说,**学习不同的编程范式,将不同编程范式中的优秀元素应用在我们日常的软件设计之中,已经由原来的可选项变成了现在的必选项**。否则,你即便拥有强大的现代化武器,也只能用作古代的冷兵器。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们今天讨论了编程范式。编程范式指的是程序的编写模式。现在主流的编程范式主要有三种:结构化编程、面向对象编程和函数式编程。编程范式对程序员的能力施加了约束,理解编程范式的一个关键点在于,**哪些事情不要做**。
|
||||
|
||||
从道理上讲,编程范式与具体语言的关系不大,但很多语言都有着自己主流的编程范式。但现在的一个趋势是,打破编程范式的“次元壁”,把不同编程范式中优秀的元素放在一起。
|
||||
|
||||
一方面,我们可以通过设计,模拟出其他编程范式中的元素,另一方面,程序设计语言的发展趋势也是要融合不同编程范式中优秀的元素。学习不同的编程范式,已经成为每个程序员的必修课。
|
||||
|
||||
在接下来的几讲里,我们就来深入地讨论一下各种编程范式。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**学习不同的编程范式,将其中优秀的元素运用在日常工作中**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5b/eb/5b70cc56084dca6bfd966d0259f03ceb.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
今天我们谈到了编程范式,每个程序员都会有自己特别熟悉的编程范式,但今天我想请你分享一下,你在学习其他编程范式时,给你思想上带来最大冲击的内容是什么。欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
113
极客时间专栏/软件设计之美/设计一个软件—编程范式/13 | 结构化编程:为什么做设计时仅有结构化编程是不够的?.md
Normal file
113
极客时间专栏/软件设计之美/设计一个软件—编程范式/13 | 结构化编程:为什么做设计时仅有结构化编程是不够的?.md
Normal file
@@ -0,0 +1,113 @@
|
||||
<audio id="audio" title="13 | 结构化编程:为什么做设计时仅有结构化编程是不够的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/15/c7/1530e9d5c695a27e01a60161df1872c7.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
上一讲,我们讲到了编程范式,现在开发的一个重要趋势就是多种编程范式的融合,显然,这就要求我们对各种编程范式都有一定的理解。从这一讲开始,我们就展开讨论一下几个主要的编程范式。首先,我们来讨论程序员们最熟悉的编程范式:结构化编程。
|
||||
|
||||
很多人学习编程都是从C语言起步的,C语言就是一种典型的结构化编程语言。C的结构化编程也渗透进了后来的程序设计语言之中,比如,C++、Java、C#等等。
|
||||
|
||||
说起结构化编程,你一定会想起那些典型的控制结构,比如:顺序结构、选择结构和循环结构,还会想到函数(如果用术语讲,应该叫subroutine)和代码块(block)。这几乎是程序员们每天都在使用的东西,对于这些内容,你已经熟悉得不能再熟悉了。
|
||||
|
||||
但是,不知道你是否想过这样一个问题?面向对象编程之所以叫面向对象,是因为其中主要的概念是对象,而函数式编程主要的概念是函数。可结构化编程为什么叫结构化呢,难道它的主要概念是结构?这好像也不太对。
|
||||
|
||||
其实,**所谓结构化,是相对于非结构化编程而言的**。所以,要想真正了解结构化编程,就要回到非结构化的古老年代,看看那时候是怎么写程序的。也就是说,只有了解结构化编程的发展历程,你才能更好地认清结构化编程的不足。
|
||||
|
||||
没错,正是因为你太熟悉结构化编程了,我反而要说说它的不足,告诉你在今天做设计,仅仅有结构化编程已经不够了。好,我们就先从结构化编程的演化讲起。
|
||||
|
||||
## 结构从何而来
|
||||
|
||||
你一定知道,结构化编程中的顺序结构就是代码按照编写的顺序执行,选择结构就是if/else,而循环结构就是do/while。这几个关键字一出,是不是就有一股亲切感扑面而来?
|
||||
|
||||
但是,你有没有想过,这些结构是怎么来的呢?
|
||||
|
||||
我们都知道,今天的编程语言都是高级语言,那对应着就应该有低级语言。就今天这个讨论而言,比高级语言低级的就是汇编语言。如果你去了解汇编指令,你会发现,它的编程模式与我们习惯的高级语言的编程模式有着很大的差异。
|
||||
|
||||
使用汇编写代码,你面对的是各种寄存器和内存地址。那些我们在高级语言中经常面对的变量,需要我们自己想办法解决,而类型,则统统没有。至于前面提及的那些控制结构,除了顺序结构之外,在汇编层面也是不存在的。
|
||||
|
||||
连if/else和do/while都没有,让我怎么写程序啊?
|
||||
|
||||
别急,在汇编里有个goto,它可以让代码跳转到另外一个地方继续执行。还有几个比较指令,让你可以比较两个值。
|
||||
|
||||
我们先想一下, if语句做的是什么呢?执行一个表达式,然后,根据这个表达式返回值是真是假,决定执行if后面的代码,还是else后面的代码。
|
||||
|
||||
好,如果我们这么写汇编代码,就是先执行一段代码,把执行结果和0比较。如果不等于0就接着执行,等于0就跳转到另外一个地方执行,这不就和if语句的执行逻辑是一样的吗?
|
||||
|
||||
没错,如果你尝试反汇编一段有if语句的C代码,也会看到类似的汇编代码。如果你是一个Java程序员,也可以通过javap反汇编一段Java类,也可以看到类似的字节码,因为字节码在Java里就相当于汇编。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/91/e1/9100bceaf7456e8df68yyd9b59c240e1.jpg" alt="">
|
||||
|
||||
有了对if语句的理解,再来理解do/while就容易了,就是在判断之后,是决定跳到另外一个地方,还是继续执行下面的代码。如果执行下面的代码,执行到后面就会有一个goto让我们跳回来,再作一次判断。
|
||||
|
||||
了解这些,再加上汇编语言本身的顺序执行,你最熟悉的控制结构就都回来了。所以,即便是用汇编,你依然可以放心地以原来的方式写代码了。
|
||||
|
||||
对于已经有了编程基础的你而言,理解这些内容并不难。但你有没有想过,以前的程序员真的就是用这样的控制结构写程序的吗?并不是。
|
||||
|
||||
原来的程序员面对的的确是这些汇编指令,但是他们是站在直接使用指令的角度去思考。所以,他们更习惯按照自己的逻辑去写,这其中最方便的写法当然就是需要用到哪块逻辑,就goto到哪里执行一段代码,然后,再goto到另外一个地方。
|
||||
|
||||
这种写起来自由自在的方式,在维护起来却会遇到极大的挑战,因为你很难预测代码的执行结果。有人可能只是图个方便,就goto到一个地方继续执行。可只要代码规模稍微一大,就几乎难以维护了,这便是非结构化的编程方式。
|
||||
|
||||
## Goto是有害的
|
||||
|
||||
于是,有人站了出来,提出编程要有结构,不能这么肆无忌惮,结构化编程的概念应运而生。这其中有个重要人物,你一定听说过,叫迪杰斯特拉(Dijkstra),他是1972年的图灵奖的获得者。
|
||||
|
||||
学习算法的时候,你肯定学过以他名字命名的最短路算法;学习操作系统时,你肯定学过PV原语,PV原语这个名字之所以看起来有些奇怪,主要因为Dijkstra是荷兰人。
|
||||
|
||||
1968 年,他在ACM通讯上发表了一篇文章,题目叫做《[Goto 是有害的](https://homepages.cwi.nl/~storm/teaching/reader/Dijkstra68.pdf)》(Go To Statement Considered Harmful),这篇文章引起了轩然大波。
|
||||
|
||||
不是所有人都会接受这种新想法,那些习惯了自由放纵的程序员对Dijkstra进行了无情的冷嘲热讽。他们认为,按照结构化的方法写效率太低。今天的你可能很难想象,C语言初问世之际,遭到最大的质疑是效率低。对,你没听错,C语言被质疑效率低,和Java面世之初遇到的挑战如出一辙。
|
||||
|
||||
提出这种质疑的人只看到了新生事物初生时的不足,却忽略了它们强大的可能性。他们不知道,一旦构建起新的模型,底层实现是可以不断优化的。
|
||||
|
||||
更重要的是,有了新的更高级却也更简单的模型,入门门槛就大幅度降低了,更多的人就可以加入进来,进一步促进这门语言的发展。程序员数量的增多,就可以证明这一点。
|
||||
|
||||
现在的很多程序员其实对底层知识的了解并不多,但丝毫不妨碍他们完成基本的业务功能。只要使用的人足够多,人们就会有更强的驱动力去优化底层实现。时至今日,已经很少有人敢说自己手写的汇编性能一定优于编译器优化后的结果。
|
||||
|
||||
最终这场争论逐渐平息,新的结构逐渐普及,也证明了Dijkstra是对的。goto语句的重要性逐渐降低,一些现代程序设计语言干脆在设计上就把goto语句拿掉了。
|
||||
|
||||
## 功能分解
|
||||
|
||||
你可能没有想过,这种结构化编程的思想最初是为了证明程序正确性而诞生的。
|
||||
|
||||
Dijkstra很早就得出一个结论:编程是一项难度很大的活动。因为一个程序会包含非常多的细节,远超一个人的认知能力范围,任何一个细微的错误都会导致整个程序出现问题。
|
||||
|
||||
所以,他提出goto语句是有害的,还有一个重要的原因是,Dijkstra为了证明程序的正确性,在借助数学推导的方法,将大问题拆分成小问题,逐步递归下去,拆分成更小的、可证明的单元时,他发现goto语句的存在影响了问题的递归拆分,导致问题无法被拆分。
|
||||
|
||||
你也许看出来了,我要说的就是结构化编程另一个重要的方面:功能分解。
|
||||
|
||||
功能分解就是将模块按照功能进行拆分。这样一来,一个大问题就会被拆解成一系列高级函数的组合,而这些高级函数各自再进一步拆分,拆分成一系列的低一级的函数,如此一步步拆分下去,每一个函数都需要按照结构化编程的方式进行开发。这一思想符合人们解决问题的直觉,对软件开发产生了深远的印象。
|
||||
|
||||
以此为基础,后来出现各种结构化分析和结构化设计的方法。将大型系统拆分成模块和组件,这些模块和组件再做进一步的拆分,这些都是来自结构化编程的设计思想。在今天看来,这一切简直再正常不过了,几乎融入了每个程序员的日常话语体系之中。
|
||||
|
||||
好,说完了结构化编程的发展历程,我们自然也就能看出它的不足之处了。
|
||||
|
||||
虽然,结构化编程是比汇编更高层次的抽象,程序员们有了更强大的工具,但人们从来不会就此满足,随之而来的是,程序规模越来越大。这时,结构化编程就显得力不从心了。用一个设计上的说法形容结构编程就是“抽象级别不够高”。
|
||||
|
||||
这就好比你拿着一个显微镜去观察,如果你观察的目标是细菌,它能够很好地完成工作,但如果用它观察一个人,你恐怕就很难去掌握全貌了。结构化编程是为了封装低层的指令而生的,而随着程序规模的膨胀,它组织程序的方式就显得很僵硬,因为它是自上而下进行分解的。
|
||||
|
||||
一旦需求变动,经常是牵一发而动全身,关联的模块由于依赖关系的存在都需要变动,无法有效隔离变化。显然,如何有效地组织这么大规模的程序并不是它的强项,所以,结构化编程注定要成为其它编程范式的基石。
|
||||
|
||||
如果站在今天的角度看,结构化编程还存在一个问题,就是可测试性不够,道理和上面是一样的,它的依赖关系太强,很难拆出来单独测试一个模块。
|
||||
|
||||
所以,仅仅会结构化编程,并不足以让我们做出好的设计,必须把它与其他编程范式结合起来,才能应对已经日益膨胀的软件规模。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们讲了程序员们最熟悉的编程范式:结构化编程。其实,从编程范式的角度,大概每个程序员都能够比较熟练地使用结构化编程提供给我们的编程元素。
|
||||
|
||||
今天这一讲,我主要带着你回顾了一下结构化编程的由来,让你知道即便是我们已经非常熟悉的一些控制结构,也是前人经过不断努力得来的。
|
||||
|
||||
除了知道结构化编程给我们提供了什么,我们还要看到它限制了什么,也就是goto语句。goto语句实际上就是一种对程序控制权的直接转移,它可以让程序跑到任何地方去执行。而对它施加限制之后,程序就不再是随意编写的了。
|
||||
|
||||
结构化编程带来的另一个重要方面是功能分解,也就是将大问题拆分成可以解决的小问题,这一思想影响深远,是我们做设计的根基所在。
|
||||
|
||||
我还给你讲了结构化编程的不足,主要就是在结构化编程中,各模块的依赖关系太强,不能有效地将变化隔离开来。所以,它还需要与其他的编程范式进行配合。下一讲,我们就来讲讲现在最主流的组织程序的方式:面向对象编程。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**结构化编程不能有效地隔离变化,需要与其他编程范式配合使用。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ef/0c/ef37ed4401ccba4237e49e18747dc40c.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
Dijkstra在结构化编程这件事上的思考远远大于我们今天看到的样子。你是否也有这样的经历,你在学习哪门技术时,了解到其背后思想之后,让你觉得受到了很大的震撼。欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
208
极客时间专栏/软件设计之美/设计一个软件—编程范式/14 | 面向对象之封装:怎样的封装才算是高内聚?.md
Normal file
208
极客时间专栏/软件设计之美/设计一个软件—编程范式/14 | 面向对象之封装:怎样的封装才算是高内聚?.md
Normal file
@@ -0,0 +1,208 @@
|
||||
<audio id="audio" title="14 | 面向对象之封装:怎样的封装才算是高内聚?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2d/bc/2d120f16099156a7dfd2c1db2e568abc.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
上一讲,我讲了你最熟悉的编程范式:结构化编程。结构化编程有效地解决了过去的很多问题,它让程序员们解决问题的规模得以扩大。
|
||||
|
||||
随着程序规模的逐渐膨胀,结构化编程在解决问题上的局限也越发凸显出来。因为在它提供的解决方案中,各模块的依赖关系太强,不能有效地将变化隔离开来。这时候,面向对象编程登上了大舞台,它为我们提供了更好的组织程序的方式。
|
||||
|
||||
在一些从结构化编程起步的程序员的视角里,面向对象就是数据加函数。虽然这种理解不算完全错误,但理解的程度远远不够。结构化编程的思考方式类似于用显微镜看世界,这种思考方式会让人只能看到局部。而想要用好面向对象编程,则需要我们有一个更宏观的视角。
|
||||
|
||||
谈到面向对象,你可能会想到面向对象的三个特点:封装、继承和多态。在接下来的三讲,我们就分别谈谈面向对象的这三个特点。
|
||||
|
||||
也许你会觉得,学面向对象程序设计语言的时候,这些内容都学过,没什么好讲的。但从我接触过的很多程序员写程序的风格来看,大多数人还真的不太理解这三个特点。还记得我们在第12讲中提到的那个故事吗?小李之所以被老赵批评,主要就是因为他虽然用了面向对象的语言,代码里却没有体现出面向对象程序的特点,没有封装,更遑论继承和多态。
|
||||
|
||||
嘴上说得明明白白,代码写得稀里糊涂,这就是大多数人学习面向对象之后的真实情况。所以,虽然看上去很简单,但还是有必要聊聊这些特点。
|
||||
|
||||
这一讲,我们先从封装说起。
|
||||
|
||||
## 理解封装
|
||||
|
||||
我们知道,面向对象是解决更大规模应用开发的一种尝试,它提升了程序员管理程序的尺度。
|
||||
|
||||
**封装,则是面向对象的根基**。它把紧密相关的信息放在一起,形成一个单元。如果这个单元是稳定的,我们就可以把这个单元和其他单元继续组合,构成更大的单元。然后,我们再用这个组合出来的新单元继续构建更大的单元。由此,一层一层地逐步向上。
|
||||
|
||||
为了让你更好地理解这个过程,我们先回到面向对象的最初。“面向对象”这个词是由Alan Kay创造的,他是2003年图灵奖的获得者。在他最初的构想中,对象就是一个细胞。当细胞一点一点组织起来,就可以组成身体的各个器官,再一点一点组织起来,就构成了人体。而当你去观察人的时候,就不用再去考虑每个细胞是怎样的。所以,面向对象给了我们一个更宏观的思考方式。
|
||||
|
||||
但是,这一切的前提是,每个对象都要构建好,也就是封装要做好,这就像每个细胞都有细胞壁将它与外界隔离开来,形成了一个完整的个体。
|
||||
|
||||
在Alan Kay关于面向对象的描述中,他强调对象之间只能通过消息来通信。如果按今天程序设计语言的通常做法,发消息就是方法调用,对象之间就是靠方法调用来通信的。但这个方法调用并不是简单地把对象内部的数据通过方法暴露。在Alan Kay的构想中,他甚至想把数据去掉。
|
||||
|
||||
因为,封装的重点在于对象提供了哪些行为,而不是有哪些数据。也就是说,即便我们把对象理解成数据加函数,数据和函数也不是对等的地位。函数是接口,而数据是内部的实现,正如我们一直说的那样,接口是稳定的,实现是易变的。
|
||||
|
||||
理解了这一点,我们来看一个很多人都有的日常编程习惯。他们编写一个类的方法是,把这个类有哪些字段写出来,然后,生成一大堆getter和setter,将这些字段的访问暴露出去。这种做法的错误就在于把数据当成了设计的核心,这一堆的getter和setter,就等于把实现细节暴露了出去。
|
||||
|
||||
一个正确的做法应该是,我们**设计一个类,先要考虑其对象应该提供哪些行为。然后,我们根据这些行为提供对应的方法,最后才是考虑实现这些方法要有哪些字段。**
|
||||
|
||||
请注意,方法的命名,体现的是你的意图,而不是具体怎么做。所以,**getXXX和setXXX绝对不是一个好的命名**。举个例子,设计一个让用户修改密码的功能,有些人直觉的做法可能是这样:
|
||||
|
||||
```
|
||||
class User {
|
||||
private String username;
|
||||
private String password;
|
||||
|
||||
...
|
||||
|
||||
// 修改密码
|
||||
public void setPassword(final String password) {
|
||||
this.password = password;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但我们鼓励的做法是,把意图表现出来:
|
||||
|
||||
```
|
||||
class User {
|
||||
private String username;
|
||||
private String password;
|
||||
|
||||
...
|
||||
|
||||
// 修改密码
|
||||
public void changePassword(final String password) {
|
||||
this.password = password;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这两段代码相比,只是修改密码的方法名变了,但二者更重要的差异是,一个在说做什么,一个在说怎么做。**将意图与实现分离开来**,这是一个优秀设计必须要考虑的问题。
|
||||
|
||||
不过,在真实的项目中,有时确实需要暴露一些数据,所以,等到你确实需要暴露的时候,再去写getter也不迟,你一定要问问自己为什么要加getter。至于setter,首先,大概率是你用错了名字,应该用一个表示意图的名字;其次,setter通常意味着修改,这是我们不鼓励的。
|
||||
|
||||
我后面讲函数式编程时,会讲到不变性,可变的对象会带来很多的问题,到时候我们再来更具体地讨论。所以,设计中更好的做法是设计不变类。
|
||||
|
||||
## 减少暴露接口
|
||||
|
||||
之所以我们需要封装,就是要构建一个内聚的单元。所以,我们要**减少这个单元对外的暴露**。这句话的第一层含义是减少内部实现细节的暴露,它还有第二层含义,**减少对外暴露的接口**。
|
||||
|
||||
一般面向对象程序设计语言都支持public、private这样的修饰符。程序员在日常开发中,经常会很草率地给一个方法加上public,从而不经意间将一些本来应该是内部实现的部分暴露出去。举个例子,一个服务要停下来的时候,你可能要把一些任务都停下来,代码可能会这样写:
|
||||
|
||||
```
|
||||
class Service {
|
||||
public void shutdownTimerTask() {
|
||||
// 停止定时器任务
|
||||
}
|
||||
|
||||
public void shutdownPollTask() {
|
||||
// 停止轮询服务
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
别人调用时,可能会这样调用这段代码:
|
||||
|
||||
```
|
||||
class Application {
|
||||
private Service service;
|
||||
|
||||
public void onShutdown() {
|
||||
service.shutdownTimerTask();
|
||||
service.shutdownPollTask();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
突然有一天,你发现,停止轮询任务必须在停止定时器任务之前,你就不得不要求别人改代码。而这一切就是因为我们很草率地给那两个方法加上了public,让别人有机会看到了这两个方法。
|
||||
|
||||
从设计的角度来说,我们必须谨慎地问一下,这个方法真的有必要暴露出去吗?
|
||||
|
||||
就这个例子而言,我们可以仅仅暴露一个方法:
|
||||
|
||||
```
|
||||
class Service {
|
||||
private void shutdownTimerTask() {
|
||||
// 停止定时器任务
|
||||
}
|
||||
|
||||
private void shutdownPollTask() {
|
||||
// 停止轮询服务
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
this.shutdownTimerTask();
|
||||
this.shutdownPollTask();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们调用代码也会简单很多:
|
||||
|
||||
```
|
||||
class Application {
|
||||
private Service service;
|
||||
|
||||
public void onShutdown() {
|
||||
service.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
尽可能减少接口暴露,这个原则不仅仅适用于类的设计,同样适用于系统设计。在我的职业生涯中,看到了很多团队非常随意地在系统里面添加接口,一个看似不那么复杂的系统里,随随便便就有成百上千个接口。
|
||||
|
||||
如果你想改造系统去掉一些接口时,很有可能会造成线上故障,因为你根本不知道哪个团队在什么时候用到了它。所以,在软件设计中,暴露接口需要非常谨慎。
|
||||
|
||||
关于这一点,你可以有一个统一的原则:**最小化接口暴露**。也就是,每增加一个接口,你都要找到一个合适的理由。
|
||||
|
||||
## 不局限于面向对象的封装
|
||||
|
||||
虽说封装是面向对象的一个重要特征,但是,当理解了封装之后,你同样可以把它运用于非面向对象的程序设计语言中,把代码写得更具模块性。
|
||||
|
||||
比如,我们知道C语言有头文件(.h 文件)和定义文件(.c 文件),在通常的理解中,头文件放的是各种声明:函数声明、结构体等等。很多C程序员甚至有一个函数就在头文件里加一个声明。
|
||||
|
||||
有了今天对于封装的讲解,再来看C语言的头文件,我们可以让它扮演接口的角色,而定义文件就成了实现。根据今天的内容,既然,接口只有相当于public接口的函数才可以放到头文件里,那么,在头文件里声明一个函数时,我们首先要问的就是,它需要成为一个公开的函数吗?
|
||||
|
||||
C语言没有public和private这样的修饰符,但我曾在一些C的项目上加入了自己的定义:
|
||||
|
||||
```
|
||||
#define PUBLIC
|
||||
#define PRIVATE static
|
||||
|
||||
```
|
||||
|
||||
然后,我们规定头文件里只能放公有接口,而在实现文件中的每个函数前面,加上了PUBLIC和PRIVATE,以示区分。这里将PRIVATE定义成了static,是利用了C语言static函数只能在一个文件中可见的特性。
|
||||
|
||||
我们还可以把一个头文件和一个定义文件合在一起,把它们看成一个类,不允许随意在头文件中声明不相关的函数。比如,下面是我在一个头文件里定义了一个点(Point):
|
||||
|
||||
```
|
||||
struct Point;
|
||||
struct Point* makePoint(double x, double y);
|
||||
double distance(struct Point* x, struct Point* y);
|
||||
|
||||
```
|
||||
|
||||
你可能注意到了,Point这个结构体我只给了声明,没有给定义。因为我并不希望给它的用户访问其字段的权限,结构体的具体定义是实现,应该被隐藏起来。对应的定义文件很简单,就不在这里罗列代码了。
|
||||
|
||||
说到这里,你也许发现了,C语言的封装做得更加彻底。如果用Java或C++ 定义Point类的话,必然会给出具体的字段。从某种程度上来说,Java 和 C++的做法削弱了封装性。
|
||||
|
||||
讲到这里,你应该已经感受到面向对象和结构化编程在思考问题上的一些差异了。有了封装,对象就成了一个个可以组合的单元,也形成了一个个可以复用的单元。面向对象编程的思考方式就是组合这些单元,完成不同的功能。同结构化编程相比,这种思考问题的方式站在了一个更宏观的视角上。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们学习了面向对象编程,它是一种以对象为编程元素的编程范式。面向对象有三个特点:封装、继承和多态。
|
||||
|
||||
封装,是面向对象的根基。面向对象编程就是要设计出一个一个可以组合,可以复用的单元。然后,组合这些单元完成不同的功能。
|
||||
|
||||
封装的重点在于对象提供了哪些行为,而不是有哪些数据。即便我们把对象理解成数据加函数,数据和函数也不是对等的地位。函数是接口,应该是稳定的;数据是实现,是易变的,应该隐藏起来。
|
||||
|
||||
设计一个类的方法,先要考虑其对象应该提供哪些行为,然后,根据这些行为提供对应的方法,最后才是考虑实现这些方法要有哪些字段。getter和setter是暴露实现细节的,尽可能不提供,尤其是setter。
|
||||
|
||||
封装,除了要减少内部实现细节的暴露,还要减少对外接口的暴露。一个原则是最小化接口暴露。有了对封装的理解,即便我们用的是C语言这样非面向对象的语言,也可以按照这个思路把程序写得更具模块性。
|
||||
|
||||
理解了封装,下一讲,我们再来看面向对象另外一个特征:继承。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**基于行为进行封装,不要暴露实现细节,最小化接口暴露。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c3/5e/c3cbdea561b6751a4c56f928c3d5345e.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你了解一下迪米特法则(Law of Demeter),结合今天的课程,分享一下你对迪米特法则的理解。欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
259
极客时间专栏/软件设计之美/设计一个软件—编程范式/15 | 面向对象之继承:继承是代码复用的合理方式吗?.md
Normal file
259
极客时间专栏/软件设计之美/设计一个软件—编程范式/15 | 面向对象之继承:继承是代码复用的合理方式吗?.md
Normal file
@@ -0,0 +1,259 @@
|
||||
<audio id="audio" title="15 | 面向对象之继承:继承是代码复用的合理方式吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7c/3c/7ca41d724b9415c48accdee97dcece3c.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
上一讲,我们讨论了面向对象的第一个特点:封装。这一讲,我们继续来看面向对象的第二个特点:继承。首先,你对继承的第一印象是什么呢?
|
||||
|
||||
说到继承,很多讲面向对象的教材一般会这么讲,给你画一棵树,父类是根节点,而子类是叶子节点,显然,一个父类可以有许多个子类。
|
||||
|
||||
父类是干什么用的呢?就是把一些公共代码放进去,之后在实现其他子类时,可以少写一些代码。讲程序库的时候,我们说过,设计的职责之一就是消除重复,代码复用。所以,在很多人的印象中,继承就是一种代码复用的方式。
|
||||
|
||||
如果我们把继承理解成一种代码复用方式,更多地是站在子类的角度向上看。在客户端代码使用的时候,面对的是子类,这种继承叫实现继承:
|
||||
|
||||
```
|
||||
Child object = new Child();
|
||||
|
||||
```
|
||||
|
||||
其实,还有一种看待继承的角度,就是从父类的角度往下看,客户端使用的时候,面对的是父类,这种继承叫接口继承:
|
||||
|
||||
```
|
||||
Parent object = new Child();
|
||||
|
||||
```
|
||||
|
||||
不过,接口继承更多是与多态相关,我们暂且放一放,留到下一讲再来讨论。这一讲,我们还是主要来说说实现继承。其实,实现继承并不是一种好的做法。
|
||||
|
||||
也就是说,**把实现继承当作一种代码复用的方式,并不是一种值得鼓励的做法**。一方面,继承是很宝贵的,尤其是Java这种单继承的程序设计语言。每个类只能有一个父类,一旦继承的位置被实现继承占据了,再想做接口继承就很难了。
|
||||
|
||||
另一方面,实现继承通常也是一种受程序设计语言局限的思维方式,有很多程序设计语言,即使不使用继承,也有自己的代码复用方式。
|
||||
|
||||
可能这么说你还不太理解,接下来,我就用一个例子来帮你更好地理解继承。
|
||||
|
||||
## 代码复用
|
||||
|
||||
假设,我要做一个产品报表服务,其中有个服务是要查询产品信息,这个查询过程是通用的,别的服务也可以用,所以,我把它放到父类里面。这就是代码复用的做法,代码用Java写出来是这样的:
|
||||
|
||||
```
|
||||
class BaseService {
|
||||
// 获取相应的产品信息
|
||||
protected List<Product> getProducts(List<String> product) {
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
// 生成报表服务
|
||||
class ReportService extends BaseService {
|
||||
public void report() {
|
||||
List<Product> product = getProduct(...);
|
||||
// 生成报表
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果采用Ruby的mixin机制,我们还可以这样实现,先定义一个模块(module):
|
||||
|
||||
```
|
||||
module ProductFetcher
|
||||
# 获取相应的产品信息
|
||||
def getProducts(products)
|
||||
...
|
||||
end
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
然后,在自己的类定义中,将它包含(include)进来:
|
||||
|
||||
```
|
||||
# 生成报表服务
|
||||
class ReportService
|
||||
include ProductFetcher
|
||||
|
||||
def report
|
||||
products = getProducts(...)
|
||||
# 生成报表
|
||||
..
|
||||
end
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
在这个例子中,ReportService并没有继承任何类,获取产品信息的代码也是可以复用的,也就是这里的ProductFetcher这个模块。这样一来,如果我需要有一个获取产品信息的地方,它不必非得是一个什么服务,无需继承任何类。
|
||||
|
||||
这是Ruby的做法,类似的语言特性还有Scala里的trait。
|
||||
|
||||
在C++中,虽然语法并没有严格地区分实现继承,但《Effective C++》这本行业的名著,给出了一个实用的建议:实现继承采用私有继承的方式实现:
|
||||
|
||||
```
|
||||
class ReportService: private ProductFetcher {
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
请注意,在这个实现里,我的私有继承类名是ProductFetcher。是的,它并不需要和这个报表服务有什么直接的关系,使用私有继承,就是为了复用它的代码。
|
||||
|
||||
从前面的分析中,我们也不难看出,获取产品信息和生成报表其实是两件事,只是因为在生成报表的过程中,需要获取产品信息,所以,它有了一个基类。
|
||||
|
||||
其实,在Java里面,我们不用继承的方式也能实现,也许你已经想到了,代码可以写成这样:
|
||||
|
||||
```
|
||||
class ProductFetcher {
|
||||
// 获取相应的产品信息
|
||||
public List<Product> getProducts(List<String> product) {
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
// 生成报表服务
|
||||
class ReportService {
|
||||
private ProductFetcher fetcher;
|
||||
|
||||
public void report() {
|
||||
List<Product> product = fetcher.getProducts(...);
|
||||
// 生成报表
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这种实现方案叫作组合,也就是说ReportService里组合进一个ProductFetcher。在设计上,有一个通用的原则叫做:**组合优于继承**。也就是说,如果一个方案既能用组合实现,也能用继承实现,那就选择用组合实现。
|
||||
|
||||
好,到这里你已经清楚了,代码复用并不是使用继承的好场景。所以,**要写继承的代码时,先问自己,这是接口继承,还是实现继承?如果是实现继承,那是不是可以写成组合?**
|
||||
|
||||
## 面向组合编程
|
||||
|
||||
之所以可以用组合的方式实现,本质的原因是,获取产品信息和生成报表服务本来就是两件事。还记得我们在[第3讲](https://time.geekbang.org/column/article/241094)里讲过的“分离关注点”吗?如果你能看出它们是两件事,就不会把它们放到一起了。
|
||||
|
||||
我还讲过,分解是设计的第一步,而且分解的粒度越小越好。当你可以分解出来多个关注点,每一个关注点就应该是一个独立的模块。最终的**类是由这些一个一个的小模块组合而成,这种编程的方式就是面向组合编程**。它相当于换了一个视角:类是由多个小模块组合而成。
|
||||
|
||||
还以前面的报表服务为例,如果使用Java,按照面向组合的思路写出来,大概是下面这样的。其中,为了增加复杂度,我增加了一个报表生成器(ReportGenerator),在获取产品信息之后,还要生成报表:
|
||||
|
||||
```
|
||||
class ReportService {
|
||||
private ProductFetcher fetcher;
|
||||
private ReportGenerator generator;
|
||||
|
||||
public void report() {
|
||||
List<Product> product = fetcher.getProducts(...);
|
||||
// 生成报表
|
||||
generator.generate(product);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
请注意,我在前面的表述中,故意用了模块这个词,而不是类。因为ProductFetcher和ReportGenerator只是因为我们用的是Java,才写成了类;如果用Ruby,它们的表现形式就会是一个module;而在Scala里,就会成为一个trait。我们再用Ruby 示意一下:
|
||||
|
||||
```
|
||||
class ReportService
|
||||
include ProductFetcher
|
||||
include ReportGenerator
|
||||
|
||||
def report
|
||||
products = getProducts(...)
|
||||
# 生成报表
|
||||
generateReport(products)
|
||||
end
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
而使用C++的话,表现形式则会是私有继承:
|
||||
|
||||
```
|
||||
class ReportService: private ProductFetcher, private ReportGenerator {
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
C++本身支持宏定义,所以,我们可以自定义一些宏,将这些不同的概念区分开来:
|
||||
|
||||
```
|
||||
#define MODULE(module) class module
|
||||
#define INCLUDE(module) private module
|
||||
|
||||
```
|
||||
|
||||
上面的类定义就可以变成更有表达性的写法:
|
||||
|
||||
```
|
||||
MODULE(ProductFetcher) {
|
||||
...
|
||||
}
|
||||
|
||||
MODULE(ReportGenerator) {
|
||||
...
|
||||
}
|
||||
|
||||
class ReportService:
|
||||
INCLUDE(ProductFetcher),
|
||||
INCLUDE(ReportGenerator) {
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我有一个C++的高手朋友,把这种做法称之为“[小类大对象](https://www.jianshu.com/p/a830d2261392)”,这里面的小类就是一个一个的模块,而最终的大对象是最终组合出来的类生成的对象。
|
||||
|
||||
关于面向对象,有一点我们还没有说,就是**面向对象面向的是“对象”,不是类**。很多程序员习惯把对象理解成类的附属品,但在Alan Kay的理解中,对象本身就是一个独立的个体。所以,有些程序设计语言可以直接支持在对象上进行操作。
|
||||
|
||||
还是前面的例子,我想给报表服务增加一个接口,对产品信息做一下处理。用Ruby写出来会是这样:
|
||||
|
||||
```
|
||||
module ProductEnhancer
|
||||
def enhance
|
||||
# 处理一下产品信息
|
||||
end
|
||||
end
|
||||
|
||||
service = ReportService.new
|
||||
# 增加了 ProductEnhancer
|
||||
service.extend(ProductEnhancer)
|
||||
|
||||
# 可以调用 enhance 方法
|
||||
service.enhance
|
||||
|
||||
```
|
||||
|
||||
这样的处理只会影响这里的一个对象,而同样是这个ReportService的其他实例,则完全不受影响。这样做的好处是,我们不必写那么多类,而是根据需要在程序运行时组合出不同的对象。
|
||||
|
||||
在这里,相信你再一次意识到了要学习多种程序设计语言的重要性。Java只有类这种组织方式,所以,很多有差异的概念只能用类这一个概念表示出来,思维就会受到限制,而不同的语言则提供了不同的表现形式,让概念更加清晰。
|
||||
|
||||
前面只是讲了面向组合编程在思考方式的转变,下面我们再来看设计上的差异。举个例子,我们有个字体类(Font),现在的需求是,字体能够加粗(Bold)、能够有下划线(Underline)、还要支持斜体(Italic),而且这些能力之间是任意组合的。
|
||||
|
||||
如果采用继承的方式,那就要有8 个类:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a3/0a/a3cf4c150e4fcbb98d4d7b6212e2700a.jpg" alt="">
|
||||
|
||||
而采用组合的方式,我们的字体类(Font)只要有三个独立的维度,也就是是否加粗(Bold)、是否有下划线(Underline)、是否是斜体(Italic)。这还不是终局,如果再来一种其他的要求,由3种要求变成4种,采用继承的方式,类的数量就会膨胀到16个类,而组合的方式只需要再增加一个维度就好。我们把一个M*N的问题,通过设计转变成了M+N的问题,复杂度的差别一望便知。
|
||||
|
||||
虽然我们一直在说,Java在面向组合编程方面能力比较弱,但Java社区也在尝试不同的方式。早期的尝试有[Qi4j](https://www.infoq.cn/article/2007/11/qi4j-intro),后来Java 8加入了default method,在一定程度上也可以支持面向组合的编程。这里我们只是讲了面向对象社区在组合方面的探索,后面讲函数式编程时,还会讲到函数式编程在这方面的探索。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们学习了面向对象的第二个特点:继承。继承分为两种,实现继承和接口继承。实现继承是站在子类的视角看问题,接口继承则是站在父类的视角。
|
||||
|
||||
很多程序员把实现继承当作了一种代码复用的方式,但实际上,实现继承并不是一个好的代码复用的方式,之所以这种方式很常见,很大程度上是受了语言的局限。
|
||||
|
||||
Ruby的mixin机制,Scala提供的trait以及C++提供的私有继承都是代码复用的方式。即便只使用Java,也可以通过组合而非继承的方式进行代码复用。
|
||||
|
||||
今天我们还讲到这些复用方式背后的编程思想:面向组合编程。它给我们提供了一个不同的视角,但支撑面向组合编程的是分离关注点。将不同的关注点分离出来,每一个关注点成为一个模块,在需要的时候组装起来。面向组合编程,在设计本身上有很多优秀的地方,可以降低程序的复杂度,更是思维上的转变。
|
||||
|
||||
现在你已经知道了,在继承树上从下往上看,并不是一个好的思考方式,那从上往下看呢?下一讲,我们就来讲讲继承的另外一个方向,接口继承,也就是面向对象的第三个特点:多态。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**组合优于继承**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/67/a0/67e0cbd436dd50a8933b251e4c97a4a0.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你去了解一下一种叫[DCI (Data,Context和 Interaction)](https://en.wikipedia.org/wiki/Data,_context_and_interaction)<br>
|
||||
的编程思想,结合今天的课程,分享一下你对DCI的理解。欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
@@ -0,0 +1,233 @@
|
||||
<audio id="audio" title="16 | 面向对象之多态:为什么“稀疏平常”的多态,是软件设计的大杀器?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/yy/2f/yybe932749e9cfdfyy25c02a925ffa2f.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
前面两讲,我们讲了面向对象的两个特点:封装和继承,但真正让面向对象华丽蜕变的是它的第三个特点:多态。
|
||||
|
||||
有一次,我在一个C++的开发团队里做了一个小调查。问题很简单:你用过virtual吗?下面坐着几十个C++程序员,只有寥寥数人举起了手。
|
||||
|
||||
在C++里,virtual表示这个函数是在父类中声明的,然后在子类中改写(Override)过。或许你已经发现了,这不就是多态吗?没错,这就是多态。这个调查说明了一件事,很多程序员虽然在用支持面向对象的程序设计语言,但根本没有用过多态。
|
||||
|
||||
只使用封装和继承的编程方式,我们称之为基于对象(Object Based)编程,而只有把多态加进来,才能称之为面向对象(Object Oriented)编程。也就是说,多态是一个分水岭,将基于对象与面向对象区分开来,可以说,没写过多态的代码,就是没写过面向对象的代码。
|
||||
|
||||
对于面向对象而言,多态至关重要,正是因为多态的存在,软件设计才有了更大的弹性,能够更好地适应未来的变化。我们说,软件设计是一门关注长期变化的学问,只有当你开始理解了多态,你才真正踏入应对长期变化的大门。这一讲,我们就谈谈多态。
|
||||
|
||||
## 理解多态
|
||||
|
||||
多态(Polymorphism),顾名思义,一个接口,多种形态。同样是一个绘图(draw)的方法,如果以正方形调用,则绘制出一个正方形;如果以圆形调用,则画出的是圆形:
|
||||
|
||||
```
|
||||
interface Shape {
|
||||
// 绘图接口
|
||||
void draw();
|
||||
}
|
||||
|
||||
class Square implements Shape {
|
||||
void draw() {
|
||||
// 画一个正方形
|
||||
}
|
||||
}
|
||||
|
||||
class Circle implements Shape {
|
||||
void draw() {
|
||||
// 画一个圆形
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上一讲,我们说过,继承有两种,实现继承和接口继承。其中,实现继承尽可能用组合的方式替代继承。而接口继承,主要是给多态用的。
|
||||
|
||||
这里面的重点在于,这个继承体系的使用者,主要考虑的是父类,而非子类。就像下面这段代码里,我们不必考虑具体的形状是什么,只要调用它的绘图方法即可。
|
||||
|
||||
```
|
||||
Shape shape = new Squre();
|
||||
shape.draw();
|
||||
|
||||
```
|
||||
|
||||
这种做法的好处就在于,一旦有了新的变化,比如,需要将正方形替换成圆形,除了变量初始化,其他的代码并不需要修改。不过,这是任何一本面向对象编程的教科书上都会讲的内容。
|
||||
|
||||
那么,问题来了。既然多态这么好,为什么很多程序员不能在自己的代码中很好地运用多态呢?因为多态需要构建出一个抽象。
|
||||
|
||||
构建抽象,需要找出不同事物的共同点,而这是最有挑战的部分。而遮住程序员们双眼的,往往就是他们眼里的不同之处。在他们眼中,鸡就是鸡,鸭就是鸭。
|
||||
|
||||
**寻找共同点这件事,地基还是在分离关注点上**。只有你能看出来,鸡和鸭都有羽毛,都养在家里,你才有机会识别出一个叫做“家禽”的概念。这里,我们又一次强调了分离关注点的重要性。
|
||||
|
||||
我们构建出来的抽象会以接口的方式体现出来,强调一点,这里的接口不一定是一个语法,而是一个类型的约束。所以,在这个关于多态的讨论中,接口、抽象类、父类等几个概念都是等价的,为了叙述方便,我这里统一采用接口的说法。
|
||||
|
||||
在构建抽象上,接口扮演着重要的角色。首先,**接口将变的部分和不变的部分隔离开来**。不变的部分就是接口的约定,而变的部分就是子类各自的实现。
|
||||
|
||||
在软件开发中,**对系统影响最大的就是变化**。有时候需求一来,你的代码就要跟着改,一个可能的原因就是各种代码混在了一起。比如,一个通信协议的调整需要你改业务逻辑,这明显就是不合理的。**对程序员来说,识别出变与不变,是一种很重要的能力。**
|
||||
|
||||
其次,**接口是一个边界**。无论是什么样的系统,清晰界定不同模块的职责是很关键的,而模块之间彼此通信最重要的就是通信协议。这种通信协议对应到代码层面上,就是接口。
|
||||
|
||||
很多程序员在接口中添加方法显得很随意,因为在他们心目中,并不存在实现者和使用者之间的角色差异。这也就造成了边界意识的欠缺,没有一个清晰的边界,其结果就是模块定义的随意,彼此之间互相影响也就在所难免。后面谈到Liskov替换法则的时候,我们还会再谈到这一点。
|
||||
|
||||
所以,**要想理解多态,首先要理解接口的价值,而理解接口,最关键的就是在于谨慎地选择接口中的方法**。
|
||||
|
||||
至此,你已经对多态和接口有了一个基本的认识。你就能很好地理解一个编程原则了:面向接口编程。面向接口编程的价值就根植于多态,也正是因为有了多态,一些设计原则,比如,开闭原则、接口隔离原则才得以成立,相应地,设计模式才有了立足之本。
|
||||
|
||||
这些原则你可能都听说过,但在编码的细节上,你可能会有一些忽略的细节,比如,下面这段代码是很多人经常写的:
|
||||
|
||||
```
|
||||
ArrayList<> list = new ArrayList<String>();
|
||||
|
||||
```
|
||||
|
||||
这么简单的代码也有问题,是的,因为它没有面向接口编程,一个更好的写法应该是这样:
|
||||
|
||||
```
|
||||
List<> list = new ArrayList<String>();
|
||||
|
||||
```
|
||||
|
||||
二者之间的差别就在于变量的类型,是面向一个接口,还是面向一个具体的实现类。
|
||||
|
||||
相对于封装和继承而言,多态对程序员的要求更高,需要你有长远的眼光,看到未来的变化,而理解好多态,也是程序员进阶的必经之路。
|
||||
|
||||
## 实现多态
|
||||
|
||||
还记得我们在编程范式那一讲留下的一个问题吗?面向对象编程,会限制使用函数指针,它是对程序控制权的间接转移施加了约束。理解这一点,就要理解多态是怎么实现的。
|
||||
|
||||
讲多范式编程时,我举了Linux文件系统的例子,它是用C实现了面向对象编程,而它的做法就是用了函数指针。再来回顾一下:
|
||||
|
||||
```
|
||||
struct file_operations {
|
||||
loff_t (*llseek) (struct file *, loff_t, int);
|
||||
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
|
||||
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
|
||||
int (*open) (struct inode *, struct file *);
|
||||
int (*flush) (struct file *, fl_owner_t id);
|
||||
int (*release) (struct inode *, struct file *);
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
假设你写一个HelloFS,那你可以这样给它赋值:
|
||||
|
||||
```
|
||||
const struct file_operations hellofs_file_operations = {
|
||||
.read = hellofs_read,
|
||||
.write = hellofs_write,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
只要给这个结构体赋上不同的值,就可以实现不同的文件系统。但是,这种做法有一个非常不安全的地方。既然是一个结构体的字段,那我就有可能改写了它,像下面这样:
|
||||
|
||||
```
|
||||
void silly_operation(struct file_operations* operations) {
|
||||
operations.read = sillyfs_read;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如此一来,本来应该在hellofs_read运行的代码,就跑到了sillyfs_read里,程序很容易就崩溃了。对于C这种非常灵活的语言来说,你根本禁止不了这种操作,只能靠人为的规定和代码检查。
|
||||
|
||||
到了面向对象程序设计语言这里,这种做法由一种编程结构变成了一种语法。给函数指针赋值的操作下沉到了运行时去实现。如果你了解运行时的实现,它就是一个查表的过程,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/81/c4/811965ea831b3df14c2165e3804b3ec4.jpg" alt="">
|
||||
|
||||
一个类在编译时,会给其中的函数在虚拟函数表中找到一个位置,把函数指针地址写进去,不同的子类对应不同的虚拟表。当我们用接口去调用对应的函数时,实际上完成的就是在对应的虚拟函数表的一个偏移,不管现在面对的是哪个子类,都可以找到相应的实现函数。
|
||||
|
||||
还记得我在开头提的那个问题吗?问C++程序员是否用过virtual。在C++这种比较注重运行时消耗的语言中,只有virtual的函数会出现在虚拟函数表里,而普通函数就是直接的函数调用,以此减少消耗。对于Java程序员而言,你可以通过给无需改写的方法添加final帮助运行时做优化。
|
||||
|
||||
当多态成了一种语法,函数指针的使用就得到了限制,犯错误的几率就大大降低了,程序行为的可预期性就大大提高了。
|
||||
|
||||
## 没有继承的多态
|
||||
|
||||
回到Alan Kay关于面向对象的思考中,他考虑过封装,考虑过多态。至于继承,却不是一个必然的选项。只要能够遵循相同的接口,就可以表现出来多态,所以,多态并不一定要依赖于继承。
|
||||
|
||||
比如,在动态语言中,有一个常见的说法,叫Duck Typing,就是说,如果走起来像鸭子,叫起来像鸭子,那它就是鸭子。两个类可以不在同一个继承体系之下,但是,只要有同样的方法接口,就是一种多态。
|
||||
|
||||
像下面这段代码,Duck和FakeDuck并不在一棵继承树上,但make_quack调用的时候,它们俩都可以传进去。
|
||||
|
||||
```
|
||||
class Duck
|
||||
def quack
|
||||
# 鸭子叫
|
||||
end
|
||||
end
|
||||
|
||||
class FakeDuck
|
||||
def quack
|
||||
# 模拟鸭子叫
|
||||
end
|
||||
end
|
||||
|
||||
def make_quack(quackable)
|
||||
quackable.quack
|
||||
end
|
||||
|
||||
make_quack(Duck.new)
|
||||
make_quack(FakeDuck.new)
|
||||
|
||||
```
|
||||
|
||||
我们都知道,很多软件都有插件能力,而插件结构本身就是一种多态的表现。比如,著名的开源图形处理软件[GIMP](https://www.gimp.org/),它自身是用C开发的,为它编写插件就需要按照它规定的结构去编写代码:
|
||||
|
||||
```
|
||||
struct GimpPlugInInfo
|
||||
{
|
||||
/* GIMP 应用初始启动时调用 */
|
||||
GimpInitProc init_proc;
|
||||
|
||||
/* GIMP 应用退出时调用 */
|
||||
GimpQuitProc quit_proc;
|
||||
|
||||
/* GIMP 查询插件能力时调用 */
|
||||
GimpQueryProc query_proc;
|
||||
|
||||
/* 插件安装之后,开始运行时调用*/
|
||||
GimpRunProc run_proc;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
我们所需做的就是按照这个结构声明出PLUG_IN_INFO,这是隐藏的名字,将插件的能力注册给GIMP这个应用:
|
||||
|
||||
```
|
||||
GimpPlugInInfo PLUG_IN_INFO = {
|
||||
init,
|
||||
quit,
|
||||
query,
|
||||
run
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
你看,这里用到的是C语言,一种连面向对象都不支持的语言,但它依然能够很好地表现出多态。
|
||||
|
||||
现在你应该理解了,多态依赖于继承,这只是某些程序设计语言自身的特点。你也看出来了,在面向对象本身的体系之中,封装和多态才是重中之重,而继承则处于一个很尴尬的位置。
|
||||
|
||||
我们花了三讲的篇幅讲了面向对象编程的特点,在这三讲中,我们不仅仅以Java为基础讲了传统的面向对象实现的一些方法,也讲到了不同语言在解决同样问题上的不同做法。正如我们在讲程序设计语言时所说,一定要跳出单一语言的局限,这样,才能对各种编程思想有更本质的认识。
|
||||
|
||||
在这里,你也看到了面向对象编程的三个特点也有不同的地位:
|
||||
|
||||
- 封装是面向对象的根基,软件就是靠各种封装好的对象逐步组合出来的;
|
||||
- 继承给了继承体系内的所有对象一个约束,让它们有了统一的行为;
|
||||
- 多态让整个体系能够更好地应对未来的变化。
|
||||
|
||||
后面我们还会讲到面向对象的设计原则,而这些原则的出发点就是面向对象的这些特点,所以,理解面向对象的这些特点,是我们后面把设计做好的基础。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们讲到了面向对象的第三个特点:多态,它是基于对象和面向对象的分水岭。多态,需要找出不同事物的共同点,建立起抽象,这也是很多程序员更好地运用多态的阻碍。而我们找出共同点,前提是要分离关注点。
|
||||
|
||||
理解多态,还要理解好接口。它是将变的部分和不变的部分隔离开来,在二者之间建立起一个边界。一个重要的编程原则就是**面向接口编程**,这是很多设计原则的基础。
|
||||
|
||||
我们今天还讨论了多态的实现,它通过将一种常见的编程结构升华为语法,降低程序员犯错的几率。最后,我们说了,多态不一定要依赖于继承实现。在面向对象编程中,更重要的是封装和多态。
|
||||
|
||||
结构化编程也好,面向对象编程也罢,这些都是大多数程序员都还是比较熟悉的,而下面我们要讲到的编程范式已经成为一股不可忽视的力量。然而,很多人却对它无知无觉,这就是函数式编程。下一讲,我们就来说说函数式编程。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**建立起恰当的抽象,面向接口编程。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/e4/d85fa1220e55fe7291b480b335d0c5e4.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你去了解一下Go语言或Rust语言是如何支持多态的,欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
270
极客时间专栏/软件设计之美/设计一个软件—编程范式/17 | 函数式编程:不用函数式编程语言,怎么写函数式的程序?.md
Normal file
270
极客时间专栏/软件设计之美/设计一个软件—编程范式/17 | 函数式编程:不用函数式编程语言,怎么写函数式的程序?.md
Normal file
@@ -0,0 +1,270 @@
|
||||
<audio id="audio" title="17 | 函数式编程:不用函数式编程语言,怎么写函数式的程序?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/85/0e/85a4d6dc9abb827e954126e6eca2850e.mp3"></audio>
|
||||
|
||||
你好,我是郑晔!
|
||||
|
||||
前面几讲,我们讲了结构化编程和面向对象编程,对于大多数程序员来说,这些内容还是比较熟悉的。接下来,我们要讨论的函数式编程,对一些人来说就要陌生一些。
|
||||
|
||||
你可能知道,Java和C++已经引入了Lambda,目的就是为了支持函数式编程。因为,函数式编程里有很多优秀的元素,比如,组合式编程、不变性等等,都是我们值得在日常设计中借鉴的。即便我们使用的是面向对象编程语言,也可以将这些函数式编程的做法运用到日常工作中,这已经成为大势所趋。
|
||||
|
||||
但是,很多人学习函数式编程,刚刚知道了概念,就碰上了函数式编程的起源,遇到许多数学概念,然后,就放弃了。为什么学习函数式编程这么困难呢?主要是因为它有一些不同的思维逻辑,同时人们也缺少一个更好的入门方式。
|
||||
|
||||
所以,在这一讲中,我打算站在一个更实用的角度,帮你做一个函数式编程的入门。等你有了基础之后,后面两讲,我们再来讨论函数式编程中优秀的设计理念。
|
||||
|
||||
好,我们开始吧!
|
||||
|
||||
## 不断增加的需求
|
||||
|
||||
我们从一个熟悉的场景出发。假设我们有一组学生,其类定义如下:
|
||||
|
||||
```
|
||||
// 单个学生的定义
|
||||
class Student {
|
||||
// 实体 ID
|
||||
private long id;
|
||||
// 学生姓名
|
||||
private String name;
|
||||
// 学号
|
||||
private long sno;
|
||||
// 年龄
|
||||
private long age;
|
||||
}
|
||||
|
||||
// 一组学生的定义
|
||||
class Students {
|
||||
private List<Student> students
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果我们需要按照姓名找出其中一个,代码可能会这么写:
|
||||
|
||||
```
|
||||
Student findByName(final String name) {
|
||||
for (Student student : students) {
|
||||
if (name.equals(student.getName())) {
|
||||
return student;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这时候,新需求来了,我们准备按照学号来找人,代码也许就会这么写:
|
||||
|
||||
```
|
||||
Student findBySno(final long sno) {
|
||||
for (Student student : students) {
|
||||
if (sno == student.getSno()) {
|
||||
return student;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
又一个新需求来了,我们这次需要按照 ID 去找人,代码可以如法炮制:
|
||||
|
||||
```
|
||||
Student findById(final long id) {
|
||||
for (Student student : students) {
|
||||
if (id == student.getId()) {
|
||||
return student;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
看完这三段代码,你发现问题了吗?这三段代码,除了查询的条件不一样,剩下的结构几乎一模一样,这就是一种重复。
|
||||
|
||||
那么,我们要怎么消除这个重复呢?我们可以引入查询条件这个概念,这里只需要返回一个真假值,我们可以这样定义:
|
||||
|
||||
```
|
||||
interface Predicate<T> {
|
||||
boolean test(T t);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
有了查询条件,我们可以改造一下查询方法,把条件作为参数传进去:
|
||||
|
||||
```
|
||||
Student find(final Predicate<Student> predicate) {
|
||||
for (Student student : students) {
|
||||
if (predicate.test(student)) {
|
||||
return student;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
于是,按名字查找就会变成下面这个样子(其他两个类似,就不写了)。为了帮助你更好地理解,我没有采用Java 8的Lambda写法,而用了你最熟悉的对象:
|
||||
|
||||
```
|
||||
Student findByName(final String name) {
|
||||
return find(new Predicate<Student>() {
|
||||
@Override
|
||||
public boolean test(final Student student) {
|
||||
return name.equals(student.getName());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样是很好,但你会发现,每次有一个新的查询,你就要做一层这个封装。为了省去这层封装,我们可以把查询条件做成一个方法:
|
||||
|
||||
```
|
||||
static Predicate<Student> byName(final String name) {
|
||||
return new Predicate<Student>() {
|
||||
@Override
|
||||
public boolean test(final Student student) {
|
||||
return name.equals(student.getName();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
其他几个字段也可以做类似的封装,这样一来,要查询什么就由使用方自己决定了:
|
||||
|
||||
```
|
||||
find(byName(name));
|
||||
find(bySno(sno));
|
||||
find(byId(id));
|
||||
|
||||
```
|
||||
|
||||
现在我们想用名字和学号同时查询,该怎么办呢?你是不是打算写一个byNameAndSno的方法呢?且慢,这样一来,岂不是每种组合你都要写一个?那还受得了吗。我们完全可以用已有的两个方法组合出一个新查询来,像这样:
|
||||
|
||||
```
|
||||
find(and(byName(name), bySno(sno)));
|
||||
|
||||
```
|
||||
|
||||
这里面多出一个and方法,它要怎么实现呢?其实也不难,按照正常的and逻辑写一个就好,像下面这样:
|
||||
|
||||
```
|
||||
static <T> Predicate<T> and(final Predicate<T>... predicates) {
|
||||
return new Predicate<T>() {
|
||||
@Override
|
||||
public boolean test(final T t) {
|
||||
for (Predicate<T> predicate : predicates) {
|
||||
if (!predicate.test(t)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
类似地,你还可以写出or和not的逻辑,这样,使用方能够使用的查询条件一下子就多了起来,他完全可以按照自己的需要任意组合。
|
||||
|
||||
这时候,又来了一个新需求,想找出所有指定年龄的人。写一个byAge现在已经很简单了。那找到所有人该怎么写呢?有了前面的基础也不难。
|
||||
|
||||
```
|
||||
Student findAll(final Predicate<Student> predicate) {
|
||||
List<Student> foundStudents = new ArrayList<Student>();
|
||||
for (Student student : students) {
|
||||
if (predicate.test(student)) {
|
||||
foundStudents.add(student);
|
||||
}
|
||||
}
|
||||
|
||||
return new Students(foundStudents);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如此一来,要做什么动作(查询一个、查询所有等)和用什么条件(名字、学号、ID 和年龄等)就成了两个维度,使用方可以按照自己的需要任意组合。
|
||||
|
||||
直到现在,我们所用的代码都是常规的Java代码,却产生了神奇的效应。这段代码的作者只提供了各种基本元素(动作和条件),而这段代码的用户通过组合这些基本的元素完成真正的需求。这种做法完全不同于常规的面向对象的做法,其背后的思想就源自函数式编程。在上面这个例子里面,让代码产生质变的地方就在于Predicate的引入,而它实际上就是一个函数。
|
||||
|
||||
这是一个简单的例子,但是我们可以发现,按照“消除重复”这样一个简单的编写代码逻辑,我们不断地调整代码,就是可以写出这种函数式风格的代码。在写代码这件事上,我们常常会有一种殊途同归的感觉。
|
||||
|
||||
现在,你已经对函数式编程应该有了一个初步的印象,接下来,我们看看函数式编程到底是什么。
|
||||
|
||||
## 函数式编程初步
|
||||
|
||||
函数式编程是一种编程范式,**它提供给我们的编程元素就是函数**。只不过,这个函数是来源于数学的函数,你可以回想一下,高中数学学到的那个f(x)。同我们习惯的函数相比,它要规避状态和副作用,换言之,同样的输入一定会给出同样的输出。
|
||||
|
||||
之所以说函数式编程的函数来自数学,因为它的起源是数学家Alonzo Church发明的Lambda演算(Lambda calculus,也写作 λ-calculus)。所以,Lambda这个词在函数式编程中经常出现,你可以简单地把它理解成**匿名函数**。
|
||||
|
||||
我们这里不关心Lambda演算的数学逻辑,你只要知道,Lambda演算和图灵机是等价的,都是那个年代对“计算”这件事探索的结果。
|
||||
|
||||
我们现在接触的大多数程序设计语言都是从图灵机的模型出发的,但既然二者是等价的,就有人选择从Lambda演算出发。比如早期的函数式编程语言LISP,它在20 世纪50年代就诞生了,是最早期的几门程序设计语言之一。它的影响却是极其深远的,后来的函数式编程语言可以说都直接或间接受着它的影响。
|
||||
|
||||
虽然说函数式编程语言早早地就出现了,但函数式编程这个概念却是John Backus在其[1977 年图灵奖获奖的演讲](https://www.thocp.net/biographies/papers/backus_turingaward_lecture.pdf)上提出来。有趣的是,John Backus 获奖的理由是他在Fortran语言上的贡献,而这门语言和函数式编程刚好是两个不同“计算”模型的极端。
|
||||
|
||||
了解了函数式编程产生的背景之后,我们就可以正式打开函数式编程的大门了。
|
||||
|
||||
函数式编程第一个需要了解的概念就是函数。在函数式编程中,函数是一等公民(first-class citizen)。一等公民是什么意思呢?
|
||||
|
||||
- 它可以按需创建;
|
||||
- 它可以存储在数据结构中;
|
||||
- 它可以当作实参传给另一个函数;
|
||||
- 它可以当作另一个函数的返回值。
|
||||
|
||||
对象,是面向对象程序设计语言的一等公民,它就满足所有上面的这些条件。在函数式编程语言里,函数就是一等公民。函数式编程语言有很多,经典的有LISP、Haskell、Scheme等,后来也出现了一批与新平台结合紧密的函数式编程语言,比如:Clojure、F#、Scala等。
|
||||
|
||||
很多语言虽然不把自己归入函数式编程语言,但它们也提供了函数式编程的支持,比如支持了Lambda的,这类的语言像Ruby、JavaScript等。
|
||||
|
||||
**如果你的语言没有这种一等公民的函数支持,完全可以用某种方式模拟出来**。在前面的例子里,我们就用对象模拟出了一个函数,也就是Predicate。在旧版本的C++中,也可以用functor(函数对象)当作一等公民的函数。在这两个例子中,既然函数是用对象模拟出来的,自然就符合一等公民的定义,可以方便将其传来传去。
|
||||
|
||||
在开头,我提到过,随着函数式编程这几年蓬勃的发展,越来越多的“老”程序设计语言已经在新的版本中加入了对函数式编程的支持。所以,如果你用的是新版本,可以不必像我写得那么复杂。
|
||||
|
||||
比如,在Java里,Predicate本身就是JDK自带的,and方法也不用自己写,加上有Lambda语法简化代码的编写,代码可以写成下面这样,省去了构建一个匿名内部类的繁琐:
|
||||
|
||||
```
|
||||
static Predicate<Student> byName(String name) {
|
||||
return student -> student.getName().equals(name);
|
||||
}
|
||||
|
||||
find(byName(name).and(bySno(sno)));
|
||||
|
||||
```
|
||||
|
||||
如果按照对象的理解方式,Predicate是一个对象接口,但它可以接受一个Lambda为其赋值。有了前面的基础,你可以把它理解成一个简化版的匿名内部类。其实,这里面主要工作都在编译器上,它帮助我们做了类型推演(Type Inference)。
|
||||
|
||||
在Java里,可以表示一个函数的接口还有几个,比如,Function(一个参数一个返回值)、Supplier(没有参数只有返回值),以及一大堆形式稍有不同的变体。
|
||||
|
||||
这些“函数”的概念为我们提供了一些基础的构造块,从前面的例子,你可以看出,函数式编程一个有趣的地方就在于这些构造块可以组合起来,这一点和面向对象是类似的,都是由基础的构造块逐步组合出来的。
|
||||
|
||||
我们讲模型也好,面向对象也罢,对于这种用小组件逐步叠加构建世界的思路已经很熟悉了,在函数式编程里,我们又一次领略到同样的风采,而这一切的出发点,就是“函数”。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一讲我们讨论了**函数式编程**这种编程范式,它给我们提供的编程元素是函数。只不过,这个函数不同于传统程序设计语言的函数,它的思想根源是数学中的**函数**。
|
||||
|
||||
函数是函数式编程的一等公民(first-class citizen)。一等公民指的是:
|
||||
|
||||
- 它可以按需创建;
|
||||
- 它可以存储在数据结构中;
|
||||
- 它可以当作实参传给另一个函数;
|
||||
- 它可以当作另一个函数的返回值。
|
||||
|
||||
如果你使用的程序设计语言不支持函数是一等公民,可以用其他的方式模拟出来,比如,用对象模拟函数。随着函数式编程的兴起,越来越多的程序设计语言加入了自己的函数,比如:Java和C++增加了Lambda,可以在一定程度上支持函数式编程。
|
||||
|
||||
函数式编程就是把函数当做一个个的构造块,然后将这些函数组合起来,构造出一个新的构造块。这样有趣的事情就来了。下一讲,我们来看看这件有趣的事,看函数式编程中是怎么组合函数的。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**函数式编程的要素是一等公民的函数,如果语言不支持,可以自己模拟。**
|
||||
|
||||
## 思考题
|
||||
|
||||
今天我们开始了函数式编程的讲解,我想请你谈谈函数式编程给你留下的最深刻印象,无论是哪门函数式编程语言也好,还是某个函数式编程的特性也罢。欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
181
极客时间专栏/软件设计之美/设计一个软件—编程范式/18 | 函数式编程之组合性:函数式编程为什么如此吸引人?.md
Normal file
181
极客时间专栏/软件设计之美/设计一个软件—编程范式/18 | 函数式编程之组合性:函数式编程为什么如此吸引人?.md
Normal file
@@ -0,0 +1,181 @@
|
||||
<audio id="audio" title="18 | 函数式编程之组合性:函数式编程为什么如此吸引人?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a5/05/a58147a2c476ab47cfc11bf9eda0ec05.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
从上一讲开始,我们开启了函数式编程之旅,相信你已经对函数式编程有了一个初步的认识。函数式编程是一种以函数为编程元素的编程范式。但是,如果只有函数这一样东西,即使是说出花来,也没有什么特别的地方。
|
||||
|
||||
之前我讲过,GC来自于函数式编程,Lambda也来自于函数式编程。此外,在 Java 8增加的对函数式编程的处理中,流(Stream)的概念也从函数式编程中来,Optional也和函数式编程中的一些概念有着紧密的联系。由此可见,函数式编程给我们提供了许多优秀的内容。
|
||||
|
||||
接下来,我们来**讲讲函数式编程在设计上对我们帮助最大的两个特性:组合性和不变性。**
|
||||
|
||||
首先,我们来讨论一下组合性,看看函数式编程为什么能够如此吸引人。
|
||||
|
||||
## 组合行为的高阶函数
|
||||
|
||||
在函数式编程中,有一类比较特殊的函数,它们可以接收函数作为输入,或者返回一个函数作为输出。这种函数叫做**高阶函数**(High-order function)。
|
||||
|
||||
听上去稍微有点复杂,如果我们回想一下高中数学里有一个复合函数的概念,也就是 f(g(x)) ,把一个函数和另一个函数组合起来,这么一类比,是不是就好接受一点了。
|
||||
|
||||
那么,**高阶函数有什么用呢?它的一个重要作用在于,我们可以用它去做行为的组合**。我们再来回顾一下上一讲写过的一段代码:
|
||||
|
||||
```
|
||||
find(byName(name).and(bySno(sno)));
|
||||
|
||||
```
|
||||
|
||||
在这里面,find的方法就扮演了一个高阶函数的角色。它接收了一个函数作为参数,由此,一些处理逻辑就可以外置出去。这段代码的使用者,就可以按照自己的需要任意组合。
|
||||
|
||||
你可能注意到了,这里的find方法只是一个普通的Java函数。是这样的,如果不需要把这个函数传来传去,普通的Java函数也可以扮演高阶函数的角色。
|
||||
|
||||
可以这么说,高阶函数的出现,让程序的编写方式出现了质变。按照传统的方式,程序库的提供者要提供一个又一个的完整功能,就像findByNameAndBySno这样,但按照函数式编程的理念,提供者提供的就变成了一个又一个的构造块,像find、byName、bySno这样。然后,使用者可以根据自己的需要进行组合,非常灵活,甚至可以创造出我们未曾想过的组合方式。
|
||||
|
||||
这就是典型的函数式编程风格。**模型提供者提供出来的是一个又一个的构造块,以及它们的组合方式。由使用者根据自己需要将这些构造块组合起来,提供出新的模型,供其他开发者使用**。就这样,模型之间一层又一层地逐步叠加,最终构建起我们的整个应用。
|
||||
|
||||
前面我们讲过,一个好模型的设计就是逐层叠加。**函数式编程的组合性,就是一种好的设计方式**。
|
||||
|
||||
但是,能把模型拆解成多个可以组合的构造块,这个过程非常考验人的洞察力,也是“分离关注点”的能力,但是这个过程可以让人得到一种智力上的愉悦。为什么函数式编程一直处于整个IT行业的角落里,还能吸引一大批优秀的开发者前赴后继地投入其中呢?这种智力上的愉悦就是一个重要的原因。
|
||||
|
||||
还记得我们在课程一开始讲的分层模型吗?这一点在函数式编程社区得到了非常好的体现。著名的创业孵化器[Y Combinator](https://www.ycombinator.com/)的创始人Paul Graham曾经写过一篇文章《[The Roots of Lisp](http://www.paulgraham.com/rootsoflisp.html)》([中文版](http://daiyuwen.freeshell.org/gb/rol/roots_of_lisp.html)),其中用了七个原始操作符加上函数定义的方式,构建起一门LISP语言。
|
||||
|
||||
没错,是构建了一门语言。有了语言,你就可以去完成任何你想做的事了。这篇文章非常好地体现了函数式编程社区这种逐步叠加构建模型的思想。有兴趣的话,你可以去读一下。
|
||||
|
||||
当我们把模型拆解成小的构造块,如果构造块足够小,我们自然就会发现一些通用的构造块。
|
||||
|
||||
## 列表转换思维
|
||||
|
||||
我们说过,早期的函数式编程探索是从LISP语言开始的。LISP这个名字源自“List Processing”,这个名字指明了这个语言中的一个核心概念:List,也就是列表。程序员对List并不陌生,这是一种最为常用的数据结构,现在的程序语言几乎都提供了各自List的实现。
|
||||
|
||||
LISP 的一个洞见就是,大部分操作最后都可以归结成列表转换,也就是说,数据经过一系列的列表转换会得到一个结果,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3c/a7/3cbf06a7962dc5ed3db56d3f93859aa7.jpg" alt="">
|
||||
|
||||
**想要理解这一系列的转换,就要先对每个基础的转换有所了解。最基础的列表转换有三种典型模式,分别是map、filter和reduce**。如果我们能够正确理解它们,基本上就可以把for循环抛之脑后了。做过大数据相关工作的同学一定听说过一个概念:MapReduce,这是最早的一个大数据处理框架,这里的map和reduce就是源自函数式编程里列表转换的模式。
|
||||
|
||||
接下来,我们就来一个一个地看看它们分别是什么。
|
||||
|
||||
首先是map。map就是把一组数据通过一个函数映射为另一组数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ea/a2/ea79b6143783def94684df60023811a2.jpg" alt="">
|
||||
|
||||
比如,我有一组数[1、2、3、4],然后做了一个map操作,这里用作映射的函数是乘以2,也就是说,这组数里面的每个元素都乘以2,这样,我就得到了一组新的数[2、4、6、8]。
|
||||
|
||||
再来看filter。filter是把一组数据按照某个条件进行过滤,只有满足条件的数据才会留下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/16/8f1653c0aa10e5c97b266ea898ec2f16.jpg" alt="">
|
||||
|
||||
同样[1、2、3、4]为例,我们做一个filter操作,过滤的函数是大于2,也就是说,只有大于2的数才会留下,得到的结果就是[3、4]。
|
||||
|
||||
最后是reduce。reduce就是把一组数据按照某个规则,归约为一个数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fc/62/fcfcd8af1e638839b831c932eae9e962.jpg" alt="">
|
||||
|
||||
还是[1、2、3、4],如果我们做一个reduce操作,其归约函数是一个加法操作,也就是这组数里面的每个元素相加,最终会得到一个结果,也就是 1+2+3+4=10。
|
||||
|
||||
好,有了基础之后,我们就可以利用这些最基础的转换模式去尝试解决问题了。比如,上一讲我们讲了一个学生的例子,现在,我们想知道这些学生里男生的总数。我们可以给Student类增加一个性别的字段:
|
||||
|
||||
```
|
||||
// 单个学生的定义
|
||||
class Student {
|
||||
...
|
||||
// 性别
|
||||
private Gender gender;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
要想知道男生的总数,传统做法应该是这么做:
|
||||
|
||||
```
|
||||
long countMale() {
|
||||
long count = 0;
|
||||
for (Student student : students) {
|
||||
if (Gender.MALE == student.getGender())) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
按照列表转换的思维来做的话,我们该怎么做呢?首先,要把这个过程做一个分解:
|
||||
|
||||
- 取出性别字段;
|
||||
- 判别性别是否为男性;
|
||||
- 计数加1。
|
||||
|
||||
这三步刚好对应着map、filter和reduce:
|
||||
|
||||
- 取出性别字段,对应着map,其映射函数是取出学生的性别字段;
|
||||
- 判别性别是否为男性,对应filter,其过滤函数是,性别为男性;
|
||||
- 计数加1,对应着reduce,其归约函数是,加1。
|
||||
|
||||
有了这个分解的结果,我们再把它映射到代码上。Java 8对于函数式编程的支持,除了Lambda之外,它也增加了对列表转换的支持。为了兼容原有的API,它提供了一个新的接口:Stream,你可以把它理解成List的另一种表现形式。如果把上面的步骤用Java 8的Stream方式写出来,代码应该是这样的:
|
||||
|
||||
```
|
||||
long countMale() {
|
||||
return students.stream()
|
||||
.map(student -> student.getGender())
|
||||
.filter(gender -> gender == Gender.MALE)
|
||||
.map(gender -> 1L)
|
||||
.reduce(0L, (sum, element) -> sum + element);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这基本和上面操作步骤是一一对应的,只是多了一步将性别转换成1,便于后面的计算。
|
||||
|
||||
map、filter和reduce只是最基础的三个操作,列表转换可以提供的操作远远比这个要多。不过,你可以这么理解,大多数都是在这三个基础上进行了封装,提供一种快捷方式。比如,上面代码的最后两步map和reduce,在Java 8的Stream接口提供了一个count方式,可以写成方法:
|
||||
|
||||
```
|
||||
long countMale() {
|
||||
return students.stream()
|
||||
.map(Student::getGender)
|
||||
.filter(byGender(Gender.MALE))
|
||||
.count();
|
||||
}
|
||||
|
||||
static Predicate<Gender> byGender(final Gender target) {
|
||||
return gender -> gender == target;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
一方面,我用了方法引用(Student::getGender),这是Java提供的简化代码编写的一种方式。另一方面,我还把按照性别比较提取了出来,如此一来,代码的可读性就提升了,你基本上可以把它同前面写的操作步骤完全对应起来了。
|
||||
|
||||
同样是一组数据的处理,我更鼓励使用函数式的列表转换,而不是传统的 for 循环。一方面因为它是一种更有表达性的写法,从前面的代码就可以看到,它几乎和我们想做的事是一一对应的。另一方面,这里面提取出来比较性别的方法,它就是一个可以用作组合的基础接口,可以在多种场合复用。
|
||||
|
||||
很多Java程序员适应不了这种写法,一个重要的原因在于,他们缺少对于列表转换的理解。缺少了一个重要的中间环节,必然会出现不适。
|
||||
|
||||
你回想一下,我们说过结构化编程给我们提供了一些基础的控制结构,那其实也是一层封装,只不过,我们在编程之初就熟悉了if、for之类的写法。如果你同样熟悉函数式编程的基础设施,这些代码理解起来同那些控制结构没有什么本质区别,而且这些基础设施的抽象级别要比那些控制结构更高,提供了更好的表达性。
|
||||
|
||||
我们之前在讲DSL的时候就谈到过代码的表达性,其中一个重要的观点就是,有一个描述了做什么的接口之后,具体怎么做就可以在背后不断地进行优化。比如,如果一个列表的数据特别多,我们可以考虑采用并发的方式进行处理,而这种优化在使用端完全可以做到不可见。MapReduce 甚至将运算分散到不同的机器上执行,其背后的逻辑是一致的。
|
||||
|
||||
## 面向对象与函数式编程的组合
|
||||
|
||||
至此,我们已经学习了函数式编程的组合。你可能会有一个疑问,我们之前在讲面向对象的时候,也谈到了组合,这里讲函数式编程,又谈到了组合。这两种组合之间是什么关系呢?其实,对比一下代码,你就不难发现了,面向对象组合的元素是类和对象,而函数式编程组合的是函数。
|
||||
|
||||
这也就牵扯到在实际工作中,如何将面向对象和函数式编程两种不同的编程范式组合运用的问题。**我们可以用面向对象编程的方式对系统的结构进行搭建,然后,用函数式编程的理念对函数接口进行设计**。你可以把它理解成盖楼,用面向对象编程搭建大楼的骨架,用函数式编程设计门窗。
|
||||
|
||||
通过这两讲的例子,相信你已经感受到,一个好的函数式的接口,需要我们做的同样是“分离关注点”。虽然你不知道组合的方式会有多少种,但你知道,所有的变化其实就是一些基础元素的不断组合。在后面的巩固篇中,讲到Moco时,我们还会领略到这种函数式接口的魅力。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我先给你讲了一类特殊的函数——高阶函数,它可以接受函数或返回函数。有了高阶函数,函数式编程就可以组合了,把不同的函数组合在一起完成功能,这也给逐层构建新抽象埋下了伏笔,函数式编程从此变得精彩起来。从设计的角度看,这种模型的层层叠加,是一种好的设计方式。
|
||||
|
||||
函数式编程中,还有一个重要的体系,就是列表转换的思想,将很多操作分解成若干转换的组合。最基础的三个转换是:map、filter和reduce,更多的转换操作都可以基于这三个转换完成。
|
||||
|
||||
面向对象和函数式编程都提到了组合性,不同的是,面向对象关键在于结构的组合,而函数式编程在于函数接口的组合。
|
||||
|
||||
组合性为我们提供了一个让函数接口组合的方式,下一讲我们再来讲一个让代码减少Bug的设计理念:不变性。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**设计可以组合的函数接口**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3a/61/3a8b5db0d00f84bacf93d9bf80e10d61.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
函数式编程的组合性会给人带来极大的智力愉悦,你在学习软件开发的过程中,还有哪些东西曾经给你带来极大的智力愉悦呢?欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
158
极客时间专栏/软件设计之美/设计一个软件—编程范式/19 | 函数式编程之不变性:怎样保证我的代码不会被别人破坏?.md
Normal file
158
极客时间专栏/软件设计之美/设计一个软件—编程范式/19 | 函数式编程之不变性:怎样保证我的代码不会被别人破坏?.md
Normal file
@@ -0,0 +1,158 @@
|
||||
<audio id="audio" title="19 | 函数式编程之不变性:怎样保证我的代码不会被别人破坏?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/98/93/98b2b3a6ef9b6564e4f023cda3e9a893.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
经过前两讲的介绍,你已经认识到了函数式编程的能力,函数以及函数之间的组合很好地体现出了函数式编程的巧妙之处。不过,我们在讲编程范式时说过,学习编程范式不仅要看它提供了什么,还要看它约束了什么。这一讲,我们就来看看函数式编程对我们施加的约束。
|
||||
|
||||
在软件开发中,有一类Bug是很让人头疼的,就是你的代码怎么看都没问题,可是运行起来就是出问题了。我曾经就遇到过这样的麻烦,有一次我用C写了一个程序,怎么运行都不对。我翻来覆去地看自己的代码,看了很多遍都没发现问题,不得已,只能一步一步跟踪代码。最后,我发现我的代码调用到一个程序库时,出现了与预期不符的结果。
|
||||
|
||||
这个程序库是其他人封装的,我只是拿过来用。按理说,我调用的这个函数逻辑也不是特别复杂,不应该出现什么问题。不过,为了更快定位问题,我还是打开了这个程序库的源代码。经过一番挖掘,我发现在这个函数底层实现中,出现了一个全局变量。
|
||||
|
||||
分析之后,我发现正是这个全局变量引起了这场麻烦,因为在我的代码执行过程中,有别的程序会调用另外的函数,修改这个全局变量的值,最终,导致了我的程序执行失败。从表面上看,我调用的这个函数和另外那个函数八竿子都打不到,但是,它们却通过一个底层的全局变量,产生了相互的影响。
|
||||
|
||||
这就是一类非常让人头疼的Bug。有人认为这是全局变量使用不当造成的,在Java设计中,甚至取消了全局变量,但类似的问题并没有因此减少,只是以不同面貌展现出来而已,比如,static 变量。
|
||||
|
||||
那么造成这类问题的真正原因是什么呢?**真正原因就在于变量是可变的**。
|
||||
|
||||
## 变之殇
|
||||
|
||||
你可能会好奇,难道变量不就应该是变的吗?为了更好地理解这一类问题,我们来看一段代码:
|
||||
|
||||
```
|
||||
class Sample1 {
|
||||
private static final DateFormat format =
|
||||
new SimpleDateFormat("yyyy.MM.dd");
|
||||
|
||||
public String getCurrentDateText() {
|
||||
return format.format(new Date());
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果你不熟悉JDK的SimpleDateFormat,你可能会觉得这段代码看上去还不错。然而,这段代码在多线程环境下就会出问题。正确的用法应该是这样:
|
||||
|
||||
```
|
||||
public class Sample2 {
|
||||
public String getCurrentDateText() {
|
||||
DateFormat format = new SimpleDateFormat("yyyy.MM.dd");
|
||||
return format.format(new Date());
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
两段代码最大的区别就在于,SimpleDateFormat在哪里构建。一个是被当作了一个字段,另一个则是在函数内部构建出来。这两种不同做法的根本差别就在于,SimpleDateFormat对象是否共享。
|
||||
|
||||
为什么这个对象共享会有问题呢?翻看format方法的源码,你会发现这样一句:
|
||||
|
||||
```
|
||||
calendar.setTime(date);
|
||||
|
||||
```
|
||||
|
||||
这里的calendar是SimpleDateFormat这个类的一个字段,正是因为在format的过程中修改了calendar字段,所以,它才会出问题。
|
||||
|
||||
我们来看看这种问题是怎么出现的,就像下面这张图看到的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d2/db/d267c5c0ec206bef0eeae93f056d50db.jpg" alt="">
|
||||
|
||||
- A线程把变量的值修改成自己需要的值;
|
||||
- 这时发生线程切换,B线程开始执行,将变量的值修改成它所需要的值;
|
||||
- 线程切换回来,A线程继续执行,但此时变量已经不是自己设置的值了,所以,执行会出错。
|
||||
|
||||
回到SimpleDateFormat上,问题是一样的,calendar就是那个共享的变量。一个线程刚刚设置的值,可能会被另外一个线程修改掉,因此会造成结果的不正确。而在Sample2的写法中,通过每次创建一个新的SimpleDateFormat对象,我们将二者之间的共享解开,规避了这个问题。
|
||||
|
||||
那如果我还是想按照Sample1的写法写,SimpleDateFormat这个库应该怎么改写呢?可能你会想,SimpleDateFormat的作者没写好,如果换我写,我就会给它加上一个同步(synchronized)或者加上锁(Lock)。你甚至都没有注意,你轻易地将多线程的复杂性引入了进来。还记得我在分离关注点那节讨论的问题吗,多线程是另外一个关注点,能少用,尽量少用。
|
||||
|
||||
一个更好的办法是将calendar变成局部变量,这样一来,不同线程之间共享变量的问题就得到了根本的解决。但是,这类非常头疼的问题在函数式编程中却几乎不存在,这就依赖于函数式编程的不变性。
|
||||
|
||||
## 不变性
|
||||
|
||||
函数式编程的不变性主要体现在值和纯函数上。值,你可以将它理解为一个初始化之后就不再改变的量,换句话说,当你使用一个值的时候,值是不会变的。纯函数,是符合下面两点的函数:
|
||||
|
||||
- 对于相同的输入,给出相同的输出;
|
||||
- 没有副作用。
|
||||
|
||||
把值和纯函数合起来看,**值保证不会显式改变一个量**,**而纯函数保证的是**,**不会隐式改变一个量**。
|
||||
|
||||
我们说过,函数式编程中的函数源自数学中的函数。在这个语境里,函数就是纯函数,一个函数计算之后是不会产生额外的改变的,而函数中用到的一个一个量就是值,它们是不会随着计算改变的。所以,在函数式编程中,计算天然就是不变的。
|
||||
|
||||
正是由于不变性的存在,我们在前面遇到的那些问题也就不再是问题了。一方面,如果你拿到一个量,这次的值是1,下一次它还是1,我们完全不用担心它会改变。另一方面,我们调用一个函数,传进去同样的参数,它保证给出同样的结果,行为是完全可以预期的,不会碰触到其他部分。即便是在多线程的情况下,我们也不必考虑同步的问题,后续一系列的问题也就不存在了。
|
||||
|
||||
这与我们习惯的方式有着非常大的区别,因为传统方式的基础是面向内存单元的,改来改去甚至已经成为了程序员的本能。所以,我们对counter = counter + 1这种代码习以为常,而初学编程的人总会觉得这在数学上是不成立的。
|
||||
|
||||
在之前的讨论中,我们说过,传统的编程方式占优的地方是执行效率,而现如今,这个优点则越来越不明显,反而是因为到处可变而带来了更多的问题。相较之下,我们更应该在现在的设计中,考虑借鉴函数式编程的思路,把不变性更多地应用在我们的代码之中。
|
||||
|
||||
那怎么应用呢?首先是值。我们可以编写不变类,就是对象一旦构造出来就不能改变,Java程序员最熟悉的不变类应该就是String类,怎样编写不变类呢?
|
||||
|
||||
- 所有的字段只在构造函数中初始化;
|
||||
- 所有的方法都是纯函数;
|
||||
- 如果需要有改变,返回一个新的对象,而不是修改已有字段。
|
||||
|
||||
前面两点可能还好理解,最后一点,我们可以看一下Java String类的replace方法签名:
|
||||
|
||||
```
|
||||
String replace(char oldChar, char newChar);
|
||||
|
||||
```
|
||||
|
||||
在这里,我们会用一个新的字符(newChar)替换掉这个字符串中原有的字符(oldChar),但我们并不是直接修改已有的这个字符串,而是创建一个新的字符串对象返回。这样一来,使用原来这个字符串的类并不用担心自己引用的内容会随之变化。
|
||||
|
||||
有了这个基础,等我们后面学习领域驱动设计的时候,你就很容易理解值对象(Value Object)是怎么回事了。
|
||||
|
||||
我们再来看纯函数。**编写纯函数的重点是**,**不修改任何字段**,**也不调用修改字段内容的方法**。因为在实际的工作中,我们使用的大多数都是传统的程序设计语言,而不是严格的函数式编程语言,不是所有用到的量都是值。所以,站在实用性的角度,如果要使用变量,就使用局部变量。
|
||||
|
||||
还有一个实用性的编程建议,就是使用语法中不变的修饰符,比如,Java就尽可能多使用final,C/C++就多写const。无论是修饰变量还是方法,它们的主要作用就是让编译器提醒你,要多从不变的角度思考问题。
|
||||
|
||||
当你有了用不变性思考问题的角度,你会发现之前的很多编程习惯是极其糟糕的,比如,Java程序员最喜欢写的setter,它就是提供了一个接口,修改一个对象内部的值。
|
||||
|
||||
不过,纯粹的函数式编程是很困难的,我们只能把编程原则设定为**尽可能编写不变类和纯函数**。但仅仅是这么来看,你也会发现,自己从前写的很多代码,尤其是大量负责业务逻辑处理的代码,完全可以写成不变的。
|
||||
|
||||
绝大多数涉及到可变或者副作用的代码,应该都是与外部系统打交道的。能够把大多数代码写成不变的,这已经是一个巨大的进步,也会减少许多后期维护的成本。
|
||||
|
||||
而正是不变性的优势,有些新的程序设计语言默认选项不再是变量,而是值。比如,在Rust里,你这么声明的是一个值,因为一旦初始化了,你将无法修改它:
|
||||
|
||||
```
|
||||
let result = 1;
|
||||
|
||||
```
|
||||
|
||||
而如果你想声明一个变量,必须显式地告诉编译器:
|
||||
|
||||
```
|
||||
let mut result = 1;
|
||||
|
||||
```
|
||||
|
||||
Java也在尝试将值类型引入语言,有一个专门的[Valhalla 项目](http://openjdk.java.net/projects/valhalla/)就是做这个的。你也看到了,不变性,是减少程序问题的一个重要努力方向。
|
||||
|
||||
现在回过头来看编程范式那一讲里说的约束:
|
||||
|
||||
>
|
||||
函数式编程,限制使用赋值语句,它是对程序中的赋值施加了约束。
|
||||
|
||||
|
||||
理解了不变性,你应该知道这句话的含义了,一旦初始化好一个量,就不要随便给它赋值了。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们讲了无论是全局变量、还是多线程,变化给程序设计带来了很多麻烦,然后我们还分析了这类问题的成因。
|
||||
|
||||
然而,这类问题在函数式编程中并不存在。其中,重要的原因就是函数式编程的不变性。函数式编程的不变性主要体现在它的值和纯函数上。深入学习函数式编程时,你会遇到的与之相关的各种说法:无副作用、无状态、引用透明等等,其实都是在讨论不变性。
|
||||
|
||||
即便使用传统的程序设计语言,我们也可以从中借鉴一些编程的方法。比如,编写不变类、编写纯函数、尽量使用不变的修饰符等等。
|
||||
|
||||
经过了这三讲的介绍,相信你已经对函数式编程有了很多认识,不过,我只是把设计中最常用的部分给你做了一个介绍,这远远不是函数式编程的全部。就算Java这种后期增补的函数式编程的语言,其中也包含了惰性求值、Optional等诸多内容,值得你去深入了解。不过我相信有了前面知识的铺垫,你再去学习函数式编程其他相关内容,难度系数就会降低一些。
|
||||
|
||||
关于编程范式的介绍,我们就告一段落,下一讲,我们开始介绍设计原则。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**尽量编写不变类和纯函数。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/24/1e/24e5693b11652ff520e01fce5648b11e.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你去了解一下[Event Sourcing](http://microservices.io/patterns/data/event-sourcing.html),结合今天的内容,谈谈你对它的理解。欢迎在留言区写下你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
331
极客时间专栏/软件设计之美/设计一个软件—编程范式/加餐 | 函数式编程拾遗.md
Normal file
331
极客时间专栏/软件设计之美/设计一个软件—编程范式/加餐 | 函数式编程拾遗.md
Normal file
@@ -0,0 +1,331 @@
|
||||
<audio id="audio" title="加餐 | 函数式编程拾遗" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8b/e6/8b242017466e3413701257479967dee6.mp3"></audio>
|
||||
|
||||
你好,我是郑晔!
|
||||
|
||||
我们之前用了三讲的篇幅讲了函数式编程,相信函数式编程在你心目中已经没有那么神秘了。我一直很偏执地认为,想要成为一个优秀的程序员,函数式编程是一定要学习的,它简直是一个待人发掘的宝库,因为里面的好东西太多了。
|
||||
|
||||
不过,考虑到整个课程的主线,我主要选择了函数式编程在设计上有较大影响的组合性和不变性来讲。但其实,函数式编程中有一些内容,虽然不一定是在设计上影响那么大,但作为一种编程技巧,也是非常值得我们去了解的。
|
||||
|
||||
所以,我准备了这次加餐,从函数式编程再找出一些内容来,让你来了解一下。相信我,即便你用的不是函数式编程语言,这些内容对你也是很有帮助的。
|
||||
|
||||
好,我们出发!
|
||||
|
||||
## 惰性求值
|
||||
|
||||
还记得我们第17讲的那个学生的例子吗?我们继续使用学生这个类。这次简化一点,我只使用其中的几个字段:
|
||||
|
||||
```
|
||||
class Student {
|
||||
// 学生姓名
|
||||
private String name;
|
||||
// 年龄
|
||||
private long age;
|
||||
// 性别
|
||||
private Gender gender;
|
||||
|
||||
public Student(final String name,
|
||||
final long age,
|
||||
final Gender gender) {
|
||||
this.name = name;
|
||||
this.age = age;
|
||||
this.gender = gender;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,我们来看一段代码,你先猜猜这段代码的执行结果会是什么样子:
|
||||
|
||||
```
|
||||
// 数据准备
|
||||
Student jack = new Student("Jack", 18, Gender.MALE);
|
||||
Student rose = new Student("Rose", 18, Gender.FEMALE);
|
||||
List<Person> students = asList(jack, rose);
|
||||
|
||||
// 模拟对象
|
||||
Function<Person, String> function = mock(Function.class);
|
||||
when(function.apply(jack)).thenReturn("Jack");
|
||||
|
||||
// 映射
|
||||
students.stream().map(function);
|
||||
|
||||
// 验证
|
||||
verify(function).apply(jack);
|
||||
|
||||
```
|
||||
|
||||
这段代码里,我们用到了一个mock框架mockito,核心就是验证这里的function变量是否得到了正确的调用,这其中就用到了我们在第18讲中提到的map函数。
|
||||
|
||||
也许你已经猜到了,虽然按照普通的Java代码执行逻辑,verify的结果一定是function得到了正常的调用,但实际上,这里的function并没有调用。也就是说,虽然看上去map函数执行了,但并没有调用到function的apply方法。你可以试着执行这段代码去验证一下。
|
||||
|
||||
为什么会是这样呢?答案就在于这段代码是惰性求值的。
|
||||
|
||||
什么叫惰性求值呢?**惰性求值(Lazy Evaluation)是一种求值策略,它将求值的过程延迟到真正需要这个值的时候**。惰性求值的好处就在于可以规避一些不必要的计算,尤其是规模比较大,或是运行时间比较长的计算。
|
||||
|
||||
其实,如果你学习过设计模式,惰性求值这个概念你应该并不陌生。有一些设计模式就是典型的惰性求值,比如,Proxy模式,它就是采用了惰性求值的策略,把一些消耗很大的计算延迟到不得不算的时候去做。还有Singleton模式有时也会采用惰性求值的策略,在第一次访问的时候,再去生成对象。
|
||||
|
||||
在函数式编程中,惰性求值是一种很常见的求值策略,也是因为惰性求值的存在,我们可以做出很多有趣的事情。
|
||||
|
||||
## 无限流
|
||||
|
||||
在传统的编程方式中,我们熟悉的集合类都是有限长度的,因为集合中的每个元素都是事先计算好的。但现在有了惰性求值,我们就可以创造出一个无限长的集合。
|
||||
|
||||
你可能会有疑问,怎么可能会有无限长的集合呢?内存也存不下啊?如果你这么想的话,说明你的思路还是传统的方式。无限长集合中的元素并不是预置进去的,而是在需要的时候,才计算出来的。
|
||||
|
||||
**无限长集合真正预置进去的是,元素的产生规则**。这样一来,元素就会像流水一样源源不断地产生出来,我们将这种集合称为无限流(Infinite Stream)。
|
||||
|
||||
比如,我们要产生一个自然数的集合,可以这么做:
|
||||
|
||||
```
|
||||
Stream.iterate(1, number -> number + 1)
|
||||
|
||||
```
|
||||
|
||||
在这里,我们定义了这个集合的第一个元素,然后给出了后续元素的推导规则,一个无限流就产生了。
|
||||
|
||||
当然,因为惰性求值的存在,这么定义的一个无限流并不会做真正的计算,只有在我们需要用到其中的一些元素时,计算才会执行。比如,我们可以按需取出一些元素,在下面这段代码中,我们跳过了无限流的前两个元素,然后,取出三个元素,将结果打印了出来:
|
||||
|
||||
```
|
||||
Stream.iterate(0, number -> number + 1)
|
||||
.skip(2)
|
||||
.limit(3)
|
||||
.forEach(System.out::println);
|
||||
|
||||
```
|
||||
|
||||
也许你会关心,什么情况下无限流才会真正的求值呢?其实,我们前面讲组合性时提到过,有一些基础的列表操作,列表操作可以分为两类,中间操作(Intermediate Operation)和终结操作(Terminal Operation),像map和filter这一类的就是中间操作,而像reduce一类的就属于终结操作。只有终结操作才需要我们给出一个结果,所以,只有终结操作才会引起真正的计算。
|
||||
|
||||
你可能会好奇,无限流的概念很有意思,但它有什么用呢?如果你对无限流有了认识,很多系统的设计都可以看作成一个无限流。比如,一些大数据平台,它就是有源源不断的数据流入其中,而我们要做的就是给这个无限流提供各种转换,你去看看现在炙手可热的Flink,它使用的就是这种思路。
|
||||
|
||||
## 记忆
|
||||
|
||||
我们再来看另一个关于惰性求值带来的有趣做法:记忆(Memoization)。
|
||||
|
||||
前面说过,Proxy模式之所以要采用惰性求值的策略,一个重要的原因就是真正的计算部分往往是消耗很大的。所以,一旦计算完成,一个好的策略就是将计算的结果缓存起来,这样,再次调用时就不必重新计算了。其实,这种做法就是记忆。
|
||||
|
||||
记忆,在Wikipedia上是这样定义的:
|
||||
|
||||
>
|
||||
在计算中,记忆是一种优化技术,主要用于加速计算机程序,其做法就是将昂贵函数的结果存储起来,当使用同样的输入再次调用时,返回其缓存的结果。
|
||||
|
||||
|
||||
这里的一个重点是,同样的输入。我们已经知道了,函数式编程中的函数是纯函数,同样的输入必然会给出同样的输出。所以,我们就不难理解,记忆这种技术在函数式编程中的作用了。
|
||||
|
||||
实现记忆这种技术并不难,下面就给出了一个实现,这里用到了Java并发库中的类AtomicReference,从而消除了可能产生的多线程问题:
|
||||
|
||||
```
|
||||
public static <T> Supplier<T> memoize(Supplier<T> delegate) {
|
||||
AtomicReference<T> value = new AtomicReference<>();
|
||||
return () -> {
|
||||
T val = value.get();
|
||||
if (val == null) {
|
||||
synchronized(value) {
|
||||
val = value.get();
|
||||
if (val == null) {
|
||||
val = Objects.requireNonNull(delegate.get());
|
||||
value.set(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
return val;
|
||||
};
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个实现用起来也很简单:
|
||||
|
||||
```
|
||||
long ultimateAnswer = memoize(() -> {
|
||||
// 这里有一个常常的计算
|
||||
// 返回一个终极答案
|
||||
return 42;
|
||||
})
|
||||
|
||||
```
|
||||
|
||||
在这里,memoize是一个通用的实现,它的适用范围很广。我们仔细对比就不难发现,这里我们已经实现了Proxy模式的能力,换言之,有了它,我们可以不再需要Proxy模式。后面我们讲到设计模式也会提到,一些设计模式是受限于程序设计语言自身能力不足而出现的,这里也算为这个观点添加了一个注脚。
|
||||
|
||||
## Optional
|
||||
|
||||
让我们回到学生的例子上,如果想获取一个学生出生的国家,我们该怎么写这段代码呢?直觉上的写法是这样的:
|
||||
|
||||
```
|
||||
public Country getBirthCountry() {
|
||||
return this.getBirthPlace() // 获取出生地
|
||||
.getCity() // 获取城市
|
||||
.getProvince() // 获取省份
|
||||
.getCountry(); // 获取国家
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然而,在真实项目中,代码并不能这么写,因为这样可能会出现空指针,所以,我们不得不把代码写成这样:
|
||||
|
||||
```
|
||||
public Country getBirthCountry() {
|
||||
Place place = this.birthPlace;
|
||||
if (place != null) {
|
||||
City city = place.getCity();
|
||||
if (city != null) {
|
||||
Province province = city.getProvince();
|
||||
if (province != null) {
|
||||
return province.getCountry();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这是一段令人作呕的代码,但我们不得不这么写,因为空指针总是一个令人头疼的问题。事实上,作为程序员,我们经常会有忘记做空指针检查的时候。这不是一个人的问题,而是整个行业的问题, IT 行业每年都会因此造成巨大的损失。
|
||||
|
||||
>
|
||||
<p>我将其称为自己犯下的十亿美元错误……<br>
|
||||
I call it my billion-dollar mistake…<br>
|
||||
——Sir C. A. R. Hoare,空引用的发明者</p>
|
||||
|
||||
|
||||
难道空指针就是一个无解的问题吗?程序员们并不打算束手就擒,于是,一种新的解决方案产生了,就是可选对象。这个解决方案在Java 8中叫Optional,在Scala中叫Option。接下来,我们就以Java 8中的Optional为例进行讲解。
|
||||
|
||||
Optional是一个对象容器,其中可能包含着一个非空的值,也可能不包含。这是什么意思呢?它和直接使用对象的场景是一一对应的,如果包含值,就对应着就是有值的场景;而不包含,则对应着值为空的场景。
|
||||
|
||||
那该如何去创建一个Optional对象呢?
|
||||
|
||||
- 如果有一个非空对象,可以用 of() 将它包装成一个 Optional 对象;
|
||||
- 如果要表示空,可以返回一个 empty();
|
||||
- 如果有一个从别处传来的对象,你不知道它是不是空,可以用 ofNullable()。
|
||||
|
||||
```
|
||||
Optional.of("Hello"); // 创建一个Optional对象,其中包含了"Hello"字符串
|
||||
Optional.empty(); // 创建了一个表示空对象的Optional对象。
|
||||
Optional.ofNullable(instance); // 创建了一个Optional对象,不知instance是否为空。
|
||||
|
||||
```
|
||||
|
||||
也许你会好奇,直接使用对象都解决不了问题,把对象放到一个容器里就解决了?还真能。因为你要用这个对象的时候,需要把对象取出来,而要取出对象,你就需要判断一下这个对象是否为空。就像下面这面代码这样:
|
||||
|
||||
```
|
||||
if (country.isPresent()) {
|
||||
return country.get();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
只有Optional里包含的是一个非空的对象时, get() 方法才能正常执行,否则,就会抛出异常。显然,当你调用 get()的时候,意图是很明显的,我要处理的是一个非空的值,所以,就必须加上一段判断对象是否存在的代码。
|
||||
|
||||
这比直接访问对象多用了一步,但正是这多出的一步让你的大脑必须想一下,自己是否需要加上判空的处理,而不是像普通对象一样,一下子就滑了过去。
|
||||
|
||||
而且因为 get()本身是有意图的,用工具也可以扫描出缺失的判断,比如,如果你用IntelliJ IDEA写程序的话,不加判断,直接get()的话,它就会给你一个警告。
|
||||
|
||||
使用Optional,我们还可以给空对象增加一些额外的处理,比如给个缺省值:
|
||||
|
||||
```
|
||||
country.orElse(china); // 返回一个缺省的对象
|
||||
|
||||
```
|
||||
|
||||
也可以生成一个新的对象:
|
||||
|
||||
```
|
||||
country.orElseGet(Country::new); // 调用了一个函数生成了一个新对象
|
||||
|
||||
```
|
||||
|
||||
或是抛出异常:
|
||||
|
||||
```
|
||||
country.orElseThrow(IllegalArgumentException::new);
|
||||
|
||||
```
|
||||
|
||||
其实,我们拿到一个值之后,往往要做一些更多的处理。使用了Optional,我们甚至可以不用把其中的值取出来,直接就做一些处理了。比如,它提供map、flatMap、filter等一些方法,就是当Optional包含的对象不为空时,调用对应的方法做处理,为空的时候,直接返回表示空的Optional对象。
|
||||
|
||||
从下面这张图,你就能够理解这些方法的基本逻辑:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/56/59/563d937c8cf9e3772a10cf3b34fd4b59.jpg" alt="">
|
||||
|
||||
好,有了对Optional的基本了解,我们在日常工作中怎么用它呢?很简单,**在方法需要返回一个值时,如果返回的对象可能为空,那就返回一个Optional**。这样就给了这个方法使用者一个提示,这个对象可能为空,小心处理。
|
||||
|
||||
比如,获取学生的出生地,方法可以这么写:
|
||||
|
||||
```
|
||||
Optional<Place> getBirthPlace() {
|
||||
return Optional.ofNullable(this.birthPlace);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
好,回到我们前面的问题上。获取一个学生出生的国家,代码可以怎么写呢?如果相应的方法都改写成Optional,代码写出来会是这个样子:
|
||||
|
||||
```
|
||||
public Optional<Country> getBirthCountry() {
|
||||
return Optional.ofNullable(this.birthPlace)
|
||||
.flatMap(Place::getCity)
|
||||
.flatMap(City::getProvince)
|
||||
.flatMap(Province::getCountry);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
虽然我们不能说这段代码一定有多优雅,但是至少比层层嵌套的if判断要整洁一些了。
|
||||
|
||||
最后,你可能会问,这个Optional和函数式编程有什么关系呢?其实,Optional将对象封装起来的做法来自于函数式编程中一个叫Monad的概念,你可以简单地把它理解成一个对象容器。Optional就对应着其中的一种:Maybe Monad。
|
||||
|
||||
我们前面也看到了,正是因为这个容器的存在,解决了很多问题。Monad 的概念解释起来还有很多东西要说,篇幅所限,就不过多阐述了,有兴趣不妨自己去了解一下。
|
||||
|
||||
这种对象容器的思想也逐渐在开枝散叶,比如,在Rust的标准库里,有一个[Result](http://doc.rust-lang.org/std/result/),用来定义可恢复的故障。它可以是一个正常值,也可以是一个错误值:
|
||||
|
||||
```
|
||||
enum Result<T, E> {
|
||||
Ok(T),
|
||||
Err(E),
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
下面是一段摘自Rust标准库文档的代码,当我们有了前面对于Optional的讲解,理解起这段代码也就容易多了。
|
||||
|
||||
```
|
||||
enum Version { Version1, Version2 }
|
||||
|
||||
// 定义一个解析版本的函数
|
||||
fn parse_version(header: &[u8]) -> Result<Version, &'static str> {
|
||||
match header.get(0) {
|
||||
None => Err("invalid header length"), // 无法解析,返回错误
|
||||
Some(&1) => Ok(Version::Version1), // 解析出版本1
|
||||
Some(&2) => Ok(Version::Version2), // 解析出版本2
|
||||
Some(_) => Err("invalid version"), // 无效版本,返回错误
|
||||
}
|
||||
}
|
||||
|
||||
let version = parse_version(&[1, 2, 3, 4]);
|
||||
// 根据返回值进行处理
|
||||
match version {
|
||||
Ok(v) => println!("working with version: {:?}", v),
|
||||
Err(e) => println!("error parsing header: {:?}", e),
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我给你讲了两个比较有用的函数式编程的概念:惰性求值和Optional。
|
||||
|
||||
惰性求值是一种求值策略,它将求值的过程延迟到真正需要这个值的时候,其作用就是规避一些不必要的计算。因为惰性求值的存在,还衍生出一些有趣的做法,比如,无限流和记忆。无限流启发了现在的一些大数据平台的设计,而记忆可以很好地替代Proxy模式。
|
||||
|
||||
Optional是为了解决空对象而产生的,它其实就是一个对象容器。因为这个容器的存在,访问对象时,需要增加一步思考,减少犯错的几率。
|
||||
|
||||
正如我在前面课程中讲到,函数式编程中有很多优秀的内容,值得我们去学习借鉴。我在这几讲中讲到的内容,也只能说是管中窥豹,帮助你见识函数式编程一些优秀的地方。
|
||||
|
||||
如果你想了解更多函数式编程,不妨读读《[计算机程序的构造与解释](http://book.douban.com/subject/1148282/)》,体会一层一层构建抽象的美妙。如果还想了解更多,那就找一门函数式编程语言去学习一下。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**花点时间学习函数式编程。**
|
||||
|
||||
## 思考题
|
||||
|
||||
现在,你已经对函数式编程不陌生了,我想请你谈谈学习函数式编程的感受,无论你是刚刚跟着我学习的,还是之前已经学习过的,欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
159
极客时间专栏/软件设计之美/设计一个软件—设计原则与模式/20 | 单一职责原则:你的模块到底为谁负责?.md
Normal file
159
极客时间专栏/软件设计之美/设计一个软件—设计原则与模式/20 | 单一职责原则:你的模块到底为谁负责?.md
Normal file
@@ -0,0 +1,159 @@
|
||||
<audio id="audio" title="20 | 单一职责原则:你的模块到底为谁负责?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/88/58/88c36aba17709c1b8f197481947b4558.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
经过前面的讲解,我们对各种编程范式已经有了基本的理解,也知道了自己手上有哪些可用的设计元素。但只有这些元素是不够的,我们还需要一些比编程范式更具体的内容来指导工作。从这一讲开始,我们就进入到设计原则的学习。
|
||||
|
||||
在众多的原则中,我们该学习哪个呢?我选择了SOLID原则,因为SOLID原则是一套比较成体系的设计原则。它不仅可以指导我们设计模块(在面向对象领域,模块主要指的就是类),还可以被当作一把尺子,来衡量我们设计的有效性。
|
||||
|
||||
那SOLID原则是什么呢?它实际上是五个设计原则首字母的缩写,它们分别是:
|
||||
|
||||
- 单一职责原则(Single responsibility principle,SRP)
|
||||
- 开放封闭原则(Open–closed principle,OCP)
|
||||
- Liskov替换原则(Liskov substitution principle,LSP)
|
||||
- 接口隔离原则(Interface segregation principle,ISP)
|
||||
- 依赖倒置原则(Dependency inversion principle,DIP)
|
||||
|
||||
这些设计原则是由Robert Martin提出并逐步整理和完善的。他在《[敏捷软件开发:原则、实践与模式](http://book.douban.com/subject/1140457/)》和《[架构整洁之道](http://book.douban.com/subject/30333919/)》两本书中,对SOLID原则进行了两次比较完整的阐述。在这两本时隔近20年的书里,你可以看到Robert Martin对SOLID原则的理解一步步在深化,如果你想了解原作者的思考,这两本书都推荐你阅读。
|
||||
|
||||
那么,在接下来的几讲中,我就来给你讲解这五个设计原则,除了设计原则的基本内容之外,我还会把我自己的理解增补其中,把两本书中没有讲到的一些逻辑给你补充进去。
|
||||
|
||||
好,我们开始,率先登场的当然就是单一职责原则。
|
||||
|
||||
## 变化的原因
|
||||
|
||||
单一职责原则,这个名字非常容易让我们望文生义,我们可能会理解成,一个类只干一件事,这看起来似乎是一项再合理不过的要求了。因为,几乎所有的程序员都知道“高内聚、低耦合”,都知道该把相关的代码放到一起。
|
||||
|
||||
所以,如果我们随便拿一个模块去问他的作者,这个模块是不是只做了一件事,他们的答案几乎都会是一样的:是的,只做了一件事。那么,既然这个设计原则如此通用,以至于所有的人都可以做到,那我们为什么还要有这样一个设计原则呢?
|
||||
|
||||
原因就在于,我们一开始的理解就是错的,我们把单一职责理解成了有关如何组合的原则,但实际上,单一职责是关于如何分解的。
|
||||
|
||||
那到底什么是单一职责原则呢?
|
||||
|
||||
正如Robert Martin所说,单一职责的定义经历了一些变化。在《敏捷软件开发:原则、实践与模式》中其定义是,“一个模块应该有且仅有一个变化的原因”;而到了《架构整洁之道》中,其定义就变成了“一个模块应该对一类且仅对一类行为者(actor)负责”。
|
||||
|
||||
单一职责原则和一个类只干一件事之间,最大的差别就是,**将变化纳入了考量**。
|
||||
|
||||
我们先分析第一个定义:一个模块应该有且仅有一个变化的原因。我们在课程一开始就在说,软件设计是一门关注长期变化的学问。变化是我们最不愿意面对却不得不面对的事,因为变化会引发新的不确定性,可能是新增功能自身的稳定问题,也可能是旧有功能遭到破坏带来的问题。
|
||||
|
||||
所以,**一个模块最理想的状态是不改变,其次是少改变**,它可以成为一个模块设计好坏的衡量标准。
|
||||
|
||||
在真实项目中,一个模块之所以会频繁变化,关键点就在于能引起它改变的原因太多了。
|
||||
|
||||
怎么理解呢?我们来看一个例子。假设我们要开发一个项目管理的工具,自然少不了一个用户的类,我们可能设计出这样一个用户类:
|
||||
|
||||
```
|
||||
// 用户类
|
||||
class User {
|
||||
// 修改密码
|
||||
void changePassword(String password);
|
||||
// 加入一个项目
|
||||
void joinProject(Project project);
|
||||
// 接管一个项目,成为管理员
|
||||
void takeOverProject(Project project);
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
看上去,这个类设计得还挺合理,有用户信息管理、有项目管理等等。没过多久,新的需求来了,要求每个用户能够设置电话号码,所以,你给它增加了一个新的方法:
|
||||
|
||||
```
|
||||
void changePhoneNumber(PhoneNumber phoneNumber):
|
||||
|
||||
```
|
||||
|
||||
过了几天,又来了新需求,要查看一个用户加入了多少项目:
|
||||
|
||||
```
|
||||
int countProject();
|
||||
|
||||
```
|
||||
|
||||
就这样,左一个需求,右一个需求,几乎每个需求都要改到这个类。那会导致什么结果呢?一方面,这个类会不断膨胀;另一方面,内部的实现会越来越复杂。按照我们提出的衡量标准,这个类变动的频繁程度显然是不理想的,主要原因就在于它引起变动的需求太多了:
|
||||
|
||||
- 为什么要增加电话号码呢?因为这是用户管理的需求。用户管理的需求还会有很多,比如,用户实名认证、用户组织归属等等;
|
||||
- 为什么要查看用户加入多少项目呢?这是项目管理的需求。项目管理的需求还会有很多,比如,团队管理、项目权限等等。
|
||||
|
||||
这就是两种完全不同的需求,但它们都改到了同一个类,所以,这个User类就很难稳定下来。解决这种问题,最好的办法就是把不同的需求引起的变动拆分开来。针对这里的用户管理和项目管理两种不同需求,我们完全可以把这个User类拆成两个类。比如,像下面这样,把用户管理类的需求放到User类里,把项目管理类的需求放到Member类里:
|
||||
|
||||
```
|
||||
// 用户类
|
||||
class User {
|
||||
// 修改密码
|
||||
void changePassword(String password);
|
||||
...
|
||||
}
|
||||
|
||||
// 项目成员类
|
||||
class Member
|
||||
// 加入一个项目
|
||||
void joinProject(Project project);
|
||||
// 接管一个项目,成为管理员
|
||||
void takeOverProject(Project project);
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如此一来,用户管理的需求只要调整User类就好,而项目管理的需求只要调整Member类即可,二者各自变动的理由就少了一些。
|
||||
|
||||
## 变化的来源
|
||||
|
||||
跟着我们课程一路学下来的同学可能发现了,上面的做法与我们之前讨论过的分离关注点很像。
|
||||
|
||||
确实是这样的,想要更好地理解单一职责原则,重要的就是要把不同的关注点分离出来。在上面这个例子中,分离的是不同的业务关注点。所以,**理解单一职责原则本质上就是要理解分离关注点**。
|
||||
|
||||
按照之前的说法,分离关注点,应该是发现的关注点越多越好,粒度越小越好。如果你能看到的关注点越多,就可以构建出更多的类,但每个类的规模相应地就会越小,与之相关的需求变动也会越少,它能够稳定下来的几率就会越大。我们代码库里**稳定的类越多越好,这应该是我们努力的一个方向**。
|
||||
|
||||
不过,也许你会想,如果将这种思路推演到极致,一个类应该只有一个方法,这样,它受到的影响应该是最小的。的确如此,但我们在真实项目中,一个类通常都不只有一个方法,如果我们要求所有人都做到极致,显然也是不现实的。
|
||||
|
||||
那应该把哪些内容组织到一起呢?这就需要我们考虑单一职责原则定义的升级版,也就是第二个定义:一个模块应该对一类且仅对一类行为者负责。
|
||||
|
||||
**如果说第一个定义将变化纳入了考量,那这个升级版的定义则将变化的来源纳入了考量**。
|
||||
|
||||
需求为什么会改变?因为有各种提出需求的人,不同的人提出的需求,其关注点是不同的。在前面的那个关于用户的讨论中,关心用户管理和关心项目管理的可能就是两拨完全不同的人,至少他们在提需求的时候扮演的是两种不同的角色。
|
||||
|
||||
两种不同角色的人,两件不同的事,到了代码里却混在了一起,这是不合理的。所以,分开才是一个好选择。用户管理的人,我和他们聊User,项目管理的人,我们来讨论Member。
|
||||
|
||||
>
|
||||
康威定律:一个组织设计出的系统,其结构受限于其组织的沟通结构。
|
||||
|
||||
|
||||
Robert Martin说,单一职责原则是基于康威定律的一个推论:一个软件系统的最佳结构高度依赖于使用这个软件的组织的内部结构。如果我们的软件结构不能够与组织结构对应,就会带来一系列麻烦,前面的那个例子只是一个小例子。
|
||||
|
||||
实际上,当我们更新了对于单一职责原则的理解,你会发现,它的应用范围不仅仅可以放在类这样的级别,也可以放到更大的级别。
|
||||
|
||||
我给你举个例子。我曾经接触过一个交易平台,其中有一个关键模型:手续费率,就是交易一次按什么比例收取佣金。平台可以利用手续费率做不同的活动,比如,给一些人比较低的手续费率,鼓励他们来交易,不同的手续费率意味着对不同交易行为的鼓励。
|
||||
|
||||
所以,对运营人员来说,手续费率是一个可以玩出花的东西。然而,对交易系统而言,稳定高效是重点。显然,经常修改的手续费率和稳定的系统之间存在矛盾。
|
||||
|
||||
经过分析,我们发现,这是两类不同的行为者。所以,在设计的时候,我们把手续费率设置放到运营子系统,而交易子系统只负责读取手续费率。当运营子系统修改了手续费率,会把最新的结果更新到交易子系统中。至于各种手续费率设置的花样,交易子系统根本不需要关心。
|
||||
|
||||
你看,单一职责原则也可以指导我们在不同的子系统之间进行职责分配。所以,单一职责原则这个看起来最简单的原则,实际上也蕴含着很多值得挖掘的内容。要想理解好单一职责原则:
|
||||
|
||||
- 我们需要理解封装,知道要把什么样的内容放到一起;
|
||||
- 我们需要理解分离关注点,知道要把不同的内容拆分开来;
|
||||
- 我们需要理解变化的来源,知道把不同行为者负责的代码放到不同的地方。
|
||||
|
||||
在《[10x程序员工作法](http://time.geekbang.org/column/intro/100022301)》中,我也提到过[单一职责原则](http://time.geekbang.org/column/article/87845),不过我是从自动化和任务分解的角度进行讲解的,其中讨论到了函数要小。结合今天的内容,你就可以更好地理解函数要小的含义了,每个函数承担的职责要单一,这样,它才能稳定下来。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们学习了单一职责原则。单一职责原则讲的并不是一个类只做一件事,它的关注点在于变化。其最初的定义是一个模块应该有且仅有一个变化的原因,后来其定义升级为一个模块应该对一类且仅对一类行为者负责。这个定义从考虑变化升级到考虑变化的来源。
|
||||
|
||||
单一职责原则,本质上体现的还是分离关注点,所以,它与分离关注点的思考角度是一样的,需要我们将模块拆分成更小的粒度。不过,相比于分离关注点,它会更加具体,因为它需要我们考察关注点的来源:不同的行为者。
|
||||
|
||||
单一职责原则可以应用于不同的层次,小到一个函数,大到一个系统,我们都可以用它来衡量我们的设计。
|
||||
|
||||
好,我们已经了解了SOLID的第一个原则:单一职责原则。下一讲,我们再来看下一个原则:开放封闭原则。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**应用单一职责原则衡量模块,粒度越小越好。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a8/dc/a8586dc72f41c12d68fe69680ba8a3dc.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你反思一下,在你现有的系统设计中,有没有不符合单一职责原则的地方呢?应该如何改进呢?欢迎在留言区写下你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
247
极客时间专栏/软件设计之美/设计一个软件—设计原则与模式/21 | 开放封闭原则:不改代码怎么写新功能?.md
Normal file
247
极客时间专栏/软件设计之美/设计一个软件—设计原则与模式/21 | 开放封闭原则:不改代码怎么写新功能?.md
Normal file
@@ -0,0 +1,247 @@
|
||||
<audio id="audio" title="21 | 开放封闭原则:不改代码怎么写新功能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9e/e8/9e85af57054c4b414bd4479ba70c06e8.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
上一讲,我们讲了一个最基础的设计原则:单一职责原则,从这个原则中,你知道了一个模块只应该包含来自同一个变化来源的内容。这一讲,我们来看下一个设计原则:开放封闭原则。
|
||||
|
||||
作为一名程序员,来了一个需求就要改一次代码,这种方式我们已经见怪不怪了,甚至已经变成了一种下意识的反应。修改也很容易,只要我们按照之前的惯例如法炮制就好了。
|
||||
|
||||
这是一种不费脑子的做法,却伴随着长期的伤害。每人每次都只改了一点点,但是,经过长期积累,再来一个新的需求,改动量就要很大了。而在这个过程中,每个人都很无辜,因为每个人都只是遵照惯例在修改。但结果是,所有人都受到了伤害,代码越来越难以维护。
|
||||
|
||||
既然“修改”会带来这么多问题,那我们可以不修改吗?开放封闭原则就提供了这样的一个新方向。
|
||||
|
||||
## 不修改代码
|
||||
|
||||
开放封闭原则是这样表述的:
|
||||
|
||||
>
|
||||
软件实体(类、模块、函数)应该对扩展开放,对修改封闭。
|
||||
|
||||
|
||||
这个说法是Bertrand Meyer在其著作《面向对象软件构造》(Object-Oriented Software Construction)中提出来的,它给软件设计提出了一个极高的要求:不修改代码。
|
||||
|
||||
或许你想问,不修改代码,那我怎么实现新的需求呢?答案就是**靠扩展**。用更通俗的话来解释,就是新需求应该用新代码实现。
|
||||
|
||||
开放封闭原则向我们描述的是一个结果,就是我们可以不修改代码而仅凭扩展就完成新功能。但是,这个结果的前提是要在软件内部留好扩展点,而这正是需要我们去设计的地方。因为**每一个扩展点都是一个需要设计的模型。**
|
||||
|
||||
举个例子,假如我们正在开发一个酒店预订系统,针对不同的用户,我们需要计算出不同的房价。比如,普通用户是全价,金卡是8折,银卡是9折,代码写出来可能是这样的:
|
||||
|
||||
```
|
||||
class HotelService {
|
||||
public double getRoomPrice(final User user, final Room room) {
|
||||
double price = room.getPrice();
|
||||
if (user.getLevel() == Level.GOLD) {
|
||||
return price * 0.8;
|
||||
}
|
||||
|
||||
if (user.getLevel() == Level.SILVER) {
|
||||
return price * 0.9;
|
||||
}
|
||||
|
||||
return price;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这时,新的需求来了,要增加白金卡会员,给出75折的优惠,如法炮制的写法应该是这样的:
|
||||
|
||||
```
|
||||
class HotelService {
|
||||
public double getRoomPrice(final User user, final Room room) {
|
||||
double price = room.getPrice();
|
||||
if (user.getLevel() == UserLevel.GOLD) {
|
||||
return price * 0.8;
|
||||
}
|
||||
|
||||
if (user.getLevel() == UserLevel.SILVER) {
|
||||
return price * 0.9;
|
||||
}
|
||||
|
||||
if (user.getLevel() == UserLevel.PLATINUM) {
|
||||
return price * 0.75;
|
||||
}
|
||||
|
||||
return price;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
显然,这种做法就是修改代码的做法,每增加一个新的类型就要修改一次代码。但是,一个有各种级别用户的酒店系统肯定不只是房价有区别,提供的服务也可能有区别。可想而知,每增加一个用户级别,我们要改的代码就漫山遍野。
|
||||
|
||||
那应该怎么办呢?我们应该考虑如何把它设计成一个可以扩展的模型。在这个例子里面,既然每次要增加的是用户级别,而且各种服务的差异都体现在用户级别上,我们就需要一个用户级别的模型。在前面的代码里,用户级别只是一个简单的枚举,我们可以给它丰富一下:
|
||||
|
||||
```
|
||||
interface UserLevel {
|
||||
double getRoomPrice(Room room);
|
||||
}
|
||||
|
||||
class GoldUserLevel implements UserLevel {
|
||||
public double getRoomPrice(final Room room) {
|
||||
return room.getPrice() * 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
class SilverUserLevel implements UserLevel {
|
||||
public double getRoomPrice(final Room room) {
|
||||
return room.getPrice() * 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们原来的代码就可以变成这样:
|
||||
|
||||
```
|
||||
class HotelService {
|
||||
public double getRoomPrice(final User user, final Room room) {
|
||||
return user.getRoomPrice(room);
|
||||
}
|
||||
}
|
||||
|
||||
class User {
|
||||
private UserLevel level;
|
||||
...
|
||||
|
||||
public double getRoomPrice(final Room room) {
|
||||
return level.getRoomPrice(room);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样一来,再增加白金用户,我们只要写一个新的类就好了:
|
||||
|
||||
```
|
||||
class PlatinumUserLevel implements UserLevel {
|
||||
public double getRoomPrice(final Room room) {
|
||||
return room.getPrice() * 0.75;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
之所以我们可以这么做,是因为我们在代码里留好了扩展点:UserLevel。在这里,我们把原来的只支持枚举值的UserLevel升级成了一个有行为的UserLevel。
|
||||
|
||||
经过这番改造,HotelService的getRoomPrice这个方法就稳定了下来,我们就不需要根据用户级别不断地调整这个方法了。至此,我们就拥有了一个稳定的构造块,可以在后期的工作中把它当做一个稳定的模块来使用。
|
||||
|
||||
当然,在这个例子里,这个方法是比较简单的。而在实际的项目中,业务方法都会比较复杂。
|
||||
|
||||
## 构建扩展点
|
||||
|
||||
好,现在我们已经对开放封闭原则有了一个基本的认识。其实,我们都知道修改是不好的,道理我们都懂,就是在**代码层面**,有人就糊涂了。我做个类比你就知道了,比如说,如果我问你,你正在开发的系统有问题吗?相信大部人的答案都是有。
|
||||
|
||||
那我又问你,那你会经常性主动调整它吗?大部人都不会。为什么呢?因为它在线上运行得好好的,万一我调整它,调整坏了怎么办。是啊!你看,道理就是这么个道理,放在系统层面人人都懂,而在代码层面,却总是习惯性被忽视。
|
||||
|
||||
所以,我们写软件就应该提供一个又一个稳定的小模块,然后,将它们组合起来。一个经常变动的模块必然是不稳定的,用它去构造更大的模块,就是将隐患深埋其中。
|
||||
|
||||
你可能会说,嗯,我懂了,可我还是做不好啊!为什么我们懂了道理后,依旧过不好“这一关”呢?因为**阻碍程序员们构造出稳定模块的障碍,其实是构建模型的能力**。你可以回顾一下前面那段代码,看看让这段代码产生变化的UserLevel是如何升级成一个有行为的UserLevel的。
|
||||
|
||||
在讲封装的时候,我说过,封装的要点是行为,数据只是实现细节,而很多人习惯性的写法是面向数据的,这也是导致很多人在设计上缺乏扩展性思考的一个重要原因。
|
||||
|
||||
**构建模型的难点,首先在于分离关注点,这个我们之前说过很多次了,不再赘述,其次在于找到共性**。
|
||||
|
||||
在多态那一讲,我们说过,要构建起抽象就要找到事物的共同点,有了这个理解,我们看前面的例子应该还算容易理解。而在一个业务处理的过程中,发现共性这件事对很多人来说就已经开始有难度了。
|
||||
|
||||
我们再来看个例子,下面是一个常见的报表服务,首先我们取出当天的订单,然后生成订单的统计报表,还要把统计结果发送给相关的人等:
|
||||
|
||||
```
|
||||
class ReportService {
|
||||
public void process() {
|
||||
// 获取当天的订单
|
||||
List<Order> orders = fetchDailyOrders();
|
||||
// 生成统计信息
|
||||
OrderStatistics statistics = generateOrderStatistics(orders);
|
||||
// 生成统计报表
|
||||
generateStatisticsReport(statistics);
|
||||
// 发送统计邮件
|
||||
sendStatisticsByMail(statistics);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
很多人在日常工作中写出的代码都是与此类似的,但这个流程肯定是比较僵化的。出现一个新需求就需要调整这段代码。我们这就有一个新需求,把统计信息发给另外一个内部系统,这个内部系统可以把统计信息展示出来,供外部合作伙伴查阅。该怎么做呢?
|
||||
|
||||
我们先分析一下,发送给另一个系统的内容是统计信息,在原有的代码里,前面两步分别是获取源数据和生成统计信息,后面两步分别是,生成报表和将统计信息通过邮件发送出去。
|
||||
|
||||
也就是说,后两步和即将添加的步骤有一个共同点,都使用了统计信息,这样我们就找到了它们的共性,所以,我们就可以用一个共同的模型去涵盖它们,比如,OrderStatisticsConsumer:
|
||||
|
||||
```
|
||||
interface OrderStatisticsConsumer {
|
||||
void consume(OrderStatistics statistics);
|
||||
}
|
||||
|
||||
class StatisticsReporter implements OrderStatisticsConsumer {
|
||||
public void consume(OrderStatistics statistics) {
|
||||
generateStatisticsReport(statistics);
|
||||
}
|
||||
}
|
||||
|
||||
class StatisticsByMailer implements OrderStatisticsConsumer {
|
||||
public void consume(OrderStatistics statistics) {
|
||||
sendStatisticsByMail(statistics);
|
||||
}
|
||||
}
|
||||
|
||||
class ReportService {
|
||||
private List<OrderStatisticsConsumer> consumers;
|
||||
|
||||
void process() {
|
||||
// 获取当天的订单
|
||||
List<Order> orders = fetchDailyOrders();
|
||||
// 生成统计信息
|
||||
OrderStatistics statistics = generateOrderStatistics(orders);
|
||||
|
||||
for (OrderStatisticsConsumer consumer: consumers) {
|
||||
consumer.consume(statistics);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如此一来,我们的新需求也只要添加一个新的类就可以实现了:
|
||||
|
||||
```
|
||||
class StatisticsSender implements OrderStatisticsConsumer {
|
||||
public void consume(final OrderStatistics statistics) {
|
||||
sendStatisticsToOtherSystem(statistics);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你能看出来,在这个例子里,我们第一步做的事情还是分解,就是把一个一个的步骤分开,然后找出步骤之间相似的地方,由此构建出一个新的模型。
|
||||
|
||||
真实项目里的代码可能比这个代码要复杂,但其实,并不一定是业务逻辑复杂,而是代码本身写得复杂了。所以,我们要先根据上一讲的单一职责原则,将不同需求来源引起的变动拆分到不同的方法里,形成一个又一个的小单元,再来做我们这里的分析。
|
||||
|
||||
通过这个例子你也可以看出,在真实的项目中,想要达到开放封闭原则的要求并不是一蹴而就的。这里我们只是因为有了需求的变动,才提取出一个OrderStatisticsConsumer。
|
||||
|
||||
未来可能还会有其他的变动,比如,生成报表的逻辑。到那时,也许我们还会提取出一个新的OrderStatisticsGenerator的接口。但总的来说,我们每做一次这种模型构建,最核心的类就会朝着稳定的方向迈进一步。
|
||||
|
||||
所以,好的设计都会提供足够的扩展点给新功能去扩展。在《Unix 编程艺术》一书中,Unix编程就提倡“提供机制,而不是策略”,这就是开放封闭原则的一种体现。
|
||||
|
||||
同样的,我们知道很多系统是有插件机制的,比如,很多人使用的VIM和Emacs,离我们比较近的还有Eclipse和Visual Studio Code,它们都体现着开放封闭原则。去了解它们的接口,我们就可以看到这个软件给我们提供的各种能力,这也是一种很好的学习方式。
|
||||
|
||||
开放封闭原则还可以帮助我们改进自己的系统,我们可以通过查看自己的源码控制系统,找出那些最经常变动的文件,它们通常都是没有满足开放封闭原则的,而这可以成为我们改进系统的起点。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们讲了开放封闭原则,软件实体应该对扩展开放,对修改封闭。简单地说,就是不要修改代码,新的功能要用新的代码实现。
|
||||
|
||||
其实,道理大家都懂,但对很多人来说,做到是有难度的,尤其是在代码里留下扩展点,往往是需要有一定设计能力的。而构建模型的难点,首先就在于分离关注点,其次是找到共性。今天我们也讲了在一个真实项目中,怎样逐步地去构建扩展点,让系统稳定下来。
|
||||
|
||||
很多优秀的软件在设计上都给我们提供了足够的扩展能力,向这些软件的接口学习,我们可以学到更多的东西。
|
||||
|
||||
如果说单一职责原则主要看的还是封装,开放封闭原则就必须有多态参与其中了。显然,要想提供扩展点,就需要面向接口编程。但是,是不是有了接口,就是好的设计了呢?下一讲,我们来看设计一个接口还需要满足什么样的原则。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**设计扩展点,迈向开放封闭原则**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/61/4a/611dd0fbcf3c87a3de84b457ac6bf44a.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你找一个提供了扩展点的开源项目,分析一下它是如何设计这个扩展点的。欢迎在留言区写下你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
202
极客时间专栏/软件设计之美/设计一个软件—设计原则与模式/22 | Liskov替换原则:用了继承,子类就设计对了吗?.md
Normal file
202
极客时间专栏/软件设计之美/设计一个软件—设计原则与模式/22 | Liskov替换原则:用了继承,子类就设计对了吗?.md
Normal file
@@ -0,0 +1,202 @@
|
||||
<audio id="audio" title="22 | Liskov替换原则:用了继承,子类就设计对了吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e1/90/e19c2204e22a7e4edd89ba804yy64490.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
上一讲,我们讲了开放封闭原则,想要让系统符合开放封闭原则,最重要的就是我们要构建起相应的扩展模型,所以,我们要面向接口编程。
|
||||
|
||||
而大部分的面向接口编程要依赖于继承实现,虽然我们在前面的课程中说过,继承的重要性不如封装和多态,但在大部分面向对象程序设计语言中,继承却是构建一个对象体系的重要组成部分。
|
||||
|
||||
理论上,在定义了接口之后,我们就可以把继承这个接口的类完美地嵌入到我们设计好的体系之中。然而,用了继承,子类就一定设计对了吗?事情可能并没有这么简单。
|
||||
|
||||
新的类虽然在语法上声明了一个接口,形成了一个继承关系,但我们要想让这个子类真正地扮演起这个接口的角色,还需要有一个好的继承指导原则。
|
||||
|
||||
所以,这一讲,我们就来看看可以把继承体系设计好的设计原则:Liskov替换法则。
|
||||
|
||||
## Liskov替换原则
|
||||
|
||||
2008年,图灵奖授予Barbara Liskov,表彰她在程序设计语言和系统设计方法方面的卓越工作。她在设计领域影响最深远的就是以她名字命名的Liskov替换原则(Liskov substitution principle,简称LSP)。
|
||||
|
||||
1988 年,Barbara Liskov在描述如何定义子类型时写下这样一段话:
|
||||
|
||||
>
|
||||
这里需要如下替换性质:若每个类型S的对象o1,都存在一个类型T的对象o2,使得在所有针对T编程的程序P中,用o1替换o2后,程序P行为保持不变,则S是T的子类型。
|
||||
|
||||
|
||||
用通俗的讲法来说,意思就是,子类型(subtype)必须能够替换其父类型(base type)。
|
||||
|
||||
这句话看似简单,但是违反这个原则,后果是很严重的,比如,父类型规定接口不能抛出异常,而子类型抛出了异常,就会导致程序运行的失败。
|
||||
|
||||
虽然很好理解,但你可能会有个疑问,我的子类型不都是继承自父类型,咋就能违反LSP呢?这个LSP是不是有点多此一举呢?
|
||||
|
||||
我们来看个例子,有不少的人经常写出类似下面这样的代码:
|
||||
|
||||
```
|
||||
void handle(final Handler handler) {
|
||||
if (handler instanceof ReportHandler) {
|
||||
// 生成报告
|
||||
((ReportHandler)handler).report();
|
||||
return;
|
||||
}
|
||||
|
||||
if (handler instanceof NotificationHandler) {
|
||||
// 发送通知
|
||||
((NotificationHandler)handler).sendNotification();
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
根据上一讲的内容,这段代码显然是违反了OCP的。另外,在这个例子里面,虽然我们定义了一个父类型Handler,但在这段代码的处理中,是通过运行时类型识别(Run-Time Type Identification,简称 RTTI),也就是这里的instanceof,知道子类型是什么的,然后去做相应的业务处理。
|
||||
|
||||
但是,ReportHandler和NotificationHandler虽然都是Handler的子类,但**它们没有统一的处理接口**,所以,它们之间并不存在一个可以替换的关系,这段代码也是违反LSP的。这里我们就得到了一个经验法则,**如果你发现了任何做运行时类型识别的代码,很有可能已经破坏了LSP**。
|
||||
|
||||
## 基于行为的IS-A
|
||||
|
||||
如果你去阅读关于LSP的资料,很有可能会遇到一个有趣的问题,也就是长方形正方形问题。在我们对于几何通常的理解中,正方形是一种特殊的长方形。所以,我们可能会写出这样的代码:
|
||||
|
||||
```
|
||||
class Rectangle {
|
||||
private int height;
|
||||
private int width;
|
||||
|
||||
// 设置长度
|
||||
public void setHeight(int height) {
|
||||
this.height = height;
|
||||
}
|
||||
|
||||
// 设置宽度
|
||||
public void setWidth(int width) {
|
||||
this.width = width;
|
||||
}
|
||||
|
||||
//
|
||||
public void area() {
|
||||
return this.height * this.width;
|
||||
}
|
||||
}
|
||||
|
||||
class Square extends Rectangle {
|
||||
// 设置边长
|
||||
public void setSide(int side) {
|
||||
this.setHeight(side);
|
||||
this.setWidth(side);
|
||||
t
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setHeight(int height) {
|
||||
this.setSide(height);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setWidth(int width) {
|
||||
this.setSide(width);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码看上去一切都很好,然而,它却是有问题的,因为它在下面这个测试里会失败:
|
||||
|
||||
```
|
||||
Rectangle rect = new Square();
|
||||
rect.setHeight(4); // 设置长度
|
||||
rect.setWidth(5); // 设置宽度
|
||||
assertThat(rect.area(), is(20)); // 对结果进行断言
|
||||
|
||||
```
|
||||
|
||||
如果想保证断言(assert)的正确性,Rectangle和Square二者在这里是不能互相替换的。使用Rectangle的代码必须知道自己使用的到底是Rectangle还是Square。
|
||||
|
||||
出现这个问题的原因就在于,我们构建模型时,会理所当然地把我们直觉中的模型直接映射到代码模型上。在我们直觉中,正方形确实是一种长方形。
|
||||
|
||||
在我们设计的这个对象体系中,边长是可以调整的。然而,在几何的体系里面,长方形的边长是不能随意改变的,设置好了就是设置好了。换句话说,两个体系内,“长方形”的行为是不一致的。所以,在这个对象体系中,正方形边长即使可以调整,但正方形也并不是一个长方形,也就是说,它们之间不满足IS-A关系。
|
||||
|
||||
你可能听说过继承要符合IS-A的关系,也就是说,**如果A是B的子类,就需要满足A是一个B(A is a B)**。但你有没有想过,凭什么A是一个B呢?判断依据从何而来呢?
|
||||
|
||||
你应该知道,这种判定显然不能依靠直觉。其实,从前面的分析中,你也能看出一些端倪来,**IS-A的判定是基于行为的**,只有行为相同,才能说是满足IS-A的关系。
|
||||
|
||||
这个道理说起来很简单,但在实际的工作中,我们时常就会走上歧途。我给你举个例子,我要做一个图片制作的网站,创作者可以在上面创作自己的内容,还可以发布自己创作的一些素材在网站上销售。显然,这个网站要提供一个销售的能力,那这个可以销售的素材算不算商品呢?
|
||||
|
||||
如果站在销售的角度看,它确实是一个商品,我们需要给它定价,需要让它支持后续的购买行为等等。从行为上看,素材也确实是商品,但它又与创作相关,我们需要知道它的作者是谁,需要知道它所应用的不同创作阶段等等,这些行为又与商品完全无关。
|
||||
|
||||
其实,在我们分析问题的时候,答案就已经呼之欲出了。这里的“素材”就不是一个“素材”,前面讲SRP的时候,我们已经做过类似的分析了,虽然我们在讨论的时候,用的是一个词“素材”,但创作者和销售却是两个不同的领域。
|
||||
|
||||
所以,如果我们把“素材”做一个拆分,这个问题就迎刃而解了。一个是“创作者素材”,一个是“可销售素材”,显然,“可销售素材”是一种商品,而“创作者素材”不是。
|
||||
|
||||
这是一种常见的概念混淆。产品经理在描述一个需求时,可能并不会注意到这是两个不同领域的概念,而程序员如果不好好分析一下,在概念上就会走偏,后续的问题将无穷无尽。
|
||||
|
||||
所以,IS-A这个关系理解起来并不难,但在实际工作中,当它和其他一些问题混在一起的时候,它就不像看起来那么简单了。
|
||||
|
||||
到这里,你应该对LSP原则有了一些理解,**要满足LSP,首先这个对象体系要有一个统一的接口,而不能各行其是,其次,子类要满足IS-A的关系**。
|
||||
|
||||
有了对LSP的理解,你再用它去衡量一些设计,就会发现一些问题。比如,程序员们最常用的数据结构List,很多人都习惯地把它当做接口传来传去。在绝大多数场景下,使用它的目的只是为了传递一些数据,也就是为了从中读取数据,但List接口本身一般都有写的方法。
|
||||
|
||||
所以,尽管你的目的是读,但还是有人不小心写了,就会导致一些奇怪的问题。Google的Guava库提供了一个ImmutableList,在概念上做了改进。但为了配合现有的各种程序,它不得不继承自List接口,实际上,根本的问题并没有得到完全的解决。
|
||||
|
||||
还有一类常见的违反LSP的问题,就是继承数据结构。比如,我要实现包含多个学生的类,结果声明成:
|
||||
|
||||
```
|
||||
class Students extends ArrayList<Student> {
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这是一种非常直觉的设计,只要一继承ArrayList,添加、获取的方法就都有了。但从我们前面讲的内容上来看,这显然是不好的,因为Students不是一个ArrayList,不能满足IS-A关系。这种做法想做的就是实现继承,而我们在前面讲继承的时候,就说过这种做法的问题。
|
||||
|
||||
你会发现,LSP的关注点让人把注意力放到父类上,而一旦子类成了重点,我们必须小心谨慎。在前面讲继承的时候,我们说过,关心子类是一种实现继承的表现,而实现继承是我们要努力摒弃的,接口继承才是我们的努力方向,而做好接口继承,显然会更符合LSP。
|
||||
|
||||
## 更广泛的LSP
|
||||
|
||||
如果理解了LSP,你会发现,它不仅适用于类级别的设计,还适用于更广泛的接口设计。比如,我们在开发中经常会遇到系统集成的问题,有不同的厂商都要通过REST接口把他们的统计信息上报到你的系统中,但是,有一个大厂上报的消息格式没法遵循你定义的格式,因为他的系统改动起来难度比较大。你该怎么办呢?
|
||||
|
||||
也许,专门为大厂设计一个特定接口是最简单的想法,但是,一旦开了这个口子,后面的各种集成接口都要为这个大厂开发一份特殊的,而且,如果未来再有其他大厂也提出要求,你要不要为它们也设计特殊接口呢?事实上,很多项目功能不多,但接口特别多,就是因为在这种决策的时候开了口子。**请记住,公开接口是最宝贵的资源,千万不能随意添加**。
|
||||
|
||||
如果我们用LSP的角度看这个问题,通用接口就是一个父类接口,而不同厂商的内容就相当于一个个子类。让厂商面对特定接口,系统将变得无法维护。后期随着人员变动,接口只会更加膨胀,到最后,没有人说清楚每个接口到底是做什么的。
|
||||
|
||||
好,那我们决定采用统一的接口,可是不同的消息格式该怎么处理呢?首先,我们需要区分出不同的厂商,办法有很多,无论是通过REST的路径,还是HTTP头的方式,我们可以得到一个标识符。然后呢?
|
||||
|
||||
很容易想到的做法就是写出一个if语句来,像下面这样:
|
||||
|
||||
```
|
||||
if (identfier.equals("SUPER_VENDOR")) {
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但是,千万要遏制自己写if的念头,一旦开了这个头,后续的代码也将变得难以维护。我们可以做的是,提供一个解析器的接口,根据标识符找到一个对应的解析器,像下面这样:
|
||||
|
||||
```
|
||||
RequestParser parser = parsers.get(identifier);
|
||||
if (parser != null) {
|
||||
return parser.parse(request);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样一来,即便有其他厂商再因为某些奇怪的原因要求有特定的格式,我们要做的只是提供一个新的接口实现。这样一来,所有代码的行为就保持了一致性,核心的代码结构也保持了稳定。
|
||||
|
||||
**总结时刻**
|
||||
|
||||
今天,我们讲了Liskov替换原则,其主要意思是说子类型必须能够替换其父类型。
|
||||
|
||||
理解LSP,我们需要站在父类的角度去看,而站在子类的角度,常常是破坏LSP的做法,一个值得警惕的现象是,代码中出现RTTI相关的代码。
|
||||
|
||||
继承需要满足IS-A的关系,但IS-A的关键在于行为上的一致性,而不能单纯凭日常的概念或直觉去理解。
|
||||
|
||||
LSP不仅仅可以用在类关系的设计上,我们还可以把它用在更广泛的接口设计中。任何接口都是宝贵的,在设计时,都要精心考量。
|
||||
|
||||
这一讲,你可以看到LSP的根基在于继承,但显然接口继承才是重点。那我们该如何设计接口呢?我们下一讲来讨论。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**用父类的角度去思考,设计行为一致的子类**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bc/00/bcfbb2358f37ae40383fe89bccedb400.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
在今天的内容中,我们提到了长方形正方形问题,我只分析了这个做法有问题的地方,现在我把解决这个问题的机会留给你,请你来动动脑,欢迎在留言区写下你的解决方案。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
278
极客时间专栏/软件设计之美/设计一个软件—设计原则与模式/23 | 接口隔离原则:接口里的方法,你都用得到吗?.md
Normal file
278
极客时间专栏/软件设计之美/设计一个软件—设计原则与模式/23 | 接口隔离原则:接口里的方法,你都用得到吗?.md
Normal file
@@ -0,0 +1,278 @@
|
||||
<audio id="audio" title="23 | 接口隔离原则:接口里的方法,你都用得到吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0c/ea/0c5623a68d83d5cced024f013f7a42ea.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
在前面几讲中,我们讲的设计原则基本上都是关于如何设计一个类。SRP告诉我们,一个类的变化来源应该是单一的;OCP说,不要随意修改一个类;LSP则教导我们应该设计好类的继承关系。
|
||||
|
||||
而在面向对象的设计中,接口设计也是一个非常重要的组成部分。我们一直都在强调面向接口编程,想实现OCP也好,或者是下一讲要讲的DIP也罢,都是要依赖于接口实现的。
|
||||
|
||||
也许你会说,接口不就是一个语法吗?把需要的方法都放到接口里面,接口不就出来了吗?顶多是Java用interface,C++都声明成纯虚函数。这种对于接口的理解,显然还停留在语法的层面上。这样设计出来的只能算作是有了一个接口,但想要设计出好的接口,还要有在设计维度上的思考。
|
||||
|
||||
那什么样的接口算是一个好接口呢?这就需要我们了解接口隔离原则。
|
||||
|
||||
## 接口隔离原则
|
||||
|
||||
接口隔离原则(Interface segregation principle,简称ISP)是这样表述的:
|
||||
|
||||
>
|
||||
<p>不应强迫使用者依赖于它们不用的方法。<br>
|
||||
No client should be forced to depend on methods it does not use.</p>
|
||||
|
||||
|
||||
这个表述看上去很容易理解,就是指在接口中,不要放置使用者用不到的方法。站在使用者的角度,这简直再合理不过了。每个人都会觉得,我怎么会依赖于我不用的方法呢?相信作为设计者,你也会同意这种观点。然而,真正在设计的时候,却不是人人都能记住这一点的。
|
||||
|
||||
首先,很多程序员分不清使用者和设计者两个是不同的角色。因为在很多人看来,接口的设计和使用常常是由同一个人完成。这就是角色区分意识的缺失,这种缺失导致我们不能把两种不同的角色区分开来,本质上来说,这也是分离关注点没有做好的一种体现。
|
||||
|
||||
实际上,很多程序员在开发过程中,其实是两种角色都没有的,他们根本没有思考过接口的问题,因为他们更关心的是一个个的具体类。只有到了必须的时候,接口才作为语法选项使用一次,这种做法干脆就是没在设计上进行思考。
|
||||
|
||||
然而,你不设计接口,并不代表没有接口。
|
||||
|
||||
在做软件设计的时候,我们经常考虑的是模型之间如何交互,接口只是一个方便描述的词汇,为了让我们把注意力从具体的实现细节中抽离出来。但是,**如果没有设计特定的接口,你的一个个具体类就变成它的接口**。同设计不好的接口一样,这样的“接口”往往也是存在问题的。
|
||||
|
||||
那接口设计不好会有什么问题呢?典型的问题就是接口过“胖”,什么叫接口过“胖”呢?我给你举个例子。
|
||||
|
||||
## 胖接口减肥
|
||||
|
||||
假设有一个银行的系统,对外提供存款、取款和转账的能力。它通过一个接口向外部系统暴露了它的这些能力,而不同能力的差异要通过请求的内容来区分。所以,我们在这里设计了一个表示业务请求的对象,像下面这样:
|
||||
|
||||
```
|
||||
class TransactionRequest {
|
||||
// 获取操作类型
|
||||
TransactionType getType() {
|
||||
...
|
||||
}
|
||||
|
||||
// 获取存款金额
|
||||
double getDepositAmount() {
|
||||
...
|
||||
}
|
||||
|
||||
// 获取取款金额
|
||||
double getWithdrawAmount() {
|
||||
...
|
||||
}
|
||||
|
||||
// 获取转账金额
|
||||
double getTransferAmount() {
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
每种操作类型都对应着一个业务处理的模块,它们会根据自己的需要,去获取所需的信息,像下面这样:
|
||||
|
||||
```
|
||||
interface TransactionHandler {
|
||||
void handle(TransactionRequest request);
|
||||
}
|
||||
|
||||
class DepositHandler implements TransactionHandler {
|
||||
void handle(final TransactionRequest request) {
|
||||
double amount = request.getDepositAmount();
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
class WithdrawHandler implements TransactionHandler {
|
||||
void handle(final TransactionRequest request) {
|
||||
double amount = request.getWithdrawAmount();
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
class TransferHandler implements TransactionHandler {
|
||||
void handle(final TransactionRequest request) {
|
||||
double amount = request.getTransferAmount();
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样一来,我们只要在收到请求之后,做一个业务分发就好了:
|
||||
|
||||
```
|
||||
TransactionHandler handler = handlers.get(request.getType());
|
||||
if (handler != null) {
|
||||
handler.handle(request);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
一切看上去都很好,不少人在实际工作中也会写出类似的代码。然而,在这个实现里,有一个接口就太“胖”了,它就是TransactionRequest。
|
||||
|
||||
TransactionRequest这个类包含了相关的请求内容,虽然这是无可厚非的。但是在这里,我们容易直觉地把它作为参数传给TransactionHandler。于是,它作为一个请求对象,摇身一变,变成了业务处理接口的一部分。
|
||||
|
||||
正如我在前面所说的,虽然你没有设计特定的接口,但具体类可以变成接口。不过,作为业务处理中的接口,TransactionRequest就显得“胖”了:
|
||||
|
||||
- getDepositAmount方法只在DepositHandler 里使用;
|
||||
- getWithdrawAmount方法只在WithdrawHandler里使用;
|
||||
- getTransferAmount只在TransferHandler使用。
|
||||
|
||||
然而,传给它们的TransactionRequest却包含所有这些方法。
|
||||
|
||||
也许你会想,这有什么问题吗?问题就在于,一个“胖”接口常常是不稳定的。比如说,现在要增加一个生活缴费的功能,TransactionRequest就要增加一个获取生活缴费金额的方法:
|
||||
|
||||
```
|
||||
class TransactionRequest {
|
||||
...
|
||||
|
||||
// 获取生活缴费金额
|
||||
double getLivingPaymentAmount() {
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
相应地,还需要增加业务处理的方法:
|
||||
|
||||
```
|
||||
class LivingPaymentHandler implements TransactionHandler {
|
||||
void handle(final TransactionRequest request) {
|
||||
double amount = request.getLivingPaymentAmount();
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
虽然这种做法看上去还挺符合OCP的,但实际上,由于TransactionRequest的修改,前面几个写好的业务处理类:DepositHandler、WithdrawHandler、TransferHandler都会受到影响。为什么这么说呢?
|
||||
|
||||
如果我们用的是一些现代的程序设计语言,你的感觉可能不明显。假如这段代码是用C/C++这些需要编译链接的语言写成的,TransactionRequest的修改势必会导致其它几个业务处理类重新编译,因为它们都引用了TransactionRequest。
|
||||
|
||||
实际上,**C/C++的程序在编译链接上常常需要花很多时间,除了语言本身的特点之外,因为设计没做好,造成本来不需要重新编译的文件也要重新编译的现象几乎是随处可见的**。
|
||||
|
||||
你可以理解为,如果一个接口修改了,依赖它的所有代码全部会受到影响,而这些代码往往也有依赖于它们实现的代码,这样一来,一个修改的影响就传播出去了。用这种角度去评估,你就会发现,不稳定的“胖”接口影响面是非常之广的,所以,我们说“胖”接口不好。
|
||||
|
||||
怎样修改这段代码呢?既然这个接口是由于“胖”造成的,给它减肥就好了。根据ISP,只给每个使用者提供它们关心的方法。所以,我们可以引入一些“瘦”接口:
|
||||
|
||||
```
|
||||
interface TransactionRequest {
|
||||
}
|
||||
|
||||
interface DepositRequest extends TransactionRequest {
|
||||
double getDepositAmount();
|
||||
}
|
||||
|
||||
interface WithdrawRequest extends TransactionRequest {
|
||||
double getWithdrawAmount();
|
||||
}
|
||||
|
||||
interface TransferRequest extends TransactionRequest {
|
||||
double getTransferAmount();
|
||||
}
|
||||
|
||||
class ActualTransactionRequest implements DepositRequest, WithdrawRequest, TransferRequest {
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里,我们把TransactionRequest变成了一个接口,目的是给后面的业务处理进行统一接口,而ActualTransactionRequest则对应着原来的实现类。我们引入了DepositRequest、WithdrawRequest、TransferRequest等几个“瘦”接口,它们就是分别供不同的业务处理方法使用的接口。
|
||||
|
||||
有了这个基础,我们也可以改造对应的业务处理方法了:
|
||||
|
||||
```
|
||||
interface TransactionHandler<T extends TransactionRequest> {
|
||||
void handle(T request);
|
||||
}
|
||||
|
||||
|
||||
class DepositHandler implements TransactionHandler<DepositRequest> {
|
||||
void handle(final DepositRequest request) {
|
||||
double amount = request.getDepositAmount();
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class WithdrawHandler implements TransactionHandler<WithdrawRequest> {
|
||||
void handle(final WithdrawRequest request) {
|
||||
double amount = request.getWithdrawAmount();
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TransferHandler implements TransactionHandler<TransferRequest> {
|
||||
void handle(final TransferRequest request) {
|
||||
double amount = request.getTransferAmount();
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
经过这个改造,每个业务处理方法就只关心自己相关的业务请求。那么,新增生活缴费该如何处理呢?你可能已经很清楚了,就是再增加一个新的接口:
|
||||
|
||||
```
|
||||
interface LivingPaymentRequest extends TransactionRequest {
|
||||
double getLivingPaymentAmount();
|
||||
}
|
||||
|
||||
class ActualTransactionRequest implements DepositRequest, WithdrawRequest, TransferRequest, LivingPaymentRequest {
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,再增加一个新的业务处理方法:
|
||||
|
||||
```
|
||||
class LivingPaymentHandler implements TransactionHandler<LivingPaymentRequest> {
|
||||
void handle(final LivingPaymentRequest request) {
|
||||
double amount = request.getLivingPaymentAmount();
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以对比一下两个设计,只有ActualTransactionRequest做了修改,而因为这个类表示的是实际的请求对象,在现在的结构之下,它是无论如何都要修改的。而其他的部分因为不存在依赖关系,所以,并不会受到这次需求增加的影响。相对于原来的做法,新设计改动的影响面变得更小了。
|
||||
|
||||
## 你的角色
|
||||
|
||||
我们来回顾一下这个设计的改进过程,其中的重点就在于,原本那个大的TransactionRequest被拆分成了若干个小接口,每个小接口就只为特定的使用者服务。这样做的好处就在于,每个使用者只要关注自己所使用的方法就行,这样的接口才可能是稳定的,“胖”接口不稳定的原因就是,它承担了太多的职责。
|
||||
|
||||
或许你从这个讨论里听出了一点SRP的味道,没错,你甚至可以把ISP理解成接口设计的 SRP。
|
||||
|
||||
这个改进还有一个有趣的地方,ActualTransactionRequest实现了多个接口。在这个设计里面,每个接口代表着与不同使用者交互的角色,Martin Fowler将这种接口称为[角色接口](https://www.martinfowler.com/bliki/RoleInterface.html)(Role Interface)。
|
||||
|
||||
这就像每个人在实际生活中扮演着不同的角色一样。在家里,我们是父母的子女;在公司里,我们是公司的员工;购物时,我们是顾客;出行时,我们是乘客,但所有这些角色最终都是由我们一个人承担的。前面讲做接口设计时,我们虽然是一个个体,但常常要同时扮演设计者和使用者两个不同的角色。而在这段代码里,各种角色则汇聚到了ActualTransactionRequest这个类上。
|
||||
|
||||
在一个设计中,识别出不同的角色是至关重要的。你可能又发现了,我想强调的还是分离关注点。
|
||||
|
||||
我们在讲多态的时候说过,接口是把变和不变隔离开。现在有了对ISP的理解,我们知道了,接口应该是尽可能稳定的。接口的使用者对于接口是一种依赖关系,被依赖的一方越稳定越好,而只有规模越小,才越有可能稳定下来。
|
||||
|
||||
我们还可以从更广泛的角度理解ISP,就是不依赖于任何不需要的东西。我曾经遇到过一个项目,项目里的核心计算中依赖了一个非常小众的数据库,选择它的理由只是它提供了一个特有的功能。
|
||||
|
||||
然而,由于项目组人员变迁,结果是,大家除了知道这个特有的功能,对其他能力知之甚少。这个系统只要运行一段时间,数据库占据的空间就会膨胀到硬盘的极限,而只要重新把数据库中的数据导出导入一次,空间瞬间就小了许多(如果你好奇产生这个现象的原因,其实就是这个数据库鼓励的是不变风格,而核心计算中有大量的修改,产生了大量的修改日志,导出导入之后,日志就减少了)。
|
||||
|
||||
于是,我们只能通过加上硬盘监控,定期去导数据,以维持系统的正常运行。最后,大家忍无可忍,想办法把这个数据库换掉了。
|
||||
|
||||
之所以会依赖于这个数据库,是因为在技术选型时,我们用到了一个特定的框架,而这个框架缺省就依赖于这个数据库。开发人员为了快速实现,就把框架和数据库一起引入到了项目中,引发了后面的这些问题。
|
||||
|
||||
从这个例子中,你可以看出,在高层次上依赖于不需要的东西,这和类依赖于不需要的东西,其实是异曲同工的,由此可见,ISP同样是一个可以广泛使用的设计原则。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们讨论了接口隔离原则,它告诉我们不应强迫使用者依赖于它们不用的方法。之所以要把这个原则列出来,很重要的一个原因就是很多接口设计得太“胖”了,里面包含了太多的内容,所以,一个更好的设计是,把大接口分解成一个一个的小接口。
|
||||
|
||||
这里说的接口不仅仅是一种语法,实际上,每个类都有自己的接口,所有的公开方法都是接口。
|
||||
|
||||
我们在做接口设计时,需要关注不同的使用者。我们可以把ISP理解成接口设计的SRP。每个使用者面对的接口,其实都是一种角色接口。识别出接口不同的角色是至关重要的,这也与分离关注点的能力是相关的。
|
||||
|
||||
ISP还可以从更广泛的角度去理解,也就是说,不要依赖于任何不需要的东西,这个原则可以指导我们在高层次上进行设计。
|
||||
|
||||
在这一讲的案例里,除了接口太“胖”造成的问题,还有一个很重要的问题,它的依赖方向搞反了。我们下一讲就来讨论到底谁该依赖谁的设计原则:依赖倒置原则。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**识别对象的不同角色,设计小接口**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d9/c6/d9f46a4153c3f26ba099ea83039207c6.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
在今天的请求对象例子里面,为了支持生活付费,根据ISP原则,我改动了ActualTransactionRequest,但其实这种做法一定程度上破坏了OCP。你可以想一下,如何改进这个例子,能够让它更好地符合OCP。 欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
197
极客时间专栏/软件设计之美/设计一个软件—设计原则与模式/24 | 依赖倒置原则:高层代码和底层代码,到底谁该依赖谁?.md
Normal file
197
极客时间专栏/软件设计之美/设计一个软件—设计原则与模式/24 | 依赖倒置原则:高层代码和底层代码,到底谁该依赖谁?.md
Normal file
@@ -0,0 +1,197 @@
|
||||
<audio id="audio" title="24 | 依赖倒置原则:高层代码和底层代码,到底谁该依赖谁?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5b/8e/5b0c47df342403fc979771c2b2bf528e.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
上一讲,我们讲了ISP原则,知道了在设计接口的时候,我们应该设计小接口,不应该让使用者依赖于用不到的方法。但在结尾的时候,我留下了一个尾巴,说在那个例子里面还有一个根本性的问题:依赖方向搞反了。
|
||||
|
||||
依赖这个词,程序员们都好理解,意思就是,我这段代码用到了谁,我就依赖了谁。依赖容易有,但能不能把依赖弄对,就需要动点脑子了。如果依赖关系没有处理好,就会导致一个小改动影响一大片,而把依赖方向搞反,就是最典型的错误。
|
||||
|
||||
那什么叫依赖方向搞反呢?这一讲我们就来讨论关于依赖的设计原则:依赖倒置原则。
|
||||
|
||||
## 谁依赖谁
|
||||
|
||||
依赖倒置原则(Dependency inversion principle,简称DIP)是这样表述的:
|
||||
|
||||
>
|
||||
<p>高层模块不应依赖于低层模块,二者应依赖于抽象。<br>
|
||||
High-level modules should not depend on low-level modules. Both should depend on abstractions.</p>
|
||||
|
||||
|
||||
>
|
||||
<p>抽象不应依赖于细节,细节应依赖于抽象。<br>
|
||||
Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.</p>
|
||||
|
||||
|
||||
我们学习这个原则,最重要的是要理解“倒置”,而要理解什么是“倒置”,就要先理解所谓的“正常依赖”是什么样的。
|
||||
|
||||
讲[结构化编程](https://time.geekbang.org/column/article/252589)时,我们曾经说过结构化编程解决问题的思路是自上而下地进行功能分解,这种解决问题的思路很自然地就会延续到很多人的编程习惯中。按照分解的结果,进行组合。所以,我们很自然地就会写出类似下面的这种代码:
|
||||
|
||||
```
|
||||
class CriticalFeature {
|
||||
private Step1 step1;
|
||||
private Step2 step2;
|
||||
...
|
||||
|
||||
void run() {
|
||||
// 执行第一步
|
||||
step1.execute();
|
||||
// 执行第二步
|
||||
step2.execute();
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但是,这种未经审视的结构天然就有一个问题:**高层模块会依赖于低层模块**。在上面这段代码里,CriticalFeature类就是高层类,Step1和Step2就是低层模块,而且Step1和Step2通常都是具体类。虽然这是一种自然而然的写法,但是这种写法确实是有问题的。
|
||||
|
||||
在实际的项目中,代码经常会直接耦合在具体的实现上。比如,我们用Kafka做消息传递,我们就在代码里直接创建了一个KafkaProducer去发送消息。我们就可能会写出这样的代码:
|
||||
|
||||
```
|
||||
class Handler {
|
||||
private KafkaProducer producer;
|
||||
|
||||
void execute() {
|
||||
...
|
||||
Message message = ...;
|
||||
producer.send(new KafkaRecord<>("topic", message);
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
也许你会问,我就是用了Kafka发消息,创建一个KafkaProducer,这有什么问题吗?其实,这个问题我们在课程中已经讲过了,就是说我们需要站在长期的角度去看,什么东西是变的、什么东西是不变的。Kafka虽然很好,但它并不是系统最核心的部分,我们在未来是可能把它换掉的。
|
||||
|
||||
你可能会想,这可是我实现的一个关键组件,我怎么可能会换掉它呢?你还记得吗,软件设计需要关注长期、放眼长期,所有那些不在自己掌控之内的东西,都是有可能被替换的。其实,在我前面讲的很多内容里,你也可以看到,替换一个中间件是经常发生的。所以,依赖于一个可能会变的东西,从设计的角度看,并不是一个好的做法。
|
||||
|
||||
那我们应该怎么做呢?这就轮到倒置登场了。
|
||||
|
||||
**所谓倒置,就是把这种习惯性的做法倒过来,让高层模块不再依赖于低层模块**。那要是这样的话,我们的功能又该如何完成呢?计算机行业中一句名言告诉了我们答案:
|
||||
|
||||
>
|
||||
<p>计算机科学中的所有问题都可以通过引入一个间接层得到解决。<br>
|
||||
All problems in computer science can be solved by another level of indirection<br>
|
||||
—— David Wheeler</p>
|
||||
|
||||
|
||||
是的,引入一个间接层。这个间接层指的就是DIP里所说的抽象。不过,在我们课程里,我一直用的说法是**模型**。也就是说,这段代码里面缺少了一个模型,而这个模型就是这个低层模块在这个过程中所承担的角色。
|
||||
|
||||
既然这个模块扮演的就是消息发送者的角色,那我们就可以引入一个消息发送者(MessageSender)的模型:
|
||||
|
||||
```
|
||||
interface MessageSender {
|
||||
void send(Message message);
|
||||
}
|
||||
|
||||
class Handler {
|
||||
private MessageSender sender;
|
||||
|
||||
void execute() {
|
||||
...
|
||||
Message message = ...;
|
||||
sender.send(message);
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
有了消息发送者这个模型,那我们又该如何把Kafka和这个模型结合起来呢?那就要实现一个Kafka的消息发送者:
|
||||
|
||||
```
|
||||
class KafkaMessageSender implements MessageSender {
|
||||
private KafkaProducer producer;
|
||||
|
||||
public void send(final Message message) {
|
||||
this.producer.send(new KafkaRecord<>("topic", message));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样一来,高层模块就不像原来一样**直接**依赖低层模块,而是将依赖关系“倒置”过来,让低层模块去依赖由高层定义好的接口。这样做的好处就在于,将高层模块与低层实现解耦开来。
|
||||
|
||||
如果未来我们要替换掉Kafka,只要重写一个MessageSender就好了,其他部分并不需要改变。这样一来,我们就可以让高层模块保持相对稳定,不会随着低层代码的改变而改变。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b1/9e/b1063bfe3dc3390d7a13eb6bcee7a89e.jpg" alt="">
|
||||
|
||||
## 依赖于抽象
|
||||
|
||||
理解了DIP的第一部分后,我们已经知道了要建立起模型(抽象)的概念。
|
||||
|
||||
你有没有发现,我们学习的所有原则都是在讲,尽可能把变的部分和不变的部分分开,让不变的部分稳定下来。我们知道,模型是相对稳定的,实现细节则是容易变动的部分。所以,构建出一个稳定的模型层,对任何一个系统而言,都是至关重要的。
|
||||
|
||||
那接下来,我们再来分析DIP的第二个部分:抽象不应依赖于细节,细节应依赖于抽象。
|
||||
|
||||
其实,这个可以更简单地理解为一点:**依赖于抽象**,从这点出发,我们可以推导出一些更具体的指导编码的规则:
|
||||
|
||||
- 任何变量都不应该指向一个具体类;
|
||||
- 任何类都不应继承自具体类;
|
||||
- 任何方法都不应该改写父类中已经实现的方法。
|
||||
|
||||
我们在讲[多态](https://time.geekbang.org/column/article/252612)时,提到过一个List声明的例子,其实背后遵循的就是这里的第一条规则:
|
||||
|
||||
```
|
||||
List<String> list = new ArrayList<>();
|
||||
|
||||
```
|
||||
|
||||
在实际的项目中,这些编码规则有时候也并不是绝对的。如果一个类特别稳定,我们也是可以直接用的,比如字符串类。但是,请注意,这种情况非常少。因为大多数人写的代码稳定度并没有那么高。所以,上面几条编码规则可以成为覆盖大部分情况的规则,出现例外时,我们就需要特别关注一下。
|
||||
|
||||
到这里,你已经理解了在DIP的指导下,具体类还是能少用就少用。但还有一个问题,最终,具体类我们还是要用的,毕竟代码要运行起来不能只依赖于接口。那具体类应该在哪用呢?
|
||||
|
||||
我们讨论的这些设计原则,核心的关注点都是一个个的业务模型。此外,还有一些代码做的工作是负责把这些模型组装起来,这些负责组装的代码就需要用到一个一个的具体类。
|
||||
|
||||
是不是说到这里,感觉话题很熟悉呢?是的,我们在[第五讲](https://time.geekbang.org/column/article/241123)讨论过DI容器的来龙去脉,在Java世界里,做这些组装工作的就是DI容器。
|
||||
|
||||
因为这些组装工作几乎是标准化的,而且非常繁琐。如果你常用的语言中,没有提供DI容器,最好还是把负责组装的代码和业务模型放到不同的代码里。
|
||||
|
||||
DI容器在最初的讨论中有另外一个说法叫IoC容器,这个IoC是Inversion of Control的缩写,你会看到IoC和DIP中的I都是inversion,二者表现的意图实际上是一致的。
|
||||
|
||||
理解了DIP,再来使用DI容器,你会觉得一切顺理成章,因为依赖之所以可以注入,是因为我们的设计遵循了 DIP。而只知道DI容器不了解DIP,时常会出现让你觉得很为难的模型组装,根本的原因就是设计没有做好。
|
||||
|
||||
关于DIP,还有一个形象的说法,称为好莱坞规则:“Don’t call us, we’ll call you”。放在设计里面,这个翻译应该是“别调用我,我会调你的”。显然,这是一个框架才会有的说法,有了一个稳定的抽象,各种具体的实现都应该是由框架去调用。
|
||||
|
||||
是的,如果你想去编写一个框架,理解DIP是非常重要的。毫不夸张地说,不理解DIP的程序员,就只能写功能,不能构建出模型,也就很难再上一个台阶。在前面讨论程序库时,我建议每个程序员都去锻炼编写程序库,这其实就是让你去锻炼构建模型的能力。
|
||||
|
||||
有了对DIP的讨论,我们再回过头看上一讲留下的疑问,为什么说一开始TransactionRequest是把依赖方向搞反了?因为最初的TransactionRequest是一个具体类,而TransactionHandler是业务类。
|
||||
|
||||
我们后来改进的版本里引入一个模型,把TransactionRequest变成了接口,ActualTransactionRequest 实现这个接口,TransactionHandler只依赖于接口,而原来的具体类从这个接口继承而来,相对来说,比原来的版本好一些。
|
||||
|
||||
**对于任何一个项目而言,了解不同模块的依赖关系是一件很重要的事**。你可以去找一些工具去生成项目的依赖关系图,然后,你就可以用DIP作为一个评判标准,去衡量一下你的项目在依赖关系上表现得到底怎么样了。很有可能,你就找到了项目改造的一些着力点。
|
||||
|
||||
理解了 DIP,再来看一些关于依赖的讨论,我们也可以看到不同的角度。比如,循环依赖,有人会说从技术上要如何解决它,但实际上,循环依赖就是设计没有做好的结果,把依赖关系弄错了,才可能会出现循环依赖,先把设计做对,把该有的接口提取出来,依赖就不会循环了。
|
||||
|
||||
至此,SOLID的五个原则,我们已经讲了一遍。有了前面对于分离关注点和面向对象基础知识的铺垫,相信你理解这些原则的难度也会相应的降低了一些。
|
||||
|
||||
你会看到,理解这些原则,关键的第一步还是**分离关注点**,把不同的内容区分开来。然后,用这些原则再把它们组合起来。而当你理解了这些原则,再回头去看,也能加深对面向对象特点的认识,现在你应该更能深刻体会到多态在面向对象世界里发挥的作用了。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天我们讲了依赖倒置原则,它的表述是:
|
||||
|
||||
- 高层模块不应依赖于低层模块,二者应依赖于抽象。
|
||||
- 抽象不应依赖于细节,细节应依赖于抽象。
|
||||
|
||||
理解这个原则的关键在于理解“倒置”,它是相对于传统自上而下的解决问题然后组合的方式而言的。高层模块不依赖于低层模块,可以通过引入一个抽象,或者模型,将二者解耦开来。高层模块依赖于这个模型,而低层模块实现这个模型。
|
||||
|
||||
DIP 还可以简单理解成要依赖于抽象,由此,还可以推导出一些指导编码的规则:
|
||||
|
||||
- 任何变量都不应该指向一个具体类;
|
||||
- 任何类都不应继承自具体类;
|
||||
- 任何方法都不应该改写父类中已经实现的方法。
|
||||
|
||||
如果我们的模型都按照DIP去编写,具体类可以放到模型组装的过程去使用,对于 Java 世界而言,这个工作是由 DI 容器完成的。即便是没有 DI 容器的语言,组装代码与模型代码也应该是分开的。把 DIP 应用于项目,可以先从生成依赖关系图开始,找到可以改进的点。
|
||||
|
||||
学习了设计原则之后,我们已经有了标准去指导我们的设计,有了尺子去衡量我们的设计。接下来,我们要学习比设计原则更具体的内容:设计模式,下一讲,我们来谈谈如何学习设计模式。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**依赖于构建出来的抽象,而不是具体类**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/64/cf/64101aa6088db60f8c9921a4c374b0cf.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后我想请你去了解一下防腐层(Anti-Corruption Layer),结合今天讲的DIP,谈谈它的适用场景,欢迎在留言区写下你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
259
极客时间专栏/软件设计之美/设计一个软件—设计原则与模式/25 | 设计模式:每一种都是一个特定问题的解决方案.md
Normal file
259
极客时间专栏/软件设计之美/设计一个软件—设计原则与模式/25 | 设计模式:每一种都是一个特定问题的解决方案.md
Normal file
@@ -0,0 +1,259 @@
|
||||
<audio id="audio" title="25 | 设计模式:每一种都是一个特定问题的解决方案" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9c/31/9c0df118ae01cc263b0a33b15bf75a31.mp3"></audio>
|
||||
|
||||
你好,我是郑晔!
|
||||
|
||||
今天,我们来聊聊设计模式。作为一个讲软件设计的专栏,不讲设计模式有些说不过去。现在的程序员,基本上在工作了一段时间之后,都会意识到学习设计模式的重要性。
|
||||
|
||||
因为随着工作经验的增多,大家会逐渐认识到,代码写不好会造成各种问题,而设计模式则是所有软件设计的知识中,市面上参考资料最多,最容易学习的知识。
|
||||
|
||||
但是,你也知道,设计模式的内容很多,多到可以单独地作为一本书或一个专栏的内容。如果我们要在这个专栏的篇幅里,细致地学习设计模式的内容就会显得有些局促。
|
||||
|
||||
所以,这一讲,我打算和你谈谈**如何理解和学习设计模式**,帮助你建立起对设计模式的一个整体认知。
|
||||
|
||||
## 设计模式:一种特定的解决方案
|
||||
|
||||
所谓模式,其实就是针对的就是一些普遍存在的问题给出的解决方案。模式这个说法起源于建筑领域,建筑师克里斯托佛·亚历山大曾把建筑中的一些模式汇集成册。结果却是墙里开花墙外香,模式这个说法却在软件行业流行了起来。
|
||||
|
||||
最早是Kent Beck和Ward Cunningham探索将模式这个想法应用于软件开发领域,之后,Erich Gamma把这一思想写入了其博士论文。而真正让建筑上的模式思想成了设计模式,在软件行业得到了广泛地接受,则是在《设计模式》这本书出版之后了。
|
||||
|
||||
这本书扩展了Erich Gamma的论文。四位作者Erich Gamma、Richard Helm、Ralph Johnson和John Vlissides也因此名声大噪,得到了GoF的称呼。我们今天大部分人知道的23种设计模式就是从这本书来的,而困惑也是从这里开始的。
|
||||
|
||||
因为,这23种设计模式只是在这本书里写的,并不是天底下只有23种设计模式。随着人们越发认识到设计模式这件事的重要性,越来越多的模式被发掘了出来,各种模式相关的书先后问世,比如,Martin Fowler 写过[《企业应用架构模式》](http://book.douban.com/subject/4826290/),甚至还有人写了一套 5 卷本的[《面向模式的软件架构》](/https://book.douban.com/series/14666)。
|
||||
|
||||
但是,很多人从开始学习设计模式,就对设计模式的认知产生了偏差,所谓的23个模式其实就是23个例子。
|
||||
|
||||
还记得我们前面几讲学习的设计原则吗?如果用数学来比喻的话,**设计原则就像公理**,它们是我们讨论各种问题的基础,而**设计模式则是定理**,它们是在特定场景下,对于经常发生的问题给出的一个可复用的解决方案。
|
||||
|
||||
所以,你要想把所有已知的模式统统学一遍,即便不是不可能,也是会花费很多时间的,更何况还会有新的模式不断地出现。而且,虽然《设计模式》那本书上提到的大部分设计模式都很流行,但**有一些模式,如果你不是编写特定的代码,你很可能根本就用不上**。
|
||||
|
||||
比如Flyweight模式,如果你的系统中没有那么多小对象,可能就根本用不到它;而 Visitor 模式,在你设计自己系统的时候也很少会用到,因为你自己写的类常常都是可以拿到信息的,犯不上舍近求远。
|
||||
|
||||
所以,**学习设计模式不要贪多求全,那注定会是一件费力不讨好的事**。
|
||||
|
||||
想要有效地学习设计模式,首先我们要知道**每一个模式都是一个特定的解决方案**。关键点在于,我们要知道这个模式在解决什么问题。很多人强行应用设计模式会让代码不必要地复杂起来,原因就在于他在解决的问题,和设计模式本身要解决的问题并不一定匹配。**学习设计模式不仅仅要学习代码怎么写,更重要的是要了解模式的应用场景**。
|
||||
|
||||
## 从原则到模式
|
||||
|
||||
设计模式之所以能成为一个特定的解决方案,很大程度上是因为它是一种好的做法,符合软件设计原则,所以,**设计原则其实是这些模式背后的东西**。
|
||||
|
||||
我们前面花了大量的篇幅在讲各种编程范式、设计原则,因为它们是比设计模式更基础的东西。掌握这些内容,按照它们去写代码,可能你并没有在刻意使用一个设计模式,往往也能写出符合某个设计模式的代码。
|
||||
|
||||
我给你举个例子。比如,在用户注册完成之后,相关信息会发给后台的数据汇总模块,以便后面我们进行相关的数据分析。所以,我们会写出这样的代码:
|
||||
|
||||
```
|
||||
interface UserSender {
|
||||
void send(User user);
|
||||
}
|
||||
|
||||
// 把用户信息发送给后台数据汇总模块
|
||||
class UserCollectorSender implements UserSender {
|
||||
private UserCollectorChannel channel;
|
||||
|
||||
public void send(final User user) {
|
||||
channel.send(user);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
同时,我们还要把用户注册成功的消息通过短信通知给用户,这里会用到第三方的服务,所以,我们这里要有一个APP的key和secret:
|
||||
|
||||
```
|
||||
// 通过短信发消息
|
||||
class UserSMSSender implements UserSender {
|
||||
private String appKey;
|
||||
private String appSecret;
|
||||
private UserSMSChannel channel;
|
||||
|
||||
public void send(final User user) {
|
||||
channel.send(appKey, appSecret, user);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
现在,我们要对用户的一些信息做处理,保证敏感信息不会泄漏,比如,用户密码。同时,我们还希望信息在发送成功之后,有一个统计,以便我们知道发出了多少的信息。
|
||||
|
||||
如果不假思索地加上这段逻辑,那两个类里必然都会有相同的处理,本着单一职责原则,我们把这个处理放到一个父类里面,于是,代码就变成这样:
|
||||
|
||||
```
|
||||
class BaseUserSender implements UserSender {
|
||||
// 敏感信息过滤
|
||||
protected User sanitize(final User user) {
|
||||
...
|
||||
}
|
||||
|
||||
// 收集消息发送信息
|
||||
protected void collectMessageSent(final User user) {
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
class UserCollectorSender extends BaseUserSender {
|
||||
...
|
||||
|
||||
public void send(final User user) {
|
||||
User sanitizedUser = sanitize(user);
|
||||
channel.send(sanitizedUser);
|
||||
collectMessageSent(user);
|
||||
}
|
||||
}
|
||||
|
||||
class UserSMSSender extends BaseUserSender {
|
||||
...
|
||||
|
||||
public void send(final User user) {
|
||||
User sanitizedUser = sanitize(user);
|
||||
channel.send(appKey, appSecret, user);
|
||||
collectMessageSent(user);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然而,这两段发送的代码除了发送的部分不一样,其他部分是完全一样的。所以,我们可以考虑把共性的东西提取出来,而差异的部分让子类各自实现:
|
||||
|
||||
```
|
||||
class BaseUserSender implements UserSender {
|
||||
// 发送用户信息
|
||||
public void send(final User user) {
|
||||
User sanitizedUser = sanitize(user);
|
||||
doSend(user);
|
||||
collectMessageSent(user);
|
||||
}
|
||||
|
||||
// 敏感信息过滤
|
||||
private User sanitize(final User user) {
|
||||
...
|
||||
}
|
||||
|
||||
// 收集消息发送信息
|
||||
private void collectMessageSent(final User user) {
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class UserCollectorSender extends BaseUserSender {
|
||||
...
|
||||
|
||||
public void doSend(final User user) {
|
||||
channel.send(sanitizedUser);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class UserSMSSender extends BaseUserSender {
|
||||
...
|
||||
|
||||
public void doSend(final User user) {
|
||||
channel.send(appKey, appSecret, user);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你是不是觉得这段代码有点眼熟了呢?没错,这就是Template Method的设计模式。我们只是遵循着单一职责原则,把重复的代码一点点地消除,结果,我们就得到了一个设计模式。在真实的项目中,你可能很难一眼就看出当前场景是否适合使用某个模式,更实际的做法就是这样遵循着设计原则一点点去调整代码。
|
||||
|
||||
其实,只要我们遵循着同样的原则,大多数设计模式都是可以这样一点点推演出来的。所以说,**设计模式只是设计原则在特定场景下的应用**。
|
||||
|
||||
## 开眼看模式
|
||||
|
||||
学习设计模式,我们还应该有一个更开阔的视角。首先是要看到**语言的局限**,虽然设计模式本身并不局限于语言,但很多模式之所以出现,就是受到了语言本身的限制。
|
||||
|
||||
比如,Visitor模式主要是因为C++、Java之类的语言只支持单分发,也就是只能根据一个对象来决定调用哪个方法。而对于支持多分发的语言,Visitor模式存在的意义就不大了。
|
||||
|
||||
[Peter Norvig](http://norvig.com/),Google 公司的研究总监,早在 1996 年就曾做过一个分享[《动态语言的设计模式》](http://www.norvig.com/design-patterns/),他在其中也敏锐地指出,设计模式在某种意义上就是为了解决语言自身缺陷的一种权宜之计,其中列举了某些设计模式采用动态语言后的替代方案。
|
||||
|
||||
我们还应该知道,随着时代的发展,有一些设计模式**本身也在经历变化**。比如,Singleton 模式是很多面试官喜爱的一个模式,因为它能考察很多编程的技巧。比如,通过将构造函数私有化,保证不创建出更多的对象、在多线程模式下要进行双重检查锁定(double-check locking)等等。
|
||||
|
||||
然而,我在讲可测试性的时候说过,Singleton并不是一个好的设计模式,它会影响系统的可测试性。从概念上说,系统里只有一个实例和限制系统里只能构建出一个实例,这其实是两件事。
|
||||
|
||||
尤其是在DI容器普遍使用的今天,DI容器缺省情况下生成的对象就是只有一个实例。所以,在大部分情况下,我们完全没有必要使用Singleton模式。当然,如果你的场景非常特殊,那就另当别论了。
|
||||
|
||||
在讲语法和程序库时,我们曾经说过,一些好的做法会逐渐被吸收到程序库,甚至成为语法。设计模式常常就是好做法的来源,所以,一些程序库就把设计模式的工作做了。比如,Observer 模式早在1.0版本的时候就进入到 JDK,被监听的对象要继承自 [Observable](http://docs.oracle.com/javase/8/docs/api/java/util/Observable.html) 类就好,用来监听的对象实现一个 [Observer](http://docs.oracle.com/javase/8/docs/api/java/util/Observer.html) 接口就行。
|
||||
|
||||
当然,我们讲继承时说过,继承不是一个特别好的选择,Observable是一个要去继承的类,所以,它做得也并不好。从Java 9开始,这个实现就过时(deprecated)了,当然官方的理由会更充分一些,你要是有兴趣可以去了解一下。JDK中提供的替代方案是[PropertyChangeSupport](http://docs.oracle.com/javase/8/docs/api/java/beans/PropertyChangeSupport.html),简言之,用组合替代了继承。
|
||||
|
||||
我个人更欣赏的替代方案是Guava的[EventBus](http://github.com/google/guava/wiki/EventBusExplained),你甚至都不用实现一个接口,只要用一个Annotation标记一下就可以监听了。
|
||||
|
||||
Annotation可以说是消灭设计模式的一个利器。我们刚说过,语言本身的局限造成了一些设计模式的出现,这一点在Java上表现得尤其明显。随着Java自身的发展,随着Java世界的发展,有一些设计模式就越来越少的用到了。比如,Builder模式通过Lombok这个库的一个Annotation就可以做到:
|
||||
|
||||
```
|
||||
@Builder
|
||||
class Student {
|
||||
private String name;
|
||||
private int age;
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
而Decorator模式也可以通过Annotation实现,比如,一种使用 Decorator 模式的典型场景,是实现事务,很多Java程序员熟悉的一种做法就是使用Spring的Transactional,就像下面这样:
|
||||
|
||||
```
|
||||
class Handler {
|
||||
@Transactional
|
||||
public void execute() {
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
随着Java 8引入Lambda,Command模式的写法也会得到简化,比如写一个文件操作的宏记录器,之前的版本需要声明很多类,类似下面这种:
|
||||
|
||||
```
|
||||
Macro macro = new Macro();
|
||||
macro.record(new OpenFile(fileReceiver));
|
||||
macro.record(new WriteFile(fileReceiver));
|
||||
macro.record(new CloseFile(fileReceiver));
|
||||
macro.run();
|
||||
|
||||
```
|
||||
|
||||
而有了Lambda,就可以简化一些,不用为每个命令声明一个类:
|
||||
|
||||
```
|
||||
Macro macro = new Macro();
|
||||
macro.record(() -> fileReceiver.openFile());
|
||||
macro.record(() -> fileReceiver.writeFile());
|
||||
macro.record(() -> fileReceiver.closeFile());
|
||||
macro.run();
|
||||
|
||||
```
|
||||
|
||||
甚至还可以用Method Reference再简化:
|
||||
|
||||
```
|
||||
Macro macro = new Macro();
|
||||
macro.record(fileReceiver::openFile);
|
||||
macro.record(fileReceiver::writeFile);
|
||||
macro.record(fileReceiver::closeFile);
|
||||
macro.run();
|
||||
|
||||
```
|
||||
|
||||
所以,我们学习设计模式除了学习标准写法的样子,还要知道,随着语言的不断发展,新的写法变成了什么样子。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们谈到了如何学习设计模式。学习设计模式,很多人的注意力都在模式的代码应该如何编写,却忽略了模式的使用场景。强行应用模式,就会有一种削足适履的感觉。
|
||||
|
||||
设计模式背后其实是各种设计原则,我们在实际的工作中,更应该按照设计原则去写代码,不一定要强求设计模式,而按照设计原则去写代码的结果,往往是变成了某个模式。
|
||||
|
||||
学习设计模式,我们也要抬头看路,比如,很多设计模式的出现是因为程序设计语言自身能力的不足,我们还要知道,随着时代的发展,一些模式已经不再适用了。
|
||||
|
||||
比如 Singleton 模式,还有些模式有了新的写法,比如,Observer、Decorator、Command 等等。我们对于设计模式的理解,也要随着程序设计语言的发展不断更新。
|
||||
|
||||
好,关于设计模式,我们就先谈到这里。下一讲,我会和你讨论一些很多人经常挂在嘴边的编程原则,虽然它们不像设计原则那么成体系,但依然会给你一些启发性的思考。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**学习设计模式,从设计原则开始,不局限于模式。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4f/36/4f01c17b5509c29085b166a7ccec6c36.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你谈谈你是怎么学习设计模式的,你现在对于设计模式的理解又是怎样的。欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
226
极客时间专栏/软件设计之美/设计一个软件—设计原则与模式/26 | 简单设计:难道一开始就要把设计做复杂吗?.md
Normal file
226
极客时间专栏/软件设计之美/设计一个软件—设计原则与模式/26 | 简单设计:难道一开始就要把设计做复杂吗?.md
Normal file
@@ -0,0 +1,226 @@
|
||||
<audio id="audio" title="26 | 简单设计:难道一开始就要把设计做复杂吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/02/52/02f230175af2d5ab78280b350e949052.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
从专栏开始到现在,关于软件设计,我们已经聊了很多。在学习设计原则和模式这个部分时,我们看着每次的代码调整,虽然结果还不错,但不知道你脑子之中有没有闪过这样的疑问:
|
||||
|
||||
如果我的每段代码都这么写,会不会把设计做复杂了呢?
|
||||
|
||||
确实,几乎每个人在初学设计的时候,都会有用力过猛的倾向。如何把握设计的度,是每个做设计的人需要耐心锤炼的。所以,行业里有人总结了一些实践原则,给了我们一些启发性的规则,帮助我们把握设计的度。
|
||||
|
||||
我把这些原则放到这个部分的最后来讲,是因为它们并不是指导你具体如何编码的原则,它们更像是一种思考方法、一种行为准则。
|
||||
|
||||
好,我们就来看看这样的原则有哪些。
|
||||
|
||||
## KISS
|
||||
|
||||
KISS原则,是“Keep it simple, stupid”的缩写,也就是保持简单、愚蠢的意思。它告诫我们,对于大多数系统而言,和变得复杂相比,**保持简单能够让系统运行得更好**。
|
||||
|
||||
很多程序员都知道这条原则,然而,很少人知道这条原则其实是出自美国海军。所以,它的适用范围远比我们以为的程序员社区要广泛得多。无论是制定一个目标,还是设计一个产品,抑或是管理一个公司,我们都可以用KISS作为一个统一的原则指导自己的工作。
|
||||
|
||||
这个原则看起来有点抽象,每个人对它都会有自己理解的角度,所以,每个人都会觉得它很有道理,而且,越是资深的人越会觉得它有道理。因为资深的人通常都是在自己的工作领域中,见识过因为复杂而引发的各种问题。比如说,堆了太多的功能,调整起来很费劲这样的情况。我们在专栏前面讲过的各种问题,很多时候都是由于复杂引起的。
|
||||
|
||||
所以,对资深的人来说,保持简单是一个再好不过的指引了。其实,每个人都可以针对自己的工作场景给出自己的阐释,比如:
|
||||
|
||||
- 如果有现成的程序库,就不要自己写;
|
||||
- 能用文本做协议就别用二进制;
|
||||
- 方法写得越小越好;
|
||||
- 能把一个基本的流程打通,软件就可以发布,无需那么多的功能;
|
||||
- ……
|
||||
|
||||
这种级别的原则听上去很有吸引力,但问题是,你并不能用它指导具体的工作。因为,怎么做叫保持简单,怎么做就叫复杂了呢?这个标准是没办法确定的。所以,有人基于自己的理解给出了一些稍微具体一点的原则。比如,在软件开发领域,你可能听说过的YAGNI和DRY原则。
|
||||
|
||||
## YAGNI
|
||||
|
||||
YAGNI 是“You aren’t gonna need it”的缩写,也就是,你用不着它。这个说法来自于极限编程社区(Extreme Programming,简称 XP),我们可以把它理解成:**如非必要,勿增功能**。
|
||||
|
||||
我们在开篇词里就说过,软件设计对抗的是需求规模。一方面,我们会通过自己的努力,让软件在需求规模膨胀之后,依然能有一个平稳的发展;另一方面,我们还应该努力地控制需求的规模。
|
||||
|
||||
YAGNI就告诫我们,其实很多需求是不需要做的。很多产品经理以为很重要的功能实际上是没什么用的。人们常说二八原则,真正重要的功能大约只占20%,80%的功能可能大多数人都用不到。做了更多的功能,并不会得到更多的回报,但是,做了更多的功能,软件本身却会不断地膨胀,变得越发难以维护。
|
||||
|
||||
所以,在现实世界中,我们经常看到一些功能简单的东西不断涌现,去颠覆更复杂的东西。比如,虽然Word已经很强大了,但对于很多人而言,它还只是一个写字的工具,甚至它的重点排版功能都用得非常少。
|
||||
|
||||
于是,这就给了Markdown一个机会。它可以让我们专注写内容,而且简单的排版标记在日常沟通中也完全够用。至少,我已经不记得自己上一次用Word写东西是什么时候了。
|
||||
|
||||
我在[《10x 程序员工作法》](http://https://time.geekbang.org/column/intro/100022301)里写的大部分内容,实际上就是告诉你,什么样的做法可以规避哪些的不必要功能。通过这里的介绍,我们不难发现,YAGNI是一种上游思维,就是尽可能不去做不该做的事,从源头上堵住。从某种意义上说,它比其他各种设计原则都重要。
|
||||
|
||||
## DRY
|
||||
|
||||
DRY是“Don’t repeat yourself”的缩写,也就是,**不要重复自己**。这个说法源自Andy Hunt和Dave Thomas的《程序员修炼之道》(The Pragmatic Programmer)。这个原则的阐述是这样的:
|
||||
|
||||
>
|
||||
<p>在一个系统中,每一处知识都必须有单一、明确、权威地表述。<br>
|
||||
Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.</p>
|
||||
|
||||
|
||||
每个人对于DRY原则的理解是千差万别的,最浅层的理解就是“不要复制粘贴代码”。不过,两个作者在二十年后的第二版特意强调,这个理解是远远不够的。**DRY针对的是你对知识和意图的复制**。它强调的是,在两个不同地方的两样东西表达的形式是不同的,但其要表达的内容却可能是相同的。
|
||||
|
||||
我从《程序员修炼之道》中借鉴了一个例子,看看我们怎么在实际的工作中运用 DRY 原则。下面是一段打印账户信息的代码,这种写法在实际的工作中也非常常见:
|
||||
|
||||
```
|
||||
public void printBalance(final Account account) {
|
||||
System.out.printf("Debits: %10.2f\n", account.getDebits());
|
||||
System.out.printf("Credits: %10.2f\n", account.getCredits());
|
||||
if (account.getFees() < 0) {
|
||||
System.out.printf("Fees: %10.2f-\n", -account.getFees());
|
||||
} else {
|
||||
System.out.printf("Fees: %10.2f\n", account.getFees());
|
||||
}
|
||||
|
||||
System.out.printf(" ----\n");
|
||||
|
||||
if (account.getBalance() < 0) {
|
||||
System.out.printf("Balance: %10.2f-\n", -account.getBalance());
|
||||
} else {
|
||||
System.out.printf("Balance: %10.2f\n", account.getBalance());
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然而,在这段代码中,隐藏着一些重复。比如,对负数的处理显然是复制的,可以通过增加一个方法消除它:
|
||||
|
||||
```
|
||||
String formatValue(final double value) {
|
||||
String result = String.format("%10.2f", Math.abs(value));
|
||||
if (value < 0) {
|
||||
return result + "-";
|
||||
} else {
|
||||
return result + " ";
|
||||
}
|
||||
}
|
||||
|
||||
void printBalance(final Account account) {
|
||||
System.out.printf("Debits: %10.2f\n", account.getDebits());
|
||||
System.out.printf("Credits: %10.2f\n", account.getCredits());
|
||||
System.out.printf("Fees:%s\n", formatValue(account.getFees()));
|
||||
System.out.printf(" ----\n");
|
||||
System.out.printf("Balance:%s\n", formatValue(account.getBalance()));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
还有,数字字段格式也是反复出现的,不过,格式与我们抽取出来的方法是一致的,所以,可以复用一下:
|
||||
|
||||
```
|
||||
String formatValue(final double value) {
|
||||
String result = String.format("%10.2f", Math.abs(value));
|
||||
if (value < 0) {
|
||||
return result + "-";
|
||||
} else {
|
||||
return result + " ";
|
||||
}
|
||||
}
|
||||
|
||||
void printBalance(final Account account) {
|
||||
System.out.printf("Debits: %s\n", formatValue(account.getDebits()));
|
||||
System.out.printf("Credits: %s\n", formatValue(account.getCredits()));
|
||||
System.out.printf("Fees:%s\n", formatValue(account.getFees()));
|
||||
System.out.printf(" ----\n");
|
||||
System.out.printf("Balance:%s\n", formatValue(account.getBalance()));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
再有,这里面的打印格式其实也是重复的,如果我要在标签和金额之间加一个空格,相关的代码都要改,所以,这也是一个可以消除的重复:
|
||||
|
||||
```
|
||||
String formatValue(final double value) {
|
||||
String result = String.format("%10.2f", Math.abs(value));
|
||||
if (value < 0) {
|
||||
return result + "-";
|
||||
} else {
|
||||
return result + " ";
|
||||
}
|
||||
}
|
||||
|
||||
void printLine(final String label, final String value) {
|
||||
System.out.printf("%-9s%s\n", label, value);
|
||||
}
|
||||
|
||||
void reportLine(final String label, final double value) {
|
||||
printLine(label + ":", formatValue(value));
|
||||
}
|
||||
|
||||
void printBalance(final Account account) {
|
||||
reportLine("Debits", account.getDebits());
|
||||
reportLine("Credits", account.getCredits());
|
||||
reportLine("Fees", account.getFees());
|
||||
System.out.printf(" ----\n");
|
||||
reportLine("Balance", account.getBalance());
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
经过这样的修改,如果我们要改金额打印的格式,就去改formatValue方法;如果我们要改标签的格式,就去改reportLine方法。
|
||||
|
||||
可能对于有的人来说,这种调整的粒度太小了。不过,我想说的是,如果你的感觉是这样的话,证明你看问题的粒度太大了。
|
||||
|
||||
如果仔细品味这个修改,你就能从中感觉到它与我们之前说的分离关注点和单一职责原则有异曲同工的地方,没错,确实是这样的。在讲分离关注点和单一职责原则的时候,我强调的重点也是**粒度要小**。这个例子从某种程度上说,也是为它们增加了注脚。
|
||||
|
||||
虽然我们在这里讲的是代码,但DRY原则并不局限于写代码,比如:
|
||||
|
||||
- 注释和代码之间存在重复,可以尝试把代码写得更清晰;
|
||||
- 内部API在不同的使用者之间存在重复,可以通过中立格式进行API的定义,然后用工具生成文档、模拟 API 等等;
|
||||
- 开发人员之间做的事情存在重复,可以建立沟通机制降低重复;
|
||||
- ……
|
||||
|
||||
所有这些努力都是在试图减少重复,同时也是为了减少后期维护的成本。
|
||||
|
||||
## 简单设计
|
||||
|
||||
上面说的这三个原则都是在偏思维方式的层面,而下面这个原则稍稍往实际的工作中靠了一些,它就是简单设计(Simple Design)原则。
|
||||
|
||||
这个原则来自极限编程社区,它的提出者是Kent Beck(这个名字在我的两个专栏中已经出现了很多次,由此可见,他对现代软件开发的影响很大)。
|
||||
|
||||
简单设计之所以叫简单设计,因为它只包含了4条规则:
|
||||
|
||||
- 通过所有测试;
|
||||
- 消除重复;
|
||||
- 表达出程序员的意图;
|
||||
- 让类和方法的数量最小化。
|
||||
|
||||
这4条规则看起来很简单,但想做到,对于很多人来说,是一个非常大的挑战。Kent Beck是极限编程这种工作方式的创始人,所以,想满足他提出的简单设计原则,最好要做到与之配套的各种实践。
|
||||
|
||||
我们来逐一地看下每条规则。第1条是**保证系统能够按照预期工作**,其实,这一点对于大多数项目而言,已经是很高的要求了。怎么才能知道系统按照预期工作,那就需要有配套的自动化测试。大多数项目并不拥有自己的自动化测试,更何况是在开发阶段使用的单元测试,尤其是还得保证测试覆盖了大多数场景。
|
||||
|
||||
在XP实践中,想要拥有这种测试,最好是能够以测试驱动开发(Test Driven Development,简称 TDD)的方式工作。而你要想做好TDD,最根本的还是要懂设计,否则,你的代码就是不可测的,想给它写测试就是难上加难的事情。
|
||||
|
||||
后面3条规则其实说的是**重构的方向**,而重构也是XP的重要实践。第2条,消除重复,正如前面讲DRY原则所说的,你得能够发现重复,这需要你对分离关注点有着深刻的认识。第3条,表达出程序员的意图,我们需要编写有表达性的代码,这也需要你对“什么是有表达性的代码”有认识。我们在讲DSL曾经说过,代码要说明做什么,而不是怎么做。
|
||||
|
||||
第4条,让类和方法的数量最小化,则告诉我们不要过度设计,除非你已经看到这个地方必须要做一个设计,比如,留下适当的扩展点,否则,就不要做。
|
||||
|
||||
但是,有一点我们需要知道,能做出过度设计的前提,是已经懂得了设计的各种知识,这时才需要用简单设计的标准对自己进行约束。所以,所谓的简单设计,对大多数人而言,并不“简单”。
|
||||
|
||||
我们前面说了,简单设计的理念来自于极限编程社区,这是一个重要的敏捷流派。谈到敏捷,很多人以为做敏捷是不需要设计的,其实这是严重的误解。在敏捷实践的工程派,也就是XP这一派中,如果单看这些实践的步骤,你都会觉得都非常简单,无论是TDD也好,抑或是重构也罢,如果你没有对设计的理解,任何一个实践你都很难做好。
|
||||
|
||||
没有良好的设计,代码就没有可测试的接口,根本没有办法测试,TDD也就无从谈起。不懂设计,重构就只是简单的提取方法,改改名字,对代码的改进也是相当有限的。
|
||||
|
||||
简单设计,是Kent Beck这样的大师级程序员在经历了足够的积累,返璞归真之后提出的设计原则,它确实可以指导我们的日常工作,但前提是,我们需要把基础打牢。片面地追求敏捷实践,而忽视基本功,往往是舍本逐末的做法。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我给你讲了一些启发性的编程原则,这些设计原则更像是一种思考方式,让我们在软件设计上有更高的追求:
|
||||
|
||||
- KISS原则,Keep it simple, stupid,我们要让系统保持简单;
|
||||
- YAGNI原则,You aren’t gonna need it,不要做不该做的需求;
|
||||
- DRY原则,Don’t repeat yourself,不要重复自己,消除各种重复。
|
||||
|
||||
我们还讲了一个可以指导我们实际工作的简单设计原则,它有4条规则:
|
||||
|
||||
- 通过所有测试;
|
||||
- 消除重复;
|
||||
- 表达出程序员的意图;
|
||||
- 让类和方法的数量最小化。
|
||||
|
||||
软件设计相关的基础内容,到这里,我已经全部给你讲了一遍。然而,你可能会有疑问,有了这些东西之后,我该如何用呢?从下一讲开始,我们来聊聊,如果有机会从头开始的话,该如何设计一个软件。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**简单地做设计**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c4/f9/c455311f514e9d66f830597ba7a5c2f9.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你分享一下,你还知道哪些让你受益匪浅的设计原则,欢迎在留言区写下你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
114
极客时间专栏/软件设计之美/设计一个软件—设计方法/27 | 领域驱动设计:如何从零开始设计一个软件?.md
Normal file
114
极客时间专栏/软件设计之美/设计一个软件—设计方法/27 | 领域驱动设计:如何从零开始设计一个软件?.md
Normal file
@@ -0,0 +1,114 @@
|
||||
<audio id="audio" title="27 | 领域驱动设计:如何从零开始设计一个软件?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f1/69/f180f97cce21d54f36yy4bd01f4ce569.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
在前面的内容中,我给你讲了软件设计的各种基本工具。我们学习了程序设计语言,知道怎样把设计更好地落地;学会了各种编程范式,知道了可以用什么样的元素去做设计;我们还学习了设计原则与模式,知道了怎样组合分解出来的各个元素。
|
||||
|
||||
工具都有了,理论也武装上了,那么我们该如何实践呢?或者说,我要去分解组合的东西是从哪而来的呢?这就需要你对**设计方法**有一个基本的认知了,也就是说,我们要理解,在真实世界中,解决具体问题是怎样的一个过程。
|
||||
|
||||
那从这一讲开始,我们就来谈谈设计方法的话题,一起了解一下设计的基本过程。
|
||||
|
||||
首先,你知道哪些设计方法呢?
|
||||
|
||||
我知道的一种做法是,有些人一上来会先设计数据库,因为他们觉得,程序就是数据加函数。数据呢,就要存到数据库里,剩下的就是根据需要对数据库表进行增删改查。但是从我们之前的讲解中,你可以看出,这种思路实际上是一种结构化编程的思路。
|
||||
|
||||
后来有人就用面向对象的思路,先来找实体,也就是对象,当然这些实体也要有一些能力。最终,这些对象还是要写到数据库里,同样也是要提供增删改查的能力。
|
||||
|
||||
你看,这两种做法本质上没什么太大的区别,都是**围绕着数据在做文章**。在业务需求不复杂的年代,围绕数据做文章的做法还能满足开发的要求,但随着软件日益深入到人们日常工作和生活中,软件变得越来越复杂,这种做法就越发显得笨拙了。
|
||||
|
||||
对,软件会越来越复杂的。当软件变得复杂起来,如果我们靠着程序员们本能的做法,就会遇到各种问题,所以,很多人探索了不同的做法。
|
||||
|
||||
在诸多的探索之中,有一种做法逐渐脱颖而出,它成功地解决业务软件开发中遇到的大部分问题,这就是**领域驱动设计**。虽然它不是万能药,但对大部分人面对的场景而言,它都能够有效地应对。
|
||||
|
||||
## 领域驱动设计
|
||||
|
||||
领域驱动设计(Domain Driven Design,简称 DDD),作为一个新的设计方法正式登上历史舞台,是从 Eric Evans 的著作《领域驱动设计》正式出版开始的。
|
||||
|
||||
这种设计方法通过使用通用语言,让业务人员加入到设计过程中,拉近了业务人员与开发人员之间的距离,打破了组织的藩篱。同时,还提供了一套标准的建模方法,帮助团队识别业务模型,避免程序员犯下一些低级错误。
|
||||
|
||||
按理说,这种设计方法这么好,应该很快流行起来才对。然而真实情况是,很多程序员都不知道 DDD,一个重要的原因就是 Eric 的这本书写得实在不怎么样。要想从中读出味道,你得比较懂DDD,但是,大多数人并不懂,这就是矛盾的地方。所以,DDD在很长一段时间都被埋没了。
|
||||
|
||||
不过,后来,随着**微服务**的兴起,人们越发认识到,微服务的难度并不在于将一个系统拆分成若干的服务,而在于如何有效地划分微服务。这个时候,人们发现,DDD才是最恰当的指引。关于微服务和 DDD 之间的关系,我在[《10x 程序员工作法》](https://time.geekbang.org/column/intro/100022301)里已经讲过了,有兴趣的可以去了解一下,这里就不再赘述了。
|
||||
|
||||
现在,你已经知道了DDD的好,准备学习DDD 了。但你只要一打开 DDD 的书,一大堆名词就会扑面而来:限界上下文、聚合根、实体、值对象,等等。我该如何下手学习呢?这是摆在每个DDD学习者面前最严峻的问题。
|
||||
|
||||
**学习 DDD,就要从理解 DDD 的根基入手:通用语言(Ubiquitous Language)和模型驱动的设计(Model-Driven Design)**,而领域驱动设计的过程,就是建立起通用语言和识别模型的过程。
|
||||
|
||||
## 通用语言
|
||||
|
||||
**通用语言,就是在业务人员和开发人员之间建立起的一套共有的语言**。要知道,在从前的设计方法中,业务人员总是把问题扔过墙头,让开发人员去解决。可是,业务人员说的都是业务名词,比如:产品、订单等等,而开发人员嘴里全是技术,比如:线程、存储等等,二者除了最基础的几个概念之外,其他的内容基本是没法沟通的。所以,一道人为鸿沟就在开发人员和业务人员之间形成了。
|
||||
|
||||
我们在[第1讲](https://time.geekbang.org/column/article/240177)说过,**软件设计是要在问题和解决方案架设一座桥梁,好的设计要更接近问题**。开发人员对解决方案一端简直再熟悉不过了,但是对业务一端理解则通常不够充分。而通用语言所做的事情,就是把开发人员的思考起点拉到了业务上,也就是从问题出发,这就在一定程度上填平了那道人为的鸿沟。
|
||||
|
||||
通用语言是什么呢?就是这个业务中有哪些概念以及哪些操作。比如说,我要做一个电商平台,就要有产品、订单的概念。其中,产品就要有上架、下架、修改产品信息等操作,而订单就会有下单、撤单、修改订单等操作。
|
||||
|
||||
在业务人员看来,这里说的都是自己擅长的事情,自己就可以有更多的发言权。在开发人员的视角,概念就是一个一个的类,操作就是一个一个的方法,也很好理解。所以,有一套通用语言,双方皆大欢喜。
|
||||
|
||||
但是,通用语言是从哪来的呢?也就是说,如何设计通用语言呢?最简单的做法就是让业务人员和开发人员一起,找一块白板,把各种概念都写在上面。然后,双方重新进行分类整理。
|
||||
|
||||
这里面的重点是,让业务人员和开发人员在一起。如果只让一方出现,结果又会是原来的样子,因为你没法判断,这里面的语言对方是否听得懂。
|
||||
|
||||
这种做法很简单,但通常都不够系统,会存在各种遗漏。所以,有人探索出一种更正式的实践:**事件风暴**(Event Storming)。
|
||||
|
||||
[事件风暴](https://www.eventstorming.com/)是一个工作坊,基本做法就是找一面很宽的墙,上面铺上大白纸,然后,用便利贴把识别出来的概念贴在上面。当然,前提依然是让业务人员和技术人员都参与其中。
|
||||
|
||||
这个实践之所以叫作事件风暴,因为它的关注点在于**领域事件**。领域事件是用来记录业务过程中发生过的重要事情,比如,作为电商平台的工作人员,你想知道产品是不是已经上架了,这个领域事件就是产品已上架;作为消费者,你会关心我的订单是不是下成功了,这个领域事件就是订单已下。
|
||||
|
||||
人们做了一个动作,都会关心做过这个动作之后的结果,所以,领域事件用的描述方式都是过去式,比如:OrderPlaced。
|
||||
|
||||
事件风暴这个工作坊主要分成三步:
|
||||
|
||||
- **第一步就是把领域事件识别出来**,这个系统有哪些是人们关心的结果。有了领域事件,下面一个问题是,这些事件是如何产生的,它必然会是某个动作的结果。
|
||||
- **第二步就是找出这些动作,也就是引发领域事件的命令**。比如:产品已上架是由产品上架这个动作引发的,而订单已下就是由下单这个命令引发的。
|
||||
- **第三步就是找出与事件和命令相关的实体或聚合**,比如,产品上架就需要有个产品(Product),下单就需要有订单(Order)。
|
||||
|
||||
至此,我们已经把最核心的内容找出来了。通常,在工作坊过程中,为了增强趣味性和清晰性,不同的概念会用不同的颜色的便利贴标识出来,比如,领域事件用橙色、命令用蓝色、实体/聚合用黄色等等。
|
||||
|
||||
其实,用不同的颜色建模,事件风暴并不是独一份。Peter Coad也曾提出过一种[四色建模](https://en.wikipedia.org/wiki/Object_Modeling_in_Color)的方法:
|
||||
|
||||
- 粉色表示时标性对象(moment-interval);
|
||||
- 黄色表示角色(role);
|
||||
- 蓝色表示描述(description);
|
||||
- 绿色表示人、地点、物(party/place/thing)。
|
||||
|
||||
他还写了一本[《彩色UML建模》](https://book.douban.com/subject/3354137/)(Java Modeling in Color with UML)介绍这种方法。我在ThoughtWorks的前同事徐昊按照自己的理解,对这种方法做了一些更新,有兴趣的话,可以去[了解一下](https://www.infoq.cn/article/xh-four-color-modeling)。
|
||||
|
||||
当然,这里的事件风暴,我只是描述了最简单的一个过程。在具体实施的过程中,还会有更多的细节。不过,最重要的还是,让不同角色的参与其中,让知识在所有人的头脑中进行构建,得到一个大家都认同的结果。
|
||||
|
||||
## 模型驱动设计
|
||||
|
||||
有了通用语言,接下来就进入模型设计阶段了。虽然有了通用语言,但是业务人员能够帮到开发人员的还是很少,他们只能告诉开发人员哪些模型是符合业务概念的。
|
||||
|
||||
但这么多的业务模型,该如何组织呢?怎样补全欠缺的模型,使之成为一个可以落地的方案呢?这就是开发人员要想办法解决的事情了。
|
||||
|
||||
也正是因为在通常情况下,业务模型数量众多,所以在 DDD 的过程中,我们将设计分成了两个阶段:**战略设计**(Strategic Design)和**战术设计**(Tactical Design)。
|
||||
|
||||
战略设计是高层设计,是指将系统拆分成不同的领域。而领域驱动设计,核心的概念就是领域,也就是说,它给了我们一个拆分系统的新视角:按业务领域拆分。
|
||||
|
||||
比如,我把一个电商系统拆分成产品域、订单域、支付域、物流域等。拆分成领域之后,我们识别出来的各种业务对象就会归结到各个领域之中。然而,有时候,不同领域的业务对象会进行交互,比如,我要知道自己订单的物流情况。所以,要在不同的领域之间设计一些交互的方式。
|
||||
|
||||
而战术设计是低层设计,也就是如何具体地组织不同的业务模型。在这个层次上,DDD 给我们提供了一些标准的做法供我们参考。比如,哪种模型应该设计成实体,哪些应该设计成值对象。
|
||||
|
||||
我们还要考虑模型之间是什么样的关系,比如,哪些模型要一起使用,可以成为一个聚合。接下来,我们还需要考虑这些模型从哪来、怎样演变,DDD 同样为我们提供了一些标准的设计概念,比如仓库、服务等等。
|
||||
|
||||
通过战略设计和战术设计,我们就可以把发现出来的不同业务概念各归其位了。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们讲了领域驱动设计,这是目前在软件行业内最符合软件发展趋势的一种设计方法,因为它把软件设计的起始点从技术拉到了业务。
|
||||
|
||||
学习领域驱动设计,我们要从通用语言和模型驱动设计入手。通用语言是在业务人员和技术人员之间建立一套共有的语言,开发通用语言的一种实践是事件风暴,这是一种工作坊,通过识别领域事件找到引发事件的命令,找出与事件和命令相关的实体或聚合,帮助团队建立通用语言。
|
||||
|
||||
DDD 的模型设计可以分为战略设计和战术设计。战略设计是高层设计,将系统拆分成领域,战术设计是低层设计,考虑如何组织不同的模型。
|
||||
|
||||
好,我们已经对 DDD 有了一个初步的了解。接下来的两讲,我们就分别来看看,如何进行战略设计和战术设计。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**建立一套业务人员和开发人员共享的通用语言**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/88/03/88d3dd98642fd2d2d619a435d0d85903.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你分享一下,你们在实际工作中是如何与业务人员沟通的?欢迎在留言区写下你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
121
极客时间专栏/软件设计之美/设计一个软件—设计方法/28 | 战略设计:如何划分系统的模块?.md
Normal file
121
极客时间专栏/软件设计之美/设计一个软件—设计方法/28 | 战略设计:如何划分系统的模块?.md
Normal file
@@ -0,0 +1,121 @@
|
||||
<audio id="audio" title="28 | 战略设计:如何划分系统的模块?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/68/bc/68a9b51d6801cdb5yy09db23f56d66bc.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
上一讲,我们已经初步认识了 DDD ,知道了支撑 DDD 最核心的就是通用语言和模型驱动设计的方法。我们在上一讲已经讲了建立通用语言的方法,接下来,就该进行模型的设计了。
|
||||
|
||||
在模型设计中,DDD 又分成了两个阶段,战略设计和战术设计。这一讲,我们先来聊聊战略设计,下一讲再来谈战术设计。
|
||||
|
||||
战略设计,这个名字听上去有点高大上。而且,战略设计包含很多的概念,比如,子域、限界上下文和上下文映射图等等。这让很多人有些望而却步。虽然概念看似很多,但只要有一条主线将它们贯穿起来,这些概念也不难理解。
|
||||
|
||||
我们可以先把这些概念做一个划分,分为**做业务的划分和落地成解决方案**两个部分,也就是说,战略设计中的概念,一部分是为了将不同的业务区分开来,也就是要将识别出来的业务概念做一个划分,另一部分则是将划分出来的业务落实到真实的解决方案中。
|
||||
|
||||
好,我们接下来就先来看看战略设计中的这些概念到底是怎么回事。
|
||||
|
||||
## 业务概念的划分
|
||||
|
||||
我们前面说过,软件开发就是在解决问题,所以,一方面,我们要知道要解决的问题是什么;另一方面,我们要知道怎么去解决问题。
|
||||
|
||||
我们要解决的问题就是领域问题,在 DDD 中,有几个概念是与领域相关的,比如,子域、核心域、支撑域、通用域等。其实,它们说的都是一件事,就是如何先把问题从大面上进行分解。
|
||||
|
||||
领域驱动设计这个名字里面,排在第一位的是**领域(Domain)**,它就对应着要解决的问题。正如我们一直说的,软件开发是解决问题,而解决问题要分而治之。所谓分而治之,就是要把问题分解了,对应到领域驱动设计中,就是要把一个大领域分解成若干的小领域,而这个分解出来的小领域就是**子域(Subdomain)**。
|
||||
|
||||
我们在上一讲中说,领域驱动设计首先要建立起一套通用语言,这样一来,我们就拥有了各种各样的词汇,它们对应着模型。接下来,我们就要给这些词汇做个分类,而分类就是要把它们划分到不同的子域中去。这里面的关键就在于,要找出不同的关注点。没错,还是分离关注点。
|
||||
|
||||
比如,我要做一个项目管理软件,就需要有用户、有项目、有团队,不同的人还要扮演不同的角色。第一步,我们至少可以先把身份管理和项目管理这两件事分开,因为它们的关注点是不同的。身份管理关注的是用户的身份信息,诸如用户名密码之类的,而项目管理关注的重点是项目和团队之类的。所以,我们这里有了两个子域:身份管理和项目管理。
|
||||
|
||||
如果直接给你看结果,你可能会觉得很好理解。但是,划分出不同的子域还是比较容易出问题的,因为有一些概念并不容易区分。比如,用户应该怎么划分呢?放在身份管理是合适的,但项目管理也要用到用户。
|
||||
|
||||
幸好,我们已经学习了单一职责原则,它给了我们一个重要的思考维度,变化从何而来。不同角色的人会关注不同的变化,所以,我们知道虽然我们用的词都是“用户”,但我们想表达的含义却是不同的,我们最好将这些不同的含义分开,也就是将不同的角色分开。
|
||||
|
||||
比如,在身份管理中,它是“用户”,而在项目管理中,它就成了“项目成员”。所以,我们划分子域实际上就是在把不同的概念区分开来,让它们各归其位。
|
||||
|
||||
对于一个真实项目而言,划分出来的子域可能会有很多,但并非每个子域都一样重要。所以,我们还要把划分出来的子域再做一下区分,分成核心域(Core Domain)、支撑域(Supporting Subdomain)和通用域(Generic Subdomain)。
|
||||
|
||||
核心域是整个系统最重要的部分,是整个业务得以成功的关键。关于核心域,Eric Evans 曾提出过几个问题,帮我们识别核心域:
|
||||
|
||||
- 为什么这个系统值得写?
|
||||
- 为什么不直接买一个?
|
||||
- 为什么不外包?
|
||||
|
||||
如果你对这几个问题的回答能够帮你找到这个系统非写不可的理由,那它就是你的核心域。
|
||||
|
||||
什么是支撑域呢?有一些子域不是你的核心竞争力,但却是系统不得不做的东西,市场上也找不到一个现成的方案,这种子域就是支撑域。比如,我们要做一个排行榜功能,可能根据各种信息做排名,这种东西没有人会按照你的需要做出一个,对你来说,又是扩展自己系统的重要一步,它就是一个支撑域。
|
||||
|
||||
还有一种子域叫通用域,就是行业里通常都是这么做,即便不自己做,也并不影响你的业务运行。比如,很多 App 要给用户发通知,这样的功能完全可以买一个服务来做,丝毫不影响你的业务运行。它就是一个通用域。
|
||||
|
||||
我们之所以要区分不同的子域,关键的原因就在于,我们可以决定不同的投资策略。核心域要全力投入,支撑域次之,通用域甚至可以花钱买服务。
|
||||
|
||||
## 业务概念的落地
|
||||
|
||||
通过划分子域,区分核心域、支撑域和通用域,我们把DDD在问题层面的概念已经说清楚了。接下来,就要进入到解决方案层面了。
|
||||
|
||||
我们现在有了切分出来的子域,怎样去落实到代码上呢?首先要解决的就是这些子域如何组织的问题,是写一个程序把所有子域都放在里面呢,还是每个子域做一个独立的应用,抑或是有一些在一起,有一些分开。
|
||||
|
||||
这就引出了领域驱动设计中的一个重要的概念,限界上下文(Bounded Context)。
|
||||
|
||||
**限界上下文,顾名思义,它形成了一个边界,一个限定了通用语言自由使用的边界,一旦出界,含义便无法保证**。比如,同样是说“订单”,如果不加限制,你很难区分它是用在哪种场景之下。而一旦定义了限界上下文,那交易上下文的“订单”和物流上下文的“订单”肯定是不同的。原因就在于,订单这个说法,在不同的边界内,含义是不一样的。
|
||||
|
||||
注意,子域和限界上下文不一定是一一对应的,可能在一个限界上下文中包含了多个子域,也可能在一个子域横跨了多个限界上下文。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/41/ec/4188375e5221108bc6e59ab47d9c30ec.jpg" alt="">
|
||||
|
||||
前面我们说了限界上下文是在解决方案层面的,所以,很自然地,我们就可以把限界上下文看作是一个独立的系统。很多团队做微服务的时候,最纠结的问题就是如何划分服务边界,而限界上下文的出现刚好与微服务的理念契合,每个限界上下文都可以成为一个独立的服务。
|
||||
|
||||
**限界上下文的重点在于,它是完全独立的,不会为了完成一个业务需求要跑到其他服务中去做很多事**,而这恰恰是很多微服务出问题的点,比如,一个业务功能要调用很多其他系统的功能。
|
||||
|
||||
有了对限界上下文的理解,我们就可以把整个业务分解到不同的限界上下文中,但是,尽管我们拆分了系统,它们终究还是一个系统,免不了彼此之间要有交互。
|
||||
|
||||
比如,一个用户下了订单,这是在订单上下文中完成的。那接下来,用户要去支付,这是在支付上下文中完成的。我们肯定要通过某种途径让订单上下文的一些信息发送到支付上下文里的。
|
||||
|
||||
所以,我们就要有一种描述方式,将不同限界上下文之间交互的方式描述出来,这就是上下文映射图(Context Map)。DDD 给我们提供了一些描述这种交互的方式,比如:
|
||||
|
||||
- 合作关系(Partnership);
|
||||
- 共享内核(Shared Kernel);
|
||||
- 客户-供应商(Customer-Supplier);
|
||||
- 跟随者(Conformist);
|
||||
- 防腐层(Anticorruption Layer);
|
||||
- 开放主机服务(Open Host Service);
|
||||
- 发布语言(Published Language);
|
||||
- 各行其道(Separate Ways);
|
||||
- 大泥球(Big Ball of Mud)。
|
||||
|
||||
之所以有这么多不同的交互方式,主要是为了让你在头脑中仔细辨认一下,看看限界上下文之间到底在以怎样的方式进行交互。
|
||||
|
||||
当然这么多交互方式,想一次性记住也是不现实的,有些甚至是你要规避的,比如大泥球。如果说这么多交互方式你必须要记住一个的话,那就是防腐层(Anticorruption Layer)。
|
||||
|
||||
防腐层是最具防御性的一种关系,简言之,就是指我们要在外部模型和内部模型之间建立起一个翻译层,将外部模型转化为内部模型。我在第1讲给你讲了一个因为没有建立防腐造成的问题。所以,但凡有可能,就要建立防腐层,将外部模型完全隔离开。
|
||||
|
||||
当我们知道了不同的限界上下文之间采用哪种交互方式之后,不同的交互方式就可以落地为不同的协议。现在最常用的几种协议有REST API、RPC 或是消息队列,我们可以根据实际情况进行选择。
|
||||
|
||||
在我们定义好不同的限界上下文,将它们之间的交互呈现出来之后,我们就得到了一张上下文映射图。上下文映射图是可以帮助我们理解系统的各个部分之间,是怎样进行交互的,帮我们建立一个全局性的认知,而这往往是很多团队欠缺的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/60/f4/606f7d3f445117475652409a837caff4.jpg" alt="">
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们主要讲了DDD 中的战略设计。战略设计中的概念主要是为了**做业务的划分和落地成解决方案**。
|
||||
|
||||
首先业务的划分,我们要把识别出来的模型做一个分类,把它们放置到不同的子域中。划分子域的出发点就是不同的关注点,也就是不同的变化来源。
|
||||
|
||||
划分出来的子域有着不同的重要程度,我们将它们再分为核心域、支撑域和通用域。做出这种区分,主要是为了针对它们各自的特点,决定不同的投入。
|
||||
|
||||
有了不同的领域划分,我们还要把这些领域映射到解决方案上,这就引出了限界上下文。限界上下文限定了模型的使用边界,它可以成为一个独立的系统。如果对应到微服务中,每一个限界上下文可以对应成一个微服务。
|
||||
|
||||
上下文映射图定义了不同上下文之间的交互方式,如果你只能记住一种交互方式的话,就应该记住防腐层。
|
||||
|
||||
按照我们之前介绍的了解软件设计的思路,建立起通用语言之后,我们就找到了主要的模型,通过战略设计,我们可以把识别出来的模型放到不同的限界上下文中,就相当于把模型做了分组。然后,我们需要定义出一些接口,让不同的模型之间可以交互,我们也就有了一张上下文映射图。
|
||||
|
||||
这样一来,我们就把之前学习的知识和新的知识建立起了连接。
|
||||
|
||||
我们有了模型,有了接口,接下来就该深入到实现中。下一讲,我们就要进一步了解 DDD 的实现:战术设计。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**战略设计,就是将不同的模型进行分组**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2e/51/2e0c3075edbe4f9817751abe041e8d51.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你分享一下,你的项目在模型的分组上哪些地方做得好,哪些地方做得不够好呢?欢迎在留言区写下你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
130
极客时间专栏/软件设计之美/设计一个软件—设计方法/29 | 战术设计:如何像写故事一样找出模型?.md
Normal file
130
极客时间专栏/软件设计之美/设计一个软件—设计方法/29 | 战术设计:如何像写故事一样找出模型?.md
Normal file
@@ -0,0 +1,130 @@
|
||||
<audio id="audio" title="29 | 战术设计:如何像写故事一样找出模型?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/03/fb/03282dc929677028001913a242ebd3fb.mp3"></audio>
|
||||
|
||||
你好,我是郑晔!
|
||||
|
||||
在上一讲中,我们讲了 DDD 中的战略设计,学习如何将识别出来的不同模型放到不同的限界上下文中。那么,接下来,我们就该做更具体的工作了,也就是如何设计模型。在 DDD 中,把具体的模型找出来的做法有一个更响亮的名字:战术设计。
|
||||
|
||||
战术设计同样也包含了很多概念,比如,实体、值对象、聚合、领域服务、应用服务等等。有这么多概念,我们该如何区分和理解他们呢?我们同样需要一根主线。
|
||||
|
||||
其实,我们可以把战术设计理解成写一个故事。你知道怎样去写个故事吗?写故事通常都是有一定套路的。我们要先构建好故事的背景,然后,要设定不同的角色,接下来,创建角色之间的关系,最后,我们要安排人物之间互动起来,形成故事。
|
||||
|
||||
对于战术设计而言,故事的背景就是我们面对的领域问题,剩下的就是我们在这个故事背景下,要找出不同的角色,找出角色之间的关系,让它们互动起来,这样,我们就有了故事,也完成了战术设计。
|
||||
|
||||
接下来,我们就来看看,战术设计这个故事模板,我们应该怎么填?
|
||||
|
||||
## 角色:实体、值对象
|
||||
|
||||
我们的首要任务就是设计角色,在战术设计中,我们的角色就是各种名词。我们在初学面向对象的时候,课本上的内容就告诉我们要识别出一个一个的模型,其实,就是让我们识别名词。
|
||||
|
||||
识别名词也是很多人对于面向对象的直觉反应。有一些设计方法会先建立数据库表,这种做法本质上也是从识别名词入手的。**我们在战术设计中,要识别的名词包括了实体和值对象。**
|
||||
|
||||
什么是实体呢?**实体(Entity)指的是能够通过唯一标识符标识出来的对象。**
|
||||
|
||||
我们都知道,在业务处理中,有一类对象会有一定的生命周期。我以电商平台上的订单为例,它会在一次交易的过程中存在,而在它的生命周期中,它的一些属性可能会有变化,比如说,订单的状态刚开始是下单完成,然后在支付之后,变成了已支付,在发货之后就变成了已发货。
|
||||
|
||||
但是这个订单始终都是这个订单,因为这个订单有唯一的标识符,也就是订单号,订单号作为它的标识符能将它标识出来。你可以通过订单号查询它的状态,可以修改订单的一些信息,比如,配送的地址。像这种通过唯一标识符标识出来的对象,就是实体。
|
||||
|
||||
其实,大多数程序员对于实体并不陌生,因为在各种设计方法中,都有相应的方法识别实体。你甚至可以简单粗暴地将它理解成数据库里存储的对象,虽然这种理解并不完全正确。
|
||||
|
||||
**还有一类对象称为值对象,它就表示一个值。**比如,订单地址,它是由省、市、区和具体住址组成。它同实体的差别在于,它没有标识符。之所以它叫值对象,是因为它表现得像一个值。值对象可能会有很多属性,而要想判断值对象是否相等,我们就要判断这些属性是否相等。对于两个订单地址来说,只有省、市、区和具体住址等多个属性都相同,我们才认为它们是同一个地址。
|
||||
|
||||
实体的属性是可以变的,只要标识符不变,它就还是那个实体。但是,值对象的属性却不能变,一旦变了,它就不再是那个对象,所以,我们会把值对象设置成一个不变的对象。在前面讲函数式编程的不变性时,我给你介绍了不变性的诸多好处,这里也完全适用于值对象。
|
||||
|
||||
那你现在应该懂了,**我们为什么要将对象分为实体和值对象?其实主要是为了分出值对象**,也就是把变的对象和不变的对象区分开。在传统的做法中,找出实体是你一定会做的一件事,而在不同的模型中,区分出值对象是我们通常欠缺的考虑。
|
||||
|
||||
一方面,我们会把一些值对象当作实体,但其实这种对象并不需要一个标识符;另一方面,也是更重要的,就是很多值对象我们并没有识别出来,比如,很多人会用一个字符串表示电话号码,会用一个 double 类型表示价格,而这些东西其实都应该是一个值对象。
|
||||
|
||||
之所以说这里缺少了对象,原因就在于,这里用基本类型是没有行为的。在 DDD 的对象设计中,对象应该是有行为的。比如,价格其实要有精度的限制,计算时要有自己的计算规则。如果不用一个类将它封装起来,这种行为就将散落在代码的各处,变得难以维护。
|
||||
|
||||
其实,我们在讨论面向对象的封装时就已经说过了,只有数据的对象是封装没做好的结果,一个好的封装应该是基于行为的。在 DDD 的相关讨论中,经常有人批评所谓的“贫血模型”,说的其实就是这种没有行为的对象。你可以回头复习一下[第15讲](https://time.geekbang.org/column/article/252605),我就不在这里赘述了。
|
||||
|
||||
## 关系:聚合和聚合根
|
||||
|
||||
选定了角色之后,接下来,我们就该考虑它们的关系了。
|
||||
|
||||
在传统的开发中,我们经常会遇到一个难题。比如,如果我有一个订单,它有自己对应的订单项。问题来了,我取订单的时候,该不该把订单项一起取出来呢?取吧,怕一次取出来东西太多;不取吧?要是我用到了,再去一条一条地取,太浪费时间了。
|
||||
|
||||
这就是典型的一对多问题,只不过,在其他场景中,主角就变成了各种其他的对象。
|
||||
|
||||
不过,这也是一种用技术解决业务问题的典型思路。我们之所以这么纠结,主要就是因为我们考虑问题的出发点是技术,如果我们把考虑问题的出发点放到业务上呢?
|
||||
|
||||
战术设计就给了我们这样一个思考的维度:聚合。**聚合(Aggregate)就是多个实体或值对象的组合,这些对象是什么关系呢?你可以理解为它们要同生共死。**比如,一个订单里有很多个订单项,如果这个订单作废了,这些订单项也就没用了。所以,我们基本上可以把订单和订单项看成一个单元,订单和订单项就是一个聚合。
|
||||
|
||||
学习 DDD 时,有人会告诉你,聚合要保证事务(Transaction)一致性。简言之,就是要更新就一起更新,要删除就一起删除。只要你理解了它们是一个整体,你就不难理解为什么这些对象要一起操作了。
|
||||
|
||||
不过,一个聚合里可以包含很多个对象,每个对象里还可以继续包含其它的对象,就像一棵大树一层层展开。但重点是,这是一棵树,所以,它只能有一个树根,这个根就是聚合根。
|
||||
|
||||
聚合根(Aggregate Root),就是从外部访问这个聚合的起点。我还以上面的订单和订单项为例,在订单和订单项组成的这个聚合里,订单就是聚合根。因为你想访问它们,就要从订单入手,你要通过订单号找到订单,然后,把相关的订单项也一并拿出来。
|
||||
|
||||
其实,我们可以把所有的对象都看成是一种聚合。只不过,有一些聚合根下还有其他的对象,有一些没有而已。这样一来,你就有了一个统一的视角看待所有的对象了。所以,我们也可以用统一的标准要求聚合,比如,聚合不能设计得太大。你有没有发现,这其实就是单一职责原则在聚合上的应用。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7f/f7/7fca1371e0b3443b0bceaa412cd6d2f7.jpg" alt="">
|
||||
|
||||
那如果不同的聚合之间有关系怎么办?比如,我要在订单项里知道到底买了哪个产品,这个时候,我在订单项里保存的不是完整的产品信息,而是产品ID。还记得吗?我们在前面说过,实体是有唯一标识符的。如果需要,我们就可以根据产品 ID 找出产品信息。
|
||||
|
||||
有了对于聚合的理解,做设计的时候,我们就要识别出哪些对象可以组成聚合。所以,我们的一对多问题也就不再是问题了:是聚合的,我们可以一次都拿出来;不是聚合的,我们就靠标识符按需提取。**当你纠结于技术时,先想想自己是不是解错了问题。**
|
||||
|
||||
## 互动:工厂、仓库、领域服务、应用服务
|
||||
|
||||
我们现在有角色了,也确定关系了。接下来,我们就要安排互动了,也就是说,我们要把故事的来龙去脉讲清楚了。
|
||||
|
||||
还记得[第27讲](https://time.geekbang.org/column/article/266819)的事件风暴吗?我们在其中识别出了事件和动作,而故事的来龙去脉其实就是这些事件和动作。因为有了各种动作,各种角色才能够生动地活跃起来,整个故事才得以展开。
|
||||
|
||||
动作的结果会产生出各种事件,也就是**领域事件**,领域事件相当于记录了业务过程中最重要的事情。相对于 DDD 中的其他概念,领域事件加入 DDD 大家庭是比较晚的,但因为其价值所在,它迅速地就成了DDD 中不可或缺的一个重要概念。
|
||||
|
||||
因为领域事件是一条很好的主线,可以帮我们梳理出业务上的变化。同时,在如今这个分布式系统此起彼伏的时代,领域事件可以帮助我们让系统达成最终一致的状态。
|
||||
|
||||
那各种动作又是什么呢?那就是我们在写作中常用到的动词。在战术设计中,**领域服务(Domain Service)**就是动词。只不过,它操作的目标是领域对象,更准确地说,它操作的是聚合根。
|
||||
|
||||
动词,是我们在学习面向对象中最为缺少的一个环节,很多教材都会教你如何识别名词。在实际编码中,我们会大量地使用像 Handler、Service之类的名字,它们其实就是动词。
|
||||
|
||||
你可能会问,按照前面的说法,动作不应该在实体或值对象上吗?确实是这样的,能放到这些对象上的动作固然可以,但是,总会有一些动作不适合放在这些对象上面,比如,要在两个账户之间转账,这个操作牵扯到两个账户,肯定不能放到一个实体类中。这样的动作就可以放到领域服务中。
|
||||
|
||||
还有一类动作也比较特殊,就是创建对象的动作。显然,这个时候还没有对象,所以,这一类的动作也要放在领域服务上。这种动作对应的就是**工厂(Factory)**。这个工厂其实就是设计模式中常提到的工厂,有了设计模式的基础之后,你理解起来就容易多了。
|
||||
|
||||
需要注意的是,由于聚合的存在,聚合里的各种子对象都要从聚合根创建出来,以便保证二者之间的关联。比如,订单项的产生应该从订单上的订单项工厂方法创建出来。而聚合根本身的产生,就可以由领域服务来扮演工厂的角色。
|
||||
|
||||
对于这些领域对象,无论是创建,还是修改,我们都需要有一个地方把变更的结果保存下来,而承担这个职责的就是**仓库(Repository)**。你可以简单地把它理解成持久化操作(当然,在不同的项目中,具体的处理还是会有些差别的)。
|
||||
|
||||
其实,很多人熟悉的 CRUD,可以对应成一个一个的领域服务。如果我们用战术设计的做法来表示,应该是这样:
|
||||
|
||||
- 创建(Create),从工厂中创建出一个对象,然后,保存到仓库中;
|
||||
- 查询(Read),通过仓库进行查询;
|
||||
- 修改(Update),通过仓库找到要修改的对象,修改之后,存回到仓库中;
|
||||
- 删除(Delete),通过仓库找到要删除的对象,然后,在仓库中删除。
|
||||
|
||||
当然,这种简单的映射并不好,没有体现出业务含义,这里只是为了帮助你把已有知识和新知识之间架设起桥梁。
|
||||
|
||||
当我们把领域服务构建起来之后,核心的业务逻辑基本就成型了。但要做一个系统,肯定还会有一些杂七杂八的东西,比如,用户要修改一个订单,但首先要保证这个订单是他的。在 DDD中,承载这些内容的就是**应用服务**。
|
||||
|
||||
应用服务可以扮演协调者的角色,协调不同的领域对象、领域服务等完成客户端所要求的各种业务动作,所以,也有人把它称之为“工作流服务”。一般来说,一些与业务逻辑无关的内容都会放到应用服务中完成,比如,监控、身份认证等等。说到这里,我们已经说出了应用服务和领域服务之间的区别。
|
||||
|
||||
**应用服务和领域服务之间最大的区别就在于,领域服务包含业务逻辑,而应用服务不包含。**至于哪些东西算是业务逻辑,就要结合具体的项目来看了。
|
||||
|
||||
至此,我已经把战术设计这个故事模板给你讲了一遍,DDD 也算完整地讲了一遍了。你现在应该对DDD的各种基础概念之间是个什么关系、如果要做领域驱动设计,要有怎样一个步骤等有一个基本的认识了。
|
||||
|
||||
当然,仅凭三讲的篇幅,我们想要完整地理解领域驱动设计几乎是不可能的。但是你现在至少有了一个框架,当你再去学习 DDD 中那些让人眼花缭乱的知识时,你就不会轻易地迷失了。
|
||||
|
||||
Vaughn Vernon 写过两本关于 DDD 的书,是现在市面上比较好的 DDD 学习材料。建议你先阅读《领域驱动设计精粹》,这本书可以帮你快速入门;然后你再看《实现领域驱动设计》,这本书很厚,但讲得要更细致一些。当然,想要真正想学会 DDD,还是需要你在实际项目中进行练习。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们讲了DDD 中的战术设计,我们把战术设计当作了一个故事模板。让你先去识别角色,也就是找到**实体和值对象**。一个简单的区分就是,能通过唯一标识符识别出来的就是实体,只能通过字段组合才能识别出来的是值对象。
|
||||
|
||||
然后我们应该找到角色之间的关系,也就是**聚合**。操作聚合关键点在于找到**聚合根**。当聚合根不存在时,聚合中的对象也就不再有价值了。
|
||||
|
||||
有了角色及其关系,接下来就是找到各种动词,让故事生动起来。这里,我们讲到了动作,也就是**领域服务**,以及动作的结果,也就是**领域事件**,还有创建对象的**工厂**和保存对象的**仓库**。这些内容构成了我们最核心的业务逻辑。一些额外的工作,我们可以放到外围来做,这就是**应用服务**。
|
||||
|
||||
通过这几讲关于DDD的学习,你知道了如何识别出各种对象。通过前面设计原则、设计模式的讲解,你知道了如何组织这些对象。至此,我已经把设计相关的主要知识给你讲过一遍了,你现在应该知道如何做设计了。
|
||||
|
||||
那现在我们已经有了这样的基础,我们就可以做自己的设计了。从下一讲开始,我们就来体验一下,如何在真实的项目中做设计。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**战术设计,就是按照模板寻找相应的模型。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1f/5a/1f4e21934c11a3b984aafe2238745c5a.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你分享一下,你可以按照战术设计的模板,简要地描述一下你的项目吗?欢迎在留言区写下你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
133
极客时间专栏/软件设计之美/课前必读/01 | 软件设计到底是什么?.md
Normal file
133
极客时间专栏/软件设计之美/课前必读/01 | 软件设计到底是什么?.md
Normal file
@@ -0,0 +1,133 @@
|
||||
<audio id="audio" title="01 | 软件设计到底是什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/64/0e/6464cf35de7f644cf93111bdb4e2220e.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
一个软件需要设计,这是你一定认同的。但软件设计到底是什么,不同的人却有着不同的理解:
|
||||
|
||||
- 有人认为,设计就是讨论要用什么技术实现功能;
|
||||
- 有人认为,设计就是要考虑选择哪些框架和中间件;
|
||||
- 有人认为,设计就是设计模式;
|
||||
- 有人认为,设计就是Controller、Service加Model;
|
||||
- ……
|
||||
|
||||
你会发现,如果我们按照这些方式去了解“软件设计”,不仅软件设计的知识会很零散,而且你会像站在流沙之上一般:
|
||||
|
||||
- 今天你刚学会用Java,明天JavaScript成了新宠,还没等你下定决心转向,Rust又成了一批大公司吹捧的目标;
|
||||
- 你终于知道了消息队列在解决什么问题,准备学习强大的Kafka,这时候有人告诉你Pulsar在某些地方表现得更好;
|
||||
- 你总算理解了Observer模式,却有人告诉你JDK中早就提供了原生的支持,但更好的做法应该是用Guava的EventBus;
|
||||
- 你好不容易弄清楚MVC是怎样回事,却发现后端开发现在的主要工作是写RESTful服务,Controller还没有用,就应该改名成Resource了;
|
||||
- ……
|
||||
|
||||
我们说,软件设计要关注长期变化,需要应对需求规模的膨胀。这些在不断流变的东西可能还没你的软件生命周期长,又怎能支撑起长期的变化呢!
|
||||
|
||||
那么回到一开始的问题,软件设计到底是什么呢?
|
||||
|
||||
## 核心的模型
|
||||
|
||||
在回答这个问题之前,我们先来思考这样一件事:软件的开发目的是什么?
|
||||
|
||||
一个直白的答案就是,软件开发是为了解决由需求带来的各种问题,而解决的结果是一个可以运行的交付物。比如,我们在线购物的需求,是通过电商平台这个方案解决的。
|
||||
|
||||
那软件设计在这个过程中做的事情是什么呢?就是在需求和解决方案之间架设一个桥梁。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/20/a6920a1e9a4a8af9fe86b88f032cb820.jpg" alt="">
|
||||
|
||||
区别于解决简单的问题,软件的开发往往是一项长期的工作,会有许多人参与其中。在这种情况下,就需要建立起一个统一的结构,以便于所有人都能有一个共同的理解。这就如同建筑中的图纸,懂建筑的人看了之后,就会产生一个统一的认识。
|
||||
|
||||
而在软件的开发过程中,这种统一的结构就是模型,而**软件设计就是要构建出一套模型**。
|
||||
|
||||
这里所说的模型,不仅包括用来描述业务的各种实体,也包括完成业务功能的各种组件。人们写代码中常常会用到的服务(Service)、调度器(Scheduler)等概念就是一个一个的模型。
|
||||
|
||||
**模型,是一个软件的骨架,是一个软件之所以是这个软件的核心**。一个电商平台,它不用关系型数据库,还可以用NoSQL,但如果没有产品信息,没有订单,它就不再是电商平台了。
|
||||
|
||||
可能有不少人一听到模型,就会情不自禁地要打退堂鼓,认为这些内容过于高大上,其实大可不必,**模型的粒度可大可小**。如果把模型理解为一个一个的类,是不是你就会熟悉很多了,这就是小的模型。你也可以把一整个系统当作一个整体来理解,这就是大的模型。
|
||||
|
||||
关于设计,你一定听说过一个说法,“**高内聚、低耦合**”,(模块的内聚程度越高越好,模块间的耦合程度越低越好),**这其实就是对模型的要求。**一个“高内聚、低耦合”的模型能够有效地隐藏细节,让人理解起来也更容易,甚至还可以在上面继续扩展。比如,我们后面课程会讲到的程序设计语言,就是提供了一个又一个的编程模型,让我们今天写程序不用再面对各种硬件的差异,还能够在此基础上继续提供新功能。
|
||||
|
||||
你在日常工作中用到的各种框架和技术,也是提供了一个又一个的模型,它们大幅度降低了我们的开发门槛。所以你看,整个计算机世界就是在这样一个又一个模型的叠加中,一点一点构建出来的。用一个程序员所熟悉的说法就是:模型是分层的。这就像乐高一样,由一个个小块构建出一个个大一些的部件,再用这些部件组成最终的成品。
|
||||
|
||||
这与一些人常规理解的Controller、Service那种分层略有差异。但实际上,这才是在计算机行业中普遍存在的分层。我们熟悉的网络模型就是一个典型的分层模型。按照TCP/IP的分层方法,网络层要构建在网络接口层之上,应用层则要依赖传输层,而我们平时使用的大多数协议则属于应用层。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bc/5a/bc7c736084e79d067477c306a9f5bb5a.jpg" alt="">
|
||||
|
||||
即便是在一个软件内部,模型也可以是分层的。**我们可以先从最核心的模型<strong><strong>开始构建**</strong>,有了这个核心模型之后,**<strong>可以**</strong>通过组合这些基础的模型,构建出上面一层的模型</strong>。
|
||||
|
||||
我曾经做过一个交易系统的设计。在分析了主要的交易动作之后,我提出了一个交易原语的概念,包括资产冻结、解冻、出金、入金等少数几个动作。然后,把原先的交易动作变成了原语的组合。比如,下单是资产冻结,成交是不同账户的出金和入金,撤单则是资产解冻。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b6/8d/b6432a9f6de195311674a8c0ac5a9c8d.jpg" alt="">
|
||||
|
||||
在这个结构下,由交易原语保证每个业务的准确性,由交易动作保证整个操作的事务性。从上面这个图中,你可以看出,这就是一种分层,一种模型上的分层。
|
||||
|
||||
好,到这里我们已经对软件设计中的模型有了一个初步的认识。总结一下就是,模型是一个软件的核心;模型的粒度可大可小;好的模型应该“高内聚、低耦合”;模型可以分层,由底层的模型提供接口,构建出上层的模型。
|
||||
|
||||
后续我们这个课程的大部分内容都会围绕着模型来讲:怎样理解模型、建立模型、评判模型的优劣等等。
|
||||
|
||||
学会这些知识之后,能在多大的粒度上应用它们,你就能掌控多大的模块。不过,仅仅是把软件设计理解成构建模型,这个理解还不够。模型设计也不能任意妄为,需要有一定的约束,而这个约束,就是软件设计要构建的另一个部分:规范。
|
||||
|
||||
## 约束的规范
|
||||
|
||||
如果说,软件设计要构建出一套模型,这还是比较直观好理解的。因为模型通常可以直接体现在代码中。但软件设计的另一部分——规范,就常常会被忽略。
|
||||
|
||||
**规范,就是限定了什么样的需求应该以怎样的方式去完成****。**比如:
|
||||
|
||||
- 与业务处理相关的代码,应该体现在领域模型中;
|
||||
- 与网络连接相关的代码,应该写在网关里;
|
||||
- 与外部系统集成的代码,需要有防腐层;
|
||||
- ……
|
||||
|
||||
其实,每个项目都会有自己的规范。比如,你总会遇到一些项目里的老人,他们会告诉你,这个代码应该写在这,而不应该写在那,这就是某种意义上的规范。虽然规范通常都有,但问题常常也在。
|
||||
|
||||
**一种<strong><strong>常见的问题**</strong>就是缺乏显式的、统一的规范。</strong>
|
||||
|
||||
规范的一个重要作用,就是维系软件长期的演化。如果没有显式的规范,项目的维系只能依靠团队成员个人的发挥,老成员一个没留神,新成员就可能创造出一种诡异的新写法,项目就朝着失控又迈出了一步。
|
||||
|
||||
不知道你是否接触过这样的项目,多种不同的做法并存其中:
|
||||
|
||||
- 数据库访问,有用MyBatis的,有用JDBC的,也有用Hibernate的;
|
||||
- 外部接口设计,有用REST风格的,有用URL表示各种动作的;
|
||||
- 文件组织,有的按照业务功能划分(比如,产品、订单等),有的按照代码结构划分(比如,Resource、Service等);
|
||||
- ……
|
||||
|
||||
没有一个统一的规范,每一个项目上的新成员都会痛斥一番前人的不负责任。然后,新的人准备另起炉灶,增加一些新东西。这种场景你是不是很熟悉呢?混乱通常就是这样开始的。
|
||||
|
||||
如果存在一个显式的、统一的规范,项目会按照一个统一的方向行进。即便未来设计要演化、规范要调整,有一个统一的规范也要比散弹打鸟来得可控得多。
|
||||
|
||||
关于规范,**还有一种常见问题就是,<strong><strong>规范不符合**</strong>软件设计原则</strong>。我给你讲一个让我印象深刻的故事。
|
||||
|
||||
我曾经遇到一个网关出现了OOM(Out of Memory,内存溢出)。这个网关日常的内存消耗高达150G,一次流量暴增它就扛不住了。后来经过优化,把内存消耗降到了8G。
|
||||
|
||||
如果单看数字,这是一个接近20倍的优化,大手笔啊,但这里面究竟发生了什么呢?实际上,这次优化最核心的内容就是构建了一个防腐层,将请求过来的JSON转换成了普通的内存对象。而原来的做法是把JSON解析器解析出来的对象到处使用,因为这些对象上附加很多额外的信息,导致占用了大量的内存。
|
||||
|
||||
很遗憾,这不是大牛战天斗地的故事,只是因为旧的规范不符合软件设计原则而导致的错误:外部请求的对象需要在防腐层转换为内部对象。
|
||||
|
||||
## 模型与规范
|
||||
|
||||
有了模型,有了规范,**那模型与规范是什么关系呢?模型与规范,二者相辅相成**。一个项目最初建立起的模型,往往是要符合一定规范的,而规范的制定也有赖于模型。这就像讨论户型设计时,你可以按照各种方式组合不同的空间(模型),却不会把厨房与卫生间放在一起(规范)。
|
||||
|
||||
至此,我们已经知道了,软件设计既包含构建出一套模型,也包括制定出相应的规范。再回过头来看这节课开头的问题,你是不是对软件设计有了重新的认识呢?特定技术、框架和中间件,只是支撑我们模型的实现,而设计模式、Controller、Service、Model这些东西也只是一个特定的实现结果,是某些特定场景下的模型。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们学习了软件设计到底是什么,它应该包括“模型”和“规范”两部分:
|
||||
|
||||
<li>
|
||||
模型,是一个软件的骨架,是一个软件之所以是这个软件的核心。模型的粒度可大可小。我们所说的“高内聚、低耦合”指的就是对模型的要求,一个好的模型可以有效地隐藏细节,让开发者易于理解。模型是分层的,可以不断地叠加,基于一个基础的模型去构建上一层的模型,计算机世界就是这样一点点构建出来的。
|
||||
</li>
|
||||
<li>
|
||||
规范,就是限定了什么样的需求应该以怎样的方式去完成。它对于维系软件长期演化至关重要。关于规范,常见的两种问题是:一个项目缺乏显式的、统一的规范;规范不符合软件设计原则。
|
||||
</li>
|
||||
<li>
|
||||
模型与规范,二者相辅相成,一个项目最初建立起的模型,往往是要符合一定规范的,而规范的制定也有赖于模型。
|
||||
</li>
|
||||
|
||||
有了对软件设计的初步了解,我们就准备开始做设计了,但该从哪入手呢?这就是我们下一讲的内容。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**软件设计**,**应该包括模型和规范**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/03/58/038f4e3e01a9cdd4d40ccf37e6771558.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你分享一下,你的项目是如何做设计的。欢迎在留言区写下你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
104
极客时间专栏/软件设计之美/课前必读/02 | 分离关注点:软件设计至关重要的第一步.md
Normal file
104
极客时间专栏/软件设计之美/课前必读/02 | 分离关注点:软件设计至关重要的第一步.md
Normal file
@@ -0,0 +1,104 @@
|
||||
<audio id="audio" title="02 | 分离关注点:软件设计至关重要的第一步" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/68/51/6878ef216d653e1ea142008ce707c251.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
上一讲我们讲了软件开发就是在解决问题。那问题一般是如何解决的呢?最常见的解决问题思路是**分而治之**,也就是说,我们要先把问题拆分开。在每个问题都得到解决之后,再把这些解决好的子问题以恰当的方式组装起来。如何分解与组合,就是我们要在软件设计中考虑的问题。
|
||||
|
||||
然而,在软件设计这个环节中,大部分人都把焦点放在了如何组合上,却忽略了至关重要的第一步:分解。你可能会觉得:“分解?我会啊,不就是把一个大系统拆成若干个子系统,再把子系统再拆成若干个模块,一层一层拆下去嘛。”
|
||||
|
||||
然而,在我看来,这种程度的分解远远不够,因为分解出来的粒度太大了。**粒度太大会造成什么影响呢?<strong><strong>这会导致我们**</strong>把不同的东西混淆在一起</strong>,为日后埋下许多隐患。
|
||||
|
||||
为什么这么说呢?我来给你举个例子。
|
||||
|
||||
## 一个失败的分解案例
|
||||
|
||||
我曾经见过一个故障频出的清结算系统,它的主要职责是执行清结算。一开始我觉得,清结算系统是一个业务规则比较多的系统,偶尔出点故障,也是情有可原。
|
||||
|
||||
但是在分析了这个系统的故障报告后,我们发现这个系统设计得极其复杂。其中有一处是这样的:上游系统以推送的方式向这个系统发消息。在原本的实现中,开发人员发现这个过程可能会丢消息,于是,他们设计了一个补偿机制。
|
||||
|
||||
因为推送过来的数据是之前由这个系统发出去的,它本身有这些数据的初始信息,于是,开发人员就在数据库里增加了一个状态,记录消息返回的情况。一旦发现丢消息了,这个系统就会访问上游系统的接口,将丢失的数据请求回来。
|
||||
|
||||
正是这个补偿机制的设计,带来了一系列的后续问题。比如,当系统业务量增加的时候,数据库访问的压力本身就很大,但在这种场景下,丢数据的概率也增加了,用于补偿的线程也会频繁访问数据库,因为它要找出丢失的数据,还要把请求回来的数据写回到数据库里。
|
||||
|
||||
也就是说,一旦业务量上升,本来就已经很吃力的系统,它的负担就更重了,系统出现卡顿也就在所难免了。
|
||||
|
||||
这个补偿机制的设计是有问题的,问题的点在于,上游系统向下游推送消息,这应该是一个通信层面的问题。而在原有的设计中,因为那个状态的添加,这个问题被带到了业务层面。
|
||||
|
||||
这就是一个典型的分解没有做好的例子,是分解粒度太大造成的。开发人员只考虑了业务功能,忽视其他维度。**技术和业务被混在了一起,随之而来的就是无尽的后患。**
|
||||
|
||||
一旦理解了这一点,我们就可以想办法解决了。既然是否丢消息是通信层面的事,我们就争取在通信层面解决它。我们当时的解决方案是,选择了一个吞吐量更大的消息队列。在未来可见的业务量下,消息都不会丢。**通信层面的问题在通信层面解决了,业务层面也就不会受到影响了**。果不其然,这样改造之后,系统的稳定性得到了大幅度的提升。
|
||||
|
||||
上面我只讲了这个故事的主线,其实,相关的事情还有一些。比如,上游系统专门为补偿而开发的接口,现在也不需要了,于是上游系统得到了简化;这个系统里那个表示状态的字段,其实还被用在了业务处理中,也引发过其他问题,现在它只用在业务处理中,角色单一了,与此相关的问题也少了。
|
||||
|
||||
## 分离关注点
|
||||
|
||||
至此,我们已经对分解粒度太大所造成的影响,有了一个初步的了解。那在做设计时,该如何考虑分解呢?传统上,我们习惯的分解问题的方式是树型的。比如,按功能分解,可分为:功能1、功能2、功能3,等等,然后,每个功能再分成功能1.1、功能1.2、功能2.1、功能3.1等等,以此类推。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d4/9b/d439cc19ef775ea53b7809737609f59b.jpg" alt="">
|
||||
|
||||
如果只从业务上看,这似乎没什么问题。但我们要实现一个真实的系统,就不仅仅要考虑功能性的需求,还要考虑非功能性的需求。比如,前面提到的数据不能丢失、有的系统还要求处理速度要快,等等。
|
||||
|
||||
这与业务并不是一个维度的事情,我们在做设计时,要能够发现这些非功能性的需求。也就是说,我们在分解问题的时候,会有很多维度,每一个维度都代表着一个关注点,这就是设计中一个常见的说法,“**分离关注点(Separation of concerns)**”。
|
||||
|
||||
可以分离的关注点有非常多,你只要稍微注意一下,就能识别出来。但还有一些你可能注意不到,结果导致了混淆。最常见的一类问题就是**把业务处理和技术实现两个关注点混在了一起**,前面举的那个例子就是一个典型。
|
||||
|
||||
对于“把业务处理和技术实现混在一起”的问题,我再给你举个例子。如果现在业务的处理性能跟不上,你有什么办法解决吗?大多数程序员的第一反应是,多线程啊!
|
||||
|
||||
没错,多线程的确是一种解决办法。但如果不加限制地让人去把这段代码改成多线程的,一些多线程相关的问题也会随之而来。比如,让人头疼的资源竞争、数据同步等等。
|
||||
|
||||
写好业务规则和正确地处理多线程,这是两个不同的关注点。如果我们把二者放到同一段代码里去写,彼此影响也就在所难免了。问题说明白了,解决方案才能清楚,那就是把业务处理和多线程处理的代码分开。
|
||||
|
||||
按照我的理解,**大部分程序员都不应该编写多线程程序**。由专门的程序员把并发处理的部分封装成框架,大家在里面写业务代码就好了。
|
||||
|
||||
把业务处理和技术实现混在一起,类似问题还有很多。比如我们经常问怎么处理分布式事务,怎么做分库分表等。其实,你更应该问的是,我的业务需要分布式事务吗?我是不是业务划分没有做清楚,才造成了数据库的压力?
|
||||
|
||||
在真实项目中,程序员最常犯的错误就是认为所有问题都是技术问题,总是试图用技术解决所有问题。**任何试图用技术去解决其<strong><strong>他**</strong>关注点的问题,只能是陷入焦油坑之中,越挣扎,陷得越深。</strong>
|
||||
|
||||
另外一个常见的容易产生混淆的关注点是**不同的数据变动方向**。
|
||||
|
||||
有人问过我这样一个问题:在Java应用里,做数据库访问用Spring Data JPA好,还是MyBatis好。Spring Data JPA简化了数据库访问,自动生成对应的SQL语句,而MyBatis则要自己手写SQL。
|
||||
|
||||
普通的增删改查用Spring Data JPA非常省事,但对于一些复杂场景,他会担心自动生成SQL的性能有问题,还是手写SQL优化来得直接。是不是挺纠结的?
|
||||
|
||||
随即我又问了他一个问题,为什么需要复杂查询呢?他告诉我,有一些统计报表需要。
|
||||
|
||||
不知道你是否发现了其中混淆关注点的地方?普通的增删改查需要经常改动数据库,而复杂查询的使用频率其实是很低的。
|
||||
|
||||
从本质上说,之所以出现工具选择的困难,是因为他把两种数据使用频率不同的场景混在一起所造成的。如果将前台访问(处理增删改查)和后台访问(统计报表)分开,纠结也就不复存在了。
|
||||
|
||||
不同的数据变动方向还有很多,比如:
|
||||
|
||||
- 动静分离,就是把变和不变的内容分开;
|
||||
- 读写分离,就是把读和写分开;
|
||||
- 前面提到的高频和低频,也可以分解开;
|
||||
- ……
|
||||
|
||||
**不同的数据变动方向,就是一个潜在的、可以分离的关注点。**
|
||||
|
||||
在实际的项目中,可以分离的关注点远不止这些。做设计时,你需要一直有一根弦去发现不同的关注点。分离关注点,不只适用于宏观的层面。
|
||||
|
||||
在微观的代码层面,你用同样的思维方式,也可以帮助你识别出一些混在一起的代码。比如,很多程序员很喜欢写setter,但你真的有那么多要改变的东西吗?实际上可能就是封装没做好而已。
|
||||
|
||||
分离关注点之所以重要,有两方面原因。一方面,不同的关注点混在一起会带来一系列的问题,正如前面提到的各种问题;另一方面,当分解得足够细小,你就会发现不同模块的共性,才有机会把同样的信息聚合在一起。这会为软件设计的后续过程,也就是组合,做好准备。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们学习了软件设计中至关重要的第一步:分解。
|
||||
|
||||
大多数系统的设计做得不够好,问题常常出现在分解这步就没做好。常见的分解问题就是分解的粒度太大,把各种维度混淆在一起。在设计中,将一个模块的不同维度分开,有一个专门的说法,叫分离关注点。
|
||||
|
||||
分离关注点很重要,一方面,不同的关注点混在一起会带来许多问题;另一方面,分离关注点有助于我们发现不同模块的共性,更好地进行设计。分离关注点,是我们在做设计的时候,需要时时绷起的一根弦。
|
||||
|
||||
今天,我还给你举了两种常见的关注点混淆的情况。一种是技术和业务的混淆,另一种是不同数据变动方向的混淆。希望你在日常开发中,引以为戒。
|
||||
|
||||
好,我们已经迈出了软件设计的第一步。接下来,就该考虑如何组合了。在组合的过程中,会有很多因素影响到组合的方式。下一讲我们就来看一个非常重要却不受重视的因素:可测试性。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**分离关注点,发现的关注点越多越好,粒度越小越好**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/46/c5/46fb9a7cd6daac77dde4ffb6d28b7bc5.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后我想请你去了解一下CQRS(Command Query Responsibility Segregation),看看它分离了哪些关注点,以及在什么样的场景下使用这种架构是合理的。欢迎在留言区写下你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
131
极客时间专栏/软件设计之美/课前必读/03 | 可测试性: 一个影响软件设计的重要因素.md
Normal file
131
极客时间专栏/软件设计之美/课前必读/03 | 可测试性: 一个影响软件设计的重要因素.md
Normal file
@@ -0,0 +1,131 @@
|
||||
<audio id="audio" title="03 | 可测试性: 一个影响软件设计的重要因素" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/13/58/1378eb4d7b62b726f7ebdd7889ea1958.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
上一讲,我们讲了软件设计的第一步:分离关注点。作为至关重要的第一步,分离关注点常常被人忽略,严重影响了设计的有效性。这一讲,我们再来看另一个经常被很多人忽视的因素:可测试性。
|
||||
|
||||
在讨论可测试性之前,我们不妨先来思考一个问题:你觉得软件开发中最浪费时间的环节是什么?答案肯定不是写代码,因为写代码是一个建设的过程,谈不上是在浪费时间。在我接触过的诸多项目里,集成测试可以说是一个浪费时间的大户。
|
||||
|
||||
那你的项目是怎么做集成测试的呢?一个常见的测试场景是这样的:你先花了一些时间打包部署一个服务端应用,然后开始测试。测着测着,你发现一个Bug,然后调查半天,最后发现是一个简单的错误。你就在心里暗恨,为啥写代码的时候没发现呢!
|
||||
|
||||
这还只是一个简单的场景,也有稍微复杂一点的。比如,有多个不同项目组的人一起联合测试。当你测出一个Bug,然后辛辛苦苦调查半天,发现是另外一个模块出了问题,你唯一能做的就是等着那个组的同事把Bug改好,测试才能进行下去。更可恨的是,他们查了半天,结果也是一个简单的错误。你会在心里嘀咕,为啥写代码的时候不仔细一点呢?
|
||||
|
||||
在实际工作中,我们经常遇到类似的场景。你觉得这种状态正常吗?可能很多人对此习以为常。虽然难受,却不得不忍受。
|
||||
|
||||
但我想说的是,这样的问题原本有机会得到优化。而出现这样的问题,主要原因就在于**前期设计时<strong><strong>就埋下了隐患**</strong>,你**<strong>根本**</strong>没有考虑“可测试性”</strong>。
|
||||
|
||||
## 软件设计要考虑“可测试性”
|
||||
|
||||
我们知道,软件开发要解决的问题是从需求而来。需求包括两大类,第一类是功能性需求,也就是要完成怎样的业务功能;第二类是非功能性需求,是业务功能之外的一些需求。
|
||||
|
||||
非功能性需求也被分为两大类,一类称为执行质量(Execution qualities),你所熟悉的吞吐、延迟、安全就属于这一类,它们都是可以在运行时通过运维手段被观察到的;而另一类称为演化质量(Evolution qualities),它们内含于一个软件的结构之中,包括可测试性、可维护性、可扩展性等。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a1/da/a126ab6ed0251c82b7d15c2e9c041cda.jpg" alt="">
|
||||
|
||||
做设计的时候,功能性需求自不必说,你肯定会考虑到。在非功能性需求中,执行质量是很多程序员的心头爱,一般也不会被忽略。但演化质量的地位却很低,常常为人忽略,尤其是其中的“可测试性”。
|
||||
|
||||
我们在开发过程中欠下的很多技术债,本质上都是因为忽略了“可测试性”这个需求。
|
||||
|
||||
可测试性为什么如此重要?因为我们做设计,其实就是把一个软件拆分成一个一个的小模块。如果不尽可能地保证每个小模块的正确性,而只是从最外围的系统角度去验证系统的正确性,这将会是一个非常困难的过程。就和盖楼是一个道理,不保证钢筋、水泥、砖土质量合格,却想要盖出合格的大楼来,很荒唐吧!然而,很多团队的软件开发就是这么做的。
|
||||
|
||||
我们要保证每个小模块的正确性,就要保证每个模块在开发阶段能够测试,而想要每个模块能够测试,在设计过程中,就要保证每个模块是可以测试的,而这就是可测试性。
|
||||
|
||||
一旦我们在可测试性上考虑不足,就会引发一系列的后续问题。比如,复杂的系统不仅仅在测试上有难度,在集成、部署等各个环节,都有其复杂性,完成一次部署往往也需要很长时间。
|
||||
|
||||
这也就意味着,即便是一个简单的验证工作,部署的时间成本也非常昂贵。这还不包括在出问题时,我们在一个复杂系统中定位问题的成本。
|
||||
|
||||
我们只有把每个小模块尽可能做好,才能尽量降低对集成环境的依赖程度,从而节省后期的成本。这就相当于在前面多花了1块钱,却省下了后期的10块钱。
|
||||
|
||||
我们回过头思考一下这节课刚开始提到的那个问题,为什么我们在集成测试场景中,会浪费那么多时间呢?因为这个系统只能在集成测试环境中进行测试,所以,即使是一些非常简单的问题,也只能在这阶段暴露。这些问题原本可以在更前面的阶段解决,比如,单元测试。
|
||||
|
||||
可为什么这些问题会遗留到集成测试环境呢?很多程序员给你的回答都会是,不好测。而这不好测的背后,往往就是因为在设计中没有考虑“可测试性”这个因素。
|
||||
|
||||
那么如何在设计中考虑可测试性呢?其实就是要在设计时想一下,**这个函数/模块/系统要怎么测。**
|
||||
|
||||
当你用这个标准衡量一些系统时,可能就会发现一种典型的错误,就是设计根本没有考虑过测试。这样的系统常常只有最外层的接口可以测试,也就是说,整个系统必须集成起来才能测试。前面提到的集成测试的问题犯下的就是这种错误。
|
||||
|
||||
在实际工作中,很多公司为了做集成测试,要把所有的子系统全部都搭建出来,也就是一套完整的环境。这种环境要占用大量的资源,一般来说,公司不会准备很多套。这样造成的结果就是各个团队对于环境的竞争,再叠加上各个系统配合的问题,测试的效率还会进一步降低。
|
||||
|
||||
**所以,我们在设计一个函数/模块/系统时,必须将可测试性纳入考量,以便于能够完成不同层次的测试,减少对集成环境的依赖。**
|
||||
|
||||
那么,具体该如何做呢?一方面,尽可能地给每个模块更多的测试,使构成系统的每个模块尽可能稳定,把集成测试环境更多地留作公共的验收资源。另一方面,尽可能搭建本地的集成测试环境,周边的系统可以采用模拟服务的方案。
|
||||
|
||||
在软件开发过程中考虑测试,实际上是思考软件的质量问题,而把质量的思考前移到开发,甚至是设计阶段,是软件开发从传统进入到现代的重要一步。
|
||||
|
||||
## 当你有了可测试性的视角
|
||||
|
||||
现在你已经对软件设计中的可测试性有了一个初步的认识。其实,在了解可测试性之后,我们还可以把它作为一个衡量标准来考察已有的设计。
|
||||
|
||||
比如,有一个设计模式叫Singleton,通常的做法是把构造函数做成私有的。如果这个Singleton的类与其他组件配合,由于这个私有函数的存在,这个类无法继承,也就不能用一个子类对象去模拟它。所以,从可测试性的角度来看,Singleton就不是一个好的设计模式。
|
||||
|
||||
再比如,TDD(Test-Driven Development,测试驱动开发)对于很多人来说都非常困难,主要有两方面原因。一方面,这些人不习惯先写测试的工作方式,但另外一方面,也是更重要的原因,是他们不知道怎么测试。
|
||||
|
||||
因为很多模块的设计根本没有考虑过如何做测试,要把它们单独拿出来测试,必然会遇到很多问题。
|
||||
|
||||
举个例子,在通常的架构中,服务会调用数据库访问的代码。如果是不考虑测试的做法,代码可能写成这样:
|
||||
|
||||
```
|
||||
class ProductService {
|
||||
// 访问数据库的对象
|
||||
private ProduceRepository repository = new ProductRepository();
|
||||
|
||||
public Product find(final long id) {
|
||||
return this.repository.find(id);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这里,我们要直接创建数据库访问的对象,然而,要创建数据库访问对象,就要同时把数据库连接起来,你要准备一大堆相关的东西,所以,测试的复杂度就会非常大。
|
||||
|
||||
可是,测试这个服务目的是,关心这个服务的逻辑是不是写正确了,这与是不是用数据库没关系啊!所以,如果我考虑了可测试性,服务的依赖就变成了一个数据访问的接口:
|
||||
|
||||
```
|
||||
class ProductService {
|
||||
// 访问数据库的对象
|
||||
private ProduceRepository repository;
|
||||
|
||||
public ProductService(final ProduceRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public Product find(final long id) {
|
||||
return this.repository.find(id);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这种代码里,我们只需要将数据访问的接口模拟出来,而用来模拟接口的Mock框架在各种程序语言里几乎都可以找到。我们唯一要保证的,就是模拟出来的对象要与接口定义的行为保持一致,不过,这可比准备数据库,难度系数要低多了。
|
||||
|
||||
真正懂得了可测试性,还可以帮助我们理解软件开发的趋势。有些Java工作经验的同学可能听说过EJB(Enterprise Java Beans),它是2000年左右的开发主流。当时一个Java系统如果没用到EJB,你都不好意思和人打招呼。但是,今天你很难听说有谁还在用EJB做新系统了。
|
||||
|
||||
在每次测试时,EJB都需要部署到专门的应用服务器上。站在可测试性的角度看,它的测试成本就是极其高昂的,相应的开发成本也就变得很高。
|
||||
|
||||
当年与EJB竞争的正是当今如日中天的Spring,Spring胜出的一个重要原因就是它简化了开发。它当年的口号正是without EJB。**这是一种重要的开发趋势:轻量级开发**。而这背后,重要的思维基础,**<strong>就是可测试性**</strong>。后面在第五讲中,我们会讲到SpringDI容器的设计,你会进一步看到可测试性在其中发挥的作用。
|
||||
|
||||
实际上,Spring在简化开发的道路上从未停下脚步。今天的Java程序员使用Spring Boot的时候,启动它就像启动一个普通的Java应用,在IDE里做各种调试,甚至都没有注意到它启动时,下面有一个Tomcat。
|
||||
|
||||
要知道,当年可是要打出一个WAR包,部署到Tomcat上。所以,曾几何时,能够连接远程的Web服务器是IDE一项重要的功能,而这项功能在今天来看,已经非常鸡肋了。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们学习了一个影响软件设计的重要因素:可测试性。
|
||||
|
||||
在软件设计中,可测试性常常被人忽视,结果造成了很多模块的不可测,由此引发了很多技术债。所以,在设计中就要充分考虑可测试性。
|
||||
|
||||
在设计中考虑可测试性,就是在设计时问一下,**这个函数/模块/系统怎么测**。在软件开发中,只有把一个一个的小模块做了足够的测试,我们才会有稳定的构造块,才可以在集成测试的时候,只关注最终的结果。
|
||||
|
||||
而有了可测试性的视角,我们可以把它当作一个衡量标准去看待其他的设计或实践,也可以用它帮助我们理解软件的发展趋势。
|
||||
|
||||
经过前几讲基础知识的铺垫,你对软件设计已经有了一个初步的了解。下一讲,我们将进入到实际的工作环节中,去了解一个软件的设计。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**做软件设计,请考虑可测试性。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/58/9e/587d5a0c7a5e6dbfea504e7bfee69e9e.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你回想一下,如果以可测试性衡量一下你开发过的系统,它的可测试性如何?有哪些问题是由于最初没有考虑可测试性造成的呢?欢迎在留言区分享你的经历。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
Reference in New Issue
Block a user