設計模式之美 - 26 | 實戰二(下):如何實現一個支持各種統計規則的性能計數器?

這系列相關博客,參考 設計模式之美

設計模式之美 - 26 | 實戰二(下):如何實現一個支持各種統計規則的性能計數器?

在上一節課中,我們對計數器框架做了需求分析和粗略的模塊劃分。今天這節課,我們利用面向對象設計、實現方法,並結合之前學過的設計思想、設計原則來看一下,如何編寫靈活、可擴展的、高質量的代碼實現。

話不多說,現在就讓我們正式開始今天的學習吧!

小步快跑、逐步迭代

在上一節課中,我們將整個框架分爲數據採集、存儲、聚合統計、顯示這四個模塊。除此之外,關於統計觸發方式(主動推送、被動觸發統計)、統計時間區間(統計哪一個時間段內的數據)、統計時間間隔(對於主動推送方法,多久統計推送一次)我們也做了簡單的設計。這裏我就不重新描述了,你可以打開上一節課回顧一下。

雖然上一節課的最小原型爲我們奠定了迭代開發的基礎,但離我們最終期望的框架的樣子還有很大的距離。我自己在寫這篇文章的時候,試圖去實現上面羅列的所有功能需求,希望寫出一個完美的框架,發現這是件挺燒腦的事情,在寫代碼的過程中,一直有種“腦子不夠使”的感覺。我這個有十多年工作經驗的人尚且如此,對於沒有太多經驗的開發者來說,想一下子把所有需求都實現出來,更是一件非常有挑戰的事情。一旦無法順利完成,你可能就會有很強的挫敗感,就會陷入自我否定的情緒中。

不過,即便你有能力將所有需求都實現,可能也要花費很大的設計精力和開發時間,遲遲沒有產出,你的 leader 會因此產生很強的不可控感。對於現在的互聯網項目來說,小步快跑、逐步迭代是一種更好的開發模式。所以,我們應該分多個版本逐步完善這個框架。第一個版本可以先實現一些基本功能,對於更高級、更復雜的功能,以及非功能性需求不做過高的要求,在後續的 v2.0、v3.0……版本中繼續迭代優化。

針對這個框架的開發,我們在 v1.0 版本中,暫時只實現下面這些功能。剩下的功能留在v2.0、v3.0 版本,也就是我們後面的第 39 節和第 40 節課中再來講解。

  • 數據採集:負責打點採集原始數據,包括記錄每次接口請求的響應時間和請求時間。

  • 存儲:負責將採集的原始數據保存下來,以便之後做聚合統計。數據的存儲方式有很多種,我們暫時只支持 Redis 這一種存儲方式,並且,採集與存儲兩個過程同步執行。

  • 聚合統計:負責將原始數據聚合爲統計數據,包括響應時間的最大值、最小值、平均值、99.9 百分位值、99 百分位值,以及接口請求的次數和 tps。

  • 顯示:負責將統計數據以某種格式顯示到終端,暫時只支持主動推送給命令行和郵件。命令行間隔 n 秒統計顯示上 m 秒的數據(比如,間隔 60s 統計上 60s 的數據)。郵件每日統計上日的數據。

現在這個版本的需求比之前的要更加具體、簡單了,實現起來也更加容易一些。實際上,學會結合具體的需求,做合理的預判、假設、取捨,規劃版本的迭代設計開發,也是一個資深工程師必須要具備的能力。

面向對象設計與實現

在第 13 節和第 14 節課中,我們把面向對象設計與實現分開來講解,界限劃分比較明顯。在實際的軟件開發中,這兩個過程往往是交叉進行的。一般是先有一個粗糙的設計,然後着手實現,實現的過程發現問題,再回過頭來補充修改設計。所以,對於這個框架的開發來說,我們把設計和實現放到一塊來講解。

回顧上一節課中的最小原型的實現,所有的代碼都耦合在一個類中,這顯然是不合理的。接下來,我們就按照之前講的面向對象設計的幾個步驟,來重新劃分、設計類。

1. 劃分職責進而識別出有哪些類

根據需求描述,我們先大致識別出下面幾個接口或類。這一步不難,完全就是翻譯需求。

  • MetricsCollector 類負責提供 API,來採集接口請求的原始數據。我們可以爲MetricsCollector 抽象出一個接口,但這並不是必須的,因爲暫時我們只能想到一個MetricsCollector 的實現方式。

  • MetricsStorage 接口負責原始數據存儲,RedisMetricsStorage 類實現MetricsStorage 接口。這樣做是爲了今後靈活地擴展新的存儲方法,比如用 HBase 來存儲。

  • Aggregator 類負責根據原始數據計算統計數據。

  • ConsoleReporter 類、EmailReporter 類分別負責以一定頻率統計併發送統計數據到命令行和郵件。至於 ConsoleReporter 和 EmailReporter 是否可以抽象出可複用的抽象類,或者抽象出一個公共的接口,我們暫時還不能確定。

2. 定義類及類與類之間的關係

接下來就是定義類及屬性和方法,定義類與類之間的關係。這兩步沒法分得很開,所以,我們今天將它們合在一起來講解。

大致地識別出幾個核心的類之後,我的習慣性做法是,先在 IDE 中創建好這幾個類,然後開始試着定義它們的屬性和方法。在設計類、類與類之間交互的時候,我會不斷地用之前學過的設計原則和思想來審視設計是否合理,比如,是否滿足單一職責原則、開閉原則、依賴注入、KISS 原則、DRY 原則、迪米特法則,是否符合基於接口而非實現編程思想,代碼是否高內聚、低耦合,是否可以抽象出可複用代碼等等。

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 類的屬性和方法也比較明確。具體的代碼實現如下所示。注意,一次性取太長時間區間的數據,可能會導致拉取太多的數據到內存中,有可能會撐爆內存。對於 Java 來說,就有可能會觸發 OOM(Out Of Memory)。而且,即便不出現 OOM,內存還夠用,但也會因爲內存吃緊,導致頻繁的 Full GC,進而導致系統接口請求處理變慢,甚至超時。這個問題解決起來並不難,先留給你自己思考一下。我會在第 40 節課中解答。

public interface MetricsStorage {
	void saveRequestInfo(RequestInfo requestInfo);
	
	List<RequestInfo> getRequestInfos(String apiName, long startTimeInMillis, lon
	
	Map<String, List<RequestInfo>> getRequestInfos(long startTimeInMillis, long e
}

public class RedisMetricsStorage implements MetricsStorage {
	//... 省略屬性和構造函數等...
	@Override
	public void saveRequestInfo(RequestInfo requestInfo) {
		//...
	}
	
	@Override
	public List<RequestInfo> getRequestInfos(String apiName, long startTimestamp
		//...
	}
	
	@Override
	public Map<String, List<RequestInfo>> getRequestInfos(long startTimestamp, lo
		//...
	}
}

MetricsCollector 類和 MetricsStorage 類的設計思路比較簡單,不同的人給出的設計結果應該大差不差。但是,統計和顯示這兩個功能就不一樣了,可以有多種設計思路。實際上,如果我們把統計顯示所要完成的功能邏輯細分一下的話,主要包含下面 4 點:

  1. 根據給定的時間區間,從數據庫中拉取數據;
  2. 根據原始數據,計算得到統計數據;
  3. 將統計數據顯示到終端(命令行或郵件);
  4. 定時觸發以上 3 個過程的執行。

實際上,如果用一句話總結一下的話,面向對象設計和實現要做的事情,就是把合適的代碼放到合適的類中。所以,我們現在要做的工作就是,把以上的 4 個功能邏輯劃分到幾個類中。劃分的方法有很多種,比如,我們可以把前兩個邏輯放到一個類中,第 3 個邏輯放到另外一個類中,第 4 個邏輯作爲上帝類(God Class)組合前面兩個類來觸發前 3 個邏輯的執行。當然,我們也可以把第 2 個邏輯單獨放到一個類中,第 1、3、4 都放到另外一個類中。

至於到底選擇哪種排列組合方式,判定的標準是,讓代碼儘量地滿足低耦合、高內聚、單一職責、對擴展開放對修改關閉等之前講到的各種設計原則和思想,儘量地讓設計滿足代碼易複用、易讀、易擴展、易維護。

我們暫時選擇把第 1、3、4 邏輯放到 ConsoleReporter 或 EmailReporter 類中,把第 2個邏輯放到 Aggregator 類中。其中,Aggregator 類負責的邏輯比較簡單,我們把它設計成只包含靜態方法的工具類。具體的代碼實現如下所示:

public class Aggregator {
	public static RequestStat aggregate(List<RequestInfo> requestInfos, long dura
		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;
	}
}

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() {
			@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, endTimeInMill

				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, du
					stats.put(apiName, requestStat);
				}
				// 第 3 個代碼邏輯:將統計數據顯示到終端(命令行或郵件);
				System.out.println("Time Span: [" + startTimeInMillis + ", " + endTimeI
				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, endTimeInMill
				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, du
					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("[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();
		}
	}
}

Review 設計與實現

我們前面講到了 SOLID、KISS、DRY、YAGNI、LOD 等設計原則,基於接口而非實現編程、多用組合少用繼承、高內聚低耦合等設計思想。我們現在就來看下,上面的代碼實現是否符合這些設計原則和思想。

MetricsCollector

MetricsCollector 負責採集和存儲數據,職責相對來說還算比較單一。它基於接口而非實現編程,通過依賴注入的方式來傳遞 MetricsStorage 對象,可以在不需要修改代碼的情況下,靈活地替換不同的存儲方式,滿足開閉原則。

MetricsStorage、RedisMetricsStorage

MetricsStorage 和 RedisMetricsStorage 的設計比較簡單。當我們需要實現新的存儲方式的時候,只需要實現 MetricsStorage 接口即可。因爲所有用到 MetricsStorage 和RedisMetricsStorage 的地方,都是基於相同的接口函數來編程的,所以,除了在組裝類的地方有所改動(從 RedisMetricsStorage 改爲新的存儲實現類),其他接口函數調用的地方都不需要改動,滿足開閉原則。

Aggregator

Aggregator 類是一個工具類,裏面只有一個靜態函數,有 50 行左右的代碼量,負責各種統計數據的計算。當需要擴展新的統計功能的時候,需要修改 aggregate() 函數代碼,並且一旦越來越多的統計功能添加進來之後,這個函數的代碼量會持續增加,可讀性、可維護性就變差了。所以,從剛剛的分析來看,這個類的設計可能存在職責不夠單一、不易擴展等問題,需要在之後的版本中,對其結構做優化。

ConsoleReporter、EmailReporter

ConsoleReporter 和 EmailReporter 中存在代碼重複問題。在這兩個類中,從數據庫中取數據、做統計的邏輯都是相同的,可以抽取出來複用,否則就違反了 DRY 原則。而且整個類負責的事情比較多,職責不是太單一。特別是顯示部分的代碼,可能會比較複雜(比如Email 的展示方式),最好是將顯示部分的代碼邏輯拆分成獨立的類。除此之外,因爲代碼中涉及線程操作,並且調用了 Aggregator 的靜態函數,所以代碼的可測試性不好。

今天我們給出的代碼實現還是有諸多問題的,在後面的章節(第 39、40 講)中,我們會慢慢優化,給你展示整個設計演進的過程,這比直接給你最終的最優方案要有意義得多!實際上,優秀的代碼都是重構出來的,複雜的代碼都是慢慢堆砌出來的 。所以,當你看到那些優秀而複雜的開源代碼或者項目代碼的時候,也不必自慚形穢,覺得自己寫不出來。畢竟羅馬不是一天建成的,這些優秀的代碼也是靠幾年的時間慢慢迭代優化出來的。

重點回顧

好了,今天的內容到此就講完了。我們一塊總結回顧一下,你需要掌握的重點內容。

寫代碼的過程本就是一個修修改改、不停調整的過程,肯定不是一氣呵成的。你看到的那些大牛開源項目的設計和實現,也都是在不停優化、修改過程中產生的。比如,我們熟悉的Unix 系統,第一版很簡單、粗糙,代碼不到 1 萬行。所以,迭代思維很重要,不要剛開始就追求完美。

面向對象設計和實現要做的事情,就是把合適的代碼放到合適的類中。至於到底選擇哪種劃分方法,判定的標準是讓代碼儘量地滿足低耦合、高內聚、單一職責、對擴展開放對修改關閉等之前講的各種設計原則和思想,儘量地做到代碼可複用、易讀、易擴展、易維護。

課堂討論

今天課堂討論題有下面兩道。

  1. 對於今天的設計與代碼實現,你有沒有發現哪些不合理的地方?有哪些可以繼續優化的地方呢?或者留言說說你的設計方案。
  2. 說一個你覺得不錯的開源框架或者項目,聊聊你爲什麼覺得它不錯?
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章