作者:京東物流 京東物流
背景
本文通過閱讀《Effective Java》、《Clean Code》、《京東JAVA代碼規(guī)范》等代碼質量書籍,結合團隊日常代碼實踐案例進行整理,拋磚引玉、分享一些在編寫高質量代碼方面的見解和經(jīng)驗。這些書籍提供了豐富的理論知識,而團隊的實際案例則展示了這些原則在實際開發(fā)中的應用。希望通過這篇文章,能夠幫助大家更好地理解和運用這些編程最佳實踐,提高代碼質量和開發(fā)效率。
什么是一個好的方法
在 Java 中,方法是類的一部分,定義了類的行為。方法通常包含方法頭和方法體。方法頭包括訪問修飾符、返回類型、方法名和參數(shù)列表,而方法體包含實現(xiàn)方法功能的代碼。
方法的基本結構 [訪問修飾符] [返回類型] [方法名]([參數(shù)列表]) { // 方法體 // 實現(xiàn)方法功能的代碼 }
如果一個方法在滿足業(yè)務需求本身的基礎上,職責單一,清晰明了,重點是團隊其他成員可以簡單看懂及維護,這就是一個好的方法。如果只有自己看得懂,其他人看不太懂,則不是一個好的方法。具體原則細節(jié)從以下【入?yún)ⅰ俊痉椒w】【出參】維度詳細描述
一、入?yún)?/h2>
1)入?yún)⒉灰?/h2>
理想情況下,方法的參數(shù)應盡量少。最佳情況是沒有參數(shù),其次是一個參數(shù),再次是兩個或三個參數(shù),盡量避免超過四個參數(shù)。參數(shù)越多,方法通常越復雜。從測試的角度來看,編寫各種參數(shù)組合的單元測試場景也會變得復雜。
設定四個或更少的參數(shù),因為大多數(shù)程序員記不住更長的參數(shù)列表。同類型的參數(shù)尤其有害,如果不小心弄反了參數(shù)的順序,程序可以正常編譯和運行,但結果可能不正確,這極易導致錯誤。
如果方法確實需要多個參數(shù),這通常意味著這些參數(shù)應該封裝為一個類,通過創(chuàng)建參數(shù)對象來減少參數(shù)的數(shù)量。
?錯誤案例:重量/體積 同類型參數(shù)順序錯誤導致問題
// 錯誤的方法定義,參數(shù)過多且容易混淆
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");
?正確案例:在這個示例中,由于重量和體積的順序弄反,計算出來的運費會有誤。為了避免這種錯誤,可以將這些參數(shù)封裝成一個類:
public class ShippingDetails {
private double weight;
private double volume;
private double length;
private double width;
private double height;
private String destination;
// 構造方法、getter和setter省略
}
// 使用參數(shù)對象來簡化方法簽名
public void calculateShippingCost(ShippingDetails details) {
// 假設這里有計算運費的邏輯
}
通過將參數(shù)封裝成一個類,可以有效減少方法的參數(shù)數(shù)量,避免參數(shù)順序錯誤的問題,提高代碼的可讀性和可維護性。
?
2)謹慎使用可變參數(shù)
可變參數(shù)數(shù)量,它接受0個或者N個指定類型的參數(shù)??勺儏?shù)的原理是根據(jù)調用位置傳入的參數(shù)數(shù)量,先創(chuàng)建一個數(shù)組,然后將參數(shù)放入這個數(shù)組中,最后將數(shù)值傳遞給該方法。
?
注意:在對性能要求很高的情況下,使用可變參數(shù)要特別小心,每次調用可變參數(shù)方法都會導致一次數(shù)組的分配和初始化。
?錯誤案例:循環(huán)中調用可變參數(shù)方法
public class Logger {
// 可變參數(shù)方法
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
方法每次調用都會創(chuàng)建一個新的數(shù)組來保存可變參數(shù)messages
。在高頻調用的場景下,這種數(shù)組分配和初始化的開銷會顯著影響性能。
?
?優(yōu)化案例:避免可變參數(shù)帶來的性能開銷 我們使用了
List
來傳遞日志消息。雖然在每次調用時仍然會創(chuàng)建一個List
對象,但相比于每次創(chuàng)建一個數(shù)組,這種方式的性能開銷更小,特別是在高頻調用的場景下。
public class Logger {
// 使用List代替可變參數(shù)
public void log(String level, List 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)));
}
?進一步優(yōu)化:使用StringBuilder直接拼接 在這種情況下,我們完全避免了數(shù)組或集合的創(chuàng)建,直接通過
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));
}
?
如果無法承受上面的性能開銷,但又需要可變參數(shù)的便利性,可以有一種兼容的做法,假設方法95%的調用參數(shù)不超過3個,那么我們可以聲明該方法的5個重載版本,分別包含(0,1,2,3)個參數(shù)和一個(3,可變參數(shù)),這樣只有最后一個方法才需要付出創(chuàng)建數(shù)組的開銷,而這只占用5%的調用。
?案例:org.slf4j.Logger 每個日志級別都有多個重載的方法,支持不同數(shù)量的參數(shù),通過這些方法,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)校驗參數(shù)的有效性
大部分方法都會對入?yún)⒌闹涤幸欢ㄏ拗?,比如String字符串長度,類型轉換,對象不能為null,訂單運單唯一性,批量接口List個數(shù)限制等。首先我們應該在API中詳細描述入?yún)⒌母鞣N限制條件,并且在方法體的入口進行校驗檢查,以強制實施這些限制。
對參數(shù)的檢查,原則是盡早檢查,否則整個鏈路被檢測的可能性降低,并且一旦檢測到,定位起源頭比較復雜。反過來思考,如果不在開頭進行檢查,則可能發(fā)生如下情況:方法在接下來鏈路處理過程中拋出錯誤的結果,但方法可能是正常返回,比如接口返回正常,但數(shù)據(jù)庫保持的時候,由于字段越界導致保存數(shù)據(jù)庫異常。
?
參數(shù)校驗 應該反應到 技術指標還是業(yè)務指標?
技術指標:個人理解入?yún)⒎欠ú粦擉w現(xiàn)到UMP技術可用率指標,因為這是API正常的一種體現(xiàn),如果入?yún)⒎欠ú缓侠恚祷厣嫌螌腻e誤碼CODE,本身的技術可用率正常。 業(yè)務指標:但方法對應的業(yè)務指標可以反映入?yún)⒎欠ǖ那闆r。例如,可以記錄非法入?yún)⒌拇螖?shù),以便分析和改進整個鏈路的業(yè)務邏輯。
?
?案例:鏈路校驗一致 比如某個入?yún)ⅲ瑥纳嫌蔚秸麄€鏈路下游,包括方法內部鏈路,最終到數(shù)據(jù)庫存儲,校驗規(guī)則是一致的。在下面這個例子中,
userName
的長度限制在方法入口和數(shù)據(jù)庫存儲過程中保持一致,確保鏈路校驗一致。
public class UserService {
// 用戶信息保存方法
public void saveUser(String userName) {
// 參數(shù)校驗
if (userName == null || userName.length() > 20) {
throw new IllegalArgumentException("User name cannot be null and must be less than 20 characters");
}
// 假設數(shù)據(jù)庫字段長度限制為 20
saveToDatabase(userName);
}
private void saveToDatabase(String userName) {
// 數(shù)據(jù)庫保存邏輯
// ...
}
}
?錯誤案例:鏈路校驗規(guī)則不一致 零售C端/B端用戶可以填寫20個字符串,整個鏈路校驗也是20,但底層數(shù)據(jù)庫是varchar(10)
// 假設數(shù)據(jù)庫字段長度限制為 10
private void saveToDatabase(String userName) {
// 數(shù)據(jù)庫保存邏輯
// ...
}
探討:鏈路重復校驗
比如物流鏈路運單合法性校驗,N個系統(tǒng)都進行校驗是否有必要?是否應該只在入口處校驗,其他鏈路保持信任機制?
?
二、方法體
1)方法要短小
方法的第一規(guī)則是短小,正如行業(yè)很多代碼規(guī)約,比如阿里規(guī)約方法總行數(shù)不超過80行,京東代碼規(guī)范中方法體的行數(shù)不能多于70行,否則降低編碼效率,不方便閱讀和理解。
其實個人理解不用太關注多少行,核心是方法的職責要單一,分清楚方法主干和分支,,看方法里的代碼是否還可以再抽取一個方法,分清代碼個性和共性,把共性的代碼抽取方法,用于復用,讓方法主干更清晰。
?
2)無副作用
在Java 編程語言中,術語“副作用”(side effects) 指的是一個函數(shù)或表達式在計算結果以外對程序狀態(tài)(如修改全局變量、改變輸入?yún)?shù)的值、進行I/O 操作等)產(chǎn)生的影響。
?
?副作用案例: 如下
filterBusinessType
方法的主要作用是返回一個業(yè)務類型int
類型的值,但它也修改了傳入的response
對象的A值作為一個副作用。在外面鏈路使用了A屬性值做邏輯判斷 副作用問題:在filterBusinessType方法中
如果是在response之前return了數(shù)據(jù),從方法角度看不出問題,但整個鏈路會出現(xiàn)問題。
public int filterBusinessType( Request request,Response response) {
if(...){
return ...
}
boolean flag = isXXX(request, response);
}
正如上面說的方法職責單一,只做一件事,但副作用就是一個謊言,方法還會做其他隱藏起來的事情,我們需要理解副作用的存在,并采取合適的策略來管理和控制它們。
如何規(guī)避這種現(xiàn)象
為了避免這種情況,可以采用以下幾種策略:
1.分離關注點: 可以將獲取業(yè)務類型和響應設置分離成兩個不同的方法。這樣,調用者就可以清晰地看到每個方法的職責。
public int filterBusinessType(String logPrefix,Request request){
// 過濾邏輯...
int businessType=...;
return businessType;
}
public void setResponseData(int filterResult,Response response){
// 根據(jù)過濾結果設置響應數(shù)據(jù)...
response.setFilteredData(...);
}
1.返回復合對象(上下文context): 如果業(yè)務類型結果和響應數(shù)據(jù)是緊密相關的,可以考慮創(chuàng)建一個包含這兩個信息的復合對象,并將其作為方法的返回值。
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等)
不要在條件判斷中執(zhí)行復雜的語句,將復雜邏輯判斷的結果賦值給一個有意義的布爾變量,以提高可讀性。團隊中也存在很多if語句內的邏輯相當復雜,閱讀者需要分析條件表達式的最終結果,才能明確什么樣的條件執(zhí)行什么樣的語句。復雜邏輯表達式,與、或、取反混合運算,甚至各種方法縱深調用,理解成本非常高。如果賦值一個非常好理解的布爾變量名字,則是件令人爽心悅目的事情
?
?錯誤案例: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;
}
}
?案例:使用常規(guī)控制結構替代異常處理
// 使用常規(guī)控制結構來處理正常流程
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違背了異常的本意,是一種不好的實踐。它不僅違背了異常處理的本意,還可能導致潛在的問題未被發(fā)現(xiàn)和解決。
?錯誤案例
try {
// 可能拋出IOException
throw new IOException("File not found");
} catch (IOException e) {
// 空的catch塊,忽略異常
}
4.3)異常封裝
對于業(yè)務層面的異常,應當進行適當?shù)姆庋b,定義統(tǒng)一的異常模型。避免直接將底層異常暴露給上層模塊,以保持業(yè)務邏輯的清晰性。比如DependencyFailureException:表示服務端依賴的其他服務出現(xiàn)錯誤,服務端是不可用的,可以嘗試重試,類比HTTP的5XX響應狀態(tài)碼。InternalFailureException:表示服務端自身出現(xiàn)錯誤,服務端是不可用的,可以嘗試重試,類比HTTP的5XX響應狀態(tài)碼。
4.4)異常轉換
1.Web 層絕不應該繼續(xù)往上拋異常,因為已經(jīng)處于頂層,無繼續(xù)處理異常的方式,如果意識到這個異常將導致頁面無法正常渲染,那么就應該直接跳轉到友好錯誤頁面,加上友好的錯誤提示信息。
2.開放接口層不能直接拋異常,應該將異常處理成code錯誤碼和錯誤信息message方式返回。其中錯誤碼應該能夠快速識別錯誤的來源,便于團隊成員快速定位問題。同時,錯誤碼應易于比對,有助于團隊對錯誤原因達成共識。其中錯誤編碼可參考HTTP協(xié)議的響應狀態(tài)碼:
?2XX(成功響應):表示操作被成功接收并處理。例如,200表示請求成功。
?4XX(客戶端錯誤):表示請求包含語法錯誤或無法完成請求。例如,404表示請求的資源(網(wǎng)頁等)不存在。
?5XX(服務端錯誤):表示服務器在處理請求的過程中發(fā)生了錯誤。例如,500表示服務器內部錯誤,無法完成請求。
5)日志
5.1)日志三字經(jīng):準、懂、少
準:日志打印一定要準確,該打的地方打,不該打的地方不打。如何確定什么地方該打,原則之一看上線后日志是否可以覆蓋方法的所有業(yè)務場景
懂:打印日志不只是給你自己看的,更是給團隊其他人看的,所以一定要打印的讓其他人也能看懂,盡量用一些通俗易懂的文字描述讓團隊能看懂
少:少即是多,日志太多第一影響性能,第二存儲成本,第三影響排查
?
5.2)日志注意事項
1.日志必須有traceId,可追蹤唯一性
2.日志打印建議打中文結合代碼英文字段方法屬性等,確保日志內容清晰、易于理解和分析,否則看完日志還得去看代碼
3.對外API方法出入?yún)⒈仨毚蛴?/p>
4.調用其他團隊API(JSF接口、中間件Redis等)同理,必須打印出入?yún)?/p>
5.異常信息要打印
6.不用打重復日志,比如在DAO層,由于可能會遇到多種類型的異常,DAO層不需要打印日志。這是因為在Manager或Service層,異常會被再次捕獲并記錄到日志文件中。
7.在 Service 層出現(xiàn)異常時,必須記錄出錯日志到磁盤,其中日志記錄應該遵循一定的規(guī)范,包括錯誤碼、異常信息和必要的上下文信息。日志內容應該清晰明了,相當于保護案發(fā)現(xiàn)場。
?案例:團隊日志我一直想治理,其中2個痛點:第一個是打印的太多,第二個是很多日志只有當事人能看懂,其他成員看不懂
?
6)詳細的注釋
詳細的代碼注釋在方法中至關重要,原因如下:
1.業(yè)務迭代:隨著業(yè)務的不斷迭代,許多方法的意圖變得難以理解。
2.有坑的代碼:團隊中存在非常規(guī)、有坑的代碼,增加了維護的難度。
3.人員變更:團隊成員的變動使得代碼的可讀性和可維護性變得更加重要。
方法注釋的要點
1.描述方法和客戶端之間的約定:注釋應詳細描述方法的功能和其與調用方之間的約定,即方法應該完成什么任務。
2.列出前置條件:注釋應列出所有調用該方法前必須滿足的條件。這可以幫助調用者理解在什么情況下可以安全地調用該方法。
3.列出后置條件:注釋應明確調用方法后哪些條件肯定會成立。這有助于調用者了解調用方法后的預期結果和狀態(tài)變化。
4.描述副作用:如果方法有任何副作用,如啟動后臺線程或修改入?yún)ο蟮哪硞€值,這些都應該在注釋中詳細說明。這可以幫助調用者預見和處理可能的影響。
public int filterBusinessType( Request request,Response response) {
/** * 切記:return必須在下面這行代碼(isXXX方法)后面,因為外面會使用response.A()來判斷邏輯
* 你可以理解本filterBusinessType方法會返回業(yè)務類型,同時如果isXXX方法會修改response.setA()屬性
*/
boolean flag = isXXX(request, response);
if(...){
return ...
}
}
對外API文檔
對于對外的API文檔,注釋應詳細說明每個字段的條件,確保調用方能夠無歧義地理解API的使用。關于API文檔的細節(jié),在此不做詳細討論,但同樣需要強調清晰和詳細的重要性。
?
通過詳細的注釋,能夠提高代碼的可讀性和可維護性,減少因業(yè)務迭代、歷史代碼和人員變更帶來的困擾。
?
?案例:針對時效內核,代碼比較抽象,添加的詳細注釋詳細,加一下case案例,方便新人可讀性
?注意點: 1、注釋會撒謊,代碼注釋的時間越久,就離其代碼的本意越遠,越來越變得錯誤,原因很簡單:程序員不能堅持維護注釋。 2、不準確的注釋比沒注釋壞的多,只有代碼能忠實的告訴你告訴你它做的事,那是唯一真正準確的信息來源
三、出參
1)返回空的集合或者數(shù)組,而不是null
如果方法返回null,而不是空的集合或者數(shù)組,那么幾乎所有使用這個方法的地方,都需要特殊判斷null,這樣很容易由于遺忘而出錯,
?
如本文里面信息不對請指正,如有更好的知識點,歡迎評論交流完善補充。謝謝!
?
相關文獻
1、Effective Java
2、Clean Code
3、京東JAVA代碼規(guī)范
審核編輯 黃宇
-
JAVA
+關注
關注
19文章
2952瀏覽量
104462 -
effective
+關注
關注
0文章
4瀏覽量
6711
發(fā)布評論請先 登錄
相關推薦
評論