Java方法設計原則與實踐:從Effective Java到團隊案例

京东云开发者發表於2024-11-01

作者:京東物流 京東物流

背景

本文透過閱讀《Effective Java》、《Clean Code》、《京東JAVA程式碼規範》等程式碼質量書籍,結合團隊日常程式碼實踐案例進行整理,拋磚引玉、分享一些在編寫高質量程式碼方面的見解和經驗。這些書籍提供了豐富的理論知識,而團隊的實際案例則展示了這些原則在實際開發中的應用。希望透過這篇文章,能夠幫助大家更好地理解和運用這些程式設計最佳實踐,提高程式碼質量和開發效率。

什麼是一個好的方法

在 Java 中,方法是類的一部分,定義了類的行為。方法通常包含方法頭和方法體。方法頭包括訪問修飾符、返回型別、方法名和引數列表,而方法體包含實現方法功能的程式碼。

方法的基本結構 [訪問修飾符] [返回型別] 方法名 { // 方法體 // 實現方法功能的程式碼 }

如果一個方法在滿足業務需求本身的基礎上,職責單一,清晰明瞭,重點是團隊其他成員可以簡單看懂及維護,這就是一個好的方法。 如果只有自己看得懂,其他人看不太懂,則不是一個好的方法。具體原則細節從以下【入參】【方法體】【出參】維度詳細描述

一、入參

1)入參不要太多

理想情況下,方法的引數應儘量少。最佳情況是沒有引數,其次是一個引數,再次是兩個或三個引數,儘量避免超過四個引數。引數越多,方法通常越複雜。從測試的角度來看,編寫各種引數組合的單元測試場景也會變得複雜。

設定四個或更少的引數,因為大多數程式設計師記不住更長的引數列表。同型別的引數尤其有害,如果不小心弄反了引數的順序,程式可以正常編譯和執行,但結果可能不正確,這極易導致錯誤。

如果方法確實需要多個引數,這通常意味著這些引數應該封裝為一個類,透過建立引數物件來減少引數的數量。

錯誤案例:重量/體積 同型別引數順序錯誤導致問題
// 錯誤的方法定義,引數過多且容易混淆
public void calculateShippingCost(double weight, double volume, double length,
                             double width, double height, String destination) {
    // 假設這裡有計算運費的邏輯
}

// 這裡將重量和體積的順序弄反了
service.calculateShippingCost(30.0, 50.0, 10.0, 5.0, 3.0, "New York");
// 實際上應該是:
service.calculateShippingCost(50.0, 30.0, 10.0, 5.0, 3.0, "New York");
✅正確案例:在這個示例中,由於重量和體積的順序弄反,計算出來的運費會有誤。為了避免這種錯誤,可以將這些引數封裝成一個類:
public class ShippingDetails {
    private double weight;
    private double volume;
    private double length;
    private double width;
    private double height;
    private String destination;
    // 構造方法、getter和setter省略
}
// 使用引數物件來簡化方法簽名
public void calculateShippingCost(ShippingDetails details) {
    // 假設這裡有計算運費的邏輯
}

透過將引數封裝成一個類,可以有效減少方法的引數數量,避免引數順序錯誤的問題,提高程式碼的可讀性和可維護性。



2)謹慎使用可變引數

可變引數數量,它接受0個或者N個指定型別的引數。可變引數的原理是根據呼叫位置傳入的引數數量,先建立一個陣列,然後將引數放入這個陣列中,最後將數值傳遞給該方法。



注意:在對效能要求很高的情況下,使用可變引數要特別小心,每次呼叫可變引數方法都會導致一次陣列的分配和初始化。

❌錯誤案例:迴圈中呼叫可變引數方法
public class Logger {
    // 可變引數方法
    public void log(String level, String... messages) {
        StringBuilder sb = new StringBuilder();
        sb.append(level).append(": ");
        for (String message : messages) {
            sb.append(message).append(" ");
        }
        System.out.println(sb.toString());
    }
}

// 模擬高頻呼叫
for (int i = 0; i < 1000000; i++) {
    logger.log("INFO", "Message", "number", String.valueOf(i));
}

在這個案例中,log方法每次呼叫都會建立一個新的陣列來儲存可變引數messages。在高頻呼叫的場景下,這種陣列分配和初始化的開銷會顯著影響效能。



✅最佳化案例:避免可變引數帶來的效能開銷 我們使用了List來傳遞日誌訊息。雖然在每次呼叫時仍然會建立一個List物件,但相比於每次建立一個陣列,這種方式的效能開銷更小,特別是在高頻呼叫的場景下。
public class Logger {
  // 使用List代替可變引數
    public void log(String level, List<String> messages) {
        StringBuilder sb = new StringBuilder();
        sb.append(level).append(": ");
        for (String message : messages) {
            sb.append(message).append(" ");
        }
        System.out.println(sb.toString());
    }
}

// 模擬高頻呼叫
for (int i = 0; i < 1000000; i++) {
    logger.log("INFO", List.of("Message", "number", String.valueOf(i)));
}
✅進一步最佳化:使用StringBuilder直接拼接 在這種情況下,我們完全避免了陣列或集合的建立,直接透過StringBuilder拼接字串,從而最大限度地減少了效能開銷。
public class Logger {
   // 使用StringBuilder直接拼接
    public void log(String level, String message1, String message2, String message3) {
        StringBuilder sb = new StringBuilder();
        sb.append(level).append(": ")
          .append(message1).append(" ")
          .append(message2).append(" ")
          .append(message3).append(" ");
        System.out.println(sb.toString());
    }
}

// 模擬高頻呼叫
for (int i = 0; i < 1000000; i++) {
    logger.log("INFO", "Message", "number", String.valueOf(i));
}



如果無法承受上面的效能開銷,但又需要可變引數的便利性,可以有一種相容的做法,假設方法95%的呼叫引數不超過3個,那麼我們可以宣告該方法的5個過載版本,分別包含(0,1,2,3)個引數和一個(3,可變引數),這樣只有最後一個方法才需要付出建立陣列的開銷,而這隻佔用5%的呼叫。

✅案例:org.slf4j.Logger 每個日誌級別都有多個過載的方法,支援不同數量的引數,透過這些方法,SLF4J 提供了靈活且高效的日誌記錄介面,可以適應各種不同的日誌記錄需求。
package org.slf4j;
public interface Logger {
    public boolean isInfoEnabled();
    public void info(String msg);
    public void info(String format, Object arg);
    public void info(String format, Object arg1, Object arg2);
    public void info(String format, Object... arguments);
    public void info(String msg, Throwable t);
}

3)校驗引數的有效性

大部分方法都會對入參的值有一定限制,比如String字串長度,型別轉換,物件不能為null,訂單運單唯一性,批次介面List個數限制等。首先我們應該在API中詳細描述入參的各種限制條件,並且在方法體的入口進行校驗檢查,以強制實施這些限制。

對引數的檢查,原則是儘早檢查,否則整個鏈路被檢測的可能性降低,並且一旦檢測到,定位起源頭比較複雜。反過來思考,如果不在開頭進行檢查,則可能發生如下情況:方法在接下來鏈路處理過程中丟擲錯誤的結果,但方法可能是正常返回,比如介面返回正常,但資料庫保持的時候,由於欄位越界導致儲存資料庫異常。



引數校驗 應該反應到 技術指標還是業務指標?

技術指標:個人理解入參非法不應該體現到UMP技術可用率指標,因為這是API正常的一種體現,如果入參非法不合理,返回上游對應的錯誤碼CODE,本身的技術可用率正常。 業務指標:但方法對應的業務指標可以反映入參非法的情況。例如,可以記錄非法入參的次數,以便分析和改進整個鏈路的業務邏輯。



✅案例:鏈路校驗一致 比如某個入參,從上游到整個鏈路下游,包括方法內部鏈路,最終到資料庫儲存,校驗規則是一致的。在下面這個例子中,userName的長度限制在方法入口和資料庫儲存過程中保持一致,確保鏈路校驗一致。
public class UserService {

    // 使用者資訊儲存方法
    public void saveUser(String userName) {
        // 引數校驗
        if (userName == null || userName.length() > 20) {
            throw new IllegalArgumentException("User name cannot be null and must be less than 20 characters");
        }

        // 假設資料庫欄位長度限制為 20
        saveToDatabase(userName);
    }

    private void saveToDatabase(String userName) {
        // 資料庫儲存邏輯
        // ...
    }
}
❌錯誤案例:鏈路校驗規則不一致 零售C端/B端使用者可以填寫20個字串,整個鏈路校驗也是20,但底層資料庫是varchar(10)
   // 假設資料庫欄位長度限制為 10
    private void saveToDatabase(String userName) {
        // 資料庫儲存邏輯
        // ...
    }

探討:鏈路重複校驗

比如物流鏈路運單合法性校驗,N個系統都進行校驗是否有必要?是否應該只在入口處校驗,其他鏈路保持信任機制?



二、方法體

1)方法要短小

方法的第一規則是短小,正如行業很多程式碼規約,比如阿里規約方法總行數不超過80行,京東程式碼規範中方法體的行數不能多於70行,否則降低編碼效率,不方便閱讀和理解。

其實個人理解不用太關注多少行,核心是方法的職責要單一,分清楚方法主幹和分支,,看方法裡的程式碼是否還可以再抽取一個方法,分清程式碼個性和共性,把共性的程式碼抽取方法,用於複用,讓方法主幹更清晰。



2)無副作用

在Java 程式語言中,術語“副作用”(side effects) 指的是一個函式或表示式在計算結果以外對程式狀態(如修改全域性變數、改變輸入引數的值、進行I/O 操作等)產生的影響。



副作用案例: 如下filterBusinessType方法的主要作用是返回一個業務型別int型別的值,但它也修改了傳入的response物件的A值作為一個副作用。在外面鏈路使用了A屬性值做邏輯判斷 副作用問題:在filterBusinessType方法中如果是在response之前return了資料,從方法角度看不出問題,但整個鏈路會出現問題。
public int filterBusinessType( Request request,Response response) {
 if(...){
  return ... 
  }
   boolean flag = isXXX(request, response); 
} 

正如上面說的方法職責單一,只做一件事,但副作用就是一個謊言,方法還會做其他隱藏起來的事情,我們需要理解副作用的存在,並採取合適的策略來管理和控制它們。

如何規避這種現象

為了避免這種情況,可以採用以下幾種策略:

1.分離關注點: 可以將獲取業務型別和響應設定分離成兩個不同的方法。這樣,呼叫者就可以清晰地看到每個方法的職責。

public int filterBusinessType(String logPrefix,Request request){
    // 過濾邏輯...
    int businessType=...;
    return businessType;
}
public void setResponseData(int filterResult,Response response){
    // 根據過濾結果設定響應資料...
    response.setFilteredData(...);
}

1.返回複合物件(上下文context) : 如果業務型別結果和響應資料是緊密相關的,可以考慮建立一個包含這兩個資訊的複合物件,並將其作為方法的返回值。

public FilterResultAndResponse filterBusinessType(String logPrefix,Request request){
    // 過濾邏輯...
    int result=...;
    Response response=new Response();
    response.setFilteredData(...);
    return new FilterResultAndResponse(result, response);
}

class FilterResultAndResponse{
    private int filterResult;
    private Response response;
    
    public FilterResultAndResponse(int filterResult,Response response){
        this.filterResult = filterResult;
        this.response = response;
    }
    
    // Getters and setters for filterResult and response}

3)控制語句(if/else/while/for等)

不要在條件判斷中執行復雜的語句,將複雜邏輯判斷的結果賦值給一個有意義的布林變數,以提高可讀性。團隊中也存在很多if語句內的邏輯相當複雜,閱讀者需要分析條件表示式的最終結果,才能明確什麼樣的條件執行什麼樣的語句。複雜邏輯表示式,與、或、取反混合運算,甚至各種方法縱深呼叫,理解成本非常高。如果賦值一個非常好理解的布林變數名字,則是件令人爽心悅目的事情



錯誤案例:if/else if語句中條件邏輯複雜,並且還存在!取反混合運算,導致這段程式碼理解成本比較高
boolean flagA = isKaWhiteFlag(logPrefix, request);
boolean flagB = PlatformTypeEnum.JD_STATION.getValue() == request.getPlatformType();
boolean flagC = KaPromiseUccSwitch.isPopJDDeliverySwitch(request.getDict(),request.getStoreId()) 
                           && (PlatformTypeEnum.JD_STATION.getValue() == request.getPlatformType())
                           && (DeliveryTypeEnum.JD_DELIVERY.getType() == request.getDeliveryType());
if (!flagC && flagA) {
......
}else if (!flagB && !flagC && 
          StringUtils.isNotBlank(request.getProductCode()) 
         && kaPromiseSwitch.isKaStoreRouterDs(logPrefix.getLogPrefix(), request.getDict(), request.getStoreId(), request.getCalculateTime(),request.getDeptNo())){
......
}else{
......
}

4)異常

4.1)異常應該僅用於異常的情況,不應該用於普通的控制流程

案例:不當使用異常處理控制流程
   // 使用異常處理來控制流程
    public static int parseNumber(String number) {
        try {
            return Integer.parseInt(number);
        } catch (NumberFormatException e) {
            throw e;
        }
    }
✅案例:使用常規控制結構替代異常處理
  // 使用常規控制結構來處理正常流程
    public static boolean isNumeric(String str) {
        if (str == null) {
            return false;
        }
        try {
            Integer.parseInt(str);
            return true;
        } catch (NumberFormatException e) {
            return false;
        }
    }

4.2)不要忽略異常

很多程式碼都違法了這一條原則,所以本文值得再強調。 當方法會丟擲一個異常時,就是想要告訴你一些重要資訊,所以不要忽略它。忽略它很簡單,catch住,然後裡面什麼也不做。異常就是強制我們要處理的,空的catch違背了異常的本意,是一種不好的實踐。它不僅違背了異常處理的本意,還可能導致潛在的問題未被發現和解決。

錯誤案例
 try {
       
        // 可能丟擲IOException
        throw new IOException("File not found");
    } catch (IOException e) {
        // 空的catch塊,忽略異常
    }

4.3)異常封裝

對於業務層面的異常,應當進行適當的封裝,定義統一的異常模型。避免直接將底層異常暴露給上層模組,以保持業務邏輯的清晰性。比如DependencyFailureException:表示服務端依賴的其他服務出現錯誤,服務端是不可用的,可以嘗試重試,類比HTTP的5XX響應狀態碼。InternalFailureException:表示服務端自身出現錯誤,服務端是不可用的,可以嘗試重試,類比HTTP的5XX響應狀態碼。

4.4)異常轉換

1.Web 層絕不應該繼續往上拋異常,因為已經處於頂層,無繼續處理異常的方式,如果意識到這個異常將導致頁面無法正常渲染,那麼就應該直接跳轉到友好錯誤頁面,加上友好的錯誤提示資訊。

2.開放介面層不能直接拋異常,應該將異常處理成code錯誤碼和錯誤資訊message方式返回。其中錯誤碼應該能夠快速識別錯誤的來源,便於團隊成員快速定位問題。同時,錯誤碼應易於比對,有助於團隊對錯誤原因達成共識。其中錯誤編碼可參考HTTP協議的響應狀態碼:

•2XX(成功響應):表示操作被成功接收並處理。例如,200表示請求成功。

•4XX(客戶端錯誤):表示請求包含語法錯誤或無法完成請求。例如,404表示請求的資源(網頁等)不存在。

•5XX(服務端錯誤):表示伺服器在處理請求的過程中發生了錯誤。例如,500表示伺服器內部錯誤,無法完成請求。

5)日誌

5.1)日誌三字經:準、懂、少

準: 日誌列印一定要準確,該打的地方打,不該打的地方不打。如何確定什麼地方該打,原則之一看上線後日志是否可以覆蓋方法的所有業務場景

懂: 列印日誌不只是給你自己看的,更是給團隊其他人看的,所以一定要列印的讓其他人也能看懂,儘量用一些通俗易懂的文字描述讓團隊能看懂

少: 少即是多,日誌太多第一影響效能,第二儲存成本,第三影響排查



5.2)日誌注意事項

1.日誌必須有traceId,可追蹤唯一性

2.日誌列印建議打中文結合程式碼英文欄位方法屬性等,確保日誌內容清晰、易於理解和分析,否則看完日誌還得去看程式碼

3.對外API方法出入參必須列印

4.呼叫其他團隊API(JSF介面、中介軟體Redis等)同理,必須列印出入參

5.異常資訊要列印

6.不用打重複日誌,比如在DAO層,由於可能會遇到多種型別的異常,DAO層不需要列印日誌。這是因為在Manager或Service層,異常會被再次捕獲並記錄到日誌檔案中。

7.在 Service 層出現異常時,必須記錄出錯日誌到磁碟,其中日誌記錄應該遵循一定的規範,包括錯誤碼、異常資訊和必要的上下文資訊。日誌內容應該清晰明瞭,相當於保護案發現場。

案例:團隊日誌我一直想治理,其中2個痛點:第一個是列印的太多,第二個是很多日誌只有當事人能看懂,其他成員看不懂



6)詳細的註釋

詳細的程式碼註釋在方法中至關重要,原因如下:

1.業務迭代:隨著業務的不斷迭代,許多方法的意圖變得難以理解。

2.有坑的程式碼:團隊中存在非常規、有坑的程式碼,增加了維護的難度。

3.人員變更:團隊成員的變動使得程式碼的可讀性和可維護性變得更加重要。

方法註釋的要點

1.描述方法和客戶端之間的約定:註釋應詳細描述方法的功能和其與呼叫方之間的約定,即方法應該完成什麼任務。

2.列出前置條件:註釋應列出所有呼叫該方法前必須滿足的條件。這可以幫助呼叫者理解在什麼情況下可以安全地呼叫該方法。

3.列出後置條件:註釋應明確呼叫方法後哪些條件肯定會成立。這有助於呼叫者瞭解呼叫方法後的預期結果和狀態變化。

4.描述副作用:如果方法有任何副作用,如啟動後臺執行緒或修改入參物件的某個值,這些都應該在註釋中詳細說明。這可以幫助呼叫者預見和處理可能的影響。

public int filterBusinessType( Request request,Response response) {
 /** * 切記:return必須在下面這行程式碼(isXXX方法)後面,因為外面會使用response.A()來判斷邏輯 
     * 你可以理解本filterBusinessType方法會返回業務型別,同時如果isXXX方法會修改response.setA()屬性 
 */ 
 boolean flag = isXXX(request, response); 
 if(...){
  return ... 
 } 
 } 

對外API文件

對於對外的API文件,註釋應詳細說明每個欄位的條件,確保呼叫方能夠無歧義地理解API的使用。關於API文件的細節,在此不做詳細討論,但同樣需要強調清晰和詳細的重要性。



透過詳細的註釋,能夠提高程式碼的可讀性和可維護性,減少因業務迭代、歷史程式碼和人員變更帶來的困擾。



✅案例:針對時效核心,程式碼比較抽象,新增的詳細註釋詳細,加一下case案例,方便新人可讀性

❎注意點: 1、註釋會撒謊,程式碼註釋的時間越久,就離其程式碼的本意越遠,越來越變得錯誤,原因很簡單:程式設計師不能堅持維護註釋。 2、不準確的註釋比沒註釋壞的多,只有程式碼能忠實的告訴你告訴你它做的事,那是唯一真正準確的資訊來源

三、出參

1)返回空的集合或者陣列,而不是null

如果方法返回null,而不是空的集合或者陣列,那麼幾乎所有使用這個方法的地方,都需要特殊判斷null,這樣很容易由於遺忘而出錯,




如本文裡面資訊不對請指正,如有更好的知識點,歡迎評論交流完善補充。謝謝!




相關文獻

1、Effective Java

2、Clean Code

3、京東JAVA程式碼規範

相關文章