一、项目背景:
我们希望设计开发一个小的框架,能够获取接口调用的各种统计信息,比如,响应时间的最大值(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 小时发送一封统计信息邮件。
三、框架设计:
对于性能计数器这个框架的开发来说,我们可以先聚焦于一个非常具体、简单的应用场景,比如统计用户注册、登录这两个接口的响应时间的最大值和平均值、接口调用次数,并且将统计结果以 JSON 的格式输出到命令行中。现在这个需求简单、具体、明确,设计实现起来难度降低了很多。
//应用场景:统计下面两个接口(注册和登录)的响应时间和访问次数
public class UserController {public void register(UserVo user) {//...}public UserVo login(String telephone, String password) {//...}
}
要输出接口的响应时间的最大值、平均值和接口调用次数,我们首先要采集每次接口请求的响应时间,并且存储起来,然后按照某个时间间隔做聚合统计,最后才是将结果输出。在原型系统的代码实现中,我们可以把所有代码都塞到一个类中,暂时不用考虑任何代码质量、线程安全、性能、扩展性等等问题,怎么简单怎么来就行。
最小原型的代码实现如下所示。其中,recordResponseTime() 和 recordTimestamp() 两个函数分别用来记录接口请求的响应时间和访问时间。startRepeatedReport() 函数以指定的频率统计数据并输出结果。
public class Metrics {// Map的key是接口名称,value对应接口请求的响应时间或时间戳;private Map<String, List<Double>> responseTimes = new HashMap<>();private Map<String, List<Double>> timestamps = new HashMap<>();private ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();public void recordResponseTime(String apiName, double responseTime) {responseTimes.putIfAbsent(apiName, new ArrayList<>());responseTimes.get(apiName).add(responseTime);}public void recordTimestamp(String apiName, double timestamp) {timestamps.putIfAbsent(apiName, new ArrayList<>());timestamps.get(apiName).add(timestamp);}public void startRepeatedReport(long period, TimeUnit unit){executor.scheduleAtFixedRate(new Runnable() {@Overridepublic 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) {//省略代码实现}
}
我们通过不到 50 行代码就实现了最小原型。接下来,我们再来看,如何用它来统计注册、登录接口的响应时间和访问次数。具体的代码如下所示:
//应用场景:统计下面两个接口(注册和登录)的响应时间和访问次数
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);}
}
最小原型的代码实现虽然简陋,但它却帮我们将思路理顺了很多,我们现在就基于它做最终的框架设计。下面是我针对性能计数器框架画的一个粗略的系统设计图。图可以非常直观地体现设计思想,并且能有效地帮助我们释放更多的脑空间,来思考其他细节问题。

如图所示,我们把整个框架分为四个模块:数据采集、存储、聚合统计、显示。每个模块负责的工作简单罗列如下。
数据采集:负责打点采集原始数据,包括记录每次接口请求的响应时间和请求时间。数据采集过程要高度容错,不能影响到接口本身的可用性。除此之外,因为这部分功能是暴露给框架的使用者的,所以在设计数据采集 API 的时候,我们也要尽量考虑其易用性。
存储:负责将采集的原始数据保存下来,以便后面做聚合统计。数据的存储方式有多种,比如:Redis、MySQL、HBase、日志、文件、内存等。数据存储比较耗时,为了尽量地减少对接口性能(比如响应时间)的影响,采集和存储的过程异步完成。
聚合统计:负责将原始数据聚合为统计数据,比如:max、min、avg、pencentile、count、tps 等。为了支持更多的聚合统计规则,代码希望尽可能灵活、可扩展。
显示:负责将统计数据以某种格式显示到终端,比如:输出到命令行、邮件、网页、自定义显示终端等。
四、面向对象设计与实现
1. 划分职责进而识别出有哪些类
MetricsCollector 类负责提供 API,来采集接口请求的原始数据。我们可以为 MetricsCollector 抽象出一个接口,但这并不是必须的,因为暂时我们只能想到一个 MetricsCollector 的实现方式。
MetricsStorage 接口负责原始数据存储,RedisMetricsStorage 类实现 MetricsStorage 接口。这样做是为了今后灵活地扩展新的存储方法,比如用 HBase 来存储。
Aggregator 类负责根据原始数据计算统计数据。
ConsoleReporter 类、EmailReporter 类分别负责以一定频率统计并发送统计数据到命令行和邮件。至于 ConsoleReporter 和 EmailReporter 是否可以抽象出可复用的抽象类,或者抽象出一个公共的接口,我们暂时还不能确定。
2. 定义类及类与类之间的关系
MetricsCollector 类的定义非常简单,具体代码如下所示。对比上一节课中最小原型的代码,MetricsCollector 通过引入 RequestInfo 类来封装原始数据信息,用一个采集函数代替了之前的两个函数。
public class MetricsCollector {private MetricsStorage metricsStorage;//基于接口而非实现编程//依赖注入public MetricsCollector(MetricsStorage metricsStorage) {this.metricsStorage = metricsStorage;}//用一个函数代替了最小原型中的两个函数public void recordRequest(RequestInfo requestInfo) {if (requestInfo == null || StringUtils.isBlank(requestInfo.getApiName())) {return;}metricsStorage.saveRequestInfo(requestInfo);}
}public class RequestInfo {private String apiName;private double responseTime;private long timestamp;//...省略constructor/getter/setter方法...
}
MetricsStorage 类和 RedisMetricsStorage 类的属性和方法也比较明确
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 {//...省略属性和构造函数等...@Overridepublic void saveRequestInfo(RequestInfo requestInfo) {//...}@Overridepublic List<RequestInfo> getRequestInfos(String apiName, long startTimestamp, long endTimestamp) {//...}@Overridepublic Map<String, List<RequestInfo>> getRequestInfos(long startTimestamp, long endTimestamp) {//...}
}
MetricsCollector 类和 MetricsStorage 类的设计思路比较简单,不同的人给出的设计结果应该大差不差。但是,统计和显示这两个功能就不一样了,可以有多种设计思路。实际上,如果我们把统计显示所要完成的功能逻辑细分一下的话,主要包含下面 4 点:
1、根据给定的时间区间,从数据库中拉取数据;
2、根据原始数据,计算得到统计数据;
3、将统计数据显示到终端(命令行或邮件);
4、定时触发以上 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>() {@Overridepublic int compare(RequestInfo o1, RequestInfo o2) {double diff = o1.getResponseTime() - o2.getResponseTime();if (diff < 0.0) {return -1;} else if (diff > 0.0) {return 1;} else {return 0;}}});int idx999 = (int)(count * 0.999);int idx99 = (int)(count * 0.99);if (count != 0) {p999RespTime = requestInfos.get(idx999).getResponseTime();p99RespTime = requestInfos.get(idx99).getResponseTime();}RequestStat requestStat = new RequestStat();requestStat.setMaxResponseTime(maxRespTime);requestStat.setMinResponseTime(minRespTime);requestStat.setAvgResponseTime(avgRespTime);requestStat.setP999ResponseTime(p999RespTime);requestStat.setP99ResponseTime(p99RespTime);requestStat.setCount(count);requestStat.setTps(tps);return requestStat;}
}public class RequestStat {private double maxResponseTime;private double minResponseTime;private double avgResponseTime;private double p999ResponseTime;private double p99ResponseTime;private long count;private long tps;//...省略getter/setter方法...
}
ConsoleReporter 类相当于一个上帝类,定时根据给定的时间区间,从数据库中取出数据,借助 Aggregator 类完成统计工作,并将统计结果输出到命令行。具体的代码实现如下所示:
public class ConsoleReporter {private MetricsStorage metricsStorage;private ScheduledExecutorService executor;public ConsoleReporter(MetricsStorage metricsStorage) {this.metricsStorage = metricsStorage;this.executor = Executors.newSingleThreadScheduledExecutor();}// 第4个代码逻辑:定时触发第1、2、3代码逻辑的执行;public void startRepeatedReport(long periodInSeconds, long durationInSeconds) {executor.scheduleAtFixedRate(new Runnable() {@Overridepublic 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() {@Overridepublic void run() {long durationInMillis = DAY_HOURS_IN_SECONDS * 1000;long endTimeInMillis = System.currentTimeMillis();long startTimeInMillis = endTimeInMillis - durationInMillis;Map<String, List<RequestInfo>> requestInfos =metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);Map<String, RequestStat> stats = new HashMap<>();for (Map.Entry<String, List<RequestInfo>> entry : requestInfos.entrySet()) {String apiName = entry.getKey();List<RequestInfo> requestInfosPerApi = entry.getValue();RequestStat requestStat = Aggregator.aggregate(requestInfosPerApi, durationInMillis);stats.put(apiName, requestStat);}// TODO: 格式化为html格式,并且发送邮件}}, firstTime, DAY_HOURS_IN_SECONDS * 1000);}
}
3. 将类组装起来并提供执行入口
因为这个框架稍微有些特殊,有两个执行入口:一个是 MetricsCollector 类,提供了一组 API 来采集原始数据;另一个是 ConsoleReporter 类和 EmailReporter 类,用来触发统计显示。框架具体的使用方式如下所示:
public class Demo {public static void main(String[] args) {MetricsStorage storage = new RedisMetricsStorage();ConsoleReporter consoleReporter = new ConsoleReporter(storage);consoleReporter.startRepeatedReport(60, 60);EmailReporter emailReporter = new EmailReporter(storage);emailReporter.addToAddress("wangzheng@xzg.com");emailReporter.startDailyReport();MetricsCollector collector = new MetricsCollector(storage);collector.recordRequest(new RequestInfo("register", 123, 10234));collector.recordRequest(new RequestInfo("register", 223, 11234));collector.recordRequest(new RequestInfo("register", 323, 12334));collector.recordRequest(new RequestInfo("login", 23, 12434));collector.recordRequest(new RequestInfo("login", 1223, 14234));try {Thread.sleep(100000);} catch (InterruptedException e) {e.printStackTrace();}}
}
五、Review 设计与实现
1、MetricsCollector
MetricsCollector 负责采集和存储数据,职责相对来说还算比较单一。它基于接口而非实现编程,通过依赖注入的方式来传递 MetricsStorage 对象,可以在不需要修改代码的情况下,灵活地替换不同的存储方式,满足开闭原则。
2、MetricsStorage、RedisMetricsStorage
MetricsStorage 和 RedisMetricsStorage 的设计比较简单。当我们需要实现新的存储方式的时候,只需要实现 MetricsStorage 接口即可。因为所有用到 MetricsStorage 和 RedisMetricsStorage 的地方,都是基于相同的接口函数来编程的,所以,除了在组装类的地方有所改动(从 RedisMetricsStorage 改为新的存储实现类),其他接口函数调用的地方都不需要改动,满足开闭原则。
3、Aggregator
Aggregator 类是一个工具类,里面只有一个静态函数,有 50 行左右的代码量,负责各种统计数据的计算。
4、ConsoleReporter、EmailReporter