SOLID原则

单一职责原则

单一职责原则(SRP, Single Responsibility Principle):一个类或者模块只负责完成一个职责(或者功能)。

一个类包含了两个或者两个以上业务不相干的功能,那我们就说它职责不够单一,应该将它拆分成多个功能更加单一、粒度更的类。

例:一个类里既包含订单的一些操作,又包含用户的一些操作。
而订单和用户是两个独立的业务领域模型,我们将两个不相干的功能放到同一个类中,那就违反了单一职责原则。
为了满足单一职责原则,我们需要将这个类拆分成两个粒度更细、功能更加单一的两个类:订单类和用户类。

不同场景判断不同


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; // 详细地址
  // ...省略其他属性和方法...
}

不同的应用场景、不同阶段的需求背景下,对同一个类的职责是否单一的判定,可能都是不一样的。
在某种应用场景或者当下的需求背景下,一个类的设计可能已经满足单一职责原则了,但如果换个应用场景或着在未来的某个需求背景下,可能就不满足了,需要继续拆分成粒度更细的类。

设计策略

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

类的拆分

当一个类的代码,读起来让你头大了,实现某个功能时不知道该用哪个函数了,想用哪个函数翻半天都找不到了,
只用到一个小功能要引入整个类(类中包含很多无关此功能实现的函数)的时候,这就说明类的行数、函数、属性过多了。

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


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 类都需要做相应的修改,代码的内聚性显然没有将两个类合并为一个类高。
而且,如果我们仅仅对 Serializer 类做了协议修改,而忘记了修改 Deserializer 类的代码,那就会导致序列化、反序列化不匹配,程序运行出错,也就是说,
拆分之后,代码的可维护性变差了

不管是应用设计原则还是设计模式,最终的目的还是提高代码的 可读性可扩展性复用性可维护性等。
我们在考虑应用某一个设计原则是否合理的时候,也可以以此作为最终的考量标准。

开闭原则

开闭原则(OCP,Open Closed Principle):软件实体(模块、类、方法等)应该 对扩展开放、对修改关闭

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

错误的实例:基于“修改”的方式来实现新功能

API 接口监控告警的代码:

业务逻辑主要集中在 check() 函数中。当接口的 TPS 超过某个预先设置的最大值时,以及当接口请求出错数大于某个最大允许值时,就会触发告警


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() 函数的入参,添加一个新的统计数据 timeoutCount,表示超时接口请求数。并在 check() 函数中添加新的告警逻辑。

修改后的 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, "...");
    }
  }
}

存在的问题

改正


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

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 类添加单元测试,老的单元测试都不会失败,也不用修改。

误区

Q:往 ApiStatInfo 类中添加新的属性 timeoutCount 违反开闭原则吗?

A:给类中添加新的属性和方法,算作“修改”还是“扩展”?
开闭原则可以应用在不同粒度的代码中,可以是模块,也可以类,还可以是方法(及其属性)。
同样一个代码改动,在粗代码粒度下,被认定为 修改,在细代码粒度下,又可以被认定为 扩展
本例中,添加属性和 gettersetter 方法相当于修改类,在类这个层面,这个代码改动可以被认定为 修改
但这个代码改动并没有修改已有的属性和方法,在方法(及其属性)这一层面,它又可以被认定为 扩展

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

添加一个新功能,不可能任何模块、类、方法的代码都不“修改”,这个是做不到的。
类需要创建、组装、并且做一些初始化操作,才能构建成可运行的的程序,这部分代码的修改是在所难免的。
我们要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。

里氏替换原则

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

与多态的区别

多态和里式替换有点类似,但它们关注的角度是不一样的。

明显违反 LSP 原则的示例

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

拿父类的单元测试去验证子类的代码。如果某些单元测试运行失败,就有可能说明,子类的设计实现没有完全地遵守父类的约定,子类有可能违背了里式替换原则。

接口隔离原则

接口隔离原则(ISP,Interface Segregation Principle):接口的调用者或者使用者不应该被强迫依赖它不需要的接口。

可以把“接口”理解为:一组 API 接口集合、单个 API 接口或函数、OOP 中的接口概念

Api接口

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

例:

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

如果在 Service 中新添加一个 deleteUserByCellphone()deleteUserById() 接口,可以解决问题,但是也隐藏了一些安全隐患。
所有使用到 Service 的系统,都可以调用这个接口。不加限制地被其他业务系统调用,就有可能导致误删用户。

最好的解决方案是从架构设计的层面,通过接口鉴权的方式来限制接口的调用。或从代码设计的层面,尽量避免接口被误用。
将删除接口单独放到另外一个接口 RestrictedUserService 中,然后将 RestrictedUserService 只打包提供给后台管理系统来使用。

函数

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


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) { //... }
// ...省略其他统计函数...

与单一职责原则的区别

OOP 中的接口

例:

有需求如下:

三个外部系统:RedisMySQLKafka。每个系统都对应一系列配置信息,比如地址、端口、访问超时时间等。
为了在内存中存储这些配置信息,供项目中的其他模块来使用,我们分别设计实现了三个 Configuration 类:RedisConfigMysqlConfigKafkaConfig
其中 RedisKafka 配置信息需要支持热更新。

并且需要在项目中开发一个内嵌的 SimpleHttpServer,输出项目的配置信息到一个固定的 HTTP 地址,比如:http://127.0.0.1:2389/config
我们只需要在浏览器中输入这个地址,就可以显示出系统的配置信息。不过,出于某些原因,我们只想暴露 MySQLRedis 的配置信息,不想暴露 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();
    }
}

我们设计了两个功能非常单一的接口:UpdaterViewer
ScheduledUpdater 只依赖 Updater 这个跟热更新相关的接口,不需要被强迫去依赖不需要的 Viewer 接口,满足接口隔离原则。

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

依赖反转原则

依赖反转原则(DIP,Dependence Inversion Principle):高层模块(high-level modules)不要依赖低层模块(low-level)。
高层模块和低层模块应该通过抽象(abstractions)来互相依赖。
除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。

所谓 高层模块低层模块 的划分,简单来说就是,在调用链上,
调用者属于高层,被调用者属于低层。
在平时的业务代码开发中,高层模块依赖低层模块是没有任何问题的。
实际上,这条原则主要还是用来指导框架层面的设计

Tomcat 是运行 Java Web 应用程序的容器。
我们编写的 Web 应用程序代码只需要部署在 Tomcat 容器下,便可以被 Tomcat 容器调用执行。
按照之前的划分原则,Tomcat 就是高层模块,我们编写的 Web 应用程序代码就是低层模块。
Tomcat 和应用程序代码之间并没有直接的依赖关系,两者都依赖同一个 抽象
也就是 Servlet 规范。Servlet 规范不依赖具体的 Tomcat 容器和应用程序的实现细节,而 Tomcat 容器和应用程序依赖 Servlet 规范。

控制反转(IOC,Inversion Of Control)

框架提供了一个可扩展的代码骨架,用来组装对象、管理整个执行流程。
程序员利用框架进行开发的时候,只需要往预留的扩展点上,添加跟自己业务相关的代码,
就可以利用框架来驱动整个程序流程的执行。

例:

代码1:


public class UserServiceTest {
  public static boolean doTest() {
    // ... 
  }
  
  public static void main(String[] args) {//这部分逻辑可以放到框架中
    if (doTest()) {
      System.out.println("Test succeed.");
    } else {
      System.out.println("Test failed.");
    }
  }
}

代码2:


public abstract class TestCase {
  public void run() {
    if (doTest()) {
      System.out.println("Test succeed.");
    } else {
      System.out.println("Test failed.");
    }
  }
  
  public abstract boolean doTest();
}

public class UserServiceTest extends TestCase {
  @Override
  public boolean doTest() {
    // ... 
  }
}

public class JunitApplication {
  private static final List<TestCase> testCases = new ArrayList<>();
  
  public static void register(TestCase testCase) {
    testCases.add(testCase);
  }
  
  public static final void main(String[] args) {
    for (TestCase cases: testCases) {
      cases.run();
    }
  }

代码2中,我们只需要在框架预留的扩展点,也就是 TestCase 类中的 doTest() 抽象函数中,
填充具体的测试代码就可以实现之前的功能了,完全不需要写负责执行流程的 main() 函数了。

这里的 控制 指的是对程序执行流程的控制,而 反转 指的是在没有使用框架之前,程序员自己控制整个程序的执行。
在使用框架之后,整个程序的执行流程可以通过框架来控制。
流程的控制权从程序员 反转 到了框架。

实际上,实现控制反转的方法有很多,除了刚才例子中所示的类似于模板设计模式的方法之外,还有 依赖注入 等方法,
所以,控制反转并不是一种具体的实现技巧,而是一个比较笼统的设计思想,一般用来指导框架层面的设计

依赖注入(DI,Dependency Injection)

不通过 new() 的方式在类内部创建依赖类对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式**传递(或注入)**给类使用。

通过依赖注入的方式来将依赖的类对象传递进来,这样就提高了代码的扩展性,我们可以灵活地替换依赖的类。也是编写可测试性代码最有效的手段。

依赖注入框架(DI Framework)

一些项目可能会涉及几十、上百、甚至几百个类,类对象的创建和依赖注入会变得非常复杂。
如果这部分工作都是靠程序员自己写代码来完成,容易出错且开发成本也比较高。
而对象创建和依赖注入的工作,本身跟具体的业务无关,我们完全可以抽象成框架来自动完成。

这个框架就是 依赖注入框架
我们只需要通过依赖注入框架提供的扩展点,简单配置一下所有需要创建的类对象、类与类之间的依赖关系,就可以实现由框架来自动创建对象、管理对象的生命周期、
依赖注入等原本需要程序员来做的事情。Spring 框架的控制反转主要是通过依赖注入来实现的。