- 背景
- CidLink
- serde_json, serde_ipld_dagcbor
- 問題点: データフォーマットによって対象の型が異なる
- 最初の解決策: is_human_readable() による分岐
- 解決策(?): Ipld を経由しデータの構造によって分岐する
- 汎用的? な解決策: Untagged
- ベンチマーク
- 実装結果
背景
BlueskyのAT ProtocolのRust版ライブラリを作っている。
その中で最近実装した機能の話。
CidLink
AT Protocolで扱うデータモデルのSpecは、以下のページで書かれている。
この中に、Lexiconでcid-link
という名前で扱われる型がある。
https://atproto.com/specs/lexicon#cid-link
つまりIPLDのLink
をCID
で表現する型、ということのようだ。
で、これらのデータを扱うわけだが、そのデータフォーマットが2種類ある。
IPLDではデータ送信のためのCodecとして、binary formatのDAG-CBOR
と human-readable formatのDAG-JSON
を定めている。
AT Protocolでは、効率的にデータを扱いたい場合にはDAG-CBOR
を用い、XRPCのHTTP APIなどではDAG-JSON
とは異なる規約のJSONフォーマットを扱う、らしい。
で、cid-link
については以下のように書かれている。
link
field type (Lexicon typecid-link
). In DAG-CBOR encodes as a binary CID (multibase type 0x00) in a bytestring with CBOR tag 42. In JSON, encodes as$link
object
DAG-CBORでは、以下のようなバイナリ表現のCIDを含むバイト列、
0xd8, 0x2a, 0x58, 0x25, 0x00, 0x01, 0x71, 0x12, 0x20, 0x65, 0x06, 0x2a, 0x5a, 0x5a, 0x00, 0xfc, 0x16, 0xd7, 0x3c, 0x69, 0x44, 0x23, 0x7c, 0xcb, 0xc1, 0x5b, 0x1c, 0x4a, 0x72, 0x34, 0x48, 0x93, 0x36, 0x89, 0x1d, 0x09, 0x17, 0x41, 0xa2, 0x39, 0xd0,
JSONでは、以下のような $link
という単一のキーを含むオジェクト、
{ "$link": "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a" }
として表現されるらしい。
serde_json, serde_ipld_dagcbor
RustでJSONなどのデータフォーマットで Serialize/Deserialize する、となるとまず間違いなく serde を使うことになるだろう。
serde
自体は Serialize/Deserialize を行うわけではなく、あくまでRustのデータ構造に対して汎用的にそれらを可能にするためのフレームワーク、という感じ。
ATriumではLexiconから生成された各XRPCに関連するデータの型をライブラリとして提供するので、それらの型に対して基本的にはserde
のattributesを付与するだけで、実際に何らかのデータフォーマットに対応した Seriazlier/Deserializer を使って変換操作をするのはライブラリのユーザ、ということになる。
実際のところ、JSONデータを扱うなら serde_json
一択だろう。
DAG-CBORについては、CBORデータを扱うことができるライブラリが幾つか存在しているが、2024-03時点でIPLDのLink
を正しく扱えるものとしては serde_ipld_dagcbor
が現実的な選択肢になるようだった。
ので、この2つを使って実際に使われるデータに対して正しく Serialize/Deserialize できるようにする、ということを考える。
問題点: データフォーマットによって対象の型が異なる
JSONの場合/DAG-CBORの場合をそれぞれ独立して考えれば、構造に合わせて型を定義するだけなので簡単だ。
#[derive(Serialize, Deserialize, Debug)] struct CidLink { #[serde(rename = "$link")] link: String, } fn main() { let cid_link_from_json = serde_json::from_str::<CidLink>(...); println!("{cid_link_from_json:?}"); // => Ok(CidLink { link: "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a" }) }
#[derive(Serialize, Deserialize, Debug)] struct CidLink(cid::Cid); fn main() { let cid_link_from_dagcbor = serde_ipld_dagcbor::from_slice::<CidLink>(...); println!("{cid_link_from_dagcbor:?}"); // => Ok(CidLink(Cid(bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a))) }
で、問題は両方に対応しようとする場合。ライブラリのユーザがJSONを使うか DAG-CBORを使うかどちらかだけであればまだ feature flags で切り替えるなどの対応が可能だが、「どちらも使う」というユースケースも考えられるので、
serde_json
を使っている場合は$link
キーを含むオブジェクトserde_ipld_dagcbor
を使っている場合はcid::Cid
として同じ CidLink
という名前の型に情報を格納できるようにしたい。
最初の解決策: is_human_readable()
による分岐
基本的には serde
自体は、呼ばれる Serializer/Deserializer についての情報を知ることができない。
が、 Serialize
や Deserialize
を自分で実装すると、そのときに引数に含まれる serializer
や deserializer
に対して .is_human_readable()
というメソッドを呼ぶことで一つ情報を得られる。
これは serde_json
を使っていると true
になり、 serde_ipld_dagcbor
を使っていると基本的には false
になるので、以下のように分岐させることで統一した CidLink
で両方のデータフォーマットを扱うことができる。
#[derive(Debug)] struct CidLink(Cid); #[derive(Serialize, Deserialize)] struct LinkObject { #[serde(rename = "$link")] link: String, } impl Serialize for CidLink { fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { if serializer.is_human_readable() { LinkObject { link: self.0.to_string(), } .serialize(serializer) } else { self.0.serialize(serializer) } } } impl<'de> Deserialize<'de> for CidLink { fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { if deserializer.is_human_readable() { let obj = LinkObject::deserialize(deserializer)?; Ok(Self( Cid::try_from(obj.link.as_str()).map_err(serde::de::Error::custom)?, )) } else { Ok(Self(Cid::deserialize(deserializer)?)) } } }
これで解決、めでたしめでたし… といきたいところだが、そうもいかない。
うまくいかないケース
CidLink
単体が上手く処理されていても、それを子にもつ enum
を "Internally tagged" や "Untagged" で区別しようとすると、問題が生じるようだ。
例えば、以下のようなもの。
#[derive(Serialize, Deserialize, Debug)] #[serde(tag = "tag", rename_all = "lowercase")] enum Parent { Foo(Child), Bar(Child), } #[derive(Serialize, Deserialize, Debug)] struct Child { cid: CidLink, }
これは、 "tag"
キーで指定されたvariantとしてdeserializeを試みる。JSONでいうと
[ { "tag": "foo", "cid": { "$link": "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a" } }, { "tag": "bar", "cid": { "$link": "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a" } } ]
といった配列で渡されたとき、1つめの要素は Parent::Foo(Child)
、2つ目の要素は Parent::Bar(Child)
としてdeserializeすることになる。
これと同様の構造を持つDAG-CBORのデータを serde_ipld_dagcbor
でdeserializeすると
(そもそもこういうケースでCidを含むものをdeserializeできないという問題 もあったが)
Error: Msg("invalid type: newtype struct, expected struct LinkObject")
といったエラーになってしまう。
deserializer.is_human_readable()
で分岐しているところでdebug printしてみると分かるが、このような構造のデータをdeserializeするときは、 serde_ipld_dagcbor
を使っていても .is_human_readable()
は true
になってしまうらしい。
serde
の細かい挙動を知らないけど、 Internally tagged や Untagged の場合は一度mapデータとして保持してからtagや内容を見て型を決定する必要があるため?そこから目的の型にマッピングする際に使われるdeserializerはまた別物になるらしく、 .is_human_readable()
は意図したものにはならないようだ。
おそらくこのあたり。
なので、上述のように enum
を使っている箇所の下では .is_human_readable()
による分岐は機能しない。
解決策(?): Ipld
を経由しデータの構造によって分岐する
serde_ipld_dagcbor
という名前の通り、これは Ipld
というデータモデルを利用することを想定されている。このデータモデルは(互換性どれくらいか把握できていないけれど) serde_json::Value
と同じように構造化されたデータを保持できる。JSONには無い Link
というものがある点でJSONの上位互換と考えても良いかもしれない。
ということで、deserializeしたいデータを一度 Ipld
に変換してしまい、その構造を見てデータフォーマットを推定して分岐する、という手段をとった。
use libipld_core::ipld::Ipld; impl<'de> Deserialize<'de> for CidLink { fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { let ipld = Ipld::deserialize(deserializer)?; match &ipld { Ipld::Link(cid) => { return Ok(Self(*cid)); } Ipld::Map(map) => { if map.len() == 1 { if let Some(Ipld::String(link)) = map.get("$link") { return Ok(Self( Cid::try_from(link.as_str()).map_err(serde::de::Error::custom)?, )); } } } _ => {} } Err(serde::de::Error::custom("Invalid cid-link")) } }
少なくとも CidLink
としてのデータであれば、 Ipld::deserialize(deserializer)
は問題なく成功する。その結果は Ipld::Link
か、 "$link"
キーを含む Ipld::Map
かどちらか、になるはずで、前者ならそのまま得られるCid
を利用し、後者であればその "$link"
の値からCid
を復元する。
この手法であれば、 .is_human_readable()
に依存せずに正しく判別でき、どちらのデータも同様にdeserializeできる。
fn main() { let parents_json = serde_json::from_str::<Vec<Parent>>(...)?; println!("{parents_json:?}"); // => Ok([Foo(Child { cid: CidLink(Cid(bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a)) }), Bar(Child { cid: CidLink(Cid(bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a)) })]) let parents_dagcbor = serde_ipld_dagcbor::from_slice::<Vec<Parent>>(...)?; println!("{parents_dagcbor:?}"); // => Ok([Foo(Child { cid: CidLink(Cid(bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a)) }), Bar(Child { cid: CidLink(Cid(bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a)) })]) }
今回のようなケースでしか機能しないかもしれないが、一応これで問題は解決した。
(他にもっと良い方法をご存知の方がいれば教えてください…)
汎用的? な解決策: Untagged
一般的には、このように場合によって異なる型にdeserializeしたい場合は Untagged なenumを使うのが良いのかもしれない。
#[derive(Serialize, Deserialize)] #[serde(untagged)] pub enum CidLink { Raw(Cid), LinkObject { #[serde(rename = "$link")] link: String, }, }
serde(untagged)
の場合はvariantを順番に試し、最初にdeserializeに成功したものを結果として返す。
ので、上の例の場合はまず Cid
単体としてdeserializeしてみて、失敗したら "$link"
キーを持つオブジェクトとしてdeserializeしてみる、という動作になる。記述の順序を変えれば試行の順序も変化する。
ベンチマーク
上述した"Untagged"の場合は、記述の順序が大事になる。上の例の通りだとJSONのデータは毎回最初にCid
としてdeserialize試行して失敗してようやくオブジェクトとして試行、となり効率が悪い。しかし順序を逆にすると今度はDAG-CBORのデータを処理する際に毎回オブジェクトとして試行して失敗して…となる。
今回は対象が2種類だけなので差は小さいかもしれないが、これが何種類もあると…。
その点ではIpld
を経由する手法の方が、中間の変換処理は入るが安定した効率は期待できる。
当然ながら、JSONなら最初からオブジェクトとして DAG-CBORなら最初からCid
としてdeserializeするのが最も効率的で速い。
それぞれを基準として、「Raw→LinkObjectのuntagged (untagged_1
)」「LinkObject→Rawのuntagged (untagged_2
)」「Ipld経由 (via_ipld
)」のそれぞれのdeserializeをベンチマークとってみた。
running 8 tests test bench_cbor_only ... bench: 59 ns/iter (+/- 1) test bench_cbor_untagged_1 ... bench: 83 ns/iter (+/- 2) test bench_cbor_untagged_2 ... bench: 172 ns/iter (+/- 8) test bench_cbor_via_ipld ... bench: 63 ns/iter (+/- 1) test bench_json_only ... bench: 77 ns/iter (+/- 2) test bench_json_untagged_1 ... bench: 276 ns/iter (+/- 4) test bench_json_untagged_2 ... bench: 134 ns/iter (+/- 9) test bench_json_via_ipld ... bench: 325 ns/iter (+/- 6)
DAG-CBORに関しては、 untagged_2
がやはり毎回LinkObjectの試行の後になるので3倍ほど遅くなってしまう。一方で via_ipld
はほぼ同等の速度で処理できているようだ。
JSONに関しては、どれも大きく遅くなるようだ。意外にも untagged_2
でも2倍くらい遅くなる…。via_ipld
はcidのparse処理も入るので当然ながら最も遅くなってしまう、という結果だった。
実装結果
ということで、
ということもあって、dag-cbor
featureを有効にしたときのみ、Ipld
を経由する方式で両方のデータフォーマットに対応するようにした。
その後
この実装をした後、 types::string::Cid
という型が導入されて、Cid
の文字列表現であるものはこの型でvalidationするようになった。LinkObjectのものも値は String
ではなくこの types::string::Cid
を使うべきで、そうなるともはやJSONの速度差もそんなに気にしても仕方ない感じになってくる。
running 8 tests test bench_cbor_only ... bench: 59 ns/iter (+/- 0) test bench_cbor_untagged_1 ... bench: 78 ns/iter (+/- 3) test bench_cbor_untagged_2 ... bench: 169 ns/iter (+/- 3) test bench_cbor_via_ipld ... bench: 63 ns/iter (+/- 3) test bench_json_only ... bench: 227 ns/iter (+/- 4) test bench_json_untagged_1 ... bench: 426 ns/iter (+/- 6) test bench_json_untagged_2 ... bench: 288 ns/iter (+/- 5) test bench_json_via_ipld ... bench: 324 ns/iter (+/- 6)
もはや feature flags での切り替えは廃止して、必ずIpldを経由する方式にしてしまって良いかもしれない。