編寫高質量程式碼:改善Java程式的151個建議(第1章:JAVA開發中通用的方法和準則___建議16~20)

阿赫瓦里發表於2016-09-11

建議16:易變業務使用指令碼語言編寫

  Java世界一直在遭受著異種語言的入侵,比如PHP,Ruby,Groovy、Javascript等,這些入侵者都有一個共同特徵:全是同一類語言-----指令碼語言,它們都是在執行期解釋執行的。為什麼Java這種強編譯型語言會需要這些指令碼語言呢?那是因為指令碼語言的三大特徵,如下所示:

  1. 靈活:指令碼語言一般都是動態型別,可以不用宣告變數型別而直接使用,可以再執行期改變型別。  
  2. 便捷:指令碼語言是一種解釋性語言,不需要編譯成二進位制程式碼,也不需要像Java一樣生成位元組碼。它的執行時依靠直譯器解釋的,因此在執行期間變更程式碼很容易,而且不用停止應用;
  3. 簡單:只能說部分指令碼語言簡單,比如Groovy,對於程式設計師來說,沒有多大的門檻。

  指令碼語言的這些特性是Java缺少的,引入指令碼語言可以使Java更強大,於是Java6開始正式支援指令碼語言。但是因為指令碼語言比較多,Java的開發者也很難確定該支援哪種語言,於是JSCP(Java Community ProCess)很聰明的提出了JSR233規範,只要符合該規範的語言都可以在Java平臺上執行(它對JavaScript是預設支援的)。

  簡單看看下面這個小例子:

function formual(var1, var2){
     return var1 + var2 * factor;
 }

這就是一個簡單的指令碼語言函式,可能你會很疑惑:factor(因子)這個變數是從那兒來的?它是從上下文來的,類似於一個執行的環境變數。該js儲存在C:/model.js中,下一步需要呼叫JavaScript公式,程式碼如下:

 1 import java.io.FileNotFoundException;
 2 import java.io.FileReader;
 3 import java.util.Scanner;
 4 
 5 import javax.script.Bindings;
 6 import javax.script.Invocable;
 7 import javax.script.ScriptContext;
 8 import javax.script.ScriptEngine;
 9 import javax.script.ScriptEngineManager;
10 import javax.script.ScriptException;
11 
12 public class Client16 {
13     public static void main(String[] args) throws FileNotFoundException,
14             ScriptException, NoSuchMethodException {
15         // 獲得一個JavaScript執行引擎
16         ScriptEngine engine = new ScriptEngineManager().getEngineByName("javascript");
17         // 建立上下文變數
18         Bindings bind = engine.createBindings();
19         bind.put("factor", 1);
20         // 繫結上下文,作用於是當前引擎範圍
21         engine.setBindings(bind, ScriptContext.ENGINE_SCOPE);
22         Scanner input =new Scanner(System.in);
23         
24         while(input.hasNextInt()){
25             int first = input.nextInt();
26             int second = input.nextInt();
27             System.out.println("輸入引數是:"+first+","+second);
28             // 執行Js程式碼
29             engine.eval(new FileReader("C:/model.js"));
30             // 是否可呼叫方法
31             if (engine instanceof Invocable) {
32                 Invocable in = (Invocable) engine;
33                 // 執行Js中的函式
34                 Double result = (Double) in.invokeFunction("formula", first, second);
35                 System.out.println("運算結果是:" + result.intValue());
36             }
37         }
38 
39     }
40 }

上段程式碼使用Scanner類接受鍵盤輸入的兩個數字,然後呼叫JavaScript指令碼的formula函式計算其結果,注意,除非輸入了一個非int數字,否則當前JVM會一直執行,這也是模擬生成系統的線上變更情況。執行結果如下:

 輸入引數是;1,2  運算結果是:3

此時,保持JVM的執行狀態,我們修改一下formula函式,程式碼如下:

function formual(var1, var2){
     return var1 + var2 - factor;
 }

其中,乘號變成了減號,計算公式發生了重大改變。回到JVM中繼續輸入,執行結果如下:

輸入引數:1,2  執行結果是:2

     修改Js程式碼,JVM沒有重啟,輸入引數也沒有任何改變,僅僅改變指令碼函式即可產生不同的效果。這就是指令碼語言對系統設計最有利的地方:可以隨時釋出而不用部署;這也是我們javaer最喜愛它的地方----即使進行變更,也能提供不間斷的業務服務。

   Java6不僅僅提供了程式碼級的指令碼內建,還提供了jrunscript命令工具,它可以再批處理中發揮最大效能,而且不需要通過JVM解釋指令碼語言,可以直接通過該工具執行指令碼。想想看。這是多麼大的誘惑力呀!而且這個工具是可以跨作業系統的,指令碼移植就更容易了。

建議17:慎用動態編譯

   動態編譯一直是java的夢想,從Java6開始支援動態編譯了,可以再執行期直接編譯.java檔案,執行.class,並且獲得相關的輸入輸出,甚至還能監聽相關的事件。不過,我們最期望的還是定一段程式碼,直接編譯,然後執行,也就是空中編譯執行(on-the-fly),看如下程式碼:

 1 import java.io.IOException;
 2 import java.lang.reflect.Method;
 3 import java.net.URI;
 4 import java.util.ArrayList;
 5 import java.util.Arrays;
 6 import java.util.List;
 7 
 8 import javax.tools.JavaCompiler;
 9 import javax.tools.JavaFileObject;
10 import javax.tools.SimpleJavaFileObject;
11 import javax.tools.StandardJavaFileManager;
12 import javax.tools.ToolProvider;
13 
14 public class Client17 {
15     public static void main(String[] args) throws Exception {
16         // Java原始碼
17         String sourceStr = "public class Hello { public String sayHello (String name) {return \"Hello,\"+name+\"!\";}}";
18         // 類名及檔名
19         String clsName = "Hello";
20         // 方法名
21         String methodName = "sayHello";
22         // 當前編譯器
23         JavaCompiler cmp = ToolProvider.getSystemJavaCompiler();
24         // Java標準檔案管理器
25         StandardJavaFileManager fm = cmp.getStandardFileManager(null, null,
26                 null);
27         // Java檔案物件
28         JavaFileObject jfo = new StringJavaObject(clsName, sourceStr);
29         // 編譯引數,類似於javac <options>中的options
30         List<String> optionsList = new ArrayList<String>();
31         // 編譯檔案的存放地方,注意:此處是為Eclipse工具特設的
32         optionsList.addAll(Arrays.asList("-d", "./bin"));
33         // 要編譯的單元
34         List<JavaFileObject> jfos = Arrays.asList(jfo);
35         // 設定編譯環境
36         JavaCompiler.CompilationTask task = cmp.getTask(null, fm, null,
37                 optionsList, null, jfos);
38         // 編譯成功
39         if (task.call()) {
40             // 生成物件
41             Object obj = Class.forName(clsName).newInstance();
42             Class<? extends Object> cls = obj.getClass();
43             // 呼叫sayHello方法
44             Method m = cls.getMethod(methodName, String.class);
45             String str = (String) m.invoke(obj, "Dynamic Compilation");
46             System.out.println(str);
47         }
48 
49     }
50 }
51 
52 class StringJavaObject extends SimpleJavaFileObject {
53     // 原始碼
54     private String content = "";
55 
56     // 遵循Java規範的類名及檔案
57     public StringJavaObject(String _javaFileName, String _content) {
58         super(_createStringJavaObjectUri(_javaFileName), Kind.SOURCE);
59         content = _content;
60     }
61 
62     // 產生一個URL資源路徑
63     private static URI _createStringJavaObjectUri(String name) {
64         // 注意,此處沒有設定包名
65         return URI.create("String:///" + name + Kind.SOURCE.extension);
66     }
67 
68     // 文字檔案程式碼
69     @Override
70     public CharSequence getCharContent(boolean ignoreEncodingErrors)
71             throws IOException {
72         return content;
73     }
74 }

上面程式碼較多,可以作為一個動態編譯的模板程式。只要是在本地靜態編譯能夠實現的任務,比如編譯引數,輸入輸出,錯誤監控等,動態編譯都能實現。

  Java的動態編譯對源提供了多個渠道。比如,可以是字串,文字檔案,位元組碼檔案,還有存放在資料庫中的明文程式碼或者位元組碼。彙總一句話,只要符合Java規範的就可以在執行期動態載入,其實現方式就是實現JavaFileObject介面,重寫getCharContent、openInputStream、openOutputStream,或者實現JDK已經提供的兩個SimpleJavaFileObject、ForwardingJavaFileObject,具體程式碼可以參考上個例子。

  動態編譯雖然是很好的工具,讓我們可以更加自如的控制編譯過程,但是在我們目前所接觸的專案中還是使用較少。原因很簡單,靜態編譯已經能夠幫我們處理大部分的工作,甚至是全部的工作,即使真的需要動態編譯,也有很好的替代方案,比如Jruby、Groovy等無縫的指令碼語言。另外,我們在使用動態編譯時,需要注意以下幾點:

  1. 在框架中謹慎使用:比如要在struts中使用動態編譯,動態實現一個類,它若繼承自ActionSupport就希望它成為一個Action。能做到,但是debug很困難;再比如在Spring中,寫一個動態類,要讓它注入到Spring容器中,這是需要花費老大功夫的。
  2. 不要在要求效能高的專案中使用:如果你在web介面上提供了一個功能,允許上傳一個java檔案然後執行,那就等於說:"我的機器沒有密碼,大家都可以看看",這是非常典型的注入漏洞,只要上傳一個惡意Java程式就可以讓你所有的安全工作毀於一旦。
  3. 記錄動態編譯過程:建議記錄原始檔,目標檔案,編譯過程,執行過程等日誌,不僅僅是為了診斷,還是為了安全和審計,對Java專案來說,空中編譯和執行時很不讓人放心的,留下這些依據可以很好地優化程式。

建議18:避免instanceof非預期結果

 instanceof是一個簡單的二元操作符,它是用來判斷一個物件是否是一個類的實現,其操作類似於>=、==,非常簡單,我們看段程式,程式碼如下:  

 1 import java.util.Date;
 2 
 3 public class Client18 {
 4     public static void main(String[] args) {
 5         // String物件是否是Object的例項 true
 6         boolean b1 = "String" instanceof Object;
 7         // String物件是否是String的例項 true
 8         boolean b2 = new String() instanceof String;
 9         // Object物件是否是String的例項 false
10         boolean b3 = new Object() instanceof String;
11         // 拆箱型別是否是裝箱型別的例項 編譯不通過
12         boolean b4 = 'A' instanceof Character;
13         // 空物件是否是String的例項 false
14         boolean b5 = null instanceof String;
15         // 轉換後的空物件是否是String的例項 false
16         boolean b6 = (String) null instanceof String;
17         // Date是否是String的例項 編譯不通過
18         boolean b7 = new Date() instanceof String;
19         // 在泛型型別中判斷String物件是否是Date的例項 false
20         boolean b8 = new GenericClass<String>().isDateInstance("");
21 
22     }
23 }
24 
25 class GenericClass<T> {
26     // 判斷是否是Date型別
27     public boolean isDateInstance(T t) {
28         return t instanceof Date;
29     }
30 
31 }

就這麼一段程式,instanceof的應用場景基本都出現了,同時問題也產生了:這段程式中哪些語句編譯不通過,我們一個一個的解釋說:

  1. "String" instanceof Object:返回值是true,這很正常,"String"是一個字串,字串又繼承了Object,那當然返回true了。
  2. new String() instanceof String:返回值是true,沒有任何問題,一個類的物件當然是它的例項了。
  3. new Object() instanceof String:返回值為false,Object是父類,其物件當然不是String類的例項了。要注意的是,這句話其實完全可以編譯通過,只要instanceof關鍵字的左右兩個運算元有繼承或實現關係,就可以編譯通過。
  4. 'A' instanceof Character:這句話編譯不通過,為什麼呢?因為'A'是一個char型別,也就是一個基本型別,不是一個物件,instanceof只能用於物件的判斷,不能用於基本型別的判斷。
  5. null instanceof String:返回值為false,這是instanceof特有的規則,若做運算元為null,結果就直接返回false,不再運算右運算元是什麼類。這對我們的程式非常有利,在使用instanceof操作符時,不用關心被判斷的類(也就是左運算元)是否為null,這與我們經常用到的equals、toString方法不同。
  6. (String) null instanceof String:返回值為false,不要看這裡有個強制型別轉換就認為結果是true,不是的,null是一個萬用型別,也就是說它可以沒型別,即使做型別轉換還是個null。
  7. new Date() instanceof String:編譯不通過,因為Date類和String沒有繼承或實現關係,所以在編譯時就直接報錯了,instanceof操作符的左右運算元必須有繼承或實現關係,否則編譯會失敗。
  8. new GenericClass<String>().isDateInstance(""):編譯不通過,非也,編譯通過了,返回值為false,T是個String型別,於Date之間沒有繼承或實現關係,為什麼"t instanceof Date"會編譯通過呢?那是因為Java的泛型是為編碼服務的,在編譯成位元組碼時,T已經是Object型別了傳遞的實參是String型別,也就是說T的表面型別是Object,實際型別是String,那麼"t instanceof Date"等價於"Object instanceof Date"了,所以返回false就很正常了。

建議19:斷言絕對不是雞肋

  在防禦式程式設計中經常會用斷言(Assertion)對引數和環境做出判斷,避免程式因不當的判斷或輸入錯誤而產生邏輯異常,斷言在很多語言中都存在,C、C++、Python都有不同的斷言表現形式.在Java中斷言使用的是assert關鍵字,其基本用法如下:

  assert<布林表示式>

  assert<布林表示式> : <錯誤資訊>

在布林表示式為假時,跑出AssertionError錯誤,並附帶了錯誤資訊。assert的語法比較簡單,有以下兩個特性:

  (1)、assert預設是不啟用的

      我們知道斷言是為除錯程式服務的,目的是為了能夠迅速、方便地檢查到程式異常,但Java在預設條件下是不啟用的,要啟用就要在編譯、執行時加上相關的關鍵字,這就不多說,有需要的話可以參考一下Java規範。

  (2)、assert跑出的異常AssertionError是繼承自Error的

      斷言失敗後,JVM會丟擲一個AssertionError的錯誤,它繼承自Error,注意,這是一個錯誤,不可恢復,也就是表明這是一個嚴重問題,開發者必須予以關注並解決之。

  assert雖然是做斷言的,但不能將其等價於if...else...這樣的條件判斷,它在以下兩種情況下不可使用:

  (1)、在對外的公開方法中

    我們知道防禦式程式設計最核心的一點就是:所有的外部因素(輸入引數、環境變數、上下文)都是"邪惡"的,都存在著企圖摧毀程式的罪惡本源,為了抵制它,我們要在程式處處檢驗。滿地設卡,不滿足條件,就不執行後續程式,以保護後續程式的正確性,處處設卡沒問題,但就是不能用斷言做輸入校驗,特別是公開方法。我們開看一個例子: 

 1 public class Client19 {
 2     public static void main(String[] args) {
 3         System.out.println(StringUtils.encode(null));;
 4     }
 5 }
 6 
 7 class StringUtils{
 8     public static String encode(String str){
 9         assert    str != null : "加密的字串為null";
10         /*加密處理*/
11         return str;
12         
13     }
14 }

  encode方法對輸入引數做了不為空的假設,如果為空,則丟擲AssertionError錯誤,但這段程式存在一個嚴重的問題,encode是一個public方法,這標誌著它時對外公開的,任何一個類只要能傳遞一個String型別的引數(遵守契約)就可以呼叫,但是Client19類按照規定和契約呼叫encode方法,卻獲得了一個AssertionError錯誤資訊,是誰破壞了契約協議?---是encode方法自己。

  (2)、在執行邏輯程式碼的情況下

    assert的支援是可選的,在開發時可以讓他執行,但在生產環境中系統則不需要其執行了(以便提高效能),因此在assert的布林表示式中不能執行邏輯程式碼,否則會因為環境的不同而產生不同的邏輯,例如: 

public void doSomething(List list, Object element) {
        assert list.remove(element) : "刪除元素" + element + "失敗";
        /*業務處理*/
    }

這段程式碼在assert啟用的環境下沒有任何問題,但是一但投入到生成環境,就不會啟用斷言了,而這個方法就徹底完蛋了,list的刪除動作永遠不會執行,所以就永遠不會報錯或異常了,因為根本就沒有執行嘛!

  以上兩種情況下不能使用斷言assert,那在什麼情況下能夠使用assert呢?一句話:按照正常的執行邏輯不可能到達的程式碼區域可以防止assert。具體分為三種情況:

  1. 在私有方法中放置assert作為輸入引數的校驗:在私有方法中可以放置assert校驗輸入引數,因為私有方法的使用者是作者自己,私有的方法的呼叫者和被呼叫者是一種契約關係,或者說沒有契約關係,期間的約束是靠作者自己控制的,因此加上assert可以更好地預防自己犯錯,或者無意的程式犯錯。
  2. 流程控制中不可能到達的區域:這類似於Junit的fail方法,其標誌性的意義就是,程式執行到這裡就是錯誤的,例如:
public void doSomething() {
        int i = 7;
        while (i > 7) {
            /* 業務處理 */
        }
        assert false : "到達這裡就表示錯誤";
    }

3.建立程式探針:我們可能會在一段程式中定義兩個變數,分別代兩個不同的業務含義,但是兩者有固定的關係,例如:var1=var2 * 2,那我們就可以在程式中到處設"樁"了,斷言這兩者的關係,如果不滿足即表明程式已經出現了異常,業務也就沒有必要執行下去了。

建議20:不要只替換一個類

   我們經常在系統中定義一個常量介面(或常量類),以囊括系統中所涉及的常量,從而簡化程式碼,方便開發,在很多的開源專案中已經採用了類似的方法,比如在struts2中,org.apache.struts2.StrutsConstants就是一個常量類,它定義Struts框架中與配置有關的常量,而org.apache.struts2.StrutsConstants則是一個常量介面,其中定義了OGNL訪問的關鍵字。

  關於常量介面(類)我們開看一個例子,首先定義一個常量類:

public class Constant {
    //定義人類壽命極限
    public static final int MAX_AGE=150;
}

這是一個非常簡單的常量類,定義了人類的最大年齡,我們引用這個常量,程式碼如下: 

public class Client{
    public static void main(String[] args) {
        System.out.println("人類的壽命極限是:"+Constant.MAX_AGE);
    }
}

  執行結果easy,故省略。目前的程式碼是寫在"智慧型"IDE工具中完成的,下面暫時回溯到原始時代,也就是迴歸到用記事本編寫程式碼的年代,然後看看會發生什麼事情(為什麼要如此,下面會給出答案)

  修改常量Constant類,人類的壽命極限增加了,最大活到180,程式碼如下:

public class Constant {
    //定義人類壽命極限
    public static final int MAX_AGE=180;
}

  然後重新編譯,javac Constant,編譯完成後執行:java Client,大家猜猜輸出的年齡是多少?

  輸出的結果是:"人類的壽命極限是150",竟然沒有改成180,太奇怪了,這是為何?

  原因是:對於final修飾的基本型別和String型別,編譯器會認為它是穩定態的(Immutable Status)所以在編譯時就直接把值編譯到位元組碼中了,避免了在執行期引用(Run-time Reference),以提高程式碼的執行效率。對於我們的例子來說,Client類在編譯時位元組碼中就寫上了"150",這個常量,而不是一個地址引用,因此無論你後續怎麼修改常量類,只要不重新編譯Client類,輸出還是照舊。

  對於final修飾的類(即非基本型別),編譯器會認為它不是穩定態的(Mutable Status),編譯時建立的則是引用關係(該型別也叫作Soft Final)。如果Client類引入的常量是一個類或例項,及時不重新編譯也會輸出最新值。

  千萬不可小看了這點知識,細坑也能絆倒大象,比如在一個web專案中,開發人員修改了一個final型別的值(基本型別)考慮到重新發布的風險較大,或者是審批流程過於繁瑣,反正是為了偷懶,於是直接採用替換class類檔案的方式釋出,替換完畢後應用伺服器自動重啟,然後簡單測試一下,一切Ok,可執行幾天後發現業務資料對不上,有的類(引用關係的類)使用了舊值,有的類(繼承關係的類)使用的是新值,而且毫無頭緒,讓人一籌莫展,其實問題的根源就在於此。

  還有個小問題沒有說明,我們的例子為什麼不在IDE工具(比如Eclipse)中執行呢?那是因為在IDE中設定了自動編譯不能重現此問題,若修改了Constant類,IDE工具會自動編譯所有的引用類,"智慧"化遮蔽了該問題,但潛在的風險其實仍然存在,我記得Eclipse應該有個設定自動編譯的入口,有興趣大家可以自己嘗試一下。

  注意:釋出應用系統時禁止使用類檔案替換方式,整體WAR包釋出才是萬全之策。但我覺得應特殊情況特殊對待,並不可以偏概全,大家以為呢?

相關文章