規範與重構
- 一.理論一:什麼情況下要重構?到底重構什麼?又該如何重構?
- 二.理論二:爲了保證重構不出錯,有哪些非常能落地的技術手段?
- 三.理論三:什麼是代碼的可測試性?如何寫出可測試性好的代碼?
- 三.理論四:如何通過封裝、抽象、模塊化、中間層等解耦代碼?
- 四.理論五:讓你最快速地改善代碼質量的20條編程規範
- 1. 命名
- 2. 註釋
- 3. 類、函數多大才合適?
- 4. 一行代碼多長最合適?
- 5. 善用空行分割單元塊
- 6. 四格縮進還是兩格縮進?
- 7. 大括號是否要另起一行?
- 8. 類中成員的排列順序
- 9. 把代碼分割成更小的單元塊
- 10. 避免函數參數過多
- 11. 勿用函數參數來控制邏輯
- 12. 函數設計要職責單一
- 13. 移除過深的嵌套層次
- 14. 學會使用解釋性變量
- 五.實戰一(上):通過一段ID生成器代碼,學習如何發現代碼質量問題
- 六.實戰一(下):將ID生成器代碼從“能用”重構爲“好用”
- 七. 實戰二(上):程序出錯該返回啥?NULL、異常、錯誤碼、空對象?
- 八. 實戰二(下):重構ID生成器項目中各函數的異常處理代碼
一.理論一:什麼情況下要重構?到底重構什麼?又該如何重構?
1. 重構的目的:爲什麼要重構(why)?
- 重構是時刻保證代碼質量的一個極其有效的手段,不至於讓代碼腐化到無可救藥的地步。
2. 重構的對象:到底重構什麼(what)?
- 大規模高層次重構(簡稱爲“大型重構”)和小規模低層次的重構(以下簡稱爲“小型重構”)
3. 重構的時機:什麼時候重構(when)?
- 持續重構
4. 重構的方法:又該如何重構(how)?
二.理論二:爲了保證重構不出錯,有哪些非常能落地的技術手段?
1. 什麼是單元測試?
- 單元測試的測試對象是類或者函數,用來測試一個類和函數是否都按照預期的邏輯執行。這是代碼層級的測試
2. 爲什麼要寫單元測試?
- 單元測試能有效地幫你發現代碼中的 bug
- 寫單元測試能幫你發現代碼設計上的問題
- 單元測試是對集成測試的有力補充
- 寫單元測試的過程本身就是代碼重構的過程
- 單元測試是 TDD 可落地執行的改進方案
三.理論三:什麼是代碼的可測試性?如何寫出可測試性好的代碼?
1. 單元測試
- 單元測試主要是測試程序員自己編寫的代碼邏輯的正確性,並非是端到端的集成測試,它不需要測試所依賴的外部系統(分佈式鎖、Wallet RPC 服務)的邏輯正確性。所以,如果代碼中依賴了外部系統或者不可控組件,比如,需要依賴數據庫、網絡通信、文件系統等,那我們就需要將被測代碼與外部系統解依賴,而這種解依賴的方法就叫作“mock”。所謂的 mock 就是用一個“假”的服務替換真正的服務。mock 的服務完全在我們的控制之下,模擬輸出我們想要的數據
三.理論四:如何通過封裝、抽象、模塊化、中間層等解耦代碼?
1. “解耦”爲何如此重要?
- 如果說重構是保證代碼質量不至於腐化到無可救藥地步的有效手段,那麼利用解耦的方法對代碼重構,就是保證代碼不至於複雜到無法控制的有效手段。
- 代碼“高內聚、松耦合”,也就意味着,代碼結構清晰、分層和模塊化合理、依賴關係簡單、模塊或類之間的耦合小,那代碼整體的質量就不會差
2. 代碼是否需要“解耦”?
- 問題:該怎麼判斷代碼的耦合程度呢?或者說,怎麼判斷代碼是否符合“高內聚、松耦合”呢?再或者說,如何判斷系統是否需要解耦重構呢?
比如,看修改代碼會不會牽一髮而動全身。除此之外,還有一個直接的衡量標準,也是我在閱讀源碼的時候經常會用到的,那就是把模塊與模塊之間、類與類之間的依賴關係畫出來,根據依賴關係圖的複雜性來判斷是否需要解耦重構。
3. 如何給代碼“解耦”?
- 封裝與抽象
- 中間層
中間層的引入明顯地簡化了依賴關係,讓代碼結構更加清晰
- 模塊化
很多大型軟件(比如 Windows)之所以能做到幾百、上千人有條不紊地協作開發,也歸功於模塊化做得好。不同的模塊之間通過 API 來進行通信,每個模塊之間耦合很小,每個小的團隊聚焦於一個獨立的高內聚模塊來開發,最終像搭積木一樣將各個模塊組裝起來,構建成一個超級複雜的系統。
我們在開發代碼的時候,一定要有模塊化意識,將每個模塊都當作一個獨立的 lib 一樣來開發,只提供封裝了內部實現細節的接口給其他模塊使用,這樣可以減少不同模塊之間的耦合度
- 其他設計思想和原則
(1). 單一職責原則
內聚性和耦合性並非獨立的。高內聚會讓代碼更加松耦合,而實現高內聚的重要指導原則就是單一職責原則。模塊或者類的職責設計得單一,而不是大而全,那依賴它的類和它依賴的類就會比較少,代碼耦合也就相應的降低了。
(2). 基於接口而非實現編程
基於接口而非實現編程能通過接口這樣一箇中間層,隔離變化和具體的實現。這樣做的好處是,在有依賴關係的兩個模塊或類之間,一個模塊或者類的改動,不會影響到另一個模塊或類。實際上,這就相當於將一種強依賴關係(強耦合)解耦爲了弱依賴關係(弱耦合)
(3). 依賴注入
跟基於接口而非實現編程思想類似,依賴注入也是將代碼之間的強耦合變爲弱耦合。儘管依賴注入無法將本應該有依賴關係的兩個類,解耦爲沒有依賴關係,但可以讓耦合關係沒那麼緊密,容易做到插拔替換
(4). 多用組合少用繼承
繼承是一種強依賴關係,父類與子類高度耦合,且這種耦合關係非常脆弱,牽一髮而動全身,父類的每一次改動都會影響所有的子類。相反,組合關係是一種弱依賴關係,這種關係更加靈活,所以,對於繼承結構比較複雜的代碼,利用組合來替換繼承,也是一種解耦的有效手段。
(5). 迪米特法則
迪米特法則講的是,不該有直接依賴關係的類之間,不要有依賴;有依賴關係的類之間,儘量只依賴必要的接口。從定義上,我們明顯可以看出,這條原則的目的就是爲了實現代碼的松耦合
四.理論五:讓你最快速地改善代碼質量的20條編程規範
1. 命名
- 命名多長最合適?
實際上,在足夠表達其含義的情況下,命名當然是越短越好。但是,大部分情況下,短的命名都沒有長的命名更能達意。所以,很多書籍或者文章都不推薦在命名時使用縮寫
熟知的詞,推薦使用縮寫,比如,sec 表示 second、str 表示 string、num 表示 number、doc 表示 document。
對於作用域比較小的變量,我們可以使用相對短的命名,比如一些函數內的臨時變量。相反,對於類名這種作用域比較大的,我更推薦用長的命名方式。
- 利用上下文簡化命名
(1). 例子:
public class User {
private String userName;
private String userPassword;
private String userAvatarUrl;
//...
}
(2). 在 User 類這樣一個上下文中,我們沒有在成員變量的命名中重複添加“user”這樣一個前綴單詞,而是直接命名爲 name、password、avatarUrl。在使用這些屬性時候,我們能借助對象這樣一個上下文,表意也足夠明確
- 命名要可讀、可搜索
(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. 類、函數多大才合適?
- 當一個類的代碼讀起來讓你感覺頭大了,實現某個功能時不知道該用哪個函數了,想用哪個函數翻半天都找不到了,只用到一個小功能要引入整個類(類中包含很多無關此功能實現的函數)的時候,這就說明類的行數過多了
4. 一行代碼多長最合適?
- 總體上來講我們要遵循的一個原則是:一行代碼最長不能超過 IDE 顯示的寬度。需要滾動鼠標才能查看一行的全部代碼,顯然不利於代碼的閱讀。當然,這個限制也不能太小,太小會導致很多稍長點的語句被折成兩行,也會影響到代碼的整潔,不利於閱讀
5. 善用空行分割單元塊
- 在類的成員變量與函數之間、靜態成員變量與普通成員變量之間、各函數之間、甚至各成員變量之間,我們都可以通過添加空行的方式,讓這些不同模塊的代碼之間,界限更加明確
6. 四格縮進還是兩格縮進?
- Java 語言傾向於兩格縮進,PHP 語言傾向於四格縮進。
- 不管是用兩格縮進還是四格縮進,一定不要用 tab 鍵縮進。因爲在不同的 IDE 下,tab 鍵的顯示寬度不同,有的顯示爲四格縮進,有的顯示爲兩格縮進。如果在同一個項目中,不同的同事使用不同的縮進方式(空格縮進或 tab 鍵縮進),有可能會導致有的代碼顯示爲兩格縮進、有的代碼顯示爲四格縮進
7. 大括號是否要另起一行?
- 推薦,將括號放到跟語句同一行的風格。理由跟上面類似,節省代碼行數
8. 類中成員的排列順序
- 排列順序
在類中,成員變量排在函數的前面。成員變量之間或函數之間,都是按照“先靜態(靜態函數或靜態成員變量)、後普通(非靜態函數或非靜態成員變量)”的方式來排列的
9. 把代碼分割成更小的單元塊
- 要有模塊化和抽象思維,善於將大塊的複雜邏輯提煉成類或者函數,屏蔽掉細節,讓閱讀代碼的人不至於迷失在細節中,這樣能極大地提高代碼的可讀性
10. 避免函數參數過多
- 函數包含 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. 勿用函數參數來控制邏輯
-
不要在函數中使用布爾類型的標識參數來控制內部邏輯,true 的時候走這塊邏輯,false 的時候走另一塊邏輯。這明顯違背了單一職責原則和接口隔離原則。我建議將其拆成兩個函數,可讀性上也要更好
-
代碼
public void buyCourse(long userId, long courseId, boolean isVip);
// 將其拆分成兩個函數
public void buyCourse(long userId, long courseId);
public void buyCourseForVip(long userId, long courseId);
-
不過,如果函數是 private 私有函數,影響範圍有限,或者拆分之後的兩個函數經常同時被調用,我們可以酌情考慮保留標識參數
-
除了布爾類型作爲標識參數來控制邏輯的情況外,還有一種“根據參數是否爲 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. 函數設計要職責單一
- 單一職責原則的時候,針對的是類、模塊這樣的應用對象。實際上,對於函數的設計來說,更要滿足單一職責原則。相對於類和模塊,函數的粒度比較小,代碼行數少,所以在應用單一職責原則的時候,沒有像應用到類或者模塊那樣模棱兩可,能多單一就多單一。
- 例子代碼:
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. 移除過深的嵌套層次
- 過深的嵌套本身理解起來就比較費勁,除此之外,嵌套過深很容易因爲代碼多次縮進,導致嵌套內部的語句超過一行的長度而折成兩行,影響代碼的整潔。
14. 學會使用解釋性變量
- 常量取代魔法數字。
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;
}
- 使用解釋性變量來解釋複雜表達式
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. 如何發現代碼質量問題?
-
常規checkList
-
根據常規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() 函數本身的可測試性也不好,需要做比較大的重構
- 業務需求checkList
六.實戰一(下):將ID生成器代碼從“能用”重構爲“好用”
1. 重構計劃
- 循序漸進、小步快跑。重構代碼的過程也應該遵循這樣的思路。每次改動一點點,改好之後,再進行下一輪的優化,保證每次對代碼的改動不會過大,能在很短的時間內完成
- 第一輪重構:提高代碼的可讀性
- 第二輪重構:提高代碼的可測試性
- 第三輪重構:編寫完善的單元測試
- 第四輪重構:所有重構完成之後添加註釋
2. 第一輪重構:提高代碼的可讀性
- 問題
- hostName 變量不應該被重複使用,尤其當這兩次使用時的含義還不同的時候;
- 將獲取 hostName 的代碼抽離出來,定義爲 getLastfieldOfHostName() 函數;
- 刪除代碼中的魔法數,比如,57、90、97、122;
- 將隨機數生成的代碼抽離出來,定義爲 generateRandomAlphameric() 函數
- generate() 函數中的三個 if 邏輯重複了,且實現過於複雜,我們要對其進行簡化
- 對 IdGenerator 類重命名,並且抽象出對應的接口
- 重構後代碼
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. 第二輪重構:提高代碼的可測試性
-
可測試性問題包含兩方面
-
generate() 函數定義爲靜態函數,會影響使用該函數的代碼的可測試性
我們將 RandomIdGenerator 類中的 generate() 靜態函數重新定義成了普通函數。調用者可以通過依賴注入的方式,在外部創建好 RandomIdGenerator 對象後注入到自己的代碼中,從而解決靜態函數調用影響代碼可測試性的問題。
- generate() 函數的代碼實現依賴運行環境(本機名)、時間函數、隨機函數,所以 generate() 函數本身的可測試性也不好
(1). 從 getLastfieldOfHostName() 函數中,將邏輯比較複雜的那部分代碼剝離出來,定義爲 getLastSubstrSplittedByDot() 函數。因爲 getLastfieldOfHostName() 函數依賴本地主機名,所以,剝離出主要代碼之後這個函數變得非常簡單,可以不用測試。我們重點測試 getLastSubstrSplittedByDot() 函數即可
(2). 將 generateRandomAlphameric() 和 getLastSubstrSplittedByDot() 這兩個函數的訪問權限設置爲 protected。這樣做的目的是,可以直接在單元測試中通過對象來調用兩個函數進行測試
(3). 給 generateRandomAlphameric() 和 getLastSubstrSplittedByDot() 兩個函數添加 Google Guava 的 annotation @VisibleForTesting。這個 annotation 沒有任何實際的作用,只起到標識的作用,告訴其他人說,這兩個函數本該是 private 訪問權限的,之所以提升訪問權限到 protected,只是爲了測試,只能用於單元測試中。
- 重構後代碼
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. 函數出錯應該返回啥?
- 返回4種情況:錯誤碼、NULL 值、空對象、異常對象。
2. 錯誤碼
- 儘量不要使用錯誤碼。異常相對於錯誤碼,有諸多方面的優勢,比如可以攜帶更多的錯誤信息(exception 中可以有 message、stack trace 等信息)等。
3. 返回 NULL 值
- 儘管返回 NULL 值有諸多弊端,但對於以 get、find、select、search、query 等單詞開頭的查找函數來說,數據不存在,並非一種異常情況,這是一種正常行爲。所以,返回代表不存在語義的 NULL 值比返回異常更加合理。
- 看項目中的其他類似查找函數都是如何定義的,只要整個項目遵從統一的約定即可
4. 返回空對象
- 當函數返回的數據是字符串類型或者集合類型的時候,我們可以用空字符串或空集合替代 NULL 值,來表示不存在的情況。這樣,我們在使用函數的時候,就可以不用做 NULL 值判斷。
5. 拋出異常對象
- 直接吞掉
如果 func1() 拋出的異常是可以恢復,且 func2() 的調用方並不關心此異常,我們完全可以在 func2() 內將 func1() 拋出的異常吞掉
public void func1() throws Exception1 {
// ...
}
public void func2() {
//...
try {
func1();
} catch(Exception1 e) {
log.warn("...", e); //吐掉:try-catch打印日誌
}
//...
}
- 原封不動地 re-throw。
如果 func1() 拋出的異常對 func2() 的調用方來說,也是可以理解的、關心的 ,並且在業務概念上有一定的相關性,我們可以選擇直接將 func1 拋出的異常 re-throw;
public void func1() throws Exception1 {
// ...
}
public void func2() throws Exception1 {//原封不動的re-throw Exception1
//...
func1();
//...
}
- 包裝成新的異常 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() 函數
- 重構前
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;
}
-
主機名有可能獲取失敗。在目前的代碼實現中,如果主機名獲取失敗,substrOfHostName 爲 NULL,那 generate() 函數會返回類似“null-16723733647-83Ab3uK6”這樣的數據
-
更傾向於明確地將異常告知調用者。所以,這裏最好是拋出受檢異常,而非特殊值
-
重構後
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() 函數
- 重構前
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;
}
- 問題:
現在的處理方式是當主機名獲取失敗的時候,getLastFieldOfHostName() 函數返回 NULL 值。我們前面講過,是返回 NULL 值還是異常對象,要看獲取不到數據是正常行爲,還是異常行爲。獲取主機名失敗會影響後續邏輯的處理,並不是我們期望的,所以,它是一種異常行爲。這裏最好是拋出異常,而非返回 NULL 值
- 是否封裝成新的異常拋出
要看函數跟異常是否有業務相關性。getLastFieldOfHostName() 函數用來獲取主機名的最後一個字段,UnknownHostException 異常表示主機名獲取失敗,兩者算是業務相關,所以可以直接將 UnknownHostException 拋出,不需要重新包裹成新的異常。
- 重構後
private String getLastFieldOfHostName() throws UnknownHostException{
String substrOfHostName = null;
String hostName = InetAddress.getLocalHost().getHostName();
substrOfHostName = getLastSubstrSplittedByDot(hostName);
return substrOfHostName;
}
-
getLastFieldOfHostName() 函數修改之後,generate() 函數也要做相應的修改。我們需要在 generate() 函數中,捕獲 getLastFieldOfHostName() 拋出的 UnknownHostException 異常。當我們捕獲到這個異常之後,應該怎麼處理呢?
-
在 generate() 函數中,我們需要捕獲 UnknownHostException 異常,並重新包裹成新的異常 IdGenerationFailureException 往上拋出。之所以這麼做,有下面三個原因
- 調用者在使用 generate() 函數的時候,只需要知道它生成的是隨機唯一 ID,並不關心 ID 是如何生成的。也就說是,這是依賴抽象而非實現編程。如果 generate() 函數直接拋出 UnknownHostException 異常,實際上是暴露了實現細節。
- 從代碼封裝的角度來講,我們不希望將 UnknownHostException 這個比較底層的異常,暴露給更上層的代碼,也就是調用 generate() 函數的代碼。而且,調用者拿到這個異常的時候,並不能理解這個異常到底代表了什麼,也不知道該如何處理。
- UnknownHostException 異常跟 generate() 函數,在業務概念上沒有相關性
- 再次重構
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() 函數
- 對於 getLastSubstrSplittedByDot(String hostName) 函數,如果 hostName 爲 NULL 或者空字符串,這個函數應該返回什麼
- 重構前
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
String[] tokens = hostName.split("\\.");
String substrOfHostName = tokens[tokens.length - 1];
return substrOfHostName;
}
-
理論上講,參數傳遞的正確性應該有程序員來保證,我們無需做 NULL 值或者空字符串的判斷和特殊處理。調用者本不應該把 NULL 值或者空字符串傳遞給 getLastSubstrSplittedByDot() 函數。如果傳遞了,那就是 code bug,需要修復
-
如果函數是 public 的,你無法掌控會被誰調用以及如何調用(有可能某個同事一時疏忽,傳遞進了 NULL 值,這種情況也是存在的),爲了儘可能提高代碼的健壯性,我們最好是在 public 函數中做 NULL 值或空字符串的判斷。
-
重構後
@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;
}
- 我們在使用這個函數的時候,自己也要保證不傳遞 NULL 值或者空字符串進去。所以,getLastFieldOfHostName() 函數的代碼也要作相應的修改
- 重構後
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() 函數
- 對於 generateRandomAlphameric(int length) 函數,如果 length < 0 或 length = 0,是一種異常行爲,所以,當傳入的參數 length < 0 的時候,我們拋出 IllegalArgumentException 異常
- 重構前
@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 代碼
- 重構後
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);
}
}