设计模式-02.经典设计原则-第一节-单一职责原则,开闭原则,里式替换,接口隔离

文章目录

设计模式经典设计原则-第一节

单一职责原则(SRP)

如何理解单一职责原则?

如何判断类的职责是否足够单一?

类的职责是否设计得越单一越好?

开闭原则(OCP)

如何理解“对扩展开放、修改关闭”?

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

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

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

里式替换原则(LSP)

如何理解“里式替换原则”?

哪些代码明显违背了 LSP?

接口隔离原则(ISP)

如何理解“接口隔离原则”?

把“接口”理解为一组 API 接口集合

把“接口”理解为单个 API 接口或函数

把“接口”理解为 OOP 中的接口概念

文章优先发布在Github,其它平台会晚一段时间,文章纠错与更新内容只在Github:https://github.com/youthlql/JavaYouth

转载须知:转载请注明GitHub出处,让我们一起维护一个良好的技术创作环境。

如果你要提交 issue 或者 pr 的话建议到 Github 提交。笔者会陆续更新,如果对你有所帮助,不妨Github点个Star~。你的Star是我创作的动力。

       


1、本系列博客,主要是学习笔记,参考极客时间设计模式,和尚硅谷设计模式


设计模式经典设计原则-第一节

经典设计原则包括,SOLID、KISS、YAGNI、DRY、LOD 等。这些设计原则,从字面上理解,都不难。

你一看就感觉懂了,一看就感觉掌握了,但真的用到项目中的时候,

你会发现,“看懂”和“会用”是两回事,而“用好”更是难上加难。

从我之前的工作经历来看,很多同事因为对这些原则理解得不够透彻,

导致在使用的时候过于教条主义,拿原则当真理,生搬硬套,适得其反。


单一职责原则(SRP)

文章的开头我们提到了 SOLID 原则,实际上,SOLID 原则并非单纯的 1 个原则,

而是由 5 个设计原则组成的,它们分别是:单一职责原则、开闭原则、

里式替换原则、接口隔离原则和依赖反转原则,

依次对应 SOLID 中的 S、O、L、I、D 这 5 个英文字母。

我们今天要学习的是 SOLID 原则中的第一个原则:单一职责原则。


如何理解单一职责原则?

单一职责原则的英文是 Single Responsibility Principle,缩写为 SRP。

这个原则的英文描述是这样的:A class or module should have a single responsibility。

如果我们把它翻译成中文,那就是:一个类或者模块只负责完成一个职责(或者功能)。


注意,这个原则描述的对象包含两个,一个是类(class),一个是模块(module)。

关于这两个概念,有两种理解方式。

一种理解是:把模块看作比类更加抽象的概念,类也可以看作模块。

另一种理解是:把模块看作比类更加粗粒度的代码块,模块中包含多个类,多个类组成一个模块。


不管哪种理解方式,单一职责原则在应用到这两个描述对象的时候,道理都是相通的。

为了方便你理解,接下来我只从“类”设计的角度,来讲解如何应用这个设计原则。

对于“模块”来说,你可以自行引申。

单一职责原则的定义描述非常简单,也不难理解。

一个类只负责完成一个职责或者功能。也就是说,不要设计大而全的类,

要设计粒度小、功能单一的类。换个角度来讲就是,一个类包含了两个或者两个以上业务不相干的功能,

那我们就说它职责不够单一,应该将它拆分成多个功能更加单一、粒度更细的类。


我举一个例子来解释一下。比如,一个类里既包含订单的一些操作,又包含用户的一些操作。

而订单和用户是两个独立的业务领域模型,我们将两个不相干的功能放到同一个类中,那就违反了单一职责原则。

为了满足单一职责原则,我们需要将这个类拆分成两个粒度更细、功能更加单一的两个类:订单类和用户类。


如何判断类的职责是否足够单一?

从刚刚这个例子来看,单一职责原则看似不难应用。

那是因为我举的这个例子比较极端,一眼就能看出订单和用户毫不相干。

但大部分情况下,类里的方法是归为同一类功能,还是归为不相关的两类功能,并不是那么容易判定的。

在真实的软件开发中,对于一个类是否职责单一的判定,是很难拿捏的。

我举一个更加贴近实际的例子来给你解释一下。

在一个社交产品中,我们用下面的 UserInfo 类来记录用户的信息。

你觉得,UserInfo 类的设计是否满足单一职责原则呢?


public class UserInfo {

  private long userId;

  private String username;

  private String email;

  private String telephone;

  private long createTime;

  private long lastLoginTime;

  private String avatarUrl;

  private String provinceOfAddress; // 省

  private String cityOfAddress; // 市

  private String regionOfAddress; // 区 

  private String detailedAddress; // 详细地址

  // ...省略其他属性和方法...

}



对于这个问题,有两种不同的观点。

一种观点是,UserInfo 类包含的都是跟用户相关的信息,所有的属性和方法都隶属于用户这样一个业务模型,

满足单一职责原则;


另一种观点是,地址信息在 UserInfo 类中,所占的比重比较高,

可以继续拆分成独立的 UserAddress 类,UserInfo 只保留除 Address 之外的其他信息,

拆分之后的两个类的职责更加单一。


哪种观点更对呢?

实际上,要从中做出选择,我们不能脱离具体的应用场景。

如果在这个社交产品中,用户的地址信息跟其他信息一样,只是单纯地用来展示,那 UserInfo 现在的设计就是合理的。

但是,如果这个社交产品发展得比较好,之后又在产品中添加了电商的模块,用户的地址信息还会用在电商物流中,

那我们最好将地址信息从 UserInfo 中拆分出来,独立成用户物流信息(或者叫地址信息、收货信息等)。


我们再进一步延伸一下。如果做这个社交产品的公司发展得越来越好,公司内部又开发出了很多其他产品(可以理解为其他 App)。

公司希望支持统一账号系统,也就是用户一个账号可以在公司内部的所有产品中登录。

这个时候,我们就需要继续对 UserInfo 进行拆分,将跟身份认证相关的信息(比如,email、telephone 等)抽取成独立的类。


从刚刚这个例子,我们可以总结出,不同的应用场景、不同阶段的需求背景下,对同一个类的职责是否单一的判定,

可能都是不一样的。在某种应用场景或者当下的需求背景下,一个类的设计可能已经满足单一职责原则了,

但如果换个应用场景或着在未来的某个需求背景下,可能就不满足了,需要继续拆分成粒度更细的类。


除此之外,从不同的业务层面去看待同一个类的设计,对类是否职责单一,也会有不同的认识。

比如,例子中的 UserInfo 类。如果我们从“用户”这个业务层面来看,

UserInfo 包含的信息都属于用户,满足职责单一原则。如果我们从更加细分的“用户展示信息”

“地址信息”“登录认证信息”等等这些更细粒度的业务层面来看,那 UserInfo 就应该继续拆分。


综上所述,评价一个类的职责是否足够单一,我们并没有一个非常明确的、可以量化的标准,可以说,

这是件非常主观、仁者见仁智者见智的事情。

实际上,在真正的软件开发中,我们也没必要过于未雨绸缪,过度设计。

所以,我们可以先写一个粗粒度的类,满足业务需求。

随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多,这个时候,我们就可以将这个粗粒度的类,

拆分成几个更细粒度的类。这就是所谓的持续重构


听到这里,你可能会说,这个原则如此含糊不清、模棱两可,到底该如何拿捏才好啊?我这里还有一些小技巧,

能够很好地帮你,从侧面上判定一个类的职责是否够单一。

而且,我个人觉得,下面这几条判断原则,比起很主观地去思考类是否职责单一,要更有指导意义、更具有可执行性:


类中的代码行数、函数或属性过多,会影响代码的可读性和可维护性,我们就需要考虑对类进行拆分;

类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,我们就需要考虑对类进行拆分;

私有方法过多,我们就要考虑能否将私有方法独立到新的类中,设置为 public 方法,供更多的类使用,从而提高代码的复用性;

比较难给类起一个合适名字,很难用一个业务名词概括,或者只能用一些笼统的 Manager、Context 之类的词语来命名,

这就说明类的职责定义得可能不够清晰;


类中大量的方法都是集中操作类中的某几个属性,比如,在 UserInfo 例子中,如果一半的方法都是在操作 address 信息,

那就可以考虑将这几个属性和对应的方法拆分出来。

不过,你可能还会有这样的疑问:在上面的判定原则中,我提到类中的代码行数、函数或者属性过多,就有可能不满足单一职责原则。

那多少行代码才算是行数过多呢?多少个函数、属性才称得上过多呢?

实际上,也可以给你一个凑活能用、比较宽泛的、可量化的标准,

那就是一个类的代码行数最好不能超过 200 行,

函数个数及属性个数都最好不要超过 10 个。


实际上, 从另一个角度来看,当一个类的代码,读起来让你头大了,实现某个功能时不知道该用哪个函数了,

想用哪个函数翻半天都找不到了,只用到一个小功能要引入整个类(类中包含很多无关此功能实现的函数)的时候,

这就说明类的行数、函数、属性过多了。

实际上,等你做多项目了,代码写多了,在开发中慢慢“品尝”,

自然就知道什么是“放盐少许”了,这就是所谓的“专业第六感”。


类的职责是否设计得越单一越好?

为了满足单一职责原则,是不是把类拆得越细就越好呢?答案是否定的。

我们还是通过一个例子来解释一下。

Serialization 类实现了一个简单协议的序列化和反序列功能,具体代码如下:


/**

 * Protocol format: identifier-string;{gson string}

 * For example: UEUEUE;{"a":"A","b":"B"}

 */

public class Serialization {

  private static final String IDENTIFIER_STRING = "UEUEUE;";

  private Gson gson;

  

  public Serialization() {

    this.gson = new Gson();

  }

  

  public String serialize(Map<String, String> object) {

    StringBuilder textBuilder = new StringBuilder();

    textBuilder.append(IDENTIFIER_STRING);

    textBuilder.append(gson.toJson(object));

    return textBuilder.toString();

  }

  

  public Map<String, String> deserialize(String text) {

    if (!text.startsWith(IDENTIFIER_STRING)) {

        return Collections.emptyMap();

    }

    String gsonStr = text.substring(IDENTIFIER_STRING.length());

    return gson.fromJson(gsonStr, Map.class);

  }

}


如果我们想让类的职责更加单一,我们对 Serialization 类进一步拆分,

拆分成一个只负责序列化工作的 Serializer 类和另一个只负责反序列化工作的 Deserializer 类。

拆分后的具体代码如下所示:


public class Serializer {

  private static final String IDENTIFIER_STRING = "UEUEUE;";

  private Gson gson;

  

  public Serializer() {

    this.gson = new Gson();

  }

  

  public String serialize(Map<String, String> object) {

    StringBuilder textBuilder = new StringBuilder();

    textBuilder.append(IDENTIFIER_STRING);

    textBuilder.append(gson.toJson(object));

    return textBuilder.toString();

  }

}


public class Deserializer {

  private static final String IDENTIFIER_STRING = "UEUEUE;";

  private Gson gson;

  

  public Deserializer() {

    this.gson = new Gson();

  }

  

  public Map<String, String> deserialize(String text) {

    if (!text.startsWith(IDENTIFIER_STRING)) {

        return Collections.emptyMap();

    }

    String gsonStr = text.substring(IDENTIFIER_STRING.length());

    return gson.fromJson(gsonStr, Map.class);

  }

}


虽然经过拆分之后,Serializer 类和 Deserializer 类的职责更加单一了,但也随之带来了新的问题。

如果我们修改了协议的格式,数据标识从“UEUEUE”改为“DFDFDF”,

或者序列化方式从 JSON 改为了 XML,那 Serializer 类和 Deserializer 类都需要做相应的修改,

代码的内聚性显然没有原来 Serialization 高了。

而且,如果我们仅仅对 Serializer 类做了协议修改,而忘记了修改 Deserializer 类的代码,那就会导致序列化、反序列化不匹配,

程序运行出错,也就是说,拆分之后,代码的可维护性变差了。


实际上,不管是应用设计原则还是设计模式,最终的目的还是提高代码的可读性、可扩展性、复用性、可维护性等。

我们在考虑应用某一个设计原则是否合理的时候,也可以以此作为最终的考量标准。


开闭原则(OCP)

开闭原则是 SOLID 中最难理解、最难掌握,同时也是最有用的一条原则。

之所以说这条原则难理解,那是因为,“怎样的代码改动才被定义为‘扩展’?

怎样的代码改动才被定义为‘修改’?怎么才算满足或违反‘开闭原则’?修改代码就一定意味着违反‘开闭原则’吗?”

等等这些问题,都比较难理解。

之所以说这条原则难掌握,那是因为,“如何做到‘对扩展开放、修改关闭’?

如何在项目中灵活地应用‘开闭原则’,以避免在追求扩展性的同时影响到代码的可读性?”

等等这些问题,都比较难掌握。

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

在 23 种经典设计模式中,大部分设计模式都是为了解决代码的扩展性问题而存在的,

主要遵从的设计原则就是开闭原则。


如何理解“对扩展开放、修改关闭”?

开闭原则的英文全称是 Open Closed Principle,简写为 OCP。

它的英文描述是:software entities (modules, classes, functions, etc.) 

should be open for extension , but closed for modification。

我们把它翻译成中文就是:软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。

这个描述比较简略,如果我们详细表述一下,那就是,添加一个新的功能应该是,

在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。


为了让你更好地理解这个原则,我举一个例子来进一步解释一下。

这是一段 API 接口监控告警的代码。其中,AlertRule 存储告警规则,可以自由设置。Notification 是告警通知类,

支持邮件、短信、微信、手机等多种通知渠道。NotificationEmergencyLevel 表示通知的紧急程度,

包括 SEVERE(严重)、URGENCY(紧急)、NORMAL(普通)、TRIVIAL(无关紧要),

不同的紧急程度对应不同的发送渠道。


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() 函数中。当接口的 TPS 超过某个预先设置的最大值时,

以及当接口请求出错数大于某个最大允许值时,就会触发告警,通知接口的相关负责人或者团队。


现在,如果我们需要添加一个功能,当每秒钟接口超时请求个数,超过某个预先设置的最大阈值时,

我们也要触发告警发送通知。这个时候,我们该如何改动代码呢?主要的改动有两处:

第一处是修改 check() 函数的入参,添加一个新的统计数据 timeoutCount,表示超时接口请求数;

第二处是在 check() 函数中添加新的告警逻辑。具体的代码改动如下所示:


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, "...");

    }

  }

}


这样的代码修改实际上存在挺多问题的。一方面,我们对接口进行了修改,

这就意味着调用这个接口的代码都要做相应的修改。

另一方面,修改了 check() 函数,相应的单元测试都需要修改


上面的代码改动是基于“修改”的方式来实现新功能的。如果我们遵循开闭原则,

也就是“对扩展开放、对修改关闭”。那如何通过“扩展”的方式,来实现同样的功能呢?


我们先重构一下之前的 Alert 代码,让它的扩展性更好一些。重构的内容主要包含两部分:

第一部分是将 check() 函数的多个入参封装成 ApiStatInfo 类;

第二部分是引入 handler 的概念,将 if 判断逻辑分散在各个 handler 中。具体的代码实现如下所示:


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, "...");

    }

  }

}


上面的代码是对 Alert 的重构,我们再来看下,重构之后的 Alert 该如何使用呢?

具体的使用代码我也写在这里了。


其中,ApplicationContext 是一个单例类,负责 Alert 的创建、

组装(alertRule 和 notification 的依赖注入)、初始化(添加 handlers)工作。


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);

  }

}


现在,我们再来看下,基于重构之后的代码,如果再添加上面讲到的那个新功能,

每秒钟接口超时请求个数超过某个最大阈值就告警,我们又该如何改动代码呢?

主要的改动有下面四处。


第一处改动是:在 ApiStatInfo 类中添加新的属性 timeoutCount。

第二处改动是:添加新的 TimeoutAlertHander 类。

第三处改动是:在 ApplicationContext 类的 initializeBeans() 方法中,

往 alert 对象中注册新的 timeoutAlertHandler。

第四处改动是:在使用 Alert 类的时候,需要给 check() 函数的

入参 apiStatInfo 对象设置 timeoutCount 的值。

改动之后的代码如下所示:


public class Alert { // 代码未改动... }

public class ApiStatInfo {//省略constructor/getter/setter方法

  private String api;

  private long requestCount;

  private long errorCount;

  private long durationOfSeconds;

  private long timeoutCount; // 改动一:添加新字段

}

public abstract class AlertHandler { //代码未改动... }

public class TpsAlertHandler extends AlertHandler {//代码未改动...}

public class ErrorAlertHandler extends AlertHandler {//代码未改动...}

// 改动二:添加新的handler

public class TimeoutAlertHandler extends AlertHandler {//省略代码...}


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));

    // 改动三:注册handler

    alert.addAlertHandler(new TimeoutAlertHandler(alertRule, notification));

  }

  //...省略其他未改动代码...

}


public class Demo {

  public static void main(String[] args) {

    ApiStatInfo apiStatInfo = new ApiStatInfo();

    // ...省略apiStatInfo的set字段代码

    apiStatInfo.setTimeoutCount(289); // 改动四:设置tiemoutCount值

    ApplicationContext.getInstance().getAlert().check(apiStatInfo);

}


重构之后的代码更加灵活和易扩展。如果我们要想添加新的告警逻辑,

只需要基于扩展的方式创建新的 handler 类即可,

不需要改动原来的 check() 函数的逻辑。而且,我们只需要为新的 handler 类添加单元测试,

老的单元测试都不会失败,也不用修改。


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

看了上面重构之后的代码,你可能还会有疑问:在添加新的告警逻辑的时候,

尽管改动二(添加新的 handler 类)是基于扩展而非修改的方式来完成的,

但改动一、三、四貌似不是基于扩展而是基于修改的方式来完成的,

那改动一、三、四不就违背了开闭原则吗?


我们先来分析一下改动一:往 ApiStatInfo 类中添加新的属性 timeoutCount。


实际上,我们不仅往 ApiStatInfo 类中添加了属性,还添加了对应的 getter/setter 方法。

那这个问题就转化为:给类中添加新的属性和方法,算作“修改”还是“扩展”?

我们再一块回忆一下开闭原则的定义:

软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。


从定义中,我们可以看出,开闭原则可以应用在不同粒度的代码中,

可以是模块,也可以类,还可以是方法(及其属性)。

同样一个代码改动,在粗代码粒度下,被认定为“修改”,

在细代码粒度下,又可以被认定为“扩展”。

比如,改动一,添加属性和方法相当于修改类,在类这个层面,

这个代码改动可以被认定为“修改”;

但这个代码改动并没有修改已有的属性和方法,在方法(及其属性)这一层面,它又可以被认定为“扩展”。

实际上,我们也没必要纠结某个代码改动是“修改”还是“扩展”,更没必要太纠结它是否违反“开闭原则”。

我们回到这条原则的设计初衷:只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试,

我们就可以说,这是一个合格的代码改动。

我们再来分析一下改动三和改动四:在 ApplicationContext 类的 initializeBeans() 方法中,

往 alert 对象中注册新的 timeoutAlertHandler;在使用 Alert 类的时候,

需要给 check() 函数的入参 apiStatInfo 对象设置 timeoutCount 的值。


这两处改动都是在方法内部进行的,不管从哪个层面(模块、类、方法)来讲,都不能算是“扩展”,

而是地地道道的“修改”。不过,有些修改是在所难免的,是可以被接受的。

为什么这么说呢?我来解释一下。

在重构之后的 Alert 代码中,我们的核心逻辑集中在 Alert 类及其各个 handler 中,

当我们在添加新的告警逻辑的时候,Alert 类完全不需要修改,而只需要扩展一个新 handler 类。

如果我们把 Alert 类及各个 handler 类合起来看作一个“模块”,

那模块本身在添加新的功能的时候,完全满足开闭原则。


而且,我们要认识到,添加一个新功能,不可能任何模块、类、方法的代码都不“修改”,

这个是做不到的。

类需要创建、组装、并且做一些初始化操作,才能构建成可运行的的程序,

这部分代码的修改是在所难免的。

我们要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、

最复杂的那部分逻辑代码满足开闭原则。


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

在刚刚的例子中,我们通过引入一组 handler 的方式来实现支持开闭原则。

如果你没有太多复杂代码的设计和开发经验,

你可能会有这样的疑问:这样的代码设计思路我怎么想不到呢?你是怎么想到的呢?

先给你个结论,之所以能想到,靠的就是理论知识和实战经验,

这些需要你慢慢学习和积累。

对于如何做到“对扩展开放、修改关闭”,

我们也有一些指导思想和具体的方法论,我们一块来看一下。

实际上,开闭原则讲的就是代码的扩展性问题,是判断一段代码是否易扩展的“金标准”。

如果某段代码在应对未来需求变化的时候,

能够做到“对扩展开放、对修改关闭”,那就说明这段代码的扩展性比较好。

所以,问如何才能做到“对扩展开放、对修改关闭”,

也就粗略地等同于在问,如何才能写出扩展性好的代码。

在讲具体的方法论之前,我们先来看一些更加偏向顶层的指导思想。

为了尽量写出扩展性好的代码,

我们要时刻具备扩展意识、抽象意识、封装意识。

这些“潜意识”可能比任何开发技巧都重要。


在写代码的时候后,我们要多花点时间往前多思考一下,

这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,

以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,

新的代码能够很灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。

还有,在识别出代码可变部分和不可变部分之后,

我们要将可变部分封装起来,隔离变化,提供抽象化的不可变接口,

给上层系统使用。当具体的实现发生变化的时候,我们只需要基于相同的抽象接口,

扩展一个新的实现,替换掉老的实现即可,上游系统的代码几乎不需要修改。

刚刚我们讲了实现开闭原则的一些偏向顶层的指导思想,现在我们再来看下,

支持开闭原则的一些更加具体的方法论。


我们前面讲到,代码的扩展性是代码质量评判的最重要的标准之一。

实际上,设计模式的大部分知识点都是围绕扩展性问题来讲解的。

讲到的很多设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的的。

特别是 23 种经典设计模式,

大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则的。


在众多的设计原则、思想、模式中,

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

以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态等)。

设计模式这一部分内容比较多,后面我们会详细讲到,这里就不展开了。

今天我重点讲一下,如何利用多态、依赖注入、基于接口而非实现编程,

来实现“对扩展开放、对修改关闭”。


实际上,多态、依赖注入、基于接口而非实现编程,以及前面提到的抽象意识,

说的都是同一种设计思路,

只是从不同的角度、不同的层面来阐述而已。

这也体现了“很多设计原则、思想、模式都是相通的”这一思想。


接下来,我就通过一个例子来解释一下,

如何利用这几个设计思想或原则来实现“对扩展开放、对修改关闭”。

注意,依赖注入后面会讲到,如果你对这块不了解,

可以暂时先忽略这个概念,只关注多态、基于接口而非实现编程以及抽象意识。


比如,我们代码中通过 Kafka 来发送异步消息。对于这样一个功能的开发,

我们要学会将其抽象成一组跟具体消息队列(Kafka)无关的异步消息接口。

所有上层系统都依赖这组抽象的接口编程,并且通过依赖注入的方式来调用。

当我们要替换新的消息队列的时候,比如将 Kafka 替换成 RocketMQ,

可以很方便地拔掉老的消息队列实现,插入新的消息队列实现。具体代码如下所示:


// 这一部分体现了抽象意识

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) {

    //...    

  }

}


对于如何写出扩展性好的代码、如何实现“对扩展开放、对修改关闭”这个问题,

今天只是比较笼统地总结了一下,详细的知识我们在后面慢慢学习。


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

前面我们提到,写出支持“对扩展开放、对修改关闭”的代码的关键是预留扩展点。

那问题是如何才能识别出所有可能的扩展点呢?

如果你开发的是一个业务导向的系统,比如金融系统、电商系统、物流系统等,

要想识别出尽可能多的扩展点,就要对业务有足够的了解,

能够知道当下以及未来可能要支持的业务需求。

如果你开发的是跟业务无关的、通用的、偏底层的系统,

比如,框架、组件、类库,你需要了解“它们会被如何使用?

今后你打算添加哪些功能?使用者未来会有哪些更多的功能需求?”等问题。

不过,有一句话说得好,“唯一不变的只有变化本身”。

即便我们对业务、对系统有足够的了解,

那也不可能识别出所有的扩展点,即便你能识别出所有的扩展点,

为这些地方都预留扩展点,这样做的成本也是不可接受的。

我们没必要为一些遥远的、不一定发生的需求去提前买单,做过度设计。


最合理的做法是,对于一些比较确定的、短期内可能就会扩展,

或者需求改动对代码结构影响比较大的情况,

或者实现成本不高的扩展点,在编写代码的时候之后,我们就可以事先做些扩展性设计。

但对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,

我们可以等到有需求驱动的时候,再通过重构代码的方式来支持扩展的需求。

而且,开闭原则也并不是免费的。有些情况下,代码的扩展性会跟可读性相冲突。

比如,我们之前举的 Alert 告警的例子。为了更好地支持扩展性,我们对代码进行了重构,

重构之后的代码要比之前的代码复杂很多,理解起来也更加有难度。


很多时候,我们都需要在扩展性和可读性之间做权衡。

在某些场景下,代码的扩展性很重要,

我们就可以适当地牺牲一些代码的可读性;

在另一些场景下,代码的可读性更加重要,那我们就适当地牺牲一些代码的可扩展性。

在我们之前举的 Alert 告警的例子中,如果告警规则并不是很多、也不复杂,

那 check() 函数中的 if 语句就不会很多,

代码逻辑也不复杂,代码行数也不多,那最初的第一种代码实现思路简单易读,就是比较合理的选择。

相反,如果告警规则很多、很复杂,check() 函数的 if 语句、代码逻辑就会很多、很复杂,

相应的代码行数也会很多,可读性、可维护性就会变差,

那重构之后的第二种代码实现思路就是更加合理的选择了。

总之,这里没有一个放之四海而皆准的参考标准,全凭实际的应用场景来决定。


里式替换原则(LSP)

今天,我们再来学习 SOLID 中的“L”对应的原则:里式替换原则。

整体上来讲,这个设计原则是比较简单、容易理解和掌握的。

今天我主要通过几个反例,带你看看,哪些代码是违反里式替换原则的?

我们该如何将它们改造成满足里式替换原则?

除此之外,这条原则从定义上看起来,跟我们之前讲过的“多态”有点类似。

所以,我今天也会讲一下,它跟多态的区别。


如何理解“里式替换原则”?

里式替换原则的英文翻译是:Liskov Substitution Principle,缩写为 LSP。

英文描述:Functions that use pointers of references to base classes 

must be able to use objects of derived classes without knowing it。

将这条原则用中文描述出来,是这样的:

子类对象(object of subtype/derived class)能够替换程序(program)

中父类对象(object of base/parent class)出现的任何地方,

并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。


这么说还是比较抽象,我们通过一个例子来解释一下。

如下代码中,父类 Transporter 使用 org.apache.http 库中的 HttpClient 类来传输网络数据。

子类 SecurityTransporter 继承父类 Transporter,增加了额外的功能,

支持传输 appId 和 appToken 安全认证信息。


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(/*省略参数*/););


在上面的代码中,子类 SecurityTransporter 的设计完全符合里式替换原则,

可以替换父类出现的任何位置,并且原来代码的逻辑行为不变且正确性也没有被破坏。

不过,你可能会有这样的疑问,刚刚的代码设计不就是简单利用了面向对象的多态特性吗?

多态和里式替换原则说的是不是一回事呢?

从刚刚的例子和定义描述来看,里式替换原则跟多态看起来确实有点类似,

但实际上它们完全是两回事。为什么这么说呢?

我们还是通过刚才这个例子来解释一下。


不过,我们需要对 SecurityTransporter 类中 sendRequest() 函数稍加改造一下。

改造前,如果 appId 或者 appToken 没有设置,我们就不做校验;

改造后,如果 appId 或者 appToken 没有设置,

则直接抛出 NoAuthorizationRuntimeException 未授权异常。

改造前后的代码对比如下所示:


// 改造前:

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);

  }

}


在改造之后的代码中,如果传递进 demoFunction() 函数的是父类 Transporter 对象,

那 demoFunction() 函数并不会有异常抛出,

但如果传递给 demoFunction() 函数的是子类 SecurityTransporter 对象,

那 demoFunction() 有可能会有异常抛出。尽管代码中抛出的是运行时异常(Runtime Exception),

我们可以不在代码中显式地捕获处理,但子类替换父类传递进 demoFunction 函数之后,

整个程序的逻辑行为有了改变。

虽然改造之后的代码仍然可以通过 Java 的多态语法,

动态地用子类 SecurityTransporter 来替换父类 Transporter,

也并不会导致程序编译或者运行报错。但是,从设计思路上来讲,

SecurityTransporter 的设计是不符合里式替换原则的。

好了,我们稍微总结一下。虽然从定义描述和代码实现上来看,

多态和里式替换有点类似,但它们关注的角度是不一样的。多态是面向对象编程的一大特性,

也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,

是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,

不改变原有程序的逻辑以及不破坏原有程序的正确性。

哪些代码明显违背了 LSP?

实际上,里式替换原则还有另外一个更加能落地、更有指导意义的描述,

那就是“Design By Contract”,中文翻译就是“按照协议来设计”。

看起来比较抽象,我来进一步解读一下。子类在设计的时候,

要遵守父类的行为约定(或者叫协议)。父类定义了函数的行为约定,

那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。

这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;

甚至包括注释中所罗列的任何特殊说明。实际上,定义中父类和子类之间的关系,

也可以替换成接口和实现类之间的关系。为了更好地理解这句话,

我举几个违反里式替换原则的例子来解释一下。


1.子类违背父类声明要实现的功能


父类中提供的 sortOrdersByAmount() 订单排序函数,是按照金额从小到大来给订单排序的,

而子类重写这个 sortOrdersByAmount() 订单排序函数之后,

是按照创建日期来给订单排序的。那子类的设计就违背里式替换原则。


2.子类违背父类对输入、输出、异常的约定


在父类中,某个函数约定:运行出错的时候返回 null;

获取数据为空的时候返回空集合(empty collection)。

而子类重载函数之后,实现变了,运行出错返回异常(exception),

获取不到数据返回 null。那子类的设计就违背里式替换原则。

在父类中,某个函数约定,输入数据可以是任意整数,但子类实现的时候,

只允许输入数据是正整数,负数就抛出,也就是说,子类对输入的数据的校验比父类更加严格,

那子类的设计就违背了里式替换原则。

在父类中,某个函数约定,只会抛出 ArgumentNullException 异常,

那子类的设计实现中只允许抛出 ArgumentNullException 异常,

任何其他异常的抛出,都会导致子类违背里式替换原则。


3.子类违背父类注释中所罗列的任何特殊说明


父类中定义的 withdraw() 提现函数的注释是这么写的:“用户的提现金额不得超过账户余额……”,

而子类重写 withdraw() 函数之后,针对 VIP 账号实现了透支提现的功能,

也就是提现金额可以大于账户余额,那这个子类的设计也是不符合里式替换原则的。

以上便是三种典型的违背里式替换原则的情况。除此之外,

判断子类的设计实现是否违背里式替换原则,还有一个小窍门,

那就是拿父类的单元测试去验证子类的代码。如果某些单元测试运行失败,

就有可能说明,子类的设计实现没有完全地遵守父类的约定,子类有可能违背了里式替换原则。


实际上,你有没有发现,里式替换这个原则是非常宽松的。

一般情况下,我们写的代码都不怎么会违背它。所以,只要你能看懂我今天讲的这些,

这个原则就不难掌握,也不难应用


接口隔离原则(ISP)

我们学习了 SOLID 原则中的单一职责原则、开闭原则和里式替换原则,

今天我们学习第四个原则,接口隔离原则。它对应 SOLID 中的英文字母“I”。

对于这个原则,最关键就是理解其中“接口”的含义。那针对“接口”,

不同的理解方式,对应在原则上也有不同的解读方式。除此之外,

接口隔离原则跟我们之前讲到的单一职责原则还有点儿类似,

所以今天我也会具体讲一下它们之间的区别和联系。


如何理解“接口隔离原则”?

接口隔离原则的英文翻译是“ Interface Segregation Principle”,

缩写为 ISP。Robert Martin 在 SOLID 原则中是这样定义它的:

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

直译成中文的话就是:客户端不应该被强迫依赖它不需要的接口。其中的“客户端”,

可以理解为接口的调用者或者使用者。客户端不应该依赖它不需要的接口,

即一个类对另一个类的依赖应该建立在最小的接口上

实际上,“接口”这个名词可以用在很多场合中。生活中我们可以用它来指插座接口等。

在软件开发中,我们既可以把它看作一组抽象的约定,也可以具体指系统与系统之间的 API 接口,

还可以特指面向对象编程语言中的接口等。


前面我提到,理解接口隔离原则的关键,就是理解其中的“接口”二字。

在这条原则中,我们可以把“接口”理解为下面三种东西:

一组 API 接口集合

单个 API 接口或函数

OOP 中的接口概念接下来

我就按照这三种理解方式来详细讲一下,在不同的场景下,这条原则具体是如何解读和应用的。


把“接口”理解为一组 API 接口集合

我们还是结合一个例子来讲解。

微服务用户系统提供了一组跟用户相关的 API 给其他系统使用,

比如:注册、登录、获取用户信息等。具体代码如下所示:


public interface UserService {

  boolean register(String cellphone, String password);

  boolean login(String cellphone, String password);

  UserInfo getUserInfoById(long id);

  UserInfo getUserInfoByCellphone(String cellphone);

}


public class UserServiceImpl implements UserService {

  //...

}


现在,我们的后台管理系统要实现删除用户的功能,希望用户系统提供一个删除用户的接口。

这个时候我们该如何来做呢?你可能会说,这不是很简单吗,

我只需要在 UserService 中新添加一个 deleteUserByCellphone() 或 deleteUserById() 接口就可以了。

这个方法可以解决问题,但是也隐藏了一些安全隐患。

删除用户是一个非常慎重的操作,我们只希望通过后台管理系统来执行,

所以这个接口只限于给后台管理系统使用。如果我们把它放到 UserService 中,

那所有使用到 UserService 的系统,都可以调用这个接口。

不加限制地被其他业务系统调用,就有可能导致误删用户。

当然,最好的解决方案是从架构设计的层面,通过接口鉴权的方式来限制接口的调用。

不过,如果暂时没有鉴权框架来支持,我们还可以从代码设计的层面,

尽量避免接口被误用。我们参照接口隔离原则,调用者不应该强迫依赖它不需要的接口,

将删除接口单独放到另外一个接口 RestrictedUserService 中,

然后将 RestrictedUserService 只打包提供给后台管理系统来使用。

具体的代码实现如下所示:


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 接口或函数


把“接口”理解为单个 API 接口或函数

现在我们再换一种理解方式,把接口理解为单个接口或函数(以下为了方便讲解,我都简称为“函数”)。

那接口隔离原则就可以理解为:

函数的设计要功能单一,不要将多个不同的功能逻辑在一个函数中实现。

接下来,我们还是通过一个例子来解释一下。


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() 函数拆成几个更小粒度的函数,

每个函数负责一个独立的统计功能。拆分之后的代码如下所示:


public Long max(Collection<Long> dataSet) { //... }

public Long min(Collection<Long> dataSet) { //... } 

public Long average(Colletion<Long> dataSet) { //... }

// ...省略其他统计函数...


不过,你可能会说,在某种意义上讲,count() 函数也不能算是职责不够单一,

毕竟它做的事情只跟统计相关。我们在讲单一职责原则的时候,也提到过类似的问题。

实际上,判定功能是否单一,除了很强的主观性,还需要结合具体的场景。


如果在项目中,对每个统计需求,Statistics 定义的那几个统计信息都有涉及,

那 count() 函数的设计就是合理的。相反,如果每个统计需求只涉及 Statistics 罗列的统计信息中一部分,

比如,有的只需要用到 max、min、average 这三类统计信息,

有的只需要用到 average、sum。而 count() 函数每次都会把所有的统计信息计算一遍,

就会做很多无用功,势必影响代码的性能,特别是在需要统计的数据量很大的时候。

所以,在这个应用场景下,count() 函数的设计就有点不合理了,我们应该按照第二种设计思路,

将其拆分成粒度更细的多个统计函数。

不过,你应该已经发现,接口隔离原则跟单一职责原则有点类似,不过稍微还是有点区别。

单一职责原则针对的是模块、类、接口的设计。而接口隔离原则相对于单一职责原则,

一方面它更侧重于接口的设计,另一方面它的思考的角度不同。


它提供了一种判断接口是否职责单一的标准:

通过调用者如何使用接口来间接地判定。

如果调用者只使用部分接口或接口的部分功能,

那接口的设计就不够职责单一。


把“接口”理解为 OOP 中的接口概念

除了刚讲过的两种理解方式,我们还可以把“接口”理解为 OOP 中的接口概念,

比如 Java 中的 interface。我还是通过一个例子来给你解释。

假设我们的项目中用到了三个外部系统:Redis、MySQL、Kafka。

每个系统都对应一系列配置信息,比如地址、端口、访问超时时间等。

为了在内存中存储这些配置信息,供项目中的其他模块来使用,

我们分别设计实现了三个 Configuration 类:RedisConfig、MysqlConfig、KafkaConfig。

具体的代码实现如下所示。

注意,这里我只给出了 RedisConfig 的代码实现,另外两个都是类似的,我这里就不贴了。


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 { //...省略... }


现在,我们有一个新的功能需求,希望支持 Redis 和 Kafka 配置信息的热更新。

所谓“热更新(hot update)”就是,如果在配置中心中更改了配置信息,

我们希望在不用重启系统的情况下,能将最新的配置信息加载到内存中(也就是 RedisConfig、KafkaConfig 类中)。

但是,因为某些原因,我们并不希望对 MySQL 的配置信息进行热更新。


为了实现这样一个功能需求,我们设计实现了一个 ScheduledUpdater 类,

以固定时间频率(periodInSeconds)来调用 RedisConfig、KafkaConfig 的 update() 方法更新配置信息。

具体的代码实现如下所示:


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();

  }

}


刚刚的热更新的需求我们已经搞定了。现在,我们又有了一个新的监控功能需求。

通过命令行来查看 Zookeeper 中的配置信息是比较麻烦的。

所以,我们希望能有一种更加方便的配置信息查看方式。

我们可以在项目中开发一个内嵌的 SimpleHttpServer,输出项目的配置信息到一个固定的 HTTP 地址。

我们只需要在浏览器中输入这个地址,就可以显示出系统的配置信息。

不过,出于某些原因,我们只想暴露 MySQL 和 Redis 的配置信息,不想暴露 Kafka 的配置信息。

为了实现这样一个功能,我们还需要对上面的代码做进一步改造。改造之后的代码如下所示:

public interface Updater {

  void update();

}


public interface Viewer {

  String outputInPlainText();

  Map<String, String> output();

}


public class RedisConfig implemets Updater, Viewer {

  //...省略其他属性和方法...

  @Override

  public void update() { //... }

  @Override

  public String outputInPlainText() { //... }

  @Override

  public Map<String, String> output() { //...}

}


public class KafkaConfig implements Updater {

  //...省略其他属性和方法...

  @Override

  public void update() { //... }

}


public class MysqlConfig implements Viewer {

  //...省略其他属性和方法...

  @Override

  public String outputInPlainText() { //... }

  @Override

  public Map<String, String> output() { //...}

}


public class SimpleHttpServer {

  private String host;

  private int port;

  private Map<String, List<Viewer>> viewers = new HashMap<>();

  

  public SimpleHttpServer(String host, int port) {//...}

  

  public void addViewers(String urlDirectory, Viewer viewer) {

    if (!viewers.containsKey(urlDirectory)) {

      viewers.put(urlDirectory, new ArrayList<Viewer>());

    }

    this.viewers.get(urlDirectory).add(viewer);

  }

  

  public void run() { //... }

}


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);

        redisConfigUpdater.run();

        

        SimpleHttpServer simpleHttpServer = new SimpleHttpServer(“127.0.0.1”, 2389);

        simpleHttpServer.addViewer("/config", redisConfig);

        simpleHttpServer.addViewer("/config", mysqlConfig);

        simpleHttpServer.run();

    }

}


至此,热更新和监控的需求我们就都实现了。我们来回顾一下这个例子的设计思想。


我们设计了两个功能非常单一的接口:Updater 和 Viewer。

ScheduledUpdater 只依赖 Updater 这个跟热更新相关的接口,不需要被强迫去依赖不需要的 Viewer 接口,

满足接口隔离原则。同理,SimpleHttpServer 只依赖跟查看信息相关的 Viewer 接口,

不依赖不需要的 Updater 接口,也满足接口隔离原则。

你可能会说,如果我们不遵守接口隔离原则,不设计 Updater 和 Viewer 两个小接口,

而是设计一个大而全的 Config 接口,让 RedisConfig、KafkaConfig、MysqlConfig 都实现这个 Config 接口,

并且将原来传递给 ScheduledUpdater 的 Updater 和传递给 SimpleHttpServer 的 Viewer,

都替换为 Config,那会有什么问题呢?

我们先来看一下,按照这个思路来实现的代码是什么样的。

public interface Config {

  void update();

  String outputInPlainText();

  Map<String, String> output();

}


public class RedisConfig implements Config {

  //...需要实现Config的三个接口update/outputIn.../output

}


public class KafkaConfig implements Config {

  //...需要实现Config的三个接口update/outputIn.../output

}


public class MysqlConfig implements Config {

  //...需要实现Config的三个接口update/outputIn.../output

}


public class ScheduledUpdater {

  //...省略其他属性和方法..

  private Config config;


  public ScheduleUpdater(Config config, long initialDelayInSeconds, long periodInSeconds) {

      this.config = config;

      //...

  }

  //...

}


public class SimpleHttpServer {

  private String host;

  private int port;

  private Map<String, List<Config>> viewers = new HashMap<>();

 

  public SimpleHttpServer(String host, int port) {//...}

  

  public void addViewer(String urlDirectory, Config config) {

    if (!viewers.containsKey(urlDirectory)) {

      viewers.put(urlDirectory, new ArrayList<Config>());

    }

    viewers.get(urlDirectory).add(config);

  }

  

  public void run() { //... }

}


这样的设计思路也是能工作的,但是对比前后两个设计思路,在同样的代码量、实现复杂度、同等可读性的情况下,

第一种设计思路显然要比第二种好很多。为什么这么说呢?主要有两点原因。


首先,第一种设计思路更加灵活、易扩展、易复用。因为 Updater、Viewer 职责更加单一,

单一就意味了通用、复用性好。比如,我们现在又有一个新的需求,

开发一个 Metrics 性能统计模块,并且希望将 Metrics 也通过 SimpleHttpServer 显示在网页上,

以方便查看。

这个时候,尽管 Metrics 跟 RedisConfig 等没有任何关系,

但我们仍然可以让 Metrics 类实现非常通用的 Viewer 接口,

复用 SimpleHttpServer 的代码实现。具体的代码如下所示:

public class ApiMetrics implements Viewer {//...}

public class DbMetrics implements Viewer {//...}


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 final ApiMetrics apiMetrics = new ApiMetrics();

    public static final DbMetrics dbMetrics = new DbMetrics();

    

    public static void main(String[] args) {

        SimpleHttpServer simpleHttpServer = new SimpleHttpServer(“127.0.0.1”, 2389);

        simpleHttpServer.addViewer("/config", redisConfig);

        simpleHttpServer.addViewer("/config", mySqlConfig);

        simpleHttpServer.addViewer("/metrics", apiMetrics);

        simpleHttpServer.addViewer("/metrics", dbMetrics);

        simpleHttpServer.run();

    }

}


其次,第二种设计思路在代码实现上做了一些无用功。

因为 Config 接口中包含两类不相关的接口,一类是 update(),一类是 output() 和 outputInPlainText()。

理论上,KafkaConfig 只需要实现 update() 接口,并不需要实现 output() 相关的接口。

同理,MysqlConfig 只需要实现 output() 相关接口,并需要实现 update() 接口。

但第二种设计思路要求 RedisConfig、KafkaConfig、

MySqlConfig 必须同时实现 Config 的所有接口函数(update、output、outputInPlainText)。

除此之外,如果我们要往 Config 中继续添加一个新的接口,那所有的实现类都要改动。

相反,如果我们的接口粒度比较小,那涉及改动的类就比较少。


————————————————

版权声明:本文为CSDN博主「youthlql」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。

原文链接:https://blog.csdn.net/Youth_lql/article/details/117884540


本文出自勇哥的网站《少有人走的路》wwww.skcircle.com,转载请注明出处!讨论可扫码加群:
本帖最后由 勇哥,很想停止 于 2023-10-20 08:29:09 编辑

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

会员中心
搜索
«    2024年4月    »
1234567
891011121314
15161718192021
22232425262728
2930
网站分类
标签列表
最新留言
    热门文章 | 热评文章 | 随机文章
文章归档
友情链接
  • 订阅本站的 RSS 2.0 新闻聚合
  • 扫描加本站机器视觉QQ群,验证答案为:halcon勇哥的机器视觉
  • 点击查阅微信群二维码
  • 扫描加勇哥的非标自动化群,验证答案:C#/C++/VB勇哥的非标自动化群
  • 扫描加站长微信:站长微信:abc496103864
  • 扫描加站长QQ:
  • 扫描赞赏本站:
  • 留言板:

Powered By Z-BlogPHP 1.7.2

Copyright Your skcircle.com Rights Reserved.

鄂ICP备18008319号


站长QQ:496103864 微信:abc496103864