概要
カスタムクラスのインスタンス (オブジェクト) を含む複雑な構造を JSON エンコード/デコードする例について説明します。
言語は Python 3.8.1 です。
Python の場合、複雑なデータをシリアライズするには pickle を使うのが楽で良いのですが、Python 以外で読み書きしたい場合や、シリアライズしてもある程度の可読性がほしい場合は JSON を選ぶことが多いです。
ここで説明する方法以外にもやり方はあると思いますが、以下の点において、この方法が気に入っています。
- Python 標準の json モジュールを使う。
- エンコード/デコードのロジックを、クラスごとに分けられる。
カスタムで複雑なデータの例
説明に使うものなので、そこまで複雑にはしませんが、以下の条件を満たすデータでやってみます。
- カスタムクラスが複数入っていること。
- カスタムオブジェクトの属性にもカスタムオブジェクトが入っていること。
class Person:
def __init__(self, name):
self.name = name
class Robot:
def __init__(self, name, creator=None):
self.name = name
self.creator = creator
alan = Person('Alan')
beetle = Robot('Beetle', creator=alan)
chappy = Robot('Chappy', creator=beetle)
alan は人間、beetle と chappy はロボットです。
以下、ロボットのデータをリストにして、そのリストをエンコード/デコードしてみたいと思います。
robots = [beetle, chappy]
エンコード
オブジェクトから JSON 文字列へシリアライズすることをエンコードといいます。
このリストには Person クラスと Robot クラスのオブジェクトが含まれているので、それらについてエンコードできるようにする必要があります。
シンプルなエンコード
まずはシンプルな Person クラスのエンコードをしてみます。
エンコードの仕様を決める
カスタムのオブジェクトをエンコードするには、どのようにエンコードするか決めなければなりません (仕様というやつです)。
ここでは、クラス名 と 属性の内容 をそれぞれ name-value ペアにして出力することにします。
上記の alan の場合ですと、以下のような JSON 文字列になる想定です。
{"class": "Person", "name": "Alan"}
カスタムのエンコーダを作る
標準の json.dumps 関数に cls パラメタを指定することで、カスタムのエンコーダを使うことができます。
カスタムのエンコーダは、json.JSONEncoder を継承して、default メソッドをオーバーライドして作ります。
default メソッドの引数にオブジェクトが入ってくるので、json.JSONEncoder で扱える形 (ここでは str のみを含む dict) にして返せば OK です。
import json
# Person オブジェクト用の、カスタムのエンコーダ
class PersonEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, Person):
return {'class': 'Person', 'name': obj.name}
else:
return super().default(obj)
print(json.dumps(alan, cls=PersonEncoder))
# 結果:
{"class": "Person", "name": "Alan"}
複雑なエンコード
次に Robot クラスのエンコーダを作りますが、これが複雑という訳ではありません。
「概要」で書いたように、エンコードのロジックはクラスごとに分ける からです。
# Robot オブジェクト用の、カスタムのエンコーダ
class RobotEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, Robot):
return {'class': 'Robot', 'name': obj.name, 'creator': obj.creator}
else:
return super().default(obj)
ほぼ PersonEncoder と同じです。
ただし、先ほど PersonEncoder でやったようには行きません。返り値の中の creator を json.JSONEncoder で扱える形にしていないからです。
敢えてそのようにしてロジックを分けて、実際にエンコードする時には2つのエンコーダを合体させて使います。
エンコーダを合体させる
エンコーダを合体させるには、多重継承を使って新しいクラスを作ります。
# 2つのエンコーダを継承した、新しいエンコーダ
class XEncoder(PersonEncoder, RobotEncoder):
pass
print(json.dumps(robots, cls=XEncoder))
# 結果:
[{"class": "Robot", "name": "Beetle", "creator": {"class": "Person", "name": "Alan"}}, {"class": "Robot", "name": "Chappy", "creator": {"class": "Robot", "name": "Beetle", "creator": {"class": "Person", "name": "Alan"}}}]
結果を整形した場合 (クリックで表示)。
print(json.dumps(robots, cls=XEncoder, indent=4))
# 結果:
[
{
"class": "Robot",
"name": "Beetle",
"creator": {
"class": "Person",
"name": "Alan"
}
},
{
"class": "Robot",
"name": "Chappy",
"creator": {
"class": "Robot",
"name": "Beetle",
"creator": {
"class": "Person",
"name": "Alan"
}
}
}
]
json.dumps 関数にエンコーダのクラスを1個しか指定できないのでこのような方法になりますが、これでオブジェクトの種類が増えた場合でも拡張が可能です。
(補足) 多重継承による動作について
上記の XEncoder を作ることでなぜ上手く動作するのか、簡単に説明します。
Python のクラスの多重継承では、継承順に属性を参照に行きます。
XEncoder の default メソッドを呼び出すと、まず先に継承している PersonEncoder の default メソッドを見に行きます。
PersonEncoder.default メソッドでは、obj が Person オブジェクトなら自分で dict を返し、そうでないならスーパーメソッドを呼ぶようになっています。
この場合のスーパーメソッドは、json.JSONEncoder.default ではなく RobotEncoder.default になります。
これが Python の多重継承の動きです。
RobotEncoder.default がスーパーメソッドを呼べば、それ以上は多重継承していないので、本来のスーパークラスである json.JSONEncoder へと処理が委ねられます。
default メソッドがどのように再帰的に呼ばれるのかは調べていませんが、if 文でクラス判定をしている限りは、継承順が逆になっても同じ結果が得られるようです。
デコード
エンコードとは逆に、JSON 文字列からオブジェクトへデシリアライズすることをデコードといいます。
json.loads メソッドに object_hook パラメタを渡すことで、デコード後のオブジェクトにカスタムの処理を加えることができます。
シンプルな object_hook の例
まずは Person クラスのオブジェクトだけをエンコードし、それをデコードする例を見てみます。
object_hook として渡す関数はデコード済みのオブジェクト (dict 等) を受け取るので、'class' の値が 'Person' である dict だった場合の処理を書きます。
# Person オブジェクト用のフック関数
def person_hook(obj):
if type(obj) == dict and obj.get('class') == 'Person':
return Person(obj['name'])
else:
return obj
# JSON 文字列にエンコードする
alan_encoded = json.dumps(alan, cls=PersonEncoder)
# JSON 文字列からデコードする
alan_decoded = json.loads(alan_encoded, object_hook=person_hook)
print(alan_decoded)
print(alan_decoded.__class__.__name__, vars(alan_decoded))
# 結果:
<__main__.Person object at 0x0000027F67919AF0>
Person {'name': 'Alan'}
object_hook を合体させる
次に、Robot クラスのための object_hook を作って、2つを合体させた新しい関数を作ります。
# Robot オブジェクト用のフック関数
def robot_hook(obj):
if type(obj) == dict and obj.get('class') == 'Robot':
return Robot(obj['name'], creator=obj['creator'])
else:
return obj
# 2つのフック関数を呼び出す、新しいフック関数
def x_hook(obj):
return person_hook(robot_hook(obj))
合体させた関数 x_hook は以下のようにも書けます。少し長くなりますが、hook を増やしやすいです (上の例とは hook の適用順が変わるが、問題ない)。
def x_hook(obj):
hooks = [person_hook, robot_hook]
for hook in hooks:
obj = hook(obj)
return obj
これを使って、上の方で作ったロボットのリストをエンコード/デコードしてみます。
# JSON 文字列にエンコードする
robots_encoded = json.dumps(robots, cls=XEncoder)
# JSON 文字列からデコードする
robots_decoded = json.loads(robots_encoded, object_hook=x_hook)
for robot in robots_decoded:
print(robot)
print(robot.__class__.__name__, vars(robot))
# 結果:
<__main__.Robot object at 0x0000027F67919A30>
Robot {'name': 'Beetle', 'creator': <__main__.Person object at 0x0000027F67919B50>}
<__main__.Robot object at 0x0000027F67919E50>
Robot {'name': 'Chappy', 'creator': <__main__.Robot object at 0x0000027F679199D0>}
エンコードの時と同様に (おそらく内側から再帰的にデコードされるので)、hook を適用する順番を変えても結果は変わりませんでした。
(補足) エンコードも同じ方法でカスタマイズできる
実は、エンコード側も同じように関数を与える形でカスタマイズする事ができます。
逆に、デコード側をデコーダのサブクラスを作る形にしようとすると、もっとややこしくなります。
カスタムのエンコードのロジックを合体させる時に、シンプルに多重継承のみで書きたい場合はサブクラスを作る方法を、デコード側のスタイルに合わせたい場合は関数を与える方法を選ぶと良いでしょう。
エンコード側を関数を与える形でカスタマイズした例
def person_default(obj):
if isinstance(obj, Person):
return {'class': 'Person', 'name': obj.name}
else:
return obj
def robot_default(obj):
if isinstance(obj, Robot):
return {'class': 'Robot', 'name': obj.name, 'creator': obj.creator}
else:
return obj
def x_default(obj):
defaults = [person_default, robot_default]
for default in defaults:
obj = default(obj)
return obj
print(json.dumps(robots, default=x_default))
# 結果:
[{"class": "Robot", "name": "Beetle", "creator": {"class": "Person", "name": "Alan"}}, {"class": "Robot", "name": "Chappy", "creator": {"class": "Robot", "name": "Beetle", "creator": {"class": "Person", "name": "Alan"}}}]
課題
デコードの方に少し課題があります。
上記の例でいうと、最初にデコードしたロボット 'Beetle' と、'Chappy' の creator である 'Beetle' は、元々は同じオブジェクトでした。
また、それらの 'Beelte' の creator である 'Alan' も同じオブジェクトでした。
上記のデコード方法では、「名前が同じだからすでに作ったオブジェクトを使い回す」という事はしていないので、完全にはエンコード前の状況を再現していません。
それをしたい場合は、Person クラスや Robot クラスの方にその仕組みを作って、object_hook からは名前を指定するだけで適切なオブジェクトを受け取れるようにすれば良いでしょう。