IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    设计模式之美-课程笔记16-设计原则实战2-接口调用信息统计框架

    10k发表于 2023-07-10 00:00:00
    love 0

    针对非业务的通用框架开发,如何做需求分析和设计?

    项目背景

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

    需求分析

    作为可被复用的框架,除了功能需求,非功能需求也很重要。

    1. 功能性需求分析

    接口统计信息:包括接口响应时间的统计信息,以及接口调用次数的统计信息等。

    统计信息的类型:max、min、avg、percentile、count、tps 等。

    统计信息显示格式:Json、Html、自定义显示格式。

    统计信息显示终端:Console、Email、HTTP 网页、日志、自定义显示终端。

    除了罗列这些, 还可以通过线框图,把最终数据的显示样式画出来,会更加一目了然。

    img

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

    统计触发方式:主动和被动。

    统计时间区间:可以自定义

    统计时间间隔

    2. 非功能性需求分析

    • 易用性:是否跟业务热插拔,解耦。
    • 性能:这部分代码不影响接口本身,堆内存消耗也不大。
    • 扩展性:从框架使用者的角度来说的,特指使用者可以在不修改框架源码,甚至不拿到框架源码的情况下,为框架扩展新的功能。这就有点类似给框架开发插件
      • feign 是一个 HTTP 客户端框架,我们可以在不修改框架源码的情况下,用如下方式来扩展我们自己的编解码方式、日志、拦截器等。通过继承和重写:
    Feign feign = Feign.builder()
            .logger(new CustomizedLogger())
            .encoder(new FormEncoder(new JacksonEncoder()))
            .decoder(new JacksonDecoder())
            .errorDecoder(new ResponseErrorDecoder())
            .requestInterceptor(new RequestHeadersInterceptor()).build();
    
    public class RequestHeadersInterceptor implements RequestInterceptor {  
      @Override
      public void apply(RequestTemplate template) {
        template.header("appId", "...");
        template.header("version", "...");
        template.header("timestamp", "...");
        template.header("token", "...");
        template.header("idempotent-token", "...");
        template.header("sequence-id", "...");
    }
    
    public class CustomizedLogger extends feign.Logger {
      //...
    }
    
    public class ResponseErrorDecoder implements ErrorDecoder {
      @Override
      public Exception decode(String methodKey, Response response) {
        //...
      }
    }
    
    • 容错性: 框架可能存在的各种异常情况都考虑全面,对外暴露的接口抛出的所有运行时、非运行时异常都进行捕获处理。
    • 通用性:提高复用性,应对尽可能多的场景。

    框架设计

    1. 借鉴TDD或者最小原型原则,先聚焦一个最基本的小场景开发。这有助于我们缕清思路,是可持续迭代的基础。
    2. 比如对于性能计数器,我们可以先加上对用户注册和登录两个接口的响应时间的max,avg,调用次数统计,并且将统计结果用JSON的格式输出到命令行中。
    //应用场景:统计下面两个接口(注册和登录)的响应时间和访问次数
    public class UserController {
      public void register(UserVo user) {
        //...
      }
      
      public UserVo login(String telephone, String password) {
        //...
      }
    }
    
    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. 划分职责进而识别有哪些类

    • MetricsCollector 类负责提供 API,来采集接口请求的原始数据。我们可以为 MetricsCollector 抽象出一个接口,但这并不是必须的,因为暂时我们只能想到一个 MetricsCollector 的实现方式。

    • MetricsStorage 接口负责原始数据存储,RedisMetricsStorage 类实现 MetricsStorage 接口。这样做是为了今后灵活地扩展新的存储方法,比如用 HBase 来存储。

    • Aggregator 类负责根据原始数据计算统计数据。
    • ConsoleReporter 类、EmailReporter 类分别负责以一定频率统计并发送统计数据到命令行和邮件。至于 ConsoleReporter 和 EmailReporter 是否可以抽象出可复用的抽象类,或者抽象出一个公共的接口,我们暂时还不能确定。

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

    1. 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方法...
    }
    
    1. 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) {
        //...
      }
    }
    
    1. 统计和显示这两个功能,可以有多种设计思路。实际上,如果我们把统计显示所要完成的功能逻辑细分一下的话,主要包含下面 4 点:
      • 根据给定的时间区间,从数据库中拉取数据;
      • 根据原始数据,计算得到统计数据;
      • 将统计数据显示到终端(命令行或邮件);
      • 定时触发以上 3 个过程的执行。
    2. 面向对象设计和实现要做的事情,就是把合适的代码放到合适的类中。
    3. 我们暂时选择把第 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方法...
    }
    
    1. 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. 将类组装起来提供执行入口

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

    Review 设计与实现

    • MetricsCollector

      MetricsCollector 负责采集和存储数据,职责相对来说还算比较单一。它基于接口而非实现编程,通过依赖注入的方式来传递 MetricsStorage 对象,可以在不需要修改代码的情况下,灵活地替换不同的存储方式,满足开闭原则。

    • MetricsStorage、RedisMetricsStorage

      MetricsStorage 和 RedisMetricsStorage 的设计比较简单。当我们需要实现新的存储方式的时候,只需要实现 MetricsStorage 接口即可。因为所有用到 MetricsStorage 和 RedisMetricsStorage 的地方,都是基于相同的接口函数来编程的,所以,除了在组装类的地方有所改动(从 RedisMetricsStorage 改为新的存储实现类),其他接口函数调用的地方都不需要改动,满足开闭原则。

    • Aggregator

      Aggregator 类是一个工具类,里面只有一个静态函数,有 50 行左右的代码量,负责各种统计数据的计算。当需要扩展新的统计功能的时候,需要修改 aggregate() 函数代码,并且一旦越来越多的统计功能添加进来之后,这个函数的代码量会持续增加,可读性、可维护性就变差了。所以,从刚刚的分析来看,这个类的设计可能存在职责不够单一、不易扩展等问题,需要在之后的版本中,对其结构做优化。

    • ConsoleReporter、EmailReporter

      ConsoleReporter 和 EmailReporter 中存在代码重复问题。在这两个类中,从数据库中取数据、做统计的逻辑都是相同的,可以抽取出来复用,否则就违反了 DRY 原则。而且整个类负责的事情比较多,职责不是太单一。特别是显示部分的代码,可能会比较复杂(比如 Email 的展示方式),最好是将显示部分的代码逻辑拆分成独立的类。除此之外,因为代码中涉及线程操作,并且调用了 Aggregator 的静态函数,所以代码的可测试性不好。



沪ICP备19023445号-2号
友情链接