概要
jsonを読み込んで好きなクラスに変換する、汎用的な方法を考えて、コードを書いてみました。こちらの記事の内容と合せて、jsonと任意のクラスインスタンスを相互変換できます。
-
やりたいこと
- pythonでjsonを扱うとき、
json.load
はdictやlistを返す。 - できれば、
obj['valuekey']
とかdictの形より、obj.valuekey
とかアトリビュートとしてアクセスしたい。 - そのまま任意のクラスに割り当てて、データと振舞いを持つオブジェクトを組み立てたい。
- pythonでjsonを扱うとき、
-
やったこと
-
json.load
が返したdictまたはlistを、任意のルールに従って変換するコードを書いた。
-
設計
loadした後のdictに対して処理する
json.load
で使用されるJsonDecoderを改造してなんとかならないかと頑張ってみましたが、どうにもうまく行かないので、一度json.load
を通して、生成されたdict(またはlist)を分解して行く方針を取ります。
jsonのキー名をクラスと結びつける
どんなクラスを生成すればいいのか、これはアプリケーションの都合によるので自分でマッピングを生成します。以下のようなイメージです。
例えばこんなjsonを…
{
"value1" : "string value 1",
"nested" : {
"value" : "nested value 1"
}
}
こんなクラスのインスタンスにしたいと思います。1
# テスト用クラスその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>
というキーワードを特別扱いにしてそれに充てます。2
object_mapping = {
'<root>' : TestObject
'nested' : NestedObject
}
これらを利用して、以下のような使い方で変換を行うクラスを作ってみます。
builder = ObjectBuilder(root_class=TestObject, mapping=object_mapping)
result = builder.build(dict_data)
実装
こんなコードを書いてみました。
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のリストを生成します。
- また、マッピングに指定されていないキーは、処理中のクラスのアトリビュートとして設定されます。3
- dictやlistがマッピングに指定されていない場合、そのままdictやlistで取り込みます。4
使い方の紹介を兼ねたユニットテスト
長いので折りたたみます。
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)
使い道とか
jsonとクラスインスタンスの相互変換が可能になったので、jsonで受け取ったデータをクラスのメソッドで処理して、再びjsonで返すといったことができるようになりました。
また、DynamoDBの検索結果を処理するのにもいいかもしれません。5
-
ここで定義したクラスでは
__init__
が省略されていますが、(selfを除いた)引数なしで呼び出せる__init__
が必要です。 ↩ -
ObjectBuilderの生成時に生成するクラスを明示したいと思って、最上位のクラスはコンストラクタの引数で別に指定する方法も考えましたが、単純にマッピングが混乱しそうなので、ひとつのdictにまとめました。 ↩
-
jsonに存在が必須ではないキーがある場合、そのキーが存在しないとアトリビュートが存在せず、アクセスするとAttributeErrorになります。頻繁にアクセスするアトリビュートに関しては、クラスの
__init__
で適当な初期値を作成してあげた方がいいと思います。 ↩ -
実はこの実装には小さなバグがあって、マッピングに定義されていないdictの中は、マッピングに定義されたキーがあってもマッピングが適用されません。しかし、これが問題になるマッピングはあまり奇麗ではないと思うので、そのままです。また、マッピング前のオブジェクトと参照を共有してしまうので、混乱も起きるかもしれません。 ↩
-
DynamoDBの検索結果を止むを得ず集計しなきゃいけない場合とか、集計メソッドを持つクラスに読み込んであげる。なんてことができるかも。 ↩