Java序列化Serializable的N種坑
你知道的越多,不知道的就越多,業(yè)余的像一棵小草!
你來,我們一起精進!你不來,我和你的競爭對手一起精進!
編輯:業(yè)余草
來源:juejin.cn/post/7097515553497022472
推薦:https://www.xttblog.com/?p=5357
自律才能自由
本文檔探討了改進Java平臺中的"序列化"的可能方向。這只是一份探索性文檔,并不針對日后Java平臺構(gòu)建任何特定版本中任何特定功能的計劃。

探討的原因
Java的序列化有點自我矛盾:一方面,它可能對Java的成功至關(guān)重要(如果沒有它,Java 可能不會占據(jù)主導地位,因為序列化采用了透明的遠程處理,進而促成了Java EE的成功)。另一方面,Java 的序列化幾乎犯下所有可以想象的錯誤,并為庫維護者、語言開發(fā)人員和用戶帶來了持續(xù)的負擔(以維護成本、安全風險和緩慢發(fā)展等形式)。
需要明確的是序列化概念本身并沒有錯;將對象轉(zhuǎn)換為可以輕松跨JVM傳輸并在另一端還原的能力是一個完全合理的想法。問題在于Java中的序列化設計,以及ta是否適合當前的對象模型。
序列化有什么問題?
Java的序列化所犯的錯誤是多方面的。部分罪惡如下:
「偽裝成JDK庫特性」:您通過實現(xiàn)Serializable接口來標記該類型允許被序列化,并使用ObjectOutputStream進行序列化。但實際上,序列化提取對象屬性并通過特殊的、語言外的機制繞過構(gòu)造方法并忽略類和字段的可訪問性,創(chuàng)建新的對象。 「假裝是Serializable靜態(tài)類型的特性」:實現(xiàn)Serializable接口實際上并不意味著對象是可序列化的,僅僅只能說明ta與序列化的操作并不敵對,因此即使實現(xiàn)了該接口,你并沒有足夠的信心來保證該對象可以被序列化成功。 「編譯器沒法幫助你」:編寫可序列化類時可能會犯各種錯誤,編譯器無法幫助您識別它們,然后錯誤會在運行時被拋出。
/* 此處Comparator實現(xiàn)最好是可序列化的,但編輯器無法更好的提醒你 */
public TreeMap(Comparator<? super K> comparator) {
this.comparator = comparator;
}
「充斥大量的魔法」:有那么多影響序列化行為的“魔法”方法和字段(從某種意義上說,它們沒有由任何基類或接口定義)。沒有幾個人可以準確無誤的將ta們?nèi)恐赋?。又因為這些不存在于任何公共類型中,所以ta們很難被發(fā)現(xiàn),并且無法輕松導航到它們的規(guī)范說明。ta們也很容易意外出錯——如果您拼寫錯誤,或者簽名錯誤,或者在ta們應該是實例成員時將它們設為靜態(tài)成員,沒有人會告訴您。(我待會就偷偷告訴你??????)
{
/* 列舉Java中關(guān)于序列化的相關(guān)魔法 */
/* 魔法字段 */
private static final long serialVersionUID;
private static final ObjectStreamField[] serialPersistentFields;
/* 魔法方法 */
void readObject(ObjectInputStream stream);
void writeObject(ObjectOutputStream out);
void readObjectNoData();
Object readResolve();
Object writeReplace();
}
寫到這里筆者不禁想到一道面試八股文題目:ArrayList中的elementData明明被transient關(guān)鍵字修飾不參與序列化,為何序列化之后數(shù)據(jù)并沒有丟失呢?原因就是ta重寫了writeObject(ObjectOutputStream s)和readObject(ObjectInputStream s),Map同理。
「無法回避的必要性」:如果你想要一個自定義的序列化形式,你可以實現(xiàn)方法readObject和 writeObject。但是人類無法輕易地閱讀代碼并推斷出序列格式——它隱含在這些方法的主體中,您需要確保二者遵循同一約定,此外,如果您使用這些方法,則需要從一開始就構(gòu)建版本控制機制(知易行難),否則當你想要作出改變并希望序列化可以保持兼容的時候,這將十分痛苦。 「與代碼緊密耦合」:序列化機制與其字節(jié)流的相關(guān)代碼緊密耦合。這使得將序列化邏輯與其他編碼格式(如JSON或XML)重用時變得不必要地困難;解構(gòu)和重構(gòu)對象的邏輯與讀取和寫入流的邏輯交織在一起。 「被上帝拋棄的流格式」:我們被序列化流的格式所困擾,因為這種格式既不緊湊,也不高效,更不可讀。
這些設計的惡果
如上文所述Java的這些序列化設計將直接或間接的帶來很多嚴重的問題:
「增加開源庫的維護者負擔」:Library設計者在發(fā)布可序列化的類之前必須仔細考慮——只有這樣做才可能會使您保持與所有已序列化的實例的兼容性。 「這是對封裝的嘲弄」:對象的序列化實際上是其成員屬性的序列化,并且通過一種繞過用戶編寫的構(gòu)造方法的非語言機制的方式進行對象的還原。所以選擇可序列化意味著放棄封裝的好處。序列化構(gòu)成了一個不可見但公共的構(gòu)造方法,以及一組不可見但公共的內(nèi)部狀態(tài)訪問器。這意味著很容易將壞數(shù)據(jù)注入到可序列化的類中,除非您痛苦地在構(gòu)造方法和readObject()之間重復檢查參數(shù),這種情況下,您就背離了 Java序列化機制的初衷:它應該是不需要額外耗費心力的。 「不能僅僅通過閱讀代碼來驗證正確性」:在面向?qū)ο蟮南到y(tǒng)中,構(gòu)造函數(shù)的作用是初始化一個對象并建立其不變量(如果成員變量恰好經(jīng)過final修飾)。這允許系統(tǒng)的其余部分假定一個基本的對象完整性程度。理論上,我們應該能夠通過閱讀其構(gòu)造方法和任何改變屬性的方法來推斷對象可能處于的狀態(tài)。但是由于序列化構(gòu)成了一個隱藏的公共構(gòu)造器,您還必須根據(jù)以前版本的代碼(其源代碼甚至可能不再存在,更不用說惡意構(gòu)造的字節(jié)流)來推斷對象可能處于的狀態(tài)。通過繞過構(gòu)造方法,序列化徹底顛覆了對象模型的完整性。 「難以確定其安全性」:對序列化的安全漏洞的多樣性和微妙性令人印象深刻。沒有一個普通的開發(fā)人員可以一次將它們?nèi)糠旁谀X海中。即使是安全專家代碼中的漏洞也可能從他眼皮底下溜走。確保代碼序列化值得信任太難了——因為序列化操作大多是不可見的,并且由神秘的的底層機制控制。 「阻礙語言進化」:編程語言的復雜性來自特性之間的意外交互,而序列化幾乎與一切交互??赡芴砑拥秸Z言中的每個特性都必須以某種方式與序列化相結(jié)合。Java內(nèi)存模型的一些細節(jié)是被序列化需要在對象構(gòu)造后寫入final字段所驅(qū)使的。(想一想:內(nèi)存模型應該描述語言與硬件的底層交互,但是我們卻需要扭曲ta以適應序列化?。㎜ambdas設計工作的一個重要部分涉及到與序列化的交互——我們所能做到最好的實現(xiàn)是一個沒有人會喜歡的妥協(xié)的讓步的結(jié)果。語言發(fā)展被迫長久背負序列化這個歷史包袱。
錯誤的源頭
上面列舉的諸多設計錯誤都源于一個共同的來源——通過“魔術(shù)”來實現(xiàn)序列化,而不是在對象模型本身中將解構(gòu)和重建放在首位。手機對象的字段是魔術(shù);通過語言外的后門重建對象則更神奇。使用這些語言外機制意味著我們在對象模型之外,因此我們只得放棄對象模型為我們提供的許多好處。
更糟糕的是,魔法會盡可能的讓工作者無感。假如魔法的附近有警告,那將會是另一回事——至少我們需要停下思考一下是不是會有預期之外的代碼邏輯被執(zhí)行。但是由于魔法是在無形之中損害程序,所以我們?nèi)匀徽J為我們的主要工作是設計健壯的API和實現(xiàn)我們的業(yè)務邏輯,而實際上我們已經(jīng)打開了后門,沒有任何防備。
魔法的誘惑是顯而易見的;只要在您的「Class」上撒上一些序列化粉末,瞧:即時透明遠程處理!但累積起來的成本是沉重的。在「Goto Considered Harmful」一文中,E?W?Dijkstra提供了一個合理依據(jù),解釋了為什么帶有「goto」的語言會給開發(fā)人員帶來不合理的認知負擔。相同的論點同樣適用于當前的序列化。
?我們的智力能力傾向于掌握靜態(tài)關(guān)系,而將隨時間演變的過程形象化的能力則相對落后。因此,我們應該最大限度地縮短靜態(tài)程序與動態(tài)過程之間的概念鴻溝:使程序(在文本空間維度展開)與執(zhí)行過程(在時間維度展開)之間的對應關(guān)系盡可能地細小入微。(筆者注:平鋪直敘的代碼總是更加可讀,但是用了goto關(guān)鍵字,代碼執(zhí)行順序,就與代碼文本從上至下的順序有些出入,我們需要想象一下執(zhí)行的動態(tài)過程,就好像遞歸怎么也沒有迭代好理解)
?
序列化,正如它目前實現(xiàn)的那樣,代碼文本和計算效果之間如此懸殊。
為什么不寫一個新的序列化庫
市面上現(xiàn)存大量的第三方類庫,它們要么旨在成為序列化“替代品”,要么是某些特定場景下的序列化的有效”替代品“。(包含但不僅限于:Arrow、Avro、Bert、Blixter、Bond、Capn Proto、CBOR、Colfer , Elsa, Externalizor, FlatBuffers, FST, GemFire PDX, Gson, Hessian, Ion, Jackson, JBoss Marshaling, JSON.simple, Kryo, Kudu, Lightning, MessagePack, Okapi, ORC, Paranoid, Parcelable, Parquet, POF, Portable, Protocol Buffers、Protostuff、Quickser、ReflecT、Seren、Serial、Simple、Simple Binary Encoding、SnakeYAML、Stephenerialization、Thrift、TinySerializer、travny、Verjson、Wobly、Xson、XStream、YamlBeans 等等)。
其中不乏一些流行的跨語言的序列化解決方案(例如 CBOR、Protocol Buffers)。種種跡象表明大多數(shù)人期許“更好的”的序列化。值得發(fā)問:怎么才算“更好“呢?現(xiàn)有的環(huán)境孵化了如此之多的序列化類庫說明諸多方案總是不能完美滿足開發(fā)者需求(例如:考慮人類可讀性或互通性,JSON是一個相對優(yōu)秀的選擇,但是有的人認為ta低效且易出錯)。同時我們發(fā)現(xiàn),這些類庫往往只是關(guān)注編碼格式、效率、靈活性,卻很少有人試圖解決基本的編程模型或安全隱患。
怎樣科學的做
正如我們所說,將對象序列化為字節(jié)流的概念根本沒有錯。想避免到目前為止所描述的問題,那就不得不調(diào)整我們的目標和各種需求的優(yōu)先級。我們先約定一些術(shù)語并嘗試陳述一些更好的實踐路線。
對于本文其余部分:
序列化將指將對象轉(zhuǎn)換為字節(jié)流并重構(gòu)它們的抽象概念 序列化框架是指實現(xiàn)某種形式的序列化的庫或工具 Java序列化將指的是內(nèi)置在平臺中并由《Java對象序列化規(guī)范》 https://docs.oracle.com/en/java/javase/12/docs/specs/serialization/index.html定義的特定序列化框架
減少期望
我們在上面已經(jīng)注意到,Java序列化的一個問題是它試圖做太多的事情?,F(xiàn)實場景中對序列化機制的期望通常并沒有那么苛刻;應用程序使用序列化來持久化數(shù)據(jù),或與其他應用程序交換數(shù)據(jù)。關(guān)注的重心在于數(shù)據(jù)而非對象。
更加顯式的設計
Java序列化是透明的;這被認為是它的主要優(yōu)點之一。但是這種透明性也是一個弱點,我們很容易忘記我們處理的是一個可序列化的類。Java為開發(fā)者構(gòu)建健壯、安全的Api提供了很好的幫助。開發(fā)人員知道如何編寫構(gòu)造方法來驗證其參數(shù)、對可變數(shù)據(jù)進行防御性拷貝,以及如何使用非公共成員將某些操作排除在對外公開API之外。但是Java序列化構(gòu)成了一個隱式的公開API,而且由于它通常是不可見的,所以很容易忘記保護它。
我們設計一個「class」時,通??紤]它的更典型的情景——面向他方訪問的公開Api以及拓展對象自身能力的私有api。我們稱其為“Front Door Api”或“User-Facing Api”。但是仍有一些特殊情景,例如序列化、mock或依賴注入等框架。我們通常會公開一些Api,這些Api是供框架使用的,可能并不想要公開給普通開發(fā)人員。我們稱呼ta們?yōu)椤癇ack Door Apis”。問題不在于我們有“Back Door Apis”;而是ta們是隱式的,因此對于「class」作者來說很難保護。保護“Back Door Apis”應該和保護“Front Door Api”一樣容易,理想情況下,我們可以使用相同的辦法來實現(xiàn)這一點。至少讓開發(fā)者不能徹底忘記、無視ta們。
對象模型中引進序列化
如果序列化的主要問題與它的語言外特性有關(guān),那么解決方法是將序列化帶回到語言和對象模型中,以便開發(fā)人員可以對“Front Door Api”和“Back Door Apis”一視同仁。這意味著不僅提供對類是否可序列化的顯式控制,而且還提供對如何進行序列化和反序列化以及由誰序列化和反序列化的控制。一些基本要求包括:
可序列化的類應該是為序列化而設計的;開發(fā)者應該提供解構(gòu)和重構(gòu)對象的類成員。閱讀源代碼或文檔時應該可以清楚地看出它是為序列化而設計的。 開發(fā)者應該控制他們的類的序列化形式。序列形式應該在代碼中體現(xiàn)出來,以便讀者可以閱讀、推理。 反序列化的對象應該通過普通的構(gòu)造方法或工廠創(chuàng)建,以獲得有效性檢查和對可變數(shù)據(jù)進行防御性拷貝的全部好處。用于反序列化的構(gòu)造函數(shù)可以但不必與“Front Door Api”共享。(筆者注:支持反序列化的類,針對創(chuàng)造對象、和反序列化還原出新對象的兩種情景,最好能夠分離Api)
后續(xù)
雖然筆者不想承認但是不得不誠實的說:這是目前耗費我最多心血的一篇博客,可惜的是還并非原創(chuàng),筆者只是在翻譯(或者說自以為已經(jīng)理解了部分真意的轉(zhuǎn)述)他人的智慧結(jié)晶。
在行文過程中加入了一些自己的思考與理解,原作的內(nèi)容要更加豐富多彩,論述更加睿智精確,可惜筆者能力有限,在窮盡心力之后也沒有交出滿意答卷。完成一半內(nèi)容以后,我決定暫時擱淺繼續(xù)下去的想法。待有了更深入的理解,方敢續(xù)接上文。
本文翻譯自:《Towards Better Serialization》https://openjdk.java.net/projects/amber/design-notes/towards-better-serialization
