聊一聊设计模式究竟是什么

大家好,我是流沙。设计模式是每个程序员都会经常接触到的东西,但是相信很多人对于设计模式究竟是什么还会有些疑问。所以,我们今天就聊聊这个,主要目标是帮大家理解设计模式的作用,以及要用什么样的心态对待设计模式。

前置知识

提到设计模式,其实首先需要理解清楚的是面向对象思想。相信大家即使不能非常清晰的描述出来,对面向对象也应该是比较熟悉的。

我们就快速讲一下,面向对象有四大基本特性:封装、抽象、继承、多态;

封装:仅暴露有限的接口,授权外部来访问。将逻辑集中,因此更可控;可读性、可维护性也更好;易用性也更好。

抽象:隐藏方法的具体实现,让调用者只需要关心方法提供了哪些功能,并不需要知道这些功能是如何实现的。

继承:好处就是代码复用。

多态:子类可以替换父类。提高代码的可扩展和可复用性。

什么是设计模式

设计模式是针对软件开发中经常遇到的一些设计问题,根据基本的设计原则,总结出来的一套实用的解决方案或者设计思路。

可以看到,设计模式是非常偏实际应用的,相比设计原则更加具体、可执行。

因此,在了解设计模式之前,就需要了解一些基本的设计原则。这些原则才是指导我们写出好代码的关键。

那么什么是好代码呢?

好代码的标准

这个其实很难描述,我们可以试着归纳一些好代码的特征,比如下面这些:

可维护、可扩展、可读、可测、可复用、简洁。

为了达到这些标准,就需要现有一些基本的设计原则。

基本设计原则

SOLID

单一职责 Single Responsibility Principle

描述很简单,一个类只负责完成一个职责或者功能。但是实际要做好其实还是很难的。

关于怎么样才算职责单一,可以定出一些简单的标准,比如代码行数过多;依赖的其他类过多;私有方法过多;难以给类起名;类中大量方法功能集中等。但是无法定义出一个普适的标准,一定要结合实际情况去考虑。

比如这样一个类

1
2
3
4
5
6
7
8
9
10
11
Student {
int id;
String name;
String age;
...
String province;
String city;
String county;
String detailAddress;
...
}

在其中至少有四个字段是和地址有关的,那么是否需要把地址相关的职责抽离出来,封装一个新的Address类?

其实,是要视情况而定的,如果地址这些属性只是单纯的展示信息,那么直接放在Student类里,就没有问题;如果在这背后还有一套复杂的物流系统,也就是有很多地址相关的复杂逻辑,那么这一部分就应当单独抽离出来。

在实际的开发中,我们可以先写一个粗粒度的类,满足业务需求。随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多,这个时候,我们就可以将这个粗粒度的类,拆分成几个更细粒度的类。这就是所谓的持续重构。

开闭原则 Open Closed Principle

对扩展开放、对修改关闭。

添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。

开闭原则是相对难理解的一条原则。

“怎样的代码改动才被定义为‘扩展’?怎样的代码改动才被定义为‘修改’?怎么才算满足或违反‘开闭原则’?修改代码就一定意味着违反‘开闭原则’吗?”等问题,都很难回答。

而这条原则又是最有用,因为扩展性是代码质量最重要的衡量标准之一。

实际上,我们也没必要纠结某个代码改动是“修改”还是“扩展”,更没必要太纠结它是否违反“开闭原则”。我们应当回到它的初衷上,只要没有破坏原有代码的运行,就没有违反开闭原则。一个简单的判断标准,当功能没有变化时,对应的单元测试没被破坏,就是一次满足开闭原则的改动。

关于如何做到对扩展开放、修改关,也需要慢慢学习和积累。在 23 种经典设计模式中,大部分设计模式都是为了解决代码的扩展性问题而存在的,主要遵从的设计原则就是开闭原则。

实际写代码时可以假定变化不会发生,当出现变化时,需要抽象以隔离将来的同类变化。

里式替换原则 Liskov Substitution Principle

子类能够在任何地方替换父类,并且保证原来程序的逻辑行为不变及正确性不被破坏。

里式替换原则还有另外一个更加能落地、更有指导意义的描述,那就是“Design By Contract”,中文翻译就是“按照协议来设计”。描述里子类和父类的关系,可以替换成接口和实现的关系。换一种方式描述里式替换原则,就是实现类不能违反接口声明要提供的功能、输入、输出、异常等。

接口隔离原则 Interface Segregation Principle

Clients should not be forced to depend upon interfaces that they do not use。

其核心要素就是一个「接口」的功能要尽量单一,调用方只需要依赖它需要的部分,而不需要去依赖或者调用整个「接口」。

这里的接口可以指代不同的含义,比如一组api接口集合(也就是我们用的一个proxy)。

比如有个项目,会做一些业务流程,生成一些数据,然后又要作为基础服务把数据提供出去,这时,最后就是提供了两个接口, 一个是用来处理内部流程;一个则是作为基础服务堆外提供。这样做的好处就是可以一定程度上避免误调用。

也可以将接口就理解为单独一个接口方法,这时候就是说一个方法的功能要单一。

接口隔离原则跟单一职责原则有点类似,不过稍微还是有点区别。单一职责原则针对的是模块、类、接口的设计。而接口隔离原则相对于单一职责原则,更侧重于接口的设计。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。

依赖反转原则 Dependency Inversion Principle

High-level modules shouldn’t depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn’t depend on details. Details depend on abstractions.

所谓高层模块和低层模块的划分,简单来说就是,在调用链上,调用者属于高层,被调用者属于低层.

这一原则更多针对框架开发,业务开发里,高层模块依赖底层模块,实际上没有任何问题。

以tomcat为例,tomcat调用web程序来运行,所以tomcat是高层模块。

Tomcat 和应用程序代码之间并没有直接的依赖关系,两者都依赖同一个“抽象”,也就是 Servlet 规范。Servlet 规范不依赖具体的 Tomcat 容器和应用程序的实现细节,而 Tomcat 容器和应用程序依赖 Servlet 规范。

KISS

Keep It Simple and Stupid.

Keep It Short and Simple.

Keep It Simple and Straightforward

KISS原则有不同的表述方式,但是大体意思都差不多,就是要让代码尽量简单。

「简单」实际上是一个很主观的事情,code review是一种很好的间接评价代码是否简单的方式。如果同事review你的代码时,会有很多地方看不懂,这就很可能是代码不够简单。

一些简单的经验:不要重复造轮子;避免过度设计,无论是为了未来不确定的扩展性,还是细微的性能差别。

举个例子,StringUtils中的很多方法,在一个具体的场景中,我们是大概率可以写出一个性能更好的实现的,因为StringUtils中要考虑的很多通用性的问题,在具体的场景中就不需要考虑了。那么,我们是不是要真的这么去做?

DRY 原则(Don’t Repeat Yourself)

这一条本身很简单,不过要注意这里的repeat和单纯代码重复是有区别的。比如两个无关的方法,实现恰好一模一样,这时候就不认为它违反了DRY原则,因为语义上是不重复的。而两个方法,如果方法名到实现都完全不一样,但是是在做一件事,比如isValidIp(), checkIpValid(), 两个方法各自用不同的方法实现了,我们也认为违反了DRY原则。

另外要注意执行重复的问题,比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void run1() {
a();
b();
}

void run2() {
a();
c();
}

main () {
run1();
run2();
}

虽然看起来代码封装的很好,但是执行main方法时,a方法执行了两次。如果a是一个开销很大的方法,就会很有问题。

迪米特法则

Each unit should have only limited knowledge about other units: only units “closely” related to the current unit. Or: Each unit should only talk to its friends; Don’t talk to strangers.

“高内聚、松耦合”是一个比较通用的设计思想,可以用来指导不同粒度代码的设计与开发,比如系统、模块、类、函数。

所谓高内聚,就是指相近的功能应该放到同一个类(模块、系统)中,不相近的功能不要放到同一个类(模块、系统)中。

所谓松耦合是说,在代码中,类(模块、系统)与类(模块、系统)之间的依赖关系简单清晰,只依赖必要的东西。

像qwerty中之前对作业相关逻辑的处理,需要了解每种类型作业的实现细节,就是一种违反了迪米特法则的做法。正确的做法是在作业这一侧定义一个通用的模型,模型中将各类作业的不同逻辑封装好,qwerty则只需要了解这个模型。

对于这些设计原则,不需要花费很多精力来扣它们的字眼。更重要的是,理解它们的思想,理解为什么这些原则能帮助我们写出来「可维护、可扩展、可读、可测、可复用、简洁」的代码。

接下来,我们就可以具体说设计模式了。

常用设计模式

经典的设计模式有 23 种。随着编程语言的演进,一些设计模式(比如 Singleton)也随之过时,甚至成了反模式,一些则被内置在编程语言中(比如 Iterator),另外还有一些新的模式诞生(比如 Monostate)

创建型模式,共五种:工厂方法模式、抽象工厂模式、单例模式、建造者模式、原型模式

结构型模式,共七种:适配器模式、装饰者模式、代理模式、门面模式、桥接模式、组合模式、享元模式。

行为型模式,共十一种:策略模式、模板方法模式、观察者模式、迭代子模式、责任链模式、命令模式、备忘录模式、状态模式、访问者模式、中介者模式、解释器模式。

我们只挑其中一些重要的来讲吧。

简单工厂&工厂方法

比如一个需要根据文件格式来创建不同parser,将配置读取到RuleConfig中的功能,简单工厂模式的实现如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class RuleConfigSource {
public RuleConfig load(String ruleConfigFilePath) {
String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath);
IRuleConfigParser parser = RuleConfigParserFactory.createParser(ruleConfigFileExtension);
if (parser == null) {
throw new InvalidRuleConfigException(
"Rule config file format is not supported: " + ruleConfigFilePath);
}

String configText = "";
//从ruleConfigFilePath文件中读取配置文本到configText中
RuleConfig ruleConfig = parser.parse(configText);
return ruleConfig;
}

private String getFileExtension(String filePath) {
//...解析文件名获取扩展名,比如rule.json,返回json
return "json";
}
}

public class RuleConfigParserFactory {
public static IRuleConfigParser createParser(String configFormat) {
IRuleConfigParser parser = null;
if ("json".equalsIgnoreCase(configFormat)) {
parser = new JsonRuleConfigParser();
} else if ("xml".equalsIgnoreCase(configFormat)) {
parser = new XmlRuleConfigParser();
} else if ("yaml".equalsIgnoreCase(configFormat)) {
parser = new YamlRuleConfigParser();
} else if ("properties".equalsIgnoreCase(configFormat)) {
parser = new PropertiesRuleConfigParser();
}
return parser;
}
}

也就是创建一个工厂类,专门用来「生产」parser。

这里如果就把创建parser的过程,放到load方法里,其实也不是不行。之所以拆出来,就是要把创建这一部分逻辑拆分开。所以,只有当创建逻辑比较复杂的时候才会使用工厂模式。

上边的实现还可以进一步优化,去除大量的if-else。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class RuleConfigSource {
public RuleConfig load(String ruleConfigFilePath) {
String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath);

IRuleConfigParserFactory parserFactory = RuleConfigParserFactoryMap.getParserFactory(ruleConfigFileExtension);
if (parserFactory == null) {
throw new InvalidRuleConfigException("Rule config file format is not supported: " + ruleConfigFilePath);
}
IRuleConfigParser parser = parserFactory.createParser();

String configText = "";
//从ruleConfigFilePath文件中读取配置文本到configText中
RuleConfig ruleConfig = parser.parse(configText);
return ruleConfig;
}

private String getFileExtension(String filePath) {
//...解析文件名获取扩展名,比如rule.json,返回json
return "json";
}
}

//因为工厂类只包含方法,不包含成员变量,完全可以复用,
//不需要每次都创建新的工厂类对象,所以,简单工厂模式的第二种实现思路更加合适。
public class RuleConfigParserFactoryMap { //工厂的工厂
private static final Map<String, IRuleConfigParserFactory> cachedFactories = new HashMap<>();

static {
cachedFactories.put("json", new JsonRuleConfigParserFactory());
cachedFactories.put("xml", new XmlRuleConfigParserFactory());
cachedFactories.put("yaml", new YamlRuleConfigParserFactory());
cachedFactories.put("properties", new PropertiesRuleConfigParserFactory());
}

public static IRuleConfigParserFactory getParserFactory(String type) {
if (type == null || type.isEmpty()) {
return null;
}
IRuleConfigParserFactory parserFactory = cachedFactories.get(type.toLowerCase());
return parserFactory;
}
}

这就是工厂方法模式,工厂方法模式实质上是为工厂类再创建一个简单工厂,也就是工厂的工厂,用来创建工厂类对象。

工厂模式的原理和实现都很简单,在实现时其实还有很多可以探究的细节,但在这里就不细说了。我们探讨的关键还是理解什么场景下应该用这种模式,它解决了什么问题。

对于工厂模式来说,应用的场景就是创建类的逻辑比较复杂时,比如需要有一堆if-else, 动态的决定要创建什么对象;或者虽然创建单个对象,但是创建本身的过程很复杂,调用方无需了解如何去创建。

建造者

建造者模式(Builder), 其实应用非常广泛,我们打开任何一个项目,都可以看到很多XXXBuilder.

建造者模式使用的场景是:一个类有多个可配置项,使用方可以自由选择其中若干个进行配置。

如果采用不同构造方法来实现这种效果,面对的就会说无穷无尽的组合。

而如果采用set方法来配置,那么首先当前类就彻底失去了对配置项的控制,调用方可以随时随地调用set方法。另外,如果需要在配置项被配置好之后进行一些合法性的校验,这种方式也无法实现。

这种情况下的解决方案就是建造者模式。

1
2
3
4
5
6
ResourcePoolConfig config = new ResourcePoolConfig.Builder()
.setName("dbconnectionpool")
.setMaxTotal(16)
.setMaxIdle(10)
.setMinIdle(12)
.build();

实现也比较简单。

工厂模式是用来创建一组相关但不同的的对象,建造者模式则是对同一个对象进行不同方式的定制。

单例

关于单例是什么,怎么实现单例,作为经典的面试题,就不多说了。只说一下单例的问题,为什么单例主键变成了一种反模式。

单例的问题:

隐藏类之间的依赖关系;

同时,依赖的直接就是单例的实现,违反了基于接口而非实现的设计原则;

可测性不好: 无法被mock(Mockito中);全局变量有可能导致测试之间互相影响;

不支持有参数的构造函数。

但是,单例模式的优点是有些场景下使用起来更简洁方便,所以也没必要直接就认定不能使用单例。

代理&装饰器

代理模式和被代理类实现同一个接口,负责在业务代码执行前后附加其他逻辑代码(比如性能监控、计数等),然后用委托的形式调用被代理类完成业务功能,从而实现业务代码和框架代码的解耦。

装饰器模式是通过继承被装饰类,来实现功能的增强。比如java中inputStream系列类,就是装饰器模式的应用。

实现层面,代理模式和装饰器模式几乎一样,但是其意图不同。

代理模式中,代理类附加的是跟原始类无关的功能,而在装饰器模式中,装饰器类附加的是跟原始类相关的增强功能。

还是这个观点,对于设计模式,我们没必要扣字眼深究每种设计模式到底是什么,只要知道有这么一种技巧,可以处理类似的这种场景就可以了。

适配器

适配器模式很简单,我们可以直接看例子。

1
2
3
4
5
6
7
8
9
10
public void debug(String msg) { 
logger.log(FQCN, Level.DEBUG, msg, null);
}

public void debug(String format, Object arg) {
if (logger.isDebugEnabled()) {
FormattingTuple ft = MessageFormatter.format(format, arg);
logger.log(FQCN, Level.DEBUG, ft.getMessage(), ft.getThrowable());
}
}

应用场景包括:

兼容老版本接口;

应对不同的输入;

封装有缺陷的接口设计;

门面

门面模式为子系统提供一组统一的接口,定义一组高层接口让子系统更易用。这里主要是关系到一个接口的粒度应该有多大,从而平衡接口的通用性和易用性。

通常,粒度小的接口通用性肯定会好一些,但这样调用方就需要调用多个方法,必然不太好用。相反,如果接口粒度太大,就会有通用性不足的问题。

门面模式的作用就是解决这个问题。我是做在线教育的,有一个场景是在B端展示各种各样的题目,比如课前的题、课中的题、课后的题,每类中又分很多具体类型,每个类型有不同的数据来源。

这里,就需要权衡接口的通用性和可用性了。而门面模式刚好可以解决我们的问题。首先,我们要定义一个通用的模型,包括题干、参考答案、用户作答等,然后实现一批具体类型的接口。在此之上根据业务场景,封装一些门面层,门面层中只是简单的将子系统的数据组合起来。

策略

定义一族算法类,将每个算法分别封装起来,让它们可以互相替换。

实现方式是定义一个策略接口和一组实现这个接口的策略,运行时可以根据一些条件动态的选择执行哪个策略。

模板方法

模板方法模式在一个方法中定义一个算法骨架,并将某些步骤推迟到子类中实现。模板方法模式可以让子类在不改变算法整体结构的情况下,重新定义算法中的某些步骤。

比如java的AbstractList 的addAll方法就是一个模板方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
public boolean addAll(int index, Collection<? extends E> c) {
rangeCheckForAdd(index);
boolean modified = false;
for (E e : c) {
add(index++, e);
modified = true;
}
return modified;
}

public void add(int index, E element) {
throw new UnsupportedOperationException();
}

观察者

在对象之间定义一个一对多的依赖,当一个对象状态改变的时候,所有依赖的对象都会自动收到通知。也就是发布订阅模式。

观察者模式是一个比较抽象的模式,根据不同的应用场景和需求,有完全不同的实现方式,但是思想很容易理解,所以也就不列代码了。像我们使用mq, 也就是在使用观察者模式。

责任链

将请求的发送和接收解耦,让多个接收对象都有机会处理这个请求。将这些接收对象串成一条链,并沿着这条链传递这个请求,直到链上的某个接收对象能够处理它为止。

实际使用中,责任链模式的一种变体应用也很广泛,就是让链上的每个接收对象都处理一遍这个请求。

比如我们有一个拼装数据的逻辑,就是责任链变体的一个应用。

1
2
3
4
5
6
7
private boolean fillUserDataCollection(Map<Integer, UserDataCollection> userExpandMap, Map<Integer, UserDataCollection> originUserExpandMap) {
return !fillingServices
.stream()
.map(filler -> filler.fillUserDataWithFallback(userExpandMap, originUserExpandMap))
.collect(toSet())
.contains(false);
}

UserDataCollection中包含大量字段,每个fillingService负责其中一部分数据的补充。

责任链的核心思路就是拆分,将大类拆成小类。这也是解决代码复杂度的重要手段之一。

经验总结

对于如何写出好的代码,最关键的是要先建立起心理上的认知,知道什么是好代码,也有写好代码的意愿。在此基础上,要对面向对象的思想和各类设计原则有充分的理解,知道为什么好代码要符合这些原则。

实践上,写出好代码的最佳方式就是持续重构。一开始业务发展不明朗的时候,代码无法设计到最好是很正常的。而随着业务的发展,我们会逐渐意识到代码里存在的问题,这时候可以翻翻设计模式中有没有什么现成的解决方案,然后应用到自己的项目中。

最后,说一下,这篇文章中的很多内容实际上来自极客时间的专栏《设计模式之美》。我觉得这个专栏是讲设计模式最好的一个专栏,推荐大家也可以去看看。点击下方图片购买即可。

design

原文地址: http://lichuanyang.top/posts/58527/

流沙 wechat
订阅公众号