深入認識二進位制序列化--記一次生產事故的思考

hkant發表於2019-07-01

一 概要

二進位制序列化是公司內部自研微服務框架的主要的資料傳輸處理方式,但是普通的開發人員對於二進位制的學習和了解並不深入,容易導致使用過程中出現了問題卻沒有分析解決的思路。本文從一次生產環境的事故引入這個話題,通過對於事故的分析過程,探討了平時沒有關注到的一些技術要點。二進位制序列化結果並不像Json序列化一樣具備良好的可讀性,對於序列化的結果大多數人並不瞭解,因此本文最後通過實際的例子,對照MSDN的文件對於序列化結果進行詳細解析,並意圖通過本次分析對於二進位制序列化的結果有直觀和深入的認識。

二 事故描述

某天晚上突發了一批預警,當時的場景:

A:B,幫忙看下你們的服務,我這裡預警了

B:我剛釋出了一個補丁,跟我有關?

A:我這裡沒有釋出,當然有關係了,趕緊回退!

B:我這裡又沒改你們用到的介面,為啥是我們回退?

A:那怪我嘍,我這裡又沒釋出過東西,趕緊回退!

B:這個介面很長時間沒有改過,肯定是你們自己的問題。

A:不管誰的問題,我們們先回退看看。

B:行吧,稍等下

釋出助手:回退中……(回退後預警消失)

A:……

B:……

三 事故問題分析

雖然事故發生後通過回退補丁解決了當時的問題,但是事後對於問題的分析一直進行到了深夜。

因為這次事故雖然解決起來簡單,但是直接挑戰了我們對於服務的認識,如果不查詢到根本原因,後續的工作難以放心的開展。

以前我們對於服務的認識簡單歸納為:

增加屬性不會導致客戶端反序列化的失敗。

但是,這個並非是官方的說法,只是開發人員在使用過程中通過實際使用總結出來的規律。經驗的總結往往缺乏理論的支援,在遇到問題的時候便一籌莫展。

發生問題時,客戶端捕獲到的異常堆疊是這樣的:

System.Runtime.Serialization.SerializationException
  HResult=0x8013150C
  Message=ObjectManager 發現連結地址資訊的數目無效。這通常表示格式化程式中有問題。
  Source=mscorlib
  StackTrace:
   在 System.Runtime.Serialization.ObjectManager.DoFixups()
   在 System.Runtime.Serialization.Formatters.Binary.ObjectReader.Deserialize(HeaderHandler handler, __BinaryParser serParser, Boolean fCheck, Boolean isCrossAppDomain, IMethodCallMessage methodCallMessage)
   在 System.Runtime.Serialization.Formatters.Binary.BinaryFormatter.Deserialize(Stream serializationStream, HeaderHandler handler, Boolean fCheck, Boolean isCrossAppDomain, IMethodCallMessage methodCallMessage)
   在 System.Runtime.Serialization.Formatters.Binary.BinaryFormatter.Deserialize(Stream serializationStream)

通過異常堆疊能夠看出是在進行二進位制反序列化時發生了異常。通過多方查閱資料,針對此問題的觀點基本可以總結為兩點:

  1. 反序列化使用的客戶端過舊,將反序列化使用的類替換為最新的類。
  2. 出現該問題跟泛型集合有關,如果新增了泛型集合容易出現此類問題。

觀點一對於解決當前問題毫無幫助,觀點二倒是有些用處,經過了解,當日釋出的補丁中涉及的微服務介面並未新增泛型集合屬性,而是對於以前增加而未使用的一個泛型集合增加了賦值的邏輯。後來經過測試,確實是由此處改動造成的問題。由此也可以看出,開發人員在日常開發過程中所總結出來的經驗有一些侷限性,有必要深入的分析下二進位制序列化在何種情況下會導致反序列化失敗。

四 二進位制序列化與反序列化測試

為了測試不同的資料型別對於反序列化的影響,針對常用資料型別編寫測試方案。本次測試涉及到兩個程式碼解決方案,序列化的程式(簡稱V1)和反序列化的程式(簡稱V2)。

測試步驟:

  1. V1中宣告類及屬性;
  2. V1中將類物件進行二進位制序列化並儲存到檔案中;
  3. 修改V1中類的屬性,去掉相關的屬性的宣告後重新編譯DLL;
  4. V2中引用步驟3中生成的DLL,並讀取步驟2中生成的資料進行反序列化;
/// <summary>
/// V1測試過程用到的類
/// </summary>
[Serializable]
public class ObjectItem
{
    public string TestStr { get; set; }
}
/// <summary>
/// V1測試過程用到的結構體
/// </summary>
[Serializable]
public struct StructItem
{
    public string TestStr;
}

測試常用資料型別的結果:

新增資料型別 測試用的數值 反序列化是否成功
int 100 成功
int[] {1,100} 成功
string "test" 成功
string[] {"a","1"} 成功
double 1d 成功
double[] {1d,2d} 成功
bool true 成功
bool[] {false,true} 成功
List<string> null 成功
List<string> {} 成功
List<string> {"1","a"} 成功
List<int> null 成功
List<int> {} 成功
List<int> {1,100} 成功
List<double> null 成功
List<double> {} 成功
List<double> {1d,100d} 成功
List<bool> null 成功
List<bool> {} 成功
List<bool> {true,false} 成功
ObjectItem null 成功
ObjectItem new ObjectItem() 成功
ObjectItem[] {} 成功
ObjectItem{} {new ObjectItem()} 失敗(當反序列化時客戶端沒有ObjectItem這個類)
ObjectItem{} {new ObjectItem()} 成功(當反序列化時客戶端有ObjectItem這個類)
List<ObjectItem> null 成功
List<ObjectItem> {} 成功
List<ObjectItem> {new ObjectItem()} 失敗(當反序列化時客戶端沒有ObjectItem這個類)
List<ObjectItem> {new ObjectItem()} 成功(當反序列化時客戶端有ObjectItem這個類)
StructItem null 成功
StructItem new StructItem() 成功
List<StructItem> null 成功
List<StructItem> {} 成功
List<StructItem> {new StructItem()} 成功(當反序列化時客戶端沒有ObjectItem這個類)
List<StructItem> {new StructItem()} 成功(當反序列化時客戶端有ObjectItem這個類)

測試結果總結:二進位制反序列化的時候會自動相容處理序列化一方新增的資料。但是在個別情況下會出現反序列化的過程中遇到異常的情況。
出現反序列化異常的資料型別:

  1. 泛型集合
  2. 陣列

這兩種資料結構並非是一定會導致二進位制反序列化報錯,而是有一定的條件。泛型集合出現反序列化異常的條件有三個:

  1. 序列化的物件新增了泛型集合;
  2. 泛型使用的是新增的類;
  3. 新增的類在反序列化的時候不存在;

陣列也是類似的,只有滿足上述三個條件的時候,才會導致二進位制反序列化失敗。這也是為什麼之前釋出後一直沒有問題而對於其中的泛型集合進行賦值後出現微服務客戶端報錯的原因。

既然通過測試瞭解到了二進位制反序列化確實會有自動的相容處理機制,那麼有必要深入瞭解下MSDN上對於二進位制反序列化的容錯機制的理論知識。

五 二進位制反序列化的容錯機制

二進位制反序列化過程中不可避免會遇到序列化與反序列化使用的程式集版本不同的情況,如果強行要求反序列化的一方(比如微服務的客戶端)一定要跟序列化的一方(比如微服務的服務端)時時刻刻保持一致在實際應用過程是不現實的。從.NET2.0版本開始,.NET中針對二進位制反序列化引入了版本容錯機制(Version Tolerant Serialization,簡稱VTS)。

當使用 BinaryFormatter 時,將啟用 VTS 功能。VTS 功能尤其是為應用了 SerializableAttribute 特性的類(包括泛型型別)而啟用的。 VTS 允許向這些類新增新欄位,而不破壞與該型別其他版本的相容性。

序列化與反序列化過程中如果遇到客戶端與服務端程式集不同的情況下,.NET會盡量的進行相容,所以平時使用過程中對此基本沒有太大的感觸,甚至有習以為常的感覺。

要確保版本管理行為正確,修改型別版本時請遵循以下規則:

  • 切勿移除已序列化的欄位。
  • 如果未在以前版本中將 NonSerializedAttribute 特性應用於某個欄位,則切勿將該特性應用於該欄位。
  • 切勿更改已序列化欄位的名稱或型別。
  • 新增新的已序列化欄位時,請應用 OptionalFieldAttribute 特性。
  • 從欄位(在以前版本中不可序列化)中移除 NonSerializedAttribute 特性時,請應用 OptionalFieldAttribute 特性。
  • 對於所有可選欄位,除非可接受 0 或 null 作為預設值,否則請使用序列化回撥設定有意義的預設值。

要確保型別與將來的序列化引擎相容,請遵循以下準則:

  • 始終正確設定 OptionalFieldAttribute 特性上的 VersionAdded 屬性。
  • 避免版本管理分支。

六 二進位制序列化資料的結構

通過前文已經瞭解了二進位制序列化以及版本相容性的理論知識。接下來有必要對於平時所用的二進位制序列化結果進行直觀的學習,消除對於二進位制序列化結果的陌生感。

6.1 遠端呼叫過程中傳送的資料

目前我們所使用的.NET微服務框架所使用的正是二進位制的資料序列化方式。當進行遠端呼叫的過程中,客戶端發給服務端的資料到底是什麼樣子的呢?

引用文件中一個現成的例子(參考資料4):

遠端呼叫的例子

上圖表示的是客戶端遠端呼叫服務端的SendAddress方法,並且傳送的是名為Address的類物件,該類有四個屬性:(Street = "One Microsoft Way", City = "Redmond", State = "WA" and Zip = "98054") 。服務端回覆的是一個字串“Address Received”。

客戶端實際傳送的資料如下:

0000  00 01 00 00 00 FF FF FF FF 01 00 00 00 00 00 00 .....ÿÿÿÿ.......
0010  00 15 14 00 00 00 12 0B 53 65 6E 64 41 64 64 72 ........SendAddr
0020  65 73 73 12 6F 44 4F 4A 52 65 6D 6F 74 69 6E 67 ess.oDOJRemoting
0030  4D 65 74 61 64 61 74 61 2E 4D 79 53 65 72 76 65 Metadata.MyServe
0040  72 2C 20 44 4F 4A 52 65 6D 6F 74 69 6E 67 4D 65 r, DOJRemotingMe
0050  74 61 64 61 74 61 2C 20 56 65 72 73 69 6F 6E 3D tadata, Version=
0060  31 2E 30 2E 32 36 32 32 2E 33 31 33 32 36 2C 20 1.0.2622.31326,
0070  43 75 6C 74 75 72 65 3D 6E 65 75 74 72 61 6C 2C Culture=neutral,
0080  20 50 75 62 6C 69 63 4B 65 79 54 6F 6B 65 6E 3D PublicKeyToken=
0090  6E 75 6C 6C 10 01 00 00 00 01 00 00 00 09 02 00 null............
00A0  00 00 0C 03 00 00 00 51 44 4F 4A 52 65 6D 6F 74 .......QDOJRemot
00B0  69 6E 67 4D 65 74 61 64 61 74 61 2C 20 56 65 72 ingMetadata, Ver
00C0  73 69 6F 6E 3D 31 2E 30 2E 32 36 32 32 2E 33 31 sion=1.0.2622.31
00D0  33 32 36 2C 20 43 75 6C 74 75 72 65 3D 6E 65 75 326, Culture=neu
00E0  74 72 61 6C 2C 20 50 75 62 6C 69 63 4B 65 79 54 tral, PublicKeyT
00F0  6F 6B 65 6E 3D 6E 75 6C 6C 05 02 00 00 00 1B 44 oken=null......D
0100  4F 4A 52 65 6D 6F 74 69 6E 67 4D 65 74 61 64 61 OJRemotingMetada
0110  74 61 2E 41 64 64 72 65 73 73 04 00 00 00 06 53 ta.Address.....S
0120  74 72 65 65 74 04 43 69 74 79 05 53 74 61 74 65 treet.City.State
0130  03 5A 69 70 01 01 01 01 03 00 00 00 06 04 00 00 .Zip............
0140  00 11 4F 6E 65 20 4D 69 63 72 6F 73 6F 66 74 20 ..One Microsoft 
0150  57 61 79 06 05 00 00 00 07 52 65 64 6D 6F 6E 64 Way......Redmond
0160  06 06 00 00 00 02 57 41 06 07 00 00 00 05 39 38 ......WA......98
0170  30 35 34 0B                                     054.  

上文的資料是二進位制的,能看出來序列化後的結果中包含程式集資訊,被呼叫的方法、使用的引數類、屬性及各個屬性的值等資訊。對於上述的序列化後資料進行詳細解讀的分析可以參考資料4。

6.2 類物件二進位制序列化結果

對於類物件進行序列化後的結果沒有現成的例子,針對此專門設計了一個簡單的場景,將序列化後的資料儲存到本地檔案中。

/// <summary>
/// 自定義序列化物件
/// </summary>
[Serializable]
public class MyObject
{
    public bool BoolMember { get; set; }
    public int IntMember { get; set; }
}
/// <summary>
/// 程式入口
/// </summary>
class Program
{
    static void Main(string[] args)
    {
        var obj = new MyObject();
        obj.BoolMember = true;
        obj.IntMember = 10000;

        IFormatter formatter = new BinaryFormatter();
        Stream stream = new FileStream("data.dat", FileMode.Create, FileAccess.Write, FileShare.None);

        formatter.Serialize(stream, obj);
        stream.Close();
    }
}

data.dat中的內容:

0000: 00 01 00 00 00 ff ff ff ff 01 00 00 00 00 00 00  ................
0010: 00 0c 02 00 00 00 4e 42 69 6e 61 72 79 53 65 72  ......NBinarySer
0020: 69 61 6c 69 7a 65 50 72 61 63 74 69 73 65 2c 20  ializePractise, 
0030: 56 65 72 73 69 6f 6e 3d 31 2e 30 2e 30 2e 30 2c  Version=1.0.0.0,
0040: 20 43 75 6c 74 75 72 65 3d 6e 65 75 74 72 61 6c   Culture=neutral
0050: 2c 20 50 75 62 6c 69 63 4b 65 79 54 6f 6b 65 6e  , PublicKeyToken
0060: 3d 6e 75 6c 6c 05 01 00 00 00 20 42 69 6e 61 72  =null..... Binar
0070: 79 53 65 72 69 61 6c 69 7a 65 50 72 61 63 74 69  ySerializePracti
0080: 73 65 2e 4d 79 4f 62 6a 65 63 74 02 00 00 00 1b  se.MyObject.....
0090: 3c 42 6f 6f 6c 4d 65 6d 62 65 72 3e 6b 5f 5f 42  <BoolMember>k__B
00a0: 61 63 6b 69 6e 67 46 69 65 6c 64 1a 3c 49 6e 74  ackingField.<Int
00b0: 4d 65 6d 62 65 72 3e 6b 5f 5f 42 61 63 6b 69 6e  Member>k__Backin
00c0: 67 46 69 65 6c 64 00 00 01 08 02 00 00 00 01 10  gField..........
00d0: 27 00 00 0b                                      '...

對於類物件直接進行二進位制序列化後的結果與遠端呼叫場景二進位制序列化的結構有所不同。

按照[MS-NRBF]所言,序列化後的結果首先是序列化資料頭,其中包含RecordTypeEnum、TopId、HeaderId、MajorVersion和MajorVersion。這之後就是被序列化的類的一些資訊,包括程式集、類名、屬性和屬性對應的值。

Binary Serialization Format
   SerializationHeaderRecord:
       RecordTypeEnum: SerializedStreamHeader (0x00)
       TopId: 1 (0x1)
       HeaderId: -1 (0xFFFFFFFF)
       MajorVersion: 1 (0x1)
       MinorVersion: 0 (0x0)
   Record Definition:
       RecordTypeEnum: SystemClassWithMembers (0x02)
       ClassInfo:
            ObjectId:  (0x4e000000)
            LengthPrefixedString:
                Length: 78 (0x4e)
                String: BinarySerializePractise, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
            ObjectId:  (0x00000001)
            LengthPrefixedString:
                Length: 32 (0x20)
                String: BinarySerializePractise.MyObject
            MemberCount: 2(0x00000002)
            LengthPrefixedString:
                Length: 27(0x1b)
                String: <BoolMember>k__BackingField
            LengthPrefixedString:
                Length: 26(0x1a)
                String: <IntMember>k__BackingField
            ObjectId:0x08010000
            Length:0x00000002
            Value:1(0x01)
            Value:10000(0x00002710)
    MessageEnd:
             RecordTypeEnum: MessageEnd (0x0b)

七 總結

二進位制序列化和反序列化雖然是目前使用的微服務的主要資料處理方式,但是對於開發人員來說這部分內容比較神祕,對於序列化資料和反序列化機制不甚瞭解。本文中通過一次事故的分析過程,梳理總結了反序列化機制,反序列化相容性,序列化資料結構等內容,希望通過本文的一些知識,能夠消除對於二進位制序列化的陌生感,增進對於二進位制序列化的深入認識。

八 參考資料

  1. Some gotchas in backward compatibility
  2. 版本容錯序列化
  3. [MS-NRBF]: .NET Remoting: Binary Format Data Structure
  4. [MS-NRBF]: 3 Structure Examples

相關文章