代碼審查者應該關注哪些方面?
代碼審查時應該關注以下方面:
- 設計:設計是否合理?
- 功能:是否滿足prd需求?是否滿足用戶流程交互合理性?
- 復雜度:代碼能更簡單嗎?將來其他開發人員能輕松理解并使用此代碼嗎?
- 測試:代碼是否具有正確且設計良好的單元測試?
- 命名:開發人員是否為變量、類、方法、包等選擇了明確的名稱?
- 注釋:注釋是否清晰有用?
- 風格:代碼是否遵守了代碼規范?
- 文檔:開發人員是否同時更新了相關文檔?
設計
審查中最重要的是 CL 的整體設計。CL 中各種代碼的交互是否有意義?現在是添加此功能的好時機嗎?
單一職責原則 (Single Responsibility Principle)
- 類中的代碼行數、函數或屬性過多,會影響代碼的可讀性和可維護性,我們就需要考慮對類進行拆分;
- 類依賴的其他類過多,或者依賴類的其他類過多,不符合高內聚、低耦合的設計思想,我們就需要考慮對類進行拆分;
- 私有方法過多,我們就要考慮能否將私有方法獨立到新的類中,設置為 public 方法,供更多的類使用,從而提高代碼的復用性;
- 比較難給類起一個合適名字,很難用一個業務名詞概括,或者只能用一些籠統的 Manager、Context 之類的詞語來命名,這就說明類的職責定義得可能不夠清晰;
- 類中大量的方法都是集中操作類中的某幾個屬性,那就可以考慮將這幾個屬性和對應的方法拆分出來。
違背SRP原則的案例
1.CreditSchemaAbilityImpl中函數、依賴過多,充斥著類轉換邏輯、訪問facade邏輯、工廠邏輯、私有方法過多、大量私有方法最終只是得到一個字段值等。
2.User類中大量方法操作的都是UserAddress成員變量對象,而這些方法散落在User類而不是委托UserAddress類。
開閉原則 (Open-Closed Principle)
添加一個新的功能,應該是通過在已有代碼基礎上擴展代碼(新增模塊、類、方法、屬性等),而非修改已有代碼(修改模塊、類、方法、屬性等)的方式來完成。關于定義,我們有兩點要注意。第一點是,開閉原則并不是說完全杜絕修改,而是以最小的修改代碼的代價來完成新功能的開發。第二點是,同樣的代碼改動,在粗代碼粒度下,可能被認定為“修改”;在細代碼粒度下,可能又被認定為“擴展”。
我們要時刻具備擴展意識、抽象意識、封裝意識。在寫代碼的時候,我們要多花點時間思考一下,這段代碼未來可能有哪些需求變更,如何設計代碼結構,事先留好擴展點,以便在未來需求變更的時候,在不改動代碼整體結構、做到最小代碼改動的情況下,將新的代碼靈活地插入到擴展點上。
最常用來提高代碼擴展性的方法有:多態、依賴注入、基于接口而非實現編程,以及大部分的設計模式(比如,裝飾、策略、模板、職責鏈、狀態)。
違背OCP原則的案例
public class Alert {
private AlertRule rule;
private Notification notification;
public Alert(AlertRule rule, Notification notification) {
this.rule = rule;
this.notification = notification;
}
public void check(String api, long requestCount, long errorCount, long durationOfSeconds) {
long tps = requestCount / durationOfSeconds;
if (tps > rule.getMatchedRule(api).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
}
}
上面這段代碼非常簡單,業務邏輯主要集中在 check() 函數中。當接口的 TPS 超過某個預先設置的最大值時,以及當接口請求出錯數大于某個最大允許值時,就會觸發告警,通知接口的相關負責人或者團隊。現在,如果我們需要添加一個功能,當每秒鐘接口超時請求個數,超過某個預先設置的最大閾值時,我們也要觸發告警發送通知。這個時候,我們該如何改動代碼呢?主要的改動有兩處:第一處是修改 check() 函數的入參,添加一個新的統計數據 timeoutCount,表示超時接口請求數;第二處是在 check() 函數中添加新的告警邏輯。具體的代碼改動如下所示:
public class Alert {
// ...省略AlertRule/Notification屬性和構造函數...
// 改動一:添加參數timeoutCount
public void check(String api, long requestCount, long errorCount, long timeoutCount, long durationOfSeconds) {
long tps = requestCount / durationOfSeconds;
if (tps > rule.getMatchedRule(api).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
// 改動二:添加接口超時處理邏輯
long timeoutTps = timeoutCount / durationOfSeconds;
if (timeoutTps > rule.getMatchedRule(api).getMaxTimeoutTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
}
}
這樣的代碼修改實際上存在挺多問題的。一方面,我們對接口進行了修改,這就意味著調用這個接口的代碼都要做相應的修改。另一方面,修改了 check() 函數,相應的單元測試都需要修改(關于單元測試的內容我們在重構那部分會詳細介紹)。
里氏替換原則 (Liskov Substitution Principle)
子類對象(object of subtype/derived class)能夠替換程序(program)中父類對象(object of base/parent class)出現的任何地方,并且保證原來程序的邏輯行為(behavior)不變及正確性不被破壞。
判斷子類的設計實現是否違背里式替換原則,一個小竅門,那就是拿父類的單元測試去驗證子類的代碼。如果某些單元測試運行失敗,就有可能說明,子類的設計實現沒有完全地遵守父類的約定,子類有可能違背了里式替換原則。
違背LSP原則的案例
子類違背父類聲明要實現的功能父類中提供的 sortOrdersByAmount() 訂單排序函數,是按照金額從小到大來給訂單排序的,而子類重寫這個 sortOrdersByAmount() 訂單排序函數之后,是按照創建日期來給訂單排序的。那子類的設計就違背里式替換原則。
子類違背父類對輸入、輸出、異常的約定在父類中,某個函數約定:運行出錯的時候返回 null;獲取數據為空的時候返回空集合(empty collection)。而子類重載函數之后,實現變了,運行出錯返回異常(exception),獲取不到數據返回 null。那子類的設計就違背里式替換原則。在父類中,某個函數約定,輸入數據可以是任意整數,但子類實現的時候,只允許輸入數據是正整數,負數就拋出,也就是說,子類對輸入的數據的校驗比父類更加嚴格,那子類的設計就違背了里式替換原則。在父類中,某個函數約定,只會拋出 ArgumentNullException 異常,那子類的設計實現中只允許拋出 ArgumentNullException 異常,任何其他異常的拋出,都會導致子類違背里式替換原則。
子類違背父類注釋中所羅列的任何特殊說明父類中定義的 withdraw() 提現函數的注釋是這么寫的:“用戶的提現金額不得超過賬戶余額……”,而子類重寫 withdraw() 函數之后,針對 VIP 賬號實現了透支提現的功能,也就是提現金額可以大于賬戶余額,那這個子類的設計也是不符合里式替換原則的。
依賴倒轉原則 (Dependence Inversion Principle)
- 高層模塊(high-level modules)不要依賴低層模塊(low-level)。高層模塊和低層模塊應該通過抽象(abstractions)來互相依賴。除此之外,抽象(abstractions)不要依賴具體實現細節(details),具體實現細節(details)依賴抽象(abstractions)。
違背DIP原則的案例
1.在sop流程中提供通用功能,拓展點卻依賴了具體實現環節中的類,關心了具體實現。
2.在某個類中依賴了某個接口具體實現類,而不是依賴接口。
3.沒有按照DI方式分層,單測困難。
4.沒有通過spring ioc容器控制反轉,直接在類中new成員變量,單測困難 , 依賴細節。
接口隔離原則 (Interface Segregation Principle)
客戶端不應該被強迫依賴它不需要的接口。其中的“客戶端”,可以理解為接口的調用者或者使用者。
違背ISP原則的案例
1.為了程序分層方便api將對數據庫的邏輯DELETE方法也暴露對外了,造成風險。
2.由于接口設計不佳,授信領域間接被強迫依賴了支用領域接口。
3.一個接口方法功能太多,只能兼容一些入參白跑了很多多余的邏輯。
迪米特法則(Law Of Demeter)
每個模塊(unit)只應該了解那些與它關系密切的模塊(units: only units “closely” related to the current unit)的有限知識(knowledge)。或者說,每個模塊只和自己的朋友“說話”(talk),不和陌生人“說話”(talk)。
不該有直接依賴關系的類之間,不要有依賴;有依賴關系的類之間,盡量只依賴必要的接口(也就是定義中的“有限知識”)。
違背LOD原則的案例
1.文件上傳接口FileUploadInterface依賴了OSS的Bucket對象,而不是定義抽象的文件上傳參數,文件上傳不一定要用OSS實現的。
2.還款實體為了得到某個字段,依賴了征信實體,只因為征信實體里面有這個字段,獲取方便,但是征信不屬于還款領域概念。
功能
背景了解
Code Reviewer 要知道改動的背景,本次改動代碼是為了做什么?不能做什么?會影響什么?
邏輯分析
Code Reviewer 根據需求背景,對代碼邏輯進行分析,是否滿足需求,是否有業務邏輯漏洞。
邊界檢查
Code Reviewer 檢查改動影響的功能模塊,是否有邊界問題,如果是refactor change,應當檢查兼容性,如果是feature change,需要檢查領域邊界是否合理。
異常監控
Code Reviewer 檢查日志處理、異常處理、監控埋點方式等代碼,是否存在缺陷、丟失。
非業務功能性檢查
Code Reviewer 檢查多線程、并發、代碼安全、性能等角度,是否存在漏洞,是否可以優化。
復雜度
圈復雜度
Code Reviewer 檢查代碼圈復雜度情況(if while分支過多、方法分支太多),圈復雜度在1-10為合理范圍。
改進方法
- 簡化、合并條件表達式
- 將條件判定提煉出獨立函數
- 將大函數拆成小函數
- 以明確函數取代參數
- 替換算法、策略模式
方法長度
代碼長短可以直接說明它復不復雜、閱讀成本高不高,長度度量往往是復雜度分析里最簡單直接的手段。此處,有效代碼長度 = 代碼行 - 空白行 - 注釋行。當前,集團95%的方法的有效代碼行數在42以內。
方法參數長度
方法參數過多會降低容錯性、模糊表達意圖,參數個數應當在3個以內,可以通過靜態創建、參數合并為Model等方式降低長度。
最高結構控制層數
花括號 { } 的最大嵌套層數。深度嵌套的代碼總是像俄羅斯套娃一樣晦澀難懂,可讀性和可維護性大打折扣。當前,集團95%的方法的最大嵌套層數在3以內。
設計復雜度
是否有over設計的情況,簡單的兩個分支,拓展性低的case,做成了策略模式之類的。
測試
case覆蓋率
Code Reviewer 檢查單測case覆蓋情況,是否覆蓋了主要業務流程,嚴格來說每個邏輯分支都需要覆蓋。
可測性
Code Reviewer 檢查代碼中是否有環境、外部、靜態類等依賴,如有,會影響單測隔離性,單測應該可以通過打樁隔絕任何依賴。
規范
單測代碼也需要規范,清晰易讀,重復性低為準。
命名、注釋、風格
代碼規范
文檔
文檔更新
Code Reviewer 提醒 developer 在二分庫、應用等升級之后更新文檔。
如何撰寫 Code Review 評論
總結
- 保持友善。
- 解釋你的推理。
- 在給出明確的指示與只指出問題并讓開發人員自己決定間做好平衡。
- 鼓勵開發人員簡化代碼或添加代碼注釋,而不僅僅是向你解釋復雜性。
糟糕的示例:“為什么這里你使用了線程,顯然并發并沒有帶來什么好處?”
好的示例:“這里的并發模型增加了系統的復雜性,但沒有任何實際的性能優勢,因為沒有性能優勢,最好是將這些代碼作為單線程處理而不是使用多線程。”
引用
- <設計模式之美> 王爭
- <Code Review Developer Guide> Google