追求程式碼質量: 用 AOP 進行防禦性程式設計

發表於2019-05-11
雖然防禦性程式設計有效地保證了方法輸入的條件,但如果在一系列方法中使用它,不免過於重複。本月,Andrew Glover 將向您展示通過一種更為容易的方式,即使用 AOP、契約式設計和一個便捷的叫做 OVal 的庫,來向程式碼中新增可重用的驗證約束條件。

開發人員測試的主要缺點是:絕大部分測試都是在理想的場景中進行的。在這些情況下並不會出現缺陷 —— 能導致出現問題的往往是那些邊界情況。
什麼是邊界情況呢?比方說,把 null 值傳入一個並未編寫如何處理 null 值的方法中,這就是一種邊界情況。大多數開發人員通常都不能成功測試這樣的場景,因為這沒多大意義。但不管有沒有意義,發生了這樣的情況,就會丟擲一個 NullPointerException,然後整個程式就會崩潰。
本月,我將為您推薦一種多層面的方法,來處理程式碼中那些不易預料的缺陷。嘗試為應用程式整合進防禦性程式設計、契約式設計和一種叫做 OVal 的易用的通用驗證框架。
將敵人暴露出來
清單 1 中的程式碼為給定的 Class 物件(省去了 java.lang.Object,因為所有物件都最終由它擴充套件)構建一個類層次。但如果仔細看的話,您會注意到一個有待發現的潛在缺陷,即該方法對物件值所做的假設。
清單 1. 不檢驗 null 的方法
public static Hierarchy buildHierarchy(Class clzz){

 Hierarchy hier = new Hierarchy();
 hier.setBaseClass(clzz);
 Class superclass = clzz.getSuperclass();

 if(superclass != null && superclass.getName().equals("java.lang.Object")){
  return hier; 
 }else{      
  while((clzz.getSuperclass() != null) && 
    (!clzz.getSuperclass().getName().equals("java.lang.Object"))){
     clzz = clzz.getSuperclass();
     hier.addClass(clzz);
  }                
  return hier;
 }
}
剛編好這個方法,我還沒注意到這個缺陷,但由於我狂熱地崇拜開發人員測試,於是我編寫了一個使用 TestNG 的常規測試。而且,我還利用了 TestNG 方便的 DataProvider 特性,藉助該特性,我建立了一個通用的測試用例並通過另一個方法來改變它的引數。執行清單 2 中定義的測試用例會產生兩個通過結果!一切都運轉良好,不是嗎?
清單 2. 驗證兩個值的 TestNG 測試
import java.util.Vector;
import static org.testng.Assert.assertEquals;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;

public class BuildHierarchyTest {
        
 @DataProvider(name = "class-hierarchies")
 public Object[][] dataValues(){
  return new Object[][]{
   {Vector.class, new String[] {"java.util.AbstractList", 
      "java.util.AbstractCollection"}},
   {String.class, new String[] {}}
  };
 }

 @Test(dataProvider = "class-hierarchies"})
 public void verifyHierarchies(Class clzz, String[] names) throws Exception{
  Hierarchy hier = HierarchyBuilder.buildHierarchy(clzz);
  assertEquals(hier.getHierarchyClassNames(), names, "values were not equal");
 }
}
至此,我還是沒有發現缺陷,但一些程式碼問題卻困擾著我。如果有人不經意地為 Class 引數傳入一個 null 值會怎麼樣呢?清單 1 中第 4 行的 clzz.getSuperclass() 呼叫會丟擲一個 NullPointerException,是這樣嗎?
測試我的理論很容易;甚至都不用從頭開始。僅僅把 {null, null} 新增到初始 BuildHierarchyTest 的 dataValues 方法中的多維 Object 陣列中,然後再次執行它。我定會得到如圖 1 所示的 NullPointerException:
圖 1. 可怕的 NullPointerException
[img=AOP,防禦性程式設計,572,]http://www.ibm.com/developerworks/cn/java/j-cq01307/testng-failure.jpg[/img]
參見這裡的 全圖。
防禦性程式設計
一旦出現這個問題,下一步就是要拿出對抗的策略。問題是我控制不了這個方法能否接收這種輸入。對於這類問題,開發人員通常會使用防禦性程式設計技術,該技術專門用來在發生摧毀性後果前捕捉潛在錯誤。
物件驗證是處理不確定性的一項經典的防禦性程式設計策略。相應地,我會新增一項檢驗來驗證 clzz 是否為 null,如清單 3 所示。如果其值最終為 null,我就會丟擲一個 RuntimeException 來警告他人注意這個潛在問題
清單 3. 新增驗證 null 值的檢驗
public static Hierarchy buildHierarchy(Class clzz){  if(clzz == null){
  throw new RuntimeException("Class parameter can not be null");
 } Hierarchy hier = new Hierarchy();
 hier.setBaseClass(clzz);

 Class superclass = clzz.getSuperclass();

 if(superclass != null && superclass.getName().equals("java.lang.Object")){
  return hier; 
 }else{      
  while((clzz.getSuperclass() != null) && 
    (!clzz.getSuperclass().getName().equals("java.lang.Object"))){
     clzz = clzz.getSuperclass();
     hier.addClass(clzz);
  }                
  return hier;
 }
}
清單 4. 驗證 null 檢驗
@Test(expectedExceptions={RuntimeException.class})
public void verifyHierarchyNull() throws Exception{
 Class clzz = null;
 HierarchyBuilder.buildHierarchy(null);                
}
本例中,防禦性程式設計似乎解決了問題。但僅依靠這項策略會存在一些缺陷。
防禦的缺陷
儘管防禦性程式設計有效地保證了方法的輸入條件,但如果在一系列方法中使用它,不免過於重複。熟悉面向方面程式設計(或 AOP)的人們會把它認為是橫切關注點,這意味著防禦性程式設計技術橫跨了程式碼庫。許多不同的物件都採用這些語法,儘管從純物件導向的觀點來看這些語法跟物件毫不相關。
而且,橫切關注點開始滲入到契約式設計(DBC)的概念中。DBC 是這樣一項技術,它通過在元件的介面顯式地陳述每個元件應有的功能和客戶機的期望值來確保系統中所有的元件完成它們應盡的職責。從 DBC 的角度講,元件應有的功能被認為是後置條件,本質上就是元件的責任,而客戶機的期望值則普遍被認為是前置條件。另外,在純 DBC 術語中,遵循 DBC 規則的類針對其將維護的內部一致性與外部世界有一個契約,即人所共知的類不變式。
契約式設計
我在以前的一篇關於用 Nice 程式設計的文章中介紹過 DBC 的概念,Nice 是一門與 JRE 相容的物件導向程式語言,它的特點是側重於模組性、可表達性和安全性。有趣的是,Nice 併入了功能性開發技術,其中包括了一些在面向方面程式設計中的技術。功能性開發使得為方法指定前置條件和後置條件成為可能。
儘管 Nice 支援 DBC,但它與 Java™ 語言完全不同,因而很難將其用於開發。幸運的是,很多針對 Java 語言的庫也都為 DBC 提供了方便。每個庫都有其優點和缺點,每個庫在 DBC 內針對 Java 語言進行構建的方法也不同;但最近的一些新特性大都利用了 AOP 來更多地將 DBC 關注點包括進來,這些關注點基本上就相當於方法的包裝器。
前置條件在包裝過的方法執行前擊發,後置條件在該方法完成後擊發。使用 AOP 構建 DBC 結構的一個好處(請不要同該語言本身相混淆!)是:可以在不需要 DBC 關注點的環境中將這些結構關掉(就像斷言能被關掉一樣)。以橫切的方式對待安全性關注點的真正妙處是:可以有效地重用 這些關注點。眾所周知,重用是物件導向程式設計的一個基本原則。AOP 如此完美地補充了 OOP 難道不是一件極好的事情嗎?
結合了 OVal 的 AOP
OVal 是一個通用的驗證框架,它通過 AOP 支援簡單的 DBC 結構並明確地允許:
為類欄位和方法返回值指定約束條件
為結構引數指定約束條件
為方法引數指定約束條件
此外,OVal 還帶來大量預定義的約束條件,這讓建立新條件變得相當容易。
由於 OVal 使用 AspectJ 的 AOP 實現來為 DBC 概念定義建議,所以必須將 AspectJ 併入一個使用 OVal 的專案中。對於不熟悉 AOP 和 AspectJ 的人們來說,好訊息是這不難實現,且使用 OVal (甚至是建立新的約束條件)並不需要真正對方面進行編碼,只需編寫一個簡單的自載入程式即可,該程式會使 OVal 所附帶的預設方面植入您的程式碼中。
在建立這個自載入程式方面前,要先下載 AspectJ。具體地說,您需要將 aspectjtools 和 aspectjrt JAR 檔案併入您的構建中來編譯所需的自載入程式方面並將其編入您的程式碼中。
自引導 AOP
下載了 AspectJ 後,下一步是建立一個可擴充套件 OVal GuardAspect 的方面。它本身不需要做什麼,如清單 5 所示。請確保檔案的副檔名以 .aj 結束,但不要試著用常規的 javac 對其進行編譯。
清單 5. DefaultGuardAspect 自載入程式方面

import net.sf.oval.aspectj.GuardAspect;

public aspect DefaultGuardAspect extends GuardAspect{        
 public DefaultGuardAspect(){
  super();                
 }        
}
AspectJ 引入了一個 Ant 任務,稱為 iajc,充當著 javac 的角色;此過程對方面進行編譯並將其編入主體程式碼中。在本例中,只要是我指定了 OVal 約束條件的地方,在 OVal 程式碼中定義的邏輯就會編入我的程式碼,進而充當起前置條件和後置條件。
請記住 iajc 代替了 javac。例如,清單 6 是我的 Ant build.xml 檔案的一個程式碼片段,其中對程式碼進行了編譯並把通過程式碼標註發現的所有 OVal 方面編入進來,如下所示:
清單 6. 用 AOP 編譯的 Ant 構建檔案片段
為 OVal 鋪好了路、為 AOP 過程做了引導之後,就可以開始使用 Java 5 標註來為程式碼指定簡單的約束條件了。
OVal 的可重用約束條件
用 OVal 為方法指定前置條件必須對方法引數進行標註。相應地,當呼叫一個用 OVal 約束條件標註過的方法時,OVal 會在該方法真正執行前 驗證該約束條件。
在我的例子中,我想要指定當 Class 引數的值為 null 時,buildHierarchy 方法不能被呼叫。OVal 通過 @NotNull 標註支援此約束條件,該標註在方法所需的所有引數前指定。也要注意,任何想要使用 OVal 約束條件的類也必須在類層次上指定 @Guarded 標註,就像我在清單 7 中所做的那樣:
清單 7. OVal 約束條件

import net.sf.oval.annotations.Guarded;
import net.sf.oval.constraints.NotNull; @Guarded public class HierarchyBuilder {  

 public static Hierarchy buildHierarchy(@NotNull Class clzz){

  Hierarchy hier = new Hierarchy();
  hier.setBaseClass(clzz);

  Class superclass = clzz.getSuperclass();

  if(superclass != null && superclass.getName().equals("java.lang.Object")){
   return hier; 
  }else{      
   while((clzz.getSuperclass() != null) && 
     (!clzz.getSuperclass().getName().equals("java.lang.Object"))){
       clzz = clzz.getSuperclass();
       hier.addClass(clzz);
    }                
   return hier;
  }
 }      
}
通過標註指定這個約束條件意味著我的程式碼不再會被重複的條件弄得亂七八糟,這些條件檢查 null 值,並且一旦找到該值就會丟擲異常。現在這項邏輯由 OVal 處理,且處理的方法有些相似 —— 事實上,如果違反了約束條件,OVal 會丟擲一個 ConstraintsViolatedException,它是 RuntimeException 的子類。
當然,我下一步就要編譯 HierarchyBuilder 類和 清單 5 中相應的 DefaultGuardAspect 類。我用 清單 6 中的 iajc 任務來實現這一目的,這樣我就能把 OVal 的行為編入我的程式碼中了。
接下來,我更新 清單 4 中的測試用例來驗證是否丟擲了一個 ConstraintsViolatedException,如清單 8 所示:
清單 8. 驗證是否丟擲了 ConstraintsViolatedException

@Test(expectedExceptions={ConstraintsViolatedException.class})
public void verifyHierarchyNull() throws Exception{
 Class clzz = null;
 HierarchyBuilder.buildHierarchy(clzz);                
}
指定後置條件
正如您所見,指定前置條件其實相當容易,指定後置條件的過程也是一樣。例如,如果我想對所有呼叫 buildHierarchy 的程式保證它不會返回 null 值(這樣,這些呼叫程式就不需要再檢查這個了),我可以在方法宣告之上放置一個 @NotNull 標註,如清單 9 所示:
清單 9. OVal 中的後置條件

@NotNull public static Hierarchy buildHierarchy(@NotNull Class clzz){   
 //method body
}
當然,@NotNull 絕不是 OVal 提供的惟一約束條件,但我發現它能非常有效地限制這些令人討厭的 NullPointerException,或至少能夠快速地暴露 它們。
更多的 OVal 約束條件
OVal 也支援在方法呼叫前或後對類成員進行預先驗證。這種機制具有限制針對特定約束條件的重複條件測試的好處,如集合大小或之前討論過的非 null 的情況。
例如,在清單 10 中,我使用 HierarchyBuilder 定義了一個為類層次構建報告的 Ant 任務。請注意 execute() 方法是如何呼叫 validate的,後者會依次驗證 fileSet 類成員是否含值;如果不含,會丟擲一個異常,因為沒有了要評估的類,該報告不能執行。
清單 10. 帶條件檢驗的 HierarchyBuilderTask
public class HierarchyBuilderTask extends Task {
 private Report report;
 private List fileSet;

 private void validate() throws BuildException{ if(!(this.fileSet.size() > 0)){
   throw new BuildException("must supply classes to evaluate");
  } if(this.report == null){
   this.log("no report defined, printing XML to System.out");
  }
 }

 public void execute() throws BuildException {
  validate();
  String[] classes = this.getQualifiedClassNames(this.fileSet);
  Hierarchy[] hclz = new Hierarchy[classes.length];

  try{
   for(int x = 0; x < classes.length; x++){ hclz[x] = HierarchyBuilder.buildHierarchy(classes[x]); } BatchHierarchyXMLReport xmler = new BatchHierarchyXMLReport(new Date(), hclz); this.handleReportCreation(xmler); }catch(ClassNotFoundException e){ throw new BuildException("Unable to load class check classpath! " + e.getMessage()); } } //more methods below.... }
因為我用的是 OVal,所以我可以完成下列任務:
  • 對 fileSet 類成員指定一個約束條件,確保使用 @Size 標註時其大小總是至少為 1 或更大。
  • 確保在使用 @PreValidateThis 標註呼叫 execute() 方法前 驗證這個約束條件。
這兩步讓我能夠有效地去除 validate() 方法中的條件檢驗,讓 OVal 為我完成這些,如清單 11 所示:
清單 11. 經過改進、無條件檢驗的 HierarchyBuilderTask
@Guarded public class HierarchyBuilderTask extends Task {
 private Report report; @Size(min = 1) private List fileSet;

 private void validate() throws BuildException {
  if (this.report == null) {
   this.log("no report defined, printing XML to System.out");
  }
 } @PreValidateThis public void execute() throws BuildException {
  validate();
  String[] classes = this.getQualifiedClassNames(this.fileSet);
  Hierarchy[] hclz = new Hierarchy[classes.length];

  try{
   for(int x = 0; x < classes.length; x++){ hclz[x] = HierarchyBuilder.buildHierarchy(classes[x]); } BatchHierarchyXMLReport xmler = new BatchHierarchyXMLReport(new Date(), hclz); this.handleReportCreation(xmler); }catch(ClassNotFoundException e){ throw new BuildException("Unable to load class check classpath! " + e.getMessage()); } } //more methods below.... }
清單 11 中的 execute() 一經呼叫(由 Ant 完成),OVal 就會驗證 fileSet 成員。如果其為空,就意味著沒有指定任何要評估的類,就會丟擲一個 ConstraintsViolatedException。這個異常會暫停這一過程,就像初始程式碼一樣,只不過初始程式碼會丟擲一個 BuildException。
結束語
防禦性程式設計結構阻止了一個又一個缺陷,但這些結構本身卻不免為程式碼新增了重複的邏輯。把防禦性程式設計技術和麵向方面程式設計(通過契約式設計)聯絡起來是抵禦所有重複性程式碼的一道堅強防線。
OVal 並不是惟一可用的 DBC 庫,事實上其 DBC 結構對比其他框架來說是相當有限的(例如,它未提供指定類不變式的簡易方法)。從另一方面講,OVal 很容易使用,對約束條件也有很大的選擇餘地,若想要花少量力氣就可向程式碼新增驗證約束條件,它無疑是個上佳之選。另外,用 OVal 建立定製約束條件也相當簡單,所以請不要再新增條件檢驗了,盡情享用 AOP 吧!


更多精彩文章,點選檢視
回覆

相關文章