在 JavaScript 中序列化複雜對象
已發表: 2022-03-11網站性能和數據緩存
現代網站通常從多個不同位置檢索數據,包括數據庫和第三方 API。 例如,在對用戶進行身份驗證時,網站可能會從數據庫中查找用戶記錄,然後通過 API 調用使用來自某些外部服務的數據對其進行修飾。 最大限度地減少對這些數據源的昂貴調用,例如用於數據庫查詢的磁盤訪問和用於 API 調用的 Internet 往返,對於維護一個快速響應的站點至關重要。 數據緩存是用於實現此目的的常用優化技術。
進程將其工作數據存儲在內存中。 如果 Web 服務器在單個進程中運行(例如 Node.js/Express),則可以使用在同一進程中運行的內存緩存輕鬆緩存這些數據。 但是,負載平衡的 Web 服務器跨越多個進程,即使使用單個進程,您可能希望緩存在服務器重新啟動時保持不變。 這需要進程外緩存解決方案,例如 Redis,這意味著數據需要以某種方式進行序列化,並在從緩存中讀取時進行反序列化。
序列化和反序列化在 C# 等靜態類型語言中相對容易實現。 然而,JavaScript 的動態特性使問題變得有點棘手。 雖然 ECMAScript 6 (ES6) 引入了類,但這些類(及其類型)上的字段直到它們被初始化時才被定義——這可能不是在類被實例化時——並且沒有定義字段和函數的返回類型完全在架構中。 更重要的是,類的結構可以在運行時輕鬆更改——可以添加或刪除字段,可以更改類型等。雖然這可以在 C# 中使用反射,但反射代表了該語言的“黑魔法”,並且開發人員希望它會破壞功能。
幾年前,當我在 Toptal 核心團隊工作時,我在工作中遇到了這個問題。 我們正在為我們的團隊構建一個敏捷的儀表板,它需要快速; 否則,開發人員和產品所有者不會使用它。 我們從多個來源提取數據:我們的工作跟踪系統、我們的項目管理工具和一個數據庫。 該站點是在 Node.js/Express 中構建的,我們有一個內存緩存來最大限度地減少對這些數據源的調用。 然而,我們快速、迭代的開發過程意味著我們每天部署(並因此重新啟動)多次,使緩存失效,從而失去了它的許多好處。
一個明顯的解決方案是進程外緩存,例如 Redis。 但是,經過一番研究,我發現 JavaScript 沒有好的序列化庫。 內置 JSON.stringify/JSON.parse 方法返回對像類型的數據,丟失原始類原型上的任何函數。 這意味著反序列化的對像不能簡單地在我們的應用程序中“就地”使用,因此需要進行大量重構才能使用替代設計。
對圖書館的要求
為了支持 JavaScript 中任意數據的序列化和反序列化,使反序列化的表示和原始數據可以互換使用,我們需要一個具有以下屬性的序列化庫:
- 反序列化的表示必須與原始對象具有相同的原型(函數、getter、setter)。
- 該庫應支持嵌套複雜類型(包括數組和映射),並正確設置嵌套對象的原型。
- 應該可以多次序列化和反序列化相同的對象——這個過程應該是冪等的。
- 序列化格式應該可以通過 TCP 輕鬆傳輸,並且可以使用 Redis 或類似服務進行存儲。
- 將類標記為可序列化應該需要最少的代碼更改。
- 庫例程應該很快。
- 理想情況下,應該有某種方法通過某種映射/版本控制來支持類的舊版本的反序列化。
執行
為了填補這個空白,我決定編寫Tanagra.js ,一個用於 JavaScript 的通用序列化庫。 圖書館的名稱參考了我最喜歡的《星際迷航:下一代》劇集之一,其中企業的船員必須學會與一個語言難以理解的神秘外星種族交流。 此序列化庫支持常見的數據格式,以避免此類問題。
Tanagra.js被設計為簡單和輕量級,它目前支持 Node.js(尚未在瀏覽器中測試,但理論上應該可以工作)和 ES6 類(包括 Maps)。 主要實現支持 JSON,實驗版本支持 Google Protocol Buffers。 該庫只需要標準 JavaScript(目前使用 ES6 和 Node.js 進行測試),不依賴於實驗性功能、 Babel轉譯或TypeScript 。
可序列化的類在導出類時使用方法調用進行標記:
module.exports = serializable(Foo, myUniqueSerialisationKey)
該方法向該類返回一個代理,該代理攔截構造函數並註入唯一標識符。 (如果未指定,則默認為類名。)該鍵與其餘數據一起序列化,並且該類還將其公開為靜態字段。 如果該類包含任何嵌套類型(即,具有需要序列化類型的成員),它們也在方法調用中指定:
module.exports = serializable(Foo, [Bar, Baz], myUniqueSerialisationKey)
(該類的先前版本的嵌套類型也可以以類似的方式指定,例如,如果您序列化 Foo1,則可以將其反序列化為 Foo2。)
在序列化過程中,該庫遞歸地構建一個類鍵的全局映射,並在反序列化過程中使用它。 (請記住,密鑰與其餘數據一起序列化。)為了知道“頂級”類的類型,庫要求在反序列化調用中指定它:
const foo = decodeEntity(serializedFoo, Foo)
一個實驗性的自動映射庫遍歷模塊樹並從類名生成映射,但這僅適用於唯一命名的類。
項目佈局
該項目分為多個模塊:
- tanagra-core - 不同序列化格式所需的通用功能,包括將類標記為可序列化的功能
- tanagra-json - 將數據序列化為 JSON 格式
- tanagra-protobuf - 將數據序列化為 Google protobuffers 格式(實驗性)
- tanagra-protobuf-redis-cache - 一個幫助庫,用於在Redis中存儲序列化的 protobuf
- tanagra-auto-mapper - 遍歷Node.js中的模塊樹以構建類映射,這意味著用戶不必指定要反序列化到的類型(實驗性)。
請注意,該庫使用美國拼寫。
示例用法
以下示例聲明了一個可序列化的類並使用 tanagra-json 模塊對其進行序列化/反序列化:
const serializable = require('tanagra-core').serializable class Foo { constructor(bar, baz1, baz2, fooBar1, fooBar2) { this.someNumber = 123 this.someString = 'hello, world!' this.bar = bar // a complex object with a prototype this.bazArray = [baz1, baz2] this.fooBarMap = new Map([ ['a', fooBar1], ['b', fooBar2] ]) } } // Mark class `Foo` as serializable and containing sub-types `Bar`, `Baz` and `FooBar` module.exports = serializable(Foo, [Bar, Baz, FooBar]) ... const json = require('tanagra-json') json.init() // or: // require('tanagra-protobuf') // await json.init() const foo = new Foo(bar, baz) const encoded = json.encodeEntity(foo) ... const decoded = json.decodeEntity(encoded, Foo)
表現
我將兩個序列化程序( JSON序列化程序和實驗性protobufs序列化程序)的性能與一個控制(本機 JSON.parse 和 JSON.stringify)進行了比較。 我一共進行了 10 次試驗。

我在運行 Ubuntu 17.10 的 2017 年戴爾 XPS15筆記本電腦上進行了測試,該筆記本電腦具有 32Gb 內存。
我序列化了以下嵌套對象:
foo: { "string": "Hello foo", "number": 123123, "bars": [ { "string": "Complex Bar 1", "date": "2019-01-09T18:22:25.663Z", "baz": { "string": "Simple Baz", "number": 456456, "map": Map { 'a' => 1, 'b' => 2, 'c' => 2 } } }, { "string": "Complex Bar 2", "date": "2019-01-09T18:22:25.663Z", "baz": { "string": "Simple Baz", "number": 456456, "map": Map { 'a' => 1, 'b' => 2, 'c' => 2 } } } ], "bazs": Map { 'baz1' => Baz { string: 'baz1', number: 111, map: Map { 'a' => 1, 'b' => 2, 'c' => 2 } }, 'baz2' => Baz { string: 'baz2', number: 222, map: Map { 'a' => 1, 'b' => 2, 'c' => 2 } }, 'baz3' => Baz { string: 'baz3', number: 333, map: Map { 'a' => 1, 'b' => 2, 'c' => 2 } } }, }
寫入性能
序列化方法 | 大道公司一審(毫秒) | 標準差。 公司一審(毫秒) | 大道前。 一審(毫秒) | 標準差。 前任。 一審(毫秒) |
JSON | 0.115 | 0.0903 | 0.0879 | 0.0256 |
谷歌 Protobufs | 2.00 | 2.748 | 1.13 | 0.278 |
控制組 | 0.0155 | 0.00726 | 0.0139 | 0.00570 |
讀
序列化方法 | 大道公司一審(毫秒) | 標準差。 公司一審(毫秒) | 大道前。 一審(毫秒) | 標準差。 前任。 一審(毫秒) |
JSON | 0.133 | 0.102 | 0.104 | 0.0429 |
谷歌 Protobufs | 2.62 | 1.12 | 2.28 | 0.364 |
控制組 | 0.0135 | 0.00729 | 0.0115 | 0.00390 |
概括
JSON序列化器比本機序列化慢大約 6-7 倍。 實驗性的protobufs序列化器比JSON序列化器慢大約 13 倍,或者比原生序列化慢 100 倍。
此外,每個序列化程序中模式/結構信息的內部緩存顯然會對性能產生影響。 對於 JSON 序列化程序,第一次寫入比平均速度慢四倍左右。 對於 protobuf 序列化程序,它慢了九倍。 因此,在任一庫中寫入元數據已被緩存的對像要快得多。
對於讀取觀察到相同的效果。 對於 JSON 庫,第一次讀取大約比平均速度慢四倍,而對於 protobuf 庫,它大約慢兩倍半。
protobuf 序列化程序的性能問題意味著它仍處於實驗階段,只有當您出於某種原因需要格式時,我才會推薦它。 但是,值得投入一些時間,因為格式比 JSON 更簡潔,因此更適合通過網絡發送。 Stack Exchange 使用該格式進行內部緩存。
JSON 序列化器的性能顯然要好得多,但仍然比本機實現慢得多。 對於小型對象樹,這種差異並不顯著(在 50 毫秒請求之上的幾毫秒不會破壞您網站的性能),但這可能會成為極大對象樹的問題,並且是我的開發重點之一。
路線圖
該庫仍處於測試階段。 JSON 序列化程序經過了合理的良好測試和穩定。 以下是未來幾個月的路線圖:
- 兩個序列化程序的性能改進
- 更好地支持 pre-ES6 JavaScript
- 支持 ES-Next 裝飾器
我知道沒有其他 JavaScript 庫支持序列化複雜的嵌套對像數據並反序列化為其原始類型。 如果您正在實現將從庫中受益的功能,請嘗試一下,與您的反饋聯繫,並考慮做出貢獻。
項目主頁
GitHub存儲庫