PythonでJSONを扱うとき、多くの人がjson.loads()でdictに変換しています。
そして、.get()やキャストで値を取り出しているはずです。
この方法は手軽ですが、型の保証がありません。
さらに、コードも冗長になりがちです。
本記事では、msgspecを使ってJSONを宣言した型へ直接デコードするテクニックを解説します。
dict[str, Any]へのデコードが抱える問題
json.loads()の戻り値はdict[str, Any]です。
つまり、どんなキーにどんな型の値が入っているのか、コード上では何も保証されません。
そのため、値を取り出すたびに.get()で防御的に書くことになります。
あるいは、型チェッカーを黙らせるためにキャストを挟む羽目になるでしょう。
高速なorjsonに置き換えても、この構造は変わりません。
速くなるのはパースだけです。
その後の「dictを掘って型を祈る」作業は、そのまま残ります。
msgspecなら型に直接デコードできる
msgspecは、JSONのデコードと型検証を一度に行うライブラリです。
C言語で実装されています。
そして、デコード結果をdictではなく、あなたが宣言した型のインスタンスとして直接返してくれます。
使い方はシンプルです。
import msgspec
class Grant(msgspec.Struct):
id: int
name: str
amount: float
grant = msgspec.json.decode(raw_bytes, type=Grant)
これだけで、パースと検証が同時に完了します。
不正なデータが来れば、その時点でエラーになるわけです。
なお、msgspec専用のStructだけでなく、標準のdataclassも渡せます。
そのため、既存のコードベースにも導入しやすいでしょう。
主要な選択肢との比較
同じペイロードをデコードする場合、代表的な選択肢は次の3つです。
- json.loads / orjson.loads: dict[str, Any]を返す。orjsonは速いが、型の保証はない
- pydanticのTypeAdapter(…).validate_json: 検証済みのリッチなモデルを返す。ただし、機能が豊富な分だけ重め
- msgspec.json.decode(raw, type=T): 検証済みのあなたの型を、C言語の速度で返す
msgspecの公式ベンチマークでは、pydantic v2の約12倍高速と謳われています。
もちろん、ベンチマークの数字は条件次第です。
鵜呑みは禁物ですが、ホットパスで差が出ることは間違いなさそうです。
実際、スレッドのコメント欄には切り替え事例の報告もありました。
マイクロサービスをmsgspecに移行したところ、レイテンシが30%下がったそうです。
PEP 695ジェネリクスとの組み合わせ
元の投稿で特に興味深かったのが、PEP 695のジェネリクス構文との組み合わせです。
これにより、デシリアライズ層全体がたった1つの関数に集約されます。
def deserialize[T](raw: bytes, t: type[T]) -> T:
return msgspec.json.decode(raw, type=t, strict=False)
deserialize(raw, Grant) # -> Grant
deserialize(raw, list[Grant]) # -> list[Grant]
単一のオブジェクトでもリストでも、同じ関数で型安全にデコードできます。
ボイラープレートの削減効果は大きいでしょう。
速度だけではないメリット
コメント欄で「なるほど」と思わされた指摘があります。
msgspecのStructは、デフォルトでスロット(slots)を使うという点です。
そのため、dictのような文字列キーでのアクセスは不要になります。
data[“key”][“nested”]ではなく、data.key.nestedと書けるわけです。
エディタの補完も効きます。
さらに、タイポは実行前に型チェッカーが検出してくれます。
ベンチマークの数字より、この日常的なバグ防止効果のほうが実益は大きいかもしれませんね。
導入前に知っておくべき注意点
ここまで良い話ばかりでした。
しかし、コメント欄では鋭い批判も出ていました。
公平のために紹介しておきます。
型変換の柔軟性に課題がある
msgspecは型変換の扱いが弱い、という指摘がありました。
例として挙がっていたのがDynamoDBです。
DynamoDBは、数値をすべてDecimalとして返します。
そのため、モデルのフィールドをintにしたい場合、msgspecでは面倒な回避策が必要になるとのことでした。
メンテナンス体制への懸念
この型変換に関するバグ報告が、1年以上放置されているという不満の声もありました。
ただし、スレッドにはメンテナーを名乗る人物も現れています。
オープンなissueが多く対応が遅れているものの、型変換は重要な機能なので何らかの形で対応するつもりだと表明していました。
また、代替としてattrsとcattrsの組み合わせを推す声も複数ありました。
面白いことに、cattrsの作者もスレッドに登場しています。
cattrsには、msgspec用の変換器が用意されているそうです。
msgspecの挙動で問題ない部分は、msgspecの速度をそのまま活かします。
一方、カスタマイズが必要な部分は、cattrs側の仕組みにフォールバックする設計とのことでした。
そもそも速度が要らないケースも多い
もう1つ、重要な指摘があります。
ボトルネックがネットワークやAPI待ちにある場合、JSONパースを数ミリ秒削っても体感は変わりません。
ページネーションされた同期リクエストで1イテレーションに数秒かかるなら、パース速度は誤差です。
依存関係が1つ増えるコストと天秤にかけて判断してください。
使い分けの考え方
スレッド全体の議論をまとめると、使い分けの軸は次のようになります。
複雑なバリデーションや豊富なエコシステムが必要なら、pydanticが依然として有力です。
元の投稿者自身も、モデル中心のコードではpydanticをデフォルトにしていると述べていました。
一方、大量のJSONをさばくホットパスでは、msgspecの「型へ直接デコード」が輝きます。
両者を使い分けている開発者もいました。
エンドポイントごとにシリアライザを切り替えられる設計にして、シンプルなモデルにはmsgspecを使います。
そして、複雑なモデルにはpydanticを選ぶという方針です。
あと、忘れてはいけない指摘がもう1つ。
単純なデータの入れ物が欲しいだけなら、pydanticすら不要な場面も多いということです。
その場合は、標準のdataclassで十分でしょう。
道具は適材適所で選びましょう。
まとめ
JSONをdict[str, Any]にデコードして掘り進める書き方は、型安全の観点で弱点を抱えています。
msgspecを使えば、宣言した型への直接デコードと検証をC言語の速度で実行できます。
さらに、PEP 695ジェネリクスと組み合わせれば、デシリアライズ層は関数1つに集約可能です。
ただし、型変換の柔軟性やメンテナンス体制には懸念の声もあります。
また、ボトルネックがパース以外にあるなら、導入の恩恵は限定的でしょう。
pydantic、msgspec、attrs+cattrs、そして標準のdataclass。
それぞれの得意分野を理解した上で、あなたのプロジェクトに合った道具を選んでください。
