【Python】jsonをデコードしてクラスに割り当てたい

## 概要
jsonを読み込んで好きなクラスに変換する、汎用的な方法を考えて、コードを書いてみました。[こちらの記事](https://qiita.com/Akio-1978/items/0bb4075ea05a8b4d53cc)の内容と合せて、jsonと任意のクラスインスタンスを相互変換できます。

* やりたいこと
    * pythonでjsonを扱うとき、`json.load`はdictやlistを返す。
    * できれば、`obj['valuekey']`とかdictの形より、`obj.valuekey`とかアトリビュートとしてアクセスしたい。
    * そのまま任意のクラスに割り当てて、データと振舞いを持つオブジェクトを組み立てたい。

* やったこと
    * `json.load`が返したdictまたはlistを、任意のルールに従って変換するコードを書いた。

## 設計
### loadした後のdictに対して処理する
`json.load`で使用されるJsonDecoderを改造してなんとかならないかと頑張ってみましたが、どうにもうまく行かないので、一度`json.load`を通して、生成されたdict（またはlist）を分解して行く方針を取ります。

### jsonのキー名をクラスと結びつける
どんなクラスを生成すればいいのか、これはアプリケーションの都合によるので自分でマッピングを生成します。以下のようなイメージです。
例えばこんなjsonを…

```json
{
    "value1" : "string value 1",
    "nested" : {
        "value" : "nested value 1"
    }
}
```
こんなクラスのインスタンスにしたいと思います。[^constructor]

```python
# テスト用クラスその1
class TestObject:
    def test_method(self):
        return 'TestObject.test_method'

# テスト用クラスその2
class NestedObject:
    def test_method(self):
        return 'NestedObject.test_method'
```
そこで、jsonのキーとクラスを結びつける、こんなdictを用意します。jsonの最上位のオブジェクトはキーを持たないので、`<root>`というキーワードを特別扱いにしてそれに充てます。[^root_class_mapping]

```python
object_mapping = {
    '<root>' : TestObject
    'nested' : NestedObject
}
```
これらを利用して、以下のような使い方で変換を行うクラスを作ってみます。

```python:usage
builder = ObjectBuilder(root_class=TestObject, mapping=object_mapping)
result = builder.build(dict_data)
```
## 実装
こんなコードを書いてみました。

```python:objectbuilder.py
MAPPING_ROOT_CLASS = '<root>'

class ObjectBuilder:
    def __init__(self, mapping):
        self.mapping = mapping # マッピング定義

    # 変換の起点になるメソッド
    def build(self, src):
        # jsonの最上位はオブジェクト以外にリストになることがある
        if isinstance(src, list):
            root_instance = self.build_sequence(src, self.mapping[MAPPING_ROOT_CLASS])
        else:
            root_instance = self.mapping[MAPPING_ROOT_CLASS]()
            self.build_object(root_instance, src)

        return root_instance

    # オブジェクトの処理
    def build_object(self, current, src):
        for key, value in src.items():
            if key in self.mapping:
                func = self.mapping[key]
                setattr(current, key, self.build_value(value, func))
            else:
                # マッピングに定義されていないものは、アトリビュートとして扱う
                setattr(current, key, value)

        return current

    # リストの処理
    def build_sequence(self, src, func):
        current = []
        for value in src:
            current.append(self.build_value(value, func))
        
        return current

    # build_objectとbuild_sequenceで使うもの
    def build_value(self, value, func):
        if isinstance(value, dict):
            result = self.build_object(func(), value)
        elif isinstance(value, list):
            result = self.build_sequence(value, func)
        else:
            result = func(value)

        return result

```
なんのことはない、相変わらずの素直な力技ですが、こんな動きをします。

* buildの引数がdictではなくlistだった場合は、root_classのリストを生成します。
* また、マッピングに指定されていないキーは、処理中のクラスのアトリビュートとして設定されます。[^init_tips]
* dictやlistがマッピングに指定されていない場合、そのままdictやlistで取り込みます。[^known_bugs]

## 使い方の紹介を兼ねたユニットテスト
<details><summary>長いので折りたたみます。</summary><div>

```python:test_objectbuilder.py
import unittest
import json
from objectbuilder import ObjectBuilder, MAPPING_ROOT_CLASS

# テスト用クラスその1
class TestObject:
    def test_method(self):
        return 'TestObject.test_method'


# テスト用クラスその2
class NestedObject:
    def test_method(self):
        return 'NestedObject.test_method'

# 相互変換のテスト用
def default_method(item):
    if isinstance(item, object) and hasattr(item, '__dict__'):
        return item.__dict__
    else:
        raise TypeError

class ObjectBuilderTest(unittest.TestCase):

    # ルートになるクラスのプロパティを設定するだけ
    def testObject(self):

        dict_data = json.loads('''{
            "value1" : "string value 1"
       }''')

        builder = ObjectBuilder(mapping={MAPPING_ROOT_CLASS : TestObject})
        result = builder.build(dict_data)
        self.assertEqual(result.value1, 'string value 1')

        # 生成したクラスのメソッドを呼んでみる
        self.assertEqual(result.test_method(), 'TestObject.test_method')

    # ネストしたクラスを生成する
    def testNestedObject(self):
        # jsonのキーとクラスをマッピングするdict
        object_mapping = {
            MAPPING_ROOT_CLASS : TestObject,
            'nested' : NestedObject
        }
        # 生成元のソース
        dict_data = json.loads('''{
            "value1" : "string value 1",
            "nested" : {
                "value" : "nested value 1"
            }
       }''')

        builder = ObjectBuilder(mapping=object_mapping)
        result = builder.build(dict_data)
        self.assertEqual(result.value1, 'string value 1')
        self.assertIsInstance(result.nested, NestedObject)
        self.assertEqual(result.nested.value, 'nested value 1')

    # マッピングを指定しない場合はただのdict
    def testNestedDict(self):
        object_mapping = {
            MAPPING_ROOT_CLASS : TestObject
        }

        # 生成元のソース
        dict_data = json.loads('''{
            "value1" : "string value 1",
            "nested" : {
                "value" : "nested value 1"
            }
        }''')

        builder = ObjectBuilder(mapping = object_mapping)
        result = builder.build(dict_data)
        self.assertEqual(result.value1, 'string value 1')
        self.assertIsInstance(result.nested, dict)
        self.assertEqual(result.nested['value'], 'nested value 1')

    # リストの処理
    def testSequenceProperty(self):
        mapping = {
            MAPPING_ROOT_CLASS : TestObject,
            'nestedObjects' : NestedObject,
        }
        source_dict = json.loads('''{
            "value1" : "string value 1",
            "nestedObjects" : [
                {
                    "value" : "0"
                },
                {
                    "value" : "1"
                },
                {
                    "value" : "2"
                }
            ]
        }''')

        builder = ObjectBuilder(mapping=mapping)
        result = builder.build(source_dict)
        self.assertEqual(result.value1, 'string value 1')
        self.assertEqual(len(result.nestedObjects), 3)

        for i in range(3):
            self.assertIsInstance(result.nestedObjects[i], NestedObject)
            self.assertEqual(result.nestedObjects[i].value, str(i))

    # ルート要素自体がリストの場合
    def testSequenceObjects(self):
        object_mapping = {
            MAPPING_ROOT_CLASS : TestObject,
        }

        source_list = json.loads('''[
            {
                "value" : "0"
            },
            {
                "value" : "1"
            },
            {
                "value" : "2"
            }
        ]''')

        builder = ObjectBuilder(mapping=object_mapping)
        result = builder.build(source_list)

        self.assertIsInstance(result, list)
        self.assertEqual(len(result), 3)

        for i in range(3):
            self.assertIsInstance(result[i], TestObject)
            self.assertEqual(result[i].value, str(i))

    # jsonとクラスを相互に変換する
    def testDecodeToEncode(self):
        # jsonのキーとクラスをマッピングするdict
        object_mapping = {
            MAPPING_ROOT_CLASS : TestObject,
            'nested' : NestedObject
        }
        # 生成元のソース - 比較の都合のため一行で
        string_data = '{"value1": "string value 1", "nested": {"value": "nested value 1"}}'
        dict_data = json.loads(string_data)

        builder = ObjectBuilder(mapping=object_mapping)
        result = builder.build(dict_data)
        dump_string = json.dumps(result, default=default_method)
        self.assertEqual(dump_string, string_data)

        # 再変換しても結果が同じこと
        result = builder.build(json.loads(dump_string))
        self.assertEqual(result.value1, 'string value 1')
        self.assertIsInstance(result.nested, NestedObject)
        self.assertEqual(result.nested.value, 'nested value 1')

    # 型変換にも使ってみる - jsonでstringな値をintに
    def testPropertyTypeConvert(self):
        mapping = {
            MAPPING_ROOT_CLASS : TestObject,
            'value' : int,
        }
        source_dict = {
            'value' : '1024',
        }

        builder = ObjectBuilder(mapping=mapping)
        result = builder.build(source_dict)
        self.assertEqual(result.value, 1024)

if __name__ == '__main__':
    unittest.main(verbosity=2)
```

</div></details>
[インスタンスをjsonに変換する](https://qiita.com/Akio-1978/items/0bb4075ea05a8b4d53cc)方法と合せて、相互変換のテストもばっちり動いています。
ちょっと変わった使い方として、json中でstringになっているデータをintに変換するパターンなんかも試しています。[^factory_method]
コードが長いので、githubにでも上げた方が解りやすいのかもしれません。それについては後日検討ということで。

## 使い道とか
jsonとクラスインスタンスの相互変換が可能になったので、jsonで受け取ったデータをクラスのメソッドで処理して、再びjsonで返すといったことができるようになりました。
また、DynamoDBの検索結果を処理するのにもいいかもしれません。[^dynamodb_calc]

[^constructor]: ここで定義したクラスでは`__init__`が省略されていますが、（selfを除いた）引数なしで呼び出せる`__init__`が必要です。
[^factory_method]: マッピングの値として指定するのはクラスでなくてもCallableな存在なら問題ないので、状況に応じて別のクラスを返すような、ファクトリ的なメソッドを渡しても良いと思います（試してないけど）。
[^init_tips]: jsonに存在が必須ではないキーがある場合、そのキーが存在しないとアトリビュートが存在せず、アクセスするとAttributeErrorになります。頻繁にアクセスするアトリビュートに関しては、クラスの`__init__`で適当な初期値を作成してあげた方がいいと思います。
[^known_bugs]: 実はこの実装には小さなバグがあって、マッピングに定義されていないdictの中は、マッピングに定義されたキーがあってもマッピングが適用されません。しかし、これが問題になるマッピングはあまり奇麗ではないと思うので、そのままです。また、マッピング前のオブジェクトと参照を共有してしまうので、混乱も起きるかもしれません。
[^dynamodb_calc]: DynamoDBの検索結果を止むを得ず集計しなきゃいけない場合とか、集計メソッドを持つクラスに読み込んであげる。なんてことができるかも。
[^root_class_mapping]: ObjectBuilderの生成時に生成するクラスを明示したいと思って、最上位のクラスはコンストラクタの引数で別に指定する方法も考えましたが、単純にマッピングが混乱しそうなので、ひとつのdictにまとめました。