设计模式之美笔记7

记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步

实战1:id生成器的重构

1. 需求背景

id中文译为标识identifier,如身份证、商品条码、二维码、车牌号、驾照号等。软件开发中,id常用来表示一些业务信息的唯一标识,如订单的单号或数据库的唯一主键。

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

听起来像微服务的调用链追踪,不过,微服务的是服务间的追踪,我们实现的是服务内的追踪。借鉴微服务调用链追踪的实现思路,给每个请求分配一个唯一id,并且保存到请求的上下文context中,如处理请求的工作线程的局部变量中。在java中可将id存储到servlet线程的ThreadLocal中,或者利用slf4j日志框架的MDC(Mapped Diagnostic Contexts)来实现(底层也是基于线程的ThreadLocal)。每次打印日志,从请求上下文取出请求id,跟日志一块输出,这样每个请求的所有日志都包含同样的请求id信息了。

2. 代码实现

一个简单的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] = (cahr)('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由三部分组成:

  • 本机名的最后一个字段
  • 当前时间戳,精确到毫秒
  • 8位随机字符串,包含大小字母和数字

对于日志追踪来说,重复概率极低,可接受。

但是这样一份代码,只能说能用,有很多值得优化的地方,如何将60分及格代码优化到80、90分呢?

3. 如何发现代码质量问题

具体细节,可从以下几个方面审视代码:

  • 目录设置是否合理、模块划分是否清晰、代码结构是否满足高内聚、低耦合
  • 是否遵循经典的设计原则和设计思想(SOLID、DRY、KISS、YAGNI、LOD等)
  • 设计模式是否应用得当?是否有过度设计
  • 代码是否易扩展,如果添加新功能,是否易实现
  • 代码是否可复用?是否可复用已有的项目代码或类库?是否有重复造轮子?
  • 代码是否易测试?单元测试是否全面覆盖各种正常和异常的情况
  • 代码是否易读?是否符合编程规范?

除了上述的通用的关注点,针对业务本身特有的功能和非功能需求,还有些checklist,如下:

  • 代码是否实现了预期的业务需求?
  • 逻辑是否正确?是否处理各种异常情况
  • 日志打印是否得当?是否方便debug排查问题
  • 接口是否易用?是否支持幂等、事务等
  • 代码是否存在并发问题?是否线程安全
  • 性能是否有优化空间?如SQL、算法是否可优化?
  • 是否有安全漏洞?如输入、输出校验是否全面?

对照上面的checklist,看id生成器的代码有哪些问题。

首先,IdGenerator代码简单,只有一个类,不涉及到目录设置、模块划分、代码结构等问题,也不违反设计原则,没有用设计模式,不存在不合理使用和过度设计的问题。

其次,IdGenerator设计为实现类而非接口,调用者直接依赖实现而非接口,违反基于接口而非实现编程的设计思想。实际问题不大。但是,如果公司项目中需要同时存在两种ID生成算法,也就是同时存在两个实现类,如需要将这个框架给更多系统用。这时就要定义为接口了。

再次,将IdGenerator的generate()方法定义为静态方法,会影响使用该方法的代码的可测试性。同时,generate()方法的代码实现依赖运行环境(本机名)、时间函数、随机函数,generate()方法的本身可测试性也不好,要做较大的重构。此外,也没编写单元测试代码,要补充。

最后,虽然只包含一个方法,方法的代码行数也不多,但代码的可读性不好,特别是随机字符串生成的部分,一方面,代码没有注释,生成算法比较难读懂;另一个方面,代码有很多魔法数,影响代码的可读性。

再对照业务本身的功能和非功能需求,审视代码:

虽然生成的id并非绝对唯一,但对于追踪打印日志来说,可接受,满足预期的业务需求。不过,获取hostName部分代码逻辑有点问题,没有处理“hostName为空”的情况。此外,尽管对获取不到本机名做了异常处理,但对异常处理是在IdGenerator内部吞掉,打印一条报警日志,并没有继续向上抛出,是否得当?

该代码日志打印得当,日志描述准确,方便debug,只暴露一个generate()接口供使用者调用,不存在不宜用问题。方法的代码中并没有涉及到共享变量,代码线程安全,多线程下不存在并发问题。

性能方面,ID的生成不依赖外部存储,内存中生成,日志打印频率也并不高,性能可以应对目前的场景。不过每次生成id都要获取本机名,较为耗时。还有randomAscii的范围是0122,但可用数字只包含三段子区间(09,az,AZ),极端情况下孙吉生成很多三段区间之外的无效的数字,需要循环多次才能生成随机字符串,可优化。

具体的代码方面,在generate()方法的while循环里,三个if语句内的代码很相似,而且实现稍微复杂,可进一步简化,将三个if合并。

4. 制定重构计划

循环渐进、小步快跑。重构每次改动一点点。分为四次重构完成:

  • 第一轮重构:提高代码的可读性
  • 第二轮重构:提高代码的可测试性
  • 第三轮重构:编写完善的单元测试
  • 第四轮重构:所有重构完成后添加注释

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

先解决最明显、最急需改进的代码可读性问题:

  • hostName变量不该被重复使用,尤其当两次使用的含义不同的时候
  • 将获取hostName的代码抽离出来,定义为getLastFieldOfHostName()方法
  • 删除代码的魔法数,如57、90、97、122
  • 将随机数生成的代码抽离出来,定义为generateRandomAlphameric()方法
  • generate()方法的三个if逻辑重复,且实现过于复杂,需要简化
  • 对IdGenrator类重命名,且抽象出对应的接口

对于生成ID生成器的代码,有下面三种类的命名方式:

接口 实现类
命名方式1 IdGenerator LogTraceIdGenerator
命名方式2 LogTraceIdGenerator HostNameMillisIdGenerator
命名方式3 LogTraceIdGenerator RandomIdGenerator

这三种命名方式:

第一种,最先想到,但如果考虑以后两个类的使用和扩展,就不合理了。

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

其次,假设没有日志ID扩展需求,但要扩展其他业务的ID生成算法,如UserIdGenerator、OrderIdGenerator,第一种名字也不合理。基于接口而非实现编程,主要目的是为了方便后续灵活的替换实现类。而LogTraceIdGenerator、UserIdGenerator、OrderIdGenerator三个类是完全不同的业务,不存在互相替换的场景。

第二种呢?也不合理。LogTraceIdGenerator合理,但HostNameMillisIdGenerator暴露了太多实现细节,只要代码稍微改动,就可能需要改名字,才能匹配实现。

第三种最推荐,目前的ID生成器代码实现上,生成的ID是一个随机ID,命名较为合理,如果之后要实现一个递增有序的ID生成算法,可命名为SequenceIdGenerator。

更好的命名是:抽象出两个接口,一个是IdGenerator,一个是LogTraceIdGenerator,LogTraceIdGenerator继承IdGenerator。实现类实现接口IdGenerator,命名为RandomIdGenerator、SequenceIdGenerator这样,实现类可复用到很多业务模块,如用户、订单。

重构后的代码:

public class RandomIdGenrator implements IdGenerator{
    private static final Logger logger = LoggerFactory.getLogger(RandomIdGenrator.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();
            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);
    }
}

//代码使用举例
LogTraceIdGenrator logTraceIdGenrator = new RandomIdGenrator();

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

可测试性包含两个方面:

  • generate()方法定义为静态方法,影响使用该方法的代码的可测试性
  • generate()方法的代码实现依赖运行环境(本机名)、时间函数等未决行为,本身可测试性不好

对第一点,在第一轮重构已解决。改为了普通方法。

对于第二点,需要再重构,主要包含几点改动:

  • 从getLastfiledOfHostName()方法中,将逻辑较为复杂的代码剥离出来,定义为getLastSusstrSplittedByDot()方法,剥离后,方法简单,不用测试。重点测getLastSusstrSplittedByDot()即可。
  • 将generateRandomAlphameric()和getLastSusstrSplittedByDot()两个方法的访问权限设置为protected,目的是可直接在单元测试通过对象来调用这两个方法进行测试
  • 给generateRandomAlphameric()和getLastSusstrSplittedByDot()两个方法添加Google Guava的annotation@VisibleForTesting,这个注解只是起到标识的作用,说明方法本该是private访问权限,提升到protected,只是为了测试,只能用于单元测试
public class RandomIdGenrator implements IdGenerator{
    private static final Logger logger = LoggerFactory.getLogger(RandomIdGenrator.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){
       //...
    }
}

打印日志的Logger对象被定义为static final,并在类内部创建,是否影响代码的可测试性?是否应该将Logger对象通过依赖注入的方式注入到类中?

依赖注入之所以提高代码的可测试性,因为这样能通过mock对象替换依赖的真实对象。为什么要mock?因为这个对象参与逻辑执行,但又不可控。不过Logger对象我们只往里面写数据,并不读取数据,不参与业务逻辑的执行,没必要mock Logger对象。

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

代码存在的明显问题已经解决,为代码补全单元测试。RandomIdGenerator类有4个方法:

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

先看后两个方法,逻辑较复杂,是测试的重点。已经将它们跟不可控的组件(本机名、随机函数、时间函数)进行了隔离,只需要设计测试用例即可。

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

    //此单元测试会失败,因为在代码中没有处理hostName为null或空字符串的情况
    //之后优化
    @Test
    public void testGetLastSubstrSplittedByDot_nullOrEmpty(){
        RandomIdGenrator idGenrator = new RandomIdGenrator();
        String actualSubstr = idGenrator.getLastSubstrSplittedByDot(null);
        Assert.assertNull(actualSubstr);

        actualSubstr = idGenrator.getLastSubstrSplittedByDot("");
        Assert.assertEquals("",actualSubstr);
    }
    
    @Test
    public void testGenerateRandomAlphameric(){
        RandomIdGenrator idGenrator = new RandomIdGenrator();
        String actualRandomString = idGenrator.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的情况
    //之后优化
    @Test
    public void testGenerateRandomAlphameric_lengthEqualsOrLessThanZero(){
        RandomIdGenrator idGenrator = new RandomIdGenrator();
        String actualRandomString = idGenrator.generateRandomAlphameric(0);
        Assert.assertEquals("",actualRandomString);
        
        actualRandomString = idGenrator.generateRandomAlphameric(-1);
        Assert.assertNull(actualRandomString);
    }
}

再看generate()方法,这个方法是唯一暴露给外部使用的public方法,它依赖主机名、随机函数、时间函数,如何测试?

要分情况看,单元测试,测试对象是方法定义的功能,而非具体的实现逻辑。那generate()的功能是什么呢?

针对同一份generate()方法的代码实现,有三种不同的功能定义,对应三种不同的单元测试

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

最后看getLastfiledOfHostName()方法,这个方法不容易测试,因为调用静态方法,且这个静态方法依赖运行环境。但这个方法的实现很简单,肉眼可以排除明显的bug。毕竟不是为了写单元测试而写单元测试。

第四轮重构:添加注释

注释需要写:做什么、为什么、怎么做、怎么用,对一些边界条件、特殊情况进行说明,以及对方法输入、输出、异常进行说明


/**
 * 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) {
    //...
  }
}

5. 异常的处理

程序的bug往往出现在一些边界条件和异常情况下,异常处理的好坏直接影响代码的健壮性。全面、合理的处理各种异常能有效减少代码bug,保证代码的质量。

1. 方法出错应该返回什么

返回数据类型,有4种情况

  1. 返回错误码 一般在C语言中使用错误码
  2. 返回null值 对于get、find、select等查找方法来说,数据不存在,并非是异常情况,是正常行为。对于查找方法,有的还会返回下标位置,如java的indexOf()方法,用来实现在某个字符串中查找另一个子串第一次出现的位置,方法的返回值类型为基本类型int,无法使用null值表示不存在,有两种思路:返回NotFoundException和返回一个特殊值如-1。-1更合理点,也就是说,没有找到是正常行为
  3. 返回空对象 有个空对象设计模式,后面会谈到。有两种简单的空对象,就是空字符串和空集合。如果方法返回的数据是字符串类型或集合类型,可以用空字符串""或空集合Collections.emptyList()替代。
  4. 抛异常 最常用,异常携带很多错误信息,也将正常逻辑和异常逻辑处理区分开,代码可读性更好。对于代码bug如数组越界或不可恢复异常如数据库连接失败,倾向于使用非受检异常。对于可恢复异常、业务异常,如提现金额大于余额的异常,倾向于使用受检异常,明确告知调用者要捕获处理。

2. 如何处理方法抛出的异常

3种:

  • 直接吞掉 如果底层抛出的异常可恢复,且上层不关心此异常,可吞掉
  • 原封不动的re-throw 如果底层抛出的异常,和上层业务相关,直接抛出
  • 包装成新的异常re-throw 如果底层抛出的异常跟上层业务无关,重新包装为上层可理解的新异常,抛出

3. id生成器代码的出错处理

  • 对generate()方法,如果本机名获取失败,方法该返回什么?这样的返回值是否合理?
  • 对getLastFiledOfHostName()方法,是否该将UnknownHostException异常内部吞掉(try-catch并打印日志)?还是将异常继续往上抛?往上抛出的话,将该异常原封不动的抛出,还是封装为新的异常抛出?
  • 对getLastSubstrSplittedByDot(String hostName)方法,如果hostName为null或空字符串,方法该返回什么?
  • 对generateRandomAlphameric(int length)方法,如果length小于0或者等于0,方法该返回什么?

4. 重构generate()方法

首先看:对generate()方法,如果本机名获取失败,方法该返回什么?这样的返回值是否合理?

id是由三部分组成,时间戳和随机数的生成函数不会出错,只有主机名可能获取失败。目前的代码实现上,主机名获取失败,substrOfHostName为null,generate()方法返回类似"null-16723733647-83332ua1"这样的数据,如果主机名获取失败,substrOfHostName为空字符串,返回类似"-16723733647-83332ua1"这样的数据。

这样是否合理呢?要看具体业务需求,更倾向于明确将异常告知调用者,最好抛出受检异常,而非特殊值。

重构后:

public String generate() throws IdGenerationFailureException{
    String substrOfHostName = getLastfiledOfHostName();
    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;
}

5. 重构getLastFieldOfHostName()方法

对getLastFiledOfHostName()方法,是否该将UnknownHostException异常内部吞掉(try-catch并打印日志)?还是将异常继续往上抛?往上抛出的话,将该异常原封不动的抛出,还是封装为新的异常抛出?

目前的处理是返回null值,获取主机名失败会影响后续逻辑的处理,不是期望的,是异常行为,最好抛出异常,而非返回null值。

直接抛还是封装后抛出,要看方法跟异常是否有业务相关性。该方法获取主机名的最后一个字段,UnknownHostException异常标识主机名获取失败,算是业务相关,可以直接将UnknownHostException抛出,不需要重新包裹

重构后:

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

getLastfiledOfHostName()方法修改后,generate()方法也要做对应的修改,捕获UnknownHostException异常,捕获后怎么处理呢?

按之前分析,id生成失败后,要明确告知调用者,不能在generate()方法中,将UnknownHostException异常吞掉,是否要封装为新异常抛出呢?

要封装为IdGenerateFailureException往上抛出。调用者不care底层的细节。跟上层的业务也无关。

对generate()方法再次重构:

public String generate() throws IdGenerationFailureException{
  String substrOfHostName = null;
  try {
    substrOfHostName = getLastfiledOfHostName();
  } 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;
}

6. 重构getLastSubstrSplittedByDot(String hostName)方法

对于getLastSubstrSplittedByDot(String hostName)方法,如果hostName为null或者空字符串,应该返回什么?

理论上说,参数传递的正确性应该有程序员保证,无需做null值或者空字符串的判断和特殊处理。调用者不该把null值或空字符串传给getLastSubstrSplittedByDot(),如果传递了就是code bug,需要修复,但万一传了这种,是否做判断呢?

如果方法是private,只在类内部调用,自己保证不会传null值或空字符串即可,不用判断。但如果是public,为保证代码的健壮性,还是做判断。因此,最好加上校验。

对getLastSubstrSplittedByDot(String hostName)方法重构:

@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 getLastfiledOfHostName() throws UnknownHostException{
  String substrOfHostName = null;
  String hostName = InetAddress.getLocalHost().getHostName();
  if (hostName==null || hostName.isEmpty()){//此处做判断
    throw new UnknownHostException("...");
  }
  substrOfHostName = getLastSubstrSplittedByDot(hostName);
  return substrOfHostName;
}

7. 重构generateRandomAlphameric()方法

对generateRandomAlphameric(int length)方法,如果length小于0或者等于0,方法该返回什么?

先看length<0,这种不符合常规逻辑,是异常行为,抛出IllegalArgumentException异常。

再看length=0的情况,是否为异常?看自己定义。可以定义为异常,抛出IllegalArgumentException异常,可以定义为一种正常行为,让方法在入参length=0时,直接返回空字符串。关键是要在方法注释中,明确告知length=0时,返回什么样的数据。

6.总结

  1. 再简单的代码,看上去再完美的代码,只要下功夫推敲,总可以优化,就看是否愿意把事情做到极致
  2. 如果内功不够深厚,理论知识不够扎实,很难参透开源项目的代码到底优秀在哪里。

实战2:完善性能计数器

1. 回顾版本1

整个框架的代码分为下面几个类:

  • MetricsCollector:负责埋点采集原始数据,包括记录每次接口请求的响应时间和请求时间戳,并调用MetricsStorage提供的接口存储原始数据
  • MetricsStorage和RedisMetricsStorage负责原始数据的存储和读取
  • Aggregator:工具类,负责各种统计数据的计算,如响应时间的最大值、最小值、平均值、百分位值、接口访问次数、tps
  • ConsoleReporter和EmailReporter相当于上帝类,定时根据给定的时间区间,从 数据库获取数据,借助Aggregator类完成统计工作,并将统计结果输出到相应的终端,如命令行、邮件

2. 问题

先看Aggregator类存在的问题

Aggregator类只有一个静态方法,负责各种统计数据的计算,当要添加新的功能时,需要修改aggregate()方法的代码。统计功能增加后,代码量持续增加,可读性、可维护性变差。需要重构

再看ConsoleReporter和EmailReporter存在的问题

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

整个类负责的事情较多,不相关的逻辑杂糅在一起,职责不够单一,特别是显示部分的代码可能较为复杂(如email的显示方式),最好能将这部分显示逻辑剥离出来,为独立的类。

此外,涉及到线程操作,调用Aggregator的静态方法,代码的可测试性有待提高。

3. 重构版本1

Aggregator类和ConsoleReporter、EmailReporter类主要负责统计显示的工作。如果把统计显示要完成的功能逻辑细分,包含4点:

  1. 根据给定的时间区间,从数据库中拉取数据
  2. 根据原始数据,计算得到统计数据
  3. 将统计数据显示到终端
  4. 定时触发上述三个过程的执行

之前的划分方法是将所有的逻辑都放到ConsoleReporter、EmailReporter这两个上帝类中,而Aggregator只是个包含静态方法的工具类。划分存在前面提到的问题。

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

  1. 根据给定时间区间,从数据库拉取数据。这部分逻辑已经被封装到MetricsStorage类,不需要处理
  2. 根据原始数据,计算得到统计数据,可将这部分逻辑移动到Aggregator类中
  3. 将统计数据显示到终端,将这部分逻辑剥离出来,设计为两个类ConsoleViewer、EmailViewer类
  4. 组装类并定时触发执行统计显示。核心逻辑剥离后,类只负责组装各个类来完成整个工作流程

具体代码:

  • Aggregator的代码:
public class Aggregator {
    public Map<String, RequestStat> aggregate(Map<String,List<RequestInfo>> requestInfos, long durationInMills){
        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,durationInMills);
            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){
        return 0.0;
    }
    private double min(List<Double> dataset){
        return 0.0;
    }
    private double avg(List<Double> dataset){
        return 0.0;
    }
    private double percentile999(List<Double> dataset){
        return 0.0;
    }
    private double percentile99(List<Double> dataset){
        return 0.0;
    }
    private double percentile(List<Double> dataset,double ratio){
        return 0.0;
    }
    private double tps(int count,double duration){
        return 0.0;
    }
}
  • 显示部分的代码:
public interface StatViewer {
    void output(Map<String, RequestStat> requestStats, long startTimeInMillis, long endTimeInMillis);
}

public class ConsoleViewer implements StatViewer {
    @Override
    public void output(Map<String, RequestStat> requestStats, long startTimeInMillis, long endTimeInMillis) {
        System.out.println("Time Span: ["+startTimeInMillis+", "+endTimeInMillis);
        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);
    }
    @Override
    public void output(Map<String, RequestStat> requestStats, long startTimeInMillis, long endTimeInMillis) {
        //format the requestStats to HTML style
        // send it to email toAddresses
    }
}
  • 组装类
public class ConsoleReporter {
    private MetricsStorage metricsStorage;
    private ScheduledExecutorService executor;
    private Aggregator aggregator;
    private StatViewer viewer;

    public ConsoleReporter(MetricsStorage metricsStorage,  Aggregator aggregator, StatViewer viewer) {
        this.metricsStorage = metricsStorage;
        this.executor = Executors.newSingleThreadScheduledExecutor();
        this.aggregator = aggregator;
        this.viewer = viewer;
    }
    
    public void startRepeatedReport(long periodInSeconds, final long durationInSeconds) {
        executor.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                long durationInMills = durationInSeconds * 1000;
                long endTimeInMills = System.currentTimeMillis();
                long startTimeInMills = endTimeInMills - durationInMills;
                Map<String, List<RequestInfo>> requestInfos =
                        metricsStorage.getRequestInfos(startTimeInMills, endTimeInMills);
                Map<String, RequestStat> requestStats = aggregator.aggregate(requestInfos,durationInMills);
                viewer.output(requestStats, startTimeInMills, endTimeInMills);
            }
        }, 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对象,调用它的startRepeatedReporter()方法,启动定时统计并输出数据到终端,同样,创建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("[email protected]");
        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("login", 23, 12234));
        collector.recordRequest(new RequestInfo("login", 1223, 14234));

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

4. 代码review

重构后,MetricsStorage负责存储,Aggregator负责统计,StatViewer(ConsoleViewer EmailViewer)负责显示,三个类各司其职。ConsoleReporter和EmailReporter负责组装三个类,将获取原始数据、聚合统计、显示统计结果到终端的工作串联起来,定时触发执行。

此外,MetricsStorage Aggregator StatViewer三个类的设计符合迪米特法则。只跟自己有直接关系的数据交互。MetricsStorage输出的是RequestInfo相关数据。Aggregator输入的是RequestInfo数据,输出RequestStat数据。StatViewer输入的是RequestStat数据。

在这里插入图片描述

上图为代码的整体结构和依赖关系。再看具体每个类的设计。

Aggregator类从一个只包含一个静态方法的工具类,变为一个普通的聚合统计类。通过依赖注入方式,将其组装进ConsoleReporter和EmailReporter类,更容易编写单元测试。

Aggregator类的设计目前还算较为合理,如果统计功能越来越多,可以将统计方法剥离出来,设计为独立的类,解决该类无限膨胀问题。

ConsoleReporter和EmailReporter重构后,代码重复问题变小,但仍没有完美解决。涉及到多线程和时间相关的计算,代码的测试性不够好。

版本3

ConsoleReporter和EmailReporter仍存在代码重复问题,可测试性差的问题。此外,也要继续完善框架的功能和非功能需求。如,让原始数据的采集和存储异步执行,解决聚合统计在数据量大的情况下导致内存吃紧的问题,以及提高框架的易用性。

可将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 doStatAndReporter(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);
    }
}

再看代码的可测试性问题,以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) {
        super(metricsStorage, aggregator, 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;
                doStatAndReporter(startTimeInMillis,endTimeInMillis);
            }
        }, firstTime, DAY_HOURS_IN_SECONDS * 1000);
    }
}

经过重构,EmailReporter的startDailyReport()方法的核心逻辑已经被抽离出来,较复杂的、易出bug的只剩下firstTime的部分代码,可将该部分代码继续抽离,封装为一个方法,再针对该方法写单元测试

public void startDailyReport() {
        Date firstTime = trimTimeFieldsToZeroOfNextDay();
        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                //...
            }
        }, firstTime, DAY_HOURS_IN_SECONDS * 1000);
    }
    
    @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();
    }

代码抽离后更清晰,但可测试性依旧不好,强依赖当前的系统时间。这个问题很普遍,一般解决方案是,将强依赖部分通过参数传递进来

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);
    }
    
    @VisibleForTesting
    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();
    }

重构后,比较容易编写单元测试了。

不过,EmailReporter类的startDailyReport()还是涉及多线程,如何单元测试呢?多次重构后,该方法里已经没有多少代码逻辑了,没必要对其写单元测试。不需要为了提高单元测试覆盖率而写单元测试。代码足够简单。

2. 功能的完善

已初步实现功能了

3. 非功能的完善

需要考虑非功能性需求:易用性、性能、扩展性、容错性、通用性

  • 易用性

就是框架是否好用。从PerfCounterTest看,框架使用较为复杂,需要组装各种类,如创建MetricsStorage对象、Aggregator对象、ConsoleViewer对象,然后注入到ConsoleReporter中,才能用ConsoleReporter。此外,还可能误用,如把EmailViewer传递给ConsoleReporter。总体说,暴露太多细节给用户,过于灵活的同时降低了易用性。

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

public class MetricsCollector {
    private MetricsStorage metricsStorage;
    
    //兼顾代码的易用性,新增一个封装了默认依赖的构造函数
    public MetricsCollector(){
        this(new RedisMetricsStorage());
    }
    
    public MetricsCollector(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;

    private MetricsStorage metricsStorage;
    private Aggregator aggregator;
    private StatViewer viewer;
    
    //兼顾代码的易用性,新增一个封装了默认依赖的构造方法
    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("[email protected]");
        com.ai.doc.chonggou1.metricsv2.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("login", 23, 12234));
        collector.recordRequest(new RequestInfo("login", 1223, 14234));

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

当然,RedisMetricsStorage和EmailViewer还需要另外一些配置信息才能构建成功,如Redis的地址,Email邮箱的pop3服务器地址、发送地址。这些配置信息如何获取?

可将配置信息放到配置文件中,在框架启动时,读取配置文件的配置信息到Configuration单例类。RedisMetricsStorage类和EmailViewer类都可从Configuration中获取需要的配置信息构建自己。

2. 性能

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

在具体的代码层面,需要解决两个问题,一个是采集和存储要异步执行,因为存储基于外部存储如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(){
        this(new RedisMetricsStorage());
    }
    
    public MetricsCollector(MetricsStorage metricsStorage){
        this(metricsStorage,DEFAULT_STORAGE_THREAD_POOL_SIZE);
    }
    
    public MetricsCollector(MetricsStorage metricsStorage,int thredNumToSaveData){
        this.metricsStorage = metricsStorage;
        this.eventBus = new AsyncEventBus(Executors.newFixedThreadPool(thredNumToSaveData));
        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个数据,就是对应的percentile响应时间。

暂时只给出除了percentile外的统计信息的计算代码。

public abstract class ScheduledReporter {
    private static final long MAX_STAT_DURATION_IN_MILLIS = 10*60*1000;//10 MIN
    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 doStatAndReporter(long startTimeInMillis,long endTimeInMillis){
        Map<String,RequestStat> stats = doStat(startTimeInMillis,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);
    }

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

    private Map<String, RequestStat> aggregateStat(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.MAX_VALUE;
            double minRespTime = Double.MIN_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.setTps(count/durationInMillis*1000);
            aggregatedStats.put(apiName,aggregatedStat);
        }
        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<RequestStat>());
            statList.add(stat);
        }
    }
}

3. 扩展性

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

框架在兼顾易用性的同时,也可以灵活的替换各种类对象。如MetricsStorage、StatViewer。例如,我们让框架基于HBase存储原始数据,而非日的是,只需要设计一个实现MetricsStorage接口的HBaseMetricsStorage类,传递给MetricsCollector和ConsoleReporter、EmailReporter即可。

4. 容错性

对框架来说,不能因为框架本身的异常导致接口请求出错,对于框架可能存在的各种异常,要考虑全面。

在现在的框架设计和实现中,采集和存储是异步执行的,即使redis挂掉,不影响接口的正常响应。此外,redis异常,可能影响数据统计显示(也就是ConsoleReporter、EmailReporter负责的工作),但不影响接口的正常响应。

5. 通用性

为提高框架的复用性,能灵活应用于各种场景,框架设计时,尽可能通用。多思考,除了接口统计的需求,这个框架还可应用哪些场景。如是否可处理其他事件的统计信息,如SQL请求时间的统计、业务统计(如支付成功率)等。目前版本3暂时没考虑。

猜你喜欢

转载自blog.csdn.net/wjl31802/article/details/107896476