设计模式学习笔记[01] 面向对象&设计原则

本笔记内容源于王争的《设计模式之美》课程。

零、导读

评价代码质量

实际上,咱们平时嘴中常说的“好”和“烂”,是对代码质量的一种描述。“好”笼统地表示代码质量高,“烂”笼统地表示代码质量低。对于代码质量的描述,除了“好”“烂”这样比较简单粗暴的描述方式之外,我们也经常会听到很多其他的描述方式。这些描述方法语义更丰富、更专业、更细化。

灵活性(flexibility)、可扩展性(extensibility)、可维护性(maintainability)、可读性(readability)、可理解性(understandability)、易修改性(changeability)、可复用(reusability)、可测试性(testability)、模块化(modularity)、高内聚低耦合(high cohesion loose coupling)、高效(high effciency)、高性能(high performance)、安全性(security)、兼容性(compatibility)、易用性(usability)、整洁(clean)、清晰(clarity)、简单(simple)、直接(straightforward)、少即是多(less code is more)、文档详尽(well-documented)、分层清晰(well-layered)、正确性(correctness、bug free)、健壮性(robustness)、鲁棒性(robustness)、可用性(reliability)、可伸缩性(scalability)、稳定性(stability)、优雅(elegant)、好(good)、坏(bad)……

实际上,我们很难通过其中的某个或者某几个词汇来全面地评价代码质量。因为这些词汇都是从不同维度来说的。

最常用的几个评价标准

可维护性(maintainability)

落实到编码开发,所谓的“维护”无外乎就是修改bug、修改老的代码、添加新的代码之类的工作。所谓“代码易维护”就是指,在不破坏原有代码设计、不引入新的bug的情况下,能够快速地修改或者添加代码。所谓“代码不易维护”就是指,修改或者添加代码需要冒着极大的引入新bug的风险,并且需要花费很长的时间才能完成。

实际上,可维护性也是一个很难量化、偏向对代码整体的评价标准,它有点类似之前提到的“好”“坏”“优雅”之类的笼统评价。代码的可维护性是由很多因素协同作用的结果。代码的可读性好、简洁、可扩展性好,就会使得代码易维护;相反,就会使得代码不易维护。更细化地讲,如果代码分层清晰、模块化好、高内聚低耦合、遵从基于接口而非实现编程的设计原则等等,那就可能意味着代码易维护。除此之外,代码的易维护性还跟项目代码量的多少、业务的复杂程度、利用到的技术的复杂程度、文档是否全面、团队成员的开发水平等诸多因素有关。

可读性(readability)

软件设计大师Martin Fowler曾经说过:“Any fool can write code that a computer can understand. Good programmers write code that humans can understand.”翻译成中文就是:“任何傻瓜都会编写计算机能理解的代码。好的程序员能够编写人能够理解的代码。”Google内部甚至专门有个认证就叫作Readability。只有拿到这个认证的工程师,才有资格在code review的时候,批准别人提交代码。可见代码的可读性有多重要,毕竟,代码被阅读的次数远远超过被编写和执行的次数。

代码的可读性应该是评价代码质量最重要的指标之一。我们在编写代码的时候,时刻要考虑到代码是否易读、易理解。除此之外,代码的可读性在非常大程度上会影响代码的可维护性。毕竟,不管是修改bug,还是修改添加功能代码,我们首先要做的事情就是读懂代码。代码读不大懂,就很有可能因为考虑不周全,而引入新的bug。

既评价一段代码的可读性需要看代码是否符合编码规范、命名是否达意、注释是否详尽、函数是否长短合适、模块划分是否清晰、是否符合高内聚低耦合等等。你应该也能感觉到,从正面上,我们很难给出一个覆盖所有评价指标的列表。这也是我们无法量化可读性的原因。

可扩展性(extensibility)

代码的可扩展性表示,我们在不修改或少量修改原有代码的情况下,通过扩展的方式添加新的功能代码。说直白点就是,代码预留了一些功能扩展点,你可以把新功能代码,直接插到扩展点上,而不需要因为要添加一个功能而大动干戈,改动大量的原始代码。

灵活性(flexibility)

实际上,灵活性是一个挺抽象的评价标准,要给灵活性下个定义也是挺难的。不过,我们可以想一下,什么情况下我们才会说代码写得好灵活呢?这里罗列了几个场景:

简洁性(simplicity)

有一条非常著名的设计原则,你一定听过,那就是KISS原则:“Keep It Simple,Stupid”。这个原则说的意思就是,尽量保持代码简单。代码简单、逻辑清晰,也就意味着易读、易维护。我们在编写代码的时候,往往也会把简单、清晰放到首位。

可复用性(reusability)

代码的可复用性可以简单地理解为,尽量减少重复代码的编写,复用已有的代码。

比如,当讲到面向对象特性的时候,我们会讲到继承、多态存在的目的之一,就是为了提高代码的可复用性;当讲到设计原则的时候,我们会讲到单一职责原则也跟代码的可复用性相关;当讲到重构技巧的时候,我们会讲到解耦、高内聚、模块化等都能提高代码的可复用性。可见,可复用性也是一个非常重要的代码评价标准,是很多设计原则、思想、模式等所要达到的最终效果。

实际上,代码可复用性跟DRY(Don’t Repeat Yourself)这条设计原则的关系挺紧密的。

可测试性(testability)

相对于前面六个评价标准,代码的可测试性是一个相对较少被提及,但又非常重要的代码质量评价标准。代码可测试性的好坏,能从侧面上非常准确地反应代码质量的好坏。代码的可测试性差,比较难写单元测试,那基本上就能说明代码设计得有问题。

一、面向对象

面向对象编程的四大特性分别是:封装、抽象、继承、多态。

封装(Encapsulation)

封装也叫作信息隐藏或者数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据。

如果对类中属性的访问不做限制,那任何代码都可以访问、修改类中的属性,虽然这样看起来更加灵活,但从另一方面来说,过度灵活也意味着不可控,属性可以随意被以各种奇葩的方式修改,而且修改逻辑可能散落在代码中的各个角落,势必影响代码的可读性、可维护性。

除此之外,类仅仅通过有限的方法暴露必要的操作,也能提高类的易用性。如果我们把类属性都暴露给类的调用者,调用者想要正确地操作这些属性,就势必要对业务细节有足够的了解。而这对于调用者来说也是一种负担。相反,如果我们将属性封装起来,暴露少许的几个必要的方法给调用者使用,调用者就不需要了解太多背后的业务细节,用错的概率就减少很多。这就好比,如果一个冰箱有很多按钮,你就要研究很长时间,还不一定能操作正确。相反,如果只有几个必要的按钮,比如开、停、调节温度,你一眼就能知道该如何来操作,而且操作出错的概率也会降低很多。

抽象(Abstraction)

抽象讲的是如何隐藏方法的具体实现,让调用者只需要关心方法提供了哪些功能,并不需要知道这些功能是如何实现的。在面向对象编程中,我们常借助编程语言提供的接口类(比如Java中的interface关键字语法)或者抽象类(比如Java中的abstract关键字语法)这两种语法机制,来实现抽象这一特性。

抽象这个概念是一个非常通用的设计思想,并不单单用在面向对象编程中,也可以用来指导架构设计等。而且这个特性也并不需要编程语言提供特殊的语法机制来支持,只需要提供“函数”这一非常基础的语法机制,就可以实现抽象特性、所以,它没有很强的“特异性”,有时候并不被看作面向对象编程的特性之一。

实际上,如果上升一个思考层面的话,抽象及其前面讲到的封装都是人类处理复杂性的有效手段。在面对复杂系统的时候,人脑能承受的信息复杂程度是有限的,所以我们必须忽略掉一些非关键性的实现细节。而抽象作为一种只关注功能点不关注实现的设计思路,正好帮我们的大脑过滤掉许多非必要的信息。

除此之外,抽象作为一个非常宽泛的设计思想,在代码设计中,起到非常重要的指导作用。很多设计原则都体现了抽象这种设计思想,比如基于接口而非实现编程、开闭原则(对扩展开放、对修改关闭)、代码解耦(降低代码的耦合性)等。

继承(Inheritance)

继承最大的一个好处就是代码复用。假如两个类有一些相同的属性和方法,我们就可以将这些相同的部分,抽取到父类中,让两个子类继承父类。这样,两个子类就可以重用父类中的代码,避免代码重复写多遍。不过,这一点也并不是继承所独有的,我们也可以通过其他方式来解决这个代码复用的问题,比如利用组合关系而不是继承关系。

多态(Polymorphism)

多态是指,子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。

public class DynamicArray {
  private static final int DEFAULT_CAPACITY = 10;
  protected int size = 0;
  protected int capacity = DEFAULT_CAPACITY;
  protected Integer[] elements = new Integer[DEFAULT_CAPACITY];
  
  public int size() { return this.size; }
  public Integer get(int index) { return elements[index];}
  //...省略n多方法...
  
  public void add(Integer e) {
    ensureCapacity();
    elements[size++] = e;
  }
  
  protected void ensureCapacity() {
    //...如果数组满了就扩容...代码省略...
  }
}

public class SortedDynamicArray extends DynamicArray {
  @Override
  public void add(Integer e) {
    ensureCapacity();
    int i;
    for (i = size-1; i>=0; --i) { //保证数组中的数据有序
      if (elements[i] > e) {
        elements[i+1] = elements[i];
      } else {
        break;
      }
    }
    elements[i+1] = e;
    ++size;
  }
}

public class Example {
  public static void test(DynamicArray dynamicArray) {
    dynamicArray.add(5);
    dynamicArray.add(1);
    dynamicArray.add(3);
    for (int i = 0; i < dynamicArray.size(); ++i) {
      System.out.println(dynamicArray.get(i));
    }
  }
  
  public static void main(String args[]) {
    DynamicArray dynamicArray = new SortedDynamicArray();
    test(dynamicArray); // 打印结果:1、3、5
  }
}

多态这种特性也需要编程语言提供特殊的语法机制来实现。在上面的例子中,用到了三个语法机制来实现多态:

除了“继承加方法重写”的实现方式,还可以利用接口类实现:

public interface Iterator {
  boolean hasNext();
  String next();
  String remove();
}

public class Array implements Iterator {
  private String[] data;
  
  public boolean hasNext() { ... }
  public String next() { ... }
  public String remove() { ... }
  //...省略其他方法...
}

public class LinkedList implements Iterator {
  private LinkedListNode head;
  
  public boolean hasNext() { ... }
  public String next() { ... }
  public String remove() { ... }
  //...省略其他方法... 
}

public class Demo {
  private static void print(Iterator iterator) {
    while (iterator.hasNext()) {
      System.out.println(iterator.next());
    }
  }
  
  public static void main(String[] args) {
    Iterator arrayIterator = new Array();
    print(arrayIterator);
    
    Iterator linkedListIterator = new LinkedList();
    print(linkedListIterator);
  }
}

也可以用duck-typing实现多态,不过duck-typing只有一些动态语言才支持,比如Python、JavaScript等。

class Logger:
    def record(self):
        print(I write a log into file.)
        
class DB:
    def record(self):
        print(I insert data into db. )
        
def test(recorder):
    recorder.record()

def demo():
    logger = Logger()
    db = DB()
    test(logger)
    test(db)

Logger和DB两个类没有任何关系,既不是继承关系,也不是接口和实现的关系,但是只要它们都有定义了record()方法,就可以被传递到test()方法中,在实际运行的时候,执行对应的record()方法。也就是说,只要两个类具有相同的方法,就可以实现多态,并不要求两个类之间有任何关系,这就是所谓的duck-typing,是一些动态语言所特有的语法机制。

与面向过程比较

面向对象风格与面向过程风格相比有三个优点:

  1. OOP更加能够应对大规模复杂程序的开发

对于简单程序的开发来说,不管是用面向过程编程风格,还是用面向对象编程风格,差别确实不会很大,甚至有的时候,面向过程的编程风格反倒更有优势。因为需求足够简单,整个程序的处理流程只有一条主线,很容易被划分成顺序执行的几个步骤,然后逐句翻译成代码,这就非常适合采用面向过程这种面条式的编程风格来实现。

但对于大规模复杂程序的开发来说,整个程序的处理流程错综复杂,并非只有一条主线。如果把整个程序的处理流程画出来的话,会是一个网状结构。如果我们再用面向过程编程这种流程化、线性的思维方式,去翻译这个网状结构,去思考如何把程序拆解为一组顺序执行的方法,就会比较吃力。这个时候,面向对象的编程风格的优势就比较明显了。

面向对象编程是以类为思考对象。在进行面向对象编程的时候,我们并不是一上来就去思考,如何将复杂的流程拆解为一个一个方法,而是采用曲线救国的策略,先去思考如何给业务建模,如何将需求翻译为类,如何给类之间建立交互关系,而完成这些工作完全不需要考虑错综复杂的处理流程。当我们有了类的设计之后,然后再像搭积木一样,按照处理流程,将类组装起来形成整个程序。这种开发模式、思考问题的方式,能让我们在应对复杂程序开发的时候,思路更加清晰。

除此之外,面向对象编程还提供了一种更加清晰的、更加模块化的代码组织方式。比如,我们开发一个电商交易系统,业务逻辑复杂,代码量很大,可能要定义数百个函数、数百个数据结构,那如何分门别类地组织这些函数和数据结构,才能不至于看起来比较凌乱呢?类就是一种非常好的组织这些函数和数据结构的方式,是一种将代码模块化的有效手段。

  1. OOP风格的代码更易复用、易扩展、易维护

面向过程编程是一种非常简单的编程风格,并没有像面向对象编程那样提供丰富的特性。而面向对象编程提供的封装、抽象、继承、多态这些特性,能极大地满足复杂的编程需求,能方便我们写出更易复用、易扩展、易维护的代码。

  1. OOP语言更加人性化、更加高级、更加智能

但面向对象和面向过程两种编程风格,也并不是非黑即白、完全对立的。在用面向对象编程语言开发的软件中,面向过程风格的代码并不少见,甚至在一些标准的开发库(比如JDK、Apache Commons、Google Guava)中,也有很多面向过程风格的代码。

不管使用面向过程还是面向对象哪种风格来写代码,我们最终的目的还是写出易维护、易读、易复用、易扩展的高质量代码。只要我们能避免面向过程编程风格的一些弊端,控制好它的副作用,在掌控范围内为我们所用,我们就大可不用避讳在面向对象编程中写面向过程风格的代码。

接口 vs 抽象类

抽象类 abstract

下面这段代码是一个比较典型的抽象类的使用场景(模板设计模式)。Logger是一个记录日志的抽象类,FileLogger和MessageQueueLogger继承Logger,分别实现两种不同的日志记录方式:记录日志到文件中和记录日志到消息队列中。FileLogger和MessageQueueLogger两个子类复用了父类Logger中的name、enabled、minPermittedLevel属性和log()方法,但因为这两个子类写日志的方式不同,它们又各自重写了父类中的doLog()方法。

// 抽象类
public abstract class Logger {
  private String name;
  private boolean enabled;
  private Level minPermittedLevel;

  public Logger(String name, boolean enabled, Level minPermittedLevel) {
    this.name = name;
    this.enabled = enabled;
    this.minPermittedLevel = minPermittedLevel;
  }
  
  public void log(Level level, String message) {
    boolean loggable = enabled && (minPermittedLevel.intValue() <= level.intValue());
    if (!loggable) return;
    doLog(level, message);
  }
  
  protected abstract void doLog(Level level, String message);
}
// 抽象类的子类:输出日志到文件
public class FileLogger extends Logger {
  private Writer fileWriter;

  public FileLogger(String name, boolean enabled,
    Level minPermittedLevel, String filepath) {
    super(name, enabled, minPermittedLevel);
    this.fileWriter = new FileWriter(filepath); 
  }
  
  @Override
  public void doLog(Level level, String mesage) {
    // 格式化level和message,输出到日志文件
    fileWriter.write(...);
  }
}
// 抽象类的子类: 输出日志到消息中间件(比如kafka)
public class MessageQueueLogger extends Logger {
  private MessageQueueClient msgQueueClient;
  
  public MessageQueueLogger(String name, boolean enabled,
    Level minPermittedLevel, MessageQueueClient msgQueueClient) {
    super(name, enabled, minPermittedLevel);
    this.msgQueueClient = msgQueueClient;
  }
  
  @Override
  protected void doLog(Level level, String mesage) {
    // 格式化level和message,输出到消息中间件
    msgQueueClient.send(...);
  }
}

抽象类的特性:

接口 interface

下面这段代码是一个比较典型的接口的使用场景。我们通过Java中的interface关键字定义了一个Filter接口。AuthencationFilter和RateLimitFilter是接口的两个实现类,分别实现了对RPC请求鉴权和限流的过滤功能。

// 接口
public interface Filter {
  void doFilter(RpcRequest req) throws RpcException;
}
// 接口实现类:鉴权过滤器
public class AuthencationFilter implements Filter {
  @Override
  public void doFilter(RpcRequest req) throws RpcException {
    //...鉴权逻辑..
  }
}
// 接口实现类:限流过滤器
public class RateLimitFilter implements Filter {
  @Override
  public void doFilter(RpcRequest req) throws RpcException {
    //...限流逻辑...
  }
}
// 过滤器使用Demo
public class Application {
  // filters.add(new AuthencationFilter());
  // filters.add(new RateLimitFilter());
  private List<Filter> filters = new ArrayList<>();
  
  public void handleRpcRequest(RpcRequest req) {
    try {
      for (Filter filter : filters) {
        filter.doFilter(req);
      }
    } catch(RpcException e) {
      // ...处理过滤结果...
    }
    // ...省略其他处理逻辑...
  }
}

接口的特性:

比较 abstract class VS interface

从语法特性上对比,这两者有比较大的区别,比如抽象类中可以定义属性、方法的实现,而接口中不能定义属性,方法也不能包含代码实现等等。

从设计的角度,抽象类实际上就是类,只不过是一种特殊的类,这种类不能被实例化为对象,只能被子类继承。我们知道,继承关系是一种is-a的关系,那抽象类既然属于类,也表示一种is-a的关系。相对于抽象类的is-a关系来说,接口表示一种has-a关系,表示具有某些功能。对于接口,有一个更加形象的叫法,那就是协议(contract)。

基于接口而非实现编程

“基于接口而非实现编程”这条原则的英文描述是:“Program to an interface, not an implementation”。我们理解这条原则的时候,千万不要一开始就与具体的编程语言挂钩,局限在编程语言的“接口”语法中(比如Java中的interface接口语法)。这条原则最早出现于1994年GoF的《设计模式》这本书,它先于很多编程语言而诞生(比如Java语言),是一条比较抽象、泛化的设计思想。

实际上,理解这条原则的关键,就是理解其中的“接口”两个字。还记得我们上一节课讲的“接口”的定义吗?从本质上来看,“接口”就是一组“协议”或者“约定”,是功能提供者提供给使用者的一个“功能列表”。“接口”在不同的应用场景下会有不同的解读,比如服务端与客户端之间的“接口”,类库提供的“接口”,甚至是一组通信的协议都可以叫作“接口”。刚刚对“接口”的理解,都比较偏上层、偏抽象,与实际的写代码离得有点远。如果落实到具体的编码,“基于接口而非实现编程”这条原则中的“接口”,可以理解为编程语言中的接口或者抽象类。

前面我们提到,这条原则能非常有效地提高代码质量,之所以这么说,那是因为,应用这条原则,可以将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低耦合性,提高扩展性。

实际上,“基于接口而非实现编程”这条原则的另一个表述方式,是“基于抽象而非实现编程”。后者的表述方式其实更能体现这条原则的设计初衷。在软件开发中,最大的挑战之一就是需求的不断变化,这也是考验代码设计好坏的一个标准。**越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情况下灵活应对。**而抽象就是提高代码扩展性、灵活性、可维护性最有效的手段之一。

多用组合少用继承?

继承是面向对象的四大特性之一,用来表示类之间的is-a关系,可以解决代码复用的问题。虽然继承有诸多作用,但继承层次过深、过复杂,也会影响到代码的可维护性。

假设我们要设计一个关于鸟的类。我们将“鸟类”这样一个抽象的事物概念,定义为一个抽象类AbstractBird。所有更细分的鸟,比如麻雀、鸽子、乌鸦等,都继承这个抽象类。

我们知道,大部分鸟都会飞,那我们可不可以在AbstractBird抽象类中,定义一个fly()方法呢?答案是否定的。尽管大部分鸟都会飞,但也有特例,比如鸵鸟就不会飞。鸵鸟继承具有fly()方法的父类,那鸵鸟就具有“飞”这样的行为,这显然不符合我们对现实世界中事物的认识。

你可能又会说,那我们再通过AbstractBird类派生出两个更加细分的抽象类:会飞的鸟类AbstractFlyableBird和不会飞的鸟类AbstractUnFlyableBird,让麻雀、乌鸦这些会飞的鸟都继承AbstractFlyableBird,让鸵鸟、企鹅这些不会飞的鸟,都继承AbstractUnFlyableBird类,不就可以了吗?

整体上来讲,目前的继承关系还比较简单,层次比较浅,也算是一种可以接受的设计思路。我们再继续加点难度。在刚刚这个场景中,我们只关注“鸟会不会飞”,但如果我们还关注“鸟会不会叫”,那这个时候,我们又该如何设计类之间的继承关系呢?

是否会飞?是否会叫?两个行为搭配起来会产生四种情况:会飞会叫、不会飞会叫、会飞不会叫、不会飞不会叫。如果我们继续沿用刚才的设计思路,那就需要再定义四个抽象类(AbstractFlyableTweetableBird、AbstractFlyableUnTweetableBird、AbstractUnFlyableTweetableBird、AbstractUnFlyableUnTweetableBird)。

如果我们还需要考虑“是否会下蛋”这样一个行为,那估计就要组合爆炸了。类的继承层次会越来越深、继承关系会越来越复杂。而这种层次很深、很复杂的继承关系,一方面,会导致代码的可读性变差。因为我们要搞清楚某个类具有哪些方法、属性,必须阅读父类的代码、父类的父类的代码……一直追溯到最顶层父类的代码。另一方面,这也破坏了类的封装特性,将父类的实现细节暴露给了子类。子类的实现依赖父类的实现,两者高度耦合,一旦父类代码修改,就会影响所有子类的逻辑。

实际上,我们可以利用组合(composition)、接口、委托(delegation)三个技术手段,一块儿来解决刚刚继承存在的问题。

接口表示具有某种行为特性。针对“会飞”这样一个行为特性,我们可以定义一个Flyable接口,只让会飞的鸟去实现这个接口。对于会叫、会下蛋这些行为特性,我们可以类似地定义Tweetable接口、EggLayable接口。

public interface Flyable {
  void fly();
}
public interface Tweetable {
  void tweet();
}
public interface EggLayable {
  void layEgg();
}
public class Ostrich implements Tweetable, EggLayable {//鸵鸟
  //... 省略其他属性和方法...
  @Override
  public void tweet() { //... }
  @Override
  public void layEgg() { //... }
}
public class Sparrow impelents Flyable, Tweetable, EggLayable {//麻雀
  //... 省略其他属性和方法...
  @Override
  public void fly() { //... }
  @Override
  public void tweet() { //... }
  @Override
  public void layEgg() { //... }
}

不过,我们知道,接口只声明方法,不定义实现。也就是说,每个会下蛋的鸟都要实现一遍layEgg()方法,并且实现逻辑是一样的,这就会导致代码重复的问题。那这个问题又该如何解决呢?

我们可以针对三个接口再定义三个实现类,它们分别是:实现了fly()方法的FlyAbility类、实现了tweet()方法的TweetAbility类、实现了layEgg()方法的EggLayAbility类。然后,通过组合和委托技术来消除代码重复。具体的代码实现如下所示:

public interface Flyable {
  void fly()
}
public class FlyAbility implements Flyable {
  @Override
  public void fly() { //... }
}
//省略Tweetable/TweetAbility/EggLayable/EggLayAbility

public class Ostrich implements Tweetable, EggLayable {//鸵鸟
  private TweetAbility tweetAbility = new TweetAbility(); //组合
  private EggLayAbility eggLayAbility = new EggLayAbility(); //组合
  //... 省略其他属性和方法...
  @Override
  public void tweet() {
    tweetAbility.tweet(); // 委托
  }
  @Override
  public void layEgg() {
    eggLayAbility.layEgg(); // 委托
  }
}

我们知道继承主要有三个作用:表示is-a关系,支持多态特性,代码复用。而这三个作用都可以通过其他技术手段来达成。比如is-a关系,我们可以通过组合和接口的has-a关系来替代;多态特性我们可以利用接口来实现;代码复用我们可以通过组合和委托来实现。所以,从理论上讲,通过组合、接口、委托三个技术手段,我们完全可以替换掉继承,在项目中不用或者少用继承关系,特别是一些复杂的继承关系。


尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。从上面的例子来看,继承改写成组合意味着要做更细粒度的类的拆分。这也就意味着,我们要定义更多的类和接口。类和接口的增多也就或多或少地增加代码的复杂程度和维护成本。

如果类之间的继承结构稳定(不会轻易改变),继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承。反之,系统越不稳定,继承层次很深,继承关系复杂,我们就尽量使用组合来替代继承。

除此之外,还有一些设计模式会固定使用继承或者组合。比如,装饰者模式(decorator pattern)、策略模式(strategy pattern)、组合模式(composite pattern)等都使用了组合关系,而模板模式(template pattern)使用了继承关系。

基于贫血模型的传统开发模式

MVC三层架构中的M表示Model,V表示View,C表示Controller。它将整个项目分为三层:展示层、逻辑层、数据层。MVC三层开发架构是一个比较笼统的分层方式,落实到具体的开发层面,很多项目也并不会100%遵从MVC固定的分层方式,而是会根据具体的项目需求,做适当的调整。

比如,现在很多Web或者App项目都是前后端分离的,后端负责暴露接口给前端调用。这种情况下,我们一般就将后端项目分为Repository层、Service层、Controller层。其中,Repository层负责数据访问,Service层负责业务逻辑,Controller层负责暴露接口。

下面是贫血模型的一个简单例子:

////////// Controller+VO(View Object) //////////
public class UserController {
  private UserService userService; //通过构造函数或者IOC框架注入
  
  public UserVo getUserById(Long userId) {
    UserBo userBo = userService.getUserById(userId);
    UserVo userVo = [...convert userBo to userVo...];
    return userVo;
  }
}

public class UserVo {//省略其他属性、get/set/construct方法
  private Long id;
  private String name;
  private String cellphone;
}

////////// Service+BO(Business Object) //////////
public class UserService {
  private UserRepository userRepository; //通过构造函数或者IOC框架注入
  
  public UserBo getUserById(Long userId) {
    UserEntity userEntity = userRepository.getUserById(userId);
    UserBo userBo = [...convert userEntity to userBo...];
    return userBo;
  }
}

public class UserBo {//省略其他属性、get/set/construct方法
  private Long id;
  private String name;
  private String cellphone;
}

////////// Repository+Entity //////////
public class UserRepository {
  public UserEntity getUserById(Long userId) { //... }
}

public class UserEntity {//省略其他属性、get/set/construct方法
  private Long id;
  private String name;
  private String cellphone;
}

UserEntity和UserRepository组成了数据访问层,UserBo和UserService组成了业务逻辑层,UserVo和UserController在这里属于接口层。

从代码中,我们可以发现,UserBo是一个纯粹的数据结构,只包含数据,不包含任何业务逻辑。业务逻辑集中在UserService中。我们通过UserService来操作UserBo。换句话说,Service层的数据和业务逻辑,被分割为BO和Service两个类中。像UserBo这样,只包含数据,不包含业务逻辑的类,就叫作贫血模型(Anemic Domain Model)。同理,UserEntity、UserVo都是基于贫血模型设计的。这种贫血模型将数据与操作分离,破坏了面向对象的封装特性,是一种典型的面向过程的编程风格。

基于充血模型的DDD开发模式

在贫血模型中,数据和业务逻辑被分割到不同的类中。充血模型(Rich Domain Model)正好相反,数据和对应的业务逻辑被封装到同一个类中。因此,这种充血模型满足面向对象的封装特性,是典型的面向对象编程风格。

领域驱动设计,即DDD,主要是用来指导如何解耦业务系统,划分业务模块,定义业务领域模型及其交互。

在基于贫血模型的传统开发模式中,Service层包含Service类和BO类两部分,BO是贫血模型,只包含数据,不包含具体的业务逻辑。业务逻辑集中在Service类中。在基于充血模型的DDD开发模式中,Service层包含Service类和Domain类两部分。Domain就相当于贫血模型中的BO。不过,Domain与BO的区别在于它是基于充血模型开发的,既包含数据,也包含业务逻辑。而Service类变得非常单薄。总结一下的话就是,基于贫血模型的传统的开发模式,重Service轻BO;基于充血模型的DDD开发模式,轻Service重Domain。

实战例子:接口鉴权的面向对象分析&设计&编程

假设,你正在参与开发一个微服务。微服务通过HTTP协议暴露接口给其他系统调用,说直白点就是,其他系统通过URL来调用微服务的接口。有一天,你的leader找到你说,“为了保证接口调用的安全性,我们希望设计实现一个接口调用鉴权功能,只有经过认证之后的系统才能调用我们的接口,没有认证过的系统调用我们的接口会被拒绝。我希望由你来负责这个任务的开发,争取尽快上线。”

面向对象分析

需求分析

  1. 第一轮基础分析

对于如何做鉴权这样一个问题,最简单的解决方案就是,通过用户名加密码来做认证。我们给每个允许访问我们服务的调用方,派发一个应用名(或者叫应用ID、AppID)和一个对应的密码(或者叫秘钥)。调用方每次进行接口请求的时候,都携带自己的AppID和密码。微服务在接收到接口调用请求之后,会解析出AppID和密码,跟存储在微服务端的AppID和密码进行比对。如果一致,说明认证成功,则允许接口调用请求;否则,就拒绝接口调用请求。

  1. 第二轮分析优化

不过,这样的验证方式,每次都要明文传输密码。密码很容易被截获,是不安全的。那如果我们借助加密算法(比如SHA),对密码进行加密之后,再传递到微服务端验证,是不是就可以了呢?实际上,这样也是不安全的,因为加密之后的密码及AppID,照样可以被未认证系统(或者说黑客)截获,未认证系统可以携带这个加密之后的密码以及对应的AppID,伪装成已认证系统来访问我们的接口。这就是典型的“重放攻击”。

提出问题,然后再解决问题,是一个非常好的迭代优化方法。对于刚刚这个问题,我们可以借助OAuth的验证思路来解决。调用方将请求接口的URL跟AppID、密码拼接在一起,然后进行加密,生成一个token。调用方在进行接口请求的的时候,将这个token及AppID,随URL一块传递给微服务端。微服务端接收到这些数据之后,根据AppID从数据库中取出对应的密码,并通过同样的token生成算法,生成另外一个token。用这个新生成的token跟调用方传递过来的token对比。如果一致,则允许接口调用请求;否则,就拒绝接口调用请求。

  1. 第三轮分析优化

不过,这样的设计仍然存在重放攻击的风险,还是不够安全。每个URL拼接上AppID、密码生成的token都是固定的。未认证系统截获URL、token和AppID之后,还是可以通过重放攻击的方式,伪装成认证系统,调用这个URL对应的接口。

为了解决这个问题,我们可以进一步优化token生成算法,引入一个随机变量,让每次接口请求生成的token都不一样。我们可以选择时间戳作为随机变量。原来的token是对URL、AppID、密码三者进行加密生成的,现在我们将URL、AppID、密码、时间戳四者进行加密来生成token。调用方在进行接口请求的时候,将token、AppID、时间戳,随URL一并传递给微服务端。

微服务端在收到这些数据之后,会验证当前时间戳跟传递过来的时间戳,是否在一定的时间窗口内(比如一分钟)。如果超过一分钟,则判定token过期,拒绝接口请求。如果没有超过一分钟,则说明token没有过期,就再通过同样的token生成算法,在服务端生成新的token,与调用方传递过来的token比对,看是否一致。如果一致,则允许接口调用请求;否则,就拒绝接口调用请求。

  1. 第四轮分析优化

不过,你可能会说,这样还是不够安全啊。未认证系统还是可以在这一分钟的token失效窗口内,通过截获请求、重放请求,来调用我们的接口啊!

你说得没错。不过,攻与防之间,本来就没有绝对的安全。我们能做的就是,尽量提高攻击的成本。这个方案虽然还有漏洞,但是实现起来足够简单,而且不会过度影响接口本身的性能(比如响应时间)。所以,权衡安全性、开发成本、对系统性能的影响,这个方案算是比较折中、比较合理的了。

实际上,还有一个细节我们没有考虑到,那就是,如何在微服务端存储每个授权调用方的AppID和密码。当然,这个问题并不难。最容易想到的方案就是存储到数据库里,比如MySQL。不过,开发像鉴权这样的非业务功能,最好不要与具体的第三方系统有过度的耦合。

针对AppID和密码的存储,我们最好能灵活地支持各种不同的存储方式,比如ZooKeeper、本地配置文件、自研配置中心、MySQL、Redis等。我们不一定针对每种存储方式都去做代码实现,但起码要留有扩展点,保证系统有足够的灵活性和扩展性,能够在我们切换存储方式的时候,尽可能地减少代码的改动。

  1. 最终确定需求

到此,需求已经足够细化和具体了。现在,我们按照鉴权的流程,对需求再重新描述一下。如果你熟悉UML,也可以用时序图、流程图来描述。不过,用什么描述不是重点,描述清楚才是最重要的。考虑到在接下来的面向对象设计环节中,我会基于文字版本的需求描述,来进行类、属性、方法、交互等的设计,所以,这里我给出的最终需求描述是文字版本的。

这就是我们需求分析的整个思考过程,从最粗糙、最模糊的需求开始,通过“提出问题-解决问题”的方式,循序渐进地进行优化,最后得到一个足够清晰、可落地的需求描述。

面向对象设计

1. 划分职责进而识别出有哪些类

首先,我们要做的是逐句阅读上面的需求描述,拆解成小的功能点,一条一条罗列下来。注意,拆解出来的每个功能点要尽可能的小。每个功能点只负责做一件很小的事情(专业叫法是“单一职责”,后面章节中我们会讲到)。下面是我逐句拆解上述需求描述之后,得到的功能点列表:

  1. 把URL、AppID、密码、时间戳拼接为一个字符串;
  2. 对字符串通过加密算法加密生成token;
  3. 将token、AppID、时间戳拼接到URL中,形成新的URL;
  4. 解析URL,得到token、AppID、时间戳等信息;
  5. 从存储中取出AppID和对应的密码;
  6. 根据时间戳判断token是否过期失效;
  7. 验证两个token是否匹配;

从上面的功能列表中,我们发现,1、2、6、7都是跟token有关,负责token的生成、验证;3、4都是在处理URL,负责URL的拼接、解析;5是操作AppID和密码,负责从存储中读取AppID和密码。所以,我们可以粗略地得到三个核心的类:AuthToken、Url、CredentialStorage。AuthToken负责实现1、2、6、7这四个操作;Url负责3、4两个操作;CredentialStorage负责5这个操作。

当然,这是一个初步的类的划分,其他一些不重要的、边边角角的类,我们可能暂时没法一下子想全,但这也没关系,面向对象分析、设计、编程本来就是一个循环迭代、不断优化的过程。根据需求,我们先给出一个粗糙版本的设计方案,然后基于这样一个基础,再去迭代优化,会更加容易一些,思路也会更加清晰一些。

2. 定义类及其属性和方法

AuthToken类相关的功能点有四个:

对于方法的识别,很多面向对象相关的书籍,一般都是这么讲的,识别出需求描述中的动词,作为候选的方法,再进一步过滤筛选。类比一下方法的识别,我们可以把功能点中涉及的名词,作为候选属性,然后同样进行过滤筛选。

我们可以借用这个思路,根据功能点描述,识别出来AuthToken类的属性和方法,如下所示:

img

从上面的类图中,我们可以发现这样三个小细节。

第一个细节告诉我们,从业务模型上来说,不应该属于这个类的属性和方法,不应该被放到这个类里。比如URL、AppID这些信息,从业务模型上来说,不应该属于AuthToken,所以我们不应该放到这个类中。

第二、第三个细节告诉我们,在设计类具有哪些属性和方法的时候,不能单纯地依赖当下的需求,还要分析这个类从业务模型上来讲,理应具有哪些属性和方法。这样可以一方面保证类定义的完整性,另一方面不仅为当下的需求还为未来的需求做些准备。

Url类相关的功能点有两个:

虽然需求描述中,我们都是以URL来代指接口请求,但是,接口请求并不一定是以URL的形式来表达,还有可能是Dubbo、RPC等其他形式。为了让这个类更加通用,命名更加贴切,我们接下来把它命名为ApiRequest。下面是我根据功能点描述设计的ApiRequest类。

img

CredentialStorage类相关的功能点有一个:

CredentialStorage类非常简单,类图如下所示。为了做到抽象封装具体的存储方式,我们将CredentialStorage设计成了接口,基于接口而非具体的实现编程。

img

3. 定义类与类之间的交互关系

UML统一建模语言中定义了六种类之间的关系。它们分别是:泛化、实现、关联、聚合、组合、依赖。

因为目前只有三个核心的类,所以只用到了实现关系,也即CredentialStorage和MysqlCredentialStorage之间的关系。接下来讲到组装类的时候,我们还会用到依赖关系、组合关系,但是泛化关系暂时没有用到。

4. 将类组装起来并提供执行入口

类定义好了,类之间必要的交互关系也设计好了,接下来我们要将所有的类组装在一起,提供一个执行入口。这个入口可能是一个main()函数,也可能是一组给外部用的API接口。通过这个入口,我们能触发整个代码跑起来。

接口鉴权并不是一个独立运行的系统,而是一个集成在系统上运行的组件,所以,我们封装所有的实现细节,设计了一个最顶层的ApiAuthenticator接口类,暴露一组给外部调用者使用的API接口,作为触发执行鉴权逻辑的入口。具体的类的设计如下所示:

img

二、设计原则

单一职责原则(SRP)

单一职责原则的英文是Single Responsibility Principle,缩写为SRP。这个原则的英文描述是这样的:A class or module should have a single responsibility。如果我们把它翻译成中文,那就是:一个类或者模块只负责完成一个职责(或者功能)。

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

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

举个例子,在一个社交产品中,我们用下面的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等)抽取成独立的类。

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

综上所述,评价一个类的职责是否足够单一,我们并没有一个非常明确的、可以量化的标准,可以说,这是件非常主观、仁者见仁智者见智的事情。实际上,在真正的软件开发中,我们也没必要过于未雨绸缪,过度设计。所以,我们可以先写一个粗粒度的类,满足业务需求。随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多,这个时候,我们就可以将这个粗粒度的类,拆分成几个更细粒度的类。这就是所谓的持续重构。

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

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

/**
 * 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)

开闭原则的英文全称是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代码,让它的扩展性更好一些。重构的内容主要包含两部分:

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

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

现在再来看下,基于重构之后的代码,如果再添加上面讲到的那个新功能,该如何改动代码?主要的改动有四处:

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

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

上面的例子涉及了四处改动:

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

实际上,我们不仅往ApiStatInfo类中添加了属性,还添加了对应的getter/setter方法。那这个问题就转化为:给类中添加新的属性和方法,算作“修改”还是“扩展”?

我们再一块回忆一下开闭原则的定义:软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。从定义中,我们可以看出,开闭原则可以应用在不同粒度的代码中,可以是模块,也可以类,还可以是方法(及其属性)。同样一个代码改动,在粗代码粒度下,被认定为“修改”,在细代码粒度下,又可以被认定为“扩展”。比如,改动一,添加属性和方法相当于修改类,在类这个层面,这个代码改动可以被认定为“修改”;但这个代码改动并没有修改已有的属性和方法,在方法(及其属性)这一层面,它又可以被认定为“扩展”。

实际上,我们也没必要纠结某个代码改动是“修改”还是“扩展”,更没必要太纠结它是否违反“开闭原则”。我们回到这条原则的设计初衷:只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试,我们就可以说,这是一个合格的代码改动。

我们再来分析一下改动三和改动四:在ApplicationContext类的initializeBeans()方法中,往alert对象中注册新的timeoutAlertHandler;在使用Alert类的时候,需要给check()函数的入参apiStatInfo对象设置timeoutCount的值。

这两处改动都是在方法内部进行的,不管从哪个层面(模块、类、方法)来讲,都不能算是“扩展”,而是地地道道的“修改”。不过,有些修改是在所难免的,是可以被接受的。为什么这么说呢?我来解释一下。

在重构之后的Alert代码中,我们的核心逻辑集中在Alert类及其各个handler中,当我们在添加新的告警逻辑的时候,Alert类完全不需要修改,而只需要扩展一个新handler类。如果我们把Alert类及各个handler类合起来看作一个“模块”,那模块本身在添加新的功能的时候,完全满足开闭原则。

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

里式替换原则(LSP)

里式替换原则的英文翻译是:Liskov Substitution Principle,缩写为LSP。这个原则最早是在1986年由Barbara Liskov提出,他是这么描述这条原则的:

If S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program。

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

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

我们还是通过刚才这个例子来解释一下。不过,我们需要对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()订单排序函数之后,是按照创建日期来给订单排序的。那子类的设计就违背里式替换原则。

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

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

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

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

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

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

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

接口隔离原则(ISP)

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

实际上,“接口”这个名词可以用在很多场合中。生活中我们可以用它来指插座接口等。在软件开发中,我们既可以把它看作一组抽象的约定,也可以具体指系统与系统之间的API接口,还可以特指面向对象编程语言中的接口等。

如何理解?

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

把“接口”理解为一组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接口或函数

现在我们再换一种理解方式,把接口理解为单个接口或函数。那接口隔离原则就可以理解为:函数的设计要功能单一,不要将多个不同的功能逻辑在一个函数中实现。

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中的接口概念

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

假设我们的项目中用到了三个外部系统:Redis、MySQL、Kafka。每个系统都对应一系列配置信息,比如地址、端口、访问超时时间等。为了在内存中存储这些配置信息,供项目中的其他模块来使用,我们分别设计实现了三个Configuration类:RedisConfig、MysqlConfig、KafkaConfig。具体的代码实现如下所示:

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 implements 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);
    redisConfigUpdater.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接口,也满足接口隔离原则。

你可能会说,如果我们不遵守接口隔离原则,不设计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的代码实现。**第二种设计思路在代码实现上做了一些无用功。**因为Config接口中包含两类不相关的接口,一类是update(),一类是output()和outputInPlainText()。理论上,KafkaConfig只需要实现update()接口,并不需要实现output()相关的接口。同理,MysqlConfig只需要实现output()相关接口,并需要实现update()接口。但第二种设计思路要求RedisConfig、KafkaConfig、MySqlConfig必须同时实现Config的所有接口函数(update、output、outputInPlainText)。除此之外,如果我们要往Config中继续添加一个新的接口,那所有的实现类都要改动。相反,如果我们的接口粒度比较小,那涉及改动的类就比较少。

依赖反转原则

控制反转(IOC)

控制反转的英文翻译是Inversion Of Control,缩写为IOC。此处要强调一下,如果你是Java工程师的话,暂时别把这个“IOC”跟Spring框架的IOC联系在一起。

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

在上面的代码中,所有的流程都由程序员来控制。如果我们抽象出一个下面这样一个框架,我们再来看,如何利用框架来实现同样的功能。具体的代码实现如下所示:

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 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 case: testCases) {
      case.run();
    }
  }

把这个简化版本的测试框架引入到工程中之后,我们只需要在框架预留的扩展点,也就是TestCase类中的doTest()抽象函数中,填充具体的测试代码就可以实现之前的功能了,完全不需要写负责执行流程的main()函数了。 具体的代码如下所示:

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

// 注册操作还可以通过配置的方式来实现,不需要程序员显示调用register()
JunitApplication.register(new UserServiceTest();

刚刚举的这个例子,就是典型的通过框架来实现“控制反转”的例子。框架提供了一个可扩展的代码骨架,用来组装对象、管理整个执行流程。程序员利用框架进行开发的时候,只需要往预留的扩展点上,添加跟自己业务相关的代码,就可以利用框架来驱动整个程序流程的执行。

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

依赖注入(DI)

依赖注入跟控制反转恰恰相反,它是一种具体的编码技巧。依赖注入的英文翻译是Dependency Injection,缩写为DI。对于这个概念,有一个非常形象的说法,那就是:依赖注入是一个标价25美元,实际上只值5美分的概念。也就是说,这个概念听起来很“高大上”,实际上,理解、应用起来非常简单。

那到底什么是依赖注入呢?我们用一句话来概括就是:不通过new()的方式在类内部创建依赖类对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类使用。

我们还是通过一个例子来解释一下。在这个例子中,Notification类负责消息推送,依赖MessageSender类实现推送商品促销、验证码等消息给用户。我们分别用依赖注入和非依赖注入两种方式来实现一下。具体的实现代码如下所示:

// 非依赖注入实现方式
public class Notification {
  private MessageSender messageSender;
  
  public Notification() {
    this.messageSender = new MessageSender(); //此处有点像hardcode
  }
  
  public void sendMessage(String cellphone, String message) {
    //...省略校验逻辑等...
    this.messageSender.send(cellphone, message);
  }
}

public class MessageSender {
  public void send(String cellphone, String message) {
    //....
  }
}
// 使用Notification
Notification notification = new Notification();

// 依赖注入的实现方式
public class Notification {
  private MessageSender messageSender;
  
  // 通过构造函数将messageSender传递进来
  public Notification(MessageSender messageSender) {
    this.messageSender = messageSender;
  }
  
  public void sendMessage(String cellphone, String message) {
    //...省略校验逻辑等...
    this.messageSender.send(cellphone, message);
  }
}
//使用Notification
MessageSender messageSender = new MessageSender();
Notification notification = new Notification(messageSender);

通过依赖注入的方式来将依赖的类对象传递进来,这样就提高了代码的扩展性,我们可以灵活地替换依赖的类。这一点在我们之前讲“开闭原则”的时候也提到过。当然,上面代码还有继续优化的空间,我们还可以把MessageSender定义成接口,基于接口而非实现编程。

依赖注入框架(DI Framework)

弄懂了什么是“依赖注入”,我们再来看一下,什么是“依赖注入框架”。我们还是借用刚刚的例子来解释。

在采用依赖注入实现的Notification类中,虽然我们不需要用类似hard code的方式,在类内部通过new来创建MessageSender对象,但是,这个创建对象、组装(或注入)对象的工作仅仅是被移动到了更上层代码而已,还是需要我们程序员自己来实现。具体代码如下所示:

public class Demo {
  public static final void main(String args[]) {
    MessageSender sender = new SmsSender(); //创建对象
    Notification notification = new Notification(sender);//依赖注入
    notification.sendMessage("13918942177", "短信验证码:2346");
  }
}

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

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

依赖反转原则(DIP)

依赖反转原则的英文翻译是Dependency Inversion Principle,缩写为DIP。中文翻译有时候也叫依赖倒置原则。为了追本溯源,先给出这条原则最原汁原味的英文描述:

High-level modules shouldn’t depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn’t depend on details. Details depend on abstractions.

我们将它翻译成中文,大概意思就是:高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。

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

KISS原则

KISS原则的英文描述有好几个版本,比如下面这几个。

不过,仔细看你就会发现,它们要表达的意思其实差不多,翻译成中文就是:尽量保持简单。

写出满足KISS原则代码的方法:

YAGNI原则的英文全称是:You Ain’t Gonna Need It。直译就是:你不会需要它。这条原则也算是万金油了。当用在软件开发中的时候,它的意思是:不要去设计当前用不到的功能;不要去编写当前用不到的代码。实际上,这条原则的核心思想就是:不要做过度设计。

YAGNI原则跟KISS原则并非一回事儿。KISS原则讲的是“如何做”的问题(尽量保持简单),而YAGNI原则说的是“要不要做”的问题(当前不需要的就不要做)。

DRY原则

DRY原则的英文描述为:Don’t Repeat Yourself。中文直译为:不要重复自己。将它应用在编程中,可以理解为:不要写重复的代码。

三种典型的代码重复情况分别是:实现逻辑重复、功能语义重复和代码执行重复。这三种代码重复,有的看似违反DRY,实际上并不违反;有的看似不违反,实际上却违反了。

实现逻辑重复

public class UserAuthenticator {
  public void authenticate(String username, String password) {
    if (!isValidUsername(username)) {
      // ...throw InvalidUsernameException...
    }
    if (!isValidPassword(password)) {
      // ...throw InvalidPasswordException...
    }
    //...省略其他代码...
  }

  private boolean isValidUsername(String username) {
    // check not null, not empty
    if (StringUtils.isBlank(username)) {
      return false;
    }
    // check length: 4~64
    int length = username.length();
    if (length < 4 || length > 64) {
      return false;
    }
    // contains only lowcase characters
    if (!StringUtils.isAllLowerCase(username)) {
      return false;
    }
    // contains only a~z,0~9,dot
    for (int i = 0; i < length; ++i) {
      char c = username.charAt(i);
      if (!(c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.') {
        return false;
      }
    }
    return true;
  }

  private boolean isValidPassword(String password) {
    // check not null, not empty
    if (StringUtils.isBlank(password)) {
      return false;
    }
    // check length: 4~64
    int length = password.length();
    if (length < 4 || length > 64) {
      return false;
    }
    // contains only lowcase characters
    if (!StringUtils.isAllLowerCase(password)) {
      return false;
    }
    // contains only a~z,0~9,dot
    for (int i = 0; i < length; ++i) {
      char c = password.charAt(i);
      if (!(c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.') {
        return false;
      }
    }
    return true;
  }
}

在代码中,有两处非常明显的重复的代码片段:isValidUserName()函数和isValidPassword()函数。重复的代码被敲了两遍,或者简单copy-paste了一下,看起来明显违反DRY原则。为了移除重复的代码,我们对上面的代码做下重构,将isValidUserName()函数和isValidPassword()函数,合并为一个更通用的函数isValidUserNameOrPassword()。重构后的代码如下所示:

public class UserAuthenticatorV2 {

  public void authenticate(String userName, String password) {
    if (!isValidUsernameOrPassword(userName)) {
      // ...throw InvalidUsernameException...
    }

    if (!isValidUsernameOrPassword(password)) {
      // ...throw InvalidPasswordException...
    }
  }

  private boolean isValidUsernameOrPassword(String usernameOrPassword) {
    //省略实现逻辑
    //跟原来的isValidUsername()或isValidPassword()的实现逻辑一样...
    return true;
  }
}

经过重构之后,代码行数减少了,也没有重复的代码了,是不是更好了呢?答案是否定的,这可能跟你预期的不一样,我来解释一下为什么。

单从名字上看,我们就能发现,合并之后的isValidUserNameOrPassword()函数,负责两件事情:验证用户名和验证密码,违反了“单一职责原则”和“接口隔离原则”。实际上,即便将两个函数合并成isValidUserNameOrPassword(),代码仍然存在问题。

因为isValidUserName()和isValidPassword()两个函数,虽然从代码实现逻辑上看起来是重复的,但是从语义上并不重复。所谓“语义不重复”指的是:从功能上来看,这两个函数干的是完全不重复的两件事情,一个是校验用户名,另一个是校验密码。尽管在目前的设计中,两个校验逻辑是完全一样的,但如果按照第二种写法,将两个函数的合并,那就会存在潜在的问题。

功能语义重复

在同一个项目代码中有下面两个函数:isValidIp()和checkIfIpValid()。尽管两个函数的命名不同,实现逻辑不同,但功能是相同的,都是用来判定IP地址是否合法的。

之所以在同一个项目中会有两个功能相同的函数,那是因为这两个函数是由两个不同的同事开发的,其中一个同事在不知道已经有了isValidIp()的情况下,自己又定义并实现了同样用来校验IP地址是否合法的checkIfIpValid()函数。

那在同一项目代码中,存在如下两个函数,是否违反DRY原则呢?

上一个例子是代码实现逻辑重复,但语义不重复,我们并不认为它违反了DRY原则。而在这个例子中,尽管两段代码的实现逻辑不重复,但语义重复,也就是功能重复,我们认为它违反了DRY原则。我们应该在项目中,统一一种实现思路,所有用到判断IP地址是否合法的地方,都统一调用同一个函数。

代码执行重复

UserService中login()函数用来校验用户登录是否成功。如果失败,就返回异常;如果成功,就返回用户信息。具体代码如下所示:

public class UserService {
  private UserRepo userRepo;//通过依赖注入或者IOC框架注入

  public User login(String email, String password) {
    boolean existed = userRepo.checkIfUserExisted(email, password);
    if (!existed) {
      // ... throw AuthenticationFailureException...
    }
    User user = userRepo.getUserByEmail(email);
    return user;
  }
}

public class UserRepo {
  public boolean checkIfUserExisted(String email, String password) {
    if (!EmailValidation.validate(email)) {
      // ... throw InvalidEmailException...
    }

    if (!PasswordValidation.validate(password)) {
      // ... throw InvalidPasswordException...
    }

    //...query db to check if email&password exists...
  }

  public User getUserByEmail(String email) {
    if (!EmailValidation.validate(email)) {
      // ... throw InvalidEmailException...
    }
    //...query db to get user by email...
  }
}

在login()函数中,email的校验逻辑被执行了两次。一次是在调用checkIfUserExisted()函数的时候,另一次是调用getUserByEmail()函数的时候。这个问题解决起来比较简单,我们只需要将校验逻辑从UserRepo中移除,统一放到UserService中就可以了。

除此之外,代码中还有一处比较隐蔽的执行重复,实际上,login()函数并不需要调用checkIfUserExisted()函数,只需要调用一次getUserByEmail()函数,从数据库中获取到用户的email、password等信息,然后跟用户输入的email、password信息做对比,依次判断是否登录成功。

实际上,这样的优化是很有必要的。因为checkIfUserExisted()函数和getUserByEmail()函数都需要查询数据库,而数据库这类的I/O操作是比较耗时的。我们在写代码的时候,应当尽量减少这类I/O操作。

代码复用性(Code Reusability)

代码复用(Code Resue)表示一种行为:我们在开发新功能的时候,尽量复用已经存在的代码。代码的可复用性(Code Reusability)表示一段代码可被复用的特性或能力:我们在编写代码的时候,让代码尽量可复用。DRY原则是一条原则:不要写重复的代码。从定义描述上,它们好像有点类似,但深究起来,三者的区别还是蛮大的。

**首先,“不重复”并不代表“可复用”。**在一个项目代码中,可能不存在任何重复的代码,但也并不表示里面有可复用的代码,不重复和可复用完全是两个概念。所以,从这个角度来说,DRY原则跟代码的可复用性讲的是两回事。

**其次,“复用”和“可复用性”关注角度不同。**代码“可复用性”是从代码开发者的角度来讲的,“复用”是从代码使用者的角度来讲的。比如,A同事编写了一个UrlUtils类,代码的“可复用性”很好。B同事在开发新功能的时候,直接“复用”A同事编写的UrlUtils类。

怎么提高代码复用性?

对于高度耦合的代码,当我们希望复用其中的一个功能,想把这个功能的代码抽取出来成为一个独立的模块、类或者函数的时候,往往会发现牵一发而动全身。移动一点代码,就要牵连到很多其他相关的代码。所以,高度耦合的代码会影响到代码的复用性,我们要尽量减少代码耦合。

我们前面讲过,如果职责不够单一,模块、类设计得大而全,那依赖它的代码或者它依赖的代码就会比较多,进而增加了代码的耦合。根据上一点,也就会影响到代码的复用性。相反,越细粒度的代码,代码的通用性会越好,越容易被复用。

这里的“模块”,不单单指一组类构成的模块,还可以理解为单个类、函数。我们要善于将功能独立的代码,封装成模块。独立的模块就像一块一块的积木,更加容易复用,可以直接拿来搭建更加复杂的系统。

越是跟业务无关的代码越是容易复用,越是针对特定业务的代码越难复用。所以,为了复用跟业务无关的代码,我们将业务和非业务逻辑代码分离,抽取成一些通用的框架、类库、组件等。

从分层的角度来看,越底层的代码越通用、会被越多的模块调用,越应该设计得足够可复用。一般情况下,在代码分层之后,为了避免交叉调用导致调用关系混乱,我们只允许上层代码调用下层代码及同层代码之间的调用,杜绝下层代码调用上层代码。所以,通用的代码我们尽量下沉到更下层。

在讲面向对象特性的时候,我们讲到,利用继承,可以将公共的代码抽取到父类,子类复用父类的属性和方法。利用多态,我们可以动态地替换一段代码的部分逻辑,让这段代码可复用。除此之外,抽象和封装,从更加广义的层面、而非狭义的面向对象特性的层面来理解的话,越抽象、越不依赖具体的实现,越容易复用。代码封装成模块,隐藏可变的细节、暴露不变的接口,就越容易复用。

一些设计模式,也能提高代码的复用性。比如,模板模式利用了多态来实现,可以灵活地替换其中的部分代码,整个流程模板代码可复用。

迪米特法则

“高内聚、松耦合”

“高内聚、松耦合”是一个非常重要的设计思想,能够有效地提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。在这个设计思想中,“高内聚”用来指导类本身的设计,“松耦合”用来指导类与类之间依赖关系的设计。不过,这两者并非完全独立不相干。高内聚有助于松耦合,松耦合又需要高内聚的支持。

所谓高内聚,就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一个类中。相近的功能往往会被同时修改,放到同一个类中,修改会比较集中,代码容易维护。

所谓松耦合是说,在代码中,类与类之间的依赖关系简单清晰。即使两个类有依赖关系,一个类的代码改动不会或者很少导致依赖类的代码改动。

迪米特法则理论描述

迪米特法则的英文翻译是:Law of Demeter,缩写是LOD。它还有另外一个更加达意的名字,叫作最小知识原则,英文翻译为:The Least Knowledge Principle。英文定义:

Each unit should have only limited knowledge about other units: only units “closely” related to the current unit. Or: Each unit should only talk to its friends; Don’t talk to strangers.

我们把它直译成中文,就是下面这个样子:

每个模块(unit)只应该了解那些与它关系密切的模块(units: only units “closely” related to the current unit)的有限知识(knowledge)。或者说,每个模块只和自己的朋友“说话”(talk),不和陌生人“说话”(talk)。

代码example1

“不该有直接依赖关系的类之间,不要有依赖”

这个例子实现了简化版的搜索引擎爬取网页的功能。代码中包含三个主要的类。其中,NetworkTransporter类负责底层网络通信,根据请求获取数据;HtmlDownloader类用来通过URL获取网页;Document表示网页文档,后续的网页内容抽取、分词、索引都是以此为处理对象。具体的代码实现如下所示:

public class NetworkTransporter {
    // 省略属性和其他方法...
    public Byte[] send(HtmlRequest htmlRequest) {
      //...
    }
}

public class HtmlDownloader {
  private NetworkTransporter transporter;//通过构造函数或IOC注入
  
  public Html downloadHtml(String url) {
    Byte[] rawHtml = transporter.send(new HtmlRequest(url));
    return new Html(rawHtml);
  }
}

public class Document {
  private Html html;
  private String url;
  
  public Document(String url) {
    this.url = url;
    HtmlDownloader downloader = new HtmlDownloader();
    this.html = downloader.downloadHtml(url);
  }
  //...
}

**首先,我们来看NetworkTransporter类。**作为一个底层网络通信类,我们希望它的功能尽可能通用,而不只是服务于下载HTML,所以,我们不应该直接依赖太具体的发送对象HtmlRequest。从这一点上讲,NetworkTransporter类的设计违背迪米特法则,依赖了不该有直接依赖关系的HtmlRequest类。

我们应该如何进行重构,让NetworkTransporter类满足迪米特法则呢?我这里有个形象的比喻。假如你现在要去商店买东西,你肯定不会直接把钱包给收银员,让收银员自己从里面拿钱,而是你从钱包里把钱拿出来交给收银员。这里的HtmlRequest对象就相当于钱包,HtmlRequest里的address和content对象就相当于钱。我们应该把address和content交给NetworkTransporter,而非是直接把HtmlRequest交给NetworkTransporter。

**我们再来看HtmlDownloader类。**这个类的设计没有问题。不过,我们修改了NetworkTransporter的send()函数的定义,而这个类用到了send()函数,所以我们需要对它做相应的修改。

**最后,我们来看下Document类。**这个类的问题比较多,主要有三点。第一,构造函数中的downloader.downloadHtml()逻辑复杂,耗时长,不应该放到构造函数中,会影响代码的可测试性。代码的可测试性我们后面会讲到,这里你先知道有这回事就可以了。第二,HtmlDownloader对象在构造函数中通过new来创建,违反了基于接口而非实现编程的设计思想,也会影响到代码的可测试性。第三,从业务含义上来讲,Document网页文档没必要依赖HtmlDownloader类,违背了迪米特法则。

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

public class NetworkTransporter {
    // 省略属性和其他方法...
    public Byte[] send(String address, Byte[] data) {
      //...
    }
}

public class HtmlDownloader {
  private NetworkTransporter transporter;//通过构造函数或IOC注入
  
  // HtmlDownloader这里也要有相应的修改
  public Html downloadHtml(String url) {
    HtmlRequest htmlRequest = new HtmlRequest(url);
    Byte[] rawHtml = transporter.send(
      htmlRequest.getAddress(), htmlRequest.getContent().getBytes());
    return new Html(rawHtml);
  }
}

public class Document {
  private Html html;
  private String url;
  
  public Document(String url, Html html) {
    this.html = html;
    this.url = url;
  }
  //...
}

// 通过一个工厂方法来创建Document
public class DocumentFactory {
  private HtmlDownloader downloader;
  
  public DocumentFactory(HtmlDownloader downloader) {
    this.downloader = downloader;
  }
  
  public Document createDocument(String url) {
    Html html = downloader.downloadHtml(url);
    return new Document(url, html);
  }
}

代码example2

“有依赖关系的类之间,尽量只依赖必要的接口”

下面这段代码非常简单,Serialization类负责对象的序列化和反序列化。

public class Serialization {
  public String serialize(Object object) {
    String serializedResult = ...;
    //...
    return serializedResult;
  }
  
  public Object deserialize(String str) {
    Object deserializedResult = ...;
    //...
    return deserializedResult;
  }
}

单看这个类的设计,没有一点问题。不过,如果我们把它放到一定的应用场景里,那就还有继续优化的空间。假设在我们的项目中,有些类只用到了序列化操作,而另一些类只用到反序列化操作。那基于迪米特法则后半部分“有依赖关系的类之间,尽量只依赖必要的接口”,只用到序列化操作的那部分类不应该依赖反序列化接口。同理,只用到反序列化操作的那部分类不应该依赖序列化接口。

根据这个思路,我们应该将Serialization类拆分为两个更小粒度的类,一个只负责序列化(Serializer类),一个只负责反序列化(Deserializer类)。拆分之后,使用序列化操作的类只需要依赖Serializer类,使用反序列化操作的类只需要依赖Deserializer类。拆分之后的代码如下所示:

public class Serializer {
  public String serialize(Object object) {
    String serializedResult = ...;
    ...
    return serializedResult;
  }
}

public class Deserializer {
  public Object deserialize(String str) {
    Object deserializedResult = ...;
    ...
    return deserializedResult;
  }
}

不知道你有没有看出来,尽管拆分之后的代码更能满足迪米特法则,但却违背了高内聚的设计思想。高内聚要求相近的功能要放到同一个类中,这样可以方便功能修改的时候,修改的地方不至于过于分散。对于刚刚这个例子来说,如果我们修改了序列化的实现方式,比如从JSON换成了XML,那反序列化的实现逻辑也需要一并修改。在未拆分的情况下,我们只需要修改一个类即可。在拆分之后,我们需要修改两个类。显然,这种设计思路的代码改动范围变大了。

如果我们既不想违背高内聚的设计思想,也不想违背迪米特法则,那我们该如何解决这个问题呢?实际上,通过引入两个接口就能轻松解决这个问题,具体的代码如下所示。

public interface Serializable {
  String serialize(Object object);
}

public interface Deserializable {
  Object deserialize(String text);
}

public class Serialization implements Serializable, Deserializable {
  @Override
  public String serialize(Object object) {
    String serializedResult = ...;
    ...
    return serializedResult;
  }
  
  @Override
  public Object deserialize(String str) {
    Object deserializedResult = ...;
    ...
    return deserializedResult;
  }
}

public class DemoClass_1 {
  private Serializable serializer;
  
  public Demo(Serializable serializer) {
    this.serializer = serializer;
  }
  //...
}

public class DemoClass_2 {
  private Deserializable deserializer;
  
  public Demo(Deserializable deserializer) {
    this.deserializer = deserializer;
  }
  //...
}

尽管我们还是要往DemoClass_1的构造函数中,传入包含序列化和反序列化的Serialization实现类,但是,我们依赖的Serializable接口只包含序列化操作,DemoClass_1无法使用Serialization类中的反序列化接口,对反序列化操作无感知,这也就符合了迪米特法则后半部分所说的“依赖有限接口”的要求。

实际上,上面的的代码实现思路,也体现了“基于接口而非实现编程”的设计原则,结合迪米特法则,我们可以总结出一条新的设计原则,那就是“基于最小接口而非最大实现编程”。

业务开发实战:积分兑换系统

需求分析

积分是一种常见的营销手段,很多产品都会通过它来促进消费、增加用户粘性,比如淘宝积分、信用卡积分、商场消费积分等等。假设你是一家类似淘宝这样的电商平台的工程师,平台暂时还没有积分系统。Leader希望由你来负责开发这样一个系统,你会如何来做呢?

我们可以找几个类似的产品,比如淘宝,看看它们是如何设计积分系统的,然后借鉴到我们的产品中。你可以自己亲自用用淘宝,看看积分是怎么使用的,也可以直接百度一下“淘宝积分规则”。基于这两个输入,我们基本上就大致能摸清楚积分系统该如何设计了。除此之外,我们还要充分了解自己公司的产品,将借鉴来的东西糅合在我们自己的产品中,并做适当的微创新。

笼统地来讲,积分系统无外乎就两个大的功能点,一个是赚取积分,另一个是消费积分。赚取积分功能包括积分赚取渠道,比如下订单、每日签到、评论等;还包括积分兑换规则,比如订单金额与积分的兑换比例,每日签到赠送多少积分等。消费积分功能包括积分消费渠道,比如抵扣订单金额、兑换优惠券、积分换购、参与活动扣积分等;还包括积分兑换规则,比如多少积分可以换算成抵扣订单的多少金额,一张优惠券需要多少积分来兑换等等。

刚刚给出的只是非常笼统、粗糙的功能需求。在实际情况中,肯定还有一些业务细节需要考虑,比如积分的有效期问题。对于这些业务细节,还是那句话,闷头拍脑袋想是想不全面的。以防遗漏,我们还是要有方法可寻。那除了刚刚讲的“借鉴”的思路之外,我还喜欢通过产品的线框图用户用例(user case )或者叫用户故事(user story)来细化业务流程,挖掘一些比较细节的、不容易想到的功能点。

线框图对你来说应该不陌生,我就不赘述了,我这里重点说一下用户用例。用户用例有点儿类似我们后面要讲的单元测试用例。它侧重情景化,其实就是模拟用户如何使用我们的产品,描述用户在一个特定的应用场景里的一个完整的业务操作流程。所以,它包含更多的细节,且更加容易被人理解。比如,有关积分有效期的用户用例,我们可以进行如下的设计:

通过上面讲的方法,我们就可以将功能需求大致弄清楚了。积分系统的需求实际上并不复杂,我总结罗列了一下,如下所示。

  1. 积分赚取和兑换规则

积分的赚取渠道包括:下订单、每日签到、评论等。

积分兑换规则可以是比较通用的。比如,签到送10积分。再比如,按照订单总金额的10%兑换成积分,也就是100块钱的订单可以积累10积分。除此之外,积分兑换规则也可以是比较细化的。比如,不同的店铺、不同的商品,可以设置不同的积分兑换比例。

对于积分的有效期,我们可以根据不同渠道,设置不同的有效期。积分到期之后会作废;在消费积分的时候,优先使用快到期的积分。

  1. 积分消费和兑换规则

积分的消费渠道包括:抵扣订单金额、兑换优惠券、积分换购、参与活动扣积分等。

我们可以根据不同的消费渠道,设置不同的积分兑换规则。比如,积分换算成消费抵扣金额的比例是10%,也就是10积分可以抵扣1块钱;100积分可以兑换15块钱的优惠券等。

  1. 积分及其明细查询

查询用户的总积分,以及赚取积分和消费积分的历史记录。

系统设计

1. 合理地将功能划分到不同模块

前面讲到面向对象设计的时候,我们提到,面向对象设计的本质就是把合适的代码放到合适的类中。合理地划分代码可以实现代码的高内聚、低耦合,类与类之间的交互简单清晰,代码整体结构一目了然,那代码的质量就不会差到哪里去。类比面向对象设计,系统设计实际上就是将合适的功能放到合适的模块中。合理地划分模块也可以做到模块层面的高内聚、低耦合,架构整洁清晰。

对于前面罗列的所有功能点,我们有下面三种模块划分方法。

第一种划分方式是:积分赚取渠道及兑换规则、消费渠道及兑换规则的管理和维护(增删改查),不划分到积分系统中,而是放到更上层的营销系统中。这样积分系统就会变得非常简单,只需要负责增加积分、减少积分、查询积分、查询积分明细等这几个工作。

我举个例子解释一下。比如,用户通过下订单赚取积分。订单系统通过异步发送消息或者同步调用接口的方式,告知营销系统订单交易成功。营销系统根据拿到的订单信息,查询订单对应的积分兑换规则(兑换比例、有效期等),计算得到订单可兑换的积分数量,然后调用积分系统的接口给用户增加积分。

第二种划分方式是:积分赚取渠道及兑换规则、消费渠道及兑换规则的管理和维护,分散在各个相关业务系统中,比如订单系统、评论系统、签到系统、换购商城、优惠券系统等。还是刚刚那个下订单赚取积分的例子,在这种情况下,用户下订单成功之后,订单系统根据商品对应的积分兑换比例,计算所能兑换的积分数量,然后直接调用积分系统给用户增加积分。

第三种划分方式是:所有的功能都划分到积分系统中,包括积分赚取渠道及兑换规则、消费渠道及兑换规则的管理和维护。还是同样的例子,用户下订单成功之后,订单系统直接告知积分系统订单交易成功,积分系统根据订单信息查询积分兑换规则,给用户增加积分。

怎么判断哪种模块划分合理呢?实际上,我们可以反过来通过看它是否符合高内聚、低耦合特性来判断。如果一个功能的修改或添加,经常要跨团队、跨项目、跨系统才能完成,那说明模块划分的不够合理,职责不够清晰,耦合过于严重。

除此之外,为了避免业务知识的耦合,让下层系统更加通用,一般来讲,我们不希望下层系统(也就是被调用的系统)包含太多上层系统(也就是调用系统)的业务信息,但是,可以接受上层系统包含下层系统的业务信息。比如,订单系统、优惠券系统、换购商城等作为调用积分系统的上层系统,可以包含一些积分相关的业务信息。但是,反过来,积分系统中最好不要包含太多跟订单、优惠券、换购等相关的信息。

所以,综合考虑,我们更倾向于第一种和第二种模块划分方式。但是,不管选择这两种中的哪一种,积分系统所负责的工作是一样的,只包含积分的增、减、查询,以及积分明细的记录和查询。

2. 设计模块与模块之间的交互关系

在面向对象设计中,类设计好之后,我们需要设计类之间的交互关系。类比到系统设计,系统职责划分好之后,接下来就是设计系统之间的交互,也就是确定有哪些系统跟积分系统之间有交互以及如何进行交互。

比较常见的系统之间的交互方式有两种,一种是同步接口调用,另一种是利用消息中间件异步调用。第一种方式简单直接,第二种方式的解耦效果更好。

比如,用户下订单成功之后,订单系统推送一条消息到消息中间件,营销系统订阅订单成功消息,触发执行相应的积分兑换逻辑。这样订单系统就跟营销系统完全解耦,订单系统不需要知道任何跟积分相关的逻辑,而营销系统也不需要直接跟订单系统交互。

除此之外,上下层系统之间的调用倾向于通过同步接口,同层之间的调用倾向于异步消息调用。比如,营销系统和积分系统是上下层关系,它们之间就比较推荐使用同步接口调用。

3. 设计模块的接口、数据库、业务模型

刚刚讲了模块的功能划分,模块之间的交互的设计,现在,我们再来看,模块本身如何来设计。实际上,业务系统本身的设计无外乎有这样三方面的工作要做:接口设计、数据库设计和业务模型设计。

数据库设计

数据库的设计比较简单。实际上,我们只需要一张记录积分流水明细的表就可以了。

img

接口设计

对于积分系统来说,我们需要设计如下这样几个接口。

img

接口设计要符合单一职责原则,粒度越小通用性就越好。但是,接口粒度太小也会带来一些问题。比如,一个功能的实现要调用多个小接口,一方面如果接口调用走网络(特别是公网),多次远程接口调用会影响性能;另一方面,本该在一个接口中完成的原子操作,现在分拆成多个小接口来完成,就可能会涉及分布式事务的数据一致性问题(一个接口执行成功了,但另一个接口执行失败了)。所以,为了兼顾易用性和性能,我们可以借鉴facade(外观)设计模式,在职责单一的细粒度接口之上,再封装一层粗粒度的接口给外部使用。

业务模型设计

前面我们讲到,从代码实现角度来说,大部分业务系统的开发都可以分为Controller、Service、Repository三层。Controller层负责接口暴露,Repository层负责数据读写,Service层负责核心业务逻辑,也就是这里说的业务模型。

除此之外,前面我们还提到两种开发模式,基于贫血模型的传统开发模式和基于充血模型的DDD开发模式。前者是一种面向过程的编程风格,后者是一种面向对象的编程风格。不管是DDD还是OOP,高级开发模式的存在一般都是为了应对复杂系统,应对系统的复杂性。对于我们要开发的积分系统来说,因为业务相对比较简单,所以,选择简单的基于贫血模型的传统开发模式就足够了。

从开发的角度来说,我们可以把积分系统作为一个独立的项目,来独立开发,也可以跟其他业务代码(比如营销系统)放到同一个项目中进行开发。从运维的角度来说,我们可以将它跟其他业务一块部署,也可以作为一个微服务独立部署。具体选择哪种开发和部署方式,我们可以参考公司当前的技术架构来决定。

实际上,积分系统业务比较简单,代码量也不多,我更倾向于将它跟营销系统放到一个项目中开发部署。只要我们做好代码的模块化和解耦,让积分相关的业务代码跟其他业务代码之间边界清晰,没有太多耦合,后期如果需要将它拆分成独立的项目来开发部署,那也并不困难。

通用框架开发实战:接口性能统计

项目背景

我们希望设计开发一个小的框架,能够获取接口调用的各种统计信息,比如,响应时间的最大值(max)、最小值(min)、平均值(avg)、百分位值(percentile)、接口调用次数(count)、频率(tps) 等,并且支持将统计结果以各种显示格式(比如:JSON格式、网页格式、自定义显示格式等)输出到各种终端(Console命令行、HTTP网页、Email、日志文件、自定义输出终端等),以方便查看。

需求分析

性能计数器作为一个跟业务无关的功能,我们完全可以把它开发成一个独立的框架或者类库,集成到很多业务系统中。而作为可被复用的框架,除了功能性需求之外,非功能性需求也非常重要。

1. 功能性需求分析

相对于一大长串的文字描述,人脑更容易理解短的、罗列的比较规整、分门别类的列表信息。显然,刚才那段需求描述不符合这个规律。我们需要把它拆解成一个一个的“干条条”。拆解之后我写在下面了,是不是看起来更加清晰、有条理?

除此之外,我们还可以借助设计产品的时候,经常用到的线框图,把最终数据的显示样式画出来,会更加一目了然。具体的线框图如下所示:

img

实际上,从线框图中,我们还能挖掘出了下面几个隐藏的需求。

2.非功能性需求分析

易用性听起来更像是一个评判产品的标准。没错,我们在开发这样一个技术框架的时候,也要有产品意识。框架是否易集成、易插拔、跟业务代码是否松耦合、提供的接口是否够灵活等等,都是我们应该花心思去思考和设计的。有的时候,文档写得好坏甚至都有可能决定一个框架是否受欢迎。

对于需要集成到业务系统的框架来说,我们不希望框架本身的代码执行效率,对业务系统有太多性能上的影响。对于性能计数器这个框架来说,一方面,我们希望它是低延迟的,也就是说,统计代码不影响或很少影响接口本身的响应时间;另一方面,我们希望框架本身对内存的消耗不能太大。

这里说的扩展性跟之前讲到的代码的扩展性有点类似,都是指在不修改或尽量少修改代码的情况下添加新的功能。但是这两者也有区别。之前讲到的扩展是从框架代码开发者的角度来说的。这里所说的扩展是从框架使用者的角度来说的,特指使用者可以在不修改框架源码,甚至不拿到框架源码的情况下,为框架扩展新的功能。这就有点类似给框架开发插件。

容错性这一点也非常重要。对于性能计数器框架来说,不能因为框架本身的异常导致接口请求出错。所以,我们要对框架可能存在的各种异常情况都考虑全面,对外暴露的接口抛出的所有运行时、非运行时异常都进行捕获处理。

为了提高框架的复用性,能够灵活应用到各种场景中。框架在设计的时候,要尽可能通用。我们要多去思考一下,除了接口统计这样一个需求,还可以适用到其他哪些场景中,比如是否还可以处理其他事件的统计信息,比如SQL请求时间的统计信息、业务统计信息(比如支付成功率)等。

框架设计

对于稍微复杂系统的开发,很多人觉得不知从何开始。我个人喜欢借鉴TDD(测试驱动开发)和Prototype(最小原型)的思想,先聚焦于一个简单的应用场景,基于此设计实现一个简单的原型。尽管这个最小原型系统在功能和非功能特性上都不完善,但它能够看得见、摸得着,比较具体、不抽象,能够很有效地帮助我缕清更复杂的设计思路,是迭代设计的基础。

对于性能计数器这个框架的开发来说,我们可以先聚焦于一个非常具体、简单的应用场景,比如统计用户注册、登录这两个接口的响应时间的最大值和平均值、接口调用次数,并且将统计结果以JSON的格式输出到命令行中。现在这个需求简单、具体、明确,设计实现起来难度降低了很多。

我们先给出应用场景的代码。具体如下所示:

//应用场景:统计下面两个接口(注册和登录)的响应时间和访问次数
public class UserController {
  public void register(UserVo user) {
    //...
  }
  
  public UserVo login(String telephone, String password) {
    //...
  }
}

要输出接口的响应时间的最大值、平均值和接口调用次数,我们首先要采集每次接口请求的响应时间,并且存储起来,然后按照某个时间间隔做聚合统计,最后才是将结果输出。在原型系统的代码实现中,我们可以把所有代码都塞到一个类中,暂时不用考虑任何代码质量、线程安全、性能、扩展性等等问题,怎么简单怎么来就行。

最小原型的代码实现如下所示。其中,recordResponseTime()和recordTimestamp()两个函数分别用来记录接口请求的响应时间和访问时间。startRepeatedReport()函数以指定的频率统计数据并输出结果。

public class Metrics {
  // Map的key是接口名称,value对应接口请求的响应时间或时间戳;
  private Map<String, List<Double>> responseTimes = new HashMap<>();
  private Map<String, List<Double>> timestamps = new HashMap<>();
  private ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();

  public void recordResponseTime(String apiName, double responseTime) {
    responseTimes.putIfAbsent(apiName, new ArrayList<>());
    responseTimes.get(apiName).add(responseTime);
  }

  public void recordTimestamp(String apiName, double timestamp) {
    timestamps.putIfAbsent(apiName, new ArrayList<>());
    timestamps.get(apiName).add(timestamp);
  }

  public void startRepeatedReport(long period, TimeUnit unit){
    executor.scheduleAtFixedRate(new Runnable() {
      @Override
      public void run() {
        Gson gson = new Gson();
        Map<String, Map<String, Double>> stats = new HashMap<>();
        for (Map.Entry<String, List<Double>> entry : responseTimes.entrySet()) {
          String apiName = entry.getKey();
          List<Double> apiRespTimes = entry.getValue();
          stats.putIfAbsent(apiName, new HashMap<>());
          stats.get(apiName).put("max", max(apiRespTimes));
          stats.get(apiName).put("avg", avg(apiRespTimes));
        }
  
        for (Map.Entry<String, List<Double>> entry : timestamps.entrySet()) {
          String apiName = entry.getKey();
          List<Double> apiTimestamps = entry.getValue();
          stats.putIfAbsent(apiName, new HashMap<>());
          stats.get(apiName).put("count", (double)apiTimestamps.size());
        }
        System.out.println(gson.toJson(stats));
      }
    }, 0, period, unit);
  }

  private double max(List<Double> dataset) {//省略代码实现}
  private double avg(List<Double> dataset) {//省略代码实现}
}

接下来,我们再来看,如何用它来统计注册、登录接口的响应时间和访问次数。具体的代码如下所示:

//应用场景:统计下面两个接口(注册和登录)的响应时间和访问次数
public class UserController {
  private Metrics metrics = new Metrics();
  
  public UserController() {
    metrics.startRepeatedReport(60, TimeUnit.SECONDS);
  }

  public void register(UserVo user) {
    long startTimestamp = System.currentTimeMillis();
    metrics.recordTimestamp("regsiter", startTimestamp);
    //...
    long respTime = System.currentTimeMillis() - startTimestamp;
    metrics.recordResponseTime("register", respTime);
  }

  public UserVo login(String telephone, String password) {
    long startTimestamp = System.currentTimeMillis();
    metrics.recordTimestamp("login", startTimestamp);
    //...
    long respTime = System.currentTimeMillis() - startTimestamp;
    metrics.recordResponseTime("login", respTime);
  }
}

最小原型的代码实现虽然简陋,但它却帮我们将思路理顺了很多,我们现在就基于它做最终的框架设计。下面是我针对性能计数器框架画的一个粗略的系统设计图。图可以非常直观地体现设计思想,并且能有效地帮助我们释放更多的脑空间,来思考其他细节问题。

img

如图所示,我们把整个框架分为四个模块:数据采集、存储、聚合统计、显示。每个模块负责的工作简单罗列如下。

面向对象设计与实现

1. 划分职责进而识别出有哪些类

根据需求描述,我们先大致识别出下面几个接口或类。

2. 定义类及类与类之间的关系

MetricsCollector类的定义非常简单,具体代码如下所示。对比上一节课中最小原型的代码,MetricsCollector通过引入RequestInfo类来封装原始数据信息,用一个采集函数代替了之前的两个函数。

public class MetricsCollector {
  private MetricsStorage metricsStorage;//基于接口而非实现编程

  //依赖注入
  public MetricsCollector(MetricsStorage metricsStorage) {
    this.metricsStorage = metricsStorage;
  }

  //用一个函数代替了最小原型中的两个函数
  public void recordRequest(RequestInfo requestInfo) {
    if (requestInfo == null || StringUtils.isBlank(requestInfo.getApiName())) {
      return;
    }
    metricsStorage.saveRequestInfo(requestInfo);
  }
}

public class RequestInfo {
  private String apiName;
  private double responseTime;
  private long timestamp;
  //...省略constructor/getter/setter方法...
}

MetricsStorage类和RedisMetricsStorage类的属性和方法也比较明确。具体的代码实现如下所示。注意,一次性取太长时间区间的数据,可能会导致拉取太多的数据到内存中,有可能会撑爆内存。对于Java来说,就有可能会触发OOM(Out Of Memory)。而且,即便不出现OOM,内存还够用,但也会因为内存吃紧,导致频繁的Full GC,进而导致系统接口请求处理变慢,甚至超时。

public interface MetricsStorage {
  void saveRequestInfo(RequestInfo requestInfo);

  List<RequestInfo> getRequestInfos(String apiName, long startTimeInMillis, long endTimeInMillis);

  Map<String, List<RequestInfo>> getRequestInfos(long startTimeInMillis, long endTimeInMillis);
}

public class RedisMetricsStorage implements MetricsStorage {
  //...省略属性和构造函数等...
  @Override
  public void saveRequestInfo(RequestInfo requestInfo) {
    //...
  }

  @Override
  public List<RequestInfo> getRequestInfos(String apiName, long startTimestamp, long endTimestamp) {
    //...
  }

  @Override
  public Map<String, List<RequestInfo>> getRequestInfos(long startTimestamp, long endTimestamp) {
    //...
  }
}

MetricsCollector类和MetricsStorage类的设计思路比较简单,不同的人给出的设计结果应该大差不差。但是,统计和显示这两个功能就不一样了,可以有多种设计思路。实际上,如果我们把统计显示所要完成的功能逻辑细分一下的话,主要包含下面4点:

  1. 根据给定的时间区间,从数据库中拉取数据;
  2. 根据原始数据,计算得到统计数据;
  3. 将统计数据显示到终端(命令行或邮件);
  4. 定时触发以上3个过程的执行。

实际上,如果用一句话总结一下的话,面向对象设计和实现要做的事情,就是把合适的代码放到合适的类中。所以,我们现在要做的工作就是,把以上的4个功能逻辑划分到几个类中。划分的方法有很多种,比如,我们可以把前两个逻辑放到一个类中,第3个逻辑放到另外一个类中,第4个逻辑作为上帝类(God Class)组合前面两个类来触发前3个逻辑的执行。当然,我们也可以把第2个逻辑单独放到一个类中,第1、3、4都放到另外一个类中。

至于到底选择哪种排列组合方式,判定的标准是,让代码尽量地满足低耦合、高内聚、单一职责、对扩展开放对修改关闭等之前讲到的各种设计原则和思想,尽量地让设计满足代码易复用、易读、易扩展、易维护。

我们暂时选择把第1、3、4逻辑放到ConsoleReporter或EmailReporter类中,把第2个逻辑放到Aggregator类中。其中,Aggregator类负责的逻辑比较简单,我们把它设计成只包含静态方法的工具类。具体的代码实现如下所示:

public class Aggregator {
  public static RequestStat aggregate(List<RequestInfo> requestInfos, long durationInMillis) {
    double maxRespTime = Double.MIN_VALUE;
    double minRespTime = Double.MAX_VALUE;
    double avgRespTime = -1;
    double p999RespTime = -1;
    double p99RespTime = -1;
    double sumRespTime = 0;
    long count = 0;
    for (RequestInfo requestInfo : requestInfos) {
      ++count;
      double respTime = requestInfo.getResponseTime();
      if (maxRespTime < respTime) {
        maxRespTime = respTime;
      }
      if (minRespTime > respTime) {
        minRespTime = respTime;
      }
      sumRespTime += respTime;
    }
    if (count != 0) {
      avgRespTime = sumRespTime / count;
    }
    long tps = (long)(count / durationInMillis * 1000);
    Collections.sort(requestInfos, new Comparator<RequestInfo>() {
      @Override
      public int compare(RequestInfo o1, RequestInfo o2) {
        double diff = o1.getResponseTime() - o2.getResponseTime();
        if (diff < 0.0) {
          return -1;
        } else if (diff > 0.0) {
          return 1;
        } else {
          return 0;
        }
      }
    });
    int idx999 = (int)(count * 0.999);
    int idx99 = (int)(count * 0.99);
    if (count != 0) {
      p999RespTime = requestInfos.get(idx999).getResponseTime();
      p99RespTime = requestInfos.get(idx99).getResponseTime();
    }
    RequestStat requestStat = new RequestStat();
    requestStat.setMaxResponseTime(maxRespTime);
    requestStat.setMinResponseTime(minRespTime);
    requestStat.setAvgResponseTime(avgRespTime);
    requestStat.setP999ResponseTime(p999RespTime);
    requestStat.setP99ResponseTime(p99RespTime);
    requestStat.setCount(count);
    requestStat.setTps(tps);
    return requestStat;
  }
}

public class RequestStat {
  private double maxResponseTime;
  private double minResponseTime;
  private double avgResponseTime;
  private double p999ResponseTime;
  private double p99ResponseTime;
  private long count;
  private long tps;
  //...省略getter/setter方法...
}

ConsoleReporter类相当于一个上帝类,定时根据给定的时间区间,从数据库中取出数据,借助Aggregator类完成统计工作,并将统计结果输出到命令行。具体的代码实现如下所示:

public class ConsoleReporter {
  private MetricsStorage metricsStorage;
  private ScheduledExecutorService executor;

  public ConsoleReporter(MetricsStorage metricsStorage) {
    this.metricsStorage = metricsStorage;
    this.executor = Executors.newSingleThreadScheduledExecutor();
  }
  
  // 第4个代码逻辑:定时触发第1、2、3代码逻辑的执行;
  public void startRepeatedReport(long periodInSeconds, long durationInSeconds) {
    executor.scheduleAtFixedRate(new Runnable() {
      @Override
      public void run() {
        // 第1个代码逻辑:根据给定的时间区间,从数据库中拉取数据;
        long durationInMillis = durationInSeconds * 1000;
        long endTimeInMillis = System.currentTimeMillis();
        long startTimeInMillis = endTimeInMillis - durationInMillis;
        Map<String, List<RequestInfo>> requestInfos =
                metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
        Map<String, RequestStat> stats = new HashMap<>();
        for (Map.Entry<String, List<RequestInfo>> entry : requestInfos.entrySet()) {
          String apiName = entry.getKey();
          List<RequestInfo> requestInfosPerApi = entry.getValue();
          // 第2个代码逻辑:根据原始数据,计算得到统计数据;
          RequestStat requestStat = Aggregator.aggregate(requestInfosPerApi, durationInMillis);
          stats.put(apiName, requestStat);
        }
        // 第3个代码逻辑:将统计数据显示到终端(命令行或邮件);
        System.out.println("Time Span: [" + startTimeInMillis + ", " + endTimeInMillis + "]");
        Gson gson = new Gson();
        System.out.println(gson.toJson(stats));
      }
    }, 0, periodInSeconds, TimeUnit.SECONDS);
  }
}

public class EmailReporter {
  private static final Long DAY_HOURS_IN_SECONDS = 86400L;

  private MetricsStorage metricsStorage;
  private EmailSender emailSender;
  private List<String> toAddresses = new ArrayList<>();

  public EmailReporter(MetricsStorage metricsStorage) {
    this(metricsStorage, new EmailSender(/*省略参数*/));
  }

  public EmailReporter(MetricsStorage metricsStorage, EmailSender emailSender) {
    this.metricsStorage = metricsStorage;
    this.emailSender = emailSender;
  }

  public void addToAddress(String address) {
    toAddresses.add(address);
  }

  public void startDailyReport() {
    Calendar calendar = Calendar.getInstance();
    calendar.add(Calendar.DATE, 1);
    calendar.set(Calendar.HOUR_OF_DAY, 0);
    calendar.set(Calendar.MINUTE, 0);
    calendar.set(Calendar.SECOND, 0);
    calendar.set(Calendar.MILLISECOND, 0);
    Date firstTime = calendar.getTime();
    Timer timer = new Timer();
    timer.schedule(new TimerTask() {
      @Override
      public void run() {
        long durationInMillis = DAY_HOURS_IN_SECONDS * 1000;
        long endTimeInMillis = System.currentTimeMillis();
        long startTimeInMillis = endTimeInMillis - durationInMillis;
        Map<String, List<RequestInfo>> requestInfos =
                metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
        Map<String, RequestStat> stats = new HashMap<>();
        for (Map.Entry<String, List<RequestInfo>> entry : requestInfos.entrySet()) {
          String apiName = entry.getKey();
          List<RequestInfo> requestInfosPerApi = entry.getValue();
          RequestStat requestStat = Aggregator.aggregate(requestInfosPerApi, durationInMillis);
          stats.put(apiName, requestStat);
        }
        // TODO: 格式化为html格式,并且发送邮件
      }
    }, firstTime, DAY_HOURS_IN_SECONDS * 1000);
  }
}

3. 将类组装起来并提供执行入口

因为这个框架稍微有些特殊,有两个执行入口:一个是MetricsCollector类,提供了一组API来采集原始数据;另一个是ConsoleReporter类和EmailReporter类,用来触发统计显示。框架具体的使用方式如下所示:

public class Demo {
  public static void main(String[] args) {
    MetricsStorage storage = new RedisMetricsStorage();
    ConsoleReporter consoleReporter = new ConsoleReporter(storage);
    consoleReporter.startRepeatedReport(60, 60);

    EmailReporter emailReporter = new EmailReporter(storage);
    emailReporter.addToAddress("wangzheng@xzg.com");
    emailReporter.startDailyReport();

    MetricsCollector collector = new MetricsCollector(storage);
    collector.recordRequest(new RequestInfo("register", 123, 10234));
    collector.recordRequest(new RequestInfo("register", 223, 11234));
    collector.recordRequest(new RequestInfo("register", 323, 12334));
    collector.recordRequest(new RequestInfo("login", 23, 12434));
    collector.recordRequest(new RequestInfo("login", 1223, 14234));

    try {
      Thread.sleep(100000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
}

三、规范与重构

单元测试

单元测试由研发工程师自己来编写,用来测试自己写的代码的正确性。我们常常将它跟集成测试放到一块来对比。单元测试相对于集成测试(Integration Testing)来说,测试的粒度更小一些。集成测试的测试对象是整个系统或者某个功能模块,比如测试用户注册、登录功能是否正常,是一种端到端(end to end)的测试。而单元测试的测试对象是类或者函数,用来测试一个类和函数是否都按照预期的逻辑执行。这是代码层级的测试。

单元测试实战

Transaction是经过抽象简化之后的一个电商系统的交易类,用来记录每笔订单交易的情况。Transaction类中的execute()函数负责执行转账操作,将钱从买家的钱包转到卖家的钱包中。真正的转账操作是通过调用WalletRpcService RPC服务来完成的。除此之外,代码中还涉及一个分布式锁DistributedLock单例类,用来避免Transaction并发执行,导致用户的钱被重复转出。

public class Transaction {
  private String id;
  private Long buyerId;
  private Long sellerId;
  private Long productId;
  private String orderId;
  private Long createTimestamp;
  private Double amount;
  private STATUS status;
  private String walletTransactionId;
  
  // ...get() methods...
  
  public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
    if (preAssignedId != null && !preAssignedId.isEmpty()) {
      this.id = preAssignedId;
    } else {
      this.id = IdGenerator.generateTransactionId();
    }
    if (!this.id.startWith("t_")) {
      this.id = "t_" + preAssignedId;
    }
    this.buyerId = buyerId;
    this.sellerId = sellerId;
    this.productId = productId;
    this.orderId = orderId;
    this.status = STATUS.TO_BE_EXECUTD;
    this.createTimestamp = System.currentTimestamp();
  }
  
  public boolean execute() throws InvalidTransactionException {
    if ((buyerId == null || (sellerId == null || amount < 0.0) {
      throw new InvalidTransactionException(...);
    }
    if (status == STATUS.EXECUTED) return true;
    boolean isLocked = false;
    try {
      isLocked = RedisDistributedLock.getSingletonIntance().lockTransction(id);
      if (!isLocked) {
        return false; // 锁定未成功,返回false,job兜底执行
      }
      if (status == STATUS.EXECUTED) return true; // double check
      long executionInvokedTimestamp = System.currentTimestamp();
      if (executionInvokedTimestamp - createdTimestap > 14days) {
        this.status = STATUS.EXPIRED;
        return false;
      }
      WalletRpcService walletRpcService = new WalletRpcService();
      String walletTransactionId = walletRpcService.moveMoney(id, buyerId, sellerId, amount);
      if (walletTransactionId != null) {
        this.walletTransactionId = walletTransactionId;
        this.status = STATUS.EXECUTED;
        return true;
      } else {
        this.status = STATUS.FAILED;
        return false;
      }
    } finally {
      if (isLocked) {
       RedisDistributedLock.getSingletonIntance().unlockTransction(id);
      }
    }
  }
}

在Transaction类中,主要逻辑集中在execute()函数中,所以它是我们测试的重点对象。为了尽可能全面覆盖各种正常和异常情况,针对这个函数,我们设计了下面6个测试用例。

  1. 正常情况下,交易执行成功,回填用于对账(交易与钱包的交易流水)用的walletTransactionId,交易状态设置为EXECUTED,函数返回true。
  2. buyerId、sellerId为null、amount小于0,返回InvalidTransactionException。
  3. 交易已过期(createTimestamp超过14天),交易状态设置为EXPIRED,返回false。
  4. 交易已经执行了(status==EXECUTED),不再重复执行转钱逻辑,返回true。
  5. 钱包(WalletRpcService)转钱失败,交易状态设置为FAILED,函数返回false。
  6. 交易正在执行着,不会被重复执行,函数直接返回false。

测试用例设计完了。现在看起来似乎一切进展顺利。但是,事实是,当我们将测试用例落实到具体的代码实现时,你就会发现有很多行不通的地方。对于上面的测试用例,第2个实现起来非常简单,我就不做介绍了。我们重点来看其中的1和3。测试用例4、5、6跟3类似。

测试用例1:

public void testExecute() {
  Long buyerId = 123L;
  Long sellerId = 234L;
  Long productId = 345L;
  Long orderId = 456L;
  Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
  boolean executedResult = transaction.execute();
  assertTrue(executedResult);
}

execute()函数的执行依赖两个外部的服务,一个是RedisDistributedLock,一个WalletRpcService。这就导致上面的单元测试代码存在下面几个问题。

mock

我们回到单元测试的定义上来看一下。单元测试主要是测试程序员自己编写的代码逻辑的正确性,并非是端到端的集成测试,它不需要测试所依赖的外部系统(分布式锁、Wallet RPC服务)的逻辑正确性。所以,如果代码中依赖了外部系统或者不可控组件,比如,需要依赖数据库、网络通信、文件系统等,那我们就需要将被测代码与外部系统解依赖,而这种解依赖的方法就叫作“mock”。所谓的mock就是用一个“假”的服务替换真正的服务。mock的服务完全在我们的控制之下,模拟输出我们想要的数据。

那如何来mock服务呢?mock的方式主要有两种,手动mock和利用框架mock。利用框架mock仅仅是为了简化代码编写,每个框架的mock方式都不大一样。我们这里只展示手动mock。

我们通过继承WalletRpcService类,并且重写其中的moveMoney()函数的方式来实现mock。具体的代码实现如下所示。通过mock的方式,我们可以让moveMoney()返回任意我们想要的数据,完全在我们的控制范围内,并且不需要真正进行网络通信。

public class MockWalletRpcServiceOne extends WalletRpcService {
  public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) {
    return "123bac";
  } 
}

public class MockWalletRpcServiceTwo extends WalletRpcService {
  public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) {
    return null;
  } 
}

现在我们再来看,如何用MockWalletRpcServiceOne、MockWalletRpcServiceTwo来替换代码中的真正的WalletRpcService呢?

因为WalletRpcService是在execute()函数中通过new的方式创建的,我们无法动态地对其进行替换。也就是说,Transaction类中的execute()方法的可测试性很差,需要通过重构来让其变得更容易测试。该如何重构这段代码呢?

在第19节中,我们讲到,依赖注入是实现代码可测试性的最有效的手段。我们可以应用依赖注入,将WalletRpcService对象的创建反转给上层逻辑,在外部创建好之后,再注入到Transaction类中。重构之后的Transaction类的代码如下所示:

public class Transaction {
  //...
  // 添加一个成员变量及其set方法
  private WalletRpcService walletRpcService;
  
  public void setWalletRpcService(WalletRpcService walletRpcService) {
    this.walletRpcService = walletRpcService;
  }
  // ...
  public boolean execute() {
    // ...
    // 删除下面这一行代码
    // WalletRpcService walletRpcService = new WalletRpcService();
    // ...
  }
}

现在,我们就可以在单元测试中,非常容易地将WalletRpcService替换成MockWalletRpcServiceOne或WalletRpcServiceTwo了。重构之后的代码对应的单元测试如下所示:

public void testExecute() {
  Long buyerId = 123L;
  Long sellerId = 234L;
  Long productId = 345L;
  Long orderId = 456L;
  Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
  // 使用mock对象来替代真正的RPC服务
  transaction.setWalletRpcService(new MockWalletRpcServiceOne()):
  boolean executedResult = transaction.execute();
  assertTrue(executedResult);
  assertEquals(STATUS.EXECUTED, transaction.getStatus());
}

WalletRpcService的mock和替换问题解决了,我们再来看RedisDistributedLock。它的mock和替换要复杂一些,主要是因为RedisDistributedLock是一个单例类。单例相当于一个全局变量,我们无法mock(无法继承和重写方法),也无法通过依赖注入的方式来替换。

如果RedisDistributedLock是我们自己维护的,可以自由修改、重构,那我们可以将其改为非单例的模式,或者定义一个接口,比如IDistributedLock,让RedisDistributedLock实现这个接口。这样我们就可以像前面WalletRpcService的替换方式那样,替换RedisDistributedLock为MockRedisDistributedLock了。但如果RedisDistributedLock不是我们维护的,我们无权去修改这部分代码,这个时候该怎么办呢?

我们可以对transaction上锁这部分逻辑重新封装一下。具体代码实现如下所示:

public class TransactionLock {
  public boolean lock(String id) {
    return RedisDistributedLock.getSingletonIntance().lockTransction(id);
  }
  
  public void unlock() {
    RedisDistributedLock.getSingletonIntance().unlockTransction(id);
  }
}

public class Transaction {
  //...
  private TransactionLock lock;
  
  public void setTransactionLock(TransactionLock lock) {
    this.lock = lock;
  }
 
  public boolean execute() {
    //...
    try {
      isLocked = lock.lock();
      //...
    } finally {
      if (isLocked) {
        lock.unlock();
      }
    }
    //...
  }
}

针对重构过的代码,我们的单元测试代码修改为下面这个样子。这样,我们就能在单元测试代码中隔离真正的RedisDistributedLock分布式锁这部分逻辑了。

public void testExecute() {
  Long buyerId = 123L;
  Long sellerId = 234L;
  Long productId = 345L;
  Long orderId = 456L;
  
  TransactionLock mockLock = new TransactionLock() {
    public boolean lock(String id) {
      return true;
    }
  
    public void unlock() {}
  };
  
  Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
  transaction.setWalletRpcService(new MockWalletRpcServiceOne());
  transaction.setTransactionLock(mockLock);
  boolean executedResult = transaction.execute();
  assertTrue(executedResult);
  assertEquals(STATUS.EXECUTED, transaction.getStatus());
}

至此,测试用例1就算写好了。我们通过依赖注入和mock,让单元测试代码不依赖任何不可控的外部服务。

现在,我们再来看测试用例3:交易已过期(createTimestamp超过14天),交易状态设置为EXPIRED,返回false。针对这个单元测试用例,我们还是先把代码写出来,然后再来分析。

public void testExecute_with_TransactionIsExpired() {
  Long buyerId = 123L;
  Long sellerId = 234L;
  Long productId = 345L;
  Long orderId = 456L;
  Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
  transaction.setCreatedTimestamp(System.currentTimestamp() - 14days);
  boolean actualResult = transaction.execute();
  assertFalse(actualResult);
  assertEquals(STATUS.EXPIRED, transaction.getStatus());
}

上面的代码看似没有任何问题。我们将transaction的创建时间createdTimestamp设置为14天前,也就是说,当单元测试代码运行的时候,transaction一定是处于过期状态。但是,如果在Transaction类中,并没有暴露修改createdTimestamp成员变量的set方法(也就是没有定义setCreatedTimestamp()函数)呢?

你可能会说,如果没有createTimestamp的set方法,我就重新添加一个呗!实际上,这违反了类的封装特性。在Transaction类的设计中,createTimestamp是在交易生成时(也就是构造函数中)自动获取的系统时间,本来就不应该人为地轻易修改,所以,暴露createTimestamp的set方法,虽然带来了灵活性,但也带来了不可控性。因为,我们无法控制使用者是否会调用set方法重设createTimestamp,而重设createTimestamp并非我们的预期行为。

那如果没有针对createTimestamp的set方法,那测试用例3又该如何实现呢?实际上,这是一类比较常见的问题,就是代码中包含跟“时间”有关的“未决行为”逻辑。我们一般的处理方式是将这种未决行为逻辑重新封装。针对Transaction类,我们只需要将交易是否过期的逻辑,封装到isExpired()函数中即可,具体的代码实现如下所示:

public class Transaction {

  protected boolean isExpired() {
    long executionInvokedTimestamp = System.currentTimestamp();
    return executionInvokedTimestamp - createdTimestamp > 14days;
  }
  
  public boolean execute() throws InvalidTransactionException {
    //...
      if (isExpired()) {
        this.status = STATUS.EXPIRED;
        return false;
      }
    //...
  }
}

针对重构之后的代码,测试用例3的代码实现如下所示:

public void testExecute_with_TransactionIsExpired() {
  Long buyerId = 123L;
  Long sellerId = 234L;
  Long productId = 345L;
  Long orderId = 456L;
  Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId) {
    protected boolean isExpired() {
      return true;
    }
  };
  boolean actualResult = transaction.execute();
  assertFalse(actualResult);
  assertEquals(STATUS.EXPIRED, transaction.getStatus());
}

通过重构,Transaction代码的可测试性提高了。之前罗列的所有测试用例,现在我们都顺利实现了。不过,Transaction类的构造函数的设计还有点不妥。构造函数中并非只包含简单赋值操作。交易id的赋值逻辑稍微复杂。我们最好也要测试一下,以保证这部分逻辑的正确性。为了方便测试,我们可以把id赋值这部分逻辑单独抽象到一个函数中,具体的代码实现如下所示:

public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
    //...
    fillTransactionId(preAssignId);
    //...
}

protected void fillTransactionId(String preAssignedId) {
    if (preAssignedId != null && !preAssignedId.isEmpty()) {
        this.id = preAssignedId;
    } else {
        this.id = IdGenerator.generateTransactionId();
    }
    if (!this.id.startWith("t_")) {
        this.id = "t_" + preAssignedId;
    }
}

到此为止,我们一步一步将Transaction从不可测试代码重构成了测试性良好的代码。不过,你可能还会有疑问,Transaction类中isExpired()函数就不用测试了吗?对于isExpired()函数,逻辑非常简单,肉眼就能判定是否有bug,是可以不用写单元测试的。

实际上,可测试性差的代码,本身代码设计得也不够好,很多地方都没有遵守我们之前讲到的设计原则和思想,比如“基于接口而非实现编程”思想、依赖反转原则等。重构之后的代码,不仅可测试性更好,而且从代码设计的角度来说,也遵从了经典的设计原则和思想。这也印证了我们之前说过的,代码的可测试性可以从侧面上反应代码设计是否合理。

有哪些测试性不好(Anti-Patterns)的代码?

未决行为

所谓的未决行为逻辑就是,代码的输出是随机或者说不确定的,比如,跟时间、随机数有关的代码。

全局变量

前面我们讲过,全局变量是一种面向过程的编程风格,有种种弊端。实际上,滥用全局变量也让编写单元测试变得困难。我举个例子来解释一下。

RangeLimiter表示一个[-5, 5]的区间,position初始在0位置,move()函数负责移动position。其中,position是一个静态全局变量。RangeLimiterTest类是为其设计的单元测试,不过,这里面存在很大的问题。

public class RangeLimiter {
  private static AtomicInteger position = new AtomicInteger(0);
  public static final int MAX_LIMIT = 5;
  public static final int MIN_LIMIT = -5;

  public boolean move(int delta) {
    int currentPos = position.addAndGet(delta);
    boolean betweenRange = (currentPos <= MAX_LIMIT) && (currentPos >= MIN_LIMIT);
    return betweenRange;
  }
}

public class RangeLimiterTest {
  public void testMove_betweenRange() {
    RangeLimiter rangeLimiter = new RangeLimiter();
    assertTrue(rangeLimiter.move(1));
    assertTrue(rangeLimiter.move(3));
    assertTrue(rangeLimiter.move(-5));
  }

  public void testMove_exceedRange() {
    RangeLimiter rangeLimiter = new RangeLimiter();
    assertFalse(rangeLimiter.move(6));
  }
}

上面的单元测试有可能会运行失败。假设单元测试框架顺序依次执行testMove_betweenRange()和testMove_exceedRange()两个测试用例。在第一个测试用例执行完成之后,position的值变成了-1;再执行第二个测试用例的时候,position变成了5,move()函数返回true,assertFalse语句判定失败。所以,第二个测试用例运行失败。

当然,如果RangeLimiter类有暴露重设(reset)position值的函数,我们可以在每次执行单元测试用例之前,把position重设为0,这样就能解决刚刚的问题。

不过,每个单元测试框架执行单元测试用例的方式可能是不同的。有的是顺序执行,有的是并发执行。对于并发执行的情况,即便我们每次都把position重设为0,也并不奏效。如果两个测试用例并发执行,第16、17、18、23这四行代码可能会交叉执行,影响到move()函数的执行结果。

静态方法

前面我们也提到,静态方法跟全局变量一样,也是一种面向过程的编程思维。在代码中调用静态方法,有时候会导致代码不易测试。主要原因是静态方法也很难mock。但是,这个要分情况来看。只有在这个静态方法执行耗时太长、依赖外部资源、逻辑复杂、行为未决等情况下,我们才需要在单元测试中mock这个静态方法。除此之外,如果只是类似Math.abs()这样的简单静态方法,并不会影响代码的可测试性,因为本身并不需要mock。

复杂继承

我们前面提到,相比组合关系,继承关系的代码结构更加耦合、不灵活,更加不易扩展、不易维护。实际上,继承关系也更加难测试。这也印证了代码的可测试性跟代码质量的相关性。

如果父类需要mock某个依赖对象才能进行单元测试,那所有的子类、子类的子类……在编写单元测试的时候,都要mock这个依赖对象。对于层次很深(在继承关系类图中表现为纵向深度)、结构复杂(在继承关系类图中表现为横向广度)的继承关系,越底层的子类要mock的对象可能就会越多,这样就会导致,底层子类在写单元测试的时候,要一个一个mock很多依赖对象,而且还需要查看父类代码,去了解该如何mock这些依赖对象。

如果我们利用组合而非继承来组织类之间的关系,类之间的结构层次比较扁平,在编写单元测试的时候,只需要mock类所组合依赖的对象即可。

高耦合代码

如果一个类职责很重,需要依赖十几个外部对象才能完成工作,代码高度耦合,那我们在编写单元测试的时候,可能需要mock这十几个依赖的对象。不管是从代码设计的角度来说,还是从编写单元测试的角度来说,这都是不合理的。

如何解耦代码?

解耦的重要性

软件设计与开发最重要的工作之一就是应对复杂性。人处理复杂性的能力是有限的。过于复杂的代码往往在可读性、可维护性上都不友好。那如何来控制代码的复杂性呢?手段有很多,我个人认为,最关键的就是解耦,保证代码松耦合、高内聚。如果说重构是保证代码质量不至于腐化到无可救药地步的有效手段,那么利用解耦的方法对代码重构,就是保证代码不至于复杂到无法控制的有效手段。

代码“高内聚、松耦合”,也就意味着,代码结构清晰、分层和模块化合理、依赖关系简单、模块或类之间的耦合小,那代码整体的质量就不会差。即便某个具体的类或者模块设计得不怎么合理,代码质量不怎么高,影响的范围是非常有限的。我们可以聚焦于这个模块或者类,做相应的小型重构。而相对于代码结构的调整,这种改动范围比较集中的小型重构的难度就容易多了。

如何解耦?

封装与抽象

封装和抽象作为两个非常通用的设计思想,可以应用在很多设计场景中,比如系统、模块、lib、组件、接口、类等等的设计。封装和抽象可以有效地隐藏实现的复杂性,隔离实现的易变性,给依赖的模块提供稳定且易用的抽象接口。

比如,Unix系统提供的open()文件操作函数,我们用起来非常简单,但是底层实现却非常复杂,涉及权限控制、并发控制、物理存储等等。我们通过将其封装成一个抽象的open()函数,能够有效控制代码复杂性的蔓延,将复杂性封装在局部代码中。除此之外,因为open()函数基于抽象而非具体的实现来定义,所以我们在改动open()函数的底层实现的时候,并不需要改动依赖它的上层代码,也符合我们前面提到的“高内聚、松耦合”代码的评判标准。

中间层

引入中间层能简化模块或类之间的依赖关系。下面这张图是引入中间层前后的依赖关系对比图。在引入数据存储中间层之前,A、B、C三个模块都要依赖内存一级缓存、Redis二级缓存、DB持久化存储三个模块。在引入中间层之后,三个模块只需要依赖数据存储一个模块即可。从图上可以看出,中间层的引入明显地简化了依赖关系,让代码结构更加清晰。

img

除此之外,我们在进行重构的时候,引入中间层可以起到过渡的作用,能够让开发和重构同步进行,不互相干扰。比如,某个接口设计得有问题,我们需要修改它的定义,同时,所有调用这个接口的代码都要做相应的改动。如果新开发的代码也用到这个接口,那开发就跟重构冲突了。为了让重构能小步快跑,我们可以分下面四个阶段来完成接口的修改。

这样,每个阶段的开发工作量都不会很大,都可以在很短的时间内完成。重构跟开发冲突的概率也变小了。

模块化

模块化是构建复杂系统常用的手段。不仅在软件行业,在建筑、机械制造等行业,这个手段也非常有用。对于一个大型复杂系统来说,没有人能掌控所有的细节。之所以我们能搭建出如此复杂的系统,并且能维护得了,最主要的原因就是将系统划分成各个独立的模块,让不同的人负责不同的模块,这样即便在不了解全部细节的情况下,管理者也能协调各个模块,让整个系统有效运转。

实战:ID生成器

需求背景

假设你正在参与一个后端业务系统的开发,为了方便在请求出错时排查问题,我们在编写代码的时候会在关键路径上打印日志。某个请求出错之后,我们希望能搜索出这个请求对应的所有日志,以此来查找问题的原因。而实际情况是,在日志文件中,不同请求的日志会交织在一起。如果没有东西来标识哪些日志属于同一个请求,我们就无法关联同一个请求的所有日志。

这听起来有点像微服务中的调用链追踪。不过,微服务中的调用链追踪是服务间的追踪,我们现在要实现的是服务内的追踪。

借鉴微服务调用链追踪的实现思路,我们可以给每个请求分配一个唯一ID,并且保存在请求的上下文(Context)中,比如,处理请求的工作线程的局部变量中。在Java语言中,我们可以将ID存储在Servlet线程的ThreadLocal中,或者利用Slf4j日志框架的MDC(Mapped Diagnostic Contexts)来实现(实际上底层原理也是基于线程的ThreadLocal)。每次打印日志的时候,我们从请求上下文中取出请求ID,跟日志一块输出。这样,同一个请求的所有日志都包含同样的请求ID信息,我们就可以通过请求ID来搜索同一个请求的所有日志了。

初版实现

public class IdGenerator {
  private static final Logger logger = LoggerFactory.getLogger(IdGenerator.class);

  public static String generate() {
    String id = "";
    try {
      String hostName = InetAddress.getLocalHost().getHostName();
      String[] tokens = hostName.split("\\.");
      if (tokens.length > 0) {
        hostName = tokens[tokens.length - 1];
      }
      char[] randomChars = new char[8];
      int count = 0;
      Random random = new Random();
      while (count < 8) {
        int randomAscii = random.nextInt(122);
        if (randomAscii >= 48 && randomAscii <= 57) {
          randomChars[count] = (char)('0' + (randomAscii - 48));
          count++;
        } else if (randomAscii >= 65 && randomAscii <= 90) {
          randomChars[count] = (char)('A' + (randomAscii - 65));
          count++;
        } else if (randomAscii >= 97 && randomAscii <= 122) {
          randomChars[count] = (char)('a' + (randomAscii - 97));
          count++;
        }
      }
      id = String.format("%s-%d-%s", hostName,
              System.currentTimeMillis(), new String(randomChars));
    } catch (UnknownHostException e) {
      logger.warn("Failed to get the host name.", e);
    }

    return id;
  }
}

上面的代码生成的ID示例如下所示。整个ID由三部分组成。第一部分是本机名的最后一个字段。第二部分是当前时间戳,精确到毫秒。第三部分是8位的随机字符串,包含大小写字母和数字。尽管这样生成的ID并不是绝对唯一的,有重复的可能,但事实上重复的概率非常低。对于我们的日志追踪来说,极小概率的ID重复是完全可以接受的。

103-1577456311467-3nR3Do45
103-1577456311468-0wnuV5yw
103-1577456311468-sdrnkFxN
103-1577456311468-8lwk0BP0

如何发现代码质量问题

从大处着眼的话,我们可以参考之前讲过的代码质量评判标准,看这段代码是否可读、可扩展、可维护、灵活、简洁、可复用、可测试等等。落实到具体细节,我们可以从以下几个方面来审视代码。

以上是一些通用的关注点,可以作为常规检查项,套用在任何代码的重构上。除此之外,我们还要关注代码实现是否满足业务本身特有的功能和非功能需求。我罗列了一些比较有共性的问题,如下所示。这份列表可能还不够全面,剩下的需要你针对具体的业务、具体的代码去具体分析。

现在,对照上面的检查项,我们来看一下,小王编写的代码有哪些问题。

首先,IdGenerator的代码比较简单,只有一个类,所以,不涉及目录设置、模块划分、代码结构问题,也不违反基本的SOLID、DRY、KISS、YAGNI、LOD等设计原则。它没有应用设计模式,所以也不存在不合理使用和过度设计的问题。

其次,IdGenerator设计成了实现类而非接口,调用者直接依赖实现而非接口,违反基于接口而非实现编程的设计思想。实际上,将IdGenerator设计成实现类,而不定义接口,问题也不大。如果哪天ID生成算法改变了,我们只需要直接修改实现类的代码就可以。但是,如果项目中需要同时存在两种ID生成算法,也就是要同时存在两个IdGenerator实现类。比如,我们需要将这个框架给更多的系统来使用。系统在使用的时候,可以灵活地选择它需要的生成算法。这个时候,我们就需要将IdGenerator定义为接口,并且为不同的生成算法定义不同的实现类。

再次,把IdGenerator的generate()函数定义为静态函数,会影响使用该函数的代码的可测试性。同时,generate()函数的代码实现依赖运行环境(本机名)、时间函数、随机函数,所以generate()函数本身的可测试性也不好,需要做比较大的重构。除此之外,小王也没有编写单元测试代码,我们需要在重构时对其进行补充。

最后,虽然IdGenerator只包含一个函数,并且代码行数也不多,但代码的可读性并不好。特别是随机字符串生成的那部分代码,一方面,代码完全没有注释,生成算法比较难读懂,另一方面,代码里有很多魔法数,严重影响代码的可读性。在重构的时候,我们需要重点提高这部分代码的可读性。

刚刚我们参照跟业务本身无关的、通用的代码质量关注点,对小王的代码进行了评价。现在,我们再对照业务本身的功能和非功能需求,重新审视一下小王的代码。

前面我们提到,虽然小王的代码生成的ID并非绝对的唯一,但是对于追踪打印日志来说,是可以接受小概率ID冲突的,满足我们预期的业务需求。不过,获取hostName这部分代码逻辑貌似有点问题,并未处理“hostName为空”的情况。除此之外,尽管代码中针对获取不到本机名的情况做了异常处理,但是小王对异常的处理是在IdGenerator内部将其吐掉,然后打印一条报警日志,并没有继续往上抛出。这样的异常处理是否得当呢?你可以先自己思考一下,我们把这部分内容放到后面的课程中具体讲解。

小王代码的日志打印得当,日志描述能够准确反应问题,方便debug,并且没有过多的冗余日志。IdGenerator只暴露一个generate()接口供使用者使用,接口的定义简单明了,不存在不易用问题。generate()函数代码中没有涉及共享变量,所以代码线程安全,多线程环境下调用generate()函数不存在并发问题。

性能方面,ID的生成不依赖外部存储,在内存中生成,并且日志的打印频率也不会很高,所以小王的代码在性能方面足以应对目前的应用场景。不过,每次生成ID都需要获取本机名,获取主机名会比较耗时,所以,这部分可以考虑优化一下。还有,randomAscii的范围是0~122,但可用数字仅包含三段子区间(0~9,a~z,A~Z),极端情况下会随机生成很多三段区间之外的无效数字,需要循环很多次才能生成随机字符串,所以随机字符串的生成算法也可以优化一下。

刚刚我们还讲到,有一些代码质量问题不具有共性,我们没法一一罗列,需要你针对具体的业务、具体的代码去具体分析。那像小王的这份代码,你还能发现有哪些具体问题吗?

在generate()函数的while循环里面,三个if语句内部的代码非常相似,而且实现稍微有点过于复杂了,实际上可以进一步简化,将这三个if合并在一起。

第一轮重构:提高代码的可读性

首先,我们要解决最明显、最急需改进的代码可读性问题。具体有下面几点:

这里我们重点讨论下最后一个修改。实际上,对于ID生成器的代码,有下面三种类的命名方式。你觉得哪种更合适呢?

img

我们来逐一分析一下三种命名方式。

第一种命名方式,将接口命名为IdGenerator,实现类命名为LogTraceIdGenerator,这可能是很多人最先想到的命名方式了。在命名的时候,我们要考虑到,以后两个类会如何使用、会如何扩展。从使用和扩展的角度来分析,这样的命名就不合理了。

首先,如果我们扩展新的日志ID生成算法,也就是要创建另一个新的实现类,因为原来的实现类已经叫LogTraceIdGenerator了,命名过于通用,那新的实现类就不好取名了,无法取一个跟LogTraceIdGenerator平行的名字了。

其次,你可能会说,假设我们没有日志ID的扩展需求,但要扩展其他业务的ID生成算法,比如针对用户的(UserldGenerator)、订单的(OrderIdGenerator),第一种命名方式是不是就是合理的呢?答案也是否定的。基于接口而非实现编程,主要的目的是为了方便后续灵活地替换实现类。而LogTraceIdGenerator、UserIdGenerator、OrderIdGenerator三个类从命名上来看,涉及的是完全不同的业务,不存在互相替换的场景。

第二种命名方式是不是就合理了呢?答案也是否定的。其中,LogTraceIdGenerator接口的命名是合理的,但是HostNameMillisIdGenerator实现类暴露了太多实现细节,只要代码稍微有所改动,就可能需要改动命名,才能匹配实现。

第三种命名方式是我比较推荐的。在目前的ID生成器代码实现中,我们生成的ID是一个随机ID,不是递增有序的,所以,命名成RandomIdGenerator是比较合理的,即便内部生成算法有所改动,只要生成的还是随机的ID,就不需要改动命名。如果我们需要扩展新的ID生成算法,比如要实现一个递增有序的ID生成算法,那我们可以命名为SequenceIdGenerator。

实际上,更好的一种命名方式是,我们抽象出两个接口,一个是IdGenerator,一个是LogTraceIdGenerator,LogTraceIdGenerator继承IdGenerator。实现类实现接口IdGenerator,命名为RandomIdGenerator、SequenceIdGenerator等。这样,实现类可以复用到多个业务模块中,比如前面提到的用户、订单。

根据上面的优化策略,我们对代码进行第一轮的重构,重构之后的代码如下所示:

public interface IdGenerator {
  String generate();
}

public interface LogTraceIdGenerator extends IdGenerator {
}

public class RandomIdGenerator implements LogTraceIdGenerator {
  private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

  @Override
  public String generate() {
    String substrOfHostName = getLastfieldOfHostName();
    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
            substrOfHostName, currentTimeMillis, randomString);
    return id;
  }

  private String getLastfieldOfHostName() {
    String substrOfHostName = null;
    try {
      String hostName = InetAddress.getLocalHost().getHostName();
      String[] tokens = hostName.split("\\.");
      substrOfHostName = tokens[tokens.length - 1];
      return substrOfHostName;
    } catch (UnknownHostException e) {
      logger.warn("Failed to get the host name.", e);
    }
    return substrOfHostName;
  }

  private String generateRandomAlphameric(int length) {
    char[] randomChars = new char[length];
    int count = 0;
    Random random = new Random();
    while (count < length) {
      int maxAscii = 'z';
      int randomAscii = random.nextInt(maxAscii);
      boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
      boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
      boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
      if (isDigit|| isUppercase || isLowercase) {
        randomChars[count] = (char) (randomAscii);
        ++count;
      }
    }
    return new String(randomChars);
  }
}

//代码使用举例
LogTraceIdGenerator logTraceIdGenerator = new RandomIdGenerator();

第二轮重构:提高代码的可测试性

关于代码可测试性的问题,主要包含下面两个方面:

对于第一点,我们已经在第一轮重构中解决了。我们将RandomIdGenerator类中的generate()静态函数重新定义成了普通函数。调用者可以通过依赖注入的方式,在外部创建好RandomIdGenerator对象后注入到自己的代码中,从而解决静态函数调用影响代码可测试性的问题。

对于第二点,我们需要在第一轮重构的基础之上再进行重构。重构之后的代码如下所示,主要包括以下几个代码改动。

public class RandomIdGenerator implements LogTraceIdGenerator {
  private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

  @Override
  public String generate() {
    String substrOfHostName = getLastfieldOfHostName();
    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
            substrOfHostName, currentTimeMillis, randomString);
    return id;
  }

  private String getLastfieldOfHostName() {
    String substrOfHostName = null;
    try {
      String hostName = InetAddress.getLocalHost().getHostName();
      substrOfHostName = getLastSubstrSplittedByDot(hostName);
    } catch (UnknownHostException e) {
      logger.warn("Failed to get the host name.", e);
    }
    return substrOfHostName;
  }

  @VisibleForTesting
  protected String getLastSubstrSplittedByDot(String hostName) {
    String[] tokens = hostName.split("\\.");
    String substrOfHostName = tokens[tokens.length - 1];
    return substrOfHostName;
  }

  @VisibleForTesting
  protected String generateRandomAlphameric(int length) {
    char[] randomChars = new char[length];
    int count = 0;
    Random random = new Random();
    while (count < length) {
      int maxAscii = 'z';
      int randomAscii = random.nextInt(maxAscii);
      boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
      boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
      boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
      if (isDigit|| isUppercase || isLowercase) {
        randomChars[count] = (char) (randomAscii);
        ++count;
      }
    }
    return new String(randomChars);
  }
}

第三轮重构:编写完善的单元测试

经过上面的重构之后,代码存在的比较明显的问题,基本上都已经解决了。我们现在为代码补全单元测试。RandomIdGenerator类中有4个函数。

public String generate();
private String getLastfieldOfHostName();
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName);
@VisibleForTesting
protected String generateRandomAlphameric(int length);

我们先来看后两个函数。这两个函数包含的逻辑比较复杂,是我们测试的重点。而且,在上一步重构中,为了提高代码的可测试性,我们已经将这两个部分代码跟不可控的组件(本机名、随机函数、时间函数)进行了隔离。所以,我们只需要设计完备的单元测试用例即可。具体的代码实现如下所示(注意,我们使用了JUnit测试框架):

public class RandomIdGeneratorTest {
  @Test
  public void testGetLastSubstrSplittedByDot() {
    RandomIdGenerator idGenerator = new RandomIdGenerator();
    String actualSubstr = idGenerator.getLastSubstrSplittedByDot("field1.field2.field3");
    Assert.assertEquals("field3", actualSubstr);

    actualSubstr = idGenerator.getLastSubstrSplittedByDot("field1");
    Assert.assertEquals("field1", actualSubstr);

    actualSubstr = idGenerator.getLastSubstrSplittedByDot("field1#field2$field3");
    Assert.assertEquals("field1#field2#field3", actualSubstr);
  }

  // 此单元测试会失败,因为我们在代码中没有处理hostName为null或空字符串的情况
  // 这部分优化留在第36、37节课中讲解
  @Test
  public void testGetLastSubstrSplittedByDot_nullOrEmpty() {
    RandomIdGenerator idGenerator = new RandomIdGenerator();
    String actualSubstr = idGenerator.getLastSubstrSplittedByDot(null);
    Assert.assertNull(actualSubstr);

    actualSubstr = idGenerator.getLastSubstrSplittedByDot("");
    Assert.assertEquals("", actualSubstr);
  }

  @Test
  public void testGenerateRandomAlphameric() {
    RandomIdGenerator idGenerator = new RandomIdGenerator();
    String actualRandomString = idGenerator.generateRandomAlphameric(6);
    Assert.assertNotNull(actualRandomString);
    Assert.assertEquals(6, actualRandomString.length());
    for (char c : actualRandomString.toCharArray()) {
      Assert.assertTrue(('0' < c && c < '9') || ('a' < c && c < 'z') || ('A' < c && c < 'Z'));
    }
  }

  // 此单元测试会失败,因为我们在代码中没有处理length<=0的情况
  // 这部分优化留在第36、37节课中讲解
  @Test
  public void testGenerateRandomAlphameric_lengthEqualsOrLessThanZero() {
    RandomIdGenerator idGenerator = new RandomIdGenerator();
    String actualRandomString = idGenerator.generateRandomAlphameric(0);
    Assert.assertEquals("", actualRandomString);

    actualRandomString = idGenerator.generateRandomAlphameric(-1);
    Assert.assertNull(actualRandomString);
  }
}

我们再来看generate()函数。这个函数也是我们唯一一个暴露给外部使用的public函数。虽然逻辑比较简单,最好还是测试一下。但是,它依赖主机名、随机函数、时间函数,我们该如何测试呢?需要mock这些函数的实现吗?

实际上,这要分情况来看。我们前面讲过,写单元测试的时候,测试对象是函数定义的功能,而非具体的实现逻辑。这样我们才能做到,函数的实现逻辑改变了之后,单元测试用例仍然可以工作。那generate()函数实现的功能是什么呢?这完全是由代码编写者自己来定义的。

比如,针对同一份generate()函数的代码实现,我们可以有3种不同的功能定义,对应3种不同的单元测试。

  1. 如果我们把generate()函数的功能定义为:“生成一个随机唯一ID”,那我们只要测试多次调用generate()函数生成的ID是否唯一即可。
  2. 如果我们把generate()函数的功能定义为:“生成一个只包含数字、大小写字母和中划线的唯一ID”,那我们不仅要测试ID的唯一性,还要测试生成的ID是否只包含数字、大小写字母和中划线。
  3. 如果我们把generate()函数的功能定义为:“生成唯一ID,格式为:{主机名substr}-{时间戳}-{8位随机数}。在主机名获取失败时,返回:null-{时间戳}-{8位随机数}”,那我们不仅要测试ID的唯一性,还要测试生成的ID是否完全符合格式要求。

**总结一下,单元测试用例如何写,关键看你如何定义函数。**针对generate()函数的前两种定义,我们不需要mock获取主机名函数、随机函数、时间函数等,但对于第3种定义,我们需要mock获取主机名函数,让其返回null,测试代码运行是否符合预期。

最后,我们来看下getLastfieldOfHostName()函数。实际上,这个函数不容易测试,因为它调用了一个静态函数(InetAddress.getLocalHost().getHostName();),并且这个静态函数依赖运行环境。但是,这个函数的实现非常简单,肉眼基本上可以排除明显的bug,所以我们可以不为其编写单元测试代码。毕竟,我们写单元测试的目的是为了减少代码bug,而不是为了写单元测试而写单元测试。

当然,如果你真的想要对它进行测试,我们也是有办法的。一种办法是使用更加高级的测试框架。比如PowerMock,它可以mock静态函数。另一种方式是将获取本机名的逻辑再封装为一个新的函数。不过,后一种方法会造成代码过度零碎,也会稍微影响到代码的可读性,这个需要你自己去权衡利弊来做选择。

第四轮重构:添加注释

/**
 * Id Generator that is used to generate random IDs.
 *
 * <p>
 * The IDs generated by this class are not absolutely unique,
 * but the probability of duplication is very low.
 */
public class RandomIdGenerator implements LogTraceIdGenerator {
  private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

  /**
   * Generate the random ID. The IDs may be duplicated only in extreme situation.
   *
   * @return an random ID
   */
  @Override
  public String generate() {
    //...
  }

  /**
   * Get the local hostname and
   * extract the last field of the name string splitted by delimiter '.'.
   *
   * @return the last field of hostname. Returns null if hostname is not obtained.
   */
  private String getLastfieldOfHostName() {
    //...
  }

  /**
   * Get the last field of {@hostName} splitted by delemiter '.'.
   *
   * @param hostName should not be null
   * @return the last field of {@hostName}. Returns empty string if {@hostName} is empty string.
   */
  @VisibleForTesting
  protected String getLastSubstrSplittedByDot(String hostName) {
    //...
  }

  /**
   * Generate random string which
   * only contains digits, uppercase letters and lowercase letters.
   *
   * @param length should not be less than 0
   * @return the random string. Returns empty string if {@length} is 0
   */
  @VisibleForTesting
  protected String generateRandomAlphameric(int length) {
    //...
  }
}

实战:异常处理

我们可以把函数的运行结果分为两类。一类是预期的结果,也就是函数在正常情况下输出的结果。一类是非预期的结果,也就是函数在异常(或叫出错)情况下输出的结果。比如,在上一节课中,获取本机名的函数,在正常情况下,函数返回字符串格式的本机名;在异常情况下,获取本机名失败,函数返回UnknownHostException异常对象。

在正常情况下,函数返回数据的类型非常明确,但是,在异常情况下,函数返回的数据类型却非常灵活,有多种选择。除了刚刚提到的类似UnknownHostException这样的异常对象之外,函数在异常情况下还可以返回错误码、NULL值、特殊值(比如-1)、空对象(比如空字符串、空集合)等。

每一种异常返回数据类型,都有各自的特点和适用场景。但有的时候,在异常情况下,函数到底该返回什么样的数据类型,并不那么容易判断。比如,上节课中,在本机名获取失败的时候,ID生成器的generate()函数应该返回什么呢?是异常?空字符?还是NULL值?又或者是其他特殊值(比如null-15293834874-fd3A9KBn,null表示本机名未获取到)呢?

对于上一节的代码:

public class RandomIdGenerator implements IdGenerator {
  private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

  @Override
  public String generate() {
    String substrOfHostName = getLastFiledOfHostName();
    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
            substrOfHostName, currentTimeMillis, randomString);
    return id;
  }

  private String getLastFiledOfHostName() {
    String substrOfHostName = null;
    try {
      String hostName = InetAddress.getLocalHost().getHostName();
      substrOfHostName = getLastSubstrSplittedByDot(hostName);
    } catch (UnknownHostException e) {
      logger.warn("Failed to get the host name.", e);
    }
    return substrOfHostName;
  }

  @VisibleForTesting
  protected String getLastSubstrSplittedByDot(String hostName) {
    String[] tokens = hostName.split("\\.");
    String substrOfHostName = tokens[tokens.length - 1];
    return substrOfHostName;
  }

  @VisibleForTesting
  protected String generateRandomAlphameric(int length) {
    char[] randomChars = new char[length];
    int count = 0;
    Random random = new Random();
    while (count < length) {
      int maxAscii = 'z';
      int randomAscii = random.nextInt(maxAscii);
      boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
      boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
      boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
      if (isDigit|| isUppercase || isLowercase) {
        randomChars[count] = (char) (randomAscii);
        ++count;
      }
    }
    return new String(randomChars);
  }
}

这段代码中有四个函数。针对这四个函数的出错处理方式,我总结出下面这样几个问题。

函数出错的返回

关于函数出错返回数据类型,我总结了4种情况,它们分别是:错误码、NULL值、空对象、异常对象。接下来,我们就一一来看它们的用法以及适用场景。

返回错误码

C语言中没有异常这样的语法机制,因此,返回错误码便是最常用的出错处理方式。而在Java、Python等比较新的编程语言中,大部分情况下,我们都用异常来处理函数出错的情况,极少会用到错误码。

在C语言中,错误码的返回方式有两种:一种是直接占用函数的返回值,函数正常执行的返回值放到出参中;另一种是将错误码定义为全局变量,在函数执行出错时,函数调用者通过这个全局变量来获取错误码。

// 错误码的返回方式一:pathname/flags/mode为入参;fd为出参,存储打开的文件句柄。
int open(const char *pathname, int flags, mode_t mode, int* fd) {
  if (/*文件不存在*/) {
    return EEXIST;
  }
  
  if (/*没有访问权限*/) {
    return EACCESS;
  }
  
  if (/*打开文件成功*/) {
    return SUCCESS; // C语言中的宏定义:#define SUCCESS 0
  }
  // ...
}
//使用举例
int fd;
int result = open(c:\test.txt, O_RDWR, S_IRWXU|S_IRWXG|S_IRWXO, &fd);
if (result == SUCCESS) {
  // 取出fd使用
} else if (result == EEXIST) {
  //...
} else if (result == EACESS) {
  //...
}

// 错误码的返回方式二:函数返回打开的文件句柄,错误码放到errno中。
int errno; // 线程安全的全局变量
int open(const char *pathname, int flags, mode_t mode{
  if (/*文件不存在*/) {
    errno = EEXIST;
    return -1;
  }
  
  if (/*没有访问权限*/) {
    errno = EACCESS;
    return -1;
  }
  
  // ...
}
// 使用举例
int hFile = open(c:\test.txt, O_RDWR, S_IRWXU|S_IRWXG|S_IRWXO);
if (-1 == hFile) {
  printf("Failed to open file, error no: %d.\n", errno);
  if (errno == EEXIST ) {
    // ...        
  } else if(errno == EACCESS) {
    // ...    
  }
  // ...
}

实际上,如果你熟悉的编程语言中有异常这种语法机制,那就尽量不要使用错误码。异常相对于错误码,有诸多方面的优势,比如可以携带更多的错误信息(exception中可以有message、stack trace等信息)等。

返回NULL值

在多数编程语言中,我们用NULL来表示“不存在”这种语义。不过,网上很多人不建议函数返回NULL值,认为这是一种不好的设计思路,主要的理由有以下两个。

public class UserService {
  private UserRepo userRepo; // 依赖注入
  
  public User getUser(String telephone) {
    // 如果用户不存在,则返回null
    return null;
  }
}

// 使用函数getUser()
User user = userService.getUser("18917718965");
if (user != null) { // 做NULL值判断,否则有可能会报NPE
  String email = user.getEmail();
  if (email != null) { // 做NULL值判断,否则有可能会报NPE
    String escapedEmail = email.replaceAll("@", "#");
  }
}

那我们是否可以用异常来替代NULL值,在查找用户不存在的时候,让函数抛出UserNotFoundException异常呢?

我个人觉得,尽管返回NULL值有诸多弊端,但对于以get、find、select、search、query等单词开头的查找函数来说,数据不存在,并非一种异常情况,这是一种正常行为。所以,返回代表不存在语义的NULL值比返回异常更加合理。

不过,话说回来,刚刚讲的这个理由,也并不是特别有说服力。对于查找数据不存在的情况,函数到底是该用NULL值还是异常,有一个比较重要的参考标准是,看项目中的其他类似查找函数都是如何定义的,只要整个项目遵从统一的约定即可。如果项目从零开始开发,并没有统一约定和可以参考的代码,那你选择两者中的任何一种都可以。你只需要在函数定义的地方注释清楚,让调用者清晰地知道数据不存在的时候会返回什么就可以了。

再补充说明一点,对于查找函数来说,除了返回数据对象之外,有的还会返回下标位置,比如Java中的indexOf()函数,用来实现在某个字符串中查找另一个子串第一次出现的位置。函数的返回值类型为基本类型int。这个时候,我们就无法用NULL值来表示不存在的情况了。对于这种情况,我们有两种处理思路,一种是返回NotFoundException,一种是返回一个特殊值,比如-1。不过,显然-1更加合理,理由也是同样的,也就是说“没有查找到”是一种正常而非异常的行为。

返回空对象

刚刚我们讲到,返回NULL值有各种弊端。应对这个问题有一个比较经典的策略,那就是应用空对象设计模式(Null Object Design Pattern)。关于这个设计模式,我们在后面章节会详细讲,现在就不展开来讲解了。不过,我们今天来讲两种比较简单、比较特殊的空对象,那就是空字符串空集合

当函数返回的数据是字符串类型或者集合类型的时候,我们可以用空字符串或空集合替代NULL值,来表示不存在的情况。这样,我们在使用函数的时候,就可以不用做NULL值判断。

// 使用空集合替代NULL
public class UserService {
  private UserRepo userRepo; // 依赖注入
  
  public List<User> getUsers(String telephonePrefix) {
   // 没有查找到数据
    return Collections.emptyList();
  }
}
// getUsers使用示例
List<User> users = userService.getUsers("189");
for (User user : users) { //这里不需要做NULL值判断
  // ...
}

// 使用空字符串替代NULL
public String retrieveUppercaseLetters(String text) {
  // 如果text中没有大写字母,返回空字符串,而非NULL值
  return "";
}
// retrieveUppercaseLetters()使用举例
String uppercaseLetters = retrieveUppercaseLetters("wangzheng");
int length = uppercaseLetters.length();// 不需要做NULL值判断 
System.out.println("Contains " + length + " upper case letters.");

抛出异常对象

尽管前面讲了很多函数出错的返回数据类型,但是,最常用的函数出错处理方式就是抛出异常。异常可以携带更多的错误信息,比如函数调用栈信息。除此之外,异常可以将正常逻辑和异常逻辑的处理分离开来,这样代码的可读性就会更好。

不同的编程语言的异常语法稍有不同。像C++和大部分的动态语言(Python、Ruby、JavaScript等)都只定义了一种异常类型:运行时异常(Runtime Exception)。而像Java,除了运行时异常外,还定义了另外一种异常类型:编译时异常(Compile Exception)。

对于运行时异常,我们在编写代码的时候,可以不用主动去try-catch,编译器在编译代码的时候,并不会检查代码是否有对运行时异常做了处理。相反,对于编译时异常,我们在编写代码的时候,需要主动去try-catch或者在函数定义中声明,否则编译就会报错。所以,运行时异常也叫作非受检异常(Unchecked Exception),编译时异常也叫作受检异常(Checked Exception)。

如果你熟悉的编程语言中,只定义了一种异常类型,那用起来反倒比较简单。如果你熟悉的编程语言中(比如Java),定义了两种异常类型,那在异常出现的时候,我们应该选择抛出哪种异常类型呢?是受检异常还是非受检异常?

对于代码bug(比如数组越界)以及不可恢复异常(比如数据库连接失败),即便我们捕获了,也做不了太多事情,所以,我们倾向于使用非受检异常。对于可恢复异常、业务异常,比如提现金额大于余额的异常,我们更倾向于使用受检异常,明确告知调用者需要捕获处理。

我举一个例子解释一下,代码如下所示。当Redis的地址(参数address)没有设置的时候,我们直接使用默认的地址(比如本地地址和默认端口);当Redis的地址格式不正确的时候,我们希望程序能fail-fast,也就是说,把这种情况当成不可恢复的异常,直接抛出运行时异常,将程序终止掉。

// address格式:"192.131.2.33:7896"
public void parseRedisAddress(String address) {
  this.host = RedisConfig.DEFAULT_HOST;
  this.port = RedisConfig.DEFAULT_PORT;
  
  if (StringUtils.isBlank(address)) {
    return;
  }

  String[] ipAndPort = address.split(":");
  if (ipAndPort.length != 2) {
    throw new RuntimeException("...");
  }
  
  this.host = ipAndPort[0];
  // parseInt()解析失败会抛出NumberFormatException运行时异常
  this.port = Integer.parseInt(ipAndPort[1]);
}

实际上,Java支持的受检异常一直被人诟病,很多人主张所有的异常情况都应该使用非受检异常。支持这种观点的理由主要有以下三个。

不过,非受检异常也有弊端,它的优点其实也正是它的缺点。从刚刚的表述中,我们可以看出,非受检异常使用起来更加灵活,怎么处理的主动权这里就交给了程序员。我们前面也讲到,过于灵活会带来不可控,非受检异常不需要显式地在函数定义中声明,那我们在使用函数的时候,就需要查看代码才能知道具体会抛出哪些异常。非受检异常不需要强制捕获处理,那程序员就有可能漏掉一些本应该捕获处理的异常。

**刚刚我们讲了两种异常类型,现在我们再来讲下,如何处理函数抛出的异常?**总结一下,一般有下面三种处理方法。

public void func1() throws Exception1 {
  // ...
}

public void func2() {
  //...
  try {
    func1();
  } catch(Exception1 e) {
    log.warn("...", e); //吐掉:try-catch打印日志
  }
  //...
}
public void func1() throws Exception1 {
  // ...
}


public void func2() throws Exception1 {//原封不动的re-throw Exception1
  //...
  func1();
  //...
}
public void func1() throws Exception1 {
  // ...
}


public void func2() throws Exception2 {
  //...
  try {
    func1();
  } catch(Exception1 e) {
   throw new Exception2("...", e); // wrap成新的Exception2然后re-throw
  }
  //...
}

当我们面对函数抛出异常的时候,应该选择上面的哪种处理方式呢?我总结了下面三个参考原则:

总之,是否往上继续抛出,要看上层代码是否关心这个异常。关心就将它抛出,否则就直接吞掉。是否需要包装成新的异常抛出,看上层代码是否能理解这个异常、是否业务相关。如果能理解、业务相关就可以直接抛出,否则就封装成新的异常抛出。

重构ID生成器项目中各函数的异常处理代码

重构generate()函数

首先,我们来看,对于generate()函数,如果本机名获取失败,函数返回什么?这样的返回值是否合理?

  public String generate() {
    String substrOfHostName = getLastFieldOfHostName();
    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
            substrOfHostName, currentTimeMillis, randomString);
    return id;
  }

ID由三部分构成:本机名、时间戳和随机数。时间戳和随机数的生成函数不会出错,唯独主机名有可能获取失败。在目前的代码实现中,如果主机名获取失败,substrOfHostName为NULL,那generate()函数会返回类似“null-16723733647-83Ab3uK6”这样的数据。如果主机名获取失败,substrOfHostName为空字符串,那generate()函数会返回类似“-16723733647-83Ab3uK6”这样的数据。

在异常情况下,返回上面两种特殊的ID数据格式,这样的做法是否合理呢?这个其实很难讲,我们要看具体的业务是怎么设计的。不过,我更倾向于明确地将异常告知调用者。所以,这里最好是抛出受检异常,而非特殊值。

按照这个设计思路,我们对generate()函数进行重构。重构之后的代码如下所示:

  public String generate() throws IdGenerationFailureException {
    String substrOfHostName = getLastFieldOfHostName();
    if (substrOfHostName == null || substrOfHostName.isEmpty()) {
      throw new IdGenerationFailureException("host name is empty.");
    }
    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
            substrOfHostName, currentTimeMillis, randomString);
    return id;
  }

重构getLastFieldOfHostName()函数

对于getLastFieldOfHostName()函数,是否应该将UnknownHostException异常在函数内部吞掉(try-catch并打印日志),还是应该将异常继续往上抛出?如果往上抛出的话,是直接把UnknownHostException异常原封不动地抛出,还是封装成新的异常抛出?

  private String getLastFieldOfHostName() {
    String substrOfHostName = null;
    try {
      String hostName = InetAddress.getLocalHost().getHostName();
      substrOfHostName = getLastSubstrSplittedByDot(hostName);
    } catch (UnknownHostException e) {
      logger.warn("Failed to get the host name.", e);
    }
    return substrOfHostName;
 }

现在的处理方式是当主机名获取失败的时候,getLastFieldOfHostName()函数返回NULL值。我们前面讲过,是返回NULL值还是异常对象,要看获取不到数据是正常行为,还是异常行为。获取主机名失败会影响后续逻辑的处理,并不是我们期望的,所以,它是一种异常行为。这里最好是抛出异常,而非返回NULL值。

至于是直接将UnknownHostException抛出,还是重新封装成新的异常抛出,要看函数跟异常是否有业务相关性。getLastFieldOfHostName()函数用来获取主机名的最后一个字段,UnknownHostException异常表示主机名获取失败,两者算是业务相关,所以可以直接将UnknownHostException抛出,不需要重新包裹成新的异常。

按照上面的设计思路,我们对getLastFieldOfHostName()函数进行重构。重构后的代码如下所示:

 private String getLastFieldOfHostName() throws UnknownHostException{
    String substrOfHostName = null;
    String hostName = InetAddress.getLocalHost().getHostName();
    substrOfHostName = getLastSubstrSplittedByDot(hostName);
    return substrOfHostName;
 }

getLastFieldOfHostName()函数修改之后,generate()函数也要做相应的修改。我们需要在generate()函数中,捕获getLastFieldOfHostName()抛出的UnknownHostException异常。当我们捕获到这个异常之后,应该怎么处理呢?

按照之前的分析,ID生成失败的时候,我们需要明确地告知调用者。所以,我们不能在generate()函数中,将UnknownHostException这个异常吞掉。那我们应该原封不动地抛出,还是封装成新的异常抛出呢?

我们选择后者。在generate()函数中,我们需要捕获UnknownHostException异常,并重新包裹成新的异常IdGenerationFailureException往上抛出。之所以这么做,有下面三个原因。

按照上面的设计思路,我们对generate()的函数再次进行重构。重构后的代码如下所示:

  public String generate() throws IdGenerationFailureException {
    String substrOfHostName = null;
    try {
      substrOfHostName = getLastFieldOfHostName();
    } catch (UnknownHostException e) {
      throw new IdGenerationFailureException("host name is empty.");
    }
    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
            substrOfHostName, currentTimeMillis, randomString);
    return id;
  }

重构getLastSubstrSplittedByDot()函数

对于getLastSubstrSplittedByDot(String hostName)函数,如果hostName为NULL或者空字符串,这个函数应该返回什么?

  @VisibleForTesting
  protected String getLastSubstrSplittedByDot(String hostName) {
    String[] tokens = hostName.split("\\.");
    String substrOfHostName = tokens[tokens.length - 1];
    return substrOfHostName;
  }

理论上讲,参数传递的正确性应该有程序员来保证,我们无需做NULL值或者空字符串的判断和特殊处理。调用者本不应该把NULL值或者空字符串传递给getLastSubstrSplittedByDot()函数。如果传递了,那就是code bug,需要修复。但是,话说回来,谁也保证不了程序员就一定不会传递NULL值或者空字符串。那我们到底该不该做NULL值或空字符串的判断呢?

如果函数是private类私有的,只在类内部被调用,完全在你自己的掌控之下,自己保证在调用这个private函数的时候,不要传递NULL值或空字符串就可以了。所以,我们可以不在private函数中做NULL值或空字符串的判断。如果函数是public的,你无法掌控会被谁调用以及如何调用(有可能某个同事一时疏忽,传递进了NULL值,这种情况也是存在的),为了尽可能提高代码的健壮性,我们最好是在public函数中做NULL值或空字符串的判断。

那你可能会说,getLastSubstrSplittedByDot()是protected的,既不是private函数,也不是public函数,那要不要做NULL值或空字符串的判断呢?

之所以将它设置为protected,是为了方便写单元测试。不过,单元测试可能要测试一些corner case,比如输入是NULL值或者空字符串的情况。所以,这里我们最好也加上NULL值或空字符串的判断逻辑。虽然加上有些冗余,但多加些检验总归不会错的。

按照这个设计思路,我们对getLastSubstrSplittedByDot()函数进行重构。重构之后的代码如下所示:

  @VisibleForTesting
  protected String getLastSubstrSplittedByDot(String hostName) {
    if (hostName == null || hostName.isEmpty()) {
      throw IllegalArgumentException("..."); //运行时异常
    }
    String[] tokens = hostName.split("\\.");
    String substrOfHostName = tokens[tokens.length - 1];
    return substrOfHostName;
  }

按照上面讲的,我们在使用这个函数的时候,自己也要保证不传递NULL值或者空字符串进去。所以,getLastFieldOfHostName()函数的代码也要作相应的修改。修改之后的代码如下所示:

 private String getLastFieldOfHostName() throws UnknownHostException{
    String substrOfHostName = null;
    String hostName = InetAddress.getLocalHost().getHostName();
    if (hostName == null || hostName.isEmpty()) { // 此处做判断
      throw new UnknownHostException("...");
    }
    substrOfHostName = getLastSubstrSplittedByDot(hostName);
    return substrOfHostName;
 }

重构generateRandomAlphameric()函数

对于generateRandomAlphameric(int length)函数,如果length < 0或length = 0,这个函数应该返回什么?

  @VisibleForTesting
  protected String generateRandomAlphameric(int length) {
    char[] randomChars = new char[length];
    int count = 0;
    Random random = new Random();
    while (count < length) {
      int maxAscii = 'z';
      int randomAscii = random.nextInt(maxAscii);
      boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
      boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
      boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
      if (isDigit|| isUppercase || isLowercase) {
        randomChars[count] = (char) (randomAscii);
        ++count;
      }
    }
    return new String(randomChars);
  }
}

我们先来看length < 0的情况。生成一个长度为负值的随机字符串是不符合常规逻辑的,是一种异常行为。所以,当传入的参数length < 0的时候,我们抛出IllegalArgumentException异常。

我们再来看length = 0的情况。length = 0是否是异常行为呢?这就看你自己怎么定义了。我们既可以把它定义为一种异常行为,抛出IllegalArgumentException异常,也可以把它定义为一种正常行为,让函数在入参length = 0的情况下,直接返回空字符串。不管选择哪种处理方式,最关键的一点是,要在函数注释中,明确告知length = 0的情况下,会返回什么样的数据。

实战:优化接口性能统计项目

回顾

在之前的项目中,整个框架的代码被划分为下面这几个类。

Aggregator类里面只有一个静态函数,有50行左右的代码量,负责各种统计数据的计算。当要添加新的统计功能的时候,我们需要修改aggregate()函数代码。一旦越来越多的统计功能添加进来之后,这个函数的代码量会持续增加,可读性、可维护性就变差了。

public class Aggregator {
  public static RequestStat aggregate(List<RequestInfo> requestInfos, long durationInMillis) {
    double maxRespTime = Double.MIN_VALUE;
    double minRespTime = Double.MAX_VALUE;
    double avgRespTime = -1;
    double p999RespTime = -1;
    double p99RespTime = -1;
    double sumRespTime = 0;
    long count = 0;
    for (RequestInfo requestInfo : requestInfos) {
      ++count;
      double respTime = requestInfo.getResponseTime();
      if (maxRespTime < respTime) {
        maxRespTime = respTime;
      }
      if (minRespTime > respTime) {
        minRespTime = respTime;
      }
      sumRespTime += respTime;
    }
    if (count != 0) {
      avgRespTime = sumRespTime / count;
    }
    long tps = (long)(count / durationInMillis * 1000);
    Collections.sort(requestInfos, new Comparator<RequestInfo>() {
      @Override
      public int compare(RequestInfo o1, RequestInfo o2) {
        double diff = o1.getResponseTime() - o2.getResponseTime();
        if (diff < 0.0) {
          return -1;
        } else if (diff > 0.0) {
          return 1;
        } else {
          return 0;
        }
      }
    });
 
    if (count != 0) {
      int idx999 = (int)(count * 0.999);
      int idx99 = (int)(count * 0.99);
      p999RespTime = requestInfos.get(idx999).getResponseTime();
      p99RespTime = requestInfos.get(idx99).getResponseTime();
    }
    RequestStat requestStat = new RequestStat();
    requestStat.setMaxResponseTime(maxRespTime);
    requestStat.setMinResponseTime(minRespTime);
    requestStat.setAvgResponseTime(avgRespTime);
    requestStat.setP999ResponseTime(p999RespTime);
    requestStat.setP99ResponseTime(p99RespTime);
    requestStat.setCount(count);
    requestStat.setTps(tps);
    return requestStat;
  }
}

public class RequestStat {
  private double maxResponseTime;
  private double minResponseTime;
  private double avgResponseTime;
  private double p999ResponseTime;
  private double p99ResponseTime;
  private long count;
  private long tps;
  //...省略getter/setter方法...
}

我们再来看一下ConsoleReporter和EmailReporter这两个类存在的问题。

ConsoleReporter和EmailReporter两个类中存在代码重复问题。在这两个类中,从数据库中取数据、做统计的逻辑都是相同的,可以抽取出来复用,否则就违反了DRY原则。

整个类负责的事情比较多,不相干的逻辑糅合在里面,职责不够单一。特别是显示部分的代码可能会比较复杂(比如Email的显示方式),最好能将这部分显示逻辑剥离出来,设计成一个独立的类。

除此之外,因为代码中涉及线程操作,并且调用了Aggregator的静态函数,所以代码的可测试性也有待提高。

public class ConsoleReporter {
  private MetricsStorage metricsStorage;
  private ScheduledExecutorService executor;

  public ConsoleReporter(MetricsStorage metricsStorage) {
    this.metricsStorage = metricsStorage;
    this.executor = Executors.newSingleThreadScheduledExecutor();
  }

  public void startRepeatedReport(long periodInSeconds, long durationInSeconds) {
    executor.scheduleAtFixedRate(new Runnable() {
      @Override
      public void run() {
        long durationInMillis = durationInSeconds * 1000;
        long endTimeInMillis = System.currentTimeMillis();
        long startTimeInMillis = endTimeInMillis - durationInMillis;
        Map<String, List<RequestInfo>> requestInfos =
                metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
        Map<String, RequestStat> stats = new HashMap<>();
        for (Map.Entry<String, List<RequestInfo>> entry : requestInfos.entrySet()) {
          String apiName = entry.getKey();
          List<RequestInfo> requestInfosPerApi = entry.getValue();
          RequestStat requestStat = Aggregator.aggregate(requestInfosPerApi, durationInMillis);
          stats.put(apiName, requestStat);
        }
        System.out.println("Time Span: [" + startTimeInMillis + ", " + endTimeInMillis + "]");
        Gson gson = new Gson();
        System.out.println(gson.toJson(stats));
      }
    }, 0, periodInSeconds, TimeUnit.SECONDS);
  }

}

public class EmailReporter {
  private static final Long DAY_HOURS_IN_SECONDS = 86400L;

  private MetricsStorage metricsStorage;
  private EmailSender emailSender;
  private List<String> toAddresses = new ArrayList<>();

  public EmailReporter(MetricsStorage metricsStorage) {
    this(metricsStorage, new EmailSender(/*省略参数*/));
  }

  public EmailReporter(MetricsStorage metricsStorage, EmailSender emailSender) {
    this.metricsStorage = metricsStorage;
    this.emailSender = emailSender;
  }

  public void addToAddress(String address) {
    toAddresses.add(address);
  }

  public void startDailyReport() {
    Calendar calendar = Calendar.getInstance();
    calendar.add(Calendar.DATE, 1);
    calendar.set(Calendar.HOUR_OF_DAY, 0);
    calendar.set(Calendar.MINUTE, 0);
    calendar.set(Calendar.SECOND, 0);
    calendar.set(Calendar.MILLISECOND, 0);
    Date firstTime = calendar.getTime(); 
    Timer timer = new Timer();
    timer.schedule(new TimerTask() {
      @Override
      public void run() {
        long durationInMillis = DAY_HOURS_IN_SECONDS * 1000;
        long endTimeInMillis = System.currentTimeMillis();
        long startTimeInMillis = endTimeInMillis - durationInMillis;
        Map<String, List<RequestInfo>> requestInfos =
                metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
        Map<String, RequestStat> stats = new HashMap<>();
        for (Map.Entry<String, List<RequestInfo>> entry : requestInfos.entrySet()) {
          String apiName = entry.getKey();
          List<RequestInfo> requestInfosPerApi = entry.getValue();
          RequestStat requestStat = Aggregator.aggregate(requestInfosPerApi, durationInMillis);
          stats.put(apiName, requestStat);
        }
        // TODO: 格式化为html格式,并且发送邮件
      }
    }, firstTime, DAY_HOURS_IN_SECONDS * 1000);
  }
}

重构一

Aggregator类和ConsoleReporter、EmailReporter类主要负责统计显示的工作。在第26节中,我们提到,如果我们把统计显示所要完成的功能逻辑细分一下,主要包含下面4点:

  1. 根据给定的时间区间,从数据库中拉取数据;
  2. 根据原始数据,计算得到统计数据;
  3. 将统计数据显示到终端(命令行或邮件);
  4. 定时触发以上三个过程的执行。

之前的划分方法是将所有的逻辑都放到ConsoleReporter和EmailReporter这两个上帝类中,而Aggregator只是一个包含静态方法的工具类。这样的划分方法存在前面提到的一些问题,我们需要对其进行重新划分。

面向对象设计中的最后一步是组装类并提供执行入口,所以,组装前三部分逻辑的上帝类是必须要有的。我们可以将上帝类做的很轻量级,把核心逻辑都剥离出去,形成独立的类,上帝类只负责组装类和串联执行流程。这样做的好处是,代码结构更加清晰,底层核心逻辑更容易被复用。按照这个设计思路,具体的重构工作包含以下4个方面。

public class Aggregator {
  public Map<String, RequestStat> aggregate(
          Map<String, List<RequestInfo>> requestInfos, long durationInMillis) {
    Map<String, RequestStat> requestStats = new HashMap<>();
    for (Map.Entry<String, List<RequestInfo>> entry : requestInfos.entrySet()) {
      String apiName = entry.getKey();
      List<RequestInfo> requestInfosPerApi = entry.getValue();
      RequestStat requestStat = doAggregate(requestInfosPerApi, durationInMillis);
      requestStats.put(apiName, requestStat);
    }
    return requestStats;
  }

  private RequestStat doAggregate(List<RequestInfo> requestInfos, long durationInMillis) {
    List<Double> respTimes = new ArrayList<>();
    for (RequestInfo requestInfo : requestInfos) {
      double respTime = requestInfo.getResponseTime();
      respTimes.add(respTime);
    }

    RequestStat requestStat = new RequestStat();
    requestStat.setMaxResponseTime(max(respTimes));
    requestStat.setMinResponseTime(min(respTimes));
    requestStat.setAvgResponseTime(avg(respTimes));
    requestStat.setP999ResponseTime(percentile999(respTimes));
    requestStat.setP99ResponseTime(percentile99(respTimes));
    requestStat.setCount(respTimes.size());
    requestStat.setTps((long) tps(respTimes.size(), durationInMillis/1000));
    return requestStat;
  }

  // 以下的函数的代码实现均省略...
  private double max(List<Double> dataset) {}
  private double min(List<Double> dataset) {}
  private double avg(List<Double> dataset) {}
  private double tps(int count, double duration) {}
  private double percentile999(List<Double> dataset) {}
  private double percentile99(List<Double> dataset) {}
  private double percentile(List<Double> dataset, double ratio) {}
}
public interface StatViewer {
  void output(Map<String, RequestStat> requestStats, long startTimeInMillis, long endTimeInMills);
}

public class ConsoleViewer implements StatViewer {
  public void output(
          Map<String, RequestStat> requestStats, long startTimeInMillis, long endTimeInMills) {
    System.out.println("Time Span: [" + startTimeInMillis + ", " + endTimeInMills + "]");
    Gson gson = new Gson();
    System.out.println(gson.toJson(requestStats));
  }
}

public class EmailViewer implements StatViewer {
  private EmailSender emailSender;
  private List<String> toAddresses = new ArrayList<>();

  public EmailViewer() {
    this.emailSender = new EmailSender(/*省略参数*/);
  }

  public EmailViewer(EmailSender emailSender) {
    this.emailSender = emailSender;
  }

  public void addToAddress(String address) {
    toAddresses.add(address);
  }

  public void output(
          Map<String, RequestStat> requestStats, long startTimeInMillis, long endTimeInMills) {
    // format the requestStats to HTML style.
    // send it to email toAddresses.
  }
}
public class ConsoleReporter {
  private MetricsStorage metricsStorage;
  private Aggregator aggregator;
  private StatViewer viewer;
  private ScheduledExecutorService executor;

  public ConsoleReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
    this.metricsStorage = metricsStorage;
    this.aggregator = aggregator;
    this.viewer = viewer;
    this.executor = Executors.newSingleThreadScheduledExecutor();
  }

  public void startRepeatedReport(long periodInSeconds, long durationInSeconds) {
    executor.scheduleAtFixedRate(new Runnable() {
      @Override
      public void run() {
        long durationInMillis = durationInSeconds * 1000;
        long endTimeInMillis = System.currentTimeMillis();
        long startTimeInMillis = endTimeInMillis - durationInMillis;
        Map<String, List<RequestInfo>> requestInfos =
                metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
        Map<String, RequestStat> requestStats = aggregator.aggregate(requestInfos, durationInMillis);
        viewer.output(requestStats, startTimeInMillis, endTimeInMillis);
      }
    }, 0L, periodInSeconds, TimeUnit.SECONDS);
  }

}

public class EmailReporter {
  private static final Long DAY_HOURS_IN_SECONDS = 86400L;

  private MetricsStorage metricsStorage;
  private Aggregator aggregator;
  private StatViewer viewer;

  public EmailReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
    this.metricsStorage = metricsStorage;
    this.aggregator = aggregator;
    this.viewer = viewer;
  }

  public void startDailyReport() {
    Calendar calendar = Calendar.getInstance();
    calendar.add(Calendar.DATE, 1);
    calendar.set(Calendar.HOUR_OF_DAY, 0);
    calendar.set(Calendar.MINUTE, 0);
    calendar.set(Calendar.SECOND, 0);
    calendar.set(Calendar.MILLISECOND, 0);
    Date firstTime = calendar.getTime();
    Timer timer = new Timer();
    timer.schedule(new TimerTask() {
      @Override
      public void run() {
        long durationInMillis = DAY_HOURS_IN_SECONDS * 1000;
        long endTimeInMillis = System.currentTimeMillis();
        long startTimeInMillis = endTimeInMillis - durationInMillis;
        Map<String, List<RequestInfo>> requestInfos =
                metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
        Map<String, RequestStat> stats = aggregator.aggregate(requestInfos, durationInMillis);
        viewer.output(stats, startTimeInMillis, endTimeInMillis);
      }
    }, firstTime, DAY_HOURS_IN_SECONDS * 1000);
  }
} 

经过上面的重构之后,我们现在再来看一下,现在框架该如何来使用。

我们需要在应用启动的时候,创建好ConsoleReporter对象,并且调用它的startRepeatedReport()函数,来启动定时统计并输出数据到终端。同理,我们还需要创建好EmailReporter对象,并且调用它的startDailyReport()函数,来启动每日统计并输出数据到制定邮件地址。我们通过MetricsCollector类来收集接口的访问情况,这部分收集代码会跟业务逻辑代码耦合在一起,或者统一放到类似Spring AOP的切面中完成。具体的使用代码示例如下:

public class PerfCounterTest {
  public static void main(String[] args) {
    MetricsStorage storage = new RedisMetricsStorage();
    Aggregator aggregator = new Aggregator();

    // 定时触发统计并将结果显示到终端
    ConsoleViewer consoleViewer = new ConsoleViewer();
    ConsoleReporter consoleReporter = new ConsoleReporter(storage, aggregator, consoleViewer);
    consoleReporter.startRepeatedReport(60, 60);

    // 定时触发统计并将结果输出到邮件
    EmailViewer emailViewer = new EmailViewer();
    emailViewer.addToAddress("wangzheng@xzg.com");
    EmailReporter emailReporter = new EmailReporter(storage, aggregator, emailViewer);
    emailReporter.startDailyReport();

    // 收集接口访问数据
    MetricsCollector collector = new MetricsCollector(storage);
    collector.recordRequest(new RequestInfo("register", 123, 10234));
    collector.recordRequest(new RequestInfo("register", 223, 11234));
    collector.recordRequest(new RequestInfo("register", 323, 12334));
    collector.recordRequest(new RequestInfo("login", 23, 12434));
    collector.recordRequest(new RequestInfo("login", 1223, 14234));

    try {
      Thread.sleep(100000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
}

重构二

我们知道,继承能解决代码重复的问题。我们可以将ConsoleReporter和EmailReporter中的相同代码逻辑,提取到父类ScheduledReporter中,以解决代码重复问题。按照这个思路,重构之后的代码如下所示:

public abstract class ScheduledReporter {
  protected MetricsStorage metricsStorage;
  protected Aggregator aggregator;
  protected StatViewer viewer;

  public ScheduledReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
    this.metricsStorage = metricsStorage;
    this.aggregator = aggregator;
    this.viewer = viewer;
  }

  protected void doStatAndReport(long startTimeInMillis, long endTimeInMillis) {
    long durationInMillis = endTimeInMillis -  startTimeInMillis;
    Map<String, List<RequestInfo>> requestInfos =
            metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
    Map<String, RequestStat> requestStats = aggregator.aggregate(requestInfos, durationInMillis);
    viewer.output(requestStats, startTimeInMillis, endTimeInMillis);
  }
}

ConsoleReporter和EmailReporter代码重复的问题解决了,那我们再来看一下代码的可测试性问题。因为ConsoleReporter和EmailReporter的代码比较相似,且EmailReporter的代码更复杂些,所以,关于如何重构来提高其可测试性,我们拿EmailReporter来举例说明。将重复代码提取到父类ScheduledReporter之后,EmailReporter代码如下所示:

public class EmailReporter extends ScheduledReporter {
  private static final Long DAY_HOURS_IN_SECONDS = 86400L;

  private MetricsStorage metricsStorage;
  private Aggregator aggregator;
  private StatViewer viewer;

  public EmailReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
    this.metricsStorage = metricsStorage;
    this.aggregator = aggregator;
    this.viewer = viewer;
  }

  public void startDailyReport() {
    Calendar calendar = Calendar.getInstance();
    calendar.add(Calendar.DATE, 1);
    calendar.set(Calendar.HOUR_OF_DAY, 0);
    calendar.set(Calendar.MINUTE, 0);
    calendar.set(Calendar.SECOND, 0);
    calendar.set(Calendar.MILLISECOND, 0);
    Date firstTime = calendar.getTime();

    Timer timer = new Timer();
    timer.schedule(new TimerTask() {
      @Override
      public void run() {
        long durationInMillis = DAY_HOURS_IN_SECONDS * 1000;
        long endTimeInMillis = System.currentTimeMillis();
        long startTimeInMillis = endTimeInMillis - durationInMillis;
        doStatAndReport(startTimeInMillis, endTimeInMillis);
      }
    }, firstTime, DAY_HOURS_IN_SECONDS * 1000);
  }
}

前面提到,之所以EmailReporter可测试性不好,一方面是因为用到了线程(定时器也相当于多线程),另一方面是因为涉及时间的计算逻辑。

实际上,在经过上一步的重构之后,EmailReporter中的startDailyReport()函数的核心逻辑已经被抽离出去了,较复杂的、容易出bug的就只剩下计算firstTime的那部分代码了。我们可以将这部分代码继续抽离出来,封装成一个函数,然后,单独针对这个函数写单元测试。重构之后的代码如下所示:

public class EmailReporter extends ScheduledReporter {
  // 省略其他代码...
  public void startDailyReport() {
    Date firstTime = trimTimeFieldsToZeroOfNextDay();
    Timer timer = new Timer();
    timer.schedule(new TimerTask() {
      @Override
      public void run() {
        // 省略其他代码...
      }
    }, firstTime, DAY_HOURS_IN_SECONDS * 1000);
  }

  // 设置成protected而非private是为了方便写单元测试
  @VisibleForTesting
  protected Date trimTimeFieldsToZeroOfNextDay() {
    Calendar calendar = Calendar.getInstance(); // 这里可以获取当前时间
    calendar.add(Calendar.DATE, 1);
    calendar.set(Calendar.HOUR_OF_DAY, 0);
    calendar.set(Calendar.MINUTE, 0);
    calendar.set(Calendar.SECOND, 0);
    calendar.set(Calendar.MILLISECOND, 0);
    return calendar.getTime();
  }
}

简单的代码抽离成trimTimeFieldsToZeroOfNextDay()函数之后,虽然代码更加清晰了,一眼就能从名字上知道这段代码的意图(获取当前时间的下一天的0点时间),但我们发现这个函数的可测试性仍然不好,因为它强依赖当前的系统时间。实际上,这个问题挺普遍的。一般的解决方法是,将强依赖的部分通过参数传递进来,这有点类似我们之前讲的依赖注入。按照这个思路,我们再对trimTimeFieldsToZeroOfNextDay()函数进行重构。重构之后的代码如下所示:

public class EmailReporter extends ScheduledReporter {
  // 省略其他代码...
  public void startDailyReport() {
    // new Date()可以获取当前时间
    Date firstTime = trimTimeFieldsToZeroOfNextDay(new Date());
    Timer timer = new Timer();
    timer.schedule(new TimerTask() {
      @Override
      public void run() {
        // 省略其他代码...
      }
    }, firstTime, DAY_HOURS_IN_SECONDS * 1000);
  }

  protected Date trimTimeFieldsToZeroOfNextDay(Date date) {
    Calendar calendar = Calendar.getInstance(); // 这里可以获取当前时间
    calendar.setTime(date); // 重新设置时间
    calendar.add(Calendar.DATE, 1);
    calendar.set(Calendar.HOUR_OF_DAY, 0);
    calendar.set(Calendar.MINUTE, 0);
    calendar.set(Calendar.SECOND, 0);
    calendar.set(Calendar.MILLISECOND, 0);
    return calendar.getTime();
  }
}

非功能需求完善

易用性

所谓的易用性,顾名思义,就是框架是否好用。框架的使用者将框架集成到自己的系统中时,主要用到MetricsCollector和EmailReporter、ConsoleReporter这几个类。通过MetricsCollector类来采集数据,通过EmailReporter、ConsoleReporter类来触发主动统计数据、显示统计结果。示例代码如下所示:

public class PerfCounterTest {
  public static void main(String[] args) {
    MetricsStorage storage = new RedisMetricsStorage();
    Aggregator aggregator = new Aggregator();

    // 定时触发统计并将结果显示到终端
    ConsoleViewer consoleViewer = new ConsoleViewer();
    ConsoleReporter consoleReporter = new ConsoleReporter(storage, aggregator, consoleViewer);
    consoleReporter.startRepeatedReport(60, 60);
    
    // 定时触发统计并将结果输出到邮件
    EmailViewer emailViewer = new EmailViewer();
    emailViewer.addToAddress("wangzheng@xzg.com");
    EmailReporter emailReporter = new EmailReporter(storage, aggregator, emailViewer);
    emailReporter.startDailyReport();

    // 收集接口访问数据
    MetricsCollector collector = new MetricsCollector(storage);
    collector.recordRequest(new RequestInfo("register", 123, 10234));
    collector.recordRequest(new RequestInfo("register", 223, 11234));
    collector.recordRequest(new RequestInfo("register", 323, 12334));
    collector.recordRequest(new RequestInfo("login", 23, 12434));
    collector.recordRequest(new RequestInfo("login", 1223, 14234));

    try {
      Thread.sleep(100000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
}

从上面的使用示例中,我们可以看出,框架用起来还是稍微有些复杂的,需要组装各种类,比如需要创建MetricsStorage对象、Aggregator对象、ConsoleViewer对象,然后注入到ConsoleReporter中,才能使用ConsoleReporter。除此之外,还有可能存在误用的情况,比如把EmailViewer传递进了ConsoleReporter中。总体上来讲,框架的使用方式暴露了太多细节给用户,过于灵活也带来了易用性的降低。

为了让框架用起来更加简单(能将组装的细节封装在框架中,不暴露给框架使用者),又不失灵活性(可以自由组装不同的MetricsStorage实现类、StatViewer实现类到ConsoleReporter或EmailReporter),也不降低代码的可测试性(通过依赖注入来组装类,方便在单元测试中mock),我们可以额外地提供一些封装了默认依赖的构造函数,让使用者自主选择使用哪种构造函数来构造对象。

public class MetricsCollector {
  private MetricsStorage metricsStorage;

  // 兼顾代码的易用性,新增一个封装了默认依赖的构造函数
  public MetricsCollectorB() {
    this(new RedisMetricsStorage());
  }

  // 兼顾灵活性和代码的可测试性,这个构造函数继续保留
  public MetricsCollectorB(MetricsStorage metricsStorage) {
    this.metricsStorage = metricsStorage;
  }
  // 省略其他代码...
}

public class ConsoleReporter extends ScheduledReporter {
  private ScheduledExecutorService executor;
  
  // 兼顾代码的易用性,新增一个封装了默认依赖的构造函数
  public ConsoleReporter() {
    this(new RedisMetricsStorage(), new Aggregator(), new ConsoleViewer());
  }

  // 兼顾灵活性和代码的可测试性,这个构造函数继续保留
  public ConsoleReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
    super(metricsStorage, aggregator, viewer);
    this.executor = Executors.newSingleThreadScheduledExecutor();
  }
  // 省略其他代码...
}

public class EmailReporter extends ScheduledReporter {
  private static final Long DAY_HOURS_IN_SECONDS = 86400L;

  // 兼顾代码的易用性,新增一个封装了默认依赖的构造函数
  public EmailReporter(List<String> emailToAddresses) {
    this(new RedisMetricsStorage(), new Aggregator(), new EmailViewer(emailToAddresses));
  }
  
  // 兼顾灵活性和代码的可测试性,这个构造函数继续保留
  public EmailReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
    super(metricsStorage, aggregator, viewer);
  }
  // 省略其他代码...
}

现在,我们再来看下框架如何来使用。具体使用示例如下所示。看起来是不是简单多了呢?

public class PerfCounterTest {
  public static void main(String[] args) {
    ConsoleReporter consoleReporter = new ConsoleReporter();
    consoleReporter.startRepeatedReport(60, 60);

    List<String> emailToAddresses = new ArrayList<>();
    emailToAddresses.add("wangzheng@xzg.com");
    EmailReporter emailReporter = new EmailReporter(emailToAddresses);
    emailReporter.startDailyReport();

    MetricsCollector collector = new MetricsCollector();
    collector.recordRequest(new RequestInfo("register", 123, 10234));
    collector.recordRequest(new RequestInfo("register", 223, 11234));
    collector.recordRequest(new RequestInfo("register", 323, 12334));
    collector.recordRequest(new RequestInfo("login", 23, 12434));
    collector.recordRequest(new RequestInfo("login", 1223, 14234));

    try {
      Thread.sleep(100000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
}

性能

对于需要集成到业务系统的框架来说,我们不希望框架本身代码的执行效率,对业务系统有太多性能上的影响。对于性能计数器这个框架来说,一方面,我们希望它是低延迟的,也就是说,统计代码不影响或很少影响接口本身的响应时间;另一方面,我们希望框架本身对内存的消耗不能太大。

对于性能这一点,落实到具体的代码层面,需要解决两个问题,也是我们之前提到过的,一个是采集和存储要异步来执行,因为存储基于外部存储(比如Redis),会比较慢,异步存储可以降低对接口响应时间的影响。另一个是当需要聚合统计的数据量比较大的时候,一次性加载太多的数据到内存,有可能会导致内存吃紧,甚至内存溢出,这样整个系统都会瘫痪掉。

针对第一个问题,我们通过在MetricsCollector中引入Google Guava EventBus来解决。实际上,我们可以把EventBus看作一个“生产者-消费者”模型或者“发布-订阅”模型,采集的数据先放入内存共享队列中,另一个线程读取共享队列中的数据,写入到外部存储(比如Redis)中。具体的代码实现如下所示:

public class MetricsCollector {
  private static final int DEFAULT_STORAGE_THREAD_POOL_SIZE = 20;

  private MetricsStorage metricsStorage;
  private EventBus eventBus;

  public MetricsCollector(MetricsStorage metricsStorage) {
    this(metricsStorage, DEFAULT_STORAGE_THREAD_POOL_SIZE);
  }

  public MetricsCollector(MetricsStorage metricsStorage, int threadNumToSaveData) {
    this.metricsStorage = metricsStorage;
    this.eventBus = new AsyncEventBus(Executors.newFixedThreadPool(threadNumToSaveData));
    this.eventBus.register(new EventListener());
  }

  public void recordRequest(RequestInfo requestInfo) {
    if (requestInfo == null || StringUtils.isBlank(requestInfo.getApiName())) {
      return;
    }
    eventBus.post(requestInfo);
  }

  public class EventListener {
    @Subscribe
    public void saveRequestInfo(RequestInfo requestInfo) {
      metricsStorage.saveRequestInfo(requestInfo);
    }
  }
}

针对第二个问题,解决的思路比较简单,但代码实现稍微有点复杂。当统计的时间间隔较大的时候,需要统计的数据量就会比较大。我们可以将其划分为一些小的时间区间(比如10分钟作为一个统计单元),针对每个小的时间区间分别进行统计,然后将统计得到的结果再进行聚合,得到最终整个时间区间的统计结果。不过,这个思路只适合响应时间的max、min、avg,及其接口请求count、tps的统计,对于响应时间的percentile的统计并不适用。

对于percentile的统计要稍微复杂一些,具体的解决思路是这样子的:我们分批从Redis中读取数据,然后存储到文件中,再根据响应时间从小到大利用外部排序算法来进行排序(具体的实现方式可以看一下《数据结构与算法之美》专栏)。排序完成之后,再从文件中读取第count*percentile(count表示总的数据个数,percentile就是百分比,99百分位就是0.99)个数据,就是对应的percentile响应时间。

这里给出了除了percentile之外的统计信息的计算代码,如下所示。

public class ScheduleReporter {
  private static final long MAX_STAT_DURATION_IN_MILLIS = 10 * 60 * 1000; // 10minutes

  protected MetricsStorage metricsStorage;
  protected Aggregator aggregator;
  protected StatViewer viewer;

  public ScheduleReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
    this.metricsStorage = metricsStorage;
    this.aggregator = aggregator;
    this.viewer = viewer;
  }

  protected void doStatAndReport(long startTimeInMillis, long endTimeInMillis) {
    Map<String, RequestStat> stats = doStat(startTimeInMillis, endTimeInMillis);
    viewer.output(stats, startTimeInMillis, endTimeInMillis);
  }

  private Map<String, RequestStat> doStat(long startTimeInMillis, long endTimeInMillis) {
    Map<String, List<RequestStat>> segmentStats = new HashMap<>();
    long segmentStartTimeMillis = startTimeInMillis;
    while (segmentStartTimeMillis < endTimeInMillis) {
      long segmentEndTimeMillis = segmentStartTimeMillis + MAX_STAT_DURATION_IN_MILLIS;
      if (segmentEndTimeMillis > endTimeInMillis) {
        segmentEndTimeMillis = endTimeInMillis;
      }
      Map<String, List<RequestInfo>> requestInfos =
              metricsStorage.getRequestInfos(segmentStartTimeMillis, segmentEndTimeMillis);
      if (requestInfos == null || requestInfos.isEmpty()) {
        continue;
      }
      Map<String, RequestStat> segmentStat = aggregator.aggregate(
              requestInfos, segmentEndTimeMillis - segmentStartTimeMillis);
      addStat(segmentStats, segmentStat);
      segmentStartTimeMillis += MAX_STAT_DURATION_IN_MILLIS;
    }

    long durationInMillis = endTimeInMillis - startTimeInMillis;
    Map<String, RequestStat> aggregatedStats = aggregateStats(segmentStats, durationInMillis);
    return aggregatedStats;
  }

  private void addStat(Map<String, List<RequestStat>> segmentStats,
                       Map<String, RequestStat> segmentStat) {
    for (Map.Entry<String, RequestStat> entry : segmentStat.entrySet()) {
      String apiName = entry.getKey();
      RequestStat stat = entry.getValue();
      List<RequestStat> statList = segmentStats.putIfAbsent(apiName, new ArrayList<>());
      statList.add(stat);
    }
  }

  private Map<String, RequestStat> aggregateStats(Map<String, List<RequestStat>> segmentStats,
                                                  long durationInMillis) {
    Map<String, RequestStat> aggregatedStats = new HashMap<>();
    for (Map.Entry<String, List<RequestStat>> entry : segmentStats.entrySet()) {
      String apiName = entry.getKey();
      List<RequestStat> apiStats = entry.getValue();
      double maxRespTime = Double.MIN_VALUE;
      double minRespTime = Double.MAX_VALUE;
      long count = 0;
      double sumRespTime = 0;
      for (RequestStat stat : apiStats) {
        if (stat.getMaxResponseTime() > maxRespTime) maxRespTime = stat.getMaxResponseTime();
        if (stat.getMinResponseTime() < minRespTime) minRespTime = stat.getMinResponseTime();
        count += stat.getCount();
        sumRespTime += (stat.getCount() * stat.getAvgResponseTime());
      }
      RequestStat aggregatedStat = new RequestStat();
      aggregatedStat.setMaxResponseTime(maxRespTime);
      aggregatedStat.setMinResponseTime(minRespTime);
      aggregatedStat.setAvgResponseTime(sumRespTime / count);
      aggregatedStat.setCount(count);
      aggregatedStat.setTps(count / durationInMillis * 1000);
      aggregatedStats.put(apiName, aggregatedStat);
    }
    return aggregatedStats;
  }
}

扩展性

前面我们提到,框架的扩展性有别于代码的扩展性,是从使用者的角度来讲的,特指使用者可以在不修改框架源码,甚至不拿到框架源码的情况下,为框架扩展新的功能。

在刚刚讲到框架的易用性的时候,我们给出了框架如何使用的代码示例。从示例中,我们可以发现,框架在兼顾易用性的同时,也可以灵活地替换各种类对象,比如MetricsStorage、StatViewer。举个例子来说,如果我们要让框架基于HBase来存储原始数据而非Redis,那我们只需要设计一个实现MetricsStorage接口的HBaseMetricsStorage类,传递给MetricsCollector和ConsoleReporter、EmailReporter类即可。

容错性

容错性这一点也非常重要。对于这个框架来说,不能因为框架本身的异常导致接口请求出错。所以,对框架可能存在的各种异常情况,我们都要考虑全面。

在现在的框架设计与实现中,采集和存储是异步执行,即便Redis挂掉或者写入超时,也不会影响到接口的正常响应。除此之外,Redis异常,可能会影响到数据统计显示(也就是ConsoleReporter、EmailReporter负责的工作),但并不会影响到接口的正常响应。

通用性

为了提高框架的复用性,能够灵活应用到各种场景中,框架在设计的时候,要尽可能通用。我们要多去思考一下,除了接口统计这样一个需求,这个框架还可以适用到其他哪些场景中。比如是否还可以处理其他事件的统计信息,比如SQL请求时间的统计、业务统计(比如支付成功率)等。