二進制Java序列化 基于JSON或XML的文本序列化的方式簡單清晰,且文本傳輸對于異構(gòu)語言都天然的優(yōu)勢,,只要各開發(fā)語言可以JSON或XML格式即可,。但文本格式由于未經(jīng)壓縮,其內(nèi)容所占據(jù)的空間較大,,并且解析較慢,,因此,對于性能要求高的互聯(lián)網(wǎng)場景,,二進制的序列化的方案更受青睞,。對于由Java語言所搭建而成的同構(gòu)系統(tǒng),有很多僅針對Java語言的序列化方案,。僅針對Java的二進制序列化方案可以很好的和Java語言本身結(jié)合,,能夠給開發(fā)工作帶來很大的便利。 Java原生序列化 Java提供了原生的序列化方式,,非常簡單易用,。只要一個類實現(xiàn)了java.io.Serializable接口,那么它就可以被序列化,。使用Java對象序列化保存對象,,會將其狀態(tài)轉(zhuǎn)化為字節(jié)數(shù)組。當某個字段被聲明為transient后,,序列化機制會忽略該字段,。另外,序列化保存的是對象的成員變量,,即對象的狀態(tài),。因此,對象序列化不會保存靜態(tài)變量,,因為它們是類的屬性。 在上文的Netty介紹中,,我們已經(jīng)引入了序列化這個概念,,使用的正是Java的原生序列化方案,。 Java原生序列化使用serialVersionUID來控制兼容性。凡是實現(xiàn)Serializable接口的類都有一個標識序列化版本標識符的靜態(tài)變量: 如果不顯示指定,,它將由Java運行時環(huán)境根據(jù)類的內(nèi)部細節(jié)自動生成的,。修改源碼再重新編譯的話,類文件的serialVersionUID的取值可能會發(fā)生變化,。 Java的序列化機制是通過在運行時判斷類的serialVersionUID來驗證版本是否一致的,。反序列化時,JVM會將字節(jié)流中的serialVersionUID與相應(yīng)類中的serialVersionUID比較,,如果不同,,則拋出序列化版本不一致的異常。 如果希望實現(xiàn)序列化接口的實體能夠兼容之前的版本,,可以顯式指定serialVersionUID,,以保證不同版本的類對序列化兼容。 雖然Java原生支持的序列化機制足夠簡單,,但在性能方面,,它簡直可以用災(zāi)難來形容。由于Java原生的序列化后的字節(jié)大小過于臃腫,,導(dǎo)致非常不利于在網(wǎng)絡(luò)中的傳輸性能,;并且它序列化與反序列化本身的性能也并不理想。因此在互聯(lián)網(wǎng)這樣對性能要求很高的場景,,不會采用Java原生的序列化的方案,,它僅僅適合于對性能要求不高的場景。 對于Java提供的RMI,、EJB等原生組件,,由于采用了其原生序列化的方式,導(dǎo)致吞吐量無法突破瓶頸,,也逐漸被棄用,。 高性能序列化框架Kryo 由于Java原生的序列化方案性能無法滿足互聯(lián)網(wǎng)的需要,很多優(yōu)秀的第三方高性能序列化框架層出不窮,。它們在不同的場景性能可能略有波動起伏,,但總體來說,高于Java原生的序列化方案十幾倍的性能,,是很容易達成的,。 Kryo是一個高效的Java序列化框架。Kryo可以選擇不將類的元信息序列化,,因此,,當一個類第一次被Kryo序列化時,它需要需要時間去加載該類,。這雖然導(dǎo)致Kryo在其序列化工具的初始化時間較長,,但這僅僅是一次性消耗,。另外可以使用注冊序列化類的方式將這樣的開銷放在應(yīng)用程序啟動時,用于避免不確定的第一次序列化時間,。這樣做的好處是使得序列化字節(jié)的容量大小明顯降低,,增加了字節(jié)信息網(wǎng)絡(luò)傳輸?shù)男剩徊⑶矣捎陬愋畔⒕呀?jīng)在內(nèi)存只加載,,讓其序列化和反序列化的性能也有所提升,。使用Kryo無需再實現(xiàn)Serializable接口。 下面是使用Kryo序列化的核心代碼: 下面是使用Kryo反序列化的核心代碼: 使用Kryo必須有一個無參的構(gòu)造器,,否則程序?qū)o法正確運行,。如果不提供無參構(gòu)造器,可以通過Kryo的setInstantiatorStrategy方法設(shè)置對象初始化策略為StdInstantiatorStrategy,,該策略可以直接創(chuàng)建一個空對象,。但如果構(gòu)造函數(shù)中需要一些初始化操作,使用這種策略會破殼對象的完整性,。因此最佳實踐還是從一開始就考慮設(shè)計一個無參的構(gòu)造器為妙,。 Kryo有3種序列化方法。 1. 調(diào)用Kryo的writeObject方法,。它只會序列化對象的實例,,而不會記錄對象所屬類的元信息。它的優(yōu)勢是進一步的節(jié)省空間,,劣勢是需要提供該類作為反序列化的模板,。上文的程序示例即采用此種方案。 2. 調(diào)用Kryo的writeClassAndObject方法,。它將一并序列化對象數(shù)據(jù)信息和類的元信息,。它的優(yōu)勢是整個程序的聲明周期都無需再提供該類信息,劣勢是空間占用大,,網(wǎng)絡(luò)間傳輸帶寬消耗多,。 3. 先調(diào)用Kryo的register方法注冊需要序列化的類,再通過調(diào)用Kryo的writeClassAndObject方法序列化,。Kryo通過對類的注冊而綁定一個唯一的數(shù)字作為id,,在writeClassAndObject時僅需要序列化id即可,無需序列化類的全部元信息,。優(yōu)勢是在節(jié)省空間的同時也無需在反序列化時提供原始類的信息,。劣勢是對于通過Kyro寫序列化通用框架的開發(fā)者并不友好,需要提供額外的接口提供使用方程序員注冊相關(guān)類,。 使用Kryo基本可以替代Java原生序列化的場景,,并且性能提升很大。因此,在Java同構(gòu)語言的序列化框架選擇上,,Kryo是一個理想的解決方案,。 二進制Java序列化 之前講述的序列化框架都是Java語言的,而完全由單一語言組成的現(xiàn)代系統(tǒng)已不多見,。由于每種開發(fā)語言都有各自的優(yōu)勢和適用的場景,因此,,一個復(fù)雜系統(tǒng)由異構(gòu)語言組成是很常見的,。 高性能異構(gòu)語言序列化框架Protobuf Protobuf的全稱是Protocol Buffers,是google開源的跨平臺,、跨語言的輕便高效的序列化協(xié)議,。它是Google內(nèi)部廣泛使用的異構(gòu)語言數(shù)據(jù)標準。它支持反序列化后的對象支持向前兼容,。與同構(gòu)語言的序列化方式不同,,Protobuf使用預(yù)先定義完成的協(xié)議格式生成代碼的方式。 使用Protobuf首先需要在系統(tǒng)上安裝它的命令用于編譯proto協(xié)議文件,。 截止至本書寫作時,,最新的穩(wěn)定版本是3.4.0,因此本書將以這個版本舉例說明,。我們介紹一下在Mac系統(tǒng)上如何安裝protobuf,,其他操作系統(tǒng)請自行查閱相關(guān)資料。請確保Mac系統(tǒng)安裝了Homebrew,,然后在命令行直接中輸入“brew install protobuf”命令等待安裝完成即可,。 校驗protobuf是否正確安裝,只需在命令行中輸入“protoc --version”,,即可返回當前安裝的protobuf版本號,,brew命令會非常聰明的將Protobuf的環(huán)境變量自動設(shè)置完成。 Protobuf通過proto協(xié)議文件來定義程序中需要處理的結(jié)構(gòu)化數(shù)據(jù),,結(jié)構(gòu)化數(shù)據(jù)在Protobuf中的術(shù)語被稱為消息(Message),。proto 協(xié)議文件以.proto結(jié)尾,它類似于 Java語言中數(shù)據(jù)對象的定義,。一個消息類型由一個或多個字段組成,,每個字段至少應(yīng)該包括類型、名稱和標識符,。 標識符是一個正整數(shù),,每個標識符在該消息體中必須是唯一的。標識符是用于在轉(zhuǎn)化為二進制的消息中識別各個字段,,一旦開始使用則不允許更改,。有一個壓縮生成二進制消息大小的竅門,1-15的數(shù)字,,在16進制中是0x1-0xF,,僅占用一個字節(jié),;以此類推,16-2047會占用2個字節(jié),。因此,,應(yīng)盡量將頻繁出現(xiàn)的消息字段保留在1-15標識符之內(nèi)。另外,,可以為將來可能出現(xiàn)的字段預(yù)留標識符,。標識符的只增不刪特性,是Protobuf的消息能夠保持向后兼容的關(guān)鍵,。 我們以一個簡單的例子來開始: 這是一個標準的proto協(xié)議文件,,我們來逐行說明一下: 1. 指明正在使用proto3語法。缺省使用proto2,。Syntax語句必須是proto文件的空行和注釋行之外的第一行,。Protobuf 2.x與Protobuf 3.x的語法不完全兼容,相比之下,,3.x的語法更加簡明清晰,。 2. 指明該文件編譯為類之后的包名稱是protobuf.pojo。 3. 定義消息類型,,對應(yīng)于Java即為類名稱,。該消息名稱為ProtoPojo,消息體包含3個字段,。 4. 定義名為id的屬性,,類型是32位的整數(shù),標識符是1,。 5. 定義名為name的屬性,,類型是字符串,標識符是2,。 6. 定義名為messages的屬性,,類型是可重復(fù)的字符串,對應(yīng)Java是一個List集合類型,,標識符是3,。 對于Protobuf的協(xié)議有了直觀的了解之后,我們再系統(tǒng)的了解一下proto3所支持的消息類型,。下表摘自Protobuf官方網(wǎng)站,,展示了它所支持的所有消息類型。為了簡單起見,,我們僅將C++,、Java、Python和Go這幾種語言的相關(guān)類型展示出來,Protobuf支持的其他語言還包括Ruby,、C#和PHP,。 Protobuf還可以使用枚舉類型和嵌套使用其他消息類型,還可以使用import命令將其他文件中定義的消息類型導(dǎo)入至當前文件中使用,。 Protobuf是一個向后兼容的協(xié)議,,更新消息的結(jié)構(gòu)而不破壞已有代碼是非常簡單的。在更新時需要滿足以下規(guī)則: 1. 不能更改已有字段的數(shù)字標識符,。 2. 使用舊代碼產(chǎn)生的消息被新代碼解析時,,新增字段將被賦為默認值;使用新代碼產(chǎn)生的消息被舊代碼解析時,,新增字段將被忽略。需要注意的是,,未識別的字段會在反序列化時將被丟棄,。 3. 非必填的字段可以刪除,但必須保證它們的數(shù)字標識符在新的消息中不再被使用,。 4. int32, uint32, int64, uint64,和bool是全部兼容的,,它們之間可以任意轉(zhuǎn)換,而不會破壞其兼容性,。需要注意的是,,如果解析出來的數(shù)字與對應(yīng)的類型不相符,將進行強制類型轉(zhuǎn)換,,這可能會導(dǎo)致精度的丟失,。例如,將一個int64的數(shù)字當作int32來讀取,,那么它將會被截斷為32位的數(shù)字,。 5. sint32與sint64相互兼容,但是與其他整數(shù)類型不兼容,;string與有效的UTF-8編碼的bytes相互兼容,;fixed32與sfixed32相互兼容;fixed64與sfixed64相互兼容,;枚舉類型與int32,,uint32,int64和uint64相兼容,。 關(guān)于Protobuf協(xié)議的格式定義還有很多細節(jié),,更加詳細的信息請閱覽它的官方網(wǎng)址:https://developers.google.com/protocol-buffers/docs/proto3 在完成消息的定義之后,即可以通過Protobuf提供的命令行生成相關(guān)開發(fā)語言的代碼,。這里仍然以Java語言為例,,在命令行中輸入:“protoc --java_out=. ./Pojo.proto”,即可在當前路徑生成相關(guān)的Java代碼。命令行中的protoc即為Protobuf編譯器的命令,,它應(yīng)該已隨著Mac系統(tǒng)的brewhome配置至系統(tǒng)的環(huán)境變量,;--java_out=.則是指定生成Java語言編譯的類,位置是當前路徑,;./Pojo.proto則是目標的協(xié)議文件路徑,。命令執(zhí)行之后即在生成的目標路徑按照配置的包名生成好了相應(yīng)的.java文件。更多的protoc命令的使用細節(jié)可以在命令行中輸入:“protoc --help”來查看,。 為了使生成的代碼通過編譯,,需要在Maven的pom.xml文件中引用Protobuf的相應(yīng)版本,在這里我們使用的是3.4.0版本,,Maven坐標如下: 下面我們看一下從.proto文件生成了什么,。 對Java語言來說,編譯器為每個.proto文件對應(yīng)生成一個.java文件,。這個Java文件的主類名稱與.proto的文件名保持一致,,并且為每一個消息類型定義一個消息對象的內(nèi)部類以及一個用來創(chuàng)建消息的構(gòu)建內(nèi)部接口。每個消息類型的內(nèi)部類中會再包含一個名為Builder的內(nèi)部類用于實現(xiàn)消息構(gòu)建接口,。 值得注意的是,,一個Java類中可以包含多個定義的消息類型。我們之前的例子為了簡單起見,,在協(xié)議中僅定義了一個名為ProtoPojo的消息,,如果在同一個協(xié)議文件中定義了多個消息,那么每個消息類型將會被生成為一對消息內(nèi)部類和消息構(gòu)建內(nèi)部接口,。 下面是ProtoPojo構(gòu)建接口的生成代碼展示: 可以看到生成的ProtoPojoOrBuilder接口中包含了協(xié)議文件中定義的3個屬性的getter方法,。 相關(guān)屬性的方法上保留著協(xié)議文件中原始定義的字符串以及相關(guān)注釋。下面我們一一對應(yīng)下協(xié)議文件中聲明的屬性和Java文件中生成的屬性,,為了清晰起見,,我們將生成文件中的包名都去掉。 1. 協(xié)議文件中的int32 id = 1,,對應(yīng)的代碼中僅生成了一個int getId()方法,。因為int32類型的數(shù)據(jù)無需做復(fù)雜的序列化。 3. 協(xié)議中的repeated string messages = 3,,對應(yīng)的代碼生成了四個方法,,分別是List< string=""> getMessagesList()、int getMessagesCount(),、String getMessages(int index)和ByteString getMessagesBytes(int index),。由于是repeated類型,因此將messages映射為一個集合,,并且提供了集合長度以及通過索引獲取集合中元素的方法,。 使用Protobuf API進行序列化和反序列化比較簡單,,序列化的方式主要是兩個: 1. byte[] toByteArray():這個方法可以將Java對象序列化為二進制字節(jié)數(shù)組,以便進行網(wǎng)絡(luò)傳遞,。 2. void writeTo(OutputStream output):這個方法用于將Java對象直接序列化并寫入一個輸出流,。 兩個序列化的方法分別對應(yīng)的兩個反序列化方法,與序列化方法不同,,反序列化方法都是類的靜態(tài)方法: 1. static T parseFrom(byte[] data):將二進制的字節(jié)數(shù)組反序列化為Java對象,。其中返回值T借用了Java的泛型概念,用于表示其返回類型與調(diào)用它的類的類型一致,。該方法是對應(yīng)于byte[] toByteArray()的反序列化方式,。 2. static T parseFrom(InputStream input):通過一個輸入流讀取二進制字節(jié)數(shù)組并反序列化為Java對象。該方法是對應(yīng)于void writeTo(OutputStream output) 的反序列化方式,。 下面是使用Protobuf將Java對象序列化的核心代碼: 下面是使用Protobuf將Java對象反序列化的核心代碼: 小結(jié) 面對種類如此之多的序列化方案,,如何選擇合適的序列化框架呢?從調(diào)試的便利性以及協(xié)議的清晰度來說,,基于文本的JSON協(xié)議是不錯的選擇,;從性能方面考慮,文本協(xié)議比二進制協(xié)議差一些,。二進制協(xié)議中,無論是Protobuf還是Kryo都是高效的,,而Java原生的序列化方案則并不理想,;在異構(gòu)語言方面,本文協(xié)議全方位支持,,二進制的協(xié)議中則只有類似于Protobuf這種靜態(tài)代碼生成方式可以支持,,但在日常開發(fā)中卻略顯麻煩,因為它們即使在同構(gòu)語言的交互中,,也仍然需要根據(jù)協(xié)議文件靜態(tài)生成代碼,。因此,使用何種序列化框架是需要綜合考量的,。我們通過下表的各類序列化框架的直觀對比來結(jié)束本節(jié)的話題,。 以上內(nèi)容節(jié)選自 《 Java云原生新一代分布式中間件架構(gòu)》 內(nèi)容簡介 【互聯(lián)網(wǎng)架構(gòu)不斷演化,經(jīng)歷了從集中式架構(gòu)到分布式架構(gòu),,再到云原生架構(gòu)的過程,。云原生因能解決傳統(tǒng)應(yīng)用升級緩慢、架構(gòu)臃腫,、不能快速迭代等問題而成為未來云端應(yīng)用的目標,。本書首先介紹了架構(gòu)演化及云原生的概念,讓讀者對基礎(chǔ)概念有一個準確的了解,。接著闡述容器調(diào)度,、服務(wù)化,、分布式等體系的原理,講解分布式中間件設(shè)計方法,。最后輔以實戰(zhàn),,以中心化和平臺化角度切入,深度揭秘兩大開源項目Elastic-Job和Sharding-JDBC的實現(xiàn)】 盡請期待 《Java云原生 新一代分布式中間件架構(gòu)》 2018年與您見面 書名尚未完全確定,,歡迎您寶貴建議,。 感謝大家關(guān)注“點亮架構(gòu)”,歡迎對公眾號文章的內(nèi)容批評指正,,如果有其他想要了解的技術(shù)問題,,也可以留言提出。 ‘點亮架構(gòu)’的火炬,,燃燒云原生‘ |
|