👋

ネストした@dataclassの一部を任意のフォーマットでJSONシリアライズする方法

2022/12/17に公開

はじめに

この記事は Xbit Advent Calendar 2022 の17日目の記事です。

株式会社クロスビットは らくしふ を中心に、Rustによる自動シフトスケジューリング機能など魅力的な開発を行っている会社です。

技術スタックとしては GCP, Rails, TypeScript(Vue, Next.js), Python, Rust などを使っています。

以前、Pythonで少し変わったJSON出力を出してみたいシチュエーションがあり、その際取った方法を書いておこうと思います。

対象環境

python3.9 で試しています。

Case-1: 普通にasdict()した結果をJSON化したい場合

from dataclasses import dataclass

@dataclass
class User:
    devices: List[Device]

@dataclass
class Device:
    name: str

user01 = User(devices=[Device('iPad'), Device('iPhone')])

user01 をJSONシリアライズしてみます。
自前encoderを書いて、dataclassであれば dataclasses.asdict(..) で dict に変換という作戦です。

import json
from dataclasses import asdict, is_dataclass

class MyEncoder(json.JSONEncoder):
    def default(self, obj):
        if is_dataclass(obj):
            return asdict(obj)
        return json.JSONEncoder.default(self, obj)

json_text = json.dumps(user01, cls=MyEncoder, indenxt=2, ensure_ascii=False)
print(json_text)

これはうまくいきます。

{
  "devices": [
    {
      "name": "iPad"
    },
    {
      "name": "iPhone"
    }
  ]
}

Case-2: ネストしている特定のdataclassはシリアライズ方式を変えたい

基本的にはdataclassの asdict() 的なシリアライズを行うが、
to_json() というメソッドを持つdataclassの場合はto_json()を使ってJSONシリアライズしたい」
というケースです。

※JSONシリアライズの挙動確認のため、parent_dataclass.child_dataclass というシンプルなデータ構造だけでなく List[ChildDataclass], Dict[str, ChildDataclass] というフィールドも追加してみました。

@dataclass
class Device:
    name: str
    def to_json(self) -> str:
        return f"Device:({self.name})"

@dataclass
class SecurityToken:
    token: str
    def to_json(self) -> str:
        return "** Invisible value for security reason **"

@dataclass
class SampleValue:
    value: Any
    def to_json(self) -> Any:
        return f"{self.value} // Sample Value"

@dataclass
class User2:
    security_token: SecurityToken
    devices: List[Device]
    metadata: Dict[str, SampleValue]

user2 = User2(
    security_token=SecurityToken("password"),
    devices=[Device("Pixel7")],
    metadata={"xxx": SampleValue("123")},
)

普通にエンコーダーを書くと、こうすれば良さそうじゃないですか?

  class MyEncoder(json.JSONEncoder):
      def default(self, obj):
+         if callable(getattr(obj, "to_json", None)):
+             return obj.to_json()
          if is_dataclass(obj):
              return asdict(obj)
          return json.JSONEncoder.default(self, obj)

でも to_json() はよばれないのです。

期待するJSONはこんなかんじですが、

{
  "security_token": "** Invisible value for security reason **",
  "devices": [
    "Device:(Pixel7)"
  ],
  "metadata": {
    "xxx": "123 // Sample Value"
  }
}

出力されるJSONはこうなっています。

{
  "security_token": {
    "token": "password"
  },
  "devices": [
    {
      "name": "Pixel7"
    }
  ],
  "metadata": {
    "xxx": {
      "value": "123"
    }
  }
}

なぜか?
上位側dataclass、つまりこの場合 User2 に対して dataclasses.asdict() が呼ばれる際、User2オブジェクト内部のネストするフィールドに対しても asdict() が再帰的にかかるっぽいです。

いったん全てdictになるものだから、この分岐の中に処理が進むことはありません....

def default(self, obj):
  ...
  if callable(getattr(obj, "to_json", None)):
    return obj.to_json()
  ...

さてどうしたものでしょう

ボツ案

asdict() には dict_factory というコールバック関数を指定できるらしいので、これを使ってみました。
しかしこのコードも to_json が呼び出されないのです。

def to_my_dict(items: List[Tuple[str, Any]]) -> Dict:
    serialized = {}
    for (k, v) in items:
        if callable(getattr(v, "to_json", None)):
            serialized[k] = v.to_json()
        else:
            serialized[k] = v
    return serialized

class MyEncoder1(json.JSONEncoder):
    def default(self, obj):
        if is_dataclass(obj):
            return asdict(obj, dict_factory=to_my_dict)
        return json.JSONEncoder.default(self, obj)

というのも dict_factoryの引数 (上記コードの実装では to_my_dictのitems) に値が渡ってくる時には、すでにdataclass オブジェクトは dictonaryに変換されているのです...

ということで dict_facotry を使う案は今回は使えそうにありません。

もし別の用途で dict_facotryを使いたい方がいましたら、コードから情報を掴むのがはやいかもしれません。dict_facotryについてはあまりPythonのドキュメントに詳しく書かれていないので。
https://github.com/python/cpython/blob/3.10/Lib/dataclasses.py

現状の暫定ソリューション

もう asdict を使うことはあきらめてこうやりました。

class MyEncoder3(json.JSONEncoder):
    def default(self, obj):
        if callable(getattr(obj, "to_json", None)):
            return obj.to_json()
        if is_dataclass(obj):
            return {k: getattr(obj, k) for (k, _) in obj.__dataclass_fields__.items()}
        return json.JSONEncoder.default(self, obj)

どことなく考慮もれがないか不安がありますが、このサンプルコードにおいてはこれで動いているっぽいです。

言語によっては、「class側に xxx というメソッド または @yyy というアノテーションが指定されているメソッドがあれば、そのメソッドを使ってJSONのシリアライズが呼ばれる」という仕組みのライブラリもあるかと思いますが、Pythonはあんまりそういうのない気がします。

json.JSONEncoder.default の実装で 独自のJSONシリアライズを実現させるという方法は少し低レベルなインターフェースのように思えます。スマートなインターフェースのJSONライブラリをご存知の方がいましたらコメントいただければ嬉しいです。

さいごに

株式会社クロスビットでは、デスクレスワーカーのためのHR管理プラットフォームを開発しています。
一緒に開発を行ってくれる各ポジションのエンジニアを募集中です。

https://x-bit.co.jp/recruit/

https://herp.careers/v1/xbit

https://note.com/xbit_recruit

https://note.com/xbit_recruit/n/na43181382616

参考資料

https://docs.python.org/3/library/dataclasses.html#dataclasses.asdict
https://www.lifewithpython.com/2022/09/python-dataclasses-dict-factory.html

Discussion