歡迎光臨
每天分享高質量文章

改善程式碼可測性的若干技巧

(點選上方公眾號,可快速關註)


來源:琴水玉 ,

www.cnblogs.com/lovesqcc/p/7898319.html

概述

軟體的工程性體現在質量與效率。單測是構成軟體質量的第一道防線,而單測改寫率是軟體質量的重要指標之一。 編寫容易測試的程式碼,可帶來更佳的單測改寫率,間接提升開發效率。

為什麼程式員不大寫單測呢? 主要有如下原因:

  • 習慣於將細小的重要業務點重覆性地混雜在應用中。 結果是:難以對那些重要的業務點編寫單測。

  • 習慣於編寫“一瀉千里”的大函式大方法。往往需要花費至少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);

    }

}

這些,就可以對這段程式碼進行測試,並且多處放心取用。 像這樣的程式碼,可稱之“業務點”。 業務系統中充滿著大量這樣的細小的業務點。將業務點抽離出來,一則可以大量復用,二則可以任意組合, 就能避免系統重構時需要改多處的問題了。

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

分離獨立邏輯

獨立邏輯是不依賴於任何外部服務依賴的業務邏輯或通用邏輯,符合“相同輸入執行任意次總是得到相同輸出”的函式模型。獨立邏輯容易編寫單測,然而很多開發者卻習慣把大段的獨立邏輯放在一個大的流程方法裡導致單測難寫。來看這段放在流程方法裡的程式碼:

if(!OrderUtils.isNewOrderNo(param.getOrderNo())){

            deliveryParam.setItemIds(param.getItemIds().stream().map(itemId->itemId.intValue()).collect(Collectors.toList()));

        }else {

            deliveryParam.setItemIds(param

                    .getItemIds()

                    .stream()

                    .map(

                            x -> {

                                if (orderItems.stream().anyMatch(orderItem -> x.equals(orderItem.getTcOrderItemId()))) {

                                    return orderItems

                                            .stream()

                                            .filter(orderItem -> x.equals(orderItem.getTcOrderItemId()))

                                            .map(orderItem -> orderItem.getId())

                                            .collect(Collectors.toList()).get(0);

                                } else {

                                    return x.intValue();

                                }

                            }

                    ).collect(Collectors.toList())

            );

        }

這段程式碼本質上就是獲取itemIds並設定引數物件,由於嵌入到方法中,導致難以單測,且增大所在方法的長度。此外,不必要地使用stream的雙重迴圈,導致程式碼難以理解和維護。如果這段邏輯非常重要,將一段未測的邏輯放在每日呼叫百萬次的介面裡,那簡直是存僥幸心理,犯兵家之忌。應當抽離出來,建立成一個純函式:

private List getItemIds(DeliveryParamV2 param, List orderItems) {

        if(!OrderUtils.isNewOrderNo(param.getOrderNo())){

            return StreamUtil.map(param.getItemIds(), Long::intValue);

        }

 

        Map itemIdMap = orderItems.stream().collect(

                                                 Collectors.toMap(OrderItem::getTcOrderItemId, OrderItem::getId));

        return StreamUtil.map(param.getItemIds(),

                              itemId -> itemIdMap.getOrDefault(itemId, itemId.intValue()));

    }

 

public class StreamUtil {

 

  public static List map(List dataList, Function 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運算式隔離和模擬外部依賴更容易滴單測” 的隔離依賴配置實際上已經給出了一個例子。 開發人員習慣於將類的實體變數在類方法中直接取用,而這樣做的後果就是破壞了方法的通用性和純粹性。改進的方法其實很簡單:編寫一個純函式,將實體變數或實體物件作為引數傳入,然後編寫一個“外殼函式”,呼叫這個函式實現功能。這樣既能保證對於外部一致的訪問介面,又能保證內部實現的通用性和純粹性,且更容易單測。

http://www.cnblogs.com/lovesqcc/p/6917448.html

分離外部服務呼叫

現在我們進入正題。 一環扣一環的外部服務呼叫,正是使單測編寫變得困難的主要因素。 在 “使用Java函式介面及lambda運算式隔離和模擬外部依賴更容易滴單測” 一文已經初步探討瞭如何使用函式介面及lambda運算式來隔離和模擬外部依賴,增強程式碼可測性。不過不徹底。 如果一個方法裡含有多個外部服務呼叫怎麼辦? 如果方法A呼叫B,B呼叫C,C呼叫D,D依賴了外部服務,怎麼讓 A,B,C,D更加容易測試? 如何可配置化地呼叫外部服務,而讓類的大部分方法保持函式純粹性而容易單測,少部分方法則承擔外部服務呼叫的職責?指導思想是: 透過函式介面隔離外部服務依賴,分離出真正可單測的部分 。真正可單測的部分往往是條件性、迴圈性的不含服務呼叫依賴的業務性邏輯,而順序的含服務呼叫依賴的流程性邏輯,應當透過介面測試用例來驗證。

表達與執行分離

表達通常是宣告式的,無狀態的;執行通常是命令式的,有狀態且依賴外部環境的。 表達與執行分離,可將狀態與依賴分離出來,從而對錶達本身進行單測。來看一段程式碼:

public Deliverer getDeliverInstance(DeliveryContext deliveryContext, ExpressParam params) {

 

    if (periodDeliverCondtion1) {

      LogUtils.info(log, “periodDeliverer for {}”, params);

      return (Deliverer) applicationContext.getBean(“periodDeliverer”);

    }

 

    if(periodDeliverCondtion2){

      LogUtils.info(log, “periodDeliverer for {}”, params);

      return (Deliverer) applicationContext.getBean(“periodDeliverer”);

    }

 

    if (fenxiaoDelivererCondition) {

      LogUtils.info(log, “fenxiaoDeliverer for {}”, params);

      return (Deliverer) applicationContext.getBean(“fenxiaoDeliverer”);

    }

    if (giftDelivererCondition) {

      LogUtils.info(log, “giftDeliverer for {}”, params);

      return (Deliverer) applicationContext.getBean(“giftDeliverer”);

    }

    if (localDelivererCondition) {

      LogUtils.info(log, “localDeliverr for {}”, JsonUtils.toJson(order));

      return (Deliverer) applicationContext.getBean(“localDeliverer”);

    }

    LogUtils.info(log, “normalDeliverer for {}”, params);

    return (Deliverer) applicationContext.getBean(“normalDeliverer”);

  }

這段程式碼根據不同條件,獲取對應的發貨子元件。 可見,程式碼要完成兩個子功能: (1) 根據不同條件判斷需要何種元件; (2) 獲取相應元件,並列印必要日誌。 (1) 是表達,真正值得測試的部分, (2) 是執行,透過介面測試即可驗證; 而程式碼將(1)與(2) 混雜到一起,從而使得編寫整個單測難度變大了,因為要mock applicationContext,還需要註入外部變數 log 。 可以將(1) 抽離出來,只傳回要發貨元件標識,更容易單測,而(2) 則使用多種方式實現。如下程式碼所示:

public Deliverer getDeliverInstanceBetter(DeliveryContext deliveryContext, ExpressParam params) {

   return getActualDeliverInstance(getDeliverComponentID(deliveryContext, params).name(), params);

 }

 

 public DelivererEnum getDeliverComponentID(DeliveryContext deliveryContext, ExpressParam params) {

 

   if (periodDeliverCondtion1) {

     return periodDeliverer;

   }

 

   if(periodDeliverCondtion2){

     return periodDeliverer;

   }

 

   if (fenxiaoDelivererCondition) {

     return fenxiaoDeliverer;

   }

   if (giftDelivererCondition) {

     return giftDeliverer;

   }

   if (localDelivererCondition) {

     return localDeliverer;

   }

   return normalDeliverer;

 }

 

 public Deliverer getActualDeliverInstance(String componentName, ExpressParam params) {

   LogUtils.info(log, “component {} for {}”, componentName, params);

   return (Deliverer) applicationContext.getBean(componentName);

 }

 

 public enum DelivererEnum {

   normal

雖然多出了兩個方法,但是隻有 getDeliverComponentID 方法是最核心的最需要單測的,並且是無狀態不依賴外部環境的,很容易編寫單測,只需要測試各種條件即可。這裡定義了 DelivererEnum ,是為了規範發貨元件的名稱僅限於指定的若干種,防止拼寫錯誤。

識別業務邏輯中的表達與執行,將表達部分分離出來。

分離純函式

看下麵這段程式碼:

/**

     * 根據指定rowkey串列及指定列族、列集合獲取Hbase資料

     * @param tableName hbase表名

     * @param rowKeyList rowkey串列

     * @param cfName 列族

     * @param columns 列名

     * @param allowNull 是否允許值為null,通常針對rowkey

     * @return hbase 資料集

     * @throws Exception 獲取資料集失敗時丟擲異常

     */

    public List getRows(String tableName, List rowKeyList,

                                String cfName, List columns,

                                boolean allowNull) throws Exception {

        HTable table = getHtable(tableName);

        final String cf = (cfName == null) ? “cf” : cfName;

        List 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 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 getRows(String tableName, List rowKeyList,

                            String cfName, List columns,

                            boolean allowNull) throws Exception {

    return getRows(

        new HbaseFetchParamObject(tableName, rowKeyList, cfName, columns, allowNull),

        this::getFromHbase

    );

}

 

private Result[] getFromHbase(String tableName, List gets) {

    try {

        HTable table = getHtable(tableName);

        return table.get(gets);

    } catch (Exception ex) {

        logger.error(ex.getMessage(), ex);

        throw new RuntimeException(ex);

    }

}

 

public List getRows(HbaseFetchParamObject hbaseFetchParamObject,

                            BiFunction, Result[]> getFromHbaseFunc) throws Exception {

    String tableName = hbaseFetchParamObject.getTableName();

    String cfName = hbaseFetchParamObject.getCfName();

    List rowKeyList = hbaseFetchParamObject.getRowKeyList();

    List columns = hbaseFetchParamObject.getColumns();

    boolean allowNull = hbaseFetchParamObject.isAllowNull();

 

    String cf = (cfName == null) ? “cf” : cfName;

    List 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 rsList = buildResult(rowKeyList, results, allowNull);

    logger.info(“got {} rows from table {} with {} rowkeys”, rsList.size(), tableName, rowKeyList.size());

    return rsList;

}

 

private List buildGets(List rowKeyList, String cf, List 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 buildResult(List rowKeyList, Result[] results, boolean allowNull) {

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

小結

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

面向物件/函式式程式設計是非常強大的混合程式設計正規化。面向物件提供了貼近現實的自然的表達方法,為應用系統提供一個優秀的外部視角; 而函式程式設計則著重於內部結構最佳化,可以讓內部實現解耦得更加清晰。 兩者是相輔相成的,而非對立的。

看完本文有收穫?請轉發分享給更多人

關註「ImportNew」,提升Java技能

贊(0)

分享創造快樂