写点什么

实践 GoF 的 23 的设计模式:SOLID 原则(下)

  • 2022 年 3 月 02 日
  • 本文字数:6698 字

    阅读完需:约 22 分钟

本文分享自华为云社区《实践 GoF 的 23 的设计模式:SOLID 原则(下)》,作者:元闰子。


在《实践GoF的23种设计模式:SOLID原则(上)》中,主要讲了 SOLID 原则中的单一职责原则、开闭原则、里氏替换原则,接下来在本文中将继续讲述接口隔离原则和依赖倒置原则。

ISP:接口隔离原则

接口隔离原则(The Interface Segregation Principle,ISP)是关于接口设计的一项原则,这里的“接口”并不单指 Java 或 Go 上使用 interface 声明的狭义接口,而是包含了狭义接口、抽象类、具象类等在内的广义接口。它的定义如下:

Client should not be forced to depend on methods it does not use.

也即,一个模块不应该强迫客户程序依赖它们不想使用的接口,模块间的关系应该建立在最小的接口集上。

下面,我们通过一个例子来详细介绍 ISP。

上图中,Client1、Client2、Client3 都依赖了 Class1,但实际上,Client1 只需使用 Class1.func1 方法,Client2 只需使用 Class1.func2,Client3 只需使用 Class1.func3,那么这时候我们就可以说该设计违反了 ISP。


违反 ISP 主要会带来如下 2 个问题:

  1. 增加模块与客户端程序的依赖,比如在上述例子中,虽然 Client2 和 Client3 都没有调用 func1,但是当 Class1 修改 func1 还是必须通知 Client1~3,因为 Class1 并不知道它们是否使用了 func1。

  2. 产生接口污染,假设开发 Client1 的程序员,在写代码时不小心把 func1 打成了 func2,那么就会带来 Client1 的行为异常。也即 Client1 被 func2 给污染了。


为了解决上述 2 个问题,我们可以把 func1、func2、func3 通过接口隔离开:

接口隔离之后,Client1 只依赖了 Interface1,而 Interface1 上只有 func1 一个方法,也即 Client1 不会受到 func2 和 func3 的污染;另外,当 Class1 修改 func1 之后,它只需通知依赖了 Interface1 的客户端即可,大大降低了模块间耦合。


实现 ISP 的关键是将大接口拆分成小接口,而拆分的关键就是接口粒度的把握。想要拆分得好,就要求接口设计人员对业务场景非常熟悉,对接口使用的场景了如指掌。否则孤立地设计接口,很难满足 ISP。

下面,我们以分布式应用系统 demo 为例,来进一步介绍 ISP 的实现。


一个消息队列模块通常包含生产(produce)和消费(consumer)两种行为,因此我们设计了 Mq 消息队列抽象接口,包含 produce 和 consume 两个方法:

// 消息队列接口public interface Mq {    Message consume(String topic);    void produce(Message message);}
// demo/src/main/java/com/yrunz/designpattern/mq/MemoryMq.java// 当前提供MemoryMq内存消息队列的实现public class MemoryMq implements Mq {...}
复制代码

当前 demo 中使用接口的模块有 2 个,分别是作为消费者的 MemoryMqInput 和作为生产者的 AccessLogSidecar:

public class MemoryMqInput implements InputPlugin {    private String topic;    private Mq mq;    ...    @Override    public Event input() {        Message message = mq.consume(topic);        Map<String, String> header = new HashMap<>();        header.put("topic", topic);        return Event.of(header, message.payload());    }    ...}public class AccessLogSidecar implements Socket {    private final Mq mq;    private final String topic    ...        @Override    public void send(Packet packet) {        if ((packet.payload() instanceof HttpReq)) {            String log = String.format("[%s][SEND_REQ]send http request to %s",                    packet.src(), packet.dest());            Message message = Message.of(topic, log);            mq.produce(message);        }        ...    }    ...}
复制代码

从领域模型上看,Mq 接口的设计确实没有问题,它就应该包含 consume 和 produce 两个方法。但是从客户端程序的角度上看,它却违反了 ISP,对 MemoryMqInput 来说,它只需要 consume 方法;对 AccessLogSidecar 来说,它只需要 produce 方法。


一种设计方案是把 Mq 接口拆分成 2 个子接口 Consumable 和 Producible,让 MemoryMq 直接实现 Consumable 和 Producible:

// demo/src/main/java/com/yrunz/designpattern/mq/Consumable.java// 消费者接口,从消息队列中消费数据public interface Consumable {    Message consume(String topic);}
// demo/src/main/java/com/yrunz/designpattern/mq/Producible.java// 生产者接口,向消息队列生产消费数据public interface Producible { void produce(Message message);}
// 当前提供MemoryMq内存消息队列的实现public class MemoryMq implements Consumable, Producible {...}
复制代码

仔细思考一下,就会发现上面的设计不太符合消息队列的领域模型,因为 Mq 的这个抽象确实应该存在的。

更好的设计应该是保留 Mq 抽象接口,让 Mq 继承自 Consumable 和 Producible,这样的分层设计之后,既能满足 ISP,又能让实现符合消息队列的领域模型:

具体实现如下:

// demo/src/main/java/com/yrunz/designpattern/mq/Mq.java// 消息队列接口,继承了Consumable和Producible,同时又consume和produce两种行为public interface Mq extends Consumable, Producible {}
// 当前提供MemoryMq内存消息队列的实现public class MemoryMq implements Mq {...}
// demo/src/main/java/com/yrunz/designpattern/monitor/input/MemoryMqInput.javapublic class MemoryMqInput implements InputPlugin { private String topic; // 消费者只依赖Consumable接口 private Consumable consumer; ... @Override public Event input() { Message message = consumer.consume(topic); Map<String, String> header = new HashMap<>(); header.put("topic", topic); return Event.of(header, message.payload()); } ...}
// demo/src/main/java/com/yrunz/designpattern/sidecar/AccessLogSidecar.javapublic class AccessLogSidecar implements Socket { // 生产者只依赖Producible接口 private final Producible producer; private final String topic ... @Override public void send(Packet packet) { if ((packet.payload() instanceof HttpReq)) { String log = String.format("[%s][SEND_REQ]send http request to %s", packet.src(), packet.dest()); Message message = Message.of(topic, log); producer.produce(message); } ... } ...}
复制代码

接口隔离可以减少模块间耦合,提升系统稳定性,但是过度地细化和拆分接口,也会导致系统的接口数量的上涨,从而产生更大的维护成本。接口的粒度需要根据具体的业务场景来定,可以参考单一职责原则,将那些为同一类客户端程序提供服务的接口合并在一起

DIP:依赖倒置原则

《Clean Architecture》中介绍 OCP 时有提过:如果要模块 A 免于模块 B 变化的影响,那么就要模块 B 依赖于模块 A。这句话貌似是矛盾的,模块 A 需要使用模块 B 的功能,怎么会让模块 B 反过来依赖模块 A 呢?这就是依赖倒置原则(The Dependency Inversion Principle,DIP)所要解答的问题。

DIP 的定义如下:

  1. High-level modules should not import anything from low-level modules. Both should depend on abstractions.

  2. Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

翻译过来,就是:

  1. 高层模块不应该依赖低层模块,两者都应该依赖抽象

  2. 抽象不应该依赖细节,细节应该依赖抽象


在 DIP 的定义里,出现了高层模块低层模块抽象细节等 4 个关键字,要弄清楚 DIP 的含义,理解者 4 个关键字至关重要。

(1)高层模块和低层模块

一般地,我们认为高层模块是包含了应用程序核心业务逻辑、策略的模块,是整个应用程序的灵魂所在;低层模块通常是一些基础设施,比如数据库、Web 框架等,它们主要为了辅助高层模块完成业务而存在。

(2)抽象和细节

在前文“OCP:开闭原则”一节中,我们可以知道,抽象就是众多细节中的共同点,抽象就是不断忽略细节的出来的。


现在再来看 DIP 的定义,对于第 2 点我们不难理解,从抽象的定义来看,抽象是不会依赖细节的,否则那就不是抽象了;而细节依赖抽象往往都是成立的。


理解 DIP 的关键在于第 1 点,按照我们正向的思维,高层模块要借助低层模块来完成业务,这必然会导致高层模块依赖低层模块。但是在软件领域里,我们可以把这个依赖关系倒置过来,这其中的关键就是抽象。我们可以忽略掉低层模块的细节,抽象出一个稳定的接口,然后让高层模块依赖该接口,同时让低层模块实现该接口,从而实现了依赖关系的倒置:

之所以要把高层模块和底层模块的依赖关系倒置过来,主要是因为作为核心的高层模块不应该受到低层模块变化的影响。高层模块的变化原因应当只能有一个,那就是来自软件用户的业务变更需求

下面,我们通过分布式应用系统 demo 来介绍 DIP 的实现。


对于服务注册中心 Registry 来说,当有新的服务注册上来时,它需要把服务信息(如服务 ID、服务类型等)保存下来,以便在后续的服务发现中能够返回给客户端。因此,Registry 需要一个数据库来辅助它完成业务。刚好,我们的数据库模块实现了一个内存数据库 MemoryDb,于是我们可以这么实现 Registry:

// 服务注册中心public class Registry implements Service {    ...    // 直接依赖MemoryDb    private final MemoryDb db;    private final SvcManagement svcManagement;    private final SvcDiscovery svcDiscovery;
private Registry(...) { ... // 初始化MemoryDb this.db = MemoryDb.instance(); this.svcManagement = new SvcManagement(localIp, this.db, sidecarFactory); this.svcDiscovery = new SvcDiscovery(this.db); } ...}
// 内存数据库public class MemoryDb { private final Map<String, Table<?, ?>> tables; ... // 查询表记录 public <PrimaryKey, Record> Optional<Record> query(String tableName, PrimaryKey primaryKey) { Table<PrimaryKey, Record> table = (Table<PrimaryKey, Record>) tableOf(tableName); return table.query(primaryKey); } // 插入表记录 public <PrimaryKey, Record> void insert(String tableName, PrimaryKey primaryKey, Record record) { Table<PrimaryKey, Record> table = (Table<PrimaryKey, Record>) tableOf(tableName); table.insert(primaryKey, record); } // 更新表记录 public <PrimaryKey, Record> void update(String tableName, PrimaryKey primaryKey, Record record) { Table<PrimaryKey, Record> table = (Table<PrimaryKey, Record>) tableOf(tableName); table.update(primaryKey, record); } // 删除表记录 public <PrimaryKey> void delete(String tableName, PrimaryKey primaryKey) { Table<PrimaryKey, ?> table = (Table<PrimaryKey, ?>) tableOf(tableName); table.delete(primaryKey); } ...}
复制代码

按照上面的设计,模块间的依赖关系是 Registry 依赖于 MemoryDb,也即高层模块依赖于低层模块。这种依赖关系是脆弱的,如果哪天需要把存储服务信息的数据库从 MemoryDb 改成 DiskDb,那么我们也得改 Registry 的代码:

// 服务注册中心public class Registry implements Service {    ...    // 改成依赖DiskDb    private final DiskDb db;    ...    private Registry(...) {        ...        // 初始化DiskDb        this.db = DiskDb.instance();        this.svcManagement = new SvcManagement(localIp, this.db, sidecarFactory);        this.svcDiscovery = new SvcDiscovery(this.db);    }    ...}
复制代码

更好的设计应该是把 Registry 和 MemoryDb 的依赖关系倒置过来,首先我们需要从细节 MemoryDb 抽象出一个稳定的接口 Db:

// demo/src/main/java/com/yrunz/designpattern/db/Db.java// DB抽象接口public interface Db {    <PrimaryKey, Record> Optional<Record> query(String tableName, PrimaryKey primaryKey);    <PrimaryKey, Record> void insert(String tableName, PrimaryKey primaryKey, Record record);    <PrimaryKey, Record> void update(String tableName, PrimaryKey primaryKey, Record record);    <PrimaryKey> void delete(String tableName, PrimaryKey primaryKey);    ...}
复制代码

接着,我们让 Registry 依赖 Db 接口,而 MemoryDb 实现 Db 接口,以此来完成依赖倒置:

// demo/src/main/java/com/yrunz/designpattern/service/registry/Registry.java// 服务注册中心public class Registry implements Service {    ...    // 只依赖于Db抽象接口    private final Db db;    private final SvcManagement svcManagement;    private final SvcDiscovery svcDiscovery;
private Registry(..., Db db) { ... // 依赖注入Db this.db = db; this.svcManagement = new SvcManagement(localIp, this.db, sidecarFactory); this.svcDiscovery = new SvcDiscovery(this.db); } ...}
// demo/src/main/java/com/yrunz/designpattern/db/MemoryDb.java// 内存数据库,实现Db抽象接口public class MemoryDb implements Db { private final Map<String, Table<?, ?>> tables; ... // 查询表记录 @Override public <PrimaryKey, Record> Optional<Record> query(String tableName, PrimaryKey primaryKey) {...} // 插入表记录 @Override public <PrimaryKey, Record> void insert(String tableName, PrimaryKey primaryKey, Record record) {...} // 更新表记录 @Override public <PrimaryKey, Record> void update(String tableName, PrimaryKey primaryKey, Record record) {...} // 删除表记录 @Override public <PrimaryKey> void delete(String tableName, PrimaryKey primaryKey) {...} ...}
// demo/src/main/java/com/yrunz/designpattern/Example.javapublic class Example { // 在main函数中完成依赖注入 public static void main(String[] args) { ... // 将MemoryDb.instance()注入到Registry上 Registry registry = Registry.of(..., MemoryDb.instance()); registry.run(); }}
复制代码

当高层模块依赖抽象接口时,总得在某个时候,某个地方把实现细节(低层模块)注入到高层模块上。在上述例子中,我们选择在 main 函数上,在创建 Registry 对象时,把 MemoryDb 注入进去。

一般地,我们都会在 main/启动函数上完成依赖注入,常见的注入的方式有以下几种:

  • 构造函数注入(Registry 所使用的方法)

  • setter 方法注入

  • 提供依赖注入的接口,客户端直调用该接口即可

  • 通过框架进行注入,比如 Spring 框架中的注解注入能力

另外,DIP 不仅仅适用于模块/类/接口设计,在架构层面也同样适用,比如 DDD 的分层架构和 Uncle Bob 的整洁架构,都是运用了 DIP:

当然,DIP 并不是说高层模块是只能依赖抽象接口,它的本意应该是依赖稳定的接口/抽象类/具象类。如果一个具象类是稳定的,比如 Java 中的 String,那么高层模块依赖它也没有问题;相反,如果一个抽象接口是不稳定的,经常变化,那么高层模块依赖该接口也是违反 DIP 的,这时候应该思考下接口是否抽象合理。

最后

本文花了很长的篇幅讨论了 23 种设计模式背后的核心思想 —— SOLID 原则,它能指导我们设计出高内聚、低耦合的软件系统。但是它毕竟只是原则,如何落地到实际的工程项目上,还是需要参考成功的实践经验。而这些实践经验正是接下来我们要探讨的设计模式


学习设计模式最好的方法就是实践,在《实践 GoF 的 23 种设计模式》后续的文章里,我们将以本文介绍的分布式应用系统demo作为实践示范,介绍 23 种设计模式的程序结构、适用场景、实现方法、优缺点等,让大家对设计模式有个更深入的理解,能够用对不滥用设计模式。

参考

  1. Clean Architecture, Robert C. Martin (“Uncle Bob”)

  2. 敏捷软件开发:原则、模式与实践, Robert C. Martin (“Uncle Bob”)

  3. 使用Go实现GoF的23种设计模式, 元闰子

  4. SOLID原则精解之里氏替换原则LSP, 人民副首席码仔


点击关注,第一时间了解华为云新鲜技术~

发布于: 刚刚阅读数: 2
用户头像

提供全面深入的云计算技术干货 2020.07.14 加入

华为云开发者社区,提供全面深入的云计算前景分析、丰富的技术干货、程序样例,分享华为云前沿资讯动态,方便开发者快速成长与发展,欢迎提问、互动,多方位了解云计算! 传送门:https://bbs.huaweicloud.com/

评论

发布
暂无评论
实践GoF的23的设计模式:SOLID原则(下)_设计模式_华为云开发者社区_InfoQ写作平台