JSONでPrototypeを蘇らせる
最近Cloudflare Durable Objectsを使って遊んでいる。Durable ObjectsはTransactional Storage APIという永続化機能が含まれているが、structuredCloneベースなのでプロトタイプ情報を失ってしまう。そこをなんとかしたい。
背景
Tau PrologとCloudflare WorkersでサーバーレスなPrologを作っている。Tauの内部状態を保存したい。その状態はPrototype付きのJSオブジェクトで管理されている。Storageにそれをそのままぶっ込むとPrototypeの情報を失ってしまう。
最初は頑張って手動で再構築していたが、だいぶ辛かったから「賢い」やり方を探すことにした。
やりたいこと
- prototype付きのオブジェクトを保存したい
- prototype付きのちゃんとしたオブジェクトを取得したい
⚠️注意
Ruby on Railsはこの手の機能でめっちゃやばい脆弱性があったのでおそらくセクシュアリティ面でアウトだ。ユーザーから受け取るJSONで使わない方がいいと思う。使う方は自己責任で。
聖ならぬ蘇らせる者: unholy reviverを作ってみた
reviverはカクテルのCorpse Reviver(=死者を蘇らせる者)でおなじみな言葉なので中二病をこじらせたネーミングにしてみた。
結論から言うと案外上手くいった!
手順
JSONのあまり知られていない(?)機能replacer
とreviver
を使ってみた。
-
JSON.stringify
のreplacer
でPrototype名を詰め込む -
JSON.parse
のreviver
でPrototype名を使って適切なオブジェクトに化ける
JSON.stringifyとreplacer
JSON.stringify
のreplacer
関数を使うことで、エンコード前のオブジェクトをいじることが出来る。
const PROTO_KEY = "$$proto";
function replacer(k, v) {
if (Array.isArray(v)) {
return v.map(function(x) { return replacer(null, x); });
}
if (typeof v == "object" && v != null) {
const proto = Object.getPrototypeOf(v);
const name = proto.constructor.name;
if (name === "Object") {
return v;
}
if (proto) {
return {...v, [PROTO_KEY]: name};
}
}
return v;
}
Prototypeの有無を確認して、Prototype付きのオブジェクトにPrototype名を入れて返す。
Arrayを特別扱いしないと{"0": "...", "$$proto": "Array"}
になってしまう。
テスト用のPrototypeを定義する。
function Foo(lang) {
this.lang = lang;
this.bar = new Bar(true);
}
Foo.prototype = {
constructor: Foo,
cry: function() { return `quack im a ${this.bar.lol()} ${this.lang} lol`; },
};
function Bar(baz) {
this.unholy = baz;
}
Bar.prototype = {
constructor: Bar,
lol: function() { return this.unholy; },
};
そしてJSON.stringify
を呼んでみる。
JSON.stringify(obj, replacer, " ");
{
"lang": "javascript",
"bar": {
"unholy": true,
"$$proto": "Bar"
},
"$$proto": "Foo"
}
JSON.parseとreviver
JSON.parse
のreviver
関数を使うことで、変換後のオブジェクトをいじることが出来る。
function reviver(k, v) {
if (typeof v !== "object") {
return v;
}
if (typeof v[PROTO_KEY] !== "string") {
return v;
}
const proto = globalThis[v[PROTO_KEY]];
if (!proto || !proto?.prototype) {
return v;
}
delete v[PROTO_KEY];
Object.setPrototypeOf(v, proto.prototype);
return v;
}
$$proto
属性のあるオブジェクトを探して、globalThis
(window
) から Prototypeを取得してObject.setPrototypeOf
でそのPrototypeを付ける。
一例としてglobalThis
を使ったが、本番では型のホワイトリストを渡している。
ちなみにObject.setPrototypeOf
はMDNによるとあまり使わない方がいいらしいけど、とりあえず使ってみた。多分Object.create
を使って新しいオブジェクトを返した方がいい?
JSON.parse
にreviver
を渡して蘇らせてみる。
const obj = JSON.parse("...", reviver);
// Prototypeのメソッドを呼んでみる
obj.cry(); // "quack im a true javascript lol"
成功したようだ。
最後に
上記の方法とDurable Objectsを組み合わせてみて使いやすいStoreを作ることが出来た。醜いコードを沢山排除出来て満足している。オープンソースなので https://github.com/guregu/worker-prolog へどうぞ!初TSでコードがあまり綺麗ではないですが…
JSの型システムは意外と柔軟で、この記事のようなぶっ飛んだ使い方も出来る。
また、Classが入ってから不人気になってしまったPrototypeも便利だと思う。
Discussion