第二部分:實戰二
第二部分:實戰二
實戰二(上)
專案背景
- 文中舉例,設計開發一個小的框架,能夠獲取介面呼叫的各種統計資訊,並且支援將統計結果以各種顯示格式輸出到各種終端,以方便檢視。
需求分析
- 效能計數器作為一個跟業務無關的功能,我們完全可以把它開發成一個獨立的框架或者類庫,整合到很多業務系統中。
- 作為可被複用的框架,除了功能性需求之外,非功能性需求也非常重要。
功能性需求分析
- 介面統計資訊:包括介面響應時間的統計資訊,以及介面呼叫次數的統計資訊等。
- 統計資訊的型別:max、min、avg、percentile、count、tps 等。
- 統計資訊顯示格式:Json、Html、自定義顯示格式。
- 統計資訊顯示終端:Console、Email、HTTP 網頁、日誌、自定義顯示終端。
- 統計觸發方式:包括主動和被動兩種。
- 統計時間區間:框架需要支援自定義統計時間區間。
- 統計時間間隔:對於主動觸發統計,我們還要支援指定統計時間間隔,也就是多久觸發一次統計顯示。
非功能性需求分析
- 易用性:
- 框架是否易整合、易插拔、跟業務程式碼是否鬆耦合、提供的介面是否夠靈活等等,都是我們應該花心思去思考和設計的。
- 有的時候,文件寫得好壞甚至都有可能決定一個框架是否受歡迎。
- 效能:
- 對於需要整合到業務系統的框架來說,我們不希望框架本身的程式碼執行效率,對業務系統有太多效能上的影響。
- 擴充套件性:
- 在不修改或儘量少修改程式碼的情況下新增新的功能。
- 使用者可以在不修改框架原始碼,甚至不拿到框架原始碼的情況下,為框架擴充套件新的功能。這就有點類似給框架開發外掛。
- 比如,在不修改框架原始碼的情況下,以繼承框架中方法的方式來達到修改框架原始碼中方法的目的。
- 容錯性:
- 不能因為框架本身的異常導致介面請求出錯。
- 對外暴露的介面丟擲的所有執行時、非執行時異常都進行捕獲處理。
- 通用性:
- 為了提高框架的複用性,能夠靈活應用到各種場景中。框架在設計的時候,要儘可能通用。
框架設計
- 資料採集:
- 負責打點採集原始資料,包括記錄每次介面請求的響應時間和請求時間。
- 資料採集過程要高度容錯,不能影響到介面本身的可用性。
- 暴露給框架的使用者,也要儘量考慮其易用性。
- 儲存:
- 負責將採集的原始資料儲存下來,以便後面做聚合統計。
- 資料儲存比較耗時,為了儘量地減少對介面效能(比如響應時間)的影響,採集和儲存的過程非同步完成。
- 聚合統計:
- 負責將原始資料聚合為統計資料。
- 為了支援更多的聚合統計規則,程式碼希望儘可能靈活、可擴充套件。
- 顯示:
- 負責將統計資料以某種格式顯示到終端。
解決一個簡單應用場景的效能計數器:統計使用者註冊、登入這兩個介面的響應時間的最大值和平均值、介面呼叫次數,並且將統計結果以 JSON 的格式輸出到命令列中。
應用場景的程式碼,具體如下所示:
// 應用場景:統計下面兩個介面 (註冊和登入)的響應時間和訪問次數
public class UserController {
public void register(UserVo user) {
//...
}
public UserVo login(String telephone, String password) {
//...
}
}
最小原型實現如下所示:recordResponseTime() 和 recordTimestamp() 兩個函式分別用來記錄介面請求的響應時間和訪問時間,startRepeatedReport() 函式以指定的頻率統計資料並輸出結果
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import com.google.gson.Gson;
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) {
// 省略程式碼實現
return (Double)null;
}
private double avg(List<Double> dataset) {
// 省略程式碼實現
return (Double)null;
}
}
如何用它來統計註冊、登入介面的響應時間和訪問次數,具體的程式碼如下所示:
// 應用場景:統計下面兩個介面 (註冊和登入)的響應時間和訪問次數
import java.util.concurrent.TimeUnit;
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);
return (UserVo) null;
}
}
實戰二(下)
小步快跑、逐步迭代
- 資料採集:負責打點採集原始資料,包括記錄每次介面請求的響應時間。
- 儲存:負責將採集的原始資料儲存下來,以便之後做聚合統計。資料的儲存方式有很多種,我們暫時只支援 Redis 這一種儲存方式,並且,採集與儲存兩個過程同步執行。
- 聚合統計:負責將原始資料聚合為統計資料,包括響應時間的最大值、最小值、平均值、99.9 百分位值、99 百分位值,以及介面請求的次數和 tps。
- 顯示:負責將統計資料以某種格式顯示到終端,暫時只支援主動推送給命令列和郵件。命令列間隔 n 秒統計顯示上 m 秒的資料(比如,間隔 60s 統計上 60s 的資料)。郵件每日統計上日的資料。
面向物件設計與實現
劃分職責進而識別出有哪些類
- MetricsCollector 類負責提供 API,來採集介面請求的原始資料。我們可以為 MetricsCollector 抽象出一個介面,但這並不是必須的,因為暫時我們只能想到一個 MetricsCollector 的實現方式。
- MetricsStorage 介面負責原始資料儲存,RedisMetricsStorage 類實現 MetricsStorage 介面。這樣做是為了今後靈活地擴充套件新的儲存方法,比如用 HBase 來儲存。
- Aggregator 類負責根據原始資料計算統計資料。
- ConsoleReporter 類、EmailReporter 類分別負責以一定頻率統計併發送統計資料到命令列和郵件。至於ConsoleReporter 和 EmailReporter 是否可以抽象出可複用的抽象類,或者抽象出一個公共的介面,我們暫時還不能確定。
定義類及類與類之間的關係
- 接下來就是定義類及屬性和方法,定義類與類之間的關係。這兩步沒法分得很開。
- MetricsStorage 介面定義存取資料相關的屬性和方法。RedisMetricsStorage 類實現 MetricsStorage 介面,填充具體的方法和屬性。
- MetricsCollector 類在建構函式中,以依賴注入的方式引入 MetricsStorage 介面,並在類內部的方法中得以呼叫資料存取的方法。
- 統計顯示所要完成的功能邏輯細分位下面 4 點:
- 根據給定的時間區間,從資料庫中拉取資料
- 根據原始資料,計算得到統計資料
- 將統計資料顯示到終端(命令列或郵件)
- 定時觸發以上 3 個過程的執行
- 面向物件設計和實現要做的事情,就是把合適的程式碼放到合適的類中。讓程式碼儘量地滿足低耦合、高內聚、單一職責、對擴充套件開放對修改關閉等之前講到的各種設計原則和思想,儘量地讓設計滿足程式碼易複用、易讀、易擴充套件、易維護。
- 我們暫時選擇把第 1、3、4 邏輯放到 ConsoleReporter 或 EmailReporter 類中,把第 2 個邏輯放到 Aggregator 類中。
- Aggregator 類負責的邏輯比較簡單,我們把它設計成只包含靜態方法的工具類。靜態方法中有統計方式,比如加和、取最大最小等,使用RequestStat 類中的 set 方法賦值給 RequestStat 類定義的這些統計屬性。
- ConsoleReporter 類相當於一個上帝類,定時根據給定的時間區間,從資料庫中取出資料,藉助 Aggregator 類完成統計工作,並將統計結果輸出到命令列。也就是在 4 中定時觸發 1、2、3 程式碼的執行。
MetricsCollector 程式碼實現:
import org.apache.commons.lang3.StringUtils;
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);
}
}
RequestInfo 程式碼實現:
public class RequestInfo {
private String apiName;
private double responseTime;
private long timestamp;
public RequestInfo(String apiName, int responseTime, int timestamp) {
this.apiName = apiName;
this.responseTime = responseTime;
this.timestamp = timestamp;
}
//... 省略 constructor/getter/setter 方法...
public String getApiName() {
return apiName;
}
public void setApiName(String apiName) {
this.apiName = apiName;
}
public double getResponseTime() {
return responseTime;
}
public void setResponseTime(double responseTime) {
this.responseTime = responseTime;
}
public long getTimestamp() {
return timestamp;
}
public void setTimestamp(long timestamp) {
this.timestamp = timestamp;
}
}
MetricsStorage 程式碼實現:
import java.util.List;
import java.util.Map;
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);
}
RedisMetricsStorage 程式碼實現:
import java.util.List;
import java.util.Map;
public class RedisMetricsStorage implements MetricsStorage{
@Override
public void saveRequestInfo(RequestInfo requestInfo) {
}
@Override
public List<RequestInfo> getRequestInfos(String apiName, long startTimeInMillis, long endTimeInMillis) {
return null;
}
@Override
public Map<String, List<RequestInfo>> getRequestInfos(long startTimeInMillis, long endTimeInMillis) {
return null;
}
}
Aggregator 程式碼實現:
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
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;
}
}
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 方法...
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 程式碼實現:
import com.google.gson.Gson;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
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);
}
}
EmailReporter 程式碼實現:
import java.util.*;
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);
}
}
Demo 類實現:
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("[email protected]");
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();
}
}
}
將類組裝起來並提供執行入口
- 一個是 MetricsCollector 類,提供了一組 API 來採集原始資料。
- 另一個是 ConsoleReporter 類和 EmailReporter 類,用來觸發統計顯示。
Review 設計與實現
-
MetricsCollector 負責採集和儲存資料,職責相對來說還算比較單一。它基於介面而非實現程式設計,通過依賴注入的方式來傳遞 MetricsStorage 物件,可以在不需要修改程式碼的情況下,靈活地替換不同的儲存方式,滿足開閉原則。
-
RedisMetricsStorage 和 MetricsStorage 的設計比較簡單。當我們需要實現新的儲存方式的時候,只需要實現 MetricsStorage 介面即可。其他介面函式呼叫的地方都不需要改動,滿足開閉原則。
-
Aggregator 類是一個工具類,裡面只有一個靜態函式,有 50 行左右的程式碼量,負責各種統計資料的計算。一旦越來越多的統計功能新增進來之後,這個函式的程式碼量會持續增加,可讀性、可維護性就變差了。這個類的設計可能存在職責不夠單一、不易擴充套件等問題,需要在之後的版本中,對其結構做優化。
-
ConsoleReporter和EmailReporter中存在問題較多:
- 從資料庫中取資料、做統計的邏輯都是相同的,可以抽取出來複用,違反了 DRY 原則。
- 整個類負責的事情比較多,職責不單一。特別是顯示部分的程式碼,可能會比較複雜(比如 Email 的展示方式),最好是將顯示部分的程式碼邏輯拆分成獨立的類。
- 因為程式碼中涉及執行緒操作,並且呼叫了 Aggregator 的靜態函式,所以程式碼的可測試性不好。