⚰️

JSONでPrototypeを蘇らせる

2022/06/04に公開

最近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のあまり知られていない(?)機能replacerreviverを使ってみた。

  • JSON.stringifyreplacerでPrototype名を詰め込む
  • JSON.parsereviverでPrototype名を使って適切なオブジェクトに化ける

JSON.stringifyとreplacer

JSON.stringifyreplacer関数を使うことで、エンコード前のオブジェクトをいじることが出来る。

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.parsereviver関数を使うことで、変換後のオブジェクトをいじることが出来る。

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.parsereviverを渡して蘇らせてみる。

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