Rust: serde 互換の JSON with comments パーサーを作ってみた
作ったもの
完成はしていないですが、一旦まともに使うことができるぐらいにはなってきたので、やったことを残すべくこの記事に書いていきます
なお、まだやれてないことも書いておくと↓のような感じです
- raw_value などの serde_json にあるような feature の実装
- パフォーマンスのチューニング、ベンチマークテスト
- commentをパースしてデシリアライズできるようにする(serdeの制限であまり現実的でないかもしれない)
ドキュメントは現在 GitHub Pages で公開しています。
使い方
使い方はおよそ README に書いてある通りですが、 serde_json とだいたい同じように使えます。
(まだ実装できてない機能もいくつかあり、また、互換性を持たせることが目的ではないので細かいインターフェースもところどころ異なります)
[dependencies]
json-with-comments = { git = "https://github.com/hayas1/json-with-comments", tag = "v0.1.5" }
Deserialize を実装している型へのデシリアライズ
JSONC からのデシリアライズは from_str 関数を使います。コメントがついていたり trailing comma があったりしてもパースできる以外はだいたい serde_json と同じですね
use serde::Deserialize;
#[derive(Deserialize)]
struct Person<'a> {
name: &'a str,
address: Address<'a>,
}
#[derive(Deserialize)]
struct Address<'a> {
street: &'a str,
number: u32,
}
let json = r#"{
"name": "John Doe", // John Doe is a fictional character
"address": {
"street": "Main",
"number": 42, /* trailing comma */
},
}"#;
let data: Person = json_with_comments::from_str(json).unwrap();
assert!(matches!(
data,
Person {
name: "John Doe",
address: Address { street: "Main", number: 42 }
}
));
Value へのデシリアライズ
JSONC をデシリアライズしたい型が決まってない時は Value の型を使うことができます。 Value は [] を使ってインデックスアクセスができたり、その他いくつか便利なメソッドを持っています。 serde_json の json! マクロと同様、と jsonc! マクロを使って Value を作ることもできます。
use json_with_comments::{from_str, Value, jsonc};
let json = r#"{
"name": "John Doe", // John Doe is a fictional character
"address": {
"street": "Main",
"number": 42, /* trailing comma */
},
}"#;
let data: Value = from_str(json).unwrap();
assert_eq!(data["name"], Value::String("John Doe".into()));
assert_eq!(data["address"]["street"], Value::String("Main".into()));
assert_eq!(data.query("address.number"), Some(&42.into()));
assert_eq!(data, jsonc!({ "name": "John Doe", "address": { "street": "Main", "number": 42 }}));
Serialize を実装している型からのシリアライズ
データを JSONC へシリアライズすることもできます。minify な JSONC (つまり JSON と同じ)にする to_string 関数と、pretty な JSONC (trailing comma がある)にする to_string_pretty 関数の2つがあります。これも serde_json とだいたい同じですね
use serde::Serialize;
#[derive(Serialize)]
struct Person<'a> {
name: &'a str,
address: Address<'a>,
}
#[derive(Serialize)]
struct Address<'a> {
street: &'a str,
number: u32,
}
let person = Person {
name: "John Doe",
address: Address {
street: "Main",
number: 42,
},
};
let minify = r#"{"name":"John Doe","address":{"street":"Main","number":42}}"#;
assert_eq!(json_with_comments::to_string(&person).unwrap(), minify);
let pretty = r#"{
"name": "John Doe",
"address": {
"street": "Main",
"number": 42,
},
}"#;
assert_eq!(json_with_comments::to_string_pretty(&person).unwrap(), pretty);
serde_json の Value との相互変換
さらに、to_value や from_value などの関数を使って、 Value を Serialize を実装している型からシリアライズしたり、 Deserialize を実装している型へデシリアライズしたりすることもできます。実はこれも serde_json とだいたい同じです。今回はこれを使って、 json_with_comments::Value と serde_json::Value の相互変換を実現しています。詳しくは下でも触れます。
use serde::{Deserialize, Serialize};
use serde_json::json;
use json_with_comments::jsonc;
let (json, jsonc) = (json!({"name": "John Doe","age": 30}), jsonc!({ "name": "John Doe", "age": 30 }));
// serde_json::Value -> json_with_comments::Value
assert_eq!(json_with_comments::to_value(&json).unwrap(), jsonc);
assert_eq!(serde_json::from_value::<json_with_comments::Value>(json.clone()).unwrap(), jsonc);
// json_with_comments::Value -> serde_json::Value
assert_eq!(json_with_comments::from_value::<serde_json::Value>(&jsonc).unwrap(), json);
assert_eq!(serde_json::to_value(jsonc.clone()).unwrap(), json);
実装について
実装は serde_json とかなり近く、serde や serde_json のおさらいみたいにもなりますが、せっかくなので書いていきます。
serde の抽象化に従う
serde では数値型や文字列などの基本の型と、 seq や map といったコレクションの型、 struct や enum といった型の、それぞれに対して Serialize と Deserialize をするメソッドを要求します。この記事で全てのメソッドに触れるわけではないですが、それでも長々と書くことになってしまうぐらいには、とてもたくさんのメソッドを実装していく必要があります。
とはいえ、逆に言うと、これらさえ実装すれば、 Rust のデータ型を JSON 文字列として書きだしたり、 パースした JSON 文字列を Rust のデータ型に変換したりできます。特定のフィールドを無視したり、スネークケースでなくキャメルケースにしたりといったオプションについても serde が提供してくれます。
Deserialize
serde において Deserialize の登場人物は大きく 3 人です。
-
Deserializeトレイト:Deserializerトレイトによってデシリアライズできる型のことです。ほぼマーカーみたいなものです。 -
Deserializerトレイト: 実際にデシリアライズする人のことです。パーサーみたいなものです。 -
Visitorトレイト: パースされた値を実際にRustの値として対応させる人です。- 例えば
100という JSON をusizeにデシリアライズしたいとして、usizeの変数に実際に値を代入する部分をやっているイメージです
- 例えば
今回実装していくのは Deserializer トレイトが主軸になり、あとは serde がよしなにやってくれます。 Deserializer トレイトはかなりたくさんのメソッドを要求していて、たとえば deserialize_any や deserialize_bool があります。ちなみに、これらのメソッドは引数として Visitor をもらっている、いわゆる Visitor パターンになっています。deserialize_bool は今読んでいる文字列が true であれば true を、 false であれば false を、 Visitor に渡せばいいだけなので実装が比較的楽です。一方で deserialize_any などはそうもいかず、例えば今読んでいる文字列が { であれば Object(いわゆる Map) のパースが必要です。
↑に載せた実装の deserialize_any では今読んでいる文字列が { だったとき(74 行目)は Deserializer トレイトで同じく要求されている deserialize_map メソッドを呼んでいます。 deserialize_map メソッドでは↓のように、 { の中身のパースを MapDeserializer 構造体に任せており、これは serde の MapAccess トレイトを実装したものになっています。
MapAccess トレイトが要求しているメソッドは幸い(?) 2 つだけで、 next_key_seed と next_value_seed だけです。つまり key と value をそれぞれ処理していくという流れです。 next_key_seed が None を返せば Map は終わりという、 Iterator のようなインターフェースを備えています。 value については、 JSON が入れ子になる可能性がありますが、それに関しての実装は実は簡単で、上で今まで作ってきたような Deserializer に処理を投げればよいです。逆にkeyの方が(JSONでは文字列だけという制限があるため)今まで作った Deserializer にまるまる処理を投げることができずむしろ大変です(専用の Deserializer を用意しています…)。それさえ済めば、あとは : による key と value の区切りや、 , による key-value の区切り、 } による Object の終わりなどを処理すればよいぐらいです。
↑では Object(いわゆる Map) の入れ子の処理について書きましたが、 Array についても似たような実装をやっていく必要があります。また、各種の数値型や struct や enum のデシリアライズについても実装していく必要があったり、ここでは触れませんがなかなか多くのコードが必要になってきます。
Serialize
serde において Serialize は Visitor パターンではないので、こっちの登場人物は大きく 2 人と言って差し支えないと思います。Serialize は Deserialize よりはシンプルです。
-
Serializeトレイト:Serializerトレイトによってシリアライズできる型のことです。-
Deserializeトレイトと同じくほぼマーカーみたいなものです。
-
-
Serializerトレイト: 実際にシリアライズをやる人のことです。-
SerializeSeqやSerializeMapなどのトレイトに、入れ子部分のシリアライズを任せたりはしています。
-
Serialize についても Deserialize と同じく、今回実装していくのは Serializer トレイトが主軸になり、あとは serde がよしなにやってくれます。このトレイトは、 SerializeMap などの入れ子をシリアライズする型を Associated Type でたくさん要求していて、serialize_bool や serialize_map などシリアライズ用のメソッドもたくさん要求しています。 Serializer については特に Visitor パターンではなく、各メソッドがそれぞれ実際の値を渡されるので、それを文字列にしていく処理をゴソゴソと書いていく形です。
bool や数値など、入れ子でないデータ型についてはそのまま文字列にすればよいですが、 Object(いわゆる Map) などの入れ子になりうるデータ型については、入れ子部分の処理もする必要があります。serialize_map などのメソッドでは、処理を SerializeMap に投げています。
SerializeMap のやることも、 Deserialize の MapAccess 同様です。value 部分の入れ子をおおもとの Serializer に投げたり、key部分には専用の Serializer を用意したりします。: による key と value の区切り、 , による key-value の区切り、 } による Object の終わりなどを文字列として書き込んでいきます(↓のコードでは、minify format の JSON と pretty format の JSON どちらにも出力できるための抽象化が入っているのでリテラルとして : や , や } が直接ここのコードに現れてはないですが)。
Serialize は Deserialize よりシンプルではありますが、入れ子を処理するためのトレイトが、SerializeSeq, SerializeTuple, SerializeTupleStruct, SerializeTupleVariant, SerializeMap, SerializeStruct, SerializeStructVariant の 7 個ほどあったりするので、なんだかんだで Deserialize と同じくらいの量のコードを書くことになります。(SerializeTuple の処理は実質 SerializeSeq に投げれたりということもあって全部が全部実装を書いていかないといけないわけではない)
Value の Serialize と Deserialize
文字列 ↔ Rust の値 だけでなく、 Value ↔ Rust の値 についても Serialize と Deserialize で抽象化されるため、その実装もあります。この話をするためには、はじめに Value について書いておく必要がありますが、これはその実 Map や String といったJSONの値を表す enum になっています。(ドキュメント)
つまり、パーサーが「今読んでいる文字列」に応じて Rust の値へデシリアライズするのと同様に、Value の「今見ている値」に応じて Rust の値へデシリアライズできます。そして、Rust の値を JSON 文字列にシリアライズできるのと同様に、 Rust の値を Value へシリアライズできます。
そのための実装が value/de や value/ser に書いてあります。
- たとえば、
deserialize_boolは、文字列 → Rust の値 で Deserialize するときはtrueかfalseの文字列をパースしようとしましたが、Value→ Rust の値 で Deserialize するときは、今見ているのがValue::Boolであれば、Visitorに bool を渡し、そうでなければエラーという感じです
- また、
serialize_boolだと、Rust の値 → 文字列 で Serialize するときはtrueかfalseの値をそのまま文字列にしていましたが、Rust の値 →Valueで Serialize するときは、受け取った bool の値に応じてValue::Boolを返す感じになります。
今回作った json-with-comments の Value と serde_json の Value で相互変換する仕組みは、これに乗っかっています。
macro の実装
主にはデシリアライズしたい型が定まっていない場合などに使う Value の列挙型ですが、文字列をパースとかしなくても作りたいですよね。
{"key": "value", "null": null} みたいな JSON を表現するためにいちいち↓みたいに書いてると大変です。
let value = Value::Object(HashMap::from([("key".to_string(), Value::String("value".to_string())), ("null".to_string(), Value::Null)]));
そのために、 serde_json では Value を簡単に作ることができる json! macro が用意されていて、今回作った json-with-comments でも同様に Value を簡単に作ることができる jsonc! macro を用意しています。↓みたいな感じでお手軽に JSON を表現できます
let value = jsonc!({"key": "value", "null": null});
この jsonc! マクロは何段階かのマクロから構成されていて、中心部分は↓の jsonc_generics! マクロです
Rust の macro (ここでは宣言的マクロのことです) は基本的に引数を解析して block, expr, tt などのマッチする構造に応じてコード生成して置き換える、といったことをします。expr は式のことで、tt はトークンツリーのことです。↑のコードだと、
-
[]で囲われていたらarray!macro に処理を投げる -
{}で囲われていたらobject!macro に処理を投げる - null だったら
Value::Nullを返す -
exprだったらValue::fromを使ってValueを生成する
という雰囲気です。 null という文字列は Rust 的には式ではないので expr にはマッチしないといったミソもあったりします。
実装の中でも、object! macro が array! macro に比べてもちょっと大変だった話があるので、簡単に紹介します
object! macro がやりたいことは、以下です。
-
key: value,の形を見つけて、(key, value)の tuple にする -
key: value,の形がなくなるまで繰り返す - すると、
[(key, value), ...]のようなリストが作られるので、.into_iter().collect()してHashMapにする
このうち、key: value の形を見つける部分がちょっと大変でした。{$key:expr : $($rest:tt)*} などで簡単にマッチできそうにも見えますが、これはコンパイラに怒られてしまいます。 expr の区切りとして : は使えなくて、 => が , か ; だそうです。macro のマッチなどはなかなか仕様が大変そうです。
こんなときにどうするかというと、前から順番に tt を一つずつ見て、 : が先頭に来るまで取り出していく、といったようなことをします(↑の macros.rs のコード片で言うと、 220 行目あたりです)。先頭に : が来ると、他のパターンにマッチして value を取り出す処理が始まります。: が来るまで一回一回 object! macro を繰り返し呼ぶということなので、ちょっとパフォーマンスに懸念がありますね。まあコンパイル時に行われることなのでよいかなという気もします。
ちなみに、こんな感じで macro で tt を1つ1つ取り出すことを、munch と呼ぶそうです。むしゃむしゃ食べるといった意味らしいです。
他にも array! macro や object! macro では trailing comma の処理が色々試してみてもうまくいかず、結局同じような処理を2回書きがちにもなっているので、ちょっと悔いが残る実装になってます。
CI について
CIもいくつか作ってるので、これについても書いてみます。
単体テスト、formatter のチェック、linterのチェック
cargo test とか cargo fmt --check とか cargo clippy とかをやっているだけではあります。
単体テストは、JSON のシリアライズやデシリアライズといったざっくりした粒度のものが多いですが、テスト通っていれば、まあ JSONC のパーサーとしてはまともに動くだろうといった感じになっています。 Rust のいいところとしてコンパイルさえ通っていれば期待しない動きはほぼしないので、コンパイルできてテストも通っていると安心感がすごいです。
細かい修正しただけなのに壊れてしまったといった状況をおよそ避けることができるので、安心して手を加えることができるようになって開発の速度も上がり、継続してプログラムの改善に取り組むことができるようになります。
パーサーは比較的きっちり仕様が決まっているので、テストと特に相性が良いです。逆に、パーサーを書くことでテストの重要性を認識できるという面もあると思っています。
自動フォーマットとかは CI ではしておらず、CI がこけるだけになっています。特に作業中のブランチだと CI に勝手にコミットされるのがうれしくないと思ったためです。ローカルで自動フォーマットされるのでこけたことはないです
clippy は個人的には正直どっちでもいいかなと思っているのですが、ときどき知らなかった書き方に出会うことができるので、とりあえず入れています。
ドキュメント生成
master ブランチの push (PR の merge も含む) をトリガーに cargo doc --no-deps して、 GitHub Pages に上げています。最近のスタンダードな2段階構成(?)のやり方です。
-
actions/upload-pages-artifact を使って
cargo docに生成された doc を artifact に上げ、
- actions/deploy-pages を使ってその artifact を GitHub Pages に反映しています。
cargo doc とかをしていると .lock みたいなパーミッションが 600 のファイルが生成され、それがあるとうまく GitHub Pages に反映できずこけるため、そのファイルを消すなどしないといけないというちょっとした落とし穴があります。
紹介した GitHub Actions によって、↓の GitHub Pages の URLにドキュメントをアップロードしています
docについては crates.io に公開すると docs.rs に上がるそうなので、そこまで必要ないことかもしれないですね
カバレッジ計測
test のカバレッジに計測に cargo-tarpaulin を使ってみています。
cargo tarpaulin --output-dir target/doc --manifest-path Cargo.toml --out Html などすると target/doc 配下にカバレッジに関しての HTML が置かれるようなので、これについてもドキュメントと同様に GitHub Pages に上げています。
↓のURLにカバレッジについてアップロードされてます。あたらめて見ると 68.75% となかなか低かったです… 😰 もっとテストを拡充していったほうがよさそう、というよう気持ちになれるので、気軽にカバレッジが確認できるのはなかなかよいですね
READMEの追従漏れチェック
ドキュメントのために lib.rs にクレートの概要や使い方を書くわけですが、これって README.md とだいたい同じですよね。というわけで、cargo-readmeを使って、lib.rs のドキュメントから README.md を自動生成します。
とはいえ、上でも少し書いた通り、CIにコミットされるのはあまりうれしくないと思っているので、コミット自体は手動で行うことになります。
そこで、CIでは cargo readme を実行して生成される README.md に差分が無いかだけを確認しています。
README.md の更新が漏れていると CI がこけて気づくことができるので、個人的にはよい落としどころだったかなと思っています。
タグの付与
Rust のプログラムを Git 管理すると、Cargo.toml に書いているバージョンと、Git でつけるタグのバージョンで、2つのバージョンを管理することになります。
それらの同期を手動でとるのは大変なので、 Cargo.toml に書いてあるバージョンで Git にもタグをつけるようにしたいです。そこで、CI ではそれらの差分を検知する composite action を用意して、柔軟に使えるようにしています。
この composite action では、以下などを output として得ることができます
-
Cargo.tomlに書いてあるバージョン - Git でついている最新のタグのバージョン
-
Cargo.tomlに書いてあるバージョンと Git でついている最新のタグのバージョンが同じかどうか
これを使ってたとえば、PR がトリガーの CI では、マージするとバージョンが上がる場合に release のラベルを付与しています
また、master ブランチの CI では、Cargo.toml のバージョンが 上がった場合に、実際にタグを付与しています
こうして、 Cargo.toml に書くバージョンだけを管理すればよい状態にすることができました。(実際はこの CI だとバージョンが上がったことは検知できておらず、 Cargo.toml と Git でバージョンが違うかどうかだけしか見られていないことは内緒です)
リリースドラフト作成
GitHub でリリースをいい感じに作るとなると、主な選択肢は2つあります。release-drafter と GitHub 公式のリリースノート自動生成機能です。
release-drafter も機能が豊富でいいですが、今回は公式のものを使うことにしました。公式のものについて機能を簡単に説明すると、↓のような .github/release.yml でどのラベルがついたPRをどのリリースに分類するかを書いておき、リリースノートを PR のタイトルをもとに自動生成できるようになります。
リリース作成時に 「Generate Release Notes」ボタンを押すと、自動生成されたリリースノートを埋めてもらえます。
「Generate Release Notes」ボタンの画像は↓の公式ドキュメントの中にこっそり写ってます
いくつかリリースをしていますが、リリースノートはこうやって自動作成されています。
なお、PR に自動でラベルをつけるために、 actions/labeler を使ってます。↓のような .github/labeler.yml を書いておいてワークフローを呼び出すと、PR のブランチ名や、変更のあったパスなどに応じてラベルを付与してくれます。
ワークフローの呼び出しも↓のように簡単にできるので、とても扱いやすいものになっています。最近(?) v5 がリリースされて↑の yaml のインターフェースが変わったりしたみたいです。
ちなみに、「Generate Release Notes」ボタンを手動で押すために、 master ブランチの CI では、リリースのドラフトだけを作成するようになっています。 「Generate Release Notes」ボタンを押したときに得られる文字列は、GitHub の API を叩くことで手に入れられるようなのですが、そこにはまだ取り組めていないです。 公式の機能なのでリリースドラフトを用意するのに使っている actions/create-release にやってもらいたいところでもあるものの、もうアーカイブされてしまっているようなので望み薄ですね 😥
感想
serdeが提供する抽象化に沿ってコードを書いていけばいい感じのパーサーを作ることができてすごいです。
一方で、記述量はその分なかなか多くなります。自分で工夫できる領域も少なくなるのでちょっと物足りない部分もあります。
とはいえ、serde や serde_json を使っているときの結局これは何なんだろうみたいな気持ちからは解放されそうなので、JSONC パーサーを書いてみて良かったと思います。
Discussion