JavaScriptでの複雑なオブジェクトのシリアル化
公開: 2022-03-11ウェブサイトのパフォーマンスとデータキャッシュ
最近のWebサイトは通常、データベースやサードパーティのAPIなど、さまざまな場所からデータを取得します。 たとえば、ユーザーを認証する場合、Webサイトはデータベースからユーザーレコードを検索し、API呼び出しを介して一部の外部サービスからのデータでそれを装飾する場合があります。 データベースクエリのディスクアクセスやAPI呼び出しのインターネットラウンドトリップなど、これらのデータソースへの高価な呼び出しを最小限に抑えることは、高速で応答性の高いサイトを維持するために不可欠です。 データキャッシングは、これを実現するために使用される一般的な最適化手法です。
プロセスは、作業データをメモリに保存します。 Webサーバーが単一のプロセス(Node.js / Expressなど)で実行されている場合、このデータは、同じプロセスで実行されているメモリキャッシュを使用して簡単にキャッシュできます。 ただし、負荷分散されたWebサーバーは複数のプロセスにまたがっており、単一のプロセスで作業している場合でも、サーバーの再起動時にキャッシュを保持したい場合があります。 これには、Redisなどのアウトプロセスキャッシュソリューションが必要です。つまり、データを何らかの方法でシリアル化し、キャッシュから読み取るときに逆シリアル化する必要があります。
シリアル化と逆シリアル化は、C#などの静的に型指定された言語で実現するのは比較的簡単です。 ただし、JavaScriptの動的な性質により、問題は少し複雑になります。 ECMAScript 6(ES6)はクラスを導入しましたが、これらのクラス(およびそれらのタイプ)のフィールドは、初期化されるまで定義されません(クラスがインスタンス化されるときではない場合があります)。また、フィールドと関数の戻りタイプは定義されません。スキーマ内にまったくありません。 さらに、クラスの構造は実行時に簡単に変更できます。フィールドの追加や削除、タイプの変更などが可能です。これはC#のリフレクションを使用して可能ですが、リフレクションはその言語の「ダークアート」を表します。開発者はそれが機能を壊すことを期待しています。
数年前、Toptalコアチームで働いていたときに、この問題が発生しました。 私たちはチームのためにアジャイルダッシュボードを構築していましたが、それは高速である必要がありました。 そうでなければ、開発者や製品所有者はそれを使用しません。 作業追跡システム、プロジェクト管理ツール、データベースなど、さまざまなソースからデータを取得しました。 このサイトはNode.js/Expressで構築されており、これらのデータソースへの呼び出しを最小限に抑えるためのメモリキャッシュがありました。 ただし、迅速で反復的な開発プロセスでは、1日に数回デプロイ(したがって再起動)し、キャッシュを無効にして、その利点の多くを失いました。
明らかな解決策は、Redisなどのアウトプロセスキャッシュでした。 しかし、いくつかの調査の結果、JavaScript用の優れたシリアル化ライブラリが存在しないことがわかりました。 組み込みのJSON.stringify/JSON.parseメソッドは、オブジェクトタイプのデータを返し、元のクラスのプロトタイプの関数をすべて失います。 これは、逆シリアル化されたオブジェクトをアプリケーション内で単に「インプレース」で使用できないことを意味します。したがって、代替設計で機能するにはかなりのリファクタリングが必要になります。
ライブラリの要件
JavaScriptで任意のデータのシリアル化と逆シリアル化をサポートし、逆シリアル化された表現と元のデータを交換可能に使用できるようにするには、次のプロパティを持つシリアル化ライブラリが必要でした。
- デシリアライズされた表現は、元のオブジェクトと同じプロトタイプ(関数、ゲッター、セッター)を持っている必要があります。
- ライブラリは、ネストされたオブジェクトのプロトタイプが正しく設定された、ネストされた複雑さのタイプ(配列とマップを含む)をサポートする必要があります。
- 同じオブジェクトを複数回シリアル化および逆シリアル化できる必要があります。プロセスはべき等である必要があります。
- シリアル化形式は、TCPを介して簡単に送信でき、Redisまたは同様のサービスを使用して保存できる必要があります。
- クラスをシリアライズ可能としてマークするには、最小限のコード変更が必要です。
- ライブラリルーチンは高速である必要があります。
- 理想的には、ある種のマッピング/バージョン管理を通じて、クラスの古いバージョンの逆シリアル化をサポートする何らかの方法があるはずです。
実装
このギャップを埋めるために、JavaScript用の汎用シリアル化ライブラリであるTanagra.jsを作成することにしました。 ライブラリの名前は、スタートレックの私のお気に入りのエピソードの1つである次世代を表しています。エンタープライズの乗組員は、言語が理解できない謎のエイリアンとのコミュニケーションを学ぶ必要があります。 このシリアル化ライブラリは、このような問題を回避するために一般的なデータ形式をサポートしています。
Tanagra.jsはシンプルで軽量になるように設計されており、現在Node.js(ブラウザーでテストされていませんが、理論的には機能するはずです)とES6クラス(マップを含む)をサポートしています。 メインの実装はJSONをサポートし、実験的なバージョンはGoogleProtocolBuffersをサポートします。 ライブラリには標準の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-データをGoogleprotobuffers形式にシリアル化します(実験的)
- tanagra-protobuf-redis-cache-シリアル化されたprotobufをRedisに保存するためのヘルパーライブラリ
- 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)
パフォーマンス
2つのシリアライザー( JSONシリアライザーと実験的なprotobufsシリアライザー)のパフォーマンスをコントロール(ネイティブJSON.parseとJSON.stringify)と比較しました。 それぞれで合計10回の試行を行いました。

Ubuntu17.10を実行している32Gbメモリを搭載した2017DellXPS15ラップトップでこれをテストしました。
次のネストされたオブジェクトをシリアル化しました。
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 } } }, }
書き込みパフォーマンス
シリアル化方式 | アベニュー株式会社最初の試行(ms) | StDev。 株式会社最初の試行(ms) | アベニュー例最初の試行(ms) | StDev。 元。 最初の試行(ms) |
JSON | 0.115 | 0.0903 | 0.0879 | 0.0256 |
Google Protobufs | 2.00 | 2.748 | 1.13 | 0.278 |
対照群 | 0.0155 | 0.00726 | 0.0139 | 0.00570 |
読んだ
シリアル化方式 | アベニュー株式会社最初の試行(ms) | StDev。 株式会社最初の試行(ms) | アベニュー例最初の試行(ms) | StDev。 元。 最初の試行(ms) |
JSON | 0.133 | 0.102 | 0.104 | 0.0429 |
Google 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シリアライザーの場合、最初の書き込みは平均の約4倍遅くなります。 protobufシリアライザーの場合、9倍遅くなります。 したがって、メタデータがすでにキャッシュされているオブジェクトの書き込みは、どちらのライブラリでもはるかに高速です。
読み取りでも同じ効果が観察されました。 JSONライブラリの場合、最初の読み取りは平均の約4倍遅く、protobufライブラリの場合は約2.5倍遅くなります。
protobufシリアライザーのパフォーマンスの問題は、まだ実験段階にあることを意味します。何らかの理由でフォーマットが必要な場合にのみお勧めします。 ただし、形式はJSONよりもはるかに簡潔であり、したがって、ネットワークを介して送信するのに適しているため、時間をかけて投資する価値があります。 Stack Exchangeは、内部キャッシュにこの形式を使用します。
JSONシリアライザーは明らかにパフォーマンスがはるかに優れていますが、ネイティブ実装よりも大幅に低速です。 小さなオブジェクトツリーの場合、この違いは重要ではありません(50ミリ秒のリクエストに加えて数ミリ秒はサイトのパフォーマンスを損なうことはありません)が、これは非常に大きなオブジェクトツリーの問題になる可能性があり、私の開発の優先事項の1つです。
ロードマップ
ライブラリはまだベータ段階です。 JSONシリアライザーは、十分にテストされており、安定しています。 今後数か月のロードマップは次のとおりです。
- 両方のシリアライザーのパフォーマンスが向上
- ES6より前のJavaScriptのサポートの改善
- ES-Nextデコレータのサポート
複雑なネストされたオブジェクトデータのシリアル化と、元の型への逆シリアル化をサポートするJavaScriptライブラリは他にありません。 ライブラリの恩恵を受ける機能を実装している場合は、それを試してみて、フィードバックに連絡し、貢献することを検討してください。
プロジェクトホームページ
GitHubリポジトリ