hirano00o's blog

技術的な記録、日常の記録

JSONをPythonのオブジェクトに変換する個人的最適解

PythonでhttpでAPIの送受信を行うとどうしてもJSON↔︎オブジェクトの変換が必要になってくる。オブジェクトへの変換は何通りか方法があるが、それぞれのPros/Consを挙げる。

基本的にはdataclasses-jsonを利用するのが良い。書き捨てのコードの場合は、他の方法が無駄がなく良い。

方法1: 力技

1つ目は、requests.get()のレスポンスをdictで取得した際に、力技で各フィールドに直接アクセスする方法。単純でわかりやすいが、複雑なJSONには向かない。

from dataclasses import dataclass

from datetime import date, datetime

@dataclass
class Foo():
    foo_str: str
    foo_int: int
    foo_date: date
    foo_list: list[str]


# 例えば下記実施後
# response = requests.get("https://...")
# input_dict = response.json()
input_dict = {"foo_str": "foofoo", "foo_int": 10, "foo_date": "2024-12-31","foo_list":["foo1", "foo2", "foo3"]}

try:
    f = Foo(
        foo_str=input_dict["foo_str"],
        foo_int=input_dict["foo_int"],
        foo_date=datetime.strptime(input_dict["foo_date"], "%Y-%m-%d"),
        foo_list=input_dict["foo_list"],
    )
    print(f) # Foo(foo_str='foofoo', foo_int=10, foo_date=datetime.datetime(2024, 12, 31, 0, 0), foo_list=['foo1', 'foo2', 'foo3'])
except Exception as e:
    print("because: ", e)

Pros

  • 単純で初心者にもわかりやすい。
  • フィールドが少なく、コードを書き捨てる場合は向いている。

Cons

  • 変換するフィールドが多い、ネストが深いと書く量が増える。
  • datetimeなどJSONには無い型は変換を実装する必要がある。

方法2: json.loads()object_hookでコンストラクタを利用する

2つ目は、json.loads()object_hookを利用する方法。JSONのフィールドが増えてもコードの増加量は方法1よりは低い。一方でJSONに存在しない型への変換はobject_hookに指定する関数内で実装する必要がある。

import re
import json
from dataclasses import dataclass

from datetime import date, datetime

r = re.compile(r"\d{4}-\d{2}-\d{2}")


@dataclass
class Foo:
    foo_str: str
    foo_int: int
    foo_date: date
    foo_list: list[str]


def hook(s):
    d = s.get("foo_date")
    if d is not None and r.match(d):
        s["foo_date"] = datetime.strptime(d, "%Y-%m-%d")
    return Foo(**s)


# 例えば下記実施後
# response = requests.get("https://...")
# input_str = response.text したと仮定
input_str = '{"foo_str": "foofoo", "foo_int": 10, "foo_date": "2024-12-31", "foo_list":["foo1", "foo2", "foo3"]}'
try:
    f = json.loads(input_str, object_hook=hook)
    print(f)  # Foo(foo_str='foofoo', foo_int=10, foo_date=datetime.datetime(2024, 12, 31, 0, 0), foo_list=['foo1', 'foo2', 'foo3'])
    print(type(f.foo_date))  # <class 'datetime.datetime'>
except Exception as e:
    print("because: ", e)

Pros

  • 変換するフィールドが多くなっても、方法1の力技よりも書く量が少ない。
  • 変換したい型がstrintboolだけなら、hook関数は作成せず、object_hook=lambda d: Foo(**d)とさらに簡潔に書ける。

Cons

  • datetimeなどstrintbool以外の型に変換したい場合はhookでフィールドを指定して変換する必要がある。
    • datetimeの場合はフォーマットが決まっている必要がある。

方法3: json.loads()object_hookSimpleNamespaceを利用する

方法3は、SimpleNamespaceを利用して方法2よりもコード量を減らした書き方。方法2と同じデメリットはあるが、フィールドやネストが増えてもコードを修正する必要がない(JSONに存在する型以外へ変換する必要がない場合)。

import json
import re
from datetime import datetime
from types import SimpleNamespace

r = re.compile(r"\d{4}-\d{2}-\d{2}")


def hook(s):
    d = s.get("foo_date")
    if d is not None and r.match(d):
        s["foo_date"] = datetime.strptime(d, "%Y-%m-%d")
    return SimpleNamespace(**s)


# 例えば下記実施後
# response = requests.get("https://...")
# input_str = response.text したと仮定
input_str = '{"foo_str": "foofoo", "foo_int": 10, "foo_date": "2024-12-31", "foo_list":["foo1", "foo2", "foo3"]}'
try:
    f = json.loads(input_str, object_hook=hook)
    print(f) # namespace(foo_str='foofoo', foo_int=10, foo_date=datetime.datetime(2024, 12, 31, 0, 0), foo_list=['foo1', 'foo2', 'foo3'])
    print(type(f.foo_date))  # <class 'datetime.datetime'>
except Exception as e:
    print("because: ", e)

Pros

  • dataclassを定義する必要がないため、さらに書く量を減らすことができる。

Cons

  • dataclassが無いために、何の変数があるのか不明。
  • オブジェクト変数へのアクセスにIDEによる補完が効かない。

方法4: dataclasses-jsonを利用する

最後はdataclasses-jsonを利用する方法。dataclassの定義は必要だが、定義をしっかりと書けば変換される。コード量は方法2と大して変わらないが、JSONがネストしていても変換される。またJSONのフィールドが特定の条件下で無い場合も、Optionalで型定義することでデフォルト値を埋められる。

from dataclasses import dataclass, field

from datetime import date
from marshmallow import fields, ValidationError

from dataclasses_json import dataclass_json, LetterCase, config
from typing_extensions import Optional


@dataclass_json(letter_case=LetterCase.SNAKE)
@dataclass
class Bar:
    bar_str: str
    bar_date: date = field(metadata=config(mm_field=fields.Date()))
    bar_none: Optional[str] = None


@dataclass_json(letter_case=LetterCase.SNAKE)
@dataclass
class Foo:
    bar: Bar
    foo_str: str
    foo_int: int
    foo_date: date = field(metadata=config(mm_field=fields.Date()))
    foo_list: list[str]
    foo_none: Optional[str] = None


# 例えば下記実施後
# response = requests.get("https://...")
# input_str = response.text したと仮定
input_str = '{"bar":{"bar_str":"barbar", "bar_date": "2025-01-01", "bar_none":"baz"}, "foo_str": "foofoo", "foo_int": 10, "foo_date": "2024-12-31", "foo_list":["bar1", "bar2", "bar3"]}'
try:
    f = Foo.schema().loads(input_str)
    print(f)  # Foo(bar=Bar(bar_str='barbar', bar_date=datetime.date(2025, 1, 1), bar_none='baz'), foo_str='foofoo', foo_int=10, foo_date=datetime.date(2024, 12, 31), foo_list=['bar1', 'bar2', 'bar3'], foo_none=None)
    print(type(f.foo_date))  # <class 'datetime.datetime'>
    print(type(f.bar.bar_date))  # <class 'datetime.datetime'>
except ValidationError as e:
    print("because: ", e)

Pros

  • dataclassで定義した型に、ネストも含めそのまま変換できる。
  • Optionalな変数なのかが一目でわかる。

Cons

  • SimpleNamespaceと比べると書く量は多いので、単純なオブジェクトや書き捨てのコードには向かない。

終わりに

JSONからPythonのオブジェクトに変換する4つの方法とそれぞれのPros/Consを挙げた。いずれの方法も使い所はあるが、プロダクションコードを書くのであればdataclasses-jsonが最適だと考えている。

もし挙げた以外の方法で、もっと良い方法があれば教えていただきたいです。