改善代码可测性的若干技巧
概述
軟件的工程性體現(xiàn)在質(zhì)量與效率。單測(cè)是構(gòu)成軟件質(zhì)量的第一道防線,而單測(cè)覆蓋率是軟件質(zhì)量的重要指標(biāo)之一。 編寫(xiě)容易測(cè)試的代碼,可帶來(lái)更佳的單測(cè)覆蓋率,間接提升開(kāi)發(fā)效率。
為什么程序員不大寫(xiě)單測(cè)呢? 主要有如下原因:
- 習(xí)慣于將細(xì)小的重要業(yè)務(wù)點(diǎn)重復(fù)性地混雜在應(yīng)用中。 結(jié)果是:難以對(duì)那些重要的業(yè)務(wù)點(diǎn)編寫(xiě)單測(cè)。
- 習(xí)慣于編寫(xiě)“一瀉千里”的大函數(shù)大方法。往往需要花費(fèi)至少1.5倍的力氣去編寫(xiě)一段測(cè)試代碼,合起來(lái)就是2.5倍的開(kāi)發(fā)量?;诠て诰o迫,又有多少人愿意費(fèi)力不討好呢?
- 習(xí)慣于編寫(xiě)耦合外部狀態(tài)的方法。這是面向?qū)ο蠓椒ㄕ摰囊粋€(gè)直接結(jié)果,但是也可以通過(guò)一個(gè)小技巧來(lái)改善。
- 習(xí)慣于將外部依賴耦合到方法中。這樣就需要花費(fèi)力氣去mock外部依賴以及一堆單調(diào)乏味的mock代碼,同樣會(huì)使單測(cè)難度增加和開(kāi)發(fā)量大增。
針對(duì)上述情況,使用“代碼語(yǔ)義化”、“分離獨(dú)立邏輯”、“分離實(shí)例狀態(tài)”、“表達(dá)與執(zhí)行分離”、“參數(shù)對(duì)象”、“分離純函數(shù)”、“面向接口編程”的技巧,用于編寫(xiě)更容易測(cè)試的代碼。
技巧
代碼語(yǔ)義化
在工程中,常常多處看到類似無(wú)語(yǔ)義的代碼:
if (state.equals(5)) {// code .... }這段代碼有兩個(gè)問(wèn)題:(1) 無(wú)語(yǔ)義,易重復(fù); (2) 容易引起 NPE。 state.equals(5) 是想表達(dá)什么業(yè)務(wù)語(yǔ)義呢? 在不同領(lǐng)域里,有不同的含義。比如用于訂單狀態(tài),可用于表達(dá)已付款。那么,代碼里就應(yīng)該明確表達(dá)這一含義,新建一個(gè)類 OrderStateUtil 及 isOrderPaid() ,把這段代碼放進(jìn)去;此外,如果 state = null,會(huì)引起 NPE,因此保險(xiǎn)的寫(xiě)法是 Integer.valueOf(5).equals(state) 。 這段代碼可寫(xiě)作:
public class OrderStateUtil {public static isOrderPaid() {return Integer.valueOf(State.ISPAID).equals(state);} }這些,就可以對(duì)這段代碼進(jìn)行測(cè)試,并且多處放心引用。 像這樣的代碼,可稱之“業(yè)務(wù)點(diǎn)”。 業(yè)務(wù)系統(tǒng)中充滿著大量這樣的細(xì)小的業(yè)務(wù)點(diǎn)。將業(yè)務(wù)點(diǎn)抽離出來(lái),一則可以大量復(fù)用,二則可以任意組合, 就能避免系統(tǒng)重構(gòu)時(shí)需要改多處的問(wèn)題了。
將單純的業(yè)務(wù)點(diǎn)從方法中分離出來(lái)。
分離獨(dú)立邏輯
獨(dú)立邏輯是不依賴于任何外部服務(wù)依賴的業(yè)務(wù)邏輯或通用邏輯,符合“相同輸入運(yùn)行任意次總是得到相同輸出”的函數(shù)模型。獨(dú)立邏輯容易編寫(xiě)單測(cè),然而很多開(kāi)發(fā)者卻習(xí)慣把大段的獨(dú)立邏輯放在一個(gè)大的流程方法里導(dǎo)致單測(cè)難寫(xiě)。來(lái)看這段放在流程方法里的代碼:
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()));這段代碼本質(zhì)上就是獲取itemIds并設(shè)置參數(shù)對(duì)象,由于嵌入到方法中,導(dǎo)致難以單測(cè),且增大所在方法的長(zhǎng)度。此外,不必要地使用stream的雙重循環(huán),導(dǎo)致代碼難以理解和維護(hù)。如果這段邏輯非常重要,將一段未測(cè)的邏輯放在每日調(diào)用百萬(wàn)次的接口里,那簡(jiǎn)直是存僥幸心理,犯兵家之忌。應(yīng)當(dāng)抽離出來(lái),創(chuàng)建成一個(gè)純函數(shù):
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 是純函數(shù),容易編寫(xiě)單測(cè),而原來(lái)的一段代碼轉(zhuǎn)化為一行調(diào)用 deliveryParam.setItemIds(getItemIds(param, orderItems)); 縮短了業(yè)務(wù)方法的長(zhǎng)度。這里封裝了一個(gè)更安全的 StreamUtil.map , 是為了防止NPE。
將獨(dú)立邏輯和通用邏輯從方法流程中分離出來(lái)。
分離實(shí)例狀態(tài)
在博文 “使用Java函數(shù)接口及l(fā)ambda表達(dá)式隔離和模擬外部依賴更容易滴單測(cè)” 的隔離依賴配置實(shí)際上已經(jīng)給出了一個(gè)例子。 開(kāi)發(fā)人員習(xí)慣于將類的實(shí)例變量在類方法中直接引用,而這樣做的后果就是破壞了方法的通用性和純粹性。改進(jìn)的方法其實(shí)很簡(jiǎn)單:編寫(xiě)一個(gè)純函數(shù),將實(shí)例變量或?qū)嵗龑?duì)象作為參數(shù)傳入,然后編寫(xiě)一個(gè)“外殼函數(shù)”,調(diào)用這個(gè)函數(shù)實(shí)現(xiàn)功能。這樣既能保證對(duì)于外部一致的訪問(wèn)接口,又能保證內(nèi)部實(shí)現(xiàn)的通用性和純粹性,且更容易單測(cè)。
分離外部服務(wù)調(diào)用
現(xiàn)在我們進(jìn)入正題。 一環(huán)扣一環(huán)的外部服務(wù)調(diào)用,正是使單測(cè)編寫(xiě)變得困難的主要因素。 在 “使用Java函數(shù)接口及l(fā)ambda表達(dá)式隔離和模擬外部依賴更容易滴單測(cè)” 一文已經(jīng)初步探討了如何使用函數(shù)接口及l(fā)ambda表達(dá)式來(lái)隔離和模擬外部依賴,增強(qiáng)代碼可測(cè)性。不過(guò)不徹底。 如果一個(gè)方法里含有多個(gè)外部服務(wù)調(diào)用怎么辦? 如果方法A調(diào)用B,B調(diào)用C,C調(diào)用D,D依賴了外部服務(wù),怎么讓 A,B,C,D更加容易測(cè)試? 如何可配置化地調(diào)用外部服務(wù),而讓類的大部分方法保持函數(shù)純粹性而容易單測(cè),少部分方法則承擔(dān)外部服務(wù)調(diào)用的職責(zé)?指導(dǎo)思想是: 通過(guò)函數(shù)接口隔離外部服務(wù)依賴,分離出真正可單測(cè)的部分 。真正可單測(cè)的部分往往是條件性、循環(huán)性的不含服務(wù)調(diào)用依賴的業(yè)務(wù)性邏輯,而順序的含服務(wù)調(diào)用依賴的流程性邏輯,應(yīng)當(dāng)通過(guò)接口測(cè)試用例來(lái)驗(yàn)證。
表達(dá)與執(zhí)行分離
表達(dá)通常是聲明式的,無(wú)狀態(tài)的;執(zhí)行通常是命令式的,有狀態(tài)且依賴外部環(huán)境的。 表達(dá)與執(zhí)行分離,可將狀態(tài)與依賴分離出來(lái),從而對(duì)表達(dá)本身進(jìn)行單測(cè)。來(lái)看一段代碼:
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");}這段代碼根據(jù)不同條件,獲取對(duì)應(yīng)的發(fā)貨子組件。 可見(jiàn),代碼要完成兩個(gè)子功能: (1) 根據(jù)不同條件判斷需要何種組件; (2) 獲取相應(yīng)組件,并打印必要日志。 (1) 是表達(dá),真正值得測(cè)試的部分, (2) 是執(zhí)行,通過(guò)接口測(cè)試即可驗(yàn)證; 而代碼將(1)與(2) 混雜到一起,從而使得編寫(xiě)整個(gè)單測(cè)難度變大了,因?yàn)橐猰ock applicationContext,還需要注入外部變量 log 。 可以將(1) 抽離出來(lái),只返回要發(fā)貨組件標(biāo)識(shí),更容易單測(cè),而(2) 則使用多種方式實(shí)現(xiàn)。如下代碼所示:
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}雖然多出了兩個(gè)方法,但是只有 getBizComponentID 方法是最核心的最需要單測(cè)的,并且是無(wú)狀態(tài)不依賴外部環(huán)境的,很容易編寫(xiě)單測(cè),只需要測(cè)試各種條件即可。這里定義了 BizComponentEnum ,是為了規(guī)范發(fā)貨組件的名稱僅限于指定的若干種,防止拼寫(xiě)錯(cuò)誤。
識(shí)別業(yè)務(wù)邏輯中的表達(dá)與執(zhí)行,將表達(dá)部分分離出來(lái)。
分離純函數(shù)
看下面這段代碼:
/*** 根據(jù)指定rowkey列表及指定列族、列集合獲取Hbase數(shù)據(jù)* @param tableName hbase表名* @param rowKeyList rowkey列表* @param cfName 列族* @param columns 列名* @param allowNull 是否允許值為null,通常針對(duì)rowkey* @return hbase 數(shù)據(jù)集* @throws Exception 獲取數(shù)據(jù)集失敗時(shí)拋出異常*/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;}這段代碼有大部分代碼慣有的毛病:多個(gè)邏輯混雜在一起;大量條件性的業(yè)務(wù)邏輯中間藏有一小段外部依賴的調(diào)用(HTable table = getHtable(tableName); Result[] results = table.get(gets); 訪問(wèn) Hbase數(shù)據(jù)源),而這一小段外部依賴使得整個(gè)方法的單測(cè)編寫(xiě)變得麻煩了。 在 “使用Java函數(shù)接口及l(fā)ambda表達(dá)式隔離和模擬外部依賴更容易滴單測(cè)” 一文中已經(jīng)指出,只要使用一個(gè) BiFunction 來(lái)模擬 Result[] results = table.get(gets); 這段調(diào)用,即可使得 getRows 整個(gè)方法變成純函數(shù)。 不過(guò),這個(gè)方法已經(jīng)有好幾個(gè)參數(shù)了,再增加一個(gè)參數(shù)會(huì)比較難看??梢詰?yīng)用參數(shù)對(duì)象模式,將多個(gè)緊密關(guān)聯(lián)的原子參數(shù)聚合為一個(gè)參數(shù)對(duì)象。注意到 htableName,rowkeyList, cf, columns, allowNull 確實(shí)是從Hbase獲取數(shù)據(jù)所需要的緊密關(guān)聯(lián)的參數(shù)聚合,因此適合參數(shù)對(duì)象模式。重構(gòu)后代碼如下所示:
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;}重構(gòu)后的代碼中,(tableName, rowKeyList, cfName, columns, allowNull) 這些原子性參數(shù)都聚合到參數(shù)對(duì)象 hbaseFetchParamObject 中,大幅減少了方法參數(shù)個(gè)數(shù)。現(xiàn)在,getRows(hbaseFetchParamObject, getFromHbaseFunc) 這個(gè)從Hbase獲取數(shù)據(jù)的核心函數(shù)變成無(wú)依賴外部的純函數(shù)了,可以更容易滴單測(cè),而原來(lái)的方法則變成了一個(gè)接口不變的外殼供外部調(diào)用。 這說(shuō)明了, 任何一個(gè)依賴外部服務(wù)的非純函數(shù),總可以分為一個(gè)不依賴外部服務(wù)的具備核心邏輯的純函數(shù)和一個(gè)調(diào)用外部服務(wù)的殼函數(shù)。而單測(cè)正是針對(duì)這個(gè)具備核心邏輯的純函數(shù)。
此外,將構(gòu)建 gets 和 results 的邏輯分離出來(lái),使得 getRows 流程更加清晰?,F(xiàn)在 getRows(hbaseFetchParamObject, getFromHbaseFunc) , buildGets, buildResult 都是純函數(shù),對(duì)三者編寫(xiě)單測(cè)后,對(duì)從Hbase獲取數(shù)據(jù)的基礎(chǔ)函數(shù)的質(zhì)量會(huì)更加自信了。
只要方法中的調(diào)用服務(wù)調(diào)用不多于2個(gè)(不包括調(diào)用方法中的服務(wù)依賴),都可以采用這種方法來(lái)解決單測(cè)的問(wèn)題。
使用函數(shù)接口將外部依賴隔離。
代碼模式
縱觀業(yè)務(wù)系統(tǒng)里的代碼,主要原子代碼模式主要有五種:
- 構(gòu)建參數(shù)
- 判斷條件是否滿足
- 組裝數(shù)據(jù)
- 調(diào)用服務(wù)查詢數(shù)據(jù)
- 調(diào)用服務(wù)執(zhí)行操作
前三者是可單測(cè)的,后兩者是不可測(cè)的。而代碼常常將前三者和后兩者混雜在一起,必須想辦法將其分離開(kāi)。
依賴于外部服務(wù)的代碼模式主要有如下五種:
- 構(gòu)建參數(shù) - 判斷條件滿足后調(diào)用服務(wù)查詢數(shù)據(jù) - 判斷邏輯或組裝數(shù)據(jù);
- 構(gòu)建參數(shù) - 判斷條件滿足后調(diào)用服務(wù)執(zhí)行操作 - 判斷邏輯或組裝數(shù)據(jù);
- 構(gòu)建參數(shù) - 判斷條件滿足后調(diào)用服務(wù)查詢數(shù)據(jù) - 判斷邏輯或組裝數(shù)據(jù) - 判斷條件滿足后調(diào)用服務(wù)執(zhí)行操作 - 判斷邏輯或組裝數(shù)據(jù);
- 構(gòu)建參數(shù) - 判斷條件滿足后調(diào)用服務(wù)執(zhí)行操作 - 判斷邏輯或組裝數(shù)據(jù) - 判斷條件滿足后調(diào)用服務(wù)查詢數(shù)據(jù) - 判斷邏輯或組裝數(shù)據(jù);
- 以上的任意可能的組合。
一般前四種都可以采用函數(shù)接口的方式來(lái)解耦外部依賴。
面向接口編程
面向接口編程有兩層含義:類級(jí)別,面向接口編程; 方法級(jí)別,面向函數(shù)接口編程。
當(dāng)要編寫(xiě)單測(cè)時(shí),很容易編寫(xiě)接口的mock類或lambda表達(dá)式。 比如 A 對(duì)象依賴 B 對(duì)象里的 M 方法,而 M 方法會(huì)從數(shù)據(jù)庫(kù)里讀取數(shù)據(jù)。那么 A 就不要直接依賴 B 的實(shí)體類,而引用 B 的接口。 當(dāng)對(duì) A 編寫(xiě)單測(cè)時(shí),只要注入 B 的 mock 實(shí)現(xiàn)即可。 同理,方法中含有 service 調(diào)用時(shí),不要直接依賴 service 調(diào)用,而是依賴函數(shù)接口,在函數(shù)接口中傳遞 service 調(diào)用,如上面的做法。這樣,編寫(xiě)單測(cè)時(shí),只要傳入 lambda 表達(dá)式返回mock數(shù)據(jù)即可。
假設(shè)有 m1, m2, m3 方法,m1調(diào)用m2, m2調(diào)用m3, m1, m2 都是純函數(shù), m3 會(huì)調(diào)用外部服務(wù)依賴。由于 m3 不純以及調(diào)用關(guān)系,導(dǎo)致 m1, m2 也不純。解耦的方法是面向函數(shù)接口編程。 m3 不依賴于外部服務(wù),而是依賴函數(shù)接口。在 m3 的參數(shù)中提供一個(gè)函數(shù)接口,m1, m2 傳入一個(gè) lambda 表達(dá)式。如果 m1, m2 也有很多業(yè)務(wù)邏輯要測(cè)試,那么 m1, m2 也提供相同的函數(shù)接口傳入服務(wù)依賴,直到某一層只是一層“殼函數(shù)”。 這樣,含有業(yè)務(wù)邏輯的方法都可以方便地單測(cè),而且更容易理解(函數(shù)接口表達(dá)了需要什么外部依賴), 而殼函數(shù)不需要單測(cè)。 當(dāng)然,這需要對(duì)編程方式和習(xí)慣的一種改變,而目前大部分編程習(xí)慣就是直接在方法里調(diào)用service,看上去直觀,卻會(huì)導(dǎo)致方法耦合了外部依賴,難以單測(cè)。
小結(jié)
良好的編程習(xí)慣會(huì)帶來(lái)可測(cè)性更佳的代碼,對(duì)軟件的質(zhì)量和開(kāi)發(fā)效率都有積極影響。代碼語(yǔ)義化、分離通用邏輯、將實(shí)例狀態(tài)放在參數(shù)中、參數(shù)對(duì)象、面向接口編程等都是一些小的技巧和做法,結(jié)合起來(lái)使用就能讓代碼表達(dá)更加容易理解和維護(hù);而函數(shù)編程,則可以解耦外部服務(wù)依賴,分離出容易測(cè)試的具有核心業(yè)務(wù)邏輯的純函數(shù)。
面向?qū)ο?函數(shù)式編程是非常強(qiáng)大的混合編程范式。面向?qū)ο筇峁┝速N近現(xiàn)實(shí)的自然的表達(dá)方法,為應(yīng)用系統(tǒng)提供一個(gè)優(yōu)秀的外部視角; 而函數(shù)編程則著重于內(nèi)部結(jié)構(gòu)優(yōu)化,可以讓內(nèi)部實(shí)現(xiàn)解耦得更加清晰。 兩者是相輔相成的,而非對(duì)立的。
轉(zhuǎn)載于:https://www.cnblogs.com/lovesqcc/p/7898319.html
總結(jié)
以上是生活随笔為你收集整理的改善代码可测性的若干技巧的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: 鸿蒙电视rom,鸿蒙系统刷机包
- 下一篇: 混合高斯模型背景建模原理