mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-17 06:33:48 +08:00
del
This commit is contained in:
159
极客时间专栏/geek/软件设计之美/设计一个软件—设计原则与模式/20 | 单一职责原则:你的模块到底为谁负责?.md
Normal file
159
极客时间专栏/geek/软件设计之美/设计一个软件—设计原则与模式/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
极客时间专栏/geek/软件设计之美/设计一个软件—设计原则与模式/21 | 开放封闭原则:不改代码怎么写新功能?.md
Normal file
247
极客时间专栏/geek/软件设计之美/设计一个软件—设计原则与模式/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="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你找一个提供了扩展点的开源项目,分析一下它是如何设计这个扩展点的。欢迎在留言区写下你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
@@ -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
极客时间专栏/geek/软件设计之美/设计一个软件—设计原则与模式/23 | 接口隔离原则:接口里的方法,你都用得到吗?.md
Normal file
278
极客时间专栏/geek/软件设计之美/设计一个软件—设计原则与模式/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。 欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
@@ -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
极客时间专栏/geek/软件设计之美/设计一个软件—设计原则与模式/25 | 设计模式:每一种都是一个特定问题的解决方案.md
Normal file
259
极客时间专栏/geek/软件设计之美/设计一个软件—设计原则与模式/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
极客时间专栏/geek/软件设计之美/设计一个软件—设计原则与模式/26 | 简单设计:难道一开始就要把设计做复杂吗?.md
Normal file
226
极客时间专栏/geek/软件设计之美/设计一个软件—设计原则与模式/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="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你分享一下,你还知道哪些让你受益匪浅的设计原则,欢迎在留言区写下你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
Reference in New Issue
Block a user