
昨天結尾說換一個層面。從這裡開始,題目不再是定義裡宣告了什麼,而是這些定義與資料離開記憶體之後怎麼走。
序列化是兩件事。一件是持久化:物件要存下來,下次還得原樣讀得回來,定義是這樣存的,SessionUser 這類系統資料也是。另一件是傳輸:物件要送到另一端,而那一端不只一種:桌面、瀏覽器、iOS、Android,執行環境各不相同。
本篇說明:
PayloadFormat 怎麼決定一次呼叫的 value 送出去長什麼樣持久化要的是人看得懂、能進版控、能手動改,這一件從頭到尾走 XML。
傳輸分兩條,分界在呼叫端是不是 .NET:
value 走 MessagePackvalue 走 JSON外面那一層兩條路都一樣:一次呼叫送出去的是一份 JSON-RPC payload,協定規定它是 JSON,換不掉。有選擇的是裡面那一格 value。不另外編碼的時候,它就是那份 JSON 的一部分;編碼過才輪到上面那兩條路。
持久化一條加傳輸兩條,三個格式各自用擅長的那一個:
| 格式 | 在框架裡負責什麼 | 需要外部套件 | 誰在用 |
|---|---|---|---|
| XML | 定義與系統資料的持久化,以及定義類 API 的回傳內容 | 否,BCL 內建 | 定義層 |
| JSON | 外面那份 JSON-RPC payload,以及編碼過的 value 的其中一種寫法 |
否,BCL 內建 | API 層 |
| MessagePack | 編碼過的 value 的另一種寫法,也是沒有另外宣告時的那一個 |
是 | API 層 |
第三欄不是附註,它是第四欄的理由:一個格式需要外部套件,它就不能放在定義層。定義層在相依圖的底部,資料庫、Repository、業務邏輯、每一個前端與定義檔工具都在它下游,加在這裡的任何套件會沿著相依鏈傳染給所有消費者,而其中沒有一個用得到它。
換到的東西是:日後要改傳輸格式,定義層零改動。代價是傳輸綁定必須在 API 層另外寫一份,而那一份跟定義型別之間沒有編譯器綁著。
PayloadFormat 決定 value 送出去長什麼樣Day 16 談 params 那一格時提過 format 與 type,當時只寫了它們在 API payload 上而不在 value 裡面。這兩格的內容就是這一節。
format 有三個值,決定的是同一件事:這次呼叫的 value 送出去要以什麼形式出現。
| 值 | value 送出去長什麼樣 |
經過哪一個 serializer | 誰在用 |
|---|---|---|---|
Plain |
一棵 JSON 物件樹,人看得懂 | 就是 JSON-RPC payload 那一份 System.Text.Json | 登入之前的幾支呼叫 |
Encoded |
一段 Base64 | 呼叫端宣告的那一個,序列化後壓縮 | 登入本身,以及降級的落點 |
Encrypted |
一段 Base64 | 呼叫端宣告的那一個,壓縮後再加密 | 其餘全部,呼叫端的預設值 |
壓縮與加密那一段管線本身是明天的題目。
同一支方法在三個值底下都走得通,這正是把格式放在 API payload 上、而不是放在方法名或路徑上的用意。對案例的登入送一個 Plain 的 API payload,value 就是一份可讀的 JSON:
{"jsonrpc":"2.0","method":"System.Login",
"params":{"format":0,"value":{"userId":"demo","password":"demo"},"type":""},"id":"1"}
回來的也是可讀的:
{"jsonrpc":"2.0","method":"System.Login",
"result":{"format":0,"value":{"accessToken":"033bedcf-...","userName":"Demo User",
"timeZone":"Asia/Taipei"},"type":""},"id":"1"}
同一支方法把 format 改成 1,其餘一字不動,換來的是一則錯誤:
{"jsonrpc":"2.0","method":"System.Login",
"error":{"code":-32099,"message":"TypeName is missing for deserialization."},"id":"1"}
type 這一格只有後兩個值用得到,而原因正好說明三個值的差別在哪裡。Plain 的 value 從頭到尾沒有離開 JSON,伺服端拿到的是一段還沒有型別的 JsonElement,等到反射查出那支方法宣告的參數型別之後,才把它反序列化成那個型別。型別是從方法簽章來的,不是從 API payload 來的。後兩個值不一樣:它們在呼叫端就把物件打成一串 bytes,而 bytes 自己不帶型別資訊,於是型別名得寫在 API payload 上跟著送。這一格因此是呼叫端說了算的欄位,伺服端在 Type.GetType 之前必須先拿它比對一份型別白名單。
Plain 不是偵錯用的旁路,它是啟動路徑。連通性探測、取得共用設定、建立連線這幾支,都發生在傳輸金鑰存在之前,那時候沒有東西可以拿來加密。
value 上那三個組件各自是一個介面加一個以名字取實作的工廠,差別在名字從哪裡來:
| 組件 | 名字寫在哪裡 | 目前有的實作 |
|---|---|---|
| serializer | 每一次呼叫的 API payload 上 | MessagePack、JSON |
| compressor | 部署的那份 SystemSettings |
Gzip、不壓縮 |
| encryptor | 部署的那份 SystemSettings |
AES-CBC-HMAC、不加密 |
取不到對應的實作就擲例外,不會安靜退回預設。
分成兩種,是因為它們回答的不是同一種問題。壓縮與加密回答的是這個部署要不要保護 value、用什麼保護,那是部署方的決定;serializer 回答的是這個呼叫端寫得出哪一種 value,那是呼叫端的能力。把後者也收進部署設定,等於要求同一台伺服器上的每一個呼叫端都說同一種話,而第一節那兩條路本來就不說同一種。
沒有宣告的呼叫一律當成 MessagePack,因為還沒認識這一格的呼叫端送的就是它。
這三個組件整個關在兩個地方:呼叫端在 Connector 裡,伺服端在 API 層裡。取一頁清單的方法,簽章上只有查詢欄位、條件、排序與分頁,沒有一個參數跟格式有關;BO 那一端拿到的也是還原好的物件。所以日後換一個更適合的傳輸格式,動的是 serializer 那一格,前後端的應用程式碼都不必改。
前一節說 value 可以是 JSON 樹。那麼取一份 FormSchema 回來,收到的應該也是一棵。實際上不是。同一個 Plain 的 API payload,向案例要一份訂單的 FormSchema,value 裡只有一個欄位:
{"result":{"format":0,"value":{"xml":"<?xml version=\"1.0\" encoding=\"utf-8\"?>
<FormSchema ProgId=\"Order\" CategoryId=\"company\" ...>
<Tables><FormTable TableName=\"Order\" DbTableName=\"ft_order\" ..."},"type":""}}
一段 XML 字串,裝在 API payload 那棵 JSON 樹裡。定義類的東西全部走這條路。API 層只負責把字串送到,還原成物件是呼叫端的 Connector 在做,所以 XmlSerializer 在每一個前端上也要跑一次。
理由是持久化那一份現成可用:定義本來就以 XML 落檔,傳出去沿用同一套序列化,零額外成本。
要讓定義型別也走 JSON 或 MessagePack,代價是持續的。每多一種序列化就多一組要顧的事:來回一趟資料會不會丟、反序列化時擋不擋得住不該被建出來的型別。而定義是巢狀結構、又會隨版本演進,每加一個屬性都要在每一種格式上再確認一次;日後換一個更適合的傳輸格式,整組定義型別還要再驗一輪。
而它們現在的形狀也確實不適合:集合屬性是唯讀的,各自握著自己的擁有者參考。三個 serializer 對這種屬性的處理方式剛好分成兩邊:
| 讀回來的時候怎麼做 | 遇到唯讀的集合屬性 | |
|---|---|---|
| XmlSerializer | 取出既有的實例,把值一項一項填進去 | 正常還原 |
| System.Text.Json | 建一個新集合,指派回去 | 指派不了,整段跳過 |
| MessagePack | 建一個新集合,指派回去 | 指派不了,整段跳過 |
兩邊的差別不在能力,在方向。而在建新集合那一邊,「能不能指派」就是「這個成員存不存在」。
MessagePack 那一側的規則是另一套。既然它連唯讀屬性都不看,最省事的作法就是讓它自己用反射把成員找出來,什麼都不必宣告。桌面上這樣確實跑得動。
問題是這條路靠的是執行期現場產生程式碼,而這個能力不是每一種執行環境都給得起。給不起的地方,一個沒有註冊的型別不是變慢,是直接擲例外。所以每一個傳輸型別都得顯式註冊一份 formatter,把成員逐一列出來,下面這段出自表單那一族的 WireContracts.Form.cs:
WireContract.For<GetListRequest>()
.Member(nameof(GetListRequest.SelectFields), static x => x.SelectFields, static (x, v) => x.SelectFields = v)
.Member(nameof(GetListRequest.Filter), static x => x.Filter, static (x, v) => x.Filter = v)
// 其餘成員逐一列出
.Build();
nameof 那一格不只是可讀性。formatter 寫出去的是以屬性名為鍵的 map,讀回來不認得的鍵就跳過,沒收到的鍵讓屬性留在宣告上的預設值。Day 16 那條「一個方法一個參數物件」因此多換到一件事:替 Args 加一個屬性,不會讓還沒升級的呼叫端打不通。這是現在這個形狀給得出的性質,不是一條被守著的承諾,沒有任何一個測試在驗它。
冗長是必然的:改用一支通用的 formatter 去讀屬性型別,就又回到現場產生程式碼那條路。所以這份清單只能手寫,也沒有產生器可以重跑。
集合在這條路上還有幾個各自不同的雷,其中有些在開發機上完全正常,所以「桌面測起來沒事」不能當成證據。
這份清單跟型別之間沒有編譯器綁著,不漂掉靠的是一組測試。它從同一組訊息型別出發,把所有到得了的走一遍,兩頭都比對:走得到的都要有註冊,註冊了卻沒有人到得了的也要被指出來。而且這組測試可以在開發機上模擬那種環境跑,不必等到部署出去才發現。
這一整包收在同一處:套件的參考、所有 formatter、這份清單,全都在 Bee.Api.Core 底下的一個資料夾裡,其餘組件都不必認識 MessagePack。第一節那張表的第三欄問的就是這件事。
反序列化是把外面送來的位元組變成行程裡的物件。而「變成哪一個型別」握在誰手上,決定了這件事有多危險,這裡有三個不同的答案。
XML 那一條最封閉。伺服端要把一份定義存回去時,型別不是從 XML 內容讀出來的,是拿請求上那個 DefineType 去查一張固定的對照表;表上沒有的值直接擲例外。
Plain 那一條由方法簽章決定,也就是第二節那段 JsonElement:呼叫端連一個可以填型別的欄位都沒有。
編碼過的那一條是唯一由呼叫端說了算的,第二節那個 type 欄位就是它,走哪一個 serializer 都一樣。bytes 不帶型別,型別名只能寫在 API payload 上跟著送,所以 Type.GetType 之前一定要有一道白名單。它篩的是整串名字,泛型引數與陣列元素一個都不放過;剖不動就拒絕,而且整道檢查發生在物件被建出來之前,不是建好之後才看。
不擋的話,呼叫端就等於指定了伺服端要建出哪一個型別。危險不在送來的資料,在於有些型別一被建出來就會做事,而反序列化做的正是建物件。
白名單本身分兩層:一組固定的基本型別,加上應用啟動時宣告的命名空間,框架自己那幾個是預設值。第二層放得越寬,這道檢查擋得越少,那是應用自己的選擇。
型別對了,不代表內容不能作怪。XML 這一側另外禁掉了 DTD:XmlSerializer 直接吃一段字串時已經擋掉「去外面載一份資源進來」那一種,但文件自己宣告的縮寫還是會被展開,而那足以讓一份幾 KB 的檔案在展開之後吃光記憶體。框架讓所有 XML 都走同一個收緊過的 reader 進來,於是每個呼叫端不必各自記得這件事。
案例的伺服端,跟序列化有關的程式碼是零行。唯一一處 JsonSerializer 在建表種子那支程式裡,讀的是自己的種子資料,跟傳輸無關。
前端也一樣。四個前端共用同一個 UI 專案,那個專案裡沒有一行在處理格式,連定義那條 XML 的還原都是 Connector 做掉的。三種序列化全部關在那一層裡,走哪一個、怎麼壓、怎麼加密,上面的程式碼都不必知道。
零程式碼不等於零成本。這四個裡,除了桌面那一個,其餘都在建置檔上留了一筆放行:瀏覽器那一個跑在 wasm 上,要明確打開反射序列化,否則第一支呼叫就送不出去,因為框架的訊息型別沒有走原始碼產生,而每一次呼叫都要經過那層 JSON-RPC payload。掛的不是 value,是外面那一層。應用要付的那一份落在建置設定上,不在原始碼裡。
持久化那一件從頭到尾走 XML:定義本來就要落檔,傳出去沿用同一份,零額外成本。
傳輸那一件分兩層。外面那份 JSON-RPC payload 是協定的固定格式,有選擇的是裡面那一格 value:前後端都是 .NET 走 MessagePack,沒有宣告時也是它,純 JavaScript 前端與第三方整合走 JSON。
而 value 上那三個組件全關在兩個地方:呼叫端的 Connector 與伺服端的 API 層。日後要換掉其中任何一個,應用程式碼一行都不必改。
明天接著看 value 上的另一半:序列化之後的壓縮與加密,以及那個順序為什麼不能調換。
本系列同步發表於 HackMD,完整目錄