這篇文章主要給大家講解序列化和反序列化。
序列化是網路通訊中非常重要的一個機制,好的序列化方式能夠直接影響資料傳輸的效能。
序列化
所謂的序列化,就是把一個物件,轉化為某種特定的形式,然後以資料流的方式傳輸。
比如把一個物件直接轉化為二進位制資料流進行傳輸。當然這個物件可以轉化為其他形式之後再轉化為資料流。
比如XML、JSON等格式。它們通過另外一種資料格式表達了一個物件的狀態,然後再把這些資料轉化為二進位制資料流進行網路傳輸。
反序列化
反序列化是序列化的逆向過程,把位元組陣列反序列化為物件,把位元組序列恢復為物件的過程成為物件的反序列化
序列化的高階認識
前面的程式碼中演示了,如何通過JDK提供了Java物件的序列化方式實現物件序列化傳輸,主要通過輸出流java.io.ObjectOutputStream和物件輸入流java.io.ObjectInputStream來實現。
java.io.ObjectOutputStream:表示物件輸出流 , 它的writeObject(Object obj)方法可以對引數指定的obj物件進行序列化,把得到的位元組序列寫到一個目標輸出流中。
java.io.ObjectInputStream:表示物件輸入流 ,它的readObject()方法源輸入流中讀取位元組序列,再把它們反序列化成為一個物件,並將其返回
需要注意的是,被序列化的物件需要實現java.io.Serializable介面
serialVersionUID的作用
在IDEA中通過如下設定可以生成serializeID,如圖5-1所示
字面意思上是序列化的版本號,凡是實現Serializable介面的類都有一個表示序列化版本識別符號的靜態變數。
<center>圖5-1</center>
下面演示一下serialVersionUID的作用。首先需要建立一個普通的spring boot專案,然後按照下面的步驟來進行演示
建立User物件
public class User implements Serializable {
private static final long serialVersionUID = -8826770719841981391L;
private String name;
private int age;
}
編寫Java序列化的程式碼
public class JavaSerializer {
public static void main(String[] args) {
User user=new User();
user.setAge(18);
user.setName("Mic");
serialToFile(user);
System.out.println("序列化成功,開始反序列化");
User nuser=deserialFromFile();
System.out.println(nuser);
}
private static void serialToFile(User user){
try {
ObjectOutputStream objectOutputStream=
new ObjectOutputStream(new FileOutputStream(new File("user")));
objectOutputStream.writeObject(user);
} catch (IOException e) {
e.printStackTrace();
}
}
private static <T> T deserialFromFile(){
try {
ObjectInputStream objectInputStream=new ObjectInputStream(new FileInputStream(new File("user")));
return (T)objectInputStream.readObject();
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}
return null;
}
}
UID驗證演示步驟
- 先將user物件序列化到檔案中
- 然後修改user物件,增加serialVersionUID欄位
- 然後通過反序列化來把物件提取出來
- 演示預期結果:提示無法反序列化
結論
Java的序列化機制是通過判斷類的serialVersionUID來驗證版本一致性的。在進行反序列化時,JVM會把傳來的位元組流中的serialVersionUID與本地相應實體類的serialVersionUID進行比較,如果相同就認為是一致的,可以進行反序列化,否則就會出現序列化版本不一致的異常,即是InvalidCastException。
從結果可以看出,檔案流中的class和classpath中的class,也就是修改過後的class,不相容了,出於安全機制考慮,程式丟擲了錯誤,並且拒絕載入。從錯誤結果來看,如果沒有為指定的class配置serialVersionUID,那麼java編譯器會自動給這個class進行一個摘要演算法,類似於指紋演算法,只要這個檔案有任何改動,得到的UID就會截然不同的,可以保證在這麼多類中,這個編號是唯一的。所以,由於沒有顯指定 serialVersionUID,編譯器又為我們生成了一個UID,當然和前面儲存在檔案中的那個不會一樣了,於是就出現了2個序列化版本號不一致的錯誤。因此,只要我們自己指定了serialVersionUID,就可以在序列化後,去新增一個欄位,或者方法,而不會影響到後期的還原,還原後的物件照樣可以使用,而且還多了方法或者屬性可以用。
tips: serialVersionUID有兩種顯示的生成方式:
一是預設的1L,比如:private static final long serialVersionUID = 1L;
二是根據類名、介面名、成員方法及屬性等來生成一個64位的雜湊欄位
當實現java.io.Serializable介面的類沒有顯式地定義一個serialVersionUID變數時候,Java序列化機制會根據編譯的Class自動生成一個serialVersionUID作序列化版本比較用,這種情況下,如果Class檔案(類名,方法明等)沒有發生變化(增加空格,換行,增加註釋等等),就算再編譯多次,serialVersionUID也不會變化的。
Transient關鍵字
Transient 關鍵字的作用是控制變數的序列化,在變數宣告前加上該關鍵字,可以阻止該變數被序列化到檔案中,在被反序列化後,transient 變數的值被設為初始值,如 int 型的是 0,物件型的是 null。
如果我們希望User類中的name欄位不序列化,則按照以下方案進行修改。
修改User類
public class User implements Serializable {
private static final long serialVersionUID = -8826770719841981391L;
private transient String name;
private int age;
}
測試效果
public class JavaSerializer {
public static void main(String[] args) {
User user=new User();
user.setAge(18);
user.setName("Mic");
serialToFile(user);
System.out.println("序列化成功,開始反序列化");
User nuser=deserialFromFile();
System.out.println(nuser.getName()); //列印反序列化的結果,發現結果是NULL.
}
}
繞開transient機制
在User類中重寫writeObject和readObject方法。
public class User implements Serializable {
private static final long serialVersionUID = -8826770719841981391L;
private transient String name;
private int age;
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
out.writeObject(name);//增加寫入name欄位
}
private void readObject(ObjectInputStream in) throws Exception{
in.defaultReadObject();
name=(String)in.readObject();
}
}
這兩個方法是在ObjectInputStream和ObjectOutputStream中,分別反序列化和序列化物件時反射呼叫目標物件中的這兩個方法。
序列化的總結
- Java序列化只是針對物件的狀態進行儲存,至於物件中的方法,序列化不關心
- 當一個父類實現了序列化,那麼子類會自動實現序列化,不需要顯示實現序列化介面
- 當一個物件的例項變數引用了其他物件,序列化這個物件的時候會自動把引用的物件也進行序列化(實現深度克隆)
- 當某個欄位被申明為transient後,預設的序列化機制會忽略這個欄位
- 被申明為transient的欄位,如果需要序列化,可以新增兩個私有方法:writeObject和readObject
常見的序列化技術及優劣分析
隨著分散式架構、微服務架構的普及。服務與服務之間的通訊成了最基本的需求。這個時候,我們不僅需要考慮通訊的效能,也需要考慮到語言多元化問題
所以,對於序列化來說,如何去提升序列化效能以及解決跨語言問題,就成了一個重點考慮的問題。
由於Java本身提供的序列化機制存在兩個問題
- 序列化的資料比較大,傳輸效率低
- 其他語言無法識別和對接
以至於在後來的很長一段時間,基於XML格式編碼的物件序列化機制成為了主流,一方面解決了多語言相容問題,另一方面比二進位制的序列化方式更容易理解。
以至於基於XML的SOAP協議及對應的WebService框架在很長一段時間內成為各個主流開發語言的必備的技術。
再到後來,基於JSON的簡單文字格式編碼的HTTP REST介面又基本上取代了複雜的Web Service介面,成為分散式架構中遠端通訊的首要選擇。
但是JSON序列化儲存佔用的空間大、效能低等問題,同時移動客戶端應用需要更高效的傳輸資料來提升使用者體驗。在這種情況下與語言無關並且高效的二進位制編碼協議就成為了大家追求的熱點技術之一。
首先誕生的一個開源的二進位制序列化框架-MessagePack。它比google的Protocol Buffers出現得還要早。
XML序列化框架介紹
XML序列化的好處在於可讀性好,方便閱讀和除錯。但是序列化以後的位元組碼檔案比較大,而且效率不高,適用於對效能不高,而且QPS較低的企業級內部系統之間的資料交換的場景,同時XML又具有語言無關性,所以還可以用於異構系統之間的資料交換和協議。比如我們熟知的Webservice,就是採用XML格式對資料進行序列化的。XML序列化/反序列化的實現方式有很多,熟知的方式有XStream和Java自帶的XML序列化和反序列化兩種。
引入jar包
<dependency>
<groupId>com.thoughtworks.xstream</groupId>
<artifactId>xstream</artifactId>
<version>1.4.12</version>
</dependency>
編寫測試程式
public class XMLSerializer {
public static void main(String[] args) {
User user=new User();
user.setName("Mic");
user.setAge(18);
String xml=serialize(user);
System.out.println("序列化完成:"+xml);
User nuser=deserialize(xml);
System.out.println(nuser);
}
private static String serialize(User user){
return new XStream(new DomDriver()).toXML(user);
}
private static User deserialize(String xml){
return (User)new XStream(new DomDriver()).fromXML(xml);
}
}
JSON序列化框架
JSON(JavaScript Object Notation)是一種輕量級的資料交換格式,相對於XML來說,JSON的位元組流更小,而且可讀性也非常好。現在JSON資料格式在企業運用是最普遍的
JSON序列化常用的開源工具有很多
- Jackson (https://github.com/FasterXML/...)
- 阿里開源的FastJson (https://github.com/alibaba/fa...)
- Google的GSON (https://github.com/google/gson)
這幾種json序列化工具中,Jackson與fastjson要比GSON的效能要好,但是Jackson、GSON的穩定性要比Fastjson好。而fastjson的優勢在於提供的api非常容易使用
引入jar包
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.72</version>
</dependency>
編寫測試程式
public class JsonSerializer{
public static void main(String[] args) {
User user=new User();
user.setName("Mic");
user.setAge(18);
String xml=serializer(user);
System.out.println("序列化完成:"+xml);
User nuser=deserializer(xml);
System.out.println(nuser);
}
private static String serializer(User user){
return JSON.toJSONString(user);
}
private static User deserializer(String json){
return (User)JSON.parseObject(json,User.class);
}
}
Hessian序列化
Hessian是一個支援跨語言傳輸的二進位制序列化協議,相對於Java預設的序列化機制來說,Hessian具有更好的效能和易用性,而且支援多種不同的語言
實際上Dubbo採用的就是Hessian序列化來實現,只不過Dubbo對Hessian進行了重構,效能更高
引入jar包
<dependency>
<groupId>com.caucho</groupId>
<artifactId>hessian</artifactId>
<version>4.0.63</version>
</dependency>
編寫測試程式
public class HessianSerializer {
public static void main(String[] args) throws IOException {
User user=new User();
user.setName("Mic");
user.setAge(18);
byte[] bytes=serializer(user);
System.out.println("序列化完成");
User nuser=deserializer(bytes);
System.out.println(nuser);
}
private static byte[] serializer(User user) throws IOException {
ByteArrayOutputStream bos=new ByteArrayOutputStream(); //表示輸出到記憶體的實現
HessianOutput ho=new HessianOutput(bos);
ho.writeObject(user);
return bos.toByteArray();
}
private static User deserializer(byte[] data) throws IOException {
ByteArrayInputStream bis=new ByteArrayInputStream(data);
HessianInput hi=new HessianInput(bis);
return (User)hi.readObject();
}
}
Avro序列化
Avro是一個資料序列化系統,設計用於支援大批量資料交換的應用。它的主要特點有:支援二進位制序列化方式,可以便捷,快速地處理大量資料;動態語言友好,Avro提供的機制使動態語言可以方便地處理Avro資料。
Avro是apache下hadoop的子專案,擁有序列化、反序列化、RPC功能。序列化的效率比jdk更高,與Google的protobuffer相當,比facebook開源Thrift(後由apache管理了)更優秀。
因為avro採用schema,如果是序列化大量型別相同的物件,那麼只需要儲存一份類的結構資訊+資料,大大減少網路通訊或者資料儲存量。
引入jar包
<dependency>
<groupId>org.apache.avro</groupId>
<artifactId>avro</artifactId>
<version>1.8.2</version>
</dependency>
<dependency>
<groupId>org.apache.avro</groupId>
<artifactId>avro-ipc</artifactId>
<version>1.8.2</version>
</dependency>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.avro</groupId>
<artifactId>avro-maven-plugin</artifactId>
<version>1.8.2</version>
<executions>
<execution>
<id>schemas</id>
<phase>generate-sources</phase>
<goals>
<goal>schema</goal>
</goals>
<configuration>
<sourceDirectory>${project.basedir}/src/main/avro</sourceDirectory>
<outputDirectory>${project.basedir}/src/main/java</outputDirectory>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
編寫avsc檔案
建立/src/main/avro目錄,專門用來儲存Avrode scheme定義檔案。
{
"namespace":"com.gupao.example",
"type":"record",
"name":"Person",
"fields":[
{"name":"name","type":"string"},
{"name":"age","type":"int"},
{"name":"sex","type":"string"}
]
}
avsc檔案中的語法定義如下:
- namespace:名稱空間,在使用外掛生成程式碼的時候,User類的包名就是它
- type:有 records, enums, arrays, maps, unions , fixed 取值,records是相當於普通的class
- name:類名,類的全名有namespace+name構成
- doc:註釋
- aliases:取的別名,其他地方使用可以使用別名來引用
fields:屬性
- name:屬性名
- type:屬性型別,可以是用["int","null"]或者["int",1]執行預設值
- default:也可以使用該欄位指定預設值
- doc:註釋
生成程式碼
執行maven install
,
會在main/java目錄下生成Person類。
編寫測試程式
public class AvroSerializer {
public static void main(String[] args) throws IOException {
Person person=Person.newBuilder().setName("Mic").setAge(18).setSex("男").build();
ByteBuffer byteBuffer=person.toByteBuffer(); //序列化
System.out.println("序列化大小:"+byteBuffer.array().length);
Person nperson=Person.fromByteBuffer(byteBuffer);
System.out.println("反序列化:"+nperson);
}
}
下面這種方式是基於檔案的形式來實現序列化和反序列化
public class AvroSerializer {
public static void main(String[] args) throws IOException {
Person person=Person.newBuilder().setName("Mic").setAge(18).setSex("男").build();
/* ByteBuffer byteBuffer=person.toByteBuffer(); //序列化
System.out.println("序列化大小:"+byteBuffer.array().length);
Person nperson=Person.fromByteBuffer(byteBuffer);
System.out.println("反序列化:"+nperson);*/
DatumWriter<Person> personDatumWriter=new SpecificDatumWriter<>(Person.class);
DataFileWriter<Person> dataFileWriter=new DataFileWriter<>(personDatumWriter);
dataFileWriter.create(person.getSchema(),new File("person.avro"));
dataFileWriter.append(person);
dataFileWriter.close();
System.out.println("序列化成功.....");
DatumReader<Person> personDatumReader=new SpecificDatumReader<>(Person.class);
DataFileReader<Person> dataFileReader=new DataFileReader<Person>(new File("person.avro"),personDatumReader);
Person nper=dataFileReader.next();
System.out.println(nper);
}
}
kyro序列化框架
Kryo是一種非常成熟的序列化實現,已經在Hive、Storm)中使用得比較廣泛,不過它不能跨語言. 目前dubbo已經在2.6版本支援kyro的序列化機制。它的效能要優於之前的hessian2
zookeeper中使用jute作為序列化
Protobuf序列化
Protobuf是Google的一種資料交換格式,它獨立於語言、獨立於平臺。Google提供了多種語言來實現,比如Java、C、Go、Python,每一種實現都包含了相應語言的編譯器和庫檔案,Protobuf是一個純粹的表示層協議,可以和各種傳輸層協議一起使用。
Protobuf使用比較廣泛,主要是空間開銷小和效能比較好,非常適合用於公司內部對效能要求高的RPC呼叫。 另外由於解析效能比較高,序列化以後資料量相對較少,所以也可以應用在物件的持久化場景中
但是要使用Protobuf會相對來說麻煩些,因為他有自己的語法,有自己的編譯器,如果需要用到的話必須要去投入成本在這個技術的學習中
protobuf有個缺點就是要傳輸的每一個類的結構都要生成對應的proto檔案,如果某個類發生修改,還得重新生成該類對應的proto檔案
使用protobuf開發的一般步驟是
- 配置開發環境,安裝protocol compiler程式碼編譯器
- 編寫.proto檔案,定義序列化物件的資料結構
- 基於編寫的.proto檔案,使用protocol compiler編譯器生成對應的序列化/反序列化工具類
- 基於自動生成的程式碼,編寫自己的序列化應用
安裝protobuf編譯工具
- https://github.com/google/pro... 找到 protoc-3.5.1-win32.zip
編寫proto檔案
syntax="proto2"; package com.gupao.example; option java_outer_classname="UserProtos"; message User { required string name=1; required int32 age=2; }
資料型別說明如下:
- string / bytes / bool / int32(4個位元組)/int64/float/double
- enum 列舉類
- message 自定義類
修飾符
- required 表示必填欄位
- optional 表示可選欄位
- repeated 可重複,表示集合
- 1,2,3,4需要在當前範圍內是唯一的,表示順序
生成例項類,在cmd中執行如下命令
protoc.exe --java_out=./ ./User.proto
實現序列化
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>3.12.2</version>
</dependency>
編寫測試程式碼.
public class ProtobufSerializer {
public static void main(String[] args) throws InvalidProtocolBufferException {
UserProtos.User user=UserProtos.User.newBuilder().setName("Mic").setAge(18).build();
ByteString bytes=user.toByteString();
System.out.println(bytes.toByteArray().length);
UserProtos.User nUser=UserProtos.User.parseFrom(bytes);
System.out.println(nUser);
}
}
Protobuf序列化原理解析
我們可以把序列化以後的資料列印出來看看結果
public static void main(String[] args) {
UserProtos.User user=UserProtos.User.newBuilder().
setAge(300).setName("Mic").build();
byte[] bytes=user.toByteArray();
for(byte bt:bytes){
System.out.print(bt+" ");
}
}
10 3 77 105 99 16 -84 2
我們可以看到,序列化出來的數字基本看不懂,但是序列化以後的資料確實很小,那我們接下來帶大家去了解一下底層的原理
正常來說,要達到最小的序列化結果,一定會用到壓縮的技術,而protobuf裡面用到了兩種壓縮演算法,一種是varint,另一種是zigzag
varint
先說第一種,我們先來看【Mic】是怎麼被壓縮的
“Mic”這個字元,需要根據ASCII對照表轉化為數字。
M =77、i=105、c=99
所以結果為 77 105 99
大家肯定有個疑問,這裡的結果為什麼直接就是ASCII編碼的值呢?怎麼沒有做壓縮呢?有沒有同學能夠回答出來
原因是,varint是對位元組碼做壓縮,但是如果這個數字的二進位制只需要一個位元組表示的時候,其實最終編碼出來的結果是不會變化的。 如果出現需要大於一個位元組的方式來表示,則需要進行壓縮。
比如,我們設定的age=300, 這裡需要2個位元組來儲存。那看一下它是如何被壓縮的。
300如何被壓縮
這兩個位元組位元組分別的結果是:-84 、2
-84怎麼計算來的呢? 我們知道在二進位制中表示負數的方法,高位設定為1, 並且是對應數字的二進位制取反以後再計算補碼錶示(補碼是反碼+1)
所以如果要反過來計算
- 【補碼】10101100 -1 得到 10101011
- 【反碼】01010100 得到的結果為84. 由於高位是1,表示負數所以結果為-84
儲存格式
protobuf採用T-L-V作為儲存方式
tag的計算方式是 field_number(當前欄位的編號) << 3 | wire_type
比如Mic的欄位編號是1 ,型別wire_type的值為 2 所以 : 1 <<3 | 2 =10
age=300的欄位編號是2,型別wire_type的值是0, 所以 : 2<<3|0 =16
所以按照T-L-V的格式,第一個欄位為name,所以它的資料為 {10} {3} {77 105 99},第二個欄位為age ,{16} {2} {-82 2}
5.5.3 負數的儲存方式
在計算機中,負數會被表示為很大的整數,因為計算機定義負數符號位為數字的最高位,所以如果採用varint編碼表示一個負數,那麼一定需要5個位元位。所以在protobuf中通過sint32/sint64型別來表示負數,負數的處理形式是先採用zigzag編碼(把符號數轉化為無符號數),在採用varint編碼。
sint32:(n << 1) ^ (n >> 31)
sint64:(n << 1) ^ (n >> 63)
比如儲存一個(-300)的值。
修改proto原始檔案
message User { required string name=1; required int32 age=2; required sint32 status=3; //增加一個sint的欄位 }
設定一個值
UserProtos.User user=UserProtos.User.newBuilder().setAge(300).setName("Mic").setStatus(-300).build();
- 此時的輸出結果:
10 3 77 105 99 16 -84 2 24 -41 4
我們發現,針對於負數型別,壓縮出來的資料是不一樣的,這裡採用的編碼方式是zigzag的編碼,再採用varint進行編碼壓縮。
比如儲存一個(-300)的值
-300
原碼:0001 0010 1100
取反:1110 1101 0011
加1 :1110 1101 0100
n<<1: 整體左移一位,右邊補0 -> 1101 1010 1000
n>>31: 整體右移31位,左邊補1 -> 1111 1111 1111
n<<1 ^ n >>31
1101 1010 1000 ^ 1111 1111 1111 = 0010 0101 0111
十進位制: 0010 0101 0111 = 599
這樣做的目的,是消除高位的1,從而形成一個可以被壓縮的資料。針對599再採用varint進行編碼。
varint演算法: 從右往做,選取7位,高位補1/0(取決於位元組數)
得到兩個位元組
1101 0111 0000 0100
-41 、 4
5.5.4 總結
Protocol Buffer的效能好,主要體現在 序列化後的資料體積小 & 序列化速度快,最終使得傳輸效率高,其原因如下:
序列化速度快的原因:
a. 編碼 / 解碼 方式簡單(只需要簡單的數學運算 = 位移等等)
b. 採用 Protocol Buffer 自身的框架程式碼 和 編譯器 共同完成
序列化後的資料量體積小(即資料壓縮效果好)的原因:
a. 採用了獨特的編碼方式,如Varint、Zigzag編碼方式等等
b. 採用T - L - V 的資料儲存方式:減少了分隔符的使用 & 資料儲存得緊湊
序列化技術選型
技術層面
- 序列化空間開銷,也就是序列化產生的結果大小,這個影響到傳輸的效能
- 序列化過程中消耗的時長,序列化消耗時間過長影響到業務的響應時間
- 序列化協議是否支援跨平臺,跨語言。因為現在的架構更加靈活,如果存在異構系統通訊需求,那麼這個是必須要考慮的
- 可擴充套件性/相容性,在實際業務開發中,系統往往需要隨著需求的快速迭代來實現快速更新,這就要求我們採用的序列化協議基於良好的可擴充套件性/相容性,比如在現有的序列化資料結構中新增一個業務欄位,不會影響到現有的服務
- 技術的流行程度,越流行的技術意味著使用的公司多,那麼很多坑都已經淌過並且得到了解決,技術解決方案也相對成熟
- 學習難度和易用性
選型建議
- 對效能要求不高的場景,可以採用基於XML的SOAP協議
- 對效能和間接性有比較高要求的場景,那麼Hessian、Protobuf、Thrift、Avro都可以。
- 基於前後端分離,或者獨立的對外的api服務,選用JSON是比較好的,對於除錯、可讀性都很不錯
- Avro設計理念偏於動態型別語言,那麼這類的場景使用Avro是可以的
版權宣告:本部落格所有文章除特別宣告外,均採用 CC BY-NC-SA 4.0 許可協議。轉載請註明來自Mic帶你學架構
!
如果本篇文章對您有幫助,還請幫忙點個關注和贊,您的堅持是我不斷創作的動力。歡迎關注同名微信公眾號獲取更多技術乾貨!