建議16:易變業務使用指令碼語言編寫
Java世界一直在遭受著異種語言的入侵,比如PHP,Ruby,Groovy、Javascript等,這些入侵者都有一個共同特徵:全是同一類語言-----指令碼語言,它們都是在執行期解釋執行的。為什麼Java這種強編譯型語言會需要這些指令碼語言呢?那是因為指令碼語言的三大特徵,如下所示:
- 靈活:指令碼語言一般都是動態型別,可以不用宣告變數型別而直接使用,可以再執行期改變型別。
- 便捷:指令碼語言是一種解釋性語言,不需要編譯成二進位制程式碼,也不需要像Java一樣生成位元組碼。它的執行時依靠直譯器解釋的,因此在執行期間變更程式碼很容易,而且不用停止應用;
- 簡單:只能說部分指令碼語言簡單,比如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等無縫的指令碼語言。另外,我們在使用動態編譯時,需要注意以下幾點:
- 在框架中謹慎使用:比如要在struts中使用動態編譯,動態實現一個類,它若繼承自ActionSupport就希望它成為一個Action。能做到,但是debug很困難;再比如在Spring中,寫一個動態類,要讓它注入到Spring容器中,這是需要花費老大功夫的。
- 不要在要求效能高的專案中使用:如果你在web介面上提供了一個功能,允許上傳一個java檔案然後執行,那就等於說:"我的機器沒有密碼,大家都可以看看",這是非常典型的注入漏洞,只要上傳一個惡意Java程式就可以讓你所有的安全工作毀於一旦。
- 記錄動態編譯過程:建議記錄原始檔,目標檔案,編譯過程,執行過程等日誌,不僅僅是為了診斷,還是為了安全和審計,對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的應用場景基本都出現了,同時問題也產生了:這段程式中哪些語句編譯不通過,我們一個一個的解釋說:
-
"String" instanceof Object:返回值是true,這很正常,"String"是一個字串,字串又繼承了Object,那當然返回true了。
-
new String() instanceof String:返回值是true,沒有任何問題,一個類的物件當然是它的例項了。
-
new Object() instanceof String:返回值為false,Object是父類,其物件當然不是String類的例項了。要注意的是,這句話其實完全可以編譯通過,只要instanceof關鍵字的左右兩個運算元有繼承或實現關係,就可以編譯通過。
-
'A' instanceof Character:這句話編譯不通過,為什麼呢?因為'A'是一個char型別,也就是一個基本型別,不是一個物件,instanceof只能用於物件的判斷,不能用於基本型別的判斷。
- null instanceof String:返回值為false,這是instanceof特有的規則,若做運算元為null,結果就直接返回false,不再運算右運算元是什麼類。這對我們的程式非常有利,在使用instanceof操作符時,不用關心被判斷的類(也就是左運算元)是否為null,這與我們經常用到的equals、toString方法不同。
-
(String) null instanceof String:返回值為false,不要看這裡有個強制型別轉換就認為結果是true,不是的,null是一個萬用型別,也就是說它可以沒型別,即使做型別轉換還是個null。
-
new Date() instanceof String:編譯不通過,因為Date類和String沒有繼承或實現關係,所以在編譯時就直接報錯了,instanceof操作符的左右運算元必須有繼承或實現關係,否則編譯會失敗。
-
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。具體分為三種情況:
- 在私有方法中放置assert作為輸入引數的校驗:在私有方法中可以放置assert校驗輸入引數,因為私有方法的使用者是作者自己,私有的方法的呼叫者和被呼叫者是一種契約關係,或者說沒有契約關係,期間的約束是靠作者自己控制的,因此加上assert可以更好地預防自己犯錯,或者無意的程式犯錯。
- 流程控制中不可能到達的區域:這類似於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包釋出才是萬全之策。但我覺得應特殊情況特殊對待,並不可以偏概全,大家以為呢?