zoukankan      html  css  js  c++  java
  • 设计模式——运用设计原则和思想完善性能计数器项目

    之前我们讲了如何对一个性能计数器框架进行分析、设计与实现,并且实践了之前学过的一些设计原则和设计思想。当时我们提到,小步快跑、逐步迭代是一种非常实用的开发模式。所以,针对这个框架的开发,我们分多个版本来逐步完善。

    我们实现了框架的第一个版本,它只包含最基本的一些功能,在设计与实现上还有很多不足。所以,接下来,我会针对这些不足,继续迭代开发两个版本:版本 2 和版本 3。

    在版本 2 中,我们会利用之前学过的重构方法,对版本 1 的设计与实现进行重构,解决版本 1 存在的设计问题,让它满足之前学过的设计原则、思想、编程规范。

    在版本 3 中,我们再对版本 2 进行迭代,并且完善框架的功能和非功能需求,让其满足第 25 节课中罗列的所有需求。

    回顾版本 1 的设计与实现

    首先,让我们一块回顾一下版本 1 的设计与实现。在版本 1 中,整个框架的代码被划分为下面这几个类。

    • MetricsCollector:负责打点采集原始数据,包括记录每次接口请求的响应时间和请求时间戳,并调用 MetricsStorage 提供的接口来存储这些原始数据。

    • MetricsStorage 和 RedisMetricsStorage:负责原始数据的存储和读取。

    • Aggregator:是一个工具类,负责各种统计数据的计算,比如响应时间的最大值、最小值、平均值、百分位值、接口访问次数、tps。

    • ConsoleReporter 和 EmailReporter:相当于一个上帝类(God Class),定时根据给定的时间区间,从数据库中取出数据,借助 Aggregator 类完成统计工作,并将统计结果输出到相应的终端,比如命令行、邮件。

    下面我们重点来看一下 Aggregator 和 ConsoleReporter、EmailReporter 这几个类。

    我们先来看一下 Aggregator 类存在的问题。

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

    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 = 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;
    
    
        public double getMaxResponseTime() {
            return maxResponseTime;
        }
    
        public void setMaxResponseTime(double maxResponseTime) {
            this.maxResponseTime = maxResponseTime;
        }
    
        public double getMinResponseTime() {
            return minResponseTime;
        }
    
        public void setMinResponseTime(double minResponseTime) {
            this.minResponseTime = minResponseTime;
        }
    
        public double getAvgResponseTime() {
            return avgResponseTime;
        }
    
        public void setAvgResponseTime(double avgResponseTime) {
            this.avgResponseTime = avgResponseTime;
        }
    
        public double getP999ResponseTime() {
            return p999ResponseTime;
        }
    
        public void setP999ResponseTime(double p999ResponseTime) {
            this.p999ResponseTime = p999ResponseTime;
        }
    
        public double getP99ResponseTime() {
            return p99ResponseTime;
        }
    
        public void setP99ResponseTime(double p99ResponseTime) {
            this.p99ResponseTime = p99ResponseTime;
        }
    
        public long getCount() {
            return count;
        }
    
        public void setCount(long count) {
            this.count = count;
        }
    
        public long getTps() {
            return tps;
        }
    
        public void setTps(long tps) {
            this.tps = tps;
        }
    
    }
    

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

    针对版本 1 的问题进行重构

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

    • 根据给定的时间区间,从数据库中拉取数据;

    • 根据原始数据,计算得到统计数据;

    • 将统计数据显示到终端(命令行或邮件);

    • 定时触发以上三个过程的执行。

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

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

    • 第 1 个逻辑:根据给定时间区间,从数据库中拉取数据。这部分逻辑已经被封装在 MetricsStorage 类中了,所以这部分不需要处理。

    • 第 2 个逻辑:根据原始数据,计算得到统计数据。我们可以将这部分逻辑移动到 Aggregator 类中。这样 Aggregator 类就不仅仅是只包含统计方法的工具类了。按照这个思路,重构之后的代码如下所示:

    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) {}
    
    }
    
    • 第 3 个逻辑:将统计数据显示到终端。我们将这部分逻辑剥离出来,设计成两个类:ConsoleViewer 类和 EmailViewer 类,分别负责将统计结果显示到命令行和邮件中。具体的代码实现如下所示:
    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);
    
        }
    
        @Override
    
        public void output(Map<String, RequestStat> requestStats, long startTimeInMillis, long endTimeInMills) {
    
            // format the requestStats to HTML style.
    
            // send it to email toAddresses.
        }
    }
    
    • 第 4 个逻辑:组装类并定时触发执行统计显示。在将核心逻辑剥离出来之后,这个类的代码变得更加简洁、清晰,只负责组装各个类(MetricsStorage、Aggegrator、StatViewer)来完成整个工作流程。重构之后的代码如下所示:
    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();
    
            }
    
        }
    
    }
    

    Review 版本 2 的设计与实现

    现在,我们 Review 一下,针对版本 1 重构之后,版本 2 的设计与实现。

    重构之后,

    MetricsStorage 负责存储,

    Aggregator 负责统计,

    StatViewerConsoleViewerEmailViewer)负责显示,

    三个类各司其职。

    ConsoleReporterEmailReporter 负责组装这三个类,将获取原始数据、聚合统计、显示统计结果到终端这三个阶段的工作串联起来,定时触发执行。

    除此之外,MetricsStorageAggregatorStatViewer 三个类的设计也符合迪米特法则。它们只与跟自己有直接相关的数据进行交互。MetricsStorage 输出的是 RequestInfo 相关数据。Aggregator 类输入的是 RequestInfo 数据,输出的是 RequestStat 数据。StatViewer 输入的是 RequestStat 数据。

    针对版本 1 和版本 2,画了一张它们的类之间依赖关系的对比图,如下所示。从图中,我们可以看出,重构之后的代码结构更加清晰、有条理。这也印证了之前提到的:面向对象设计和实现要做的事情,就是把合适的代码放到合适的类中。

    具体看每个类的设计

    Aggregator 类从一个只包含一个静态函数的工具类,变成了一个普通的聚合统计类。现在,我们可以通过依赖注入的方式,将其组装进 ConsoleReporterEmailReporter 类中,这样就更加容易编写单元测试。

    Aggregator 类在重构前,所有的逻辑都集中在 aggregate() 函数内,代码行数较多,代码的可读性和可维护性较差。在重构之后,我们将每个统计逻辑拆分成独立的函数,aggregate() 函数变得比较单薄,可读性提高了。尽管我们要添加新的统计功能,还是要修改 aggregate() 函数,但现在的 aggregate()函数代码行数很少,结构非常清晰,修改起来更加容易,可维护性提高。

    目前来看,Aggregator 的设计还算合理。但是,如果随着更多的统计功能的加入,Aggregator 类的代码会越来越多。这个时候,我们可以将统计函数剥离出来,设计成独立的类,以解决 Aggregator 类的无限膨胀问题。不过,暂时来说没有必要这么做,毕竟将每个统计函数独立成类,会增加类的个数,也会影响到代码的可读性和可维护性。

    ConsoleReporterEmailReporter 经过重构之后,代码的重复问题变小了,但仍然没有完全解决。尽管这两个类不再调用 Aggregator 的静态方法,但因为涉及多线程和时间相关的计算,代码的测试性仍然不够好。

    版本 3 的设计与实现

    代码重构优化

    我们知道,继承能解决代码重复的问题。我们可以将 ConsoleReporterEmailReporter 中的相同代码逻辑,提取到父类 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);
    
        }
    
    }
    

    ConsoleReporterEmailReporter 代码重复的问题解决了,那我们再来看一下代码的可测试性问题。因为 ConsoleReporterEmailReporter 的代码比较相似,且 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);
    
         Date firstTime = calendar.getTime();
    
    	 return calendar.getTime();
         
     }
    
    }
    

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

    public class EmailReporter extends ScheduledReporter {
        
        // 省略其他代码...
        
        public void startDailyReport() {
            
     		// new Date()可以获取当前时间     
                                                                                            Date firstTime = trimTimeFieldsToZeroOfNextDay();
    
           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);
    
         Date firstTime = calendar.getTime();
    
    	 return calendar.getTime();
         
     	}
    
    }
    

    经过这次重构之后,trimTimeFieldsToZeroOfNextDay() 函数不再强依赖当前的系统时间,所以非常容易对其编写单元测试。你可以把它作为练习,写一下这个函数的单元测试。

    不过,EmailReporter 类中 startDailyReport()还是涉及多线程,针对这个函数该如何写单元测试呢?我的看法是,这个函数不需要写单元测试。为什么这么说呢?我们可以回到写单元测试的初衷来分析这个问题。单元测试是为了提高代码质量,减少 bug。如果代码足够简单,简单到 bug 无处隐藏,那我们就没必要为了写单元测试而写单元测试,或者为了追求单元测试覆盖率而写单元测试。经过多次代码重构之后,startDailyReport() 函数里面已经没有多少代码逻辑了,所以,完全没必要对它写单元测试了。

    功能需求完善

    经过了多个版本的迭代、重构,我们现在来重新 Review 一下,目前的设计与实现是否已经完全满足第 25 讲中最初的功能需求了。

    最初的功能需求描述是下面这个样子的,我们来重新看一下。

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

    经过整理拆解之后的需求列表如下所示:

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

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

    统计信息显示格式:JSON、HTML、自定义显示格式。

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

    经过挖掘,我们还得到一些隐藏的需求,如下所示:

    统计触发方式:包括主动和被动两种。主动表示以一定的频率定时统计数据,并主动推送到显示终端,比如邮件推送。被动表示用户触发统计,比如用户在网页中选择要统计的时间区间,触发统计,并将结果显示给用户。

    统计时间区间:框架需要支持自定义统计时间区间,比如统计最近 10 分钟的某接口的 tps、访问次数,或者统计 12 月 11 日 00 点到 12 月 12 日 00 点之间某接口响应时间的最大值、最小值、平均值等。

    统计时间间隔:对于主动触发统计,我们还要支持指定统计时间间隔,也就是多久触发一次统计显示。比如,每间隔 10s 统计一次接口信息并显示到命令行中,每间隔 24 小时发送一封统计信息邮件。

    版本 3 已经实现了大部分的功能,还有以下几个小的功能点没有实现。你可以将这些还没有实现的功能,自己实现一下,继续迭代出框架的第 4 个版本。

    被动触发统计的方式,也就是需求中提到的通过网页展示统计信息。实际上,这部分代码的实现也并不难。我们可以复用框架现在的代码,编写一些展示页面和提供获取统计数据的接口即可。

    对于自定义显示终端,比如显示数据到自己开发的监控平台,这就有点类似通过网页来显示数据,不过更加简单些,只需要提供一些获取统计数据的接口,监控平台通过这些接口拉取数据来显示即可。

    自定义显示格式。在框架现在的代码实现中,显示格式和显示终端(比如 Console、Email)是紧密耦合在一起的,比如,Console 只能通过 JSON 格式来显示统计数据,Email 只能通过某种固定的 HTML 格式显示数据,这样的设计还不够灵活。我们可以将显示格式设计成独立的类,将显示终端和显示格式的代码分离,让显示终端支持配置不同的显示格式。具体的代码实现留给你自己思考,我这里就不多说了。

    非功能需求完善

    Review 完了功能需求的完善程度,现在,我们再来看,版本 3 的非功能性需求的完善程度。之前我们提到,针对这个框架的开发,我们需要考虑的非功能性需求包括:易用性、性能、扩展性、容错性、通用性。我们现在就依次来看一下这几个方面。

    易用性

    所谓的易用性,顾名思义,就是框架是否好用。框架的使用者将框架集成到自己的系统中时,主要用到 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 实现类到 ConsoleReporterEmailReporter),也不降低代码的可测试性(通过依赖注入来组装类,方便在单元测试中 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);
    
        	MetricsCollector collector = new MetricsCollector();
        	
        	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();
    
            }
            
        }
        
    }
    

    如果你足够细心,可能已经发现,RedisMeticsStorageEmailViewer 还需要另外一些配置信息才能构建成功,比如 Redis 的地址,Email 邮箱的 POP3 服务器地址、发送地址。这些配置并没有在刚刚代码中体现到,那我们该如何获取呢?

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

    性能

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

    对于性能这一点,落实到具体的代码层面,需要解决两个问题,也是我们之前提到过的,一个是采集和存储要异步来执行,因为存储基于外部存储(比如 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 之外的统计信息的计算代码,如下所示。对于 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()) {
    
                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;
    
        }
    
    }
    

    扩展性

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

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

    容错性

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

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

    通用性

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

  • 相关阅读:
    S3C2440的LCD虚拟显示测试
    arm-linux-gcc编译器测试
    韦东山教程ARM的时钟设置出现的问题及其解决方法
    程序在nor flash中真的可以运行吗?
    存储器的速度
    程序测试的方法
    对编程的一些思考

    [算法题] 字节流解析
    [C/C++]函数指针和函数分发表
  • 原文地址:https://www.cnblogs.com/wwj99/p/12822875.html
Copyright © 2011-2022 走看看