我在大廠做 CR——再談如何優雅修改程式碼

木宛城主發表於2024-10-07

書接上回為什麼需要依賴注入再做下擴充套件

上文談到:“基於抽象介面程式設計確實是最佳實踐:把易於變動的功能點透過定義抽象介面的形式暴露出來,不同的實現做到隔離和擴充套件,這體現了開閉原則”

public class Foo {
  private Bar bar ;
  @Inject
  public Foo(Bar bar) {
    this.bar = bar;
  }
  public String doSomething(int key) {
    //Bar#getResult 體驗了程式碼的複雜性,透過注入不同的 Bar 實現物件,做到功能點的隔離和擴充套件
    return bar.getResult(key);
  }
}

但在真實專案裡,往往是多人協作一起開發,一些歷史原因導致某些程式碼片段的實現往往“千奇百怪”,既不能很好的單側覆蓋,同時也充斥著違反了開閉原則的“程式碼壞味道”;

而此時的你,作為“被選中的人”,需要對其功能迭代;

或許經過你的評估後,可以去大刀闊斧的架構演進,這是點讚的;

但有時也要全域性 ROI 去評估大刀闊斧重構收益是否足夠大,有時候我們只能妥協(trade-off)。即:如何在緊張的交付週期內做到比較好的重構,不讓程式碼繼續腐化;

所以這次繼續介紹兩種修改程式碼的藝術:方法新增和方法覆蓋

策略 1:方法新增

透過新增方法來隔離舊邏輯,即:在舊方法裡橫切“縫隙”,注入新的業務邏輯被呼叫;

拿之前的 Case 舉例,一個歷史老方法,需要對返回的資料集合過濾掉空物件:

public class Foo {
   private Bar bar;
   public Foo() {
       bar = new Bar();
   }
   public List<Data> doSomething(int key) {
       //依賴三方服務,RPC 呼叫結果集
       List<Data> result = bar.getResult(key);
       //過濾掉空物件
       return result.stream().filter(Objects::nonNull).collect(Collectors.toList());
   }
}

此處邏輯很簡單,使用了Java Lambda 表示式做了過濾,但這樣的寫法無疑雪上加霜:確實原先方法已經很 Low 了,也無法單側。本次只是在最後加了一段簡單的邏輯。已經駕輕就熟了,可能不少人都會這樣搞;

但作為好的程式設計師,眼前現狀確實我們只能妥協,但後續的每一行程式碼,需要做到保質保量,努力做到不影響原有業務邏輯下做到可測試;

“方法新增”:透過新增方法 getDataIfNotNull 來隔離舊邏輯:

public List<Data> doSomething(int key) {
       //依賴三方服務,RPC 呼叫結果集
       List<Data> result = bar.getResult(key);
       return getDataIfNotNull(result);
}

如下 getDataIfNotNull 作為新增方法,很容易對其進行獨立測試,同時原有的方法 doSomething 也沒有繼續腐化

public List<Data> getDataIfNotNull(List<Data> result) {
   return result.stream().filter(Objects::nonNull).collect(Collectors.toList());
}

可以看到優點很明顯:新老程式碼清晰隔離;當然為了更加職責分明,使用新增類隔離會更好;

策略 2:方法覆蓋

將待修改的方法重新命名,並建立一個新方法和原方法名和簽名一致,同時在新方法中呼叫重新命名後的原方法;

假設有新需求:針對 doSomething 方法做一個訊息通知操作,那麼“方法覆蓋”即:將原方法 doSomething 重新命名為 doSomethingAndFilterData,再建立一個與原方法同名的新方法 doSomething,最後在新方法中呼叫更名後的原方法:

//將原方法 doSomething 重新命名為 doSomethingAndFilterData
public List<Data> doSomethingAndFilterData(int key) {
       //依賴三方服務,RPC 呼叫結果集
       List<Data> result = bar.getResult(key);
       return getDataIfNotNull(result);
}

//建立一個與原方法同名的新方法 doSomething
public List<Data> doSomething(int key) {
       //呼叫舊方法
       List<Data> data = this.doSomethingAndFilterData(key);
       //呼叫新方法
       doNotifyMsg(data);
       return data;
}

//新的擴充套件方法符合隔離擴充套件,不影響舊方法,也支援單側覆蓋
public void doNotifyMsg(List<Data> data){
      //
}

方法覆蓋的另一種寫法:通常是再定義一個新的方法,然後在新的方法依次呼叫新老業務邏輯;

一般在架構演進的時候,用於切流新老邏輯;例如:基於客戶端版本,大於 3.10.x 的客戶端切流使用新的邏輯——我們建立一個新的方法呼叫新舊兩個方法。

//老的歷史程式碼,不做改造
public List<Data> doSomething(int key) {
       //依賴三方服務,RPC 呼叫結果集
       List<Data> result = bar.getResult(key);
       List<Data> data = getDataIfNotNull(result);
       return data;
}

//新建立一個方法,聚合呼叫新老邏輯
public List<Data> doSomethingWithNotifyMsg(int key) {
       List<Data> data = this.doSomething(key);
       //呼叫新方法
       doNotifyMsg(data);
       return data;
}

//新的擴充套件方法符合隔離擴充套件,不影響舊方法,也支援單側覆蓋
public void doNotifyMsg(List<Data> data){
       //
}

這樣的好處是顯然易見的,不針對舊方法做修改,同時在更高維度的“上層”切流:保證新功能正常迭代演進,老功能維持不變

boolean enableFunc=getClientVersion()>DEFAULT_CLIENT_VERSION;
if (enableFunc){
       return doSomethingWithNotifyMsg();
   } else {
       return doSomething();
}

可以看到“方法覆蓋”不管用何總方式實現,它不會在當前舊方法裡增加邏輯,而是透過使用新方法作為入口,這樣避免新老邏輯耦合在一起;

“方法覆蓋”可以再進階一步,使用獨立的類來隔離,也就是裝飾者模式。通常情況下原有的類已經非常複雜了,已經不想在它上做功能迭代了,考慮使用裝飾者來解耦:

class DecoratedFoo extends Foo{
   private Foo foo;
   public DecoratedFoo(Foo foo){
   }

   @Override
   public List<Data> doSomething(int key) {
       List<Data> data = super.doSomething(key);
       notifyMsg();
       return data;

   }
   private void notifyMsg(){

   }
}

相關文章