CategoryResourceRepost/极客时间专栏/设计模式之美/设计原则与思想:总结课/39 | 运用学过的设计原则和思想完善之前讲的性能计数器项目(上).md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

442 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="39 | 运用学过的设计原则和思想完善之前讲的性能计数器项目(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c5/5f/c5f5274e55ade88213e6c2b81815c75f.mp3"></audio>
在[第25节](https://time.geekbang.org/column/article/179644)、[第26节](https://time.geekbang.org/column/article/179673)中,我们讲了如何对一个性能计数器框架进行分析、设计与实现,并且实践了之前学过的一些设计原则和设计思想。当时我们提到,小步快跑、逐步迭代是一种非常实用的开发模式。所以,针对这个框架的开发,我们分多个版本来逐步完善。
在第25、26节课中我们实现了框架的第一个版本它只包含最基本的一些功能在设计与实现上还有很多不足。所以接下来我会针对这些不足继续迭代开发两个版本版本2和版本3分别对应第39节和第40节的内容。
在版本2中我们会利用之前学过的重构方法对版本1的设计与实现进行重构解决版本1存在的设计问题让它满足之前学过的设计原则、思想、编程规范。在版本3中我们再对版本2进行迭代并且完善框架的功能和非功能需求让其满足第25节课中罗列的所有需求。
话不多说让我们正式开始版本2的设计与实现吧
## 回顾版本1的设计与实现
首先让我们一块回顾一下版本1的设计与实现。当然如果时间充足你最好能再重新看一下第25、26节的内容。在版本1中整个框架的代码被划分为下面这几个类。
- MetricsCollector负责打点采集原始数据包括记录每次接口请求的响应时间和请求时间戳并调用MetricsStorage提供的接口来存储这些原始数据。
- MetricsStorage和RedisMetricsStorage负责原始数据的存储和读取。
- Aggregator是一个工具类负责各种统计数据的计算比如响应时间的最大值、最小值、平均值、百分位值、接口访问次数、tps。
- ConsoleReporter和EmailReporter相当于一个上帝类God Class定时根据给定的时间区间从数据库中取出数据借助Aggregator类完成统计工作并将统计结果输出到相应的终端比如命令行、邮件。
MetricCollector、MetricsStorage、RedisMetricsStorage的设计与实现比较简单不是版本2重构的重点。今天我们重点来看一下Aggregator和ConsoleReporter、EmailReporter这几个类。
**我们先来看一下Aggregator类存在的问题。**
Aggregator类里面只有一个静态函数有50行左右的代码量负责各种统计数据的计算。当要添加新的统计功能的时候我们需要修改aggregate()函数代码。一旦越来越多的统计功能添加进来之后这个函数的代码量会持续增加可读性、可维护性就变差了。因此我们需要在版本2中对其进行重构。
```
public class Aggregator {
public static RequestStat aggregate(List&lt;RequestInfo&gt; 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 &lt; respTime) {
maxRespTime = respTime;
}
if (minRespTime &gt; respTime) {
minRespTime = respTime;
}
sumRespTime += respTime;
}
if (count != 0) {
avgRespTime = sumRespTime / count;
}
long tps = (long)(count / durationInMillis * 1000);
Collections.sort(requestInfos, new Comparator&lt;RequestInfo&gt;() {
@Override
public int compare(RequestInfo o1, RequestInfo o2) {
double diff = o1.getResponseTime() - o2.getResponseTime();
if (diff &lt; 0.0) {
return -1;
} else if (diff &gt; 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;
//...省略getter/setter方法...
}
```
**我们再来看一下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&lt;String, List&lt;RequestInfo&gt;&gt; requestInfos =
metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
Map&lt;String, RequestStat&gt; stats = new HashMap&lt;&gt;();
for (Map.Entry&lt;String, List&lt;RequestInfo&gt;&gt; entry : requestInfos.entrySet()) {
String apiName = entry.getKey();
List&lt;RequestInfo&gt; requestInfosPerApi = entry.getValue();
RequestStat requestStat = Aggregator.aggregate(requestInfosPerApi, durationInMillis);
stats.put(apiName, requestStat);
}
System.out.println(&quot;Time Span: [&quot; + startTimeInMillis + &quot;, &quot; + endTimeInMillis + &quot;]&quot;);
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&lt;String&gt; toAddresses = new ArrayList&lt;&gt;();
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&lt;String, List&lt;RequestInfo&gt;&gt; requestInfos =
metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
Map&lt;String, RequestStat&gt; stats = new HashMap&lt;&gt;();
for (Map.Entry&lt;String, List&lt;RequestInfo&gt;&gt; entry : requestInfos.entrySet()) {
String apiName = entry.getKey();
List&lt;RequestInfo&gt; 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类主要负责统计显示的工作。在第26节中我们提到如果我们把统计显示所要完成的功能逻辑细分一下主要包含下面4点
1. 根据给定的时间区间,从数据库中拉取数据;
1. 根据原始数据,计算得到统计数据;
1. 将统计数据显示到终端(命令行或邮件);
1. 定时触发以上三个过程的执行。
之前的划分方法是将所有的逻辑都放到ConsoleReporter和EmailReporter这两个上帝类中而Aggregator只是一个包含静态方法的工具类。这样的划分方法存在前面提到的一些问题我们需要对其进行重新划分。
面向对象设计中的最后一步是组装类并提供执行入口所以组装前三部分逻辑的上帝类是必须要有的。我们可以将上帝类做的很轻量级把核心逻辑都剥离出去形成独立的类上帝类只负责组装类和串联执行流程。这样做的好处是代码结构更加清晰底层核心逻辑更容易被复用。按照这个设计思路具体的重构工作包含以下4个方面。
- 第1个逻辑根据给定时间区间从数据库中拉取数据。这部分逻辑已经被封装在MetricsStorage类中了所以这部分不需要处理。
- 第2个逻辑根据原始数据计算得到统计数据。我们可以将这部分逻辑移动到Aggregator类中。这样Aggregator类就不仅仅是只包含统计方法的工具类了。按照这个思路重构之后的代码如下所示
```
public class Aggregator {
public Map&lt;String, RequestStat&gt; aggregate(
Map&lt;String, List&lt;RequestInfo&gt;&gt; requestInfos, long durationInMillis) {
Map&lt;String, RequestStat&gt; requestStats = new HashMap&lt;&gt;();
for (Map.Entry&lt;String, List&lt;RequestInfo&gt;&gt; entry : requestInfos.entrySet()) {
String apiName = entry.getKey();
List&lt;RequestInfo&gt; requestInfosPerApi = entry.getValue();
RequestStat requestStat = doAggregate(requestInfosPerApi, durationInMillis);
requestStats.put(apiName, requestStat);
}
return requestStats;
}
private RequestStat doAggregate(List&lt;RequestInfo&gt; requestInfos, long durationInMillis) {
List&lt;Double&gt; respTimes = new ArrayList&lt;&gt;();
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&lt;Double&gt; dataset) {}
private double min(List&lt;Double&gt; dataset) {}
private double avg(List&lt;Double&gt; dataset) {}
private double tps(int count, double duration) {}
private double percentile999(List&lt;Double&gt; dataset) {}
private double percentile99(List&lt;Double&gt; dataset) {}
private double percentile(List&lt;Double&gt; dataset, double ratio) {}
}
```
- 第3个逻辑将统计数据显示到终端。我们将这部分逻辑剥离出来设计成两个类ConsoleViewer类和EmailViewer类分别负责将统计结果显示到命令行和邮件中。具体的代码实现如下所示
```
public interface StatViewer {
void output(Map&lt;String, RequestStat&gt; requestStats, long startTimeInMillis, long endTimeInMills);
}
public class ConsoleViewer implements StatViewer {
public void output(
Map&lt;String, RequestStat&gt; requestStats, long startTimeInMillis, long endTimeInMills) {
System.out.println(&quot;Time Span: [&quot; + startTimeInMillis + &quot;, &quot; + endTimeInMills + &quot;]&quot;);
Gson gson = new Gson();
System.out.println(gson.toJson(requestStats));
}
}
public class EmailViewer implements StatViewer {
private EmailSender emailSender;
private List&lt;String&gt; toAddresses = new ArrayList&lt;&gt;();
public EmailViewer() {
this.emailSender = new EmailSender(/*省略参数*/);
}
public EmailViewer(EmailSender emailSender) {
this.emailSender = emailSender;
}
public void addToAddress(String address) {
toAddresses.add(address);
}
public void output(
Map&lt;String, RequestStat&gt; 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&lt;String, List&lt;RequestInfo&gt;&gt; requestInfos =
metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
Map&lt;String, RequestStat&gt; 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&lt;String, List&lt;RequestInfo&gt;&gt; requestInfos =
metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
Map&lt;String, RequestStat&gt; 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(&quot;wangzheng@xzg.com&quot;);
EmailReporter emailReporter = new EmailReporter(storage, aggregator, emailViewer);
emailReporter.startDailyReport();
// 收集接口访问数据
MetricsCollector collector = new MetricsCollector(storage);
collector.recordRequest(new RequestInfo(&quot;register&quot;, 123, 10234));
collector.recordRequest(new RequestInfo(&quot;register&quot;, 223, 11234));
collector.recordRequest(new RequestInfo(&quot;register&quot;, 323, 12334));
collector.recordRequest(new RequestInfo(&quot;login&quot;, 23, 12434));
collector.recordRequest(new RequestInfo(&quot;login&quot;, 1223, 14234));
try {
Thread.sleep(100000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
```
## Review版本2的设计与实现
现在我们Review一下针对版本1重构之后版本2的设计与实现。
重构之后MetricsStorage负责存储Aggregator负责统计StatViewerConsoleViewer、EmailViewer负责显示三个类各司其职。ConsoleReporter和EmailReporter负责组装这三个类将获取原始数据、聚合统计、显示统计结果到终端这三个阶段的工作串联起来定时触发执行。
除此之外MetricsStorage、Aggregator、StatViewer三个类的设计也符合迪米特法则。它们只与跟自己有直接相关的数据进行交互。MetricsStorage输出的是RequestInfo相关数据。Aggregator类输入的是RequestInfo数据输出的是RequestStat数据。StatViewer输入的是RequestStat数据。
针对版本1和版本2我画了一张它们的类之间依赖关系的对比图如下所示。从图中我们可以看出重构之后的代码结构更加清晰、有条理。这也印证了之前提到的面向对象设计和实现要做的事情就是把合适的代码放到合适的类中。
<img src="https://static001.geekbang.org/resource/image/13/34/1303d16f75c7266cef9105f540c54834.jpg" alt="">
刚刚我们分析了代码的整体结构和依赖关系,我们现在再来具体看每个类的设计。
Aggregator类从一个只包含一个静态函数的工具类变成了一个普通的聚合统计类。现在我们可以通过依赖注入的方式将其组装进ConsoleReporter和EmailReporter类中这样就更加容易编写单元测试。
Aggregator类在重构前所有的逻辑都集中在aggregate()函数内代码行数较多代码的可读性和可维护性较差。在重构之后我们将每个统计逻辑拆分成独立的函数aggregate()函数变得比较单薄可读性提高了。尽管我们要添加新的统计功能还是要修改aggregate()函数但现在的aggregate()函数代码行数很少,结构非常清晰,修改起来更加容易,可维护性提高。
目前来看Aggregator的设计还算合理。但是如果随着更多的统计功能的加入Aggregator类的代码会越来越多。这个时候我们可以将统计函数剥离出来设计成独立的类以解决Aggregator类的无限膨胀问题。不过暂时来说没有必要这么做毕竟将每个统计函数独立成类会增加类的个数也会影响到代码的可读性和可维护性。
ConsoleReporter和EmailReporter经过重构之后代码的重复问题变小了但仍然没有完全解决。尽管这两个类不再调用Aggregator的静态方法但因为涉及多线程和时间相关的计算代码的测试性仍然不够好。这两个问题我们留在下一节课中解决你也可以留言说说的你解决方案。
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要掌握的重点内容。
面向对象设计中的最后一步是组装类并提供执行入口,也就是上帝类要做的事情。这个上帝类是没办法去掉的,但我们可以将上帝类做得很轻量级,把核心逻辑都剥离出去,下沉形成独立的类。上帝类只负责组装类和串联执行流程。这样做的好处是,代码结构更加清晰,底层核心逻辑更容易被复用。
面向对象设计和实现要做的事情,就是把合适的代码放到合适的类中。当我们要实现某个功能的时候,不管如何设计,所需要编写的代码量基本上是一样的,唯一的区别就是如何将这些代码划分到不同的类中。不同的人有不同的划分方法,对应得到的代码结构(比如类与类之间交互等)也不尽相同。
好的设计一定是结构清晰、有条理、逻辑性强,看起来一目了然,读完之后常常有一种原来如此的感觉。差的设计往往逻辑、代码乱塞一通,没有什么设计思路可言,看起来莫名其妙,读完之后一头雾水。
## 课堂讨论
1. 今天我们提到重构之后的ConsoleReporter和EmailReporter仍然存在代码重复和可测试性差的问题你可以思考一下应该如何解决呢
1. 从上面的使用示例中我们可以看出框架易用性有待提高ConsoleReporter和EmailReporter的创建过程比较复杂使用者需要正确地组装各种类才行。对于框架的易用性你有没有什么办法改善一下呢
欢迎在留言区写下你的思考和想法,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。