一个支持各种统计规则的性能计数器项目,学习针对一个非业务的通用框架开发,如何来做需求分析、设计和实现
1 项目背景
希望设计开发一个小的框架,能够获取接口调用的各种统计信息,
比如,响应时间的最大值(max)、最小值(min)、平均值(avg)、百分位值(percentile)、接口调用次数(count)、频率(tps) 等,并且支持将统计结果以各种显示格式(比如:JSON 格式、网页格式、自定义显示格式等)输出到各种终端(Console 命令行、HTTP 网页、Email、日志文件、自定义输出终端等),以方便查看。
如果让你来负责开发这样一个通用的框架,应用到各种业务系统中,支持实时计算、查看数据的统计信息,你会如何设计和实现呢?你可以先自己主动思考一下,然后再来看我的分析思路。
2 需求分析
性能计数器作为一个跟业务无关的功能,我们完全可以把它开发成一个独立的框架或者类库,集成到很多业务系统中。
而作为可被复用的框架,除了功能性需求之外,非功能性需求也非常重要。
2.1 功能性需求分析
拆解之后我写在下面了,是不是看起来更加清晰、有条理?
- 接口统计信息:包括接口响应时间的统计信息,以及接口调用次数的统计信息等。
- 统计信息的类型:max、min、avg、percentile、count、tps 等。
- 统计信息显示格式:Json、Html、自定义显示格式。
- 统计信息显示终端:Console、Email、HTTP 网页、日志、自定义显示终端。
除此之外,我们还可以借助设计产品的时候,经常用到的线框图,把最终数据的显示样式画出来,会更加一目了然。具体的线框图如下所示:

实际上,从线框图中,我们还能挖掘出了下面几个隐藏的需求。
-
统计触发方式:包括主动和被动两种。主动表示以一定的频率定时统计数据,并主动推送到显示终端,比如邮件推送。被动表示用户触发统计,比如用户在网页中选择要统计的时间区间,触发统计,并将结果显示给用户。
-
统计时间区间:框架需要支持自定义统计时间区间,比如统计最近 10 分钟的某接口的 tps、访问次数,或者统计 12 月 11 日 00 点到 12 月 12 日 00 点之间某接口响应时间的最大值、最小值、平均值等。
-
统计时间间隔:对于主动触发统计,我们还要支持指定统计时间间隔,也就是多久触发一次统计显示。比如,每间隔 10s 统计一次接口信息并显示到命令行中,每间隔 24 小时发送一封统计信息邮件。
2.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) {
//...
}
}
- 容错性
容错性这一点也非常重要。
对于性能计数器框架来说,不能因为框架本身的异常导致接口请求出错。
我们要对框架可能存在的各种异常情况都考虑全面,对外暴露的接口抛出的所有运行时、非运行时异常都进行捕获处理。
- 通用性
为了提高框架的复用性,能够灵活应用到各种场景中。
框架在设计的时候,要尽可能通用。我们要多去思考一下,除了接口统计这样一个需求,还可以适用到其他哪些场景中,比如是否还可以处理其他事件的统计信息,比如 SQL 请求时间的统计信息、业务统计信息(比如支付成功率)等。
3 框架设计
对于稍微复杂系统的开发,很多人觉得不知从何开始。
我个人喜欢借鉴 TDD(测试驱动开发)和 Prototype(最小原型)的思想,先聚焦于一个简单的应用场景,基于此设计实现一个简单的原型。
尽管这个最小原型系统在功能和非功能特性上都不完善,但它能够看得见、摸得着,比较具体、不抽象,能够很有效地帮助我缕清更复杂的设计思路,是迭代设计的基础。这就好比做算法题目。当我们想要一下子就想出一个最优解
这就好比做算法题目。当我们想要一下子就想出一个最优解法时,可以先写几组测试数据,找找规律,再先想一个最简单的算法去解决它。
虽然这个最简单的算法在时间、空间复杂度上可能都不令人满意,但是我们可以基于此来做优化,这样思路就会更加顺畅。
对于性能计数器这个框架的开发来说,我们可以先聚焦于一个非常具体、简单的应用场景,比如统计用户注册、登录这两个接口的响应时间的最大值和平均值、接口调用次数,并且将统计结果以 JSON 的格式输出到命令行中。
现在这个需求简单、具体、明确,设计实现起来难度降低了很多。
- 应用场景的代码
//应用场景:统计下面两个接口(注册和登录)的响应时间和访问次数
public class UserController {
public void register(UserVo user) {
//...
}
public UserVo login(String telephone, String password) {
//...
}
}
- 最小原型的代码
要输出接口的响应时间的最大值、平均值和接口调用次数,我们首先要采集每次接口请求的响应时间,并且存储起来,然后按照某个时间间隔做聚合统计,最后才是将结果输出。
在原型系统的代码实现中,我们可以把所有代码都塞到一个类中,暂时不用考虑任何代码质量、线程安全、性能、扩展性等等问题,怎么简单怎么来就行。
最小原型的代码实现如下所示。其中,recordResponseTime() 和 recordTimestamp() 两个函数分别用来记录接口请求的响应时间和访问时间。
startRepeatedReport() 函数以指定的频率统计数据并输出结果。
public class Metrics {
// Map的key是接口名称,value对应接口请求的响应时间或时间戳;
private Map<String, List<Double>> responseTimes = new HashMap<>();
private Map<String, List<Double>> timestamps = new HashMap<>();
private ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
public void recordResponseTime(String apiName, double responseTime) {
responseTimes.putIfAbsent(apiName, new ArrayList<>());
responseTimes.get(apiName).add(responseTime);
}
public void recordTimestamp(String apiName, double timestamp) {
timestamps.putIfAbsent(apiName, new ArrayList<>());
timestamps.get(apiName).add(timestamp);
}
public void startRepeatedReport(long period, TimeUnit unit){
executor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
Gson gson = new Gson();
Map<String, Map<String, Double>> stats = new HashMap<>();
for (Map.Entry<String, List<Double>> entry : responseTimes.entrySet()) {
String apiName = entry.getKey();
List<Double> apiRespTimes = entry.getValue();
stats.putIfAbsent(apiName, new HashMap<>());
stats.get(apiName).put("max", max(apiRespTimes));
stats.get(apiName).put("avg", avg(apiRespTimes));
}
for (Map.Entry<String, List<Double>> entry : timestamps.entrySet()) {
String apiName = entry.getKey();
List<Double> apiTimestamps = entry.getValue();
stats.putIfAbsent(apiName, new HashMap<>());
stats.get(apiName).put("count", (double)apiTimestamps.size());
}
System.out.println(gson.toJson(stats));
}
}, 0, period, unit);
}
private double max(List<Double> dataset) {//省略代码实现}
private double avg(List<Double> dataset) {//省略代码实现}
}
- 具体的代码
我们通过不到 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 等。
为了支持更多的聚合统计规则,代码希望尽可能灵活、可扩展。 - 显示
负责将统计数据以某种格式显示到终端,比如:输出到命令行、邮件、网页、自定义显示终端等。
- 数据采集:
软件设计开发是一个迭代的过程,分析、设计和实现这三个阶段的界限划分并不明显。
网友评论