改善代码可测性的若干技巧
概述
軟件的工程性體現在質量與效率。單測是構成軟件質量的第一道防線,而單測覆蓋率是軟件質量的重要指標之一。 編寫容易測試的代碼,可帶來更佳的單測覆蓋率,間接提升開發效率。
為什么程序員不大寫單測呢? 主要有如下原因:
- 習慣于將細小的重要業務點重復性地混雜在應用中。 結果是:難以對那些重要的業務點編寫單測。
- 習慣于編寫“一瀉千里”的大函數大方法。往往需要花費至少1.5倍的力氣去編寫一段測試代碼,合起來就是2.5倍的開發量。基于工期緊迫,又有多少人愿意費力不討好呢?
- 習慣于編寫耦合外部狀態的方法。這是面向對象方法論的一個直接結果,但是也可以通過一個小技巧來改善。
- 習慣于將外部依賴耦合到方法中。這樣就需要花費力氣去mock外部依賴以及一堆單調乏味的mock代碼,同樣會使單測難度增加和開發量大增。
針對上述情況,使用“代碼語義化”、“分離獨立邏輯”、“分離實例狀態”、“表達與執行分離”、“參數對象”、“分離純函數”、“面向接口編程”的技巧,用于編寫更容易測試的代碼。
技巧
代碼語義化
在工程中,常常多處看到類似無語義的代碼:
if (state.equals(5)) {// code .... }這段代碼有兩個問題:(1) 無語義,易重復; (2) 容易引起 NPE。 state.equals(5) 是想表達什么業務語義呢? 在不同領域里,有不同的含義。比如用于訂單狀態,可用于表達已付款。那么,代碼里就應該明確表達這一含義,新建一個類 OrderStateUtil 及 isOrderPaid() ,把這段代碼放進去;此外,如果 state = null,會引起 NPE,因此保險的寫法是 Integer.valueOf(5).equals(state) 。 這段代碼可寫作:
public class OrderStateUtil {public static isOrderPaid() {return Integer.valueOf(State.ISPAID).equals(state);} }這些,就可以對這段代碼進行測試,并且多處放心引用。 像這樣的代碼,可稱之“業務點”。 業務系統中充滿著大量這樣的細小的業務點。將業務點抽離出來,一則可以大量復用,二則可以任意組合, 就能避免系統重構時需要改多處的問題了。
將單純的業務點從方法中分離出來。
分離獨立邏輯
獨立邏輯是不依賴于任何外部服務依賴的業務邏輯或通用邏輯,符合“相同輸入運行任意次總是得到相同輸出”的函數模型。獨立邏輯容易編寫單測,然而很多開發者卻習慣把大段的獨立邏輯放在一個大的流程方法里導致單測難寫。來看這段放在流程方法里的代碼:
deliveryParam.setItemIds(param.getItemIds().stream().map(x -> {if (orderItems.stream().anyMatch(orderItem -> x.equals(orderItem.getNewItemId()))) {return orderItems.stream().filter(orderItem -> x.equals(orderItem.getNewItemId())).map(orderItem -> orderItem.getId()).collect(Collectors.toList()).get(0);} else {return x.intValue();}}).collect(Collectors.toList()));這段代碼本質上就是獲取itemIds并設置參數對象,由于嵌入到方法中,導致難以單測,且增大所在方法的長度。此外,不必要地使用stream的雙重循環,導致代碼難以理解和維護。如果這段邏輯非常重要,將一段未測的邏輯放在每日調用百萬次的接口里,那簡直是存僥幸心理,犯兵家之忌。應當抽離出來,創建成一個純函數:
private List<Integer> getItemIds(DeliveryParamV2 param, List<OrderItem> orderItems) {Map<Long, Integer> itemIdMap = orderItems.stream().collect(Collectors.toMap(OrderItem::getNewItemId, OrderItem::getId));return StreamUtil.map(param.getItemIds(), itemId -> itemIdMap.getOrDefault(itemId, itemId.intValue())); }public class StreamUtil {public static <T,R> List<R> map(List<T> dataList, Function<T,R> getData) {if (dataList == null || dataList.isEmpty()) { return new ArrayList(); }return dataList.stream().map(getData).collect(Collectors.toList());}}getItemIds 是純函數,容易編寫單測,而原來的一段代碼轉化為一行調用 deliveryParam.setItemIds(getItemIds(param, orderItems)); 縮短了業務方法的長度。這里封裝了一個更安全的 StreamUtil.map , 是為了防止NPE。
將獨立邏輯和通用邏輯從方法流程中分離出來。
分離實例狀態
在博文 “使用Java函數接口及lambda表達式隔離和模擬外部依賴更容易滴單測” 的隔離依賴配置實際上已經給出了一個例子。 開發人員習慣于將類的實例變量在類方法中直接引用,而這樣做的后果就是破壞了方法的通用性和純粹性。改進的方法其實很簡單:編寫一個純函數,將實例變量或實例對象作為參數傳入,然后編寫一個“外殼函數”,調用這個函數實現功能。這樣既能保證對于外部一致的訪問接口,又能保證內部實現的通用性和純粹性,且更容易單測。
分離外部服務調用
現在我們進入正題。 一環扣一環的外部服務調用,正是使單測編寫變得困難的主要因素。 在 “使用Java函數接口及lambda表達式隔離和模擬外部依賴更容易滴單測” 一文已經初步探討了如何使用函數接口及lambda表達式來隔離和模擬外部依賴,增強代碼可測性。不過不徹底。 如果一個方法里含有多個外部服務調用怎么辦? 如果方法A調用B,B調用C,C調用D,D依賴了外部服務,怎么讓 A,B,C,D更加容易測試? 如何可配置化地調用外部服務,而讓類的大部分方法保持函數純粹性而容易單測,少部分方法則承擔外部服務調用的職責?指導思想是: 通過函數接口隔離外部服務依賴,分離出真正可單測的部分 。真正可單測的部分往往是條件性、循環性的不含服務調用依賴的業務性邏輯,而順序的含服務調用依賴的流程性邏輯,應當通過接口測試用例來驗證。
表達與執行分離
表達通常是聲明式的,無狀態的;執行通常是命令式的,有狀態且依賴外部環境的。 表達與執行分離,可將狀態與依賴分離出來,從而對表達本身進行單測。來看一段代碼:
public BizComponent getBizComponentInstance(BizContext BizContext, BizParam params) {if (ACondition1) {LogUtils.info(log, "AComponent for {}", params);return (BizComponent) applicationContext.getBean("AComponent");}if(BCondition2){LogUtils.info(log, "BComponent for {}", params);return (BizComponent) applicationContext.getBean("BComponent");}if (ECondition) {LogUtils.info(log, "EComponent for {}", params);return (BizComponent) applicationContext.getBean("EComponent");}LogUtils.info(log, "normalComponent for {}", params);return (BizComponent) applicationContext.getBean("normalComponent");}這段代碼根據不同條件,獲取對應的發貨子組件。 可見,代碼要完成兩個子功能: (1) 根據不同條件判斷需要何種組件; (2) 獲取相應組件,并打印必要日志。 (1) 是表達,真正值得測試的部分, (2) 是執行,通過接口測試即可驗證; 而代碼將(1)與(2) 混雜到一起,從而使得編寫整個單測難度變大了,因為要mock applicationContext,還需要注入外部變量 log 。 可以將(1) 抽離出來,只返回要發貨組件標識,更容易單測,而(2) 則使用多種方式實現。如下代碼所示:
public BizComponent getBizComponentInstanceBetter(BizContext bizContext, BizParam params) {return getActualComponentInstance(getBizComponentID(bizContext, params).name(), params);}public ComponentEnum getBizComponentID(BizContext BizContext, BizParam params) {if (ACondition1) {return AComponent;}if(BCondition2){return BComponent;}if (ECondition) {return EComponent;}return NormalComponent;}public BizComponent getActualComponentInstance(String componentName, BizParam params) {LogUtils.info(log, "component {} for {}", componentName, params);return (BizComponent) applicationContext.getBean(componentName);}public enum BizComponentEnum {NormalComponent, AComponent, BComponent, EComponent}雖然多出了兩個方法,但是只有 getBizComponentID 方法是最核心的最需要單測的,并且是無狀態不依賴外部環境的,很容易編寫單測,只需要測試各種條件即可。這里定義了 BizComponentEnum ,是為了規范發貨組件的名稱僅限于指定的若干種,防止拼寫錯誤。
識別業務邏輯中的表達與執行,將表達部分分離出來。
分離純函數
看下面這段代碼:
/*** 根據指定rowkey列表及指定列族、列集合獲取Hbase數據* @param tableName hbase表名* @param rowKeyList rowkey列表* @param cfName 列族* @param columns 列名* @param allowNull 是否允許值為null,通常針對rowkey* @return hbase 數據集* @throws Exception 獲取數據集失敗時拋出異常*/public List<Result> getRows(String tableName, List<String> rowKeyList,String cfName, List<String> columns,boolean allowNull) throws Exception {HTable table = getHtable(tableName);final String cf = (cfName == null) ? "cf" : cfName;List<Get> gets = rowKeyList.stream().map(rowKey -> {String rowKeyNotEmpty = (rowKey == null ? "null" : rowKey);Get get = new Get(Bytes.toBytes(rowKeyNotEmpty));if (columns != null && !columns.isEmpty()) {for (String col: columns) {get.addColumn(Bytes.toBytes(cf), Bytes.toBytes(col));}}return get;}).collect(Collectors.toList());Result[] results = table.get(gets);logger.info("Got {} results from hbase table {}. cf: {}, columns: {}", results.length, tableName, cf, columns);List<Result> rsList = new ArrayList<>();for (int i = 0; i < rowKeyList.size(); i++) {if (!allowNull && isResultEmpty(results[i])) {logger.warn("cant't get record for rowkey:{}", rowKeyList.get(i));continue;}rsList.add(results[i]);}logger.info("got {} rows from table {} with {} rowkeys", rsList.size(), tableName, rowKeyList.size());return rsList;}這段代碼有大部分代碼慣有的毛病:多個邏輯混雜在一起;大量條件性的業務邏輯中間藏有一小段外部依賴的調用(HTable table = getHtable(tableName); Result[] results = table.get(gets); 訪問 Hbase數據源),而這一小段外部依賴使得整個方法的單測編寫變得麻煩了。 在 “使用Java函數接口及lambda表達式隔離和模擬外部依賴更容易滴單測” 一文中已經指出,只要使用一個 BiFunction 來模擬 Result[] results = table.get(gets); 這段調用,即可使得 getRows 整個方法變成純函數。 不過,這個方法已經有好幾個參數了,再增加一個參數會比較難看。可以應用參數對象模式,將多個緊密關聯的原子參數聚合為一個參數對象。注意到 htableName,rowkeyList, cf, columns, allowNull 確實是從Hbase獲取數據所需要的緊密關聯的參數聚合,因此適合參數對象模式。重構后代碼如下所示:
public List<Result> getRows(String tableName, List<String> rowKeyList,String cfName, List<String> columns,boolean allowNull) throws Exception {return getRows(new HbaseFetchParamObject(tableName, rowKeyList, cfName, columns, allowNull),this::getFromHbase);}private Result[] getFromHbase(String tableName, List<Get> gets) {try {HTable table = getHtable(tableName);return table.get(gets);} catch (Exception ex) {logger.error(ex.getMessage(), ex);throw new RuntimeException(ex);}}public List<Result> getRows(HbaseFetchParamObject hbaseFetchParamObject,BiFunction<String, List<Get>, Result[]> getFromHbaseFunc) throws Exception {String tableName = hbaseFetchParamObject.getTableName();String cfName = hbaseFetchParamObject.getCfName();List<String> rowKeyList = hbaseFetchParamObject.getRowKeyList();List<String> columns = hbaseFetchParamObject.getColumns();boolean allowNull = hbaseFetchParamObject.isAllowNull();String cf = (cfName == null) ? "cf" : cfName;List<Get> gets = buildGets(rowKeyList, cf, columns);Result[] results = getFromHbaseFunc.apply(tableName, gets);logger.info("Got {} results from hbase table {}. cf: {}, columns: {}", results.length, tableName, cf, columns);List<Result> rsList = buildResult(rowKeyList, results, allowNull);logger.info("got {} rows from table {} with {} rowkeys", rsList.size(), tableName, rowKeyList.size());return rsList;}private List<Get> buildGets(List<String> rowKeyList, String cf, List<String> columns) {return StreamUtil.map(rowKeyList,rowKey -> {String rowKeyNotEmpty = (rowKey == null ? "null" : rowKey);Get get = new Get(Bytes.toBytes(rowKeyNotEmpty));if (columns != null && !columns.isEmpty()) {for (String col: columns) {get.addColumn(Bytes.toBytes(cf), Bytes.toBytes(col));}}return get;});}private List<Result> buildResult(List<String> rowKeyList, Result[] results, boolean allowNull) {List<Result> rsList = new ArrayList<>();for (int i = 0; i < rowKeyList.size(); i++) {if (!allowNull && isResultEmpty(results[i])) {logger.warn("cant't get record for rowkey:{}", rowKeyList.get(i));continue;}rsList.add(results[i]);}return rsList;}重構后的代碼中,(tableName, rowKeyList, cfName, columns, allowNull) 這些原子性參數都聚合到參數對象 hbaseFetchParamObject 中,大幅減少了方法參數個數。現在,getRows(hbaseFetchParamObject, getFromHbaseFunc) 這個從Hbase獲取數據的核心函數變成無依賴外部的純函數了,可以更容易滴單測,而原來的方法則變成了一個接口不變的外殼供外部調用。 這說明了, 任何一個依賴外部服務的非純函數,總可以分為一個不依賴外部服務的具備核心邏輯的純函數和一個調用外部服務的殼函數。而單測正是針對這個具備核心邏輯的純函數。
此外,將構建 gets 和 results 的邏輯分離出來,使得 getRows 流程更加清晰。現在 getRows(hbaseFetchParamObject, getFromHbaseFunc) , buildGets, buildResult 都是純函數,對三者編寫單測后,對從Hbase獲取數據的基礎函數的質量會更加自信了。
只要方法中的調用服務調用不多于2個(不包括調用方法中的服務依賴),都可以采用這種方法來解決單測的問題。
使用函數接口將外部依賴隔離。
代碼模式
縱觀業務系統里的代碼,主要原子代碼模式主要有五種:
- 構建參數
- 判斷條件是否滿足
- 組裝數據
- 調用服務查詢數據
- 調用服務執行操作
前三者是可單測的,后兩者是不可測的。而代碼常常將前三者和后兩者混雜在一起,必須想辦法將其分離開。
依賴于外部服務的代碼模式主要有如下五種:
- 構建參數 - 判斷條件滿足后調用服務查詢數據 - 判斷邏輯或組裝數據;
- 構建參數 - 判斷條件滿足后調用服務執行操作 - 判斷邏輯或組裝數據;
- 構建參數 - 判斷條件滿足后調用服務查詢數據 - 判斷邏輯或組裝數據 - 判斷條件滿足后調用服務執行操作 - 判斷邏輯或組裝數據;
- 構建參數 - 判斷條件滿足后調用服務執行操作 - 判斷邏輯或組裝數據 - 判斷條件滿足后調用服務查詢數據 - 判斷邏輯或組裝數據;
- 以上的任意可能的組合。
一般前四種都可以采用函數接口的方式來解耦外部依賴。
面向接口編程
面向接口編程有兩層含義:類級別,面向接口編程; 方法級別,面向函數接口編程。
當要編寫單測時,很容易編寫接口的mock類或lambda表達式。 比如 A 對象依賴 B 對象里的 M 方法,而 M 方法會從數據庫里讀取數據。那么 A 就不要直接依賴 B 的實體類,而引用 B 的接口。 當對 A 編寫單測時,只要注入 B 的 mock 實現即可。 同理,方法中含有 service 調用時,不要直接依賴 service 調用,而是依賴函數接口,在函數接口中傳遞 service 調用,如上面的做法。這樣,編寫單測時,只要傳入 lambda 表達式返回mock數據即可。
假設有 m1, m2, m3 方法,m1調用m2, m2調用m3, m1, m2 都是純函數, m3 會調用外部服務依賴。由于 m3 不純以及調用關系,導致 m1, m2 也不純。解耦的方法是面向函數接口編程。 m3 不依賴于外部服務,而是依賴函數接口。在 m3 的參數中提供一個函數接口,m1, m2 傳入一個 lambda 表達式。如果 m1, m2 也有很多業務邏輯要測試,那么 m1, m2 也提供相同的函數接口傳入服務依賴,直到某一層只是一層“殼函數”。 這樣,含有業務邏輯的方法都可以方便地單測,而且更容易理解(函數接口表達了需要什么外部依賴), 而殼函數不需要單測。 當然,這需要對編程方式和習慣的一種改變,而目前大部分編程習慣就是直接在方法里調用service,看上去直觀,卻會導致方法耦合了外部依賴,難以單測。
小結
良好的編程習慣會帶來可測性更佳的代碼,對軟件的質量和開發效率都有積極影響。代碼語義化、分離通用邏輯、將實例狀態放在參數中、參數對象、面向接口編程等都是一些小的技巧和做法,結合起來使用就能讓代碼表達更加容易理解和維護;而函數編程,則可以解耦外部服務依賴,分離出容易測試的具有核心業務邏輯的純函數。
面向對象/函數式編程是非常強大的混合編程范式。面向對象提供了貼近現實的自然的表達方法,為應用系統提供一個優秀的外部視角; 而函數編程則著重于內部結構優化,可以讓內部實現解耦得更加清晰。 兩者是相輔相成的,而非對立的。
轉載于:https://www.cnblogs.com/lovesqcc/p/7898319.html
總結
以上是生活随笔為你收集整理的改善代码可测性的若干技巧的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 鸿蒙电视rom,鸿蒙系统刷机包
- 下一篇: 混合高斯模型背景建模原理