004.設計原則與思想:設計原則

設計原則

一.理論一:對於單一職責原則,如何判定某個類的職責是否夠“單一”?

1. 如何理解單一職責原則(SRP)?
  1. 單一職責原則

一個類或者模塊只負責完成一個職責(或者功能)

  1. 一個類包含了兩個或者兩個以上業務不相干的功能,那我們就說它職責不夠單一,應該將它拆分成多個功能更加單一、粒度更細的類。

  2. 例子:

比如,一個類裏既包含訂單的一些操作,又包含用戶的一些操作。而訂單和用戶是兩個獨立的業務領域模型,我們將兩個不相干的功能放到同一個類中,那就違反了單一職責原則。爲了滿足單一職責原則,我們需要將這個類拆分成兩個粒度更細、功能更加單一的兩個類:訂單類和用戶類

  1. 不同的應用場景、不同階段的需求背景下,對同一個類的職責是否單一的判定,可能都是不一樣的。在某種應用場景或者當下的需求背景下,一個類的設計可能已經滿足單一職責原則了,但如果換個應用場景或着在未來的某個需求背景下,可能就不滿足了,需要繼續拆分成粒度更細的類

  2. 解決方法:實際上,在真正的軟件開發中,我們也沒必要過於未雨綢繆,過度設計。所以,我們可以先寫一個粗粒度的類,滿足業務需求。隨着業務的發展,如果粗粒度的類越來越龐大,代碼越來越多,這個時候,我們就可以將這個粗粒度的類,拆分成幾個更細粒度的類。這就是所謂的持續重構

  3. 下面這幾條判斷原則,比起很主觀地去思考類是否職責單一,要更有指導意義、更具有可執行性:

  1. 類中的代碼行數、函數或屬性過多,會影響代碼的可讀性和可維護性,我們就需要考慮對類進行拆分
  2. 類依賴的其他類過多,或者依賴類的其他類過多,不符合高內聚、低耦合的設計思想,我們就需要考慮對類進行拆分;
  3. 私有方法過多,我們就要考慮能否將私有方法獨立到新的類中,設置爲 public 方法,供更多的類使用,從而提高代碼的複用性;
  4. 比較難給類起一個合適名字,很難用一個業務名詞概括,或者只能用一些籠統的 Manager、Context 之類的詞語來命名,這就說明類的職責定義得可能不夠清晰;
  5. 類中大量的方法都是集中操作類中的某幾個屬性,比如,在 UserInfo 例子中,如果一半的方法都是在操作 address 信息,那就可以考慮將這幾個屬性和對應的方法拆分出來
  1. 如果拆分得過細,實際上會適得其反,反倒會降低內聚性,也會影響代碼的可維護性。

二. 如何做到“對擴展開放、修改關閉”?擴展和修改各指什麼?

  1. 如何理解“對擴展開放、修改關閉”?

開閉原則: 軟件實體(模塊、類、方法等)應該“對擴展開放、對修改關閉”。
添加一個新的功能應該是,在已有代碼基礎上擴展代碼(新增模塊、類、方法等),而非修改已有代碼(修改模塊、類、方法等)

  1. 例子:
    (1). api接口監控功能

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, "...");
    }
  }
}

(2). 現需要添加一個功能,當每秒鐘接口超時請求個數,超過某個預先設置的最大閾值時,我們也要觸發告警發送通知。這個時候,我們該如何改動代碼呢?主要的改動有兩處:第一處是修改 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, "...");
    }
  }
}

(3). 問題:一方面,我們對接口進行了修改,這就意味着調用這個接口的代碼都要做相應的修改。另一方面,修改了 check() 函數,相應的單元測試都需要修

(4). 上面的代碼改動是基於“修改”的方式來實現新功能的。如果我們遵循開閉原則,也就是“對擴展開放、對修改關閉”。那如何通過“擴展”的方式,來實現同樣的功能呢?

  1. 第一部分是將 check() 函數的多個入參封裝成 ApiStatInfo 類;
  2. 第二部分是引入 handler 的概念,將 if 判斷邏輯分散在各個 handler 中

(5). 具體實現


public class Alert {
  private List<AlertHandler> alertHandlers = new ArrayList<>();
  
  public void addAlertHandler(AlertHandler alertHandler) {
    this.alertHandlers.add(alertHandler);
  }

  public void check(ApiStatInfo apiStatInfo) {
    for (AlertHandler handler : alertHandlers) {
      handler.check(apiStatInfo);
    }
  }
}

public class ApiStatInfo {//省略constructor/getter/setter方法
  private String api;
  private long requestCount;
  private long errorCount;
  private long durationOfSeconds;
}

public abstract class AlertHandler {
  protected AlertRule rule;
  protected Notification notification;
  public AlertHandler(AlertRule rule, Notification notification) {
    this.rule = rule;
    this.notification = notification;
  }
  public abstract void check(ApiStatInfo apiStatInfo);
}

public class TpsAlertHandler extends AlertHandler {
  public TpsAlertHandler(AlertRule rule, Notification notification) {
    super(rule, notification);
  }

  @Override
  public void check(ApiStatInfo apiStatInfo) {
    long tps = apiStatInfo.getRequestCount()/ apiStatInfo.getDurationOfSeconds();
    if (tps > rule.getMatchedRule(apiStatInfo.getApi()).getMaxTps()) {
      notification.notify(NotificationEmergencyLevel.URGENCY, "...");
    }
  }
}

public class ErrorAlertHandler extends AlertHandler {
  public ErrorAlertHandler(AlertRule rule, Notification notification){
    super(rule, notification);
  }

  @Override
  public void check(ApiStatInfo apiStatInfo) {
    if (apiStatInfo.getErrorCount() > rule.getMatchedRule(apiStatInfo.getApi()).getMaxErrorCount()) {
      notification.notify(NotificationEmergencyLevel.SEVERE, "...");
    }
  }
}

(6). 使用:


public class ApplicationContext {
  private AlertRule alertRule;
  private Notification notification;
  private Alert alert;
  
  public void initializeBeans() {
    alertRule = new AlertRule(/*.省略參數.*/); //省略一些初始化代碼
    notification = new Notification(/*.省略參數.*/); //省略一些初始化代碼
    alert = new Alert();
    alert.addAlertHandler(new TpsAlertHandler(alertRule, notification));
    alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));
  }
  public Alert getAlert() { return alert; }

  // 餓漢式單例
  private static final ApplicationContext instance = new ApplicationContext();
  private ApplicationContext() {
    instance.initializeBeans();
  }
  public static ApplicationContext getInstance() {
    return instance;
  }
}

public class Demo {
  public static void main(String[] args) {
    ApiStatInfo apiStatInfo = new ApiStatInfo();
    // ...省略設置apiStatInfo數據值的代碼
    ApplicationContext.getInstance().getAlert().check(apiStatInfo);
  }
}
  1. 如何做到“對擴展開放、修改關閉”?

實際上,開閉原則講的就是代碼的擴展性問題,是判斷一段代碼是否易擴展的“金標準”。如果某段代碼在應對未來需求變化的時候,能夠做到“對擴展開放、對修改關閉”,那就說明這段代碼的擴展性比較好。所以,問如何才能做到“對擴展開放、對修改關閉”,也就粗略地等同於在問,如何才能寫出擴展性好的代碼

在講具體的方法論之前,我們先來看一些更加偏向頂層的指導思想。爲了儘量寫出擴展性好的代碼,我們要時刻具備擴展意識、抽象意識、封裝意識。這些“潛意識”可能比任何開發技巧都重要。

  1. 在寫代碼的時候後,我們要多花點時間往前多思考一下,這段代碼未來可能有哪些需求變更、如何設計代碼結構,事先留好擴展點,以便在未來需求變更的時候,不需要改動代碼整體結構、做到最小代碼改動的情況下,新的代碼能夠很靈活地插入到擴展點上,做到“對擴展開放、對修改關閉”

  2. 還有,在識別出代碼可變部分和不可變部分之後,我們要將可變部分封裝起來,隔離變化,提供抽象化的不可變接口,給上層系統使用。當具體的實現發生變化的時候,我們只需要基於相同的抽象接口,擴展一個新的實現,替換掉老的實現即可,上游系統的代碼幾乎不需要修改

  3. 如何在項目中靈活應用開閉原則?

  1. 寫出支持“對擴展開放、對修改關閉”的代碼的關鍵是預留擴展點
  2. 最合理的做法是,對於一些比較確定的、短期內可能就會擴展,或者需求改動對代碼結構影響比較大的情況,或者實現成本不高的擴展點,在編寫代碼的時候之後,我們就可以事先做些擴展性設計。但對於一些不確定未來是否要支持的需求,或者實現起來比較複雜的擴展點,我們可以等到有需求驅動的時候,再通過重構代碼的方式來支持擴展的需求
  3. Alert 告警的例子中,如果告警規則並不是很多、也不復雜,那 check() 函數中的 if 語句就不會很多,代碼邏輯也不復雜,代碼行數也不多,那最初的第一種代碼實現思路簡單易讀,就是比較合理的選擇。相反,如果告警規則很多、很複雜,check() 函數的 if 語句、代碼邏輯就會很多、很複雜,相應的代碼行數也會很多,可讀性、可維護性就會變差,那重構之後的第二種代碼實現思路就是更加合理的選擇了。總之,這裏沒有一個放之四海而皆準的參考標準,全憑實際的應用場景來決定
  1. 如何理解“對擴展開放、對修改關閉”?

添加一個新的功能,應該是通過在已有代碼基礎上擴展代碼(新增模塊、類、方法、屬性等),而非修改已有代碼(修改模塊、類、方法、屬性等)的方式來完成。關於定義,我們有兩點要注意。第一點是,開閉原則並不是說完全杜絕修改,而是以最小的修改代碼的代價來完成新功能的開發。第二點是,同樣的代碼改動,在粗代碼粒度下,可能被認定爲“修改”;在細代碼粒度下,可能又被認定爲“擴展”

三. 裏式替換(LSP)跟多態有何區別?哪些代碼違背了LSP?

  1. 概念:子類對象(object of subtype/derived class)能夠替換程序(program)中父類對象(object of base/parent class)出現的任何地方,並且保證原來程序的邏輯行爲(behavior)不變及正確性不被破壞。
  2. 例子:

public class Transporter {
  private HttpClient httpClient;
  
  public Transporter(HttpClient httpClient) {
    this.httpClient = httpClient;
  }

  public Response sendRequest(Request request) {
    // ...use httpClient to send request
  }
}

public class SecurityTransporter extends Transporter {
  private String appId;
  private String appToken;

  public SecurityTransporter(HttpClient httpClient, String appId, String appToken) {
    super(httpClient);
    this.appId = appId;
    this.appToken = appToken;
  }

  @Override
  public Response sendRequest(Request request) {
    if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) {
      request.addPayload("app-id", appId);
      request.addPayload("app-token", appToken);
    }
    return super.sendRequest(request);
  }
}

public class Demo {    
  public void demoFunction(Transporter transporter) {    
    Reuqest request = new Request();
    //...省略設置request中數據值的代碼...
    Response response = transporter.sendRequest(request);
    //...省略其他邏輯...
  }
}

// 裏式替換原則
Demo demo = new Demo();
demo.demofunction(new SecurityTransporter(/*省略參數*/););
  1. 區別:

雖然從定義描述和代碼實現上來看,多態和裏式替換有點類似,但它們關注的角度是不一樣的。多態是面向對象編程的一大特性,也是面向對象編程語言的一種語法。它是一種代碼實現的思路。而裏式替換是一種設計原則,是用來指導繼承關係中子類該如何設計的,子類的設計要保證在替換父類的時候,不改變原有程序的邏輯以及不破壞原有程序的正確性。

  1. 子類在設計的時候,要遵守父類的行爲約定(或者叫協議)。父類定義了函數的行爲約定,那子類可以改變函數的內部實現邏輯,但不能改變函數原有的行爲約定。

  2. 違背LSP—子類違背父類聲明要實現的功能

父類中提供的 sortOrdersByAmount() 訂單排序函數,是按照金額從小到大來給訂單排序的,而子類重寫這個 sortOrdersByAmount() 訂單排序函數之後,是按照創建日期來給訂單排序的。那子類的設計就違背裏式替換原則。

  1. 子類違背父類對輸入、輸出、異常的約定
  2. 子類違背父類註釋中所羅列的任何特殊說明

子類違背父類註釋中所羅列的任何特殊說明父類中定義的 withdraw() 提現函數的註釋是這麼寫的:“用戶的提現金額不得超過賬戶餘額……”,而子類重寫 withdraw() 函數之後,針對 VIP 賬號實現了透支提現的功能,也就是提現金額可以大於賬戶餘額,那這個子類的設計也是不符合裏式替換原則的。

四.接口隔離原則有哪三種應用?原則中的“接口”該如何理解?

  1. 接口隔離原則:客戶端不應該被強迫依賴它不需要的接口。其中的“客戶端”,可以理解爲接口的調用者或者使用者
  2. 把“接口”理解爲一組 API 接口集合

public interface UserService {
  boolean register(String cellphone, String password);
  boolean login(String cellphone, String password);
  UserInfo getUserInfoById(long id);
  UserInfo getUserInfoByCellphone(String cellphone);
}

public class UserServiceImpl implements UserService {
  //...
}

照接口隔離原則,調用者不應該強迫依賴它不需要的接口,將刪除接口單獨放到另外一個接口 RestrictedUserService 中,然後將 RestrictedUserService 只打包提供給後臺管理系統來使用


public interface UserService {
  boolean register(String cellphone, String password);
  boolean login(String cellphone, String password);
  UserInfo getUserInfoById(long id);
  UserInfo getUserInfoByCellphone(String cellphone);
}

public interface RestrictedUserService {
  boolean deleteUserByCellphone(String cellphone);
  boolean deleteUserById(long id);
}

public class UserServiceImpl implements UserService, RestrictedUserService {
  // ...省略實現代碼...
}

五. 理論五:控制反轉、依賴反轉、依賴注入,這三者有何區別和聯繫?

1. 控制反轉(IOC)
  1. 控制反轉:
  1. 框架提供了一個可擴展的代碼骨架,用來組裝對象、管理整個執行流程。程序員利用框架進行開發的時候,只需要往預留的擴展點上,添加跟自己業務相關的代碼,就可以利用框架來驅動整個程序流程的執行。
  2. 這裏的“控制”指的是對程序執行流程的控制,而“反轉”指的是在沒有使用框架之前,程序員自己控制整個程序的執行。在使用框架之後,整個程序的執行流程可以通過框架來控制。流程的控制權從程序員“反轉”到了框架
2. 依賴注入
  1. 不通過 new() 的方式在類內部創建依賴類對象,而是將依賴的類對象在外部創建好之後,通過構造函數、函數參數等方式傳遞(或注入)給類使用。
  2. 依賴注入 && 非依賴注入區別例子

// 非依賴注入實現方式
public class Notification {
  private MessageSender messageSender;
  
  public Notification() {
    this.messageSender = new MessageSender(); //此處有點像hardcode
  }
  
  public void sendMessage(String cellphone, String message) {
    //...省略校驗邏輯等...
    this.messageSender.send(cellphone, message);
  }
}

public class MessageSender {
  public void send(String cellphone, String message) {
    //....
  }
}
// 使用Notification
Notification notification = new Notification();

// 依賴注入的實現方式
public class Notification {
  private MessageSender messageSender;
  
  // 通過構造函數將messageSender傳遞進來
  public Notification(MessageSender messageSender) {
    this.messageSender = messageSender;
  }
  
  public void sendMessage(String cellphone, String message) {
    //...省略校驗邏輯等...
    this.messageSender.send(cellphone, message);
  }
}
//使用Notification
MessageSender messageSender = new MessageSender();
Notification notification = new Notification(messageSender);
3. 依賴注入框架
  1. 依賴注入:

public class Demo {
  public static final void main(String args[]) {
    MessageSender sender = new SmsSender(); //創建對象
    Notification notification = new Notification(sender);//依賴注入
    notification.sendMessage("13918942177", "短信驗證碼:2346");
  }
}
4. 依賴反轉原則(DIP)
  1. 依賴反轉原則也叫作依賴倒置原則。這條原則跟控制反轉有點類似,主要用來指導框架層面的設計。高層模塊不依賴低層模塊,它們共同依賴同一個抽象。抽象不要依賴具體實現細節,具體實現細節依賴抽象

六. 理論六:我爲何說KISS、YAGNI原則看似簡單,卻經常被用錯?

1. 如何理解“KISS 原則”?
  1. 儘量保持簡單
2. 代碼行數越少就越“簡單”嗎?
  1. 功能描述:檢查輸入的字符串 ipAddress 是否是合法的 IP 地址。
  2. 例子:

// 第一種實現方式: 使用正則表達式
public boolean isValidIpAddressV1(String ipAddress) {
  if (StringUtils.isBlank(ipAddress)) return false;
  String regex = "^(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|[1-9])\\."
          + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
          + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
          + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)$";
  return ipAddress.matches(regex);
}

// 第二種實現方式: 使用現成的工具類
public boolean isValidIpAddressV2(String ipAddress) {
  if (StringUtils.isBlank(ipAddress)) return false;
  String[] ipUnits = StringUtils.split(ipAddress, '.');
  if (ipUnits.length != 4) {
    return false;
  }
  for (int i = 0; i < 4; ++i) {
    int ipUnitIntValue;
    try {
      ipUnitIntValue = Integer.parseInt(ipUnits[i]);
    } catch (NumberFormatException e) {
      return false;
    }
    if (ipUnitIntValue < 0 || ipUnitIntValue > 255) {
      return false;
    }
    if (i == 0 && ipUnitIntValue == 0) {
      return false;
    }
  }
  return true;
}

// 第三種實現方式: 不使用任何工具類
public boolean isValidIpAddressV3(String ipAddress) {
  char[] ipChars = ipAddress.toCharArray();
  int length = ipChars.length;
  int ipUnitIntValue = -1;
  boolean isFirstUnit = true;
  int unitsCount = 0;
  for (int i = 0; i < length; ++i) {
    char c = ipChars[i];
    if (c == '.') {
      if (ipUnitIntValue < 0 || ipUnitIntValue > 255) return false;
      if (isFirstUnit && ipUnitIntValue == 0) return false;
      if (isFirstUnit) isFirstUnit = false;
      ipUnitIntValue = -1;
      unitsCount++;
      continue;
    }
    if (c < '0' || c > '9') {
      return false;
    }
    if (ipUnitIntValue == -1) ipUnitIntValue = 0;
    ipUnitIntValue = ipUnitIntValue * 10 + (c - '0');
  }
  if (ipUnitIntValue < 0 || ipUnitIntValue > 255) return false;
  if (unitsCount != 3) return false;
  return true;
}
  1. 儘管第三種實現方式性能更高些,但我還是更傾向於選擇第二種實現方法。那是因爲第三種實現方式實際上是一種過度優化。除非 isValidIpAddress() 函數是影響系統性能的瓶頸代碼,否則,這樣優化的投入產出比並不高,增加了代碼實現的難度、犧牲了代碼的可讀性,性能上的提升卻並不明顯。
3. 代碼邏輯複雜就違背 KISS 原則嗎?
4. 如何寫出滿足 KISS 原則的代碼?
  1. 不要使用同事可能不懂的技術來實現代碼。比如前面例子中的正則表達式,還有一些編程語言中過於高級的語法等。
  2. 不要重複造輪子,要善於使用已經有的工具類庫。經驗證明,自己去實現這些類庫,出 bug 的概率會更高,維護的成本也比較高。
  3. 不要過度優化。不要過度使用一些奇技淫巧(比如,位運算代替算術運算、複雜的條件語句代替 if-else、使用一些過於底層的函數等)來優化代碼,犧牲代碼的可讀性。

七. 理論七:重複的代碼就一定違背DRY嗎?如何提高代碼的複用性?

1. DRY 原則
  1. 三種典型的代碼重複情況,它們分別是:實現邏輯重複、功能語義重複和代碼執行重複。這三種代碼重複,有的看似違反 DRY,實際上並不違反;有的看似不違反,實際上卻違反了
2. 實現邏輯重複
  1. 例如:校驗用戶姓名合法性方法isValidUsername(),校驗密碼合法性方法isValidPassword(),部分代碼重複,合併isValidUserNameOrPassword()。
  2. 合併之後的 isValidUserNameOrPassword() 函數,負責兩件事情:驗證用戶名和驗證密碼,違反了“單一職責原則”和“接口隔離原則”。實際上,即便將兩個函數合併成 isValidUserNameOrPassword(),代碼仍然存在問題
  3. 儘管代碼的實現邏輯是相同的,但語義不同,我們判定它並不違反 DRY 原則。對於包含重複代碼的問題,我們可以通過抽象成更細粒度函數的方式來解決。比如將校驗只包含 az、09、dot 的邏輯封裝成 boolean onlyContains(String str, String charlist); 函數。
3. 功能語義重複
  1. 例如:在同一個項目中會有兩個功能相同的函數,那是因爲這兩個函數是由兩個不同的同事開發的,其中一個同事在不知道已經有了 isValidIp() 的情況下,自己又定義並實現了同樣用來校驗 IP 地址是否合法的 checkIfIpValid() 函數。
4. 代碼執行重複
  1. 例如:UserService 中 login() 函數用來校驗用戶登錄是否成功。如果失敗,就返回異常;如果成功,就返回用戶信息
  2. 代碼:

public class UserService {
  private UserRepo userRepo;//通過依賴注入或者IOC框架注入

  public User login(String email, String password) {
    boolean existed = userRepo.checkIfUserExisted(email, password);
    if (!existed) {
      // ... throw AuthenticationFailureException...
    }
    User user = userRepo.getUserByEmail(email);
    return user;
  }
}

public class UserRepo {
  public boolean checkIfUserExisted(String email, String password) {
    if (!EmailValidation.validate(email)) {
      // ... throw InvalidEmailException...
    }

    if (!PasswordValidation.validate(password)) {
      // ... throw InvalidPasswordException...
    }

    //...query db to check if email&password exists...
  }

  public User getUserByEmail(String email) {
    if (!EmailValidation.validate(email)) {
      // ... throw InvalidEmailException...
    }
    //...query db to get user by email...
  }
}

3.分析:就是在 login() 函數中,email 的校驗邏輯被執行了兩次。一次是在調用 checkIfUserExisted() 函數的時候,另一次是調用 getUserByEmail() 函數的時候。這個問題解決起來比較簡單,我們只需要將校驗邏輯從 UserRepo 中移除,統一放到 UserService 中就可以了。

5. 代碼複用性
  1. 代碼複用 && 代碼複用性 && DRY原則
  2. 代碼複用表示一種行爲:我們在開發新功能的時候,儘量複用已經存在的代碼。代碼的可複用性表示一段代碼可被複用的特性或能力:我們在編寫代碼的時候,讓代碼儘量可複用。DRY 原則是一條原則:不要寫重複的代碼。
6. 怎麼提高代碼複用性
  1. 減少代碼耦合

對於高度耦合的代碼,當我們希望複用其中的一個功能,想把這個功能的代碼抽取出來成爲一個獨立的模塊、類或者函數的時候,往往會發現牽一髮而動全身。移動一點代碼,就要牽連到很多其他相關的代碼。所以,高度耦合的代碼會影響到代碼的複用性,我們要儘量減少代碼耦合

  1. 滿足單一職責原則

如果職責不夠單一,模塊、類設計得大而全,那依賴它的代碼或者它依賴的代碼就會比較多,進而增加了代碼的耦合。根據上一點,也就會影響到代碼的複用性。相反,越細粒度的代碼,代碼的通用性會越好,越容易被複用。

  1. 模塊化

這裏的“模塊”,不單單指一組類構成的模塊,還可以理解爲單個類、函數。我們要善於將功能獨立的代碼,封裝成模塊。獨立的模塊就像一塊一塊的積木,更加容易複用,可以直接拿來搭建更加複雜的系統

  1. 業務與非業務邏輯分離
  2. 通用代碼下沉

從分層的角度來看,越底層的代碼越通用、會被越多的模塊調用,越應該設計得足夠可複用。一般情況下,在代碼分層之後,爲了避免交叉調用導致調用關係混亂,我們只允許上層代碼調用下層代碼及同層代碼之間的調用,杜絕下層代碼調用上層代碼。所以,通用的代碼我們儘量下沉到更下層

  1. 繼承、多態、抽象、封裝
  2. 應用模板等設計模式

八.理論八:如何用迪米特法則(LOD)實現“高內聚、松耦合”?

1. 何爲“高內聚、松耦合”?
  1. 高內聚、松耦合

在這個設計思想中,“高內聚”用來指導類本身的設計,“松耦合”用來指導類與類之間依賴關係的設計。不過,這兩者並非完全獨立不相干。高內聚有助於松耦合,松耦合又需要高內聚的支持

  1. 高內聚

所謂高內聚,就是指相近的功能應該放到同一個類中,不相近的功能不要放到同一個類中。相近的功能往往會被同時修改,放到同一個類中,修改會比較集中,代碼容易維護

  1. 松耦合

所謂松耦合是說,在代碼中,類與類之間的依賴關係簡單清晰。即使兩個類有依賴關係,一個類的代碼改動不會或者很少導致依賴類的代碼改動。實際上,我們前面講的依賴注入、接口隔離、基於接口而非實現編程,以及今天講的迪米特法則,都是爲了實現代碼的松耦合

2. “迪米特法則”理論描述
  1. 迪米特法則理論翻譯:

不該有直接依賴關係的類之間,不要有依賴;有依賴關係的類之間,儘量只依賴必要的接口(也就是定義中的“有限知識”)。

3.“迪米特法則”理論之前半部分—{不該有直接依賴關係的類之間,不要有依賴}
  1. 例如:NetworkTransporter 類負責底層網絡通信,根據請求獲取數據;HtmlDownloader 類用來通過 URL 獲取網頁;Document 表示網頁文檔,後續的網頁內容抽取、分詞、索引都是以此爲處理對象。
  2. 例子代碼:
// ===> 負責底層網絡通信,根據請求獲取數據
public class NetworkTransporter {
    // 省略屬性和其他方法...
    public Byte[] send(HtmlRequest htmlRequest) {
      //...
    }
}

// ===> 通過 URL 獲取網頁
public class HtmlDownloader {
  private NetworkTransporter transporter;//通過構造函數或IOC注入
  
  public Html downloadHtml(String url) {
    Byte[] rawHtml = transporter.send(new HtmlRequest(url));
    return new Html(rawHtml);
  }
}

// ===> 網頁文檔,後續的網頁內容抽取、分詞、索引都是以此爲處理對象
public class Document {
  private Html html;
  private String url;
  
  public Document(String url) {
    this.url = url;
    HtmlDownloader downloader = new HtmlDownloader();
    this.html = downloader.downloadHtml(url);
  }
  //...
}
  1. NetworkTransporter 類分析:

作爲一個底層網絡通信類,我們希望它的功能儘可能通用,而不只是服務於下載 HTML,所以,我們不應該直接依賴太具體的發送對象 HtmlRequest。從這一點上講,NetworkTransporter 類的設計違背迪米特法則,依賴了不該有直接依賴關係的 HtmlRequest 類。
我們應該把 address 和 content 交給 NetworkTransporter,而非是直接把 HtmlRequest 交給NetworkTransporter。

  1. NetworkTransporter 類重構代碼

public class NetworkTransporter {
    // 省略屬性和其他方法...
    public Byte[] send(String address, Byte[] data) {
      //...
    }
}
  1. HtmlDownloader 類分析:

這個類的設計沒有問題。不過,我們修改了 NetworkTransporter 的 send() 函數的定義,而這個類用到了 send() 函數,所以我們需要對它做相應的修改,修改後的代碼如下所示


public class HtmlDownloader {
  private NetworkTransporter transporter;//通過構造函數或IOC注入
  
  // HtmlDownloader這裏也要有相應的修改
  public Html downloadHtml(String url) {
    HtmlRequest htmlRequest = new HtmlRequest(url);
    Byte[] rawHtml = transporter.send(
      htmlRequest.getAddress(), htmlRequest.getContent().getBytes());
    return new Html(rawHtml);
  }
}
  1. Document 類分析

(1). 構造函數中的 downloader.downloadHtml() 邏輯複雜,耗時長,不應該放到構造函數中,會影響代碼的可測試性。
(2). HtmlDownloader 對象在構造函數中通過 new 來創建,違反了基於接口而非實現編程的設計思想,也會影響到代碼的可測試性
(3). 從業務含義上來講,Document 網頁文檔沒必要依賴 HtmlDownloader 類,違背了迪米特法則

  1. Document類重構

public class Document {
  private Html html;
  private String url;
  
  public Document(String url, Html html) {
    this.html = html;
    this.url = url;
  }
  //...
}

// 通過一個工廠方法來創建Document
public class DocumentFactory {
  private HtmlDownloader downloader;
  
  public DocumentFactory(HtmlDownloader downloader) {
    this.downloader = downloader;
  }
  
  public Document createDocument(String url) {
    Html html = downloader.downloadHtml(url);
    return new Document(url, html);
  }
}
4.“迪米特法則”理論之後半部分—{有依賴關係的類之間,儘量只依賴必要的接口}
  1. 例子:Serialization 類序列化和反序列化

public class Serialization {
  public String serialize(Object object) {
    String serializedResult = ...;
    //...
    return serializedResult;
  }
  
  public Object deserialize(String str) {
    Object deserializedResult = ...;
    //...
    return deserializedResult;
  }
} 
  1. 分析

單看這個類的設計,沒有一點問題。不過,如果我們把它放到一定的應用場景裏,那就還有繼續優化的空間。假設在我們的項目中,有些類只用到了序列化操作,而另一些類只用到反序列化操作。那基於迪米特法則後半部分“有依賴關係的類之間,儘量只依賴必要的接口”,只用到序列化操作的那部分類不應該依賴反序列化接口。同理,只用到反序列化操作的那部分類不應該依賴序列化接口

  1. 拆分類:一個只負責序列化(Serializer 類),一個只負責反序列化(Deserializer 類)

public class Serializer {
  public String serialize(Object object) {
    String serializedResult = ...;
    ...
    return serializedResult;
  }
}

public class Deserializer {
  public Object deserialize(String str) {
    Object deserializedResult = ...;
    ...
    return deserializedResult;
  }
}
  1. 拆分後分析

儘管拆分之後的代碼更能滿足迪米特法則,但卻違背了高內聚的設計思想。高內聚要求相近的功能要放到同一個類中,這樣可以方便功能修改的時候,修改的地方不至於過於分散。對於剛剛這個例子來說,如果我們修改了序列化的實現方式,比如從 JSON 換成了 XML,那反序列化的實現邏輯也需要一併修改。在未拆分的情況下,我們只需要修改一個類即可。在拆分之後,我們需要修改兩個類。顯然,這種設計思路的代碼改動範圍變大了。

  1. 改進

如果我們既不想違背高內聚的設計思想,也不想違背迪米特法則,那我們該如何解決這個問題呢?實際上,通過引入兩個接口就能輕鬆解決這個問題

  1. 改進實現

public interface Serializable {
  String serialize(Object object);
}

public interface Deserializable {
  Object deserialize(String text);
}

public class Serialization implements Serializable, Deserializable {
  @Override
  public String serialize(Object object) {
    String serializedResult = ...;
    ...
    return serializedResult;
  }
  
  @Override
  public Object deserialize(String str) {
    Object deserializedResult = ...;
    ...
    return deserializedResult;
  }
}

public class DemoClass_1 {
  private Serializable serializer;
  
  public Demo(Serializable serializer) {
    this.serializer = serializer;
  }
  //...
}

public class DemoClass_2 {
  private Deserializable deserializer;
  
  public Demo(Deserializable deserializer) {
    this.deserializer = deserializer;
  }
  //...
}
  1. 總結

實際上,上面的的代碼實現思路,也體現了“基於接口而非實現編程”的設計原則,結合迪米特法則,我們可以總結出一條新的設計原則,那就是“基於最小接口而非最大實現編程”

5. 辯證思考與靈活應用
  1. 整個類只包含序列化和反序列化兩個操作,只用到序列化操作的使用者,即便能夠感知到僅有的一個反序列化函數,問題也不大。那爲了滿足迪米特法則,我們將一個非常簡單的類,拆分出兩個接口,是否有點過度設計的意思呢?

  2. 設計原則本身沒有對錯,只有能否用對之說。不要爲了應用設計原則而應用設計原則,我們在應用設計原則的時候,一定要具體問題具體分析。

  3. 對於剛剛這個 Serialization 類來說,只包含兩個操作,確實沒有太大必要拆分成兩個接口。但是,如果我們對 Serialization 類添加更多的功能,實現更多更好用的序列化、反序列化函數,我們來重新考慮一下這個問題。修改之後的具體的代碼如下:


public class Serializer { // 參看JSON的接口定義
  public String serialize(Object object) { //... }
  public String serializeMap(Map map) { //... }
  public String serializeList(List list) { //... }
  
  public Object deserialize(String objectString) { //... }
  public Map deserializeMap(String mapString) { //... }
  public List deserializeList(String listString) { //... }
}
  1. 改進分析

在這種場景下,第二種設計思路要更好些。因爲基於之前的應用場景來說,大部分代碼只需要用到序列化的功能。對於這部分使用者,沒必要了解反序列化的“知識”,而修改之後的 Serialization 類,反序列化的“知識”,從一個函數變成了三個。一旦任一反序列化操作有代碼改動,我們都需要檢查、測試所有依賴 Serialization 類的代碼是否還能正常工作。爲了減少耦合和測試工作量,我們應該按照迪米特法則,將反序列化和序列化的功能隔離開來。

6. 總結
  1. 單一職責原則

適用對象:模塊,類,接口
側重點:高內聚,低耦合
思考角度:自身

  1. 接口隔離原則

適用對象:接口,函數
側重點:低耦合
思考角度:調用者

  1. 基於接口而非實現編程

適用對象:接口,抽象類
側重點:低耦合
思考角度:調用者

  1. 迪米特法則

適用對象:模塊,類
側重點:低耦合
思考角度:類關係

九. 實戰一(上):針對業務系統的開發,如何做需求分析和設計

1. 需求分析
  1. 積分系統概述

積分系統無外乎就兩個大的功能點,一個是賺取積分,另一個是消費積分。賺取積分功能包括積分賺取渠道,比如下訂單、每日簽到、評論等;還包括積分兌換規則,比如訂單金額與積分的兌換比例,每日簽到贈送多少積分等。消費積分功能包括積分消費渠道,比如抵扣訂單金額、兌換優惠券、積分換購、參與活動扣積分等;還包括積分兌換規則,比如多少積分可以換算成抵扣訂單的多少金額,一張優惠券需要多少積分來兌換等等

  1. 積分賺取和兌換規則

積分的賺取渠道包括:下訂單、每日簽到、評論等。積分兌換規則可以是比較通用的。比如,簽到送 10 積分。再比如,按照訂單總金額的 10% 兌換成積分,也就是 100 塊錢的訂單可以積累 10 積分。除此之外,積分兌換規則也可以是比較細化的。比如,不同的店鋪、不同的商品,可以設置不同的積分兌換比例

  1. 積分消費和兌換規則

積分的消費渠道包括:抵扣訂單金額、兌換優惠券、積分換購、參與活動扣積分等。我們可以根據不同的消費渠道,設置不同的積分兌換規則。比如,積分換算成消費抵扣金額的比例是 10%,也就是 10 積分可以抵扣 1 塊錢;100 積分可以兌換 15 塊錢的優惠券等。

  1. 積分及其明細查詢

查詢用戶的總積分,以及賺取積分和消費積分的歷史記錄。

2. 系統設計
  1. 合理地將功能劃分到不同模塊---------原則:高內聚、低耦合

(1). 劃分方式一

積分賺取渠道及兌換規則、消費渠道及兌換規則的管理和維護(增刪改查),不劃分到積分系統中,而是放到更上層的----營銷系統----中。這樣積分系統就會變得非常簡單,只需要負責增加積分、減少積分、查詢積分、查詢積分明細等這幾個工作

(2). 劃分方式二

積分賺取渠道及兌換規則、消費渠道及兌換規則的管理和維護,分散在各個相關業務系統中,比如訂單系統、評論系統、簽到系統、換購商城、優惠券系統等。還是剛剛那個下訂單賺取積分的例子,在這種情況下,用戶下訂單成功之後,訂單系統根據商品對應的積分兌換比例,計算所能兌換的積分數量,然後直接調用積分系統給用戶增加積分。

(3). 劃分方式三

所有的功能都劃分到積分系統中,包括積分賺取渠道及兌換規則、消費渠道及兌換規則的管理和維護。還是同樣的例子,用戶下訂單成功之後,訂單系統直接告知積分系統訂單交易成功,積分系統根據訂單信息查詢積分兌換規則,給用戶增加積分

  1. 比較

(1). 我們可以反過來通過看它是否符合高內聚、低耦合特性來判斷。如果一個功能的修改或添加,經常要跨團隊、跨項目、跨系統才能完成,那說明模塊劃分的不夠合理,職責不夠清晰,耦合過於嚴重。

(2). 爲了避免業務知識的耦合,讓下層系統更加通用,一般來講,我們不希望下層系統(也就是被調用的系統)包含太多上層系統(也就是調用系統)的業務信息,但是,可以接受上層系統包含下層系統的業務信息。比如,訂單系統、優惠券系統、換購商城等作爲調用積分系統的上層系統,可以包含一些積分相關的業務信息。但是,反過來,積分系統中最好不要包含太多跟訂單、優惠券、換購等相關的信息。

不管選擇這兩種中的哪一種,積分系統所負責的工作是一樣的,只包含積分的增、減、查詢,以及積分明細的記錄和查詢。

  1. 設計模塊與模塊之間的交互關係

(1). 常見系統之間的交互方式:一種是同步接口調用,另一種是利用消息中間件異步調用。第一種方式簡單直接,第二種方式的解耦效果更好

  1. 設計模塊的接口、數據庫、業務模型

十. 實戰一(下):如何實現一個遵從設計原則的積分兌換系統?

1. 業務開發包括哪些工作?
  1. 業務系統的設計與開發

無外乎有這樣三方面的工作要做:接口設計、數據庫設計和業務模型設計(也就是業務邏輯)

  1. 重要性

數據庫和接口的設計非常重要,一旦設計好並投入使用之後,這兩部分都不能輕易改動。改動數據庫表結構,需要涉及數據的遷移和適配;改動接口,需要推動接口的使用者作相應的代碼修改。這兩種情況,即便是微小的改動,執行起來都會非常麻煩。因此,我們在設計接口和數據庫的時候,一定要多花點心思和時間,切不可過於隨意。相反,業務邏輯代碼側重內部實現,不涉及被外部依賴的接口,也不包含持久化的數據,所以對改動的容忍性更大。

2. 數據庫設計
  1. 積分明細表
3. 接口設計
  1. 設計積分系統的接口

接口設計要符合單一職責原則,粒度越小通用性就越好。但是,接口粒度太小也會帶來一些問題。比如,一個功能的實現要調用多個小接口,一方面如果接口調用走網絡(特別是公網),多次遠程接口調用會影響性能;另一方面,本該在一個接口中完成的原子操作,現在分拆成多個小接口來完成,就可能會涉及分佈式事務的數據一致性問題(一個接口執行成功了,但另一個接口執行失敗了)。所以,爲了兼顧易用性和性能,我們可以借鑑 facade(外觀)設計模式,在職責單一的細粒度接口之上,再封裝一層粗粒度的接口給外部使用

  1. 接口例子
    在這裏插入圖片描述
4.業務模型設計
  1. DDD && OOP

前面我們還提到兩種開發模式,基於貧血模型的傳統開發模式和基於充血模型的 DDD 開發模式。前者是一種面向過程的編程風格,後者是一種面向對象的編程風格。不管是 DDD 還是 OOP,高級開發模式的存在一般都是爲了應對複雜系統,應對系統的複雜性。對於我們要開發的積分系統來說,因爲業務相對比較簡單,所以,選擇簡單的基於貧血模型的傳統開發模式就足夠了

5. 爲什麼要分 MVC 三層開發
  1. 分層能起到代碼複用的作用
  2. 分層能起到隔離變化的作用
  3. 分層能起到隔離關注點的作用

三層之間的關注點不同,分層之後,職責分明,更加符合單一職責原則,代碼的內聚性更好。

  1. 分層能提高代碼的可測試性
  2. 分層能應對系統的複雜性
6. BO、VO、Entity 存在的意義是什麼?
  1. 含義

針對 Controller、Service、Repository 三層,每層都會定義相應的數據對象,它們分別是 VO(View Object)、BO(Business Object)、Entity,例如 UserVo、UserBo、UserEntity。在實際的開發中,VO、BO、Entity 可能存在大量的重複字段,甚至三者包含的字段完全一樣。在開發的過程中,我們經常需要重複定義三個幾乎一樣的類,顯然是一種重複勞動

  1. 相對於每層定義各自的數據對象來說,是不是定義一個公共的數據對象更好些呢?

(1). 推薦每層都定義各自的數據對象這種設計思路

(2).原因一.

VO、BO、Entity 並非完全一樣。比如,我們可以在 UserEntity、UserBo 中定義 Password 字段,但顯然不能在 UserVo 中定義 Password 字段,否則就會將用戶的密碼暴露出去

(3).原因二.

VO、BO、Entity 三個類雖然代碼重複,但功能語義不重複,從職責上講是不一樣的。所以,也並不能算違背 DRY 原則。在前面講到 DRY 原則的時候,針對這種情況,如果合併爲同一個類,那也會存在後期因爲需求的變化而需要再拆分的問題。

(4).原因三

爲了儘量減少每層之間的耦合,把職責邊界劃分明確,每層都會維護自己的數據對象,層與層之間通過接口交互。數據從下一層傳遞到上一層的時候,將下一層的數據對象轉化成上一層的數據對象,再繼續處理。雖然這樣的設計稍微有些繁瑣,每層都需要定義各自的數據對象,需要做數據對象之間的轉化,但是分層清晰。對於非常大的項目來說,結構清晰是第一位的

  1. 既然 VO、BO、Entity 不能合併,那如何解決代碼重複的問題呢?

(1). 從設計的角度來說,VO、BO、Entity 的設計思路並不違反 DRY 原則,爲了分層清晰、減少耦合,多維護幾個類的成本也並不是不能接受的

(2).使用繼承,將公共的字段定義到父類中

(3).多用組合,少用繼承”設計思想,使用組合抽取公共的類

  1. 代碼重複問題解決了,那不同分層之間的數據對象該如何互相轉化呢?

(1). 現象

Service 層從 Repository 層獲取的 Entity 之後,將其轉化成 BO,再繼續業務邏輯的處理。所以,整個開發的過程會涉及“Entity 到 BO”和“BO 到 VO”這兩種轉化。

(2). 解決方法

最簡單的轉化方式是手動複製。自己寫代碼在兩個對象之間,一個字段一個字段的賦值。但這樣的做法顯然是沒有技術含量的低級勞動。Java 中提供了多種數據對象轉化工具,比如 BeanUtils、Dozer 等,可以大大簡化繁瑣的對象轉化工作。

十一. 實戰二(上):針對非業務的通用框架開發,如何做需求分析和設計?

1. 項目背景

我們希望設計開發一個小的框架,能夠獲取接口調用的各種統計信息,比如,響應時間的最大值(max)、最小值(min)、平均值(avg)、百分位值(percentile)、接口調用次數(count)、頻率(tps) 等,並且支持將統計結果以各種顯示格式(比如:JSON 格式、網頁格式、自定義顯示格式等)輸出到各種終端(Console 命令行、HTTP 網頁、Email、日誌文件、自定義輸出終端等),以方便查看。

2. 需求分析
  1. 功能性需求分析

(1). 接口統計信息:包括接口響應時間的統計信息,以及接口調用次數的統計信息等。

(2). 統計信息的類型:max、min、avg、percentile、count、tps 等

(3). 統計信息顯示格式:Json、Html、自定義顯示格式。

(4). 統計信息顯示終端:Console、Email、HTTP 網頁、日誌、自定義顯示終端

(5). 統計觸發方式:包括主動和被動兩種。主動表示以一定的頻率定時統計數據,並主動推送到顯示終端,比如郵件推送。被動表示用戶觸發統計,比如用戶在網頁中選擇要統計的時間區間,觸發統計,並將結果顯示給用戶

(6). 統計時間區間:框架需要支持自定義統計時間區間,比如統計最近 10 分鐘的某接口的 tps、訪問次數,或者統計 12 月 11 日 00 點到 12 月 12 日 00 點之間某接口響應時間的最大值、最小值、平均值等

(7). 統計時間間隔:對於主動觸發統計,我們還要支持指定統計時間間隔,也就是多久觸發一次統計顯示。比如,每間隔 10s 統計一次接口信息並顯示到命令行中,每間隔 24 小時發送一封統計信息郵件

3. 框架設計
  1. 針對需求做框架設計

對於稍微複雜系統的開發,很多人覺得不知從何開始。我個人喜歡借鑑 TDD(測試驅動開發)和 Prototype(最小原型)的思想,先聚焦於一個簡單的應用場景,基於此設計實現一個簡單的原型

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章