1. 程式人生 > 程式設計 >Java SE基礎鞏固(八):序列化

Java SE基礎鞏固(八):序列化

在資料處理中,將資料結構或者物件轉換成其他可用的格式,並做持久化儲存或者將其傳送到網路流中,這種行為就是序列化,反序列化則是與之相反。

現如今流行的微服務,服務之間相互使用RPC或者HTTP進行通訊,當一發傳送的訊息是物件的時候,就需要對其進行序列化,否則接收方可能無法識別(微服務架構下,各個服務使用的語言是可以不一樣的),當接受方接受訊息的時候就按照一定的協議反序列化成系統可識別的資料結構。

現在Java序列化的方式主要有兩種:一種是Java原生的序列化,會將Java物件轉換成位元組流,但這種方式會有危險,後面會說到,另一種是使用第三方結構化資料結構,例如JSON和Google Protobuf,下面我將簡單介紹一下這兩種方式。

1 Java原生序列化

這種方式序列化的物件所屬的類必須實現了Serializable介面,該介面只是一個標記介面,沒有任何抽象方法,所以實現該介面的時候不需要重寫任何方法。要對Java物件做序列化,需要使用java.io.ObjectOutputStream類,它有一個writeObject方法,其引數是需要序列化的物件,要對Java物件做反序列化,需要使用java.io.ObjectInputStream類,他有一個readObject()方法,其沒有引數。下面是一個對Java物件進行序列化和反序列化的示例:

ObjectOutputStream和ObjectInputStream都是BIO中面向位元組流體系下的類,所以從這裡可以推斷出Java原生的序列化是面向位元組流的。

public class User {

    private Long id;

    private String username;

    private String password;
	
    //setter and getter
}
public class Main {

    public static void main(String[] args) throws IOException,ClassNotFoundException {
        String fileName = "E:\\Java_project\\effective-java\\src\\top\\yeonon\\serializable\\origin\\user.txt"
; User user = new User(); user.setId(1L); user.setUsername("yeonon"); user.setPassword("yeonon"); //序列化 ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(fileName)); out.writeObject(user); //寫入 out.flush(); //重新整理緩衝區 out.close(); //反序列化 ObjectInputStream in = new ObjectInputStream(new FileInputStream(fileName)); User newUser = (User) in.readObject(); in.close(); //比較兩個物件 System.out.println(user); System.out.println(newUser); System.out.println(newUser.equals(user)); } } 複製程式碼

程式碼使用了ObjectOutputStream和ObjectInputStream進行序列化和反序列化,程式碼非常簡單,就不多說了。直接執行這個程式,應該會得到一個java.io.NotSerializableException異常,什麼原因呢?因為User類沒有實現Serializable介面,咱給他加上,如下所示:

public class User implements Serializable {

    private Long id;

    private String username;

    private String password;
}
複製程式碼

現在再次執行程式,輸出大概如下:

top.yeonon.serializable.User@61bbe9ba
top.yeonon.serializable.User@4e50df2e
false
複製程式碼

第一行輸出是序列化之前的物件,第二行輸出是經過序列化和反序列化之後的物件,從編號上來看,這兩個物件是不同的,第三行是使用equals方法比較兩個物件,輸出是false?為什麼,這兩個物件即使不同,用equals方法比較應該返回true啊,因為他們的狀態列位都是一樣的?這其實是equals方法的問題,我們的User類沒有重寫euqals方法,所以使用的是Object類的equals方法,Object的equals方法只是簡單的比較兩者引用是否相同而已,如下所示:

    public boolean equals(Object obj) {
        return (this == obj);
    }
複製程式碼

所以結果會返回false,但如果我們在User類裡重寫了equals方法,就可以有機會讓euqlas方法返回true,關於如何正確實現euqals方法,就不是本文討論的內容了,推薦看看《Effective Java》第三版Object主題的相關章節。

這就完成了一次序列化和反序列化操作,同時還在目錄下建立了一個user.txt檔案,該檔案時一個二進位制檔案,裡面的內容是虛擬機器器可識別的位元組碼,我們可以把這個檔案傳到另一臺電腦上,如果那臺電腦的JVM和這邊電腦的一樣,那麼就可以直接使用ObjectInputStream讀取並反序列這個物件了(還有一個前提是那邊電腦的java程式裡存在User類)。

介紹完Java原生的序列化和反序列化,接下來將介紹基於第三方結構化資料結構的序列化和反序列化,主要介紹兩種格式:JSON和Google Protobuf。

2 JSON序列化和反序列化

JSON即 JavaScript Object Notation,是一種輕量級的資料交換語言,JSON的設計初衷是為了JavaScript服務的,廣泛應用在Web領域,但現在JSON已經不僅僅用於JS和Web環境了,而變成一種獨立於語言的結構化資料格式,而且JSON是基於文字的,其內容有極高的可讀性,人類可以簡單理解。

2.1 序列化和反序列化純物件

下面我們使用java的一個第三方庫jackson來演示如何將Java物件轉換成JSON以及將JSON轉換成Java物件:

public class Main {

    //ObjectMapper物件,jackson中所有的操作都需要通過該物件
    private static final ObjectMapper objectMapper = new ObjectMapper();

    public static void main(String[] args) throws IOException {
        User user = new User();
        user.setId(1L);
        user.setUsername("yeonon");
        user.setPassword("yeonon");
        //序列化成json字串
        String jsonStr = objectMapper.writeValueAsString(user);
        System.out.println(jsonStr);

        //反序列化成Java物件
        User newUser = objectMapper.readValue(jsonStr,User.class);

        System.out.println(newUser);
        System.out.println(user);
        System.out.println(newUser.equals(user));
    }
}

複製程式碼

首先是建立一個ObjectMapper物件,jackson中所有的操作都需要通過該物件。然後呼叫writeValueAsString(Object)方法將物件轉換成String字串的形式,即序列化,通過readValue(String,Class<?>)方法將JSON字串轉換成Java物件,即反序列化。輸出大致如下所示:

{"id":1,"username":"yeonon","password":"yeonon"}
top.yeonon.serializable.User@675d3402
top.yeonon.serializable.User@51565ec2
false
複製程式碼

需要注意的是第一行輸出,這就是JSON格式的字元表示,關於JSON的語法、格式等建議網上查詢資料學習,非常簡單。另外三行和之前一樣,之前都解釋過,再次就不再解釋了。

2.2 序列化和反序列化集合物件

Jackson的功能非常豐富、強大,不僅僅能序列化user這種純物件,還可以序列化集合,只不過反序列化的時候麻煩一些而已(但仍然比較簡單),如下所示:

public class Main {

    //ObjectMapper物件,jackson中所有的操作都需要通過該物件
    private static final ObjectMapper objectMapper = new ObjectMapper();

    public static void main(String[] args) throws IOException {
        User user1 = new User(1L,"yeonon","yeonon");
        User user2 = new User(2L,"weiyanyu","weiyanyu");
        User user3 = new User(3L,"xiangjinwei","xiangjinwei");

        Map<Long,User> map = new HashMap<>();
        map.put(1L,user1);
        map.put(2L,user2);
        map.put(3L,user3);

        //序列化集合
        String jsonStr = objectMapper
                .writerWithDefaultPrettyPrinter()
                .writeValueAsString(map);
        System.out.println(jsonStr);

        //反序列化集合
        JavaType javaType = objectMapper
                .getTypeFactory()
                .constructParametricType(Map.class,Long.class,User.class);
        Map<Long,User> newMap = objectMapper.readValue(jsonStr,javaType);

        newMap.forEach((k,v) -> {
            System.out.println(v);
        });

    }
}
複製程式碼

序列化和之前一樣,只不過這裡使用了writerWithDefaultPrettyPrinter來將輸出變得更加Pretty(漂亮)一些(建議不要在生產環境使用這個,因為這個佔用的空間會比較多,不如原始的緊湊)。關鍵在反序列化那,如果還像之前一樣直接呼叫 objectMapper.readValue(jsonStr,Map.class);會發現結果雖然是一個Map,但是裡麵包含的元素鍵和值卻不是Long和User型別,而是String型別的建和List型別的的值,這顯然不是我們想要的結果。

所以,對於集合,需要做更多額外的處理,首先產生一個JavaType物件,該物件表示將幾種型別集合在一起組成一個新的型別,constructParametricType()方法接受兩個引數,第一個引數是rawType,即原始型別,在程式碼中即Map,之後的表示集合裡的元素型別別,因為Map有兩種元素型別,所以傳入兩種型別,分別是Long和User,最後呼叫readValue()的另一個過載形式將字串和javaType傳入即可,此時便完成了反序列化的操作。

執行程式,輸出結果大致如下所示:

{
  "1" : {
    "id" : 1,"username" : "yeonon","password" : "yeonon"
  },"2" : {
    "id" : 2,"username" : "weiyanyu","password" : "weiyanyu"
  },"3" : {
    "id" : 3,"username" : "xiangjinwei","password" : "xiangjinwei"
  }
}
----------------------------------
User{id=1,username='yeonon',password='yeonon'}
User{id=2,username='weiyanyu',password='weiyanyu'}
User{id=3,username='xiangjinwei',password='xiangjinwei'}
複製程式碼

Jackson還有很多強大的功能,如果想深入瞭解,建議自行搜尋資料檢視學習。下面介紹另一種結構化資料格式:Google Protobuf。

3 Google Protobuf序列化和反序列化

Protobuf是Google推出的序列化工具。它主要有以下幾個特點:

  • 語言無關、平臺無關
  • 簡潔
  • 高效能
  • 良好的相容性

語言、平臺無關是因為它是基於某種協議的格式,在序列化和反序列化的時候都需要遵循這種協議,自然就能實現平臺無關了。雖然其簡潔,但並不易讀,它不像JSON、XML等基於文字的格式,而是基於二進位制格式的,這也是其高效能的原因,下圖是它和其他工具的效能比較:

iGgLEn.png

iGgONq.png

3.1 準備工作

首先,需要到官網下載對應的工具,因為我的電腦作業系統是win,所以下載的是protoc-3.6.1-win32.zip這個檔案。下載好之後進入到bin目錄,找到protoc.exe,這個玩意兒就是等會我們要用的了。

3.2 編寫protobuf 檔案

準備工作做好之後,就開始著手編寫protobuf檔案了,protobuf檔案格式非常貼近C++,關於其格式更詳細的內容,建議到官網上去檢視,官網寫得非常清楚,下面是一個示例:

syntax = "proto2";

option java_package = "top.yeonon.serializable.protobuf";
option java_outer_classname = "UserProtobuf";

message User {
	required int64 id= 1;
	required string name = 2;
	required string password = 3;
}
複製程式碼

簡單解釋一下吧:

  • syntax。即使用哪種語法,proto2表示使用proto2的語法,proto3表示使用proto3的語法。
  • java_package可選項。表示java包名。
  • java_outer_classname可選項。表示生成的java類名,不設定就預設以檔名的駝峰表示來作為類名。
  • message。必須要有的,簡單理解為表示類吧,例如程式碼中的message User就表示要生成一個User類。
  • required。不是必須的,表示序列化的時候該欄位必須要有值,否則序列化失敗。

3.3 生成Java類

此時就要用到protoc.exe這個可執行檔案了,執行如下命令:

protoc.exe -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/addressbook.proto
複製程式碼

SRC_DIR即原始檔所在目錄,DST_DIR即要將生成的類放在哪個目錄下,下面是我的測試用例:

protoc.exe -I=C:\Users\72419\Desktop\ --java_out=E:\Java_project\effective-java\src C:\Users\72419\Desktop\user.proto
複製程式碼

執行完畢之後,可以在E:\Java_project\effective-java\src目錄下看到top目錄,從這開始,就是根據之前在protobuf檔案裡設定的包名繼續建立目錄了,所以,最終我們會在E:\Java_project\effective-java\src\top\yeonon\serializable\protobuf\目錄下看到一個UserProtobuf.java檔案,這就是生成的Java類了,但這不是我們真正想要的類,我們真正想要的應該是User類,User類實際上是UserProtobuf類的一個內部類,不能直接例項化,需要通過Buidler類來構建。下面是一個簡單的使用示例:

public class Main {
    public static void main(String[] args) throws InvalidProtocolBufferException {
        UserProtobuf.User user = UserProtobuf.User.newBuilder()
                .setId(1L)
                .setName("yeonon")
                .setPassword("yeonnon").build();

        System.out.println(user);

        //序列化成位元組流
        byte[] userBytes = user.toByteArray();

        //反序列化
        UserProtobuf.User newUser = UserProtobuf.User.parseFrom(userBytes);

        System.out.println(user);
        System.out.println(newUser);
        System.out.println(newUser.equals(user));
    }
}
複製程式碼

首先使用相關的Builder來構建物件,然後通過toByteArray生成位元組流,此時就可以將這個位元組流進行網路傳輸或者持久化了。反序列化也非常簡單,呼叫parseFrom()方法即可。執行程式,輸出大致如下:

id: 1
name: "yeonon"
password: "yeonnon"

true
複製程式碼

發現這裡返回的是true,和上面的都不一樣,為什麼呢?因為工具再生成該User類的時候,還順便重寫了equals方法,該euals方法會比較兩個物件的欄位值,這裡欄位值啥的肯定是一樣的,所以最終會返回true。

以上就是Protobuf的簡單使用了,實際上Protobuf遠不止這些功能,還有很多強大的功能,由於我自己也是“現學現賣”,所以就不獻醜了,建議網上搜索資料進行深層次的學習。

4 序列化和單例模式

單例模式的最基本要求是整個應用程式中都只存在一個例項,無論哪種實現方法,都是圍繞整個目的來做的。而序列化可能會破壞單例模式,更準確的說是反序列化會破壞單例。為什麼呢?其實從上面的介紹中,已經大概知道原因了,我們發現反序列化之後的物件和原物件並不是同一個物件,即整個系統中存在某個類的不止一個例項,顯然破壞了單例,這也是為什麼Enum類(Enum都是單例的)的readObject方法實現是直接丟擲異常(在關於列舉的那篇文章中有提到)。所以,如果要保持單例,最好不要允許其進行序列化和反序列化。

5 為什麼序列化是危險的

之所以要進行序列化,是因為要將物件進行持久化儲存或者進行網路傳輸,這樣會導致系統失去對物件的控制權。例如,現在要將user物件進行序列化並通過網路傳輸給其他系統,如果在網路傳輸過程中,序列化的位元組流被篡改了,而且沒有破壞其結構,那麼接受方反序列化的內容可能就和傳送方傳送的內容不一致,嚴重的可能會導致接收方系統崩潰。

又或者是系統接收了不知道來源的位元組流,並將其反序列化,假設此時沒有遭到網路攻擊,即位元組流沒有被篡改,但反序列化的時間非常長,甚至會導致OOM或者StackOverFlow。例如如果傳送放傳送的是一種深層次的Map結構(即Map裡內嵌了Map),假設有n層吧,那麼接收方為了反序列化成java物件,就不得不一層一層解開,時間複雜度會是2的n次方,即指數級別的時間複雜度,機器很可能永遠無法執行完畢,還有極大可能導致StackOverFlow並最終引起整個系統崩潰,很多拒絕服務攻擊就是這樣乾的。值得一提的是,一些結構化的資料結構(例如JSON)可以有效避免這種情況。

6 小結

本文簡單介紹了什麼是序列化和反序列化,還順便說了一下JSON和Protobuf的簡單使用。序列化和反序列化是有一定危險的,如果不是有必要,要儘量避免,如果不得不使用,那麼最好使用一些結構化的資料結構,例如JSON,Protobuf等,這樣至少可以規避一種危險(在第5小結中有講到)。