在 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存儲庫