PythonでJSONをオブジェクトにEncode/Decodeする

## はじめに

Pythonには組み込みでjsonというモジュールがありますが、普通に使うとEncode/Decodeはdict形式を介して行います。

別にその状態でも十分に使えるのですが、JavaScript Object Notationなのにオブジェクトにできないのはちょっと不便じゃない？と思うわけで、オブジェクト⇔JSON文字列への変換について纏めました。

基本的な辞書形式でのEncode/Decodeについては [こちらを参照](https://note.nkmk.me/python-json-load-dump/) してください。

## TL;DR

とりあえず簡単なオブジェクトは下記の要領で扱えます。

```python
import json
from types import SimpleNamespace

# オブジェクト定義
# コンストラクタのある場合
class Foo():
    def __init__(self, hoge1: str, hogeNum: int, hoge_list: list) -> None:
        self.hoge1 = hoge1
        self.hogeNum = hogeNum
        self.hoge_list = hoge_list

f = Foo("hoge1", 192, [1, 2, 3])

# コンストラクタの無い場合
class Hoge():
    pass

h = Hoge()
h.hoge = "hogehoge"
h.hoga = "hogahoga"
h.num = 123
h.num_list = [1, 2, 3]

# Encode時
# オブジェクトから __dict__ を通じてAttributeの辞書を取得し、JSONに
json_str = json.dumps(f.__dict__)
print(json_str)
# >> {"hoge1": "hoge1", "hogeNum": 192, "hoge_list": [1, 2, 3]}

h_str = json.dumps(h.__dict__)
print(h_str)
# >> {"hoge": "hogehoge", "hoga": "hogahoga", "num": 123, "num_list": [1, 2, 3]}

# Decode時
# object_hookにコンストラクタをいれる（辞書で受けた引数を展開する）
# コンストラクタが未定義の場合：
#   types モジュールのSimpleNamespaceを使う

f = json.loads(json_str, object_hook=lambda x: Foo(**x))
print(f.hoge1) # >> hoge1

h = json.loads(h_str, object_hook=lambda x: SimpleNamespace(**x))
print(h.num_list) # >> [1, 2, 3]

```

## ライブラリとかないの？

自分で頑張らなくても簡単にオブジェクトをJSON化できる「json_tricks」というモジュールがあります。
ただ、これはEncodeとDecodeの双方をこのモジュールを介して行うことが前提となっているので、既に規定されているJSONオブジェクトを扱う時は自分で書く必要があります。

他には「ijson」というライブラリもあります。数十MBを超えるデータを全てメモリに読み込まずに使えるという大規模データ解析には便利そうなツールですが、目的であるオブジェクトの読み書きとはちょっと違います。

結論：自分に適したものは、やはり自分で作るしかない。

## Encodeの基本

jsonモジュールのloads(load)ではobj引数がAnyとなってますが、普通にオブジェクトをいれるとTypeErrorが出ます。

```python

class Hoge():
    pass

h = Hoge()
h.hoge = "hogehoge"
h.hoga = "hogahoga"
h.num = 123
h.num_list = [1, 2, 3]

h_str = json.dumps(h) 
# TypeError: Object of type Hoge is not JSON serializable

```

これは既定のEncoderが参照している変換テーブルにはオブジェクトが変換対象として入ってないからです。

[Python→JSONへの変換テーブル（公式）](https://docs.python.org/ja/3/library/json.html#py-to-json-table)

そこで、`__dict__` を利用することでオブジェクトに定義された属性を辞書形式で取得して渡すわけです。

`__dict__`について、詳しくは[こちら（公式）](https://docs.python.org/ja/3/library/stdtypes.html?highlight=__dict__#object.__dict__)

```python
h_str = json.dumps(h.__dict__)
print(h_str)
# {"hoge": "hogehoge", "hoga": "hogahoga", "num": 123, "num_list": [1, 2, 3]}
```

`__dict__` を直接叩くのはNGという場合は、dict形式に変換する関数を作ってください。

クラス内でプライベートな変数が定義されてると`__dict__`の内容が特殊になるので、この場合もやはり関数を作る必要があります。

```python
class Foo():
    def __init__(self, hoge:str):
        self.hoge = hoge
        self.__hoge = "private value"

f = Foo("foo")
print(f.__dict__)
# >> {'hoge': 'foo', '_Foo__hoge': 'private value'}
# 属性名が一致していないので再現が難しい

```

## Decodeの基本

Decodeするときは、`object_hook`という引数を使います。
ここに関数を渡してあげると、Encode中にobject要素が検知されたときに関数が呼ばれます。
引数として中身がdict形式で渡されるので、それをコンストラクタに展開して渡してあげます。

引数の辞書展開については、[こちら](https://note.nkmk.me/python-argument-expand/)

コンストラクタが定義されてないクラスの場合でも、`SimpleNamespace`をtypesモジュールからimportしてあげれば簡単にオブジェクトに復元できます。
ただし、出来上がるオブジェクトは元のクラスと異なるため、メソッドやプロパティの呼び出しはできないです。

```python
from types import SimpleNamespace
h = json.loads(h_str, object_hook=lambda x: SimpleNamespace(**x))
print(h.num_list) # >> [1, 2, 3]

```

## 複雑なプロパティを持つクラスを扱う

上の例では文字列、数値、配列というJSONでも既定された要素だけで構成されたオブジェクトを扱いました。
しかし、実際は様々な要素がオブジェクトには含まれる可能性があります。
そういうときは上記の方法だけでは対処しきれないので、もう少し自分で実装する必要があります。

### Encode時

jsonモジュール内にあるJSONEncoderを継承した独自のクラスを生成し、dump/dumpsを叩く際にcls引数に渡してやります。

クラス内ではdefautメソッドをオーバーライドし、オブジェクトの種類を見て**復元可能な**dict形式に変換して返すようにします。

**復元可能な**というのは、具体的に言うと、keyに設定する文字列を属性(attribute)の名前と被らないようにします。
Decodeする際に読み込んだdictのkeyを見て元のオブジェクトに戻すのですが、その際に属性名と被ってると復元に失敗するからです。

言葉だけだと分かりにくいですが、以下のような感じです。

```python
class MyEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, uuid.UUID):
            # 属性名と被らないようなKeyを設定して辞書形式で返す。
            return {"__uuid__": str(obj)}        
        elif isinstance(obj, object) and hasattr(obj, '__dict__'):
            # 一般的なクラスオブジェクトは__dict__で返す。
            return obj.__dict__
        # 数値や文字列など元のJSONに含まれるものは元のEncoderを使う。        
        return json.JSONEncoder.default(self, obj)        
json.dumps(h, cls=MyEncoder)
```

### Decode時

Encode時と同じようにJSONDecoderを継承した独自クラスを作る、ということは必要なく、`object_hook`に渡す式を定義してあげればOKです。

Encode時に設定したkey値に応じて読み込んだ値を復元する処理を入れます。

```python
def decode(d: dict):
    if "__uuid__" in d: 
        return uuid.UUID(d["__uuid__"])
    return SimpleNamespace(**d)
    # return d にすると最終的に特定のkeyに対応するオブジェクトが変換した状態のdictが返される。
json.loads(h_json, object_hook=decode)
```

### Example

以上、まとめると以下のようになります。

```python
import json
import uuid

class Hoge():
    pass

class MyEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, uuid.UUID):
            return {"__uuid__": str(obj)}
        elif isinstance(obj, object) and hasattr(obj, '__dict__'):
            return obj.__dict__
        return json.JSONEncoder.default(self, obj)

def decode(d: dict):
    if "__uuid__" in d:
        return uuid.UUID(d["__uuid__"])
    return SimpleNamespace(**d)

h = Hoge()
h.id = uuid.uuid1()
h.hoge_str = "hoge"
c = Hoge()
c.c_str = "child"
h.child = c

# cls引数に作成したクラスを渡してやり、obj引数にはオブジェクトをそのまま渡す。
h_json = json.dumps(h, cls=MyEncoder)
print(h_json) 
# >> {"id": {"__uuid__": "生成されたuuidの値"}, "hoge_str": "hoge", "child": {"c_str": "child"}}
h = json.loads(h_json, object_hook=decode)
print(h.id) # >> (生成されたuuidの値)
print(h.child.c_str) # >> child

```