006.設計原則與思想:規範與重構

規範與重構

一.理論一:什麼情況下要重構?到底重構什麼?又該如何重構?

1. 重構的目的:爲什麼要重構(why)?
  1. 重構是時刻保證代碼質量的一個極其有效的手段,不至於讓代碼腐化到無可救藥的地步。
2. 重構的對象:到底重構什麼(what)?
  1. 大規模高層次重構(簡稱爲“大型重構”)和小規模低層次的重構(以下簡稱爲“小型重構”)
3. 重構的時機:什麼時候重構(when)?
  1. 持續重構
4. 重構的方法:又該如何重構(how)?

二.理論二:爲了保證重構不出錯,有哪些非常能落地的技術手段?

1. 什麼是單元測試?
  1. 單元測試的測試對象是類或者函數,用來測試一個類和函數是否都按照預期的邏輯執行。這是代碼層級的測試
2. 爲什麼要寫單元測試?
  1. 單元測試能有效地幫你發現代碼中的 bug
  2. 寫單元測試能幫你發現代碼設計上的問題
  3. 單元測試是對集成測試的有力補充
  4. 寫單元測試的過程本身就是代碼重構的過程
  5. 單元測試是 TDD 可落地執行的改進方案

三.理論三:什麼是代碼的可測試性?如何寫出可測試性好的代碼?

1. 單元測試
  1. 單元測試主要是測試程序員自己編寫的代碼邏輯的正確性,並非是端到端的集成測試,它不需要測試所依賴的外部系統(分佈式鎖、Wallet RPC 服務)的邏輯正確性。所以,如果代碼中依賴了外部系統或者不可控組件,比如,需要依賴數據庫、網絡通信、文件系統等,那我們就需要將被測代碼與外部系統解依賴,而這種解依賴的方法就叫作“mock”。所謂的 mock 就是用一個“假”的服務替換真正的服務。mock 的服務完全在我們的控制之下,模擬輸出我們想要的數據

三.理論四:如何通過封裝、抽象、模塊化、中間層等解耦代碼?

1. “解耦”爲何如此重要?
  1. 如果說重構是保證代碼質量不至於腐化到無可救藥地步的有效手段,那麼利用解耦的方法對代碼重構,就是保證代碼不至於複雜到無法控制的有效手段。
  2. 代碼“高內聚、松耦合”,也就意味着,代碼結構清晰、分層和模塊化合理、依賴關係簡單、模塊或類之間的耦合小,那代碼整體的質量就不會差
2. 代碼是否需要“解耦”?
  1. 問題:該怎麼判斷代碼的耦合程度呢?或者說,怎麼判斷代碼是否符合“高內聚、松耦合”呢?再或者說,如何判斷系統是否需要解耦重構呢?

比如,看修改代碼會不會牽一髮而動全身。除此之外,還有一個直接的衡量標準,也是我在閱讀源碼的時候經常會用到的,那就是把模塊與模塊之間、類與類之間的依賴關係畫出來,根據依賴關係圖的複雜性來判斷是否需要解耦重構。

3. 如何給代碼“解耦”?
  1. 封裝與抽象
  2. 中間層

中間層的引入明顯地簡化了依賴關係,讓代碼結構更加清晰
在這裏插入圖片描述

  1. 模塊化

很多大型軟件(比如 Windows)之所以能做到幾百、上千人有條不紊地協作開發,也歸功於模塊化做得好。不同的模塊之間通過 API 來進行通信,每個模塊之間耦合很小,每個小的團隊聚焦於一個獨立的高內聚模塊來開發,最終像搭積木一樣將各個模塊組裝起來,構建成一個超級複雜的系統。

我們在開發代碼的時候,一定要有模塊化意識,將每個模塊都當作一個獨立的 lib 一樣來開發,只提供封裝了內部實現細節的接口給其他模塊使用,這樣可以減少不同模塊之間的耦合度

  1. 其他設計思想和原則

(1). 單一職責原則

內聚性和耦合性並非獨立的。高內聚會讓代碼更加松耦合,而實現高內聚的重要指導原則就是單一職責原則。模塊或者類的職責設計得單一,而不是大而全,那依賴它的類和它依賴的類就會比較少,代碼耦合也就相應的降低了。

(2). 基於接口而非實現編程

基於接口而非實現編程能通過接口這樣一箇中間層,隔離變化和具體的實現。這樣做的好處是,在有依賴關係的兩個模塊或類之間,一個模塊或者類的改動,不會影響到另一個模塊或類。實際上,這就相當於將一種強依賴關係(強耦合)解耦爲了弱依賴關係(弱耦合)

(3). 依賴注入

跟基於接口而非實現編程思想類似,依賴注入也是將代碼之間的強耦合變爲弱耦合。儘管依賴注入無法將本應該有依賴關係的兩個類,解耦爲沒有依賴關係,但可以讓耦合關係沒那麼緊密,容易做到插拔替換

(4). 多用組合少用繼承

繼承是一種強依賴關係,父類與子類高度耦合,且這種耦合關係非常脆弱,牽一髮而動全身,父類的每一次改動都會影響所有的子類。相反,組合關係是一種弱依賴關係,這種關係更加靈活,所以,對於繼承結構比較複雜的代碼,利用組合來替換繼承,也是一種解耦的有效手段。

(5). 迪米特法則

迪米特法則講的是,不該有直接依賴關係的類之間,不要有依賴;有依賴關係的類之間,儘量只依賴必要的接口。從定義上,我們明顯可以看出,這條原則的目的就是爲了實現代碼的松耦合

四.理論五:讓你最快速地改善代碼質量的20條編程規範

1. 命名
  1. 命名多長最合適?

實際上,在足夠表達其含義的情況下,命名當然是越短越好。但是,大部分情況下,短的命名都沒有長的命名更能達意。所以,很多書籍或者文章都不推薦在命名時使用縮寫

熟知的詞,推薦使用縮寫,比如,sec 表示 second、str 表示 string、num 表示 number、doc 表示 document。

對於作用域比較小的變量,我們可以使用相對短的命名,比如一些函數內的臨時變量。相反,對於類名這種作用域比較大的,我更推薦用長的命名方式。

  1. 利用上下文簡化命名

(1). 例子:


public class User {
  private String userName;
  private String userPassword;
  private String userAvatarUrl;
  //...
}

(2). 在 User 類這樣一個上下文中,我們沒有在成員變量的命名中重複添加“user”這樣一個前綴單詞,而是直接命名爲 name、password、avatarUrl。在使用這些屬性時候,我們能借助對象這樣一個上下文,表意也足夠明確

  1. 命名要可讀、可搜索

(1). 可讀:指的是不要用一些特別生僻、難發音的英文單詞來命名

  1. 如何命名接口和抽象類?

(1). 接口命名

對於接口的命名,一般有兩種比較常見的方式。一種是加前綴“I”,表示一個 Interface。比如 IUserService,對應的實現類命名爲 UserService。另一種是不加前綴,比如 UserService,對應的實現類加後綴“Impl”,比如 UserServiceImpl。

(2). 抽象類

對於抽象類的命名,也有兩種方式,一種是帶上前綴“Abstract”,比如 AbstractConfiguration;另一種是不帶前綴“Abstract”

2. 註釋

/**
* (what) Bean factory to create beans. 
* 
* (why) The class likes Spring IOC framework, but is more lightweight. 
*
* (how) Create objects from different sources sequentially:
* user specified object > SPI > configuration > default object.
*/
public class BeansFactory {
  // ...
}
3. 類、函數多大才合適?
  1. 當一個類的代碼讀起來讓你感覺頭大了,實現某個功能時不知道該用哪個函數了,想用哪個函數翻半天都找不到了,只用到一個小功能要引入整個類(類中包含很多無關此功能實現的函數)的時候,這就說明類的行數過多了
4. 一行代碼多長最合適?
  1. 總體上來講我們要遵循的一個原則是:一行代碼最長不能超過 IDE 顯示的寬度。需要滾動鼠標才能查看一行的全部代碼,顯然不利於代碼的閱讀。當然,這個限制也不能太小,太小會導致很多稍長點的語句被折成兩行,也會影響到代碼的整潔,不利於閱讀
5. 善用空行分割單元塊
  1. 在類的成員變量與函數之間、靜態成員變量與普通成員變量之間、各函數之間、甚至各成員變量之間,我們都可以通過添加空行的方式,讓這些不同模塊的代碼之間,界限更加明確
6. 四格縮進還是兩格縮進?
  1. Java 語言傾向於兩格縮進,PHP 語言傾向於四格縮進。
  2. 不管是用兩格縮進還是四格縮進,一定不要用 tab 鍵縮進。因爲在不同的 IDE 下,tab 鍵的顯示寬度不同,有的顯示爲四格縮進,有的顯示爲兩格縮進。如果在同一個項目中,不同的同事使用不同的縮進方式(空格縮進或 tab 鍵縮進),有可能會導致有的代碼顯示爲兩格縮進、有的代碼顯示爲四格縮進
7. 大括號是否要另起一行?
  1. 推薦,將括號放到跟語句同一行的風格。理由跟上面類似,節省代碼行數
8. 類中成員的排列順序
  1. 排列順序

在類中,成員變量排在函數的前面。成員變量之間或函數之間,都是按照“先靜態(靜態函數或靜態成員變量)、後普通(非靜態函數或非靜態成員變量)”的方式來排列的

9. 把代碼分割成更小的單元塊
  1. 要有模塊化和抽象思維,善於將大塊的複雜邏輯提煉成類或者函數,屏蔽掉細節,讓閱讀代碼的人不至於迷失在細節中,這樣能極大地提高代碼的可讀性
10. 避免函數參數過多
  1. 函數包含 3、4 個參數的時候還是能接受的,大於等於 5 個的時候,我們就覺得參數有點過多了,會影響到代碼的可讀性,使用起來也不方便。針對參數過多的情況,一般有 2 種處理方法。

(1). 考慮函數是否職責單一,是否能通過拆分成多個函數的方式來減少參數。示例代碼如下所示


public User getUser(String username, String telephone, String email);

// 拆分成多個函數
public User getUserByUsername(String username);
public User getUserByTelephone(String telephone);
public User getUserByEmail(String email);

(2). 將函數的參數封裝成對象。示例代碼如下所示


public void postBlog(String title, String summary, String keywords, String content, String category, long authorId);

// 將參數封裝成對象
public class Blog {
  private String title;
  private String summary;
  private String keywords;
  private Strint content;
  private String category;
  private long authorId;
}
public void postBlog(Blog blog);
11. 勿用函數參數來控制邏輯
  1. 不要在函數中使用布爾類型的標識參數來控制內部邏輯,true 的時候走這塊邏輯,false 的時候走另一塊邏輯。這明顯違背了單一職責原則和接口隔離原則。我建議將其拆成兩個函數,可讀性上也要更好

  2. 代碼


public void buyCourse(long userId, long courseId, boolean isVip);

// 將其拆分成兩個函數
public void buyCourse(long userId, long courseId);
public void buyCourseForVip(long userId, long courseId);
  1. 不過,如果函數是 private 私有函數,影響範圍有限,或者拆分之後的兩個函數經常同時被調用,我們可以酌情考慮保留標識參數

  2. 除了布爾類型作爲標識參數來控制邏輯的情況外,還有一種“根據參數是否爲 null”來控制邏輯的情況。針對這種情況,我們也應該將其拆分成多個函數。拆分之後的函數職責更明確,不容易用錯


public List<Transaction> selectTransactions(Long userId, Date startDate, Date endDate) {
  if (startDate != null && endDate != null) {
    // 查詢兩個時間區間的transactions
  }
  if (startDate != null && endDate == null) {
    // 查詢startDate之後的所有transactions
  }
  if (startDate == null && endDate != null) {
    // 查詢endDate之前的所有transactions
  }
  if (startDate == null && endDate == null) {
    // 查詢所有的transactions
  }
}

// 拆分成多個public函數,更加清晰、易用
public List<Transaction> selectTransactionsBetween(Long userId, Date startDate, Date endDate) {
  return selectTransactions(userId, startDate, endDate);
}

public List<Transaction> selectTransactionsStartWith(Long userId, Date startDate) {
  return selectTransactions(userId, startDate, null);
}

public List<Transaction> selectTransactionsEndWith(Long userId, Date endDate) {
  return selectTransactions(userId, null, endDate);
}

public List<Transaction> selectAllTransactions(Long userId) {
  return selectTransactions(userId, null, null);
}

private List<Transaction> selectTransactions(Long userId, Date startDate, Date endDate) {
  // ...
}
12. 函數設計要職責單一
  1. 單一職責原則的時候,針對的是類、模塊這樣的應用對象。實際上,對於函數的設計來說,更要滿足單一職責原則。相對於類和模塊,函數的粒度比較小,代碼行數少,所以在應用單一職責原則的時候,沒有像應用到類或者模塊那樣模棱兩可,能多單一就多單一。
  2. 例子代碼:

public boolean checkUserIfExisting(String telephone, String username, String email)  { 
  if (!StringUtils.isBlank(telephone)) {
    User user = userRepo.selectUserByTelephone(telephone);
    return user != null;
  }
  
  if (!StringUtils.isBlank(username)) {
    User user = userRepo.selectUserByUsername(username);
    return user != null;
  }
  
  if (!StringUtils.isBlank(email)) {
    User user = userRepo.selectUserByEmail(email);
    return user != null;
  }
  
  return false;
}

// 拆分成三個函數
public boolean checkUserIfExistingByTelephone(String telephone);
public boolean checkUserIfExistingByUsername(String username);
public boolean checkUserIfExistingByEmail(String email);
13. 移除過深的嵌套層次
  1. 過深的嵌套本身理解起來就比較費勁,除此之外,嵌套過深很容易因爲代碼多次縮進,導致嵌套內部的語句超過一行的長度而折成兩行,影響代碼的整潔。
14. 學會使用解釋性變量
  1. 常量取代魔法數字。

public double CalculateCircularArea(double radius) {
  return (3.1415) * radius * radius;
}

// 常量替代魔法數字
public static final Double PI = 3.1415;
public double CalculateCircularArea(double radius) {
  return PI * radius * radius;
}
  1. 使用解釋性變量來解釋複雜表達式

if (date.after(SUMMER_START) && date.before(SUMMER_END)) {
  // ...
} else {
  // ...
}

// 引入解釋性變量後邏輯更加清晰
boolean isSummer = date.after(SUMMER_START)&&date.before(SUMMER_END);
if (isSummer) {
  // ...
} else {
  // ...
} 

五.實戰一(上):通過一段ID生成器代碼,學習如何發現代碼質量問題

1. ID 生成器代碼

public class IdGenerator {
  private static final Logger logger = LoggerFactory.getLogger(IdGenerator.class);

  public static String generate() {
    String id = "";
    try {
      String hostName = InetAddress.getLocalHost().getHostName();
      String[] tokens = hostName.split("\\.");
      if (tokens.length > 0) {
        hostName = tokens[tokens.length - 1];
      }
      char[] randomChars = new char[8];
      int count = 0;
      Random random = new Random();
      while (count < 8) {
        int randomAscii = random.nextInt(122);
        if (randomAscii >= 48 && randomAscii <= 57) {
          randomChars[count] = (char)('0' + (randomAscii - 48));
          count++;
        } else if (randomAscii >= 65 && randomAscii <= 90) {
          randomChars[count] = (char)('A' + (randomAscii - 65));
          count++;
        } else if (randomAscii >= 97 && randomAscii <= 122) {
          randomChars[count] = (char)('a' + (randomAscii - 97));
          count++;
        }
      }
      id = String.format("%s-%d-%s", hostName,
              System.currentTimeMillis(), new String(randomChars));
    } catch (UnknownHostException e) {
      logger.warn("Failed to get the host name.", e);
    }

    return id;
  }
}
2. 如何發現代碼質量問題?
  1. 常規checkList
    在這裏插入圖片描述

  2. 根據常規checkList問題分析

(1). 目錄設置是否合理、模塊劃分是否清晰、代碼結構是否滿足“高內聚、松耦合”?

IdGenerator 的代碼比較簡單,只有一個類,所以,不涉及目錄設置、模塊劃分、代碼結構問題

(2).是否遵循經典的設計原則和設計思想(SOLID、DRY、KISS、YAGNI、LOD 等)?

只有一個類,也不違反基本的 SOLID、DRY、KISS、YAGNI、LOD 等設計原則

(3). 設計模式是否應用得當?是否有過度設計?

它沒有應用設計模式,所以也不存在不合理使用和過度設計的問題。

(4).代碼是否容易擴展?如果要添加新功能,是否容易實現?

IdGenerator 設計成了實現類而非接口,調用者直接依賴實現而非接口,違反基於接口而非實現編程的設計思想。實際上,將 IdGenerator 設計成實現類,而不定義接口,問題也不大。如果哪天 ID 生成算法改變了,我們只需要直接修改實現類的代碼就可以。但是,如果項目中需要同時存在兩種 ID 生成算法,也就是要同時存在兩個 IdGenerator 實現類

(5).代碼是否容易測試?單元測試是否全面覆蓋了各種正常和異常的情況?

把 IdGenerator 的 generate() 函數定義爲靜態函數,會影響使用該函數的代碼的可測試性。同時,generate() 函數的代碼實現依賴運行環境(本機名)、時間函數、隨機函數,所以 generate() 函數本身的可測試性也不好,需要做比較大的重構

  1. 業務需求checkList
    在這裏插入圖片描述

六.實戰一(下):將ID生成器代碼從“能用”重構爲“好用”

1. 重構計劃
  1. 循序漸進、小步快跑。重構代碼的過程也應該遵循這樣的思路。每次改動一點點,改好之後,再進行下一輪的優化,保證每次對代碼的改動不會過大,能在很短的時間內完成
  1. 第一輪重構:提高代碼的可讀性
  2. 第二輪重構:提高代碼的可測試性
  3. 第三輪重構:編寫完善的單元測試
  4. 第四輪重構:所有重構完成之後添加註釋
2. 第一輪重構:提高代碼的可讀性
  1. 問題
  1. hostName 變量不應該被重複使用,尤其當這兩次使用時的含義還不同的時候;
  2. 將獲取 hostName 的代碼抽離出來,定義爲 getLastfieldOfHostName() 函數;
  3. 刪除代碼中的魔法數,比如,57、90、97、122;
  4. 將隨機數生成的代碼抽離出來,定義爲 generateRandomAlphameric() 函數
  5. generate() 函數中的三個 if 邏輯重複了,且實現過於複雜,我們要對其進行簡化
  6. 對 IdGenerator 類重命名,並且抽象出對應的接口
  1. 重構後代碼

public interface IdGenerator {
  String generate();
}

public interface LogTraceIdGenerator extends IdGenerator {
}

public class RandomIdGenerator implements IdGenerator {
  private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

  @Override
  public String generate() {
    String substrOfHostName = getLastfieldOfHostName();
    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
            substrOfHostName, currentTimeMillis, randomString);
    return id;
  }

  private String getLastfieldOfHostName() {
    String substrOfHostName = null;
    try {
      String hostName = InetAddress.getLocalHost().getHostName();
      String[] tokens = hostName.split("\\.");
      substrOfHostName = tokens[tokens.length - 1];
      return substrOfHostName;
    } catch (UnknownHostException e) {
      logger.warn("Failed to get the host name.", e);
    }
    return substrOfHostName;
  }

  private String generateRandomAlphameric(int length) {
    char[] randomChars = new char[length];
    int count = 0;
    Random random = new Random();
    while (count < length) {
      int maxAscii = 'z';
      int randomAscii = random.nextInt(maxAscii);
      boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
      boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
      boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
      if (isDigit|| isUppercase || isLowercase) {
        randomChars[count] = (char) (randomAscii);
        ++count;
      }
    }
    return new String(randomChars);
  }
}

//代碼使用舉例
LogTraceIdGenerator logTraceIdGenerator = new RandomIdGenerator();
3. 第二輪重構:提高代碼的可測試性
  1. 可測試性問題包含兩方面

  2. generate() 函數定義爲靜態函數,會影響使用該函數的代碼的可測試性

我們將 RandomIdGenerator 類中的 generate() 靜態函數重新定義成了普通函數。調用者可以通過依賴注入的方式,在外部創建好 RandomIdGenerator 對象後注入到自己的代碼中,從而解決靜態函數調用影響代碼可測試性的問題。

  1. generate() 函數的代碼實現依賴運行環境(本機名)、時間函數、隨機函數,所以 generate() 函數本身的可測試性也不好

(1). 從 getLastfieldOfHostName() 函數中,將邏輯比較複雜的那部分代碼剝離出來,定義爲 getLastSubstrSplittedByDot() 函數。因爲 getLastfieldOfHostName() 函數依賴本地主機名,所以,剝離出主要代碼之後這個函數變得非常簡單,可以不用測試。我們重點測試 getLastSubstrSplittedByDot() 函數即可

(2). 將 generateRandomAlphameric() 和 getLastSubstrSplittedByDot() 這兩個函數的訪問權限設置爲 protected。這樣做的目的是,可以直接在單元測試中通過對象來調用兩個函數進行測試

(3). 給 generateRandomAlphameric() 和 getLastSubstrSplittedByDot() 兩個函數添加 Google Guava 的 annotation @VisibleForTesting。這個 annotation 沒有任何實際的作用,只起到標識的作用,告訴其他人說,這兩個函數本該是 private 訪問權限的,之所以提升訪問權限到 protected,只是爲了測試,只能用於單元測試中。

  1. 重構後代碼

public class RandomIdGenerator implements IdGenerator {
  private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

  @Override
  public String generate() {
    String substrOfHostName = getLastfieldOfHostName();
    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
            substrOfHostName, currentTimeMillis, randomString);
    return id;
  }

  private String getLastfieldOfHostName() {
    String substrOfHostName = null;
    try {
      String hostName = InetAddress.getLocalHost().getHostName();
      substrOfHostName = getLastSubstrSplittedByDot(hostName);
    } catch (UnknownHostException e) {
      logger.warn("Failed to get the host name.", e);
    }
    return substrOfHostName;
  }

  @VisibleForTesting
  protected String getLastSubstrSplittedByDot(String hostName) {
    String[] tokens = hostName.split("\\.");
    String substrOfHostName = tokens[tokens.length - 1];
    return substrOfHostName;
  }

  @VisibleForTesting
  protected String generateRandomAlphameric(int length) {
    char[] randomChars = new char[length];
    int count = 0;
    Random random = new Random();
    while (count < length) {
      int maxAscii = 'z';
      int randomAscii = random.nextInt(maxAscii);
      boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
      boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
      boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
      if (isDigit|| isUppercase || isLowercase) {
        randomChars[count] = (char) (randomAscii);
        ++count;
      }
    }
    return new String(randomChars);
  }
}
4. 第三輪重構:編寫完善的單元測試
5. 第四輪重構:添加註釋

七. 實戰二(上):程序出錯該返回啥?NULL、異常、錯誤碼、空對象?

1. 函數出錯應該返回啥?
  1. 返回4種情況:錯誤碼、NULL 值、空對象、異常對象。
2. 錯誤碼
  1. 儘量不要使用錯誤碼。異常相對於錯誤碼,有諸多方面的優勢,比如可以攜帶更多的錯誤信息(exception 中可以有 message、stack trace 等信息)等。
3. 返回 NULL 值
  1. 儘管返回 NULL 值有諸多弊端,但對於以 get、find、select、search、query 等單詞開頭的查找函數來說,數據不存在,並非一種異常情況,這是一種正常行爲。所以,返回代表不存在語義的 NULL 值比返回異常更加合理。
  2. 看項目中的其他類似查找函數都是如何定義的,只要整個項目遵從統一的約定即可
4. 返回空對象
  1. 當函數返回的數據是字符串類型或者集合類型的時候,我們可以用空字符串或空集合替代 NULL 值,來表示不存在的情況。這樣,我們在使用函數的時候,就可以不用做 NULL 值判斷。
5. 拋出異常對象
  1. 直接吞掉

如果 func1() 拋出的異常是可以恢復,且 func2() 的調用方並不關心此異常,我們完全可以在 func2() 內將 func1() 拋出的異常吞掉


public void func1() throws Exception1 {
  // ...
}

public void func2() {
  //...
  try {
    func1();
  } catch(Exception1 e) {
    log.warn("...", e); //吐掉:try-catch打印日誌
  }
  //...
}
  1. 原封不動地 re-throw。

如果 func1() 拋出的異常對 func2() 的調用方來說,也是可以理解的、關心的 ,並且在業務概念上有一定的相關性,我們可以選擇直接將 func1 拋出的異常 re-throw;


public void func1() throws Exception1 {
  // ...
}


public void func2() throws Exception1 {//原封不動的re-throw Exception1
  //...
  func1();
  //...
}
  1. 包裝成新的異常 re-throw

如果 func1() 拋出的異常太底層,對 func2() 的調用方來說,缺乏背景去理解、且業務概念上無關,我們可以將它重新包裝成調用方可以理解的新異常,然後 re-throw。


public void func1() throws Exception1 {
  // ...
}


public void func2() throws Exception2 {
  //...
  try {
    func1();
  } catch(Exception1 e) {
   throw new Exception2("...", e); // wrap成新的Exception2然後re-throw
  }
  //...
}

八. 實戰二(下):重構ID生成器項目中各函數的異常處理代碼

1. 重構 generate() 函數
  1. 重構前

  public String generate() {
    String substrOfHostName = getLastFieldOfHostName();
    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
            substrOfHostName, currentTimeMillis, randomString);
    return id;
  }
  1. 主機名有可能獲取失敗。在目前的代碼實現中,如果主機名獲取失敗,substrOfHostName 爲 NULL,那 generate() 函數會返回類似“null-16723733647-83Ab3uK6”這樣的數據

  2. 更傾向於明確地將異常告知調用者。所以,這裏最好是拋出受檢異常,而非特殊值

  3. 重構後


  public String generate() throws IdGenerationFailureException {
    String substrOfHostName = getLastFieldOfHostName();
    if (substrOfHostName == null || substrOfHostName.isEmpty()) {
      throw new IdGenerationFailureException("host name is empty.");
    }
    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
            substrOfHostName, currentTimeMillis, randomString);
    return id;
  }
2. 重構 getLastFieldOfHostName() 函數
  1. 重構前

  private String getLastFieldOfHostName() {
    String substrOfHostName = null;
    try {
      String hostName = InetAddress.getLocalHost().getHostName();
      substrOfHostName = getLastSubstrSplittedByDot(hostName);
    } catch (UnknownHostException e) {
      logger.warn("Failed to get the host name.", e);
    }
    return substrOfHostName;
 }
  1. 問題:

現在的處理方式是當主機名獲取失敗的時候,getLastFieldOfHostName() 函數返回 NULL 值。我們前面講過,是返回 NULL 值還是異常對象,要看獲取不到數據是正常行爲,還是異常行爲。獲取主機名失敗會影響後續邏輯的處理,並不是我們期望的,所以,它是一種異常行爲。這裏最好是拋出異常,而非返回 NULL 值

  1. 是否封裝成新的異常拋出

要看函數跟異常是否有業務相關性。getLastFieldOfHostName() 函數用來獲取主機名的最後一個字段,UnknownHostException 異常表示主機名獲取失敗,兩者算是業務相關,所以可以直接將 UnknownHostException 拋出,不需要重新包裹成新的異常。

  1. 重構後

 private String getLastFieldOfHostName() throws UnknownHostException{
    String substrOfHostName = null;
    String hostName = InetAddress.getLocalHost().getHostName();
    substrOfHostName = getLastSubstrSplittedByDot(hostName);
    return substrOfHostName;
 }
  1. getLastFieldOfHostName() 函數修改之後,generate() 函數也要做相應的修改。我們需要在 generate() 函數中,捕獲 getLastFieldOfHostName() 拋出的 UnknownHostException 異常。當我們捕獲到這個異常之後,應該怎麼處理呢?

  2. 在 generate() 函數中,我們需要捕獲 UnknownHostException 異常,並重新包裹成新的異常 IdGenerationFailureException 往上拋出。之所以這麼做,有下面三個原因

  1. 調用者在使用 generate() 函數的時候,只需要知道它生成的是隨機唯一 ID,並不關心 ID 是如何生成的。也就說是,這是依賴抽象而非實現編程。如果 generate() 函數直接拋出 UnknownHostException 異常,實際上是暴露了實現細節。
  1. 從代碼封裝的角度來講,我們不希望將 UnknownHostException 這個比較底層的異常,暴露給更上層的代碼,也就是調用 generate() 函數的代碼。而且,調用者拿到這個異常的時候,並不能理解這個異常到底代表了什麼,也不知道該如何處理。
  1. UnknownHostException 異常跟 generate() 函數,在業務概念上沒有相關性
  1. 再次重構

  public String generate() throws IdGenerationFailureException {
    String substrOfHostName = null;
    try {
      substrOfHostName = getLastFieldOfHostName();
    } catch (UnknownHostException e) {
      throw new IdGenerationFailureException("host name is empty.");
    }
    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
            substrOfHostName, currentTimeMillis, randomString);
    return id;
  }
3. 重構 getLastSubstrSplittedByDot() 函數
  1. 對於 getLastSubstrSplittedByDot(String hostName) 函數,如果 hostName 爲 NULL 或者空字符串,這個函數應該返回什麼
  2. 重構前
  @VisibleForTesting
  protected String getLastSubstrSplittedByDot(String hostName) {
    String[] tokens = hostName.split("\\.");
    String substrOfHostName = tokens[tokens.length - 1];
    return substrOfHostName;
  }
  1. 理論上講,參數傳遞的正確性應該有程序員來保證,我們無需做 NULL 值或者空字符串的判斷和特殊處理。調用者本不應該把 NULL 值或者空字符串傳遞給 getLastSubstrSplittedByDot() 函數。如果傳遞了,那就是 code bug,需要修復

  2. 如果函數是 public 的,你無法掌控會被誰調用以及如何調用(有可能某個同事一時疏忽,傳遞進了 NULL 值,這種情況也是存在的),爲了儘可能提高代碼的健壯性,我們最好是在 public 函數中做 NULL 值或空字符串的判斷。

  3. 重構後


  @VisibleForTesting
  protected String getLastSubstrSplittedByDot(String hostName) {
    if (hostName == null || hostName.isEmpty()) {
      throw IllegalArgumentException("..."); //運行時異常
    }
    String[] tokens = hostName.split("\\.");
    String substrOfHostName = tokens[tokens.length - 1];
    return substrOfHostName;
  }
  1. 我們在使用這個函數的時候,自己也要保證不傳遞 NULL 值或者空字符串進去。所以,getLastFieldOfHostName() 函數的代碼也要作相應的修改
  2. 重構後

 private String getLastFieldOfHostName() throws UnknownHostException{
    String substrOfHostName = null;
    String hostName = InetAddress.getLocalHost().getHostName();
    if (hostName == null || hostName.isEmpty()) { // 此處做判斷
      throw new UnknownHostException("...");
    }
    substrOfHostName = getLastSubstrSplittedByDot(hostName);
    return substrOfHostName;
 }
4. 重構 getLastSubstrSplittedByDot() 函數
  1. 對於 generateRandomAlphameric(int length) 函數,如果 length < 0 或 length = 0,是一種異常行爲,所以,當傳入的參數 length < 0 的時候,我們拋出 IllegalArgumentException 異常
  2. 重構前

  @VisibleForTesting
  protected String generateRandomAlphameric(int length) {
    char[] randomChars = new char[length];
    int count = 0;
    Random random = new Random();
    while (count < length) {
      int maxAscii = 'z';
      int randomAscii = random.nextInt(maxAscii);
      boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
      boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
      boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
      if (isDigit|| isUppercase || isLowercase) {
        randomChars[count] = (char) (randomAscii);
        ++count;
      }
    }
    return new String(randomChars);
  }
}
5. 重構之後的 RandomIdGenerator 代碼
  1. 重構後

public class RandomIdGenerator implements IdGenerator {
  private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

  @Override
  public String generate() throws IdGenerationFailureException {
    String substrOfHostName = null;
    try {
      substrOfHostName = getLastFieldOfHostName();
    } catch (UnknownHostException e) {
      throw new IdGenerationFailureException("...", e);
    }
    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
            substrOfHostName, currentTimeMillis, randomString);
    return id;
  }

  private String getLastFieldOfHostName() throws UnknownHostException{
    String substrOfHostName = null;
    String hostName = InetAddress.getLocalHost().getHostName();
    if (hostName == null || hostName.isEmpty()) {
      throw new UnknownHostException("...");
    }
    substrOfHostName = getLastSubstrSplittedByDot(hostName);
    return substrOfHostName;
  }

  @VisibleForTesting
  protected String getLastSubstrSplittedByDot(String hostName) {
    if (hostName == null || hostName.isEmpty()) {
      throw new IllegalArgumentException("...");
    }

    String[] tokens = hostName.split("\\.");
    String substrOfHostName = tokens[tokens.length - 1];
    return substrOfHostName;
  }

  @VisibleForTesting
  protected String generateRandomAlphameric(int length) {
    if (length <= 0) {
      throw new IllegalArgumentException("...");
    }

    char[] randomChars = new char[length];
    int count = 0;
    Random random = new Random();
    while (count < length) {
      int maxAscii = 'z';
      int randomAscii = random.nextInt(maxAscii);
      boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
      boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
      boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
      if (isDigit|| isUppercase || isLowercase) {
        randomChars[count] = (char) (randomAscii);
        ++count;
      }
    }
    return new String(randomChars);
  }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章