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);
Value
との相互変換
serde_json の さらに、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!
マクロです
宣言的マクロのことです) は基本的に引数を解析して 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 では、リリースのドラフトだけを作成するようになっています。actions/create-release にやってもらいたいところでもあるものの、もうアーカイブされてしまっているようなので望み薄ですね 😥
「Generate Release Notes」ボタンを押したときに得られる文字列は、GitHub の API を叩くことで手に入れられるようなのですが、そこにはまだ取り組めていないです。 公式の機能なのでリリースドラフトを用意するのに使っている感想
serdeが提供する抽象化に沿ってコードを書いていけばいい感じのパーサーを作ることができてすごいです。
一方で、記述量はその分なかなか多くなります。自分で工夫できる領域も少なくなるのでちょっと物足りない部分もあります。
とはいえ、serde や serde_json を使っているときの結局これは何なんだろうみたいな気持ちからは解放されそうなので、JSONC パーサーを書いてみて良かったと思います。
Discussion