これは TECHSCORE Advent Calendar 2019 の16日目の記事です。
PythonでJSONデータを扱う
最近ではマイクロサービスだなんだと、外部サービスのHTTPのAPIを呼び出し、JSONデータを扱う機会は少なくないと思います。
Pythonではjsonパッケージを利用してdict(辞書)に変換して利用することが多いのではないでしょうか。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
json_data = """{ "name": "taro", "age": 20 }""" user = json.loads(json_data) print(user["name"]) # "taro" print(user["age"]) # 20 # getを使ったアクセス # 第2引数にデフォルト値を指定できる(未指定の場合はNone) print(user.get("name")) # "taro" print(user.get("age")) # 20 |
dictの要素を取得する場合には、getを使用するとキーが存在しなかった場合でもKeyErrorが発生せず、指定の値を返すことができます。なので個人的にはgetを好んで使っています。
階層が深いプロパティにアクセスする場合、以下のようになります。dictがネストしていく構造になっています。
1 2 3 4 5 6 7 8 9 10 11 12 |
json_data = """{ "name": "taro", "age": 20, "home": { "zip_code": "0000000", "city": "Osaka" } }""" user = json.loads(json_data) print(user.get("home").get("zip_code")) # "0000000" print(user.get("home").get("city")) # "Osaka" |
ブラケットにしてもgetにしても、シンプルなデータであれば問題にはなりませんが、階層が深くなると冗長な感じになります。
objectっぽくアクセスしたい
ブラケットやgetを使ったアクセスではなく、オブジェクトと属性のような .(dot) を使用してデータを利用したいです。先ほどの例だと以下のような使い方になります。
1 2 3 4 5 6 |
json_data = """{ ... }""" user = # ...なんか良い感じの処理 print(user.home.zip_code) # "0000000" と表示されて欲しい print(user.home.city) # "Osaka" と表示されて欲しい |
これは実際よくある話っぽく、ググると色々とやり方が出てきます。
例えば、dictを継承して属性が有るかのように振る舞うクラスを定義する方法があります。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class ObjectLike(dict): # __getattr__ は属性がなかった場合に実行される特殊メソッドで、dict.getを利用するようにする __getattr__ = dict.get user = ObjectLike(name="taro", age=20) # dictのサブクラスなので [] や get で要素を取得できる print(user.get("name")) # "taro" print(user.get("age")) # 20 # ちゃんとdot notationができます print(user.name) # "taro" print(user.age) # 20 |
シンプルにアクセスできるようになりましたね。
jsonパッケージと一緒に使う
この ObjectLike をJSONの読み取りの際にも利用します。
json.loads には object_hookという引数があり、JSONのObjectを処理する際のフックを仕込むことができるようになっています。dict を受け取って何らかの値を返す関数であれば良いので ObjectLike をそのまま使用することができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
import json class ObjectLike(dict): __getattr__ = dict.get json_data = """{ "name": "taro", "age": 20, "home": { "zip_code": "0000000", "city": "Osaka" } }""" user = json.loads(json_data, object_hook=ObjectLike) print(type(user)) # <class '__main__.ObjectLike'> print(user.name) # "taro" print(user.home.city) # "Osaka" print(user.home.prefecture) # None (存在しないキーなので) |
良い感じになってきました。
JSON側のCamelCaseに対応する
JSONのキー名では CamelCase (あるいはmixedCase)になっていることもままあります。一方で、Pythonのコーディングにおいて属性名は snake_case が推奨されています(関数や変数の名前 — pep8-ja 1.0 ドキュメント)。
ObjectLike の属性としては snake_case でアクセスできるようにしたいですね。属性アクセスの際に都度 case の確認をするのは嬉しくないので、JSONの処理時にキーを全て snake_case に変換するようにしてみます。実装は雑ですが、こんな感じになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
import json import re # 正規表現を使用するので必要です class ObjectLike(dict): __getattr__ = dict.get def camel2snake(word): # 大文字の直前に `_` を付与 # -> 小文字に変換 # -> 先頭の `_` を除去(最初大文字だとついてしまうため) return re.sub("([A-Z])", "_\\1", word).lower().lstrip("_") def c2s_hook(d): # キーをsnake_caseに変換した辞書を作成し、ObjectLikeにする converted = {camel2snake(key): value for key, value in d.items()} return ObjectLike(converted) json_data = """{ "name": "taro", "age": 20, "home": { "zipCode": "0000000", "city": "Osaka" } }""" user = json.loads(json_data, object_hook=c2s_hook) print(user.home.zipCode) # None print(user.home.zip_code) # "0000000" |
元のJSONキーは zipCode ですが、変換したので user.home.zip_code でデータが取得できるようになりました。 逆に user.home.zipCode ではキーが存在しないため None が返ってくるようになっています。
これでけっこう現実的に使える範囲になった気がします。
課題:途中の属性がないとエラーになる
JSONデータをよしなに扱えるようになってきたのですが、まだ解決できていない問題があります。
例えば間違った属性へアクセスをしてしまった場合や、そもそもデータがなかった場合など、チェインしている途中の属性が欠落してしまうとエラーになります。(APIの仕様によっては属性があったりなかったりしますよね?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import json class ObjectLike(dict): __getattr__ = dict.get # homeがなくなってしまった json_data = """{ "name": "taro", "age": 20 }""" user = json.loads(json_data, object_hook=ObjectLike) print(user.home.city) # AttributeError: 'NoneType' object has no attribute 'city' |
これは user.home が None であり None に対して city という属性がないので発生します。
回避策がないことはないのですが…
getのデフォルト値を指定する
ObjectLike が間接的に呼び出している dict.get ですが、デフォルト値が指定されていないので None が返っています。これを空のObjectLikeを返すようにしてみます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
import json class ObjectLike(dict): def __getattr__(self, attr_name): return self.get(attr_name, ObjectLike()) # homeがなくなってしまった json_data = """{ "name": "taro", "age": 20 }""" user = json.loads(json_data, object_hook=ObjectLike) print(user.home.city) # {} |
AttributeError は起きなくなりましたが None は返ってきません。
try/except で囲む
愚直にやるのが一番!?とも思いますが、アクセスするたびに try/except で囲むのはちょっとめんどくさいですね。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import json class ObjectLike(dict): __getattr__ = dict.get # homeがなくなってしまった json_data = """{ "name": "taro", "age": 20 }""" user = json.loads(json_data, object_hook=ObjectLike) try: city = user.home.city except AttributeError: city = None print(city) # None |
try/except で囲む 2
途中の属性がないのでエラーになるというのは構造が想定したものと異なる状況なのでそのままエラーとして扱えた方が良いでしょう。
またdict.getのデフォルト値はNoneのままとして or を使ったデフォルト値を指定してみます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
import json class ObjectLike(dict): __getattr__ = dict.get # homeがなくなってしまった json_data = """{ "name": "taro", "age": 20 }""" user = json.loads(json_data, object_hook=ObjectLike) try: # try の中で必要なデータを取るようにする age = user.age or -1 # ageがNoneなどの場合のデフォルト値 city = user.home.city or "" # cityがNoneなどの場合のデフォルト値 except AttributeError: # 何らかの例外処理、ここでは終了する print("想定外のデータ") exit(1) print(city) # ここには到達しません |
Optional Chainingができると良いんだけどなー。
課題:dictの属性とかぶるJSONのキーは利用できない
__getattr__ は属性がない場合に実行されるので、dictに存在する属性はそのまま利用できます。なので、名前がかぶるとJSONのデータが利用できなくなります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
import json class ObjectLike(dict): __getattr__ = dict.get json_data = """{ "name": "taro", "age": 20, "values": [1,2,3,4,5] }""" user = json.loads(json_data, object_hook=ObjectLike) print(user.values) # <built-in method values of ObjectLike object at 0x10cd62f40> print(user.values()) # dict_values(['taro', 20, [1, 2, 3, 4, 5]]) |
例えば values というのは辞書の値だけを取得するメソッドです。名前がかぶるので user.values は配列ではなくメソッドそのものが取得できます。実行すると配列が取得できますが、辞書全体の値の配列です。ここでは[1,2,3,4,5] が欲しいので、意図しない動作になっています。
まとめ
実際のところpython-boxといったライブラリもあるので、そちらを利用した方が良いと思いますが、とりあえずObjectLikeの仕組みでJSONのデータがそこそこ良い感じに扱えるのが確認できました。
また Python3.8 からは TypedDict というType Hintingのための新しい型が導入されているので、それを活用する方法もありそうです。
あと、Goで使える Golang: Convert JSON to Struct のPython版が欲しかったのですが、見当たらなかったのでした。
おわり