二進(jìn)制Java序列化
基于JSON或XML的文本序列化的方式簡(jiǎn)單清晰,且文本傳輸對于異構語(yǔ)言都天然的優(yōu)勢,只要各開(kāi)發(fā)語(yǔ)言可以JSON或XML格式即可。但文本格式由于未經(jīng)壓縮,其內容所占據的空間較大,并且解析較慢,因此,對于性能要求高的互聯(lián)網(wǎng)場(chǎng)景,二進(jìn)制的序列化的方案更受青睞。對于由Java語(yǔ)言所搭建而成的同構系統,有很多僅針對Java語(yǔ)言的序列化方案。僅針對Java的二進(jìn)制序列化方案可以很好的和Java語(yǔ)言本身結合,能夠給開(kāi)發(fā)工作帶來(lái)很大的便利。
Java原生序列化
Java提供了原生的序列化方式,非常簡(jiǎn)單易用。只要一個(gè)類(lèi)實(shí)現了java.io.Serializable接口,那么它就可以被序列化。使用Java對象序列化保存對象,會(huì )將其狀態(tài)轉化為字節數組。當某個(gè)字段被聲明為transient后,序列化機制會(huì )忽略該字段。另外,序列化保存的是對象的成員變量,即對象的狀態(tài)。因此,對象序列化不會(huì )保存靜態(tài)變量,因為它們是類(lèi)的屬性。
在上文的Netty介紹中,我們已經(jīng)引入了序列化這個(gè)概念,使用的正是Java的原生序列化方案。
Java原生序列化使用serialVersionUID來(lái)控制兼容性。凡是實(shí)現Serializable接口的類(lèi)都有一個(gè)標識序列化版本標識符的靜態(tài)變量:
如果不顯示指定,它將由Java運行時(shí)環(huán)境根據類(lèi)的內部細節自動(dòng)生成的。修改源碼再重新編譯的話(huà),類(lèi)文件的serialVersionUID的取值可能會(huì )發(fā)生變化。
Java的序列化機制是通過(guò)在運行時(shí)判斷類(lèi)的serialVersionUID來(lái)驗證版本是否一致的。反序列化時(shí),JVM會(huì )將字節流中的serialVersionUID與相應類(lèi)中的serialVersionUID比較,如果不同,則拋出序列化版本不一致的異常。
如果希望實(shí)現序列化接口的實(shí)體能夠兼容之前的版本,可以顯式指定serialVersionUID,以保證不同版本的類(lèi)對序列化兼容。
雖然Java原生支持的序列化機制足夠簡(jiǎn)單,但在性能方面,它簡(jiǎn)直可以用災難來(lái)形容。由于Java原生的序列化后的字節大小過(guò)于臃腫,導致非常不利于在網(wǎng)絡(luò )中的傳輸性能;并且它序列化與反序列化本身的性能也并不理想。因此在互聯(lián)網(wǎng)這樣對性能要求很高的場(chǎng)景,不會(huì )采用Java原生的序列化的方案,它僅僅適合于對性能要求不高的場(chǎng)景。
對于Java提供的RMI、EJB等原生組件,由于采用了其原生序列化的方式,導致吞吐量無(wú)法突破瓶頸,也逐漸被棄用。
高性能序列化框架Kryo
由于Java原生的序列化方案性能無(wú)法滿(mǎn)足互聯(lián)網(wǎng)的需要,很多優(yōu)秀的第三方高性能序列化框架層出不窮。它們在不同的場(chǎng)景性能可能略有波動(dòng)起伏,但總體來(lái)說(shuō),高于Java原生的序列化方案十幾倍的性能,是很容易達成的。
Kryo是一個(gè)高效的Java序列化框架。Kryo可以選擇不將類(lèi)的元信息序列化,因此,當一個(gè)類(lèi)第一次被Kryo序列化時(shí),它需要需要時(shí)間去加載該類(lèi)。這雖然導致Kryo在其序列化工具的初始化時(shí)間較長(cháng),但這僅僅是一次性消耗。另外可以使用注冊序列化類(lèi)的方式將這樣的開(kāi)銷(xiāo)放在應用程序啟動(dòng)時(shí),用于避免不確定的第一次序列化時(shí)間。這樣做的好處是使得序列化字節的容量大小明顯降低,增加了字節信息網(wǎng)絡(luò )傳輸的效率;并且由于類(lèi)信息均已經(jīng)在內存只加載,讓其序列化和反序列化的性能也有所提升。使用Kryo無(wú)需再實(shí)現Serializable接口。
下面是使用Kryo序列化的核心代碼:
下面是使用Kryo反序列化的核心代碼:
使用Kryo必須有一個(gè)無(wú)參的構造器,否則程序將無(wú)法正確運行。如果不提供無(wú)參構造器,可以通過(guò)Kryo的setInstantiatorStrategy方法設置對象初始化策略為StdInstantiatorStrategy,該策略可以直接創(chuàng )建一個(gè)空對象。但如果構造函數中需要一些初始化操作,使用這種策略會(huì )破殼對象的完整性。因此最佳實(shí)踐還是從一開(kāi)始就考慮設計一個(gè)無(wú)參的構造器為妙。
Kryo有3種序列化方法。
1. 調用Kryo的writeObject方法。它只會(huì )序列化對象的實(shí)例,而不會(huì )記錄對象所屬類(lèi)的元信息。它的優(yōu)勢是進(jìn)一步的節省空間,劣勢是需要提供該類(lèi)作為反序列化的模板。上文的程序示例即采用此種方案。
2. 調用Kryo的writeClassAndObject方法。它將一并序列化對象數據信息和類(lèi)的元信息。它的優(yōu)勢是整個(gè)程序的聲明周期都無(wú)需再提供該類(lèi)信息,劣勢是空間占用大,網(wǎng)絡(luò )間傳輸帶寬消耗多。
3. 先調用Kryo的register方法注冊需要序列化的類(lèi),再通過(guò)調用Kryo的writeClassAndObject方法序列化。Kryo通過(guò)對類(lèi)的注冊而綁定一個(gè)唯一的數字作為id,在writeClassAndObject時(shí)僅需要序列化id即可,無(wú)需序列化類(lèi)的全部元信息。優(yōu)勢是在節省空間的同時(shí)也無(wú)需在反序列化時(shí)提供原始類(lèi)的信息。劣勢是對于通過(guò)Kyro寫(xiě)序列化通用框架的開(kāi)發(fā)者并不友好,需要提供額外的接口提供使用方程序員注冊相關(guān)類(lèi)。
使用Kryo基本可以替代Java原生序列化的場(chǎng)景,并且性能提升很大。因此,在Java同構語(yǔ)言的序列化框架選擇上,Kryo是一個(gè)理想的解決方案。
二進(jìn)制Java序列化
之前講述的序列化框架都是Java語(yǔ)言的,而完全由單一語(yǔ)言組成的現代系統已不多見(jiàn)。由于每種開(kāi)發(fā)語(yǔ)言都有各自的優(yōu)勢和適用的場(chǎng)景,因此,一個(gè)復雜系統由異構語(yǔ)言組成是很常見(jiàn)的。
高性能異構語(yǔ)言序列化框架Protobuf
Protobuf的全稱(chēng)是Protocol Buffers,是google開(kāi)源的跨平臺、跨語(yǔ)言的輕便高效的序列化協(xié)議。它是Google內部廣泛使用的異構語(yǔ)言數據標準。它支持反序列化后的對象支持向前兼容。與同構語(yǔ)言的序列化方式不同,Protobuf使用預先定義完成的協(xié)議格式生成代碼的方式。
使用Protobuf首先需要在系統上安裝它的命令用于編譯proto協(xié)議文件。
截止至本書(shū)寫(xiě)作時(shí),最新的穩定版本是3.4.0,因此本書(shū)將以這個(gè)版本舉例說(shuō)明。我們介紹一下在Mac系統上如何安裝protobuf,其他操作系統請自行查閱相關(guān)資料。請確保Mac系統安裝了Homebrew,然后在命令行直接中輸入“brew install protobuf”命令等待安裝完成即可。
校驗protobuf是否正確安裝,只需在命令行中輸入“protoc --version”,即可返回當前安裝的protobuf版本號,brew命令會(huì )非常聰明的將Protobuf的環(huán)境變量自動(dòng)設置完成。
Protobuf通過(guò)proto協(xié)議文件來(lái)定義程序中需要處理的結構化數據,結構化數據在Protobuf中的術(shù)語(yǔ)被稱(chēng)為消息(Message)。proto 協(xié)議文件以.proto結尾,它類(lèi)似于 Java語(yǔ)言中數據對象的定義。一個(gè)消息類(lèi)型由一個(gè)或多個(gè)字段組成,每個(gè)字段至少應該包括類(lèi)型、名稱(chēng)和標識符。
標識符是一個(gè)正整數,每個(gè)標識符在該消息體中必須是唯一的。標識符是用于在轉化為二進(jìn)制的消息中識別各個(gè)字段,一旦開(kāi)始使用則不允許更改。有一個(gè)壓縮生成二進(jìn)制消息大小的竅門(mén),1-15的數字,在16進(jìn)制中是0x1-0xF,僅占用一個(gè)字節;以此類(lèi)推,16-2047會(huì )占用2個(gè)字節。因此,應盡量將頻繁出現的消息字段保留在1-15標識符之內。另外,可以為將來(lái)可能出現的字段預留標識符。標識符的只增不刪特性,是Protobuf的消息能夠保持向后兼容的關(guān)鍵。
我們以一個(gè)簡(jiǎn)單的例子來(lái)開(kāi)始:

這是一個(gè)標準的proto協(xié)議文件,我們來(lái)逐行說(shuō)明一下:
1. 指明正在使用proto3語(yǔ)法。缺省使用proto2。Syntax語(yǔ)句必須是proto文件的空行和注釋行之外的第一行。Protobuf 2.x與Protobuf 3.x的語(yǔ)法不完全兼容,相比之下,3.x的語(yǔ)法更加簡(jiǎn)明清晰。
2. 指明該文件編譯為類(lèi)之后的包名稱(chēng)是protobuf.pojo。
3. 定義消息類(lèi)型,對應于Java即為類(lèi)名稱(chēng)。該消息名稱(chēng)為ProtoPojo,消息體包含3個(gè)字段。
4. 定義名為id的屬性,類(lèi)型是32位的整數,標識符是1。
5. 定義名為name的屬性,類(lèi)型是字符串,標識符是2。
6. 定義名為messages的屬性,類(lèi)型是可重復的字符串,對應Java是一個(gè)List集合類(lèi)型,標識符是3。
對于Protobuf的協(xié)議有了直觀(guān)的了解之后,我們再系統的了解一下proto3所支持的消息類(lèi)型。下表摘自Protobuf官方網(wǎng)站,展示了它所支持的所有消息類(lèi)型。為了簡(jiǎn)單起見(jiàn),我們僅將C++、Java、Python和Go這幾種語(yǔ)言的相關(guān)類(lèi)型展示出來(lái),Protobuf支持的其他語(yǔ)言還包括Ruby、C#和PHP。

Protobuf還可以使用枚舉類(lèi)型和嵌套使用其他消息類(lèi)型,還可以使用import命令將其他文件中定義的消息類(lèi)型導入至當前文件中使用。
Protobuf是一個(gè)向后兼容的協(xié)議,更新消息的結構而不破壞已有代碼是非常簡(jiǎn)單的。在更新時(shí)需要滿(mǎn)足以下規則:
1. 不能更改已有字段的數字標識符。
2. 使用舊代碼產(chǎn)生的消息被新代碼解析時(shí),新增字段將被賦為默認值;使用新代碼產(chǎn)生的消息被舊代碼解析時(shí),新增字段將被忽略。需要注意的是,未識別的字段會(huì )在反序列化時(shí)將被丟棄。
3. 非必填的字段可以刪除,但必須保證它們的數字標識符在新的消息中不再被使用。
4. int32, uint32, int64, uint64,和bool是全部兼容的,它們之間可以任意轉換,而不會(huì )破壞其兼容性。需要注意的是,如果解析出來(lái)的數字與對應的類(lèi)型不相符,將進(jìn)行強制類(lèi)型轉換,這可能會(huì )導致精度的丟失。例如,將一個(gè)int64的數字當作int32來(lái)讀取,那么它將會(huì )被截斷為32位的數字。
5. sint32與sint64相互兼容,但是與其他整數類(lèi)型不兼容;string與有效的UTF-8編碼的bytes相互兼容;fixed32與sfixed32相互兼容;fixed64與sfixed64相互兼容;枚舉類(lèi)型與int32,uint32,int64和uint64相兼容。
關(guān)于Protobuf協(xié)議的格式定義還有很多細節,更加詳細的信息請閱覽它的官方網(wǎng)址:https://developers.google.com/protocol-buffers/docs/proto3
在完成消息的定義之后,即可以通過(guò)Protobuf提供的命令行生成相關(guān)開(kāi)發(fā)語(yǔ)言的代碼。這里仍然以Java語(yǔ)言為例,在命令行中輸入:“protoc --java_out=. ./Pojo.proto”,即可在當前路徑生成相關(guān)的Java代碼。命令行中的protoc即為Protobuf編譯器的命令,它應該已隨著(zhù)Mac系統的brewhome配置至系統的環(huán)境變量;--java_out=.則是指定生成Java語(yǔ)言編譯的類(lèi),位置是當前路徑;./Pojo.proto則是目標的協(xié)議文件路徑。命令執行之后即在生成的目標路徑按照配置的包名生成好了相應的.java文件。更多的protoc命令的使用細節可以在命令行中輸入:“protoc --help”來(lái)查看。
為了使生成的代碼通過(guò)編譯,需要在Maven的pom.xml文件中引用Protobuf的相應版本,在這里我們使用的是3.4.0版本,Maven坐標如下:
下面我們看一下從.proto文件生成了什么。
對Java語(yǔ)言來(lái)說(shuō),編譯器為每個(gè).proto文件對應生成一個(gè).java文件。這個(gè)Java文件的主類(lèi)名稱(chēng)與.proto的文件名保持一致,并且為每一個(gè)消息類(lèi)型定義一個(gè)消息對象的內部類(lèi)以及一個(gè)用來(lái)創(chuàng )建消息的構建內部接口。每個(gè)消息類(lèi)型的內部類(lèi)中會(huì )再包含一個(gè)名為Builder的內部類(lèi)用于實(shí)現消息構建接口。
值得注意的是,一個(gè)Java類(lèi)中可以包含多個(gè)定義的消息類(lèi)型。我們之前的例子為了簡(jiǎn)單起見(jiàn),在協(xié)議中僅定義了一個(gè)名為ProtoPojo的消息,如果在同一個(gè)協(xié)議文件中定義了多個(gè)消息,那么每個(gè)消息類(lèi)型將會(huì )被生成為一對消息內部類(lèi)和消息構建內部接口。
下面是ProtoPojo構建接口的生成代碼展示:


可以看到生成的ProtoPojoOrBuilder接口中包含了協(xié)議文件中定義的3個(gè)屬性的getter方法。 相關(guān)屬性的方法上保留著(zhù)協(xié)議文件中原始定義的字符串以及相關(guān)注釋。下面我們一一對應下協(xié)議文件中聲明的屬性和Java文件中生成的屬性,為了清晰起見(jiàn),我們將生成文件中的包名都去掉。
1. 協(xié)議文件中的int32 id = 1,對應的代碼中僅生成了一個(gè)int getId()方法。因為int32類(lèi)型的數據無(wú)需做復雜的序列化。
3. 協(xié)議中的repeated string messages = 3,對應的代碼生成了四個(gè)方法,分別是List< string=""> getMessagesList()、int getMessagesCount()、String getMessages(int index)和ByteString getMessagesBytes(int index)。由于是repeated類(lèi)型,因此將messages映射為一個(gè)集合,并且提供了集合長(cháng)度以及通過(guò)索引獲取集合中元素的方法。
使用Protobuf API進(jìn)行序列化和反序列化比較簡(jiǎn)單,序列化的方式主要是兩個(gè):
1. byte[] toByteArray():這個(gè)方法可以將Java對象序列化為二進(jìn)制字節數組,以便進(jìn)行網(wǎng)絡(luò )傳遞。
2. void writeTo(OutputStream output):這個(gè)方法用于將Java對象直接序列化并寫(xiě)入一個(gè)輸出流。
兩個(gè)序列化的方法分別對應的兩個(gè)反序列化方法,與序列化方法不同,反序列化方法都是類(lèi)的靜態(tài)方法:
1. static T parseFrom(byte[] data):將二進(jìn)制的字節數組反序列化為Java對象。其中返回值T借用了Java的泛型概念,用于表示其返回類(lèi)型與調用它的類(lèi)的類(lèi)型一致。該方法是對應于byte[] toByteArray()的反序列化方式。
2. static T parseFrom(InputStream input):通過(guò)一個(gè)輸入流讀取二進(jìn)制字節數組并反序列化為Java對象。該方法是對應于void writeTo(OutputStream output) 的反序列化方式。
下面是使用Protobuf將Java對象序列化的核心代碼:
下面是使用Protobuf將Java對象反序列化的核心代碼:
小結
面對種類(lèi)如此之多的序列化方案,如何選擇合適的序列化框架呢?從調試的便利性以及協(xié)議的清晰度來(lái)說(shuō),基于文本的JSON協(xié)議是不錯的選擇;從性能方面考慮,文本協(xié)議比二進(jìn)制協(xié)議差一些。二進(jìn)制協(xié)議中,無(wú)論是Protobuf還是Kryo都是高效的,而Java原生的序列化方案則并不理想;在異構語(yǔ)言方面,本文協(xié)議全方位支持,二進(jìn)制的協(xié)議中則只有類(lèi)似于Protobuf這種靜態(tài)代碼生成方式可以支持,但在日常開(kāi)發(fā)中卻略顯麻煩,因為它們即使在同構語(yǔ)言的交互中,也仍然需要根據協(xié)議文件靜態(tài)生成代碼。因此,使用何種序列化框架是需要綜合考量的。我們通過(guò)下表的各類(lèi)序列化框架的直觀(guān)對比來(lái)結束本節的話(huà)題。
以上內容節選自
《 Java云原生新一代分布式中間件架構》

內容簡(jiǎn)介
【互聯(lián)網(wǎng)架構不斷演化,經(jīng)歷了從集中式架構到分布式架構,再到云原生架構的過(guò)程。云原生因能解決傳統應用升級緩慢、架構臃腫、不能快速迭代等問(wèn)題而成為未來(lái)云端應用的目標。本書(shū)首先介紹了架構演化及云原生的概念,讓讀者對基礎概念有一個(gè)準確的了解。接著(zhù)闡述容器調度、服務(wù)化、分布式等體系的原理,講解分布式中間件設計方法。最后輔以實(shí)戰,以中心化和平臺化角度切入,深度揭秘兩大開(kāi)源項目Elastic-Job和Sharding-JDBC的實(shí)現】
盡請期待
《Java云原生 新一代分布式中間件架構》
2018年與您見(jiàn)面
書(shū)名尚未完全確定,歡迎您寶貴建議。
感謝大家關(guān)注“點(diǎn)亮架構”,歡迎對公眾號文章的內容批評指正,如果有其他想要了解的技術(shù)問(wèn)題,也可以留言提出。
‘點(diǎn)亮架構’的火炬,燃燒云原生‘
聯(lián)系客服