編寫高質量代碼:改善Java程序的151個建議(第一章:JAVA開發中通用的方法和準則)

編寫高質量代碼:改善Java程序的151個建議(第一章:JAVA開發中通用的方法和準則)

目錄

JAVA的世界豐富又多彩,但同時也佈滿了經濟陷阱,你們一不當心就可能跌入黑暗的深淵,只有在瞭解了其通行規則後才能是本身在技術的海洋裏遨遊飛翔,恣意馳騁。程序員

千里之行,始於足下,本章主要講述與JAVA語言基礎相關的問題及建議的解決方案和變量的注意事項、如何安全的序列化、斷言到底該如何使用等;web

建議1:不要在常量和變量中出現易混淆的字母

包名全小寫,類名首字母全大寫,常量所有大寫並用下劃線分隔,變量採用駝峯命名法(Camel Case)命名等,這些都是最基本的Java編碼規範,是每一個javaer都應熟知的規則,可是在變量的聲明中要注意不要引入容易混淆的字母。嘗試閱讀以下代碼,思考打印結果的i是多少:算法

public class Demo{
    public static void main(String[] args) {
        test01();
    }
    
    public static void test01(){
        long i=1l;
        System.out.println("i的兩倍是:"+(i+i));
    }
}
  • 確定會有人說:這麼簡單的例子還能出錯?運行結果確定是22!實踐是檢驗真理的惟一標準,將其Run一下看看,或許你會很奇怪,結果是2,而不是22.難道是編譯器出問題了,少了個"2"?數據庫

  • 由於賦給變量i的值就是數字"1",只是後面加了長整型變量的標示字母"l"而已。別說是我挖坑讓你跳,若是有相似程序出如今項目中,當你試圖經過閱讀代碼來理解做者的思想時,此情景就可能會出現。因此爲了讓你的程序更容易理解,字母"l"(包括大寫字母"O")儘可能不要和數字混用,以避免使讀者的理解和程序意圖產生誤差。若是字母和數字混合使用,字母"l"務必大寫,字母"O"則增長註釋。apache

注意:字母"l"做爲長整型標誌時務必大寫。編程

建議2:莫讓常量蛻變成變量

常量蛻變成變量?你胡扯吧,加了final和static的常量怎麼可能會變呢?不可能爲此賦值的呀。真的不可能嗎?看看以下代碼:數組

import java.util.Random;

public class Demo01 {
    public static void main(String[] args) {
        test02();
    }

    public static void test02() {
        System.out.println("常量會變哦:" + Constant.RAND_CONST);
    }
}

interface Constant {
    public static final int RAND_CONST = new Random().nextInt();
}
  • 同一個class文件,屢次運行,RAND_CONST值是不同的;但main方法中屢次調用值是同樣的
  • RAND_CONST是常量嗎?它的值會變嗎?絕對會變!這種常量的定義方式是絕對不可取的,常量就是常量,在編譯期就必須肯定其值,不該該在運行期更改,不然程序的可讀性會很是差,甚至連做者本身都不能肯定在運行期發生了何種神奇的事情。

  • 甭想着使用常量會變的這個功能來實現序列號算法、隨機種子生成,除非這真的是項目中的惟一方案,不然就放棄吧,常量仍是當常量使用。

注意:務必讓常量的值在運行期保持不變。

建議3:三元操做符的類型務必一致

  三元操做符是if-else的簡化寫法,在項目中使用它的地方不少,也很是好用,可是好用又簡單的東西並不表示就能夠隨意使用,看看以下代碼:
  

public static void test03() {
        int i = 80;
        String str = String.valueOf(i < 100 ? 90 : 100);
        String str1 = String.valueOf(i < 100 ? 90 : 100.0);
        System.out.println("二者是否相等:" + str.equals(str1));
    }

  分析一下這段程序,i是80,小於100,二者的返回值確定都是90,再轉成String類型,其值也絕對相等,毋庸置疑的。嗯,分析的有點道理,可是變量str中的三元操做符的第二個操做數是100,而str1中的第二個操做數是100.0,難道木有影響嗎?不可能有影響吧,三元操做符的條件都爲真了,只返回第一個值嘛,於第二個值有毛線關係,貌似有道理。

  運行以後,結果倒是:"二者是否相等:false",不相等,why?

  問題就出在了100和100.0這兩個數字上,在變量str中,三元操做符的第一個操做數90和第二個操做數100都是int類型,類型相同,返回的結果也是int類型的90,而變量str1中的第一個操做數(90)是int類型,第二個操做數100.0是浮點數,也就是兩個操做數的類型不一致,可三元操做符必需要返回一個數據,並且類型要肯定,不可能條件爲真時返回int類型,條件爲假時返回float類型,編譯器是不容許如此的,因此它會進行類型轉換int類型轉換爲浮點數90.0,也就是三元操做符的返回值是浮點數90.0,那麼固然和整型的90不相等了。這裏爲何是整型轉成浮點型,而不是浮點型轉成整型呢?這就涉及三元操做符類型的轉換規則:
  

  • 若兩個操做數不可轉換,則不做轉換,返回值是Object類型;
  • 若兩個操做數是明確類型的表達式(好比變量),則按照正常的二進制數字轉換,int轉爲long,long轉爲float等;
  • 若兩個操做數中有一個是數字S,另一個是表達式,且其類型標誌位T,那麼,若數字S在T的範圍內,則轉換爲T類型;若S超出了T的範圍,則T轉換爲S;
  • 若兩個操做數都是直接量數字,則返回值類型範圍較大者。

    知道什麼緣由了,相應的解決辦法也就有了:保證三元操做符中的兩個操做數類型一致,避免此錯誤的發生。

建議4:避免帶有變長參數的方法重載

  在項目和系統開發中,爲了提升方法的靈活度和可複用性,咱們常常要傳遞不肯定數量的參數到方法中,在JAVA5以前經常使用的設計技巧就是把形參定義成Collection類型或其子類類型,或者數組類型,這種方法的缺點就是須要對空參數進行判斷和篩選,好比實參爲null值和長度爲0的Collection或數組。而Java5引入了變長參數(varags)就是爲了更好地挺好方法的複用性,讓方法的調用者能夠"爲所欲爲"地傳遞實參數量,固然變長參數也是要遵循必定規則的,好比變長參數必須是方法中的最後一個參數;一個方法不能定義多個變長參數等,這些基本規則須要牢記,可是即便記住了這些規則,仍然有可能出現錯誤,看以下代碼:
  

public class Client {
    public static void main(String[] args) {
        Client client = new Client();
        // 499元的貨物 打75折
        client.calPrice(499, 75);
    }

    // 簡單折扣計算
    public void calPrice(int price, int discount) {
        float knockdownPrice = price * discount / 100.0F;
        System.out.println("簡單折扣後的價格是:" + formatCurrency(knockdownPrice));
    }

    // 複雜多折扣計算
    public void calPrice(int price, int... discounts) {
        float knockdownPrice = price;
        for (int discount : discounts) {
            knockdownPrice = knockdownPrice * discount / 100;
        }
        System.out.println("複雜折扣後的價格是:" + formatCurrency(knockdownPrice));
    }

    public String formatCurrency(float price) {
        return NumberFormat.getCurrencyInstance().format(price);
    }
}

  這是一個計算商品折扣的模擬類,帶有兩個參數的calPrice方法(該方法的業務邏輯是:提供商品的原價和折扣率,便可得到商品的折扣價)是一個簡單的折扣計算方法,該方法在實際項目中常常會用到,這是單一的打折方法。而帶有變長參數的calPrice方法是叫較複雜的折扣計算方式,多種折扣的疊加運算(模擬類是比較簡單的實現)在實際中也常常見到,好比在大甩賣期間對VIP會員再度進行打折;或者當天是你的生日,再給你打個9折,也就是俗話中的折上折。

  業務邏輯清楚了,咱們來仔細看看這兩個方法,它們是重載嗎?固然是了,重載的定義是:"方法名相同,參數類型或數量不一樣",很明顯這兩個方法是重載。可是這個重載有點特殊,calPrice(int price ,int... discounts)的參數範疇覆蓋了calPrice(int price,int discount)的參數範疇。那問題就出來了:對於calPrice(499,75)這樣的計算,到底該調用哪一個方法來處理呢?

  咱們知道java編譯器是很聰明的,它在編譯時會根據方法簽名來肯定調用那個方法,好比:calPrice(499,75,95)這個調用,很明顯75和95會被轉成一個包含兩個元素的數組,並傳遞到calPrice(int price,int...discounts)中,由於只有這一個方法符合這個實參類型,這很容易理解。可是咱們如今面對的是calPrice(499,75)調用,這個75既能夠被編譯成int類型的75,也能夠被編譯成int數組{75},即只包含一個元素的數組。那到底該調用哪個方法呢?運行結果是:"簡單折扣後的價格是:374.25"。看來調用了第一個方法,爲何會調用第一個方法,而不是第二個變長方法呢?由於java在編譯時,首先會根據實參的數量和類型(這裏2個實參,都爲int類型,注意沒有轉成int數組)來進行處理,也就是找到calPrice(int price,int discount)方法,並且確認他是否符合方法簽名條件。如今的問題是編譯器爲何會首先根據兩個int類型的實參而不是一個int類型,一個int數組類型的實參來查找方法呢?

  由於int是一個原生數據類型,而數組自己是一個對象,編譯器想要"偷懶",因而它會從最簡單的開始"猜測",只要符合編譯條件的便可經過,因而就出現了此問題。

  問題闡述清楚了,爲了讓咱們的程序能被"人類"看懂,仍是慎重考慮變長參數的方法重載吧,不然讓人傷腦筋不說,說不定哪天就陷入這類小陷阱裏了。

建議5:別讓null值和空值威脅到變長方法  

  上一建議講解了變長參數的重載問題,本建議會繼續討論變長參數的重載問題,上一建議的例子是變長參數的範圍覆蓋了非變長參數的範圍,此次討論兩個都是變長參數的方法提及,代碼以下:

public class Client5 {
    public void methodA(String str, Integer... is) {
    
    }
    
    public void methodA(String str, String... strs) {
    
    }
    
    public static void main(String[] args) {
        Client5 client5 = new Client5();
        client5.methodA("china", 0);
        client5.methodA("china", "people");
        client5.methodA("china");
        client5.methodA("china", null);
    }
}

  兩個methodA都進行了重載,如今的問題是:上面的client5.methodA("china");client5.methodA("china", null);編譯不經過,提示相同:方法模糊不清,編譯器不知道調用哪個方法,但這兩處代碼反應的味道是不一樣的。

  對於methodA("china")方法,根據實參"china"(String類型),兩個方法都符合形參格式,編譯器不知道調用那個方法,因而報錯。咱們思考一下此問題:Client5這個類是一個複雜的商業邏輯,提供了兩個重載方法,從其它模塊調用(系統內本地調用系統或系統外遠程系統調用)時,調用者根據變長參數的規範調用,傳入變長參數的參數數量能夠是N個(N>=0),那固然能夠寫成client5.methodA("china")方法啊!徹底符合規範,可是這個卻讓編譯器和調用者鬱悶,程序符合規則卻不能運行,如此問題,誰之責任呢?是Client5類的設計者,他違反了KISS原則(Keep it Smile,Stupid,即懶人原則),按照此設計的方法應該很容一調用,但是如今遵循規範卻編譯不經過,這對設計者和開發者而言都是應該禁止出現的。

  對於Client5.methodA("China",null),直接量null是沒喲類型的,雖然兩個methodA方法都符合調用要求,但不知道調用哪個,因而報錯了。仔細分析一下,除了不符合上面的懶人原則以外,還有一個很是很差的編碼習慣,即調用者隱藏了實參類型,這是很是危險的,不只僅調用者須要"猜想調用那個方法",並且被調用者也可能產生內部邏輯混亂的狀況。對於本例來講應該如此修改:

public static void main(String[] args) {
        Client5 client5 = new Client5();
        String strs[] = null;
        client5.methodA("china", strs);
    }

 
也就是說讓編譯器知道這個null值是String類型的,編譯便可順利經過,也就減小了錯誤的發生。

建議6:覆寫變長方法也循規蹈矩

  在JAVA中,子類覆寫父類的中的方法很常見,這樣作既能夠修正bug,也能夠提供擴展的業務功能支持,同時還符合開閉原則(Open-Closed Principle),下面咱們看一下覆寫必須知足的條件:

  • 覆寫方法不能縮小訪問權限;
  • 參數列表必須與被覆寫方法相同;
  • 返回類型必須與被重寫方法的相同;
  • 重寫方法不能拋出新的異常,或者超出父類範圍的異常,可是能夠拋出更少,更有限的異常,或者不拋出異常。

看下面這段代碼:

public class Client6 {
    public static void main(String[] args) {
        // 向上轉型
        Base base = new Sub();
        base.fun(100, 50);
        // 不轉型
        Sub sub = new Sub();
        sub.fun(100, 50);
    }
}

// 基類
class Base {
    void fun(int price, int... discounts) {
        System.out.println("Base......fun");
    }
}

// 子類,覆寫父類方法
class Sub extends Base {
    @Override
    void fun(int price, int[] discounts) {
        System.out.println("Sub......fun");
    }
}

  該程序中sub.fun(100, 50)報錯,提示找不到fun(int,int)方法。這太奇怪了:子類繼承了父類的全部屬性和方法,甭管是私有的仍是公開的訪問權限,一樣的參數,一樣的方法名,經過父類調用沒有任何問題,經過子類調用,卻編譯不過,爲啥?難到是沒繼承下來?或者子類縮小了父類方法的前置條件?若是是這樣,就不該該覆寫,@Override就應該報錯呀。

  事實上,base對象是把子類對象作了向上轉型,形參列表由父類決定,因爲是變長參數,在編譯時,base.fun(100, 50);中的50這個實參會被編譯器"猜想"而編譯成"{50}"數組,再由子類Sub執行。咱們再來看看直接調用子類的狀況,這時編譯器並不會把"50"座類型轉換由於數組自己也是一個對象,編譯器尚未聰明到要在兩個沒有繼承關係的類之間轉換,要知道JAVA是要求嚴格的類型匹配的,類型不匹配編譯器天然就會拒絕執行,並給予錯誤提示。

  這是個特例,覆寫的方法參數列表居然與父類不相同,這違背了覆寫的定義,而且會引起莫名其妙的錯誤。因此讀者在對變長參數進行覆寫時,若是要使用次相似的方法,請仔細想一想是否是要必定如此。
  
注意:覆寫的方法參數與父類相同,不只僅是類型、數量,還包括顯示形式.

建議7:警戒自增的陷阱

  記得大學剛開始學C語言時,老師就說:自增有兩種形式,分別是i++和++i,i++表示的先賦值後加1,++i是先加1後賦值,這樣理解了不少年也木有問題,直到遇到以下代碼,我才懷疑個人理解是否是錯了:

看下面這段代碼:

public class Client7 {
    public static void main(String[] args) {
        int count=0;
        for(int i=0; i<10;i++){
            count=count++;
        }
        System.out.println("count = "+count);
    }
}

  這個程序輸出的count等於幾?是count自加10次嗎?答案等於10?能夠確定的說,這個運行結果是count=0。爲何呢?
  count++是一個表達式,是由返回值的,它的返回值就是count自加前的值,Java對自加是這樣處理的:首先把count的值(注意是值,不是引用)拷貝到一個臨時變量區,而後對count變量+1,最後返回臨時變量區的值。程序第一次循環處理步驟以下:

  • JVM把count的值(其值是0)拷貝到臨時變量區;
  • count的值+1,這時候count的值是1
  • 返回臨時變量區的值,注意這個值是0,沒修改過;
  • 返回值賦給count,此時count的值被重置爲0.

"count=count++"這條語句能夠按照以下代碼理解: 

public static int mockAdd(int count) {
        // 先保存初始值
        int temp = count;
        // 作自增操做
        count = count + 1;
        // 返回原始值
        return temp;
    }

  因而第一次循環後count的值爲0,其它9次循環也是同樣的,最終你會發現count的值始終沒有改變,仍然保持着最初的狀態.

  此例中代碼做者的本意是但願count自增,因此想固然的賦值給自身就能夠了,未曾想到調到Java自增的陷阱中了,解決辦法很簡單,把"count=count++"改成"count++"便可。該問題在不一樣的語言環境中有着不一樣的實現:C++中"count=count++"與"count++"是等效的,而在PHP中保持着與JAVA相同的處理方式。每種語言對自增的實現方式各不相同。

建議8:不要讓舊語法困擾你

看下面這段代碼:

public class Client8 {
    public static void main(String[] args) {
        // 數據定義初始化
        int fee = 200;
        // 其它業務處理
        saveDefault: save(fee);
    }

    static void saveDefault() {
    System.out.println("saveDefault....");
    }

    static void save(int fee) {
    System.out.println("save....");
    }
}

  這段代碼分析一下,輸出結果,以及語法含義:

  • 首先這段代碼中有標號(:)操做符,C語言的同窗一看便知,相似JAVA中的保留關鍵字 go to 語句,但Java中拋棄了goto語法,只是不進行語義處理,與此相似的還有const關鍵字。
  • Java中雖然沒有了goto語法,但擴展了break和continue關鍵字,他們的後面均可以加上標號作跳轉,徹底實現了goto功能,同時也把goto的詬病帶進來了。
  • 運行以後代碼輸入爲"save....",運行時沒錯,但這樣的代碼,給你們閱讀上形成了很大的問題,因此就語法就讓他隨風遠去吧!

建議9:少用靜態導入

  從Java5開始引入了靜態導入語法(import static),其目的是爲了減小字符的輸入量,提升代碼的可閱讀性,以便更好地理解程序。咱們先倆看一個不用靜態導入的例子,也就是通常導入:

看下面這段代碼:

public class Client9 {
    // 計算圓面積
    public static double claCircleArea(double r) {
        return Math.PI * r * r;
    }

    // 計算球面積
    public static double claBallArea(double r) {
        return 4 * Math.PI * r * r;
    }
}

  這是很簡單的兩個方法,咱們再這兩個計算面積的方法中都引入了java.lang.Math類(該類是默認導入的)中的PI(圓周率)常量,而Math這個類寫在這裏有點多餘,特別是若是Client9類中的方法比較多時。若是每次輸入都須要敲入Math這個類,繁瑣且多餘,靜態導入能夠解決此問題,使用靜態導入後的程序以下: 

import static java.lang.Math.PI;

public class Client9 {
    // 計算圓面積
    public static double claCircleArea(double r) {
        return PI * r * r;
    }

    // 計算球面積
    public static double claBallArea(double r) {
        return 4 * PI * r * r;
    }
}

  靜態導入的做用是把Math類中的Pi常量引入到本類中,這會是程序更簡單,更容易閱讀,只要看到PI就知道這是圓周率,不用每次都把類名寫全了。可是,濫用靜態導入會使程序更難閱讀,更難維護,靜態導入後,代碼中就不須要再寫類名了,但咱們知道類是"一類事物的描述",缺乏了類名的修飾,靜態屬性和靜態方法的表象意義能夠被無限放大,這會讓閱讀者很難弄清楚其屬性或者方法表明何意,繩子哪一類的屬性(方法)都要思考一番(固然IDE的友好提示功能另說),把一個類的靜態導入元素都引入進來了,那簡直就是噩夢。咱們來看下面的例子:

import static java.lang.Math.*;
import static java.lang.Double.*;
import static java.lang.Integer.*;
import static java.text.NumberFormat.*;

import java.text.NumberFormat;

public class Client9 {

    public static void formatMessage(String s) {
        System.out.println("圓面積是: " + s);
    }

    public static void main(String[] args) {
        double s = PI * parseDouble(args[0]);
        NumberFormat nf = getInstance();
        nf.setMaximumFractionDigits(parseInt(args[1]));
        formatMessage(nf.format(s));

    }
}

  就這麼一段程序,看着就讓人惱火,常量PI,這知道是圓周率;parseDouble方法多是Double類的一個轉換方法,這看名稱能夠猜的到。那緊接着getInstance()方法是哪一個類的?是Client9本地類?不對呀,本地沒有這個方法,哦,原來是NumberFormat類的方法,這個和formatMessage本地方法沒有任何區別了---這代碼太難閱讀了,確定有人罵娘。
  C因此,對於靜態導入,必定要追尋兩個原則:

  • 不使用*(星號)通配符,除非是導入靜態常量類(只包含常量的類或接口);
  • 方法名是具備明確、清晰表象意義的工具類。

何爲具備明確、清晰表象意義的工具類,咱們看看Junit中使用靜態導入的例子:

import static org.junit.Assert.*;
class DaoTest{
    @Test
    public void testInsert(){
        //斷言
        assertEquals("foo","foo");
        assertFalse(Boolean.FALSE);
    }
}

  咱們從程序中很容易判斷出assertEquals方法是用來斷言兩個值是否相等的,assertFalse方法則是斷言表達式爲假,如此確實減小了代碼量,並且代碼的可讀性也提升了,這也是靜態導入用到正確的地方帶來的好處。

建議10:不要在本類中覆蓋靜態導入的變量和方法

  若是在一個類中的方法及屬性與靜態導入的方法及屬性相同會出現什麼問題呢?

看下面這段代碼:

import static java.lang.Math.PI;
import static java.lang.Math.abs;

public class Client10 {
    // 常量名於靜態導入的PI相同
    public final static String PI = "祖沖之";
    //方法名於靜態導入的方法相同
    public static int abs(int abs) {
        return 0;
    }

    public static void main(String[] args) {
        System.out.println("PI = "+PI);
        System.out.println("abs(-100) = "+abs(-100));
    }
}

  以上代碼中定義了一個String類型的常量PI,又定義了一個abs方法,與靜態導入的相同。首先說好消息,代碼沒有報錯,接下來是壞消息:咱們不知道那個屬性和方法別調用了,由於常量名和方法名相同,到底調用了那一個方法呢?運行以後結果爲:

  PI = "祖沖之",abs(-100) = 0;
  很明顯是本地的方法被調用了,爲什麼不調用Math類中的屬性和方法呢?那是由於編譯器有一個"最短路徑"原則:若是可以在本類中查找到相關的變量、常量、方法、就不會去其它包或父類、接口中查找,以確保本類中的屬性、方法優先。

  所以,若是要變動一個被靜態導入的方法,最好的辦法是在原始類中重構,而不是在本類中覆蓋. 

建議11:養成良好習慣,顯示聲明UID

  咱們編寫一個實現了Serializable接口(序列化標誌接口)的類,Eclipse立刻就會給一個黃色警告:須要添加一個Serial Version ID。爲何要增長?他是怎麼計算出來的?有什麼用?下面就來解釋該問題。
  類實現Serializable接口的目的是爲了可持久化,好比網絡傳輸或本地存儲,爲系統的分佈和異構部署提供先決條件支持。若沒有序列化,如今咱們熟悉的遠程調用、對象數據庫都不可能存在,咱們來看一個簡單的序列化類:

看下面這段代碼:

import java.io.Serializable;
public class Person implements Serializable {
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

}

  這是一個簡單的JavaBean,實現了Serializable接口,能夠在網絡上傳輸,也能夠在本地存儲而後讀取。這裏咱們以java消息服務(Java Message Service)方式傳遞對象(即經過網絡傳遞一個對象),定義在消息隊列中的數據類型爲ObjectMessage,首先定義一個消息的生產者(Producer),代碼以下:

public class Producer {
    public static void main(String[] args) {
        Person p = new Person();
        p.setName("混世魔王");
        // 序列化,保存到磁盤上
        SerializationUtils.writeObject(p);
    }
}

  這裏引入了一個工具類SerializationUtils,其做用是對一個類進行序列化和反序列化,並存儲到硬盤上(模擬網絡傳輸),其代碼以下:

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

public class SerializationUtils {
    private static String FILE_NAME = "c:/obj.bin";
    //序列化
    public static void writeObject(Serializable s) {
        try {
            ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(FILE_NAME));
            oos.writeObject(s);
            oos.close();
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    //反序列化
    public static Object readObject() {
        Object obj = null;
        try {
            ObjectInputStream input = new ObjectInputStream(new FileInputStream(FILE_NAME));
            obj=input.readObject();
            input.close();
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        return obj;
    }
}

  經過對象序列化過程,把一個內存塊轉化爲可傳輸的數據流,而後經過網絡發送到消息消費者(Customer)哪裏,進行反序列化,生成實驗對象,代碼以下:

public class Customer {
    public static void main(String[] args) {
        //反序列化
        Person p=(Person) SerializationUtils.readObject();
        System.out.println(p.getName());
    }
}

  這是一個反序列化的過程,也就是對象數據流轉換爲一個實例的過程,其運行後的輸出結果爲「混世魔王」。這太easy了,是的,這就是序列化和反序列化的典型Demo。但此處藏着一個問題:若是消息的生產者和消息的消費者(Person類)有差別,會出現何種神奇事件呢?好比:消息生產者中的Person類添加一個年齡屬性,而消費者沒有增長該屬性。爲啥沒有增長?由於這個是分佈式部署的應用,你甚至不知道這個應用部署在何處,特別是經過廣播方式發消息的狀況,漏掉一兩個訂閱者也是很正常的。
  這中序列化和反序列化的類在不一致的狀況下,反序列化時會報一個InalidClassException異常,緣由是序列化和反序列化所對應的類版本發生了變化,JVM不能把數據流轉換爲實例對象。刨根問底:JVM是根據什麼來判斷一個類的版本呢?
  好問題,經過SerializableUID,也叫作流標識符(Stream Unique Identifier),即類的版本定義的,它能夠顯示聲明也能夠隱式聲明。顯示聲明格式以下:

private static final long serialVersionUID = 1867341609628930239L;

  而隱式聲明則是我不聲明,你編譯器在編譯的時候幫我生成。生成的依據是經過包名、類名、繼承關係、非私有的方法和屬性,以及參數、返回值等諸多因子算出來的,極度複雜,基本上計算出來的這個值是惟一的。
  serialVersionUID如何生成已經說明了,咱們再來看看serialVersionUID的做用。JVM在反序列化時,會比較數據流中的serialVersionUID與類的serialVersionUID是否相同,若是相同,則認爲類沒有改變,能夠把數據load爲實例相同;若是不相同,對不起,我JVM不幹了,拋個異常InviladClassException給你瞧瞧。這是一個很是好的校驗機制,能夠保證一個對象即便在網絡或磁盤中「滾過」一次,仍能作到「出淤泥而不染」,完美的實現了類的一致性。

  可是,有時候咱們須要一點特例場景,例如個人類改變不大,JVM是否能夠把我之前的對象反序列化回來?就是依據顯示聲明的serialVersionUID,向JVM撒謊說"個人類版本沒有變化",如此我買你編寫的類就實現了向上兼容,咱們修改Person類,裏面添加private static final long serialVersionUID = 1867341609628930239L;
  剛開始生產者和消費者持有的Person類一致,都是V1.0,某天生產者的Person類變動了,增長了一個「年齡」屬性,升級爲V2.0,因爲種種緣由(好比程序員疏忽,升級時間窗口不一樣等)消費端的Person類仍是V1.0版本,添加的代碼爲 priavte int age;以及對應的setter和getter方法。

  此時雖然生產這和消費者對應的類版本不一樣,可是顯示聲明的serialVersionUID相同,序列化也是能夠運行的,所帶來的業務問題就是消費端不能讀取到新增的業務屬性(age屬性而已)。經過此例,咱們反序列化也實現了版本向上兼容的功能,使用V1.0版本的應用訪問了一個V2.0的對象,這無疑提升了代碼的健壯性。咱們在編寫序列化類代碼時隨手添加一個serialVersionUID字段,也不會帶來太多的工做量,但它卻能夠在關鍵時候發揮異乎尋常的做用。

  顯示聲明serialVersionUID能夠避免對象的不一致,但儘可能不要以這種方式向JVM撒謊。
  

建議12:避免用序列化類在構造函數中爲不變量賦值

  咱們知道帶有final標識的屬性是不變量,也就是隻能賦值一次,不能重複賦值,可是在序列化類中就有點複雜了,好比這個類:

看下面這段代碼:

public class Person implements Serializable {
    private static final long serialVersionUID = 1867341609628930239L;
    public final String perName="程咬金";
}

  這個Peson類(此時V1.0版本)被序列化,而後存儲在磁盤上,在反序列化時perName屬性會從新計算其值(這與static變量不一樣,static變量壓根就沒有保存到數據流中)好比perName屬性修改爲了"秦叔寶"(版本升級爲V2.0),那麼反序列化的perName值就是"秦叔寶"。保持新舊對象的final變量相同,有利於代碼業務邏輯統一,這是序列化的基本原則之一,也就是說,若是final屬性是一個直接量,在反序列化時就會從新計算。對於基本原則很少說,如今說一下final變量的另外一種賦值方式:經過構造函數賦值。代碼以下:

public class Person implements Serializable {
    private static final long serialVersionUID = 1867341609628930239L;
    public final String perName;

    public Person() {
        perName = "程咬金";
    }
}

  這也是咱們經常使用的一種賦值方式,能夠把Person類定義爲版本V1.0,而後進行序列化,看看序列化後有什麼問題,序列化代碼以下:

public class Serialize {
    public static void main(String[] args) {
        //序列化以持久保持
        SerializationUtils.writeObject(new Person());
    }
}

  Person的實習對象保存到了磁盤上,它時一個貧血對象(承載業務屬性定義,但不包含其行爲定義),咱們作一個簡單的模擬,修改一下PerName值表明變動,要注意的是serialVersionUID不變,修改後的代碼以下:

public class Person implements Serializable {
    private static final long serialVersionUID = 1867341609628930239L;
    public final String perName;

    public Person() {
        perName = "秦叔寶";
    }
}

此時Person類的版本時V2.0但serialVersionUID沒有改變,仍然能夠反序列化,代碼以下:

public class Deserialize {
    public static void main(String[] args) {
        Person p = (Person) SerializationUtils.readObject();
        System.out.println(p.perName);
    }
}

  如今問題出來了,打印出來的結果是"程咬金" 仍是"秦叔寶"?答案是:"程咬金"。final類型的變量不是會從新計算嘛,打印出來的應該是秦叔寶纔對呀。爲何會是程咬金?這是由於這裏觸及到了反序列化的兩一個原則:反序列化時構造函數不會執行.
  反序列化的執行過程是這樣的:JVM從數據流中獲取一個Object對象,而後根據數據流中的類文件描述信息(在序列化時,保存到磁盤的對象文件中包含了類描述信息,注意是描述信息,不是類)查看,發現是final變量,須要從新計算,因而引用Person類中的perName值,而此時JVM又發現perName竟沒有賦值,不能引用,因而它很聰明的再也不初始化,保持原值狀態,因此結果就是"程咬金"了。
  注意:在序列化類中不使用構造函數爲final變量賦值.

建議13:避免爲final變量複雜賦值

  爲final變量賦值還有另一種方式:經過方法賦值,及直接在聲明時經過方法的返回值賦值,仍是以Person類爲例來講明,代碼以下:

public class Person implements Serializable {
    private static final long serialVersionUID = 1867341609628930239L;
    //經過方法返回值爲final變量賦值
    public final String pName = initName();

    public String initName() {
        return "程咬金";
    }
}

  pName屬性是經過initName方法的返回值賦值的,這在複雜的類中常常用到,這比使用構造函數賦值更簡潔,易修改,那麼如此用法在序列化時會不會有問題呢?咱們一塊兒看看。Person類寫好了(定義爲V1.0版本),先把它序列化,存儲到本地文件,其代碼與以前相同,不在贅述。如今Person類的代碼須要修改,initName的返回值改成"秦叔寶".那麼咱們以前存儲在磁盤上的的實例加載上來,pName的會是什麼呢?

  如今,Person類的代碼須要修改,initName的返回值也改變了,代碼以下: 

public class Person implements Serializable {
    private static final long serialVersionUID = 1867341609628930239L;
    //經過方法返回值爲final變量賦值
    public final String pName = initName();

    public String initName() {
        return "秦叔寶";
    }
}

  上段代碼僅僅修改了initName的返回值(Person類爲V2.0版本)也就是經過new生成的對象的final變量的值都是"秦叔寶",那麼咱們把以前存儲在磁盤上的實例加載上來,pName的值會是什麼呢?
  結果是"程咬金",很詫異,上一建議說過final變量會被從新賦值,可是這個例子又沒有從新賦值,爲何?
  上個建議說的從新賦值,其中的"值"指的是簡單對象。簡單對象包括:8個基本類型,以及數組、字符串(字符串狀況複雜,不經過new關鍵字生成的String對象的狀況下,final變量的賦值與基本類型相同),可是不能方法賦值。
  其中的原理是這樣的,保存到磁盤上(或網絡傳輸)的對象文件包括兩部分:

  • 類描述信息:包括類路徑、繼承關係、訪問權限、變量描述、變量訪問權限、方法簽名、返回值、以及變量的關聯類信息。要注意一點是,它並非class文件的翻版,它不記錄方法、構造函數、static變量等的具體實現。之因此類描述會被保存,很簡單,是由於能去也能回嘛,這保證反序列化的健壯運行。
  • 非瞬態(transient關鍵字)和非靜態(static關鍵字)的實體變量值

  注意,這裏的值若是是一個基本類型,好說,就是一個簡單值保存下來;若是是複雜對象,也簡單,連該對象和關聯類信息一塊兒保存,而且持續遞歸下去(關聯類也必須實現Serializable接口,不然會出現序列化異常),也就是遞歸到最後,仍是基本數據類型的保存。
  正是由於這兩個緣由,一個持久化的對象文件會比一個class類文件大不少,有興趣的讀者能夠本身測試一下,體積確實膨脹了很多。
  總結一下:反序列化時final變量在如下狀況下不會被從新賦值:

  • 經過構造函數爲final變量賦值
  • 經過方法返回值爲final變量賦值
  • final修飾的屬性不是基本類型

建議14:使用序列化類的私有方法巧妙解決部分屬性持久化問題

  部分屬性持久化問題看似很簡單,只要把不須要持久化的屬性加上瞬態關鍵字(transient關鍵字)便可。這是一種解決方案,但有時候行不通。例如一個計稅系統和一個HR系統,經過RMI(Remote Method Invocation,遠程方法調用)對接,計稅系統須要從HR系統得到人員的姓名和基本工資,以做爲納稅的依據,而HR系統的工資分爲兩部分:基本工資和績效工資,基本工資沒什麼祕密,績效工資是保密的,不能泄露到外系統,這明顯是連個相互關聯的類,先看看薪水類Salary的代碼:

看下面這段代碼:

public class Salary implements Serializable {
    private static final long serialVersionUID = 2706085398747859680L;
    // 基本工資
    private int basePay;
    // 績效工資
    private int bonus;

    public Salary(int _basepay, int _bonus) {
        this.basePay = _basepay;
        this.bonus = _bonus;
    }
//Setter和Getter方法略

}

  Person類和Salary類是關聯關係,代碼以下:

public class Person implements Serializable {

    private static final long serialVersionUID = 9146176880143026279L;

    private String name;

    private Salary salary;

    public Person(String _name, Salary _salary) {
        this.name = _name;
        this.salary = _salary;
    }

    //Setter和Getter方法略

}

  這是兩個簡單的JavaBean,都實現了Serializable接口,具有了序列化的條件。首先計稅系統請求HR系統對一個Person對象進行序列化,把人員信息和工資信息傳遞到計稅系統中,代碼以下: 

public class Serialize {
    public static void main(String[] args) {
        // 基本工資1000元,績效工資2500元
        Salary salary = new Salary(1000, 2500);
        // 記錄人員信息
        Person person = new Person("張三", salary);
        // HR系統持久化,並傳遞到計稅系統
        SerializationUtils.writeObject(person);
    }
}

  在經過網絡傳輸到計稅系統後,進行反序列化,代碼以下:

public class Deserialize {
    public static void main(String[] args) {
        Person p = (Person) SerializationUtils.readObject();
        StringBuffer buf = new StringBuffer();
        buf.append("姓名: "+p.getName());
        buf.append("\t基本工資: "+p.getSalary().getBasePay());
        buf.append("\t績效工資: "+p.getSalary().getBonus());
        System.out.println(buf);
    }
}

打印出的結果爲:姓名: 張三 基本工資: 1000 績效工資: 2500

可是這不符合需求,由於計稅系統只能從HR系統中獲取人員姓名和基本工資,而績效工資是不能得到的,這是個保密數據,不容許發生泄漏。怎麼解決這個問題呢?你可能會想到如下四種方案:

  • 在bonus前加上關鍵字transient:這是一個方法,但不是一個好方法,加上transient關鍵字就標誌着Salary失去了分佈式部署的功能,它多是HR系統核心的類了,一旦遭遇性能瓶頸,再想實現分佈式部署就可能了,此方案否認;
  • 新增業務對象:增長一個Person4Tax類,徹底爲計稅系統服務,就是說它只有兩個屬性:姓名和基本工資。符合開閉原則,並且對原系統也沒有侵入性,只是增長了工做量而已。可是這個方法不是最優方法;
  • 請求端過濾:在計稅系統得到Person對象後,過濾掉Salary的bonus屬性,方案可行但不符合規矩,由於HR系統中的Salary類安全性居然然外系統(計稅系統來承擔),設計嚴重失職;
  • 變動傳輸契約:例如改用XML傳輸,或者重建一個WebSerive服務,能夠作但成本很高。

下面展現一個優秀的方案,其中實現了Serializable接口的類能夠實現兩個私有方法:writeObject和readObject,以影響和控制序列化和反序列化的過程。咱們把Person類稍做修改,看看如何控制序列化和反序列化,代碼以下:

public class Person implements Serializable {

    private static final long serialVersionUID = 9146176880143026279L;

    private String name;

    private transient Salary salary;

    public Person(String _name, Salary _salary) {
        this.name = _name;
        this.salary = _salary;
    }
    //序列化委託方法
    private void writeObject(ObjectOutputStream oos) throws IOException {
        oos.defaultWriteObject();
        oos.writeInt(salary.getBasePay());
    }
    //反序列化委託方法
    private void readObject(ObjectInputStream input)throws ClassNotFoundException, IOException {
        input.defaultReadObject();
        salary = new Salary(input.readInt(), 0);
    }
}

其它代碼不作任何改動,運行以後結果爲:姓名: 張三 基本工資: 1000 績效工資: 0

在Person類中增長了writeObject和readObject兩個方法,而且訪問權限都是私有級別,爲何會改變程序的運行結果呢?其實這裏用了序列化的獨有機制:序列化回調。Java調用ObjectOutputStream類把一個對象轉換成數據流時,會經過反射(Refection)檢查被序列化的類是否有writeObject方法,而且檢查其是否符合私有,無返回值的特性,如有,則會委託該方法進行對象序列化,若沒有,則由ObjectOutputStream按照默認規則繼續序列化。一樣,在從流數據恢復成實例對象時,也會檢查是否有一個私有的readObject方法,若是有,則會經過該方法讀取屬性值,此處有幾個關鍵點須要說明:

  • oos.defaultWriteObject():告知JVM按照默認的規則寫入對象,慣例的寫法是寫在第一行。
  • input.defaultReadObject():告知JVM按照默認規則讀入對象,慣例的寫法是寫在第一行。
  • oos.writeXX和input.readXX

分別是寫入和讀出相應的值,相似一個隊列,先進先出,若是此處有複雜的數據邏輯,建議按封裝Collection對象處理。你們可能注意到上面的方式也是Person失去了分佈式部署的能了,確實是,可是HR系統的難點和重點是薪水的計算,特別是績效工資,它所依賴的參數很複雜(僅從數量上說就有上百甚至上千種),計算公式也不簡單(通常是引入腳本語言,個性化公式定製)而相對來講Person類基本上都是靜態屬性,計算的可能性不大,因此即便爲性能考慮,Person類爲分佈式部署的意義也不大。

建議15:break萬萬不可忘

  咱們常常會寫一些轉換類,好比貨幣轉換,日期轉換,編碼轉換等,在金融領域裏用到的最多的要數中文數字轉換了,好比把"1"轉換爲"壹" ,不過開源工具是不會提供此工具類的,由於它太貼近中國文化了,須要本身編寫:

public class Client15 {
    public static void main(String[] args) {
        System.out.println(toChineseNuberCase(0));
    }

    public static String toChineseNuberCase(int n) {
        String chineseNumber = "";
        switch (n) {
        case 0:
            chineseNumber = "零";
        case 1:
            chineseNumber = "壹";
        case 2:
            chineseNumber = "貳";
        case 3:
            chineseNumber = "叄";
        case 4:
            chineseNumber = "肆";
        case 5:
            chineseNumber = "伍";
        case 6:
            chineseNumber = "陸";
        case 7:
            chineseNumber = "柒";
        case 8:
            chineseNumber = "捌";
        case 9:
            chineseNumber = "玖";
        }
        return chineseNumber;
    }
}

  這是一個簡單的代碼,但運行結果倒是"玖",這個很簡單,可能你們在剛接觸語法時都學過,但雖簡單,若是程序員漏寫了,簡單的問題會形成很大的後果,甚至經濟上的損失。因此在用switch語句上記得加上break,養成良好的習慣。對於此類問題,除了日常當心以外,可使用單元測試來避免,但你們都曉得,項目緊的時候,可能但單元測試都覆蓋不了。因此對於此類問題,一個最簡單的辦法就是:修改IDE的警告級別,例如在Eclipse中,能夠依次點擊PerFormaces-->Java-->Compiler-->Errors/Warings-->Potential Programming problems,而後修改'switch' case fall-through爲Errors級別,若是你膽敢不在case語句中加入break,那Eclipse直接就報個紅叉給你看,這樣能夠避免該問題的發生了。但仍是囉嗦一句,養成良好習慣更重要!

建議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公式,代碼以下:

import java.io.FileNotFoundException;
import java.io.FileReader;
import java.util.Scanner;

import javax.script.Bindings;
import javax.script.Invocable;
import javax.script.ScriptContext;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;

public class Client16 {
    public static void main(String[] args) throws FileNotFoundException,
            ScriptException, NoSuchMethodException {
        // 得到一個JavaScript執行引擎
        ScriptEngine engine = new ScriptEngineManager().getEngineByName("javascript");
        // 創建上下文變量
        Bindings bind = engine.createBindings();
        bind.put("factor", 1);
        // 綁定上下文,做用因而當前引擎範圍
        engine.setBindings(bind, ScriptContext.ENGINE_SCOPE);
        Scanner input =new Scanner(System.in);
        
        while(input.hasNextInt()){
            int first = input.nextInt();
            int second = input.nextInt();
            System.out.println("輸入參數是:"+first+","+second);
            // 執行Js代碼
            engine.eval(new FileReader("C:/model.js"));
            // 是否可調用方法
            if (engine instanceof Invocable) {
                Invocable in = (Invocable) engine;
                // 執行Js中的函數
                Double result = (Double) in.invokeFunction("formula", first, second);
                System.out.println("運算結果是:" + result.intValue());
            }
        }

    }
}

上段代碼使用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),看以下代碼:

import java.io.IOException;
import java.lang.reflect.Method;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

import javax.tools.JavaCompiler;
import javax.tools.JavaFileObject;
import javax.tools.SimpleJavaFileObject;
import javax.tools.StandardJavaFileManager;
import javax.tools.ToolProvider;

public class Client17 {
    public static void main(String[] args) throws Exception {
        // Java源代碼
        String sourceStr = "public class Hello { public String sayHello (String name) {return \"Hello,\"+name+\"!\";}}";
        // 類名及文件名
        String clsName = "Hello";
        // 方法名
        String methodName = "sayHello";
        // 當前編譯器
        JavaCompiler cmp = ToolProvider.getSystemJavaCompiler();
        // Java標準文件管理器
        StandardJavaFileManager fm = cmp.getStandardFileManager(null, null,
                null);
        // Java文件對象
        JavaFileObject jfo = new StringJavaObject(clsName, sourceStr);
        // 編譯參數,相似於javac <options>中的options
        List<String> optionsList = new ArrayList<String>();
        // 編譯文件的存放地方,注意:此處是爲Eclipse工具特設的
        optionsList.addAll(Arrays.asList("-d", "./bin"));
        // 要編譯的單元
        List<JavaFileObject> jfos = Arrays.asList(jfo);
        // 設置編譯環境
        JavaCompiler.CompilationTask task = cmp.getTask(null, fm, null,
                optionsList, null, jfos);
        // 編譯成功
        if (task.call()) {
            // 生成對象
            Object obj = Class.forName(clsName).newInstance();
            Class<? extends Object> cls = obj.getClass();
            // 調用sayHello方法
            Method m = cls.getMethod(methodName, String.class);
            String str = (String) m.invoke(obj, "Dynamic Compilation");
            System.out.println(str);
        }

    }
}

class StringJavaObject extends SimpleJavaFileObject {
    // 源代碼
    private String content = "";

    // 遵循Java規範的類名及文件
    public StringJavaObject(String _javaFileName, String _content) {
        super(_createStringJavaObjectUri(_javaFileName), Kind.SOURCE);
        content = _content;
    }

    // 產生一個URL資源路徑
    private static URI _createStringJavaObjectUri(String name) {
        // 注意,此處沒有設置包名
        return URI.create("String:///" + name + Kind.SOURCE.extension);
    }

    // 文本文件代碼
    @Override
    public CharSequence getCharContent(boolean ignoreEncodingErrors)
            throws IOException {
        return content;
    }
}

上面代碼較多,能夠做爲一個動態編譯的模板程序。只要是在本地靜態編譯可以實現的任務,好比編譯參數,輸入輸出,錯誤監控等,動態編譯都能實現。

Java的動態編譯對源提供了多個渠道。好比,能夠是字符串,文本文件,字節碼文件,還有存放在數據庫中的明文代碼或者字節碼。彙總一句話,只要符合Java規範的就能夠在運行期動態加載,其實現方式就是實現JavaFileObject接口,重寫getCharContent、openInputStream、openOutputStream,或者實現JDK已經提供的兩個SimpleJavaFileObject、ForwardingJavaFileObject,具體代碼能夠參考上個例子。

動態編譯雖然是很好的工具,讓咱們能夠更加自如的控制編譯過程,可是在咱們目前所接觸的項目中仍是使用較少。緣由很簡單,靜態編譯已經可以幫咱們處理大部分的工做,甚至是所有的工做,即便真的須要動態編譯,也有很好的替代方案,好比Jruby、Groovy等無縫的腳本語言。另外,咱們在使用動態編譯時,須要注意如下幾點:

  • 在框架中謹慎使用:好比要在struts中使用動態編譯,動態實現一個類,它若繼承自ActionSupport就但願它成爲一個Action。能作到,可是debug很困難;再好比在Spring中,寫一個動態類,要讓它注入到Spring容器中,這是須要花費老大功夫的。
  • 不要在要求性能高的項目中使用:若是你在web界面上提供了一個功能,容許上傳一個java文件而後運行,那就等於說:"個人機器沒有密碼,你們均可以看看",這是很是典型的注入漏洞,只要上傳一個惡意Java程序就可讓你全部的安全工做毀於一旦。
  • 記錄動態編譯過程:建議記錄源文件,目標文件,編譯過程,執行過程等日誌,不只僅是爲了診斷,仍是爲了安全和審計,對Java項目來講,空中編譯和運行時很不讓人放心的,留下這些依據能夠很好地優化程序。

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

instanceof是一個簡單的二元操做符,它是用來判斷一個對象是不是一個類的實現,其操做相似於>=、==,很是簡單,咱們看段程序,代碼以下: 

import java.util.Date;

public class Client18 {
    public static void main(String[] args) {
        // String對象是不是Object的實例 true
        boolean b1 = "String" instanceof Object;
        // String對象是不是String的實例 true
        boolean b2 = new String() instanceof String;
        // Object對象是不是String的實例 false
        boolean b3 = new Object() instanceof String;
        // 拆箱類型是不是裝箱類型的實例 編譯不經過
        boolean b4 = 'A' instanceof Character;
        // 空對象是不是String的實例 false
        boolean b5 = null instanceof String;
        // 轉換後的空對象是不是String的實例 false
        boolean b6 = (String) null instanceof String;
        // Date是不是String的實例 編譯不經過
        boolean b7 = new Date() instanceof String;
        // 在泛型類型中判斷String對象是不是Date的實例 false
        boolean b8 = new GenericClass<String>().isDateInstance("");

    }
}

class GenericClass<T> {
    // 判斷是不是Date類型
    public boolean isDateInstance(T t) {
        return t instanceof Date;
    }

}

就這麼一段程序,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 ().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)、在對外的公開方法中

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

public class Client19 {
    public static void main(String[] args) {
        System.out.println(StringUtils.encode(null));;
    }
}

class StringUtils{
    public static String encode(String str){
        assert    str != null : "加密的字符串爲null";
        /*加密處理*/
        return str;
        
    }
}

  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 : "到達這裏就表示錯誤";
}
  • 創建程序探針:咱們可能會在一段程序中定義兩個變量,分別代兩個不一樣的業務含義,可是二者有固定的關係,例如: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包發佈纔是萬全之策。但我以爲應特殊狀況特殊對待,並不能夠偏概全,你們覺得呢?

相關文章
相關標籤/搜索