改善代碼可測性的若干技巧

概述

軟件的工程性體如今質量與效率。單測是構成軟件質量的第一道防線,而單測覆蓋率是軟件質量的重要指標之一。 編寫容易測試的代碼,可帶來更佳的單測覆蓋率,間接提高開發效率。html

爲何程序員不大寫單測呢? 主要有以下緣由:java

  • 習慣於將細小的重要業務點重複性地混雜在應用中。 結果是:難以對那些重要的業務點編寫單測。
  • 習慣於編寫「一瀉千里」的大函數大方法。每每須要花費至少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);
    }
}

這些,就能夠對這段代碼進行測試,而且多處放心引用。 像這樣的代碼,可稱之「業務點」。 業務系統中充滿着大量這樣的細小的業務點。將業務點抽離出來,一則能夠大量複用,二則能夠任意組合, 就能避免系統重構時須要改多處的問題了。安全

將單純的業務點從方法中分離出來。app

分離獨立邏輯

獨立邏輯是不依賴於任何外部服務依賴的業務邏輯或通用邏輯,符合「相同輸入運行任意次老是獲得相同輸出」的函數模型。獨立邏輯容易編寫單測,然而不少開發者卻習慣把大段的獨立邏輯放在一個大的流程方法裏致使單測難寫。來看這段放在流程方法裏的代碼:函數式編程

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,看上去直觀,卻會致使方法耦合了外部依賴,難以單測。

小結

良好的編程習慣會帶來可測性更佳的代碼,對軟件的質量和開發效率都有積極影響。代碼語義化、分離通用邏輯、將實例狀態放在參數中、參數對象、面向接口編程等都是一些小的技巧和作法,結合起來使用就能讓代碼表達更加容易理解和維護;而函數編程,則能夠解耦外部服務依賴,分離出容易測試的具備核心業務邏輯的純函數。

面向對象/函數式編程是很是強大的混合編程範式。面向對象提供了貼近現實的天然的表達方法,爲應用系統提供一個優秀的外部視角; 而函數編程則着重於內部結構優化,可讓內部實現解耦得更加清晰。 二者是相輔相成的,而非對立的。

相關文章
相關標籤/搜索