設計模式之美 - 25 | 實戰二(上):針對非業務的通用框架開發,如何做需求分析和設計?

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

設計模式之美 - 25 | 實戰二(上):針對非業務的通用框架開發,如何做需求分析和設計?

上兩節課中,我們講了如何針對一個業務系統做需求分析、設計和實現,並且通過一個積分兌換系統的開發,實踐了之前學過的一些設計原則。接下來的兩節課,我們再結合一個支持各種統計規則的性能計數器項目,學習針對一個非業務的通用框架開發,如何來做需求分析、設計和實現,同時學習如何靈活應用各種設計原則。

話不多說,讓我們正式開始今天的內容吧!

項目背景

我們希望設計開發一個小的框架,能夠獲取接口調用的各種統計信息,比如,響應時間的最大值(max)、最小值(min)、平均值(avg)、百分位值(percentile)、接口調用次數(count)、頻率(tps) 等,並且支持將統計結果以各種顯示格式(比如:JSON 格式、網頁格式、自定義顯示格式等)輸出到各種終端(Console 命令行、HTTP 網頁、Email、日誌文件、自定義輸出終端等),以方便查看。

我們假設這是真實項目中的一個開發需求,如果讓你來負責開發這樣一個通用的框架,應用到各種業務系統中,支持實時計算、查看數據的統計信息,你會如何設計和實現呢?你可以先自己主動思考一下,然後再來看我的分析思路。

需求分析

性能計數器作爲一個跟業務無關的功能,我們完全可以把它開發成一個獨立的框架或者類庫,集成到很多業務系統中。而作爲可被複用的框架,除了功能性需求之外,非功能性需求也非常重要。所以,接下來,我們從這兩個方面來做需求分析。

1. 功能性需求分析

相對於一大長串的文字描述,人腦更容易理解短的、羅列的比較規整、分門別類的列表信息。顯然,剛纔那段需求描述不符合這個規律。我們需要把它拆解成一個一個的“幹條條”。拆解之後我寫在下面了,是不是看起來更加清晰、有條理?

  • 接口統計信息:包括接口響應時間的統計信息,以及接口調用次數的統計信息等。
  • 統計信息的類型:max、min、avg、percentile、count、tps 等。
  • 統計信息顯示格式:Json、Html、自定義顯示格式。
  • 統計信息顯示終端:Console、Email、HTTP 網頁、日誌、自定義顯示終端。

除此之外,我們還可以藉助設計產品的時候,經常用到的線框圖,把最終數據的顯示樣式畫出來,會更加一目瞭然。具體的線框圖如下所示:
在這裏插入圖片描述
實際上,從線框圖中,我們還能挖掘出了下面幾個隱藏的需求。

  • 統計觸發方式:包括主動和被動兩種。主動表示以一定的頻率定時統計數據,並主動推送到顯示終端,比如郵件推送。被動表示用戶觸發統計,比如用戶在網頁中選擇要統計的時間區間,觸發統計,並將結果顯示給用戶。

  • 統計時間區間:框架需要支持自定義統計時間區間,比如統計最近 10 分鐘的某接口的tps、訪問次數,或者統計 12 月 11 日 00 點到 12 月 12 日 00 點之間某接口響應時間的最大值、最小值、平均值等。

  • 統計時間間隔:對於主動觸發統計,我們還要支持指定統計時間間隔,也就是多久觸發一次統計顯示。比如,每間隔 10s 統計一次接口信息並顯示到命令行中,每間隔 24 小時發送一封統計信息郵件。

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 請求時間的統計信息、業務統計信息(比如支付成功率)等。

框架設計

前面講了需求分析,現在我們來看如何針對需求做框架設計。

對於稍微複雜系統的開發,很多人覺得不知從何開始。我個人喜歡借鑑 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.newSingleThreadSchedule
	
	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 等。爲了支持更多的聚合統計規則,代碼希望儘可能靈活、可擴展。

  • 顯示:負責將統計數據以某種格式顯示到終端,比如:輸出到命令行、郵件、網頁、自定義顯示終端等。

前面講到面向對象分析、設計和實現的時候,我們講到設計階段最終輸出的是類的設計,同時也講到,軟件設計開發是一個迭代的過程,分析、設計和實現這三個階段的界限劃分並不明顯。所以,今天我們只給出了比較粗略的模塊劃分,至於更加詳細的設計,我們留在下一節課中跟實現一塊來講解。

重點回顧

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

對於非業務通用框架的開發,我們在做需求分析的時候,除了功能性需求分析之外,還需要考慮框架的非功能性需求。比如,框架的易用性、性能、擴展性、容錯性、通用性等。

對於複雜框架的設計,很多人往往覺得無從下手。今天我們分享了幾個小技巧,其中包括:畫產品線框圖、聚焦簡單應用場景、設計實現最小原型、畫系統設計圖等。這些方法的目的都是爲了讓問題簡化、具體、明確,提供一個迭代設計開發的基礎,逐步推進。

實際上,不僅僅是軟件設計開發,不管做任何事情,如果我們總是等到所有的東西都想好了再開始,那這件事情可能永遠都開始不了。有句老話講:萬事開頭難,所以,先邁出第一步很重要。

課堂討論

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

  1. 應對複雜系統的設計實現,我今天講到了聚焦簡單場景、最小原型、畫圖等幾個技巧,你還有什麼經驗可以分享給大家嗎?
  2. 今天提到的線框圖、最小原型、易用性等,實際上都是產品設計方面的手段或者概念,應用到像框架這樣的技術產品的設計上也非常有用。你覺得對於一個技術人來說,產品能力是否同樣重要呢?技術人是否應該具備一些產品思維呢?
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章