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

摘要:本文以我们日常开发中经常碰到的一些技术/问题/场景作为切入点,示范如何运用设计模式来完成相关的实现。

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

前言

从1995年GoF提出23种设计模式到现在,25年过去了,设计模式依旧是软件领域的热门话题。设计模式通常被定义为:

设计模式(Design Pattern)是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结,使用设计模式是为了可重用代码、让代码更容易被他人理解并且保证代码可靠性。

从定义上看,设计模式其实是一种经验的总结,是针对特定问题的简洁而优雅的解决方案。既然是经验总结,那么学习设计模式最直接的好处就在于可以站在巨人的肩膀上解决软件开发过程中的一些特定问题。

学习设计模式的最高境界是吃透它们本质思想,可以做到即使已经忘掉某个设计模式的名称和结构,也能在解决特定问题时信手拈来。设计模式背后的本质思想,就是我们熟知的SOLID原则。如果把设计模式类比为武侠世界里的武功招式,那么SOLID原则就是内功内力。通常来说,先把内功练好,再来学习招式,会达到事半功倍的效果。因此,在介绍设计模式之前,很有必要先介绍一下SOLID原则。

本文首先会介绍本系列文章中用到的示例代码demo的整体结构,然后开始逐一介绍SOLID原则,也即单一职责原则、开闭原则、里氏替换原则、接口隔离原则和依赖倒置原则。

一个简单的分布式应用系统

本系列示例代码demo获取地址:https://github.com/ruanrunxue/Practice-Design-Pattern--Java-Implementation

示例代码demo工程实现了一个简单的分布式应用系统(单机版),该系统主要由以下几个模块组成:

  • 网络 Network,网络功能模块,模拟实现了报文转发、socket通信、http通信等功能。
  • 数据库 Db,数据库功能模块,模拟实现了表、事务、dsl等功能。
  • 消息队列 Mq,消息队列模块,模拟实现了基于topic的生产者/消费者的消息队列。
  • 监控系统 Monitor,监控系统模块,模拟实现了服务日志的收集、分析、存储等功能。
  • 边车 Sidecar,边车模块,模拟对网络报文进行拦截,实现access log上报、消息流控等功能。
  • 服务 Service,运行服务,当前模拟实现了服务注册中心、在线商城服务集群、服务消息中介等服务。

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

示例代码demo工程的主要目录结构如下:

├── db                # 数据库模块,定义Db、Table、TableVisitor等抽象接口 【@单例模式】
│   ├── cache         # 数据库缓存代理,为Db新增缓存功能 【@代理模式】
│   ├── console       # 数据库控制台实现,支持dsl语句查询和结果显示 【@适配器模式】
│   ├── dsl           # 实现数据库dsl语句查询能力,当前只支持select语句查询 【@解释器模式】
│   ├── exception     # 数据库模块相关异常定义
│   ├── iterator      # 遍历表迭代器,包含按序遍历和随机遍历 【@迭代器模式】
│   └── transaction   # 实现数据库的事务功能,包括执行、提交、回滚等 【@命令模式】【@备忘录模式】
├── monitor        # 监控系统模块,采用插件式的架构风格,当前实现access log日志etl功能
│   ├── config     # 监控系统插件配置模块  【@抽象工厂模式】【@组合模式】
│   │   ├── json   # 实现基于json格式文件的配置加载功能
│   │   └── yaml   # 实现基于yaml格式文件的配置加载功能
│   ├── entity     # 监控系统实体对象定义
│   ├── exception  # 监控系统相关异常
│   ├── filter     # Filter插件的实现定义  【@责任链模式】
│   ├── input      # Input插件的实现定义   【@策略模式】
│   ├── output     # Output插件的实现定义
│   ├── pipeline   # Pipeline插件的实现定义,一个pipeline表示一个ETL处理流程 【@桥接模式】
│   ├── plugin     # 插件抽象接口定义
│   └── schema     # 监控系统相关的数据表定义 
├── mq          # 消息队列模块
├── network        # 网络模块,模拟网络通信,定义了socket、packet等通用类型/接口  【@观察者模式】
│   └── http       # 模拟实现了http通信等服务端、客户端能力
├── service           # 服务模块,定义了服务的基本接口
│   ├── mediator      # 服务消息中介,作为服务通信的中转方,实现了服务发现,消息转发的能力 【@中介者模式】
│   ├── registry      # 服务注册中心,提供服务注册、去注册、更新、 发现、订阅、去订阅、通知等功能
│   │   ├── entity    # 服务注册/发现相关的实体定义 【@原型模式】【@建造者模式】
│   │   └── schema    # 服务注册中心相关的数据表定义 【@访问者模式】【@享元模式】
│   └── shopping      # 模拟在线商城服务群的定义,包含订单服务、库存服务、支付服务、发货服务 【@外观模式】
└── sidecar        # 边车模块,对socket进行拦截,提供http access log、流控功能 【@装饰者模式】【@工厂模式】
    └── flowctrl   # 流控模块,基于消息速率进行随机流控 【@模板方法模式】【@状态模式】

SRP:单一职责原则

单一职责原则(The Single Responsibility Principle,SRP)应该是SOLID原则中,最容易被理解的一个,但同时也是最容易被误解的一个。很多人会把“将大函数重构成一个个职责单一的小函数”这一重构手法等价为SRP,这是不对的,小函数固然体现了职责单一,但这并不是SRP。

SRP传播最广的定义应该是Uncle Bob给出的:

A module should have one, and only one, reason to change.

也即,一个模块应该有且只有一个导致其变化的原因。

这个解释里有2个需要理解的地方:

(1)如何定义一个模块

我们通常会把一个源文件定义为最小粒度的模块。

(2)如何找到这个原因

一个软件的变化往往是为了满足某个用户的需求,那么这个用户就是导致变化的原因。但是,一个模块的用户/客户端程序往往不只一个,比如Java中的ArrayList类,它可能会被成千上万的程序使用,但我们不能说ArrayList职责不单一。因此,我们应该把“一个用户”改为“一类角色”,比如ArrayList的客户端程序都可以归类为“需要链表/数组功能”的角色。

于是,Uncle Bob给出了SRP的另一个解释:

A module should be responsible to one, and only one, actor.

有了这个解释,我们就可以理解函数职责单一并不等同于SRP,比如在一个模块有A和B两个函数,它们都是职责单一的,但是函数A的使用者是A类用户,函数B的使用者是B类用户,而且A类用户和B类用户变化的原因都是不一样的,那么这个模块就不满足SRP了。

下面,以我们的分布式应用系统demo为例进一步探讨。对于Registry类(服务注册中心)来说,它对外提供的基本能力有服务注册、更新、去注册和发现功能,那么,我们可以这么实现:

// demo/src/main/java/com/yrunz/designpattern/service/Registry.java
public class Registry implements Service {
    private final HttpServer httpServer;
    private final Db db;
    ...
    @Override
    public void run() {
        httpServer.put("/api/v1/service-profile", this::register)
                .post("/api/v1/service-profile", this::update)
                .delete("/api/v1/service-profile", this::deregister)
                .get("/api/v1/service-profile", this::discovery)
                .start();
    }
    // 服务注册
    private HttpResp register(HttpReq req) {
      ...
    }
    // 服务更新
    private HttpResp update(HttpReq req) {
      ...
    }
    // 服务去注册
    private HttpResp deregister(HttpReq req) {
      ...
    }
    // 服务发现
    private HttpResp discovery(HttpReq req) {
      ...
    }
}

上述实现中,Registry包含了register、update、deregister、discovery等4个主要方法,正好对应了Registry对外提供的能力,看起来已经是职责单一了。

但是在仔细思考一下就会发现,服务注册、更新和去注册是给专门给服务提供者使用的功能,而服务发现则是专门给服务消费者使用的功能。服务提供者和服务消费者是两类不同的角色,它们产生变化的时间和方向都可能不同。比如:

当前服务发现功能是这么实现的:Registry从满足查询条件的所有ServiceProfile中挑选一个返回给服务消费者(也即Registry自己做了负载均衡)。

假设现在服务消费者提出新的需求:Registry把所有满足查询条件的ServiceProfile都返回,由服务消费者自己来做负载均衡。

为了实现这样的功能,我们就要修改Registry的代码。按理,服务注册、更新、去注册等功能并不应该受到影响,但因为它们和服务发现功能都在同一个模块(Registry)里,于是*也受到影响了,比如可能会代码冲突。

因此,更好的设计是将register、update、deregister内聚到一个服务管理模块SvcManagement,discovery则放到另一个服务发现模块SvcDiscovery,服务注册中心Registry再组合SvcManagement和SvcDiscovery。

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

具体实现如下:

// demo/src/main/java/com/yrunz/designpattern/service/SvcManagement.java
class SvcManagement {
    private final Db db;
    ...
    // 服务注册
    HttpResp register(HttpReq req) {
      ...
    }
    // 服务更新
    HttpResp update(HttpReq req) {
      ...
    }
    // 服务去注册
    HttpResp deregister(HttpReq req) {
      ...
    }
}

// demo/src/main/java/com/yrunz/designpattern/service/SvcDiscovery.java
class SvcDiscovery {
    private final Db db;
    ...
    // 服务发现
    HttpResp discovery(HttpReq req) {
      ...
    }
}

// demo/src/main/java/com/yrunz/designpattern/service/Registry.java
public class Registry implements Service {
    private final HttpServer httpServer;
    private final SvcManagement svcManagement;
    private final SvcDiscovery svcDiscovery;
    ...
    @Override
    public void run() {
        // 使用子模块的方法完成具体业务
        httpServer.put("/api/v1/service-profile", svcManagement::register)
                .post("/api/v1/service-profile", svcManagement::update)
                .delete("/api/v1/service-profile", svcManagement::deregister)
                .get("/api/v1/service-profile", svcDiscovery::discovery)
                .start();
    }
}

除了重复的代码编译,违反SRP还会带来以下2个常见的问题:

1、代码冲突。程序员A修改了模块的A功能,而程序员B在不知情的情况下也在修改该模块的B功能(因为A功能和B功能面向不同的用户,完全可能由2位不同的程序员来维护),当他们同时提交修改时,代码冲突就会发生(修改了同一个源文件)。

2、A功能的修改影响了B功能。如果A功能和B功能都使用了模块里的一个公共函数C,现在A功能有新的需求需要修改函数C,那么如果修改人没有考虑到B功能,那么B功能的原有逻辑就会受到影响。

由此可见,违反SRP会导致软件的可维护性变得极差。但是,我们也不能盲目地进行模块拆分,这样会导致代码过于碎片化,同样也会提升软件的复杂性。比如,在前面的例子中,我们就没有必要再对服务管理模块进行拆分为服务注册模块、服务更新模块和服务去注册模块,一是因为它们面向都用户是一致的;二是在可预见的未来它们要么同时变化,要么都不变。

因此,我们可以得出这样的结论:

  1. 如果一个模块面向的都是同一类用户(变化原因一致),那么就没必要进行拆分。
  2. 如果缺乏用户归类的判断,那么最好的拆分时机是变化发生时。

SRP是聚合和拆分的一个平衡,太过聚合会导致牵一发动全身,拆分过细又会提升复杂性。要从用户的视角来把握拆分的度,把面向不同用户的功能拆分开。如果实在无法判断/预测,那就等变化发生时再拆分,避免过度的设计。

OCP:开闭原则

开闭原则(The Open-Close Principle,OCP)中,“开”指的是对扩展开放,“闭”指的是对修改封闭,它的完整解释为:

A software artifact should be open for extension but closed for modification.

通俗地讲就是,一个软件系统应该具备良好的可扩展性,新增功能应当通过扩展的方式实现,而不是在已有的代码基础上修改。

然而,从字面意思上看,OCP貌似又是自相矛盾的:想要给一个模块新增功能,但是有不能修改它。

*如何才能打破这个困境呢?*关键是抽象!优秀的软件系统总是建立在良好的抽象的基础上,抽象化可以降低软件系统的复杂性。

*那么什么是抽象呢?*抽象不仅存在与软件领域,在我们的生活中也随处可见。下面以《语言学的邀请》中的一个例子来解释抽象的含义:

假设某农庄有一头叫“阿花”的母牛,那么:

1、当把它称为“阿花”时,我们看到的是它独一无二的一些特征:身上有很多斑点花纹、额头上还有一个闪电形状的伤疤。

2、当把它称为母牛时,我们忽略了它的独有特征,看到的是它与母牛“阿黑”,母牛“阿黄”的共同点:是一头牛、雌性的。

3、当把它称为家畜时,我们又忽略了它作为母牛的特征,而是看到了它和猪、鸡、羊一样的特点:是一个动物,在农庄里圈养。

4、当把它称为农庄财产时,我们只关注了它和农庄上其他可售对象的共同点:可以卖钱、转让。

从“阿花”,到母牛,到家畜,再到农庄财产,这就是一个不断抽象化的过程。

从上述例子中,我们可以得出这样的结论:

  1. 抽象就是不断忽略细节,找到事物间共同点的过程。
  2. 抽象是分层的,抽象层次越高,细节也就越少。

在回到软件领域,我们也可以把上述的例子类比到数据库上,数据库的抽象层次从低至高可以是这样的:MySQL 8.0版本 -> MySQL -> 关系型数据库 -> 数据库。现在假设有一个需求,需要业务模块将业务数据保存到数据库上,那么就有以下几种设计方案:

  • 方案一:把业务模块设计为直接依赖MySQL 8.0版本。因为版本总是经常变化的,如果哪天MySQL升级了版本,那么我们就得修改业务模块进行适配,所以方案一违反了OCP。
  • 方案二:把业务模块设计为依赖MySQL。相比于方案一,方案二消除了MySQL版本升级带来的影响。现在考虑另一种场景,如果因为某些原因公司禁止使用MySQL,必须切换到PostgreSQL,这时我们还是得修改业务模块进行数据库的切换适配。因此,在这种场景下,方案二也违反了OCP。
  • 方案三:把业务模块设计为依赖关系型数据库。到了这个方案,我们基本消除了关系型数据库切换的影响,可以随时在MySQL、PostgreSQL、Oracle等关系型数据库上进行切换,而无须修改业务模块。但是,熟悉业务的你预测未来随着用户量的迅速上涨,关系型数据库很有可能无法满足高并发写的业务场景,于是就有了下面的最终方案。
  • 方案四:把业务模块设计为依赖数据库。这样,不管以后使用MySQL还是PostgreSQL,关系型数据库还是非关系型数据库,业务模块都不需要再改动。到这里,我们基本可以认为业务模块是稳定的,不会受到底层数据库变化带来的影响,满足了OCP。

我们可以发现,上述方案的演进过程,就是我们不断对业务依赖的数据库模块进行抽象的过程,最终设计出稳定的、服务OCP的软件。

那么,在编程语言中,我们用什么来表示“数据库”这一抽象呢?是接口

数据库最常见的几个操作就是CRUD,因此我们可以设计这么一个Db接口来表示“数据库”:

public interface Db {
    Record query(String tableName, Condition cond);
    void insert(String tableName, Record record);
    void update(String tableName, Record record);
    void delete(String tableName, Record record);
}

这样,业务模块和数据库模块之间的依赖关系就变成如下图所示:

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

满足OCP的另一个关键点就是分离变化,只有先把变化点识别分离出来,我们才能对它进行抽象化。下面以我们的分布式应用系统demo为例,解释如何实现变化点的分离和抽象。

在demo中,监控系统主要负责对服务的access log进行ETL操作,也即涉及如下3个操作:1)从消息队列中获取日志数据;2)对数据进行加工;3)将加工后的数据存储在数据库上。

我们把整一个日志数据的处理流程称为pipeline,那么我们可以这么实现:

public class Pipeline implements Plugin {
    private Mq mq;
    private Db db;
    ...
    public void run() {
        while (!isClose.get()) {
            // 1、从消息队列中获取数据
            Message msg = mq.consume("monitor.topic");
            String accessLog = msg.payload();

            // 2、对数据进行清理操作,转换为json字符串对格式
            ObjectNode logJson = new ObjectNode(JsonNodeFactory.instance);
            logJson.put("content", accessLog);
            String data = logJson.asText();

            // 3、存储到数据库上
            db.insert("logs_table", logId, data);
        }
    }
    ...
}

现在考虑新上线一个服务,但是这个服务不支持对接消息队列了,只支持socket传输数据,于是我们得在Pipeline上新增一个InputType来判断是否适用socket输入源:

public class Pipeline implements Plugin {
    ...
    public void run() {
        while (!isClose.get()) {
            String accessLog;
            // 使用消息队列为消息来源
            if (inputType == InputType.MQ) {
                Message msg = mq.consume("monitor.topic");
                accessLog = msg.payload();
            }  else {
                // 使用socket为消息来源
                Packet packet = socket.receive();
                accessLog = packet.payload().toString();
            }
           ...
        }
    }
}

过一段时间,有需求需要给access log打上一个时间戳,方便后续的日志分析,于是我们需要修改Pipeline的数据加工逻辑:

public class Pipeline implements Plugin {
    ...
    public void run() {
        while (!isClose.get()) {
            ...
            // 对数据进行清理操作,转换为json字符串对格式
            ObjectNode logJson = new ObjectNode(JsonNodeFactory.instance);
            logJson.put("content", accessLog);
            // 新增一个时间戳字段
            logJson.put("timestamp", Instant.now().getEpochSecond());
            String data = logJson.asText();
           ...
        }
    }
}

很快,又有一个需求,需要将加工后的数据存储到ES上,方便后续的日志检索,于是我们再次修改了Pipeline的数据存储逻辑:

public class Pipeline implements Plugin {
    ...
    public void run() {
        while (!isClose.get()) {
            ...
            // 存储到ES上
            if (outputType == OutputType.DB) {
                db.insert("logs_table", logId, data);
            } else {
            // 存储到ES上
                es.store(logId, data)
            }
        }
    }
}

在上述的pipeline例子中,每次新增需求都需要修改Pipeline模块,明显违反了OCP。下面,我们来对它进行优化,使它满足OCP。

第一步是分离变化点,根据pipeline的业务处理逻辑,我们可以发现3个独立的变化点,数据的获取、加工和存储。第二步,我们对这3个变化点进行抽象,设计出以下3个抽象接口:

// demo/src/main/java/com/yrunz/designpattern/monitor/input/InputPlugin.java
// 数据获取抽象接口
public interface InputPlugin extends Plugin {
    Event input();
    void setContext(Config.Context context);
}

// demo/src/main/java/com/yrunz/designpattern/monitor/filter/FilterPlugin.java
// 数据加工抽象接口
public interface FilterPlugin extends Plugin {
    Event filter(Event event);
}

// demo/src/main/java/com/yrunz/designpattern/monitor/output/OutputPlugin.java
// 数据存储抽象接口
public interface OutputPlugin extends Plugin {
    void output(Event event);
    void setContext(Config.Context context);
}

最后,Pipeline的实现如下,只依赖于InputPlugin、FilterPlugin和OutputPlugin三个抽象接口。后续再有需求变更,只需扩展对应的接口即可,Pipeline无须再变更:

// demo/src/main/java/com/yrunz/designpattern/monitor/pipeline/Pipeline.java
// ETL流程定义
public class Pipeline implements Plugin {
    final InputPlugin input;
    final FilterPlugin filter;
    final OutputPlugin output;
    final AtomicBoolean isClose;

    public Pipeline(InputPlugin input, FilterPlugin filter, OutputPlugin output) {
        this.input = input;
        this.filter = filter;
        this.output = output;
        this.isClose = new AtomicBoolean(false);
    }

    // 运行pipeline
    public void run() {
        while (!isClose.get()) {
            Event event = input.input();
            event = filter.filter(event);
            output.output(event);
        }
    }
    ...
}

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

OCP是软件设计的终极目标,我们都希望能设计出可以新增功能却不用动老代码的软件。但是100%的对修改封闭肯定是做不到的,另外,遵循OCP的代价也是巨大的。它需要软件设计人员能够根据具体的业务场景识别出那些最有可能变化的点,然后分离出去,抽象成稳定的接口。这要求设计人员必须具备丰富的实战经验,以及非常熟悉该领域的业务场景。否则,盲目地分离变化点、过度地抽象,都会导致软件系统变得更加复杂。

LSP:里氏替换原则

上一节介绍中,OCP的一个关键点就是抽象,而如何判断一个抽象是否合理,这是里氏替换原则(The Liskov Substitution Principle,LSP)需要回答的问题。

LSP的最初定义如下:

If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.

简单地讲就是,子类型必须能够替换掉它们的基类型,也即基类中的所有性质,在子类中仍能成立。一个简单的例子:假设有一个函数f,它的入参类型是基类B。同时,基类B有一个派生类D,如果把D的实例传递给函数f,那么函数f的行为功能应该是不变的。

由此可以看出,违反LSP的后果很严重,会导致程序出现不在预期之内的行为错误。下面,我们看一个经典反面例子,矩形与正方形。

假设现在有矩形Rectangle,可以通过setWidth方法设置宽度,setLength方法设置长度,area方法得到矩形面积:

// 矩形定义
public class Rectangle {
    private int width; // 宽度
    private int length; // 长度
    // 设置宽度
    public void setWidth(int width) {
        this.width = width;
    }
    // 设置长度
    public void setLength(int length) {
        this.length = length;
    }
    // 返回矩形面积
    public int area() {
        return width * length;
    }
}

另外,有一个客户端程序Cient,它的方法f以Rectangle作为入参,逻辑为校验矩形的逻辑:

// 客户端程序
public class Client {
    // 校验矩形面积为长*宽
    public void f(Rectangle rectangle) {
        rectangle.setWidth(5);
        rectangle.setLength(4);
        if (rectangle.area() != 20) {
            throw new RuntimeException("rectangle's area is invalid");
        }
        System.out.println("rectangle's area is valid");
    }
}
// 运行程序
public static void main(String[] args) {
      Rectangle rectangle = new Rectangle();
      Client client = new Client();
      client.f(rectangle);
 }
// 运行结果:
// rectangle's area is valid

现在,我们打算新增一种新的类型,正方形Square。因为从数学上看,正方形也是矩形的一种,因此我们让Square继承了Rectangle。另外,正方形要求长宽一致,因此Square重写了setWidth和setLength方法:

// 正方形,长宽相等
public class Square extends Rectangle {
    // 设置宽度
    public void setWidth(int width) {
        this.width = width;
        // 长宽相等,因此同时设置长度
        this.length = width;
    }
    // 设置长度
    public void setLength(int length) {
        this.length = length;
        // 长宽相等,因此同时设置长度
        this.width = length;
    }
}

下面,我们把Square实例化后作为入参传入Cient.f上:

public static void main(String[] args) {
    Square square = new Square();
    Client client = new Client();
    client.f(square);
}
// 运行结果:
// Exception in thread "main" java.lang.RuntimeException: rectangle's area is invalid
//     at com.yrunz.designpattern.service.mediator.Client.f(Client.java:8)
//     at com.yrunz.designpattern.service.mediator.Client.main(Client.java:16)

我们发现Cient.f的行为发生了变化,子类型Square并不能替代基类型Rectangle,违反了LSP。

出现上面的这种违反LSP的设计,主要原因还是我们孤立地进行模型设计,没有从客户端程序的角度来审视该设计是否正确。我们孤立地认为在数学上成立的关系(正方形 IS-A 矩形),在程序中也一定成立,而忽略了客户端程序的使用方法(先设置宽度为5,长度为4,然后校验面积为20)。

这个例子告诉我们:一个模型的正确性或有效性,只能通过客户端程序来体现。

下面,我们总结一下在继承体系(IS-A)下,要想设计出符合LSP的模型所需要遵循的一些约束:

  1. 基类应该设计为一个抽象类(不能直接实例化,只能被继承)。
  2. 子类应该实现基类的抽象接口,而不是重写基类已经实现的具体方法。
  3. 子类可以新增功能,但不能改变基类的功能。
  4. 子类不能新增约束,包括抛出基类没有声明的异常。

前面的矩形和正方形的例子中,几乎把这些约束都打破了,从而导致了程序的异常行为:1)Square的基类Rectangle不是一个抽象类,打破约束1;2)Square重写了基类的setWidth和setLength方法,打破约束2;3)Square新增了Rectangle没有的约束,长宽相等,打破约束4。

除了继承之外,另一个实现抽象的机制是接口。如果我们是面向接口的设计,那么上述的约束1~3其实已经满足了:1)接口本身不具备实例化能力,满足约束1;2)接口没有具体的实现方法(Java中接口的default方法比较例外,本文先不考虑),也就不会被重写,满足约束2;3)接口本身只定义了行为契约,并没有实际的功能,因此也不会被改变,满足约束3。

因此,使用接口替代继承来实现多态和抽象,能够减少很多不经意的错误。但是面向接口设计仍然需要遵循约束4,下面我们以分布式应用系统demo为例,介绍一个比较隐晦地打破约束4,从而违反了LSP的实现。

还是以监控系统为例,为例实现ETL流程的灵活配置,我们需要通过配置文件定义pipeline的流程功能(数据从哪获取、需要经过哪些加工、加工后存储到哪里)。当前需要支持json和yaml两种配置文件格式,以yaml配置为例,配置内容是这样的:

# src/main/resources/pipelines/pipeline_0.yaml
name: pipeline_0 # pipeline名称
type: single_thread # pipeline类型
input: # input插件定义(数据从哪里来)
  name: input_0 # input插件名称
  type: memory_mq # input插件类型
  context: # input插件的初始化上下文
    topic: access_log.topic
filter: # filter插件定义(需要经过哪些加工)
  - name: filter_0 # 加工流程filter_0定义,类型为log_to_json
    type: log_to_json
  - name: filter_1 # 加工流程filter_1定义,类型为add_timestamp
    type: add_timestamp
  - name: filter_2 # 加工流程filter_2定义,类型为json_to_monitor_event
    type: json_to_monitor_event
output: # output插件定义(加工后存储到哪里)
  name: output_0 # output插件名称
  type: memory_db # output插件类型
  context: # output插件的初始化上下文
    tableName: monitor_event_0

首先我们定义一个Config接口来表示“配置”这一抽象:

// demo/src/main/java/com/yrunz/designpattern/monitor/config/Config.java
public interface Config {
    // 从json字符串中加载配置
    void load(String conf);
}

另外,上述配置中的input、filter、output子项,可以认为是InputPlugin、FilterPlugin、OutputPlugin插件的配置项,由Pipeline插件的配置项组合在一起,因此我们定义了如下几个Config的抽象类:

// demo/src/main/java/com/yrunz/designpattern/monitor/config/InputConfig.java
public abstract class InputConfig implements Config {
    protected String name;
    protected InputType type;
    protected Context ctx;
    // 子类实现具体加载逻辑,支持yaml和json的加载方式
    @Override
    public abstract void load(String conf);
    ...
}
// demo/src/main/java/com/yrunz/designpattern/monitor/config/FilterConfig.java
public abstract class FilterConfig implements Config {
    protected List<Item> items;
    // 子类实现具体加载逻辑,支持yaml和json的加载方式
    @Override
    public abstract void load(String conf);
    ...
}
// demo/src/main/java/com/yrunz/designpattern/monitor/config/OutputConfig.java
public abstract class OutputConfig implements Config {
    protected String name;
    protected OutputType type;
    protected Context ctx;
    // 子类实现具体加载逻辑,支持yaml和json的加载方式
    @Override
    abstract public void load(String conf);
    ...
}
// demo/src/main/java/com/yrunz/designpattern/monitor/config/PipelineConfig.java
public abstract class PipelineConfig implements Config {
    protected String name;
    protected PipelineType type;
    protected final InputConfig inputConfig;
    protected final FilterConfig filterConfig;
    protected final OutputConfig outputConfig;
    // 子类实现具体加载逻辑,支持yaml和json的加载方式
    @Override
    public abstract void load(String conf);
}

最后再实现具体的基于json和yaml的子类:

// json方式加载Config子类目录:src/main/java/com/yrunz/designpattern/monitor/config/json
public class JsonInputConfig extends InputConfig  {...}
public class JsonFilterConfig extends FilterConfig  {...}
public class JsonOutputConfig extends OutputConfig  {...}
public class JsonPipelineConfig extends PipelineConfig  {...}
// yaml方式加载Config子类目录:src/main/java/com/yrunz/designpattern/monitor/config/yaml
public class YamlInputConfig extends InputConfig  {...}
public class YamlFilterConfig extends FilterConfig  {...}
public class YamlOutputConfig extends OutputConfig  {...}
public class YamlPipelineConfig extends PipelineConfig  {...}

因为涉及到从配置到对象的实例化过程,自然会想到使用***工厂模式***来创建对象。另外因为Pipeline、InputPlugin、FilterPlugin和OutputPlugin都实现了Plugin接口,我们也很容易想到定义一个PluginFactory接口来表示“插件工厂”这一抽象,具体的插件工厂再实现该接口:

// 插件工厂接口,根据配置实例化插件
public interface PluginFactory {
    Plugin create(Config config);
}
// input插件工厂
public class InputPluginFactory implements PluginFactory {
    ...
    @Override
    public InputPlugin create(Config config) {
        InputConfig conf = (InputConfig) config;
        try {
            Class<?> inputClass = Class.forName(conf.type().classPath());
            InputPlugin input = (InputPlugin) inputClass.getConstructor().newInstance();
            input.setContext(conf.context());
            return input;
        } ...
    }
}
// filter插件工厂
public class FilterPluginFactory implements PluginFactory {
    ...
    @Override
    public FilterPlugin create(Config config) {
        FilterConfig conf = (FilterConfig) config;
        FilterChain filterChain = FilterChain.empty();
        String name = "";
        try {
            for (FilterConfig.Item item : conf.items()) {
                name = item.name();
                Class<?> filterClass = Class.forName(item.type().classPath());
                FilterPlugin filter = (FilterPlugin) filterClass.getConstructor().newInstance();
                filterChain.add(filter);
            }
        } ...
    }
}
// output插件工厂
public class OutputPluginFactory implements PluginFactory {
    ...
    @Override
    public OutputPlugin create(Config config) {
        OutputConfig conf = (OutputConfig) config;
        try {
            Class<?> outputClass = Class.forName(conf.type().classPath());
            OutputPlugin output = (OutputPlugin) outputClass.getConstructor().newInstance();
            output.setContext(conf.context());
            return output;
        } ...
    }
}
// pipeline插件工厂
public class PipelineFactory implements PluginFactory {
    ...
    @Override
    public Pipeline create(Config config) {
        PipelineConfig conf = (PipelineConfig) config;
        InputPlugin input = InputPluginFactory.newInstance().create(conf.input());
        FilterPlugin filter = FilterPluginFactory.newInstance().create(conf.filter());
        OutputPlugin output = OutputPluginFactory.newInstance().create(conf.output());
        ...
    }
}

最后,通过PipelineFactory来实创建Pipline对象:

Config config = YamlPipelineConfig.of(YamlInputConfig.empty(), YamlFilterConfig.empty(), YamlOutputConfig.empty());
config.load(Files.readAllBytes("pipeline_0.yaml"));
Pipeline pipeline = PipelineFactory.newInstance().create(config);
assertNotNull(pipeline);
// 运行结果:
Pass

到目前为止,上述的设计看起来是合理的,运行也没有问题。

但是,细心的读者可能会发现,每个插件工厂子类的create方法的第一行代码都是一个转型语句,比如PipelineFactory的是PipelineConfig conf = (PipelineConfig) config;。所以,上一段代码能够正常运行的前提是:传入PipelineFactory.create方法的入参必须是PipelineConfig 。如果客户端程序传入InputConfig的实例,PipelineFactory.create方法将会抛出转型失败的异常。

上述这个例子就是一个违反LSP的典型场景,虽然在约定好的前提下,程序可以运行正确,但是如果有客户端不小心破坏了这个约定,就会带来程序行为异常(我们永远无法预知客户端的所有行为)。

要纠正这个问题也很简单,就是去掉PluginFactory这一层抽象,让PipelineFactory.create等工厂方法的入参声明为具体的配置类,比如PipelineFactory可以这么实现:

// demo/src/main/java/com/yrunz/designpattern/monitor/pipeline/PipelineFactory.java
// pipeline插件工厂,不在实现PluginFactory接口
public class PipelineFactory {
    ...
    // 工厂方法入参为PipelineConfig实现类,消除转型
    public Pipeline create(PipelineConfig config) {
        InputPlugin input = InputPluginFactory.newInstance().create(config.input());
        FilterPlugin filter = FilterPluginFactory.newInstance().create(config.filter());
        OutputPlugin output = OutputPluginFactory.newInstance().create(config.output());
        ...
    }
}

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

从上述几个例子中,我们可以看出遵循LSP的重要性,而设计出符合LSP的软件的要点就是,根据该软件的使用者行为作出的合理假设,以此来审视它是否具备有效性和正确性。

 

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

上一篇:Structured data representation of python


下一篇:使用边框和背景