Rustのserdeで、データフォーマットによって異なる型にserialize/deserializeする

背景

BlueskyのAT ProtocolのRust版ライブラリを作っている。

memo.sugyan.com

github.com

その中で最近実装した機能の話。

AT Protocolで扱うデータモデルのSpecは、以下のページで書かれている。

atproto.com

この中に、Lexiconでcid-linkという名前で扱われる型がある。

https://atproto.com/specs/lexicon#cid-link

つまりIPLDのLinkCIDで表現する型、ということのようだ。

で、これらのデータを扱うわけだが、そのデータフォーマットが2種類ある。 IPLDではデータ送信のためのCodecとして、binary formatのDAG-CBORと human-readable formatのDAG-JSONを定めている。

https://ipld.io/docs/codecs/

AT Protocolでは、効率的にデータを扱いたい場合にはDAG-CBORを用い、XRPCのHTTP APIなどではDAG-JSONとは異なる規約のJSONフォーマットを扱う、らしい。

で、cid-linkについては以下のように書かれている。

link field type (Lexicon type cid-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 についての情報を知ることができない。 が、 SerializeDeserialize を自分で実装すると、そのときに引数に含まれる serializerdeserializer に対して .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処理も入るので当然ながら最も遅くなってしまう、という結果だった。

実装結果

ということで、

  • どうしてもJSONだけを扱うときと比較すると遅くなる
  • そもそもXRPC RequstにはJSONしか使わない
    • DAG-CBORが必要になるのはSubscriptionなどrepoデータを読むときのみ

ということもあって、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を経由する方式にしてしまって良いかもしれない。