イントロ

PythonにはJSONをencode/decodeするライブラリが標準添付ですが、外部ライブラリを使うとより便利かもしれません。

きっかけ

JSONでやり取りするWeb APIをPythonで書いていて、ふと思いました。「辞書型だけでなく、オブジェクトも良い感じでJSONにエンコードしてくれるんだろうか?」

正規化や適切な制約が為されていないDBのレコードを、プログラム内で厳密に扱うためのマッパーオブジェクトを定義しています。

このオブジェクトに辞書変換のメソッドを定義したり、辞書ライクな挙動を定義するのではなく、そのままJSONへパースしてくれれば便利ではないか、と考えたのがそもそものモチベーションです。

検証

例えば、以下のようなオブジェクトがあります。

class Customer:
    def __init__(self, company: str, last_name: str, first_name: str) -> None:
        self._company = company
        self._last_name = last_name
        self._firsrt_name = first_name

    @property
    def company(self) -> str:
        return self._company.strip()

    @property
    def full_name(self) -> str:
        ' '.join([self._last_name.strip(),
                  self._first_name.strip()])

上記の定義であれば「型」として必要十分に完結しているため、期待するJSONは辞書型でなくとも生成できそうです。

customer = Customer("株式会社JSON", "山田", "太郎")

# これが出来るなら
customer.company  # 株式会社JSON
customer.full_name  # 山田 太郎

# こうならないか?
json.dumps(customer)  # {"company": "株式会社JSON", "full_name": "山田 太郎"}

フレームワークはStarletteを利用しているため、UJSONResponseを利用して確認してみました。

from starlette.applications import Starlette
from starlette.responses import UJSONResponse


app = Starlette()

@app.route("/customer", methods=["GET"])
def customer(self):
    customer = Customer("株式会社JSON", "山田", "太郎")
    return UJSONResponse(customer)
curl http://localhost:9999/customer
{"company":"株式会社JSON","full_name":"山田 太郎"}

期待どおり、オブジェクトのプロパティをJSONのkey/valueへマッピングしてくれていますね。結果オーライでこのまま書き進めていたものの、新たな疑問がわきました。「どの層がこの変換をやってくれているんだろう?」

調査

最初は、Starletteが何らかの処理をしてくれているのだろうかと推測し、コードを読んでみましたが特殊なことはしていません。

https://github.com/encode/starlette/blob/master/starlette/responses.py

次に、上で定義したオブジェクトを標準ライブラリの json モジュールでシリアライズしてみます。

import json


customer = Customer("株式会社JSON", "山田", "太郎")
json.dumps(customer)
Traceback (most recent call last):
  File "<input>", line 1, in <module>
    json.dumps(customer)
  File "/usr/lib/python3.7/json/__init__.py", line 231, in dumps
    return _default_encoder.encode(obj)
  File "/usr/lib/python3.7/json/encoder.py", line 199, in encode
    chunks = self.iterencode(o, _one_shot=True)
  File "/usr/lib/python3.7/json/encoder.py", line 257, in iterencode
    return _iterencode(o, 0)
  File "/usr/lib/python3.7/json/encoder.py", line 179, in default
    raise TypeError(f'Object of type {o.__class__.__name__} '
TypeError: Object of type Customer is not JSON serializable

オブジェクトはJSONへシリアライズ可能ではない、と言われてエラーになりました。ではこれを ujson モジュールでシリアライズしてみます。

import ujson


customer = Customer("株式会社JSON", "山田", "太郎")
ujson.dumps(customer)
# => '{"company":"株式会社JSON","full_name":"山田 太郎"}'

無事JSONへシリアライズされました。この結果から、オブジェクトをJSONへシリアライズしてくれているのは、ujsonモジュールである事が分かります。ujsonがどのようにオブジェクトをシリアライズしているかを調べようとしましたが、このモジュールはC/C++で書かれており、残念ながらそこまで読み解けませんでした。

HTTPレスポンスでJSONを返すからといって、内部処理も常に辞書型で扱ったり、型から構造が自明なオブジェクトへ、辞書型に変換するメソッドを都度定義するのが煩雑なケースもあると思います。

そんな時には ujson でオブジェクトをJSONシリアライズさせるのも、有力な手段になるのではないでしょうか。

注意点

オブジェクトをJSONにシリアライズしてくれて、かつ、高速性も謳っている ujson ですが、利用するにあたって1つ注意点があります。

それは、 プロパティ参照時にエラーが発生した場合、その要素は無視される という点です。

import ujson


class User:
   def __init__(self, nickname: str, email: str) -> None:
       self._email = email
       self._nickname = nickname

   @property
   def email(self) -> str:
       return self._email

   @property
   def nickname(self) -> str:
       return self._nicname  # <- typo!

   @property
   def implicit_property(self):
       raise Exception  # <- Error!


user = User("panther-king", "[email protected]")
ujson.serialize(user)
# => '{"email":"[email protected]"}'

nicknameのtypoはエディターやIDEのサポートで事前に気づけそうですが、明示的にエラーを投げているプロパティも無視されてしまうというのは、Python界隈では意外な挙動だと思います。(Explicit is better than implicit.)

ドキュメントを読む限りでは、この挙動を切り替えるオプションも無さそうなので、ユニットテストでカバーといったところでしょうか。

まとめ

ujson モジュールは、標準ライブラリの json モジュールと異なり、オブジェクトをJSONへシリアライズすることが可能です。

これを利用することで、自前定義の型をそのままJSONへマッピングできるため、より宣言的なコードが書けそうです。

ただし、オブジェクトの要素参照時にエラーが発生すると、その要素は無かったものとみなされてしまうため、テストを十分に書きましょう。