设计模式_ch2

Uncategorized
15k words

二、SOLID 原则

SOLID 原则并非单纯的 1 个原则,而是由 5 个设计原则组成的,它们分别是:单一职责原则、开闭原则、里式替换原则、接口隔离原则和依赖反转原则,依次对应 SOLID 中的 S、O、L、I、D 这 5 个英文字母。

单一职责原则(SRP):A class or module should have a single reponsibility

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

存疑,在项目变复杂的情况下拆分类似乎代价也不小。

可参考的一些原则:

  1. 类中的代码行数、函数或属性过多,会影响代码的可读性和可维护性,我们就需要考虑对类进行拆分;
  2. 类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,我们就需要考虑对类进行拆分;
  3. 私有方法过多,我们就要考虑能否将私有方法独立到新的类中,设置为public方法,供更多的类使用,从而提高代码的复用性;
  4. 比较难给类起一个合适名字,很难用一个业务名词概括,或者只能用一些笼统的 ManagerContext 之类的词语来命名,这就说明类的职责定义得可能不够清晰;
  5. 类中大量的方法都是集中操作类中的某几个属性,比如,在 UserInfo 例子中,如果一半的方法都是在操作 address 信息,那就可以考虑将这几个属性和对应的方法拆分出来。

开闭原则(OCP)

software entities(modules, classes, functions, etc.) should be open for extension , but closed for modification.

开闭原则简单来说就是:添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。

以以下向用户发送警告信息模块代码为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Alert {
private AlertRule rule; //警告规则(自定义)
private Notification notification; //警告通知类

public Alert(AlertRule rule, Notification notification) {
this.rule = rule;
this.notification = notification;
}

public void check(String api, long requestCount, long errorCount, long durationOfSeconds) {
long tps = requestCount / durationOfSeconds;
if (tps > rule.getMatchedRule(api).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
}
}

现在,如果我们需要添加一个功能,当每秒钟接口超时请求个数,超过某个预先设置的最大阈值时,我们也要触发告警发送通知。我们就需要给类中增添计时相关的属性,并且修改check()方法。具体改动如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Alert {
// ...省略AlertRule/Notification属性和构造函数...

// 改动一:添加参数timeoutCount
public void check(String api, long requestCount, long errorCount, long timeoutCount, long durationOfSeconds) {
long tps = requestCount / durationOfSeconds;
if (tps > rule.getMatchedRule(api).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
// 改动二:添加接口超时处理逻辑
long timeoutTps = timeoutCount / durationOfSeconds;
if (timeoutTps > rule.getMatchedRule(api).getMaxTimeoutTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
}
}

上面的代码改动是基于“修改”的方式来实现新功能的。如果我们遵循开闭原则,也就是“对扩展开放、对修改关闭”。我们可以对代码进行重构:

  1. 第一部分是将 check() 函数的多个入参封装成 ApiStatInfo 类;
  2. 第二部分是引入 handler 的概念,将 if 判断逻辑分散在各个 handler 中。

具体实现如下:

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class Alert {
private List<AlertHandler> alertHandlers = new ArrayList<>();

public void addAlertHandler(AlertHandler alertHandler) {
this.alertHandlers.add(alertHandler);
}

public void check(ApiStatInfo apiStatInfo) {
for (AlertHandler handler : alertHandlers) {
handler.check(apiStatInfo);
}
}
}

public class ApiStatInfo {//省略constructor/getter/setter方法
private String api;
private long requestCount;
private long errorCount;
private long durationOfSeconds;
}

public abstract class AlertHandler {
protected AlertRule rule;
protected Notification notification;
public AlertHandler(AlertRule rule, Notification notification) {
this.rule = rule;
this.notification = notification;
}
public abstract void check(ApiStatInfo apiStatInfo);
}

public class TpsAlertHandler extends AlertHandler {
public TpsAlertHandler(AlertRule rule, Notification notification) {
super(rule, notification);
}

@Override
public void check(ApiStatInfo apiStatInfo) {
long tps = apiStatInfo.getRequestCount()/ apiStatInfo.getDurationOfSeconds();
if (tps > rule.getMatchedRule(apiStatInfo.getApi()).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
}
}

public class ErrorAlertHandler extends AlertHandler {
public ErrorAlertHandler(AlertRule rule, Notification notification){
super(rule, notification);
}

@Override
public void check(ApiStatInfo apiStatInfo) {
if (apiStatInfo.getErrorCount() > rule.getMatchedRule(apiStatInfo.getApi()).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
}
}

以上模块的调用过程如下:

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
public class ApplicationContext {
private AlertRule alertRule;
private Notification notification;
private Alert alert;

public void initializeBeans() {
alertRule = new AlertRule(/*.省略参数.*/); //省略一些初始化代码
notification = new Notification(/*.省略参数.*/); //省略一些初始化代码
alert = new Alert();
alert.addAlertHandler(new TpsAlertHandler(alertRule, notification));
alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));
}
public Alert getAlert() { return alert; }

// 饿汉式单例
private static final ApplicationContext instance = new ApplicationContext();
private ApplicationContext() {
initializeBeans();
}
public static ApplicationContext getInstance() {
return instance;
}
}

public class Demo {
public static void main(String[] args) {
ApiStatInfo apiStatInfo = new ApiStatInfo();
// ...省略设置apiStatInfo数据值的代码
ApplicationContext.getInstance().getAlert().check(apiStatInfo);
}
}

核心类关系:

  1. Alert 类包含多个 AlertHandler 对象
  2. AlertHandler 是抽象基类,被 TpsAlertHandlerErrorAlertHandler 继承
  3. AlertHandler 依赖于 AlertRuleNotification

ApiStatInfo中存放各种需要检查的数据,具体的 Handler 类使用 ApiStatInfo 进行数据检查

设计模式:

  1. 策略模式:不同的告警处理逻辑封装在不同的 Handler 子类中
  2. 单例模式: ApplicationContext 使用饿汉式单例
  3. 模板方法模式: AlertHandler 定义了算法框架,子类实现具体检查逻辑

数据流向:

  1. Demo 创建 ApiStatInfo 并设置数据
  2. 通过 ApplicationContext 单例获取 Alert 实例
  3. Alert 遍历所有 Handler 调用 check 方法
  4. Handler 根据规则判断是否需要发送通知

这样重构之后在需要增加新的功能只需要在原有的 ApiStatInfo中增加需要检查的数据以及创建对应的 AlertHandlerAlertRuleNotification 类再将 AlertHandler加入 Alert 中即可,先前的功能不会在之后的维护中进行修改。大大提高了系统的可维护性。

1. 修改代码就意味着违背开闭原则吗?

只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试,我们就可以说,这是一个合格的代码改动。

比如说,在上述的模块中如果增加功能则会增加 ApiStatinfo 中的参数,改变了 ApiStatinfo 类。但是,这种增加不会影响原有的功能模块,不需要对之前的功能进行修改、测试,因此这种修改并不算是违背了开闭原则。

ApplicationContext 类的 initializeBeans() 方法中,往 alert 对象中注册新的 timeoutAlertHandler ;在使用 Alert 类的时候,需要给 check() 函数的入参 apiStatInfo 对象设置 timeoutCount 的值。这部分的改动算是实打实的修改而不能说是扩展,但这种程度的修改是在所难免且是可以被接受的。

因为在重构之后的 Alert 代码中,我们的核心逻辑集中在 Alert 类及其各个 handler 中,当我们在添加新的告警逻辑的时候, Alert 类完全不需要修改,而只需要扩展一个新 handler 类。如果我们把 Alert 类及各个 handler 类合起来看作一个“模块”,那模块本身在添加新的功能的时候,完全满足开闭原则。

2. 如何做到“对扩展开放、修改关闭”?

对于如何做到开闭原则,靠的是理论知识和实战经验,这些需要慢慢学习和积累。

为了尽量写出扩展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识。这些“潜意识”可能比任何开发技巧都重要。

在写代码的时候,我们要多花点时间往前多思考一下,这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够很灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。

在识别出代码可变部分和不可变部分之后,我们要将可变部分封装起来,隔离变化,提供抽象化的不可变接口,给上层系统使用。当具体的实现发生变化的时候,我们只需要基于相同的抽象接口,扩展一个新的实现,替换掉老的实现即可,上游系统的代码几乎不需要修改。

最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程

比如,我们代码中通过 Kafka 来发送异步消息。使用多态和依赖注入向上游提供不变的接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 这一部分体现了抽象意识
public interface MessageQueue { ... }
public class KafkaMessageQueue implements MessageQueue { ... }
public class RocketMQMessageQueue implements MessageQueue {...}

public interface MessageFromatter { ... }
public class JsonMessageFromatter implements MessageFromatter {...}
public class ProtoBufMessageFromatter implements MessageFromatter {...}

public class Demo {
private MessageQueue msgQueue; // 基于接口而非实现编程
public Demo(MessageQueue msgQueue) { // 依赖注入
this.msgQueue = msgQueue;
}

// msgFormatter:多态、依赖注入
public void sendNotification(Notification notification, MessageFormatter msgFormatter) {
//...
}
}

3. 如何在项目中灵活应用开闭原则?

有一句话说得好,“唯一不变的只有变化本身”。即便我们对业务、对系统有足够的了解,那也不可能识别出所有的扩展点,即便你能识别出所有的扩展点,为这些地方都预留扩展点,这样做的成本也是不可接受的。我们没必要为一些遥远的、不一定发生的需求去提前买单,做过度设计。

最合理的做法是,对于一些比较确定的、短期内可能就会扩展,或者需求改动对代码结构影响比较大的情况,或者实现成本不高的扩展点,在编写代码的时候之后,我们就可以事先做些扩展性设计。但对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,我们可以等到有需求驱动的时候,再通过重构代码的方式来支持扩展的需求。

有些情况下,代码的扩展性会跟可读性相冲突。很多时候都需要在扩展性和可读性之间做权衡。(也可以花时间把文档写好)

里式替换(LSP)

If S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program。

中文描述即:子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。

也就是说要求保证父类对象出现的任何地方使用子类对象取替换不改变原来程序的逻辑性以及正确性不会改变。

如下代码中,父类 Transporter 使用 org.apache.http 库中的 HttpClient 类来传输网络数据。子类 SecurityTransporter 继承父类 Transporter ,增加了额外的功能,支持传输 appIdappToken 安全认证信息。

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
43
44
45
public class Transporter {
private HttpClient httpClient;

public Transporter(HttpClient httpClient) {
this.httpClient = httpClient;
}

public Response sendRequest(Request request) {
// ...use httpClient to send request
}
}

public class SecurityTransporter extends Transporter {
private String appId;
private String appToken;

public SecurityTransporter(HttpClient httpClient, String appId, String appToken) {
super(httpClient);
this.appId = appId;
this.appToken = appToken;
}

@Override
public Response sendRequest(Request request) {
if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) {
request.addPayload("app-id", appId);
request.addPayload("app-token", appToken);
}
return super.sendRequest(request);
}
}

public class Demo {
public void demoFunction(Transporter transporter) {
Reuqest request = new Request();
//...省略设置request中数据值的代码...
Response response = transporter.sendRequest(request);
//...省略其他逻辑...
}
}

// 里式替换原则
Demo demo = new Demo();
demo.demofunction(new SecurityTransporter(/*省略参数*/););

如上,在 demo 调用中 demofunction() 传入的参数本该是父类,但在这里将一个子类对象传入也完全没有问题,则这里的子类 SecurityTransporter 设计完全符合里式替换原则.

不过刚刚的代码设计似乎就是简单利用了面向对象的多态特性,里式替换原则跟多态看起来确实有点类似,但实际上它们完全是两回事。

我们还是通过刚才这个例子来解释一下。不过,我们需要对 SecurityTransporter 类中 sendRequest() 函数稍加改造一下。改造前,如果 appId 或者 appToken 没有设置,我们就不做校验;改造后,如果 appId 或者 appToken 没有设置,则直接抛出 NoAuthorizationRuntimeException 未授权异常。改造前后的代码对比如下所示:

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
// 改造前:
public class SecurityTransporter extends Transporter {
//...省略其他代码..
@Override
public Response sendRequest(Request request) {
if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) {
request.addPayload("app-id", appId);
request.addPayload("app-token", appToken);
}
return super.sendRequest(request);
}
}

// 改造后:
public class SecurityTransporter extends Transporter {
//...省略其他代码..
@Override
public Response sendRequest(Request request) {
if (StringUtils.isBlank(appId) || StringUtils.isBlank(appToken)) {
throw new NoAuthorizationRuntimeException(...);
}
request.addPayload("app-id", appId);
request.addPayload("app-token", appToken);
return super.sendRequest(request);
}
}

仅仅是增加了校验,然而父类和子类的行为逻辑已经不相同,此时的代码便不符合里氏替换原则。但这样是否会因为没有校验而产生程序bug呢?我的思路是将校验提到demo之外,这样即可以符合里氏替换,又能满足参数校验。

1. 哪些代码明显违背了 LSP?

里式替换原则还有另外一个更加能落地、更有指导意义的描述,那就是“Design By Contract”,中文翻译就是“按照协议来设计”。

具体来讲就是:子类在设计的时候,要遵守父类的行为约定(或者叫协议)。父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。

以下为几个违反里氏替换原则的例子:

  1. 子类违背父类声明要实现的功能
  2. 子类违背父类对输入、输出、异常的约定(传入参数、返回值、异常抛出,子类对输入的数据的校验不能比父类更加严格)
  3. 子类违背父类注释中所罗列的任何特殊说明

接口隔离原则(ISP)

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

“接口”在软件开发中,我们既可以把它看作一组抽象的约定,也可以具体指系统与系统之间的 API 接口,还可以特指面向对象编程语言中的接口等。在 ISP 原则中,我们可以把“接口”理解为下面三种东西:

一组 API 接口集合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public interface UserService {//只给普通用户
boolean register(String cellphone, String password);
boolean login(String cellphone, String password);
UserInfo getUserInfoById(long id);
UserInfo getUserInfoByCellphone(String cellphone);
}

public interface RestrictedUserService {//只给管理员
boolean deleteUserByCellphone(String cellphone);
boolean deleteUserById(long id);
}

public class UserServiceImpl implements UserService, RestrictedUserService {
// ...省略实现代码...
}

在设计微服务或者类库接口的时候,如果部分接口只被部分调用者使用,那我们可以将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口。

单个 API 接口或函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Statistics {
private Long max;
private Long min;
private Long average;
private Long sum;
private Long percentile99;
private Long percentile999;
//...省略constructor/getter/setter等方法...
}

public Statistics count(Collection<Long> dataSet) {
Statistics statistics = new Statistics();
//...各种指标的计算逻辑...
//...将计算所得赋值给成员变量
return statistics;
}

在上面的代码中,count()函数的功能不够单一,包含很多不同的统计功能,比如,求最大值、最小值、平均值等等。按照接口隔离原则,我们应该把count()函数拆成几个更小粒度的函数,每个函数负责一个独立的统计功能。拆分之后的代码如下所示:

1
2
3
4
5
public Long max(Collection<Long> dataSet) { ... }
public Long min(Collection<Long> dataSet) { ... }
public Long average(Colletion<Long> dataSet) { ... }
// ...省略其他统计函数...

OOP 中的接口概念:

假设我们的项目中用到了三个外部系统: RedisMySQLKafka 。每个系统都对应一系列配置信息,比如地址、端口、访问超时时间等。为了在内存中存储这些配置信息,供项目中的其他模块来使用,我们分别设计实现了三个 Configuration 类: RedisConfigMysqlConfigKafkaConfig 。具体的代码实现如下所示。注意,这里我只给出了 RedisConfig 的代码实现,另外两个都是类似的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class RedisConfig {
private ConfigSource configSource; //配置中心(比如zookeeper)
private String address;
private int timeout;
private int maxTotal;
//省略其他配置: maxWaitMillis,maxIdle,minIdle...

public RedisConfig(ConfigSource configSource) {
this.configSource = configSource;
}

public String getAddress() {
return this.address;
}
//...省略其他get()、init()方法...

public void update() {
//从configSource加载配置到address/timeout/maxTotal...
}
}

public class KafkaConfig { ...省略... }
public class MysqlConfig { ...省略... }

现在,我们有一个新的功能需求,希望支持 RedisKafka 配置信息的热更新。所谓“热更新(hot update)”就是,如果在配置中心中更改了配置信息,我们希望在不用重启系统的情况下,能将最新的配置信息加载到内存中(也就是 RedisConfig、KafkaConfig 类中)。但是,因为某些原因,我们并不希望对 MySQL 的配置信息进行热更新。

为了实现这样一个功能需求,我们设计实现了一个 ScheduledUpdater 类,以固定时间频率(periodInSeconds)来调用 RedisConfigKafkaConfigupdate() 方法更新配置信息。具体的代码实

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
43
44
45
46
47
48
49
50
51
52
53
54
55
public interface Updater {
void update();
}

public class RedisConfig implemets Updater {
//...省略其他属性和方法...
@Override
public void update() { //... }
}

public class KafkaConfig implements Updater {
//...省略其他属性和方法...
@Override
public void update() { //... }
}

public class MysqlConfig { //...省略其他属性和方法... }

public class ScheduledUpdater {
private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();;
private long initialDelayInSeconds;
private long periodInSeconds;
private Updater updater;

public ScheduleUpdater(Updater updater, long initialDelayInSeconds, long periodInSeconds) {
this.updater = updater;
this.initialDelayInSeconds = initialDelayInSeconds;
this.periodInSeconds = periodInSeconds;
}

public void run() {
executor.scheduleAtFixedRate(new Runnable() { //匿名内部类,作为参数
@Override
public void run() {
updater.update();
}
}, this.initialDelayInSeconds, this.periodInSeconds, TimeUnit.SECONDS);
}
}

public class Application {
ConfigSource configSource = new ZookeeperConfigSource(/*省略参数*/);
public static final RedisConfig redisConfig = new RedisConfig(configSource);
public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource);
public static final MySqlConfig mysqlConfig = new MysqlConfig(configSource);

public static void main(String[] args) {
ScheduledUpdater redisConfigUpdater = new ScheduledUpdater(redisConfig, 300, 300);
redisConfigUpdater.run();

ScheduledUpdater kafkaConfigUpdater = new ScheduledUpdater(kafkaConfig, 60, 60);
kafkaConfigUpdater.run();
}
}

Comments