您现在的位置是:首页 >其他 >设计模式之美-设计原则讲解二网站首页其他

设计模式之美-设计原则讲解二

兜兜转转m 2024-07-12 18:01:02
简介设计模式之美-设计原则讲解二

3. 里式替换(LSP)跟多态有何区别?哪些代码违背了LSP?

在1996年,Robert Martin在他的SOLID原则中,重新描述了这个原则,英文原话是这样的:

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的设计完全符合里式替换原则,可以替换父类出现的任何位置,并且原来代码的逻辑行为不变且正确性也没有被破坏。

不过,你可能会有这样的疑问,刚刚的代码设计不就是简单利用了面向对象的多态特性吗?多态和里式替换原则说的是不是一回事呢?从刚刚的例子和定义描述来看,里式替换原则跟多态看起来确实有点类似,但实际上它们完全是两回事。为什么这么说呢

多态关注的是:子类能够代替父类。

LSP关注的是:在多态的基础上,子类不能违背父类实现方法的功能,对输入输出的约定等。

 我们还是通过刚才这个例子来解释一下。不过,我们需要对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的设计是不符合里式替换原则的。

这就是子类不用遵循父类的约定。这里的行为约定包括:函数声明要实现的功能;对输入输出异常的约定;甚至包括注释中所罗列的任何特殊说明。实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。

 

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

父类中提供的sortOrdersByAmount()订单排序函数,是按照金额从小到大来给订单排序的,而子类重写这个sortOrdersByAmount()订单排序函数之后,是按照创建日期来给订单排序的。那子类的设计就违背里式替换原则。

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

在父类中,某个函数约定:运行出错的时候返回null;获取数据为空的时候返回空集合(empty collection)。而子类重载函数之后,实现变了,运行出错返回异常(exception),获取不到数据返回null。那子类的设计就违背里式替换原则。

在父类中,某个函数约定,输入数据可以是任意整数,但子类实现的时候,只允许输入数据是正整数,负数就抛出,也就是说,子类对输入的数据的校验比父类更加严格,那子类的设计就违背了里式替换原则。

在父类中,某个函数约定,只会抛出ArgumentNullException异常,那子类的设计实现中只允许抛出ArgumentNullException异常,任何其他异常的抛出,都会导致子类违背里式替换原则。

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

父类中定义的withdraw()提现函数的注释是这么写的:“用户的提现金额不得超过账户余额……”,而子类重写withdraw()函数之后,针对VIP账号实现了透支提现的功能,也就是提现金额可以大于账户余额,那这个子类的设计也是不符合里式替换原则的。

以上便是三种典型的违背里式替换原则的情况。除此之外,判断子类的设计实现是否违背里式替换原则,还有一个小窍门,那就是拿父类的单元测试去验证子类的代码。如果某些单元测试运行失败,就有可能说明,子类的设计实现没有完全地遵守父类的约定,子类有可能违背了里式替换原则。

实际上,你有没有发现,里式替换这个原则是非常宽松的。一般情况下,我们写的代码都不怎么会违背它。所以,只要你能看懂我今天讲的这些,这个原则就不难掌握,也不难应用。

4. 接口隔离原则有哪三种应用?原则中的“接口”该如何理解?

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

接口隔离原则的英文翻译是“ Interface Segregation Principle”,缩写为ISP。Robert Martin在SOLID原则中是这样定义它的:“Clients should not be forced to depend upon interfaces that they do not use。”直译成中文的话就是:客户端不应该被强迫依赖它不需要的接口。其中的“客户端”,可以理解为接口的调用者或者使用者

前面我提到,理解接口隔离原则的关键,就是理解其中的“接口”二字。在这条原则中,我们可以把“接口”理解为下面三种东西:

  • 一组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 {
  // ...省略实现代码...
}

这样就可以将Userservice接口完全暴露给其他微服务,而删除用户的接口就可以被避免暴露给其他微服务。其实还是用鉴权会好一点。

把“接口”理解为单个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) { //... }
// ...省略其他统计函数...

如果在项目中,对每个统计需求,Statistics定义的那几个统计信息都有涉及,那count()函数的设计就是合理的。相反,如果每个统计需求只涉及Statistics罗列的统计信息中一部分,比如,有的只需要用到max、min、average这三类统计信息,有的只需要用到average、sum。而count()函数每次都会把所有的统计信息计算一遍,就会做很多无用功,势必影响代码的性能,特别是在需要统计的数据量很大的时候。所以,在这个应用场景下,count()函数的设计就有点不合理了,我们应该按照第二种设计思路,将其拆分成粒度更细的多个统计函数。

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

除了刚讲过的两种理解方式,我们还可以把“接口”理解为OOP中的接口概念,比如Java中的interface。我还是通过一个例子来给你解释。

假设我们的项目中用到了三个外部系统:Redis、MySQL、Kafka。每个系统都对应一系列配置信息,比如地址、端口、访问超时时间等。为了在内存中存储这些配置信息,供项目中的其他模块来使用,我们分别设计实现了三个Configuration类:RedisConfigMysqlConfigKafkaConfig。具体的代码实现如下所示。注意,这里我只给出了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地址,比如:http://127.0.0.1:2389/config 。我们只需要在浏览器中输入这个地址,就可以显示出系统的配置信息。不过,出于某些原因,我们只想暴露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接口,也满足接口隔离原则。同时也基本满足开闭原则,再我们修改业务时候,一般是通过添加代码的方式来解决这个问题,很少进行代码的修改。

重点回顾

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

  • “接口”理解为一组接口集合
  • “接口”理解为单个API接口或函数
  • “接口”理解为OOP中的接口

2.接口隔离原则与单一职责原则的区别

单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度也是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。