PythonでJSONを扱うにはjsonモジュールを使うことがデファクトスタンダードとなっていますが、便利なように見えて意外と制約も強く、真面目に使おうとすると悩ましいこともあります。
jsonモジュール
jsonモジュールにはJSONとPythonオブジェクトの相互変換用のメソッドが定義されています。よく使うのはjson.dumps()とjson.loads()ですが、ファイルオブジェクトとの読み書きにはjson.dump()とjson.load()のバリアントがあります。
変換においてはJSONの型とPythonの型で以下の表のとおりに対応づけられています。型の制約は強く、配列が要求される箇所でイテレータやジェネレータを渡すことはできませんし、集合やバイト列を与えることもできません。
| JSONの型 | Pythonの型 | 注釈 |
|---|---|---|
| null | NoneType | nullは正確には型ではない |
| boolean | bool | |
| number | int / float | 値によって自動変換される |
| string | str | |
| array | list | |
| object | dict |
この制約は強いように見えますが、規模が小さく、プリミティブオブジェクトの組合せでプログラミングができているうちは問題になりにくいように見えます。しかし、ドメインモデルを考え始めるとすぐに厳しい課題に直面します。それはユーザー定義のクラスに対してJSONとの相互変換ができないということです。
class Person:
def __init__(self, name: str, age: int) -> None:
self.name = name
self.age = age
# ムムッ!
# TypeError: Object of type Person is not JSON serializable
json.dumps(Person('Alice', 14))JSONとの変換ロジックをどこに抱えているべきかは様々な意見があることが考えられます。ビジネスロジックに関係ないから変換器が持つべきというのはもっともですし、逆にファクトリを介して手軽にJSONからインスタンスを生成したいのでモデルが持つべきだということもあるでしょう。
これは言語やライブラリに依存するところもあるので一概には言えませんが、どっちの状況にも遭遇した中での経験則に基づけば、取り回しがよかったのは後者のほうで、モデルに対して変換方法を定義するものです。
変換器に変換ロジックを持たせると変換のためだけにコード量が増えますし、依存性の注入を前提にしている環境下ではどこまでも依存性の解決がついて回ります。変換ロジックとビジネスロジックと分けたかっただけなのに、JSONとの相互変換というちょっとした作業のためにドメインサービスやアプリケーションサービスが必要になってしまいます。これでは本末転倒です。
Serializableトレイトの提案と実装
そこでドメインモデルにJSONとの変換ロジックであるところの from_json() と to_json() をJSONと相互変換するクラスに定義してみます。
class Person:
def __init__(self, name: str, age: int) -> None:
self.name = name
self.age = age
@classmethod
def from_json(cls, data: str) -> Self:
return cls(**json.loads(data))
def to_json(self) -> str:
return json.dumps({'name': self.name, 'age': self.age})
この方法ではやりたいことは十分実現できますし、大体の状況ではうまくいくでしょう。しかし、クラスごとに定義しているならではの問題も生じます。
- クラスごとのJSON変換メソッド定義にブレが生じる
- 人間を信用してはならない
- なるべく同じ方法でJSONと相互変換できるほうが好ましい
- どのクラスがJSONに変換できるのかわからない
- さいわいPythonはリフレクションがあるので特定メソッドの存在を確認できる
- しかしリフレクションは可能な限り避けるべき
これらふたつの課題を解決するものがインタフェース(トレイト)という考え方です。JSONとの相互変換をしたいクラスを定義する際に、次のようなSerializableインタフェースを継承するようにして、from_object() と to_object() を定義することで、どのクラスでも統一的にJSONとの相互変換を実現できます。
import abc
import json
from typing import Self
class Serializable(metaclass=abc.ABCMeta):
@classmethod
def from_json(cls, data: str) -> Self:
return cls.from_object(json.loads(data))
@classmethod
@abc.abstractmethod
def from_object(cls, obj: dict[str, object]) -> Self:
raise NotImplementedError()
def to_json(self) -> str:
return json.dumps(self.to_object())
@abc.abstractmethod
def to_object(self) -> dict[str, object]:
raise NotImplementedError()
このインタフェースではユーザー定義クラスとJSONの相互変換方法を要求しません。代わりにJSONと互換性のあるPythonプリミティブ型との変換方法を要求するようにしています。
この理由のひとつめはユーザー定義クラスもJSONも入れ子になりうるということです。from_object() または to_object() を再帰的に呼び出すことで、データの組み立てが簡単にできます。from_json() と to_json() しかないと集約が入れ子になったデータをJSONに変換する面倒を見てやらないといけないですし、いつでも集約ルートの単位だけでデータを扱うとも限りません。
ふたつめに今回はJSONとの変換を考えていますが、XMLのような別の形式との変換が必要になったときに、こうしてプリミティブ型との変換方法を提供することで、ユーザー定義オブジェクトから直接変換するよりも相互変換がしやすくなります。
上に挙げたSerializableを使ったPersonクラスの実装例は次のようになります。
class Person(Serializable):
def __init__(self, name: str, age: int) -> None:
self.name = name
self.age = age
@classmethod
def from_object(cls, obj: dict[str, object]) -> Self:
return cls(
name=cast(str, obj['name']),
age=cast(int, obj['age'])
)
def to_object(self) -> dict[str, object]:
return {
'name': self.name,
'age': self.age
}dict[str, object] というシグネチャは正しくないかもしれませんが、正しく定義してもあまりコード的なコストが減らないので、値については object としました。
最近のPythonではType Hintingが充実してきており、正しく指定することによってコードチェッカも警告を出してくれるようになっています。object はあらゆる型の祖となっているので、int や str 扱いすることはダウンキャストに相当する動作になり安全でないので警告が発生します。あるいはPythonはダック・タイピングを採用しているので単純に型が異なるために警告が発生していると見ることもできます。
この警告を解決してくれるのが typing.cast です。実はなにもしない恒等関数ですが、静的解析器はこのメソッドを特別扱いしており、型チェックをパスできます。
JSONEncoderの使用
拡張可能なエンコーダとして json.JSONEncoder が用意されていますが、拡張したところでそのエンコーダを使い回す必要があります。それによってエンコーダに依存した実装になってしまうので、結局依存性の注入と同じ問題が発生してしまいます。
それでもモデルに定義は気持ち悪い
という人もいるでしょう。そういうときにはデータモデルを用意して、それをSerializableとすることでビジネスロジックとJSONの変換を分離できます。
まとめ
- Pythonではユーザー定義型とJSONを直接変換する方法はありません
- SerializableインタフェースでJSONとの変換方法を与えることを提案します
- プリミティブオブジェクトとの変換を挟むことでJSON以外との変換も視野に入れることができます