読者です 読者をやめる 読者になる 読者になる

json モジュールについて

はじめに

この記事はadventerの方のPython Advent Calendar の3日目の記事です。 (qiitaの方にもadvent calendarがあるみたいです)

この記事ではpythonの標準ライブラリの jsonモジュール について書くことにしました。advent caledarで何を書くか少し迷ったのですが、誰も知らない自作のライブラリを紹介するといった記事よりは、pythonを使う誰しもが日常的に使っている何かについて、ちょっとだけ詳しく覗いてみるだったり、ちょっとだけ考えてみるというような記事の方が良いのでは無いかと思いました。なので標準ライブラリの中からjsonモジュールを選んでみました。

about json module

jsonモジュールはJSON形式の文字列表現とpython object間のserialization・deserializationを担うライブラリです。通常、pythonユーザーたちの間であるモジュールがdeserialization・serializationの機能を持つモジュールだと言われた場合には、そのモジュールが以下のようなインターフェイスを持つことを想定していることが多い気がします。

  • deserialization -- ある表現からpython objectへの変換。 load() , loads() が存在
  • serialization -- python objectからある表現への変換。 dump() , dumps() が存在

もちろん今回紹介するjsonモジュールもこのインターフェイスを満たしてますし。他に同様のモジュールとしてmarshal,pickelなどがあります。また、pypiなどにあげられている外部のパッケージでは、yaml(PyYAML)やmsgpack(msgpack-python)なども同様のインターフェイスを備えています。

load()loads() のコスト

ちょっと寄り道をしてみます。

deserializationの機能を持つとして load() , loads() が存在するのですが。両者はそれぞれ引数としてfile like objectを取る関数と文字列を取る関数になっています。

直感的には、 load() の方が引数がfile like objectになっている分、全てを一度にメモリーに読み込まずに処理が進むなど、何かしらの最適化が働いているというような気になるかもしれません。そして両者の使い分けを気にした方が良いと考えたりするかもしれません。さて、ここで、ちょっとjsonモジュールの中を覗いてみましょう。

# docstringは除去
def load(fp, cls=None, object_hook=None, parse_float=None,
        parse_int=None, parse_constant=None, object_pairs_hook=None, **kw):
    return loads(fp.read(),
        cls=cls, object_hook=object_hook,
        parse_float=parse_float, parse_int=parse_int,
        parse_constant=parse_constant, object_pairs_hook=object_pairs_hook, **kw)

jsonモジュールにおいてはそのような考え事は全く無駄な心配でした。 load() は単に fp.read() でまとめて取得した文字列を loads() に渡しているだけです。

deserialization・serializationにあると便利な性質

今度はdeserialization・serializationを行う上であると便利な性質について思いつくことをあげてみたいと思います。

reversible

ところで、deserialization・serializationにあると便利な性質というのはなんでしょう?それはreversibleということかもしれません。reversibleという言葉が適切かどうかわかりませんが、例えばあるdataに対して以下のような条件が成り立っていて欲しいということです。

data == load(dump(data))

このような性質を持つものをとりあえずreversibleと呼ぶことにします。

jsonモジュールはreversibleでしょうか? 答えはある特定のsubsetに対してはイエスという感じになるかもしれません。 そもそもjsonモジュールが標準でサポートしている範囲は以下のような型のものだけという制限はありますが、その範囲内においてはreversibleだと言えると思います。(もちろん極端に大きなintを扱うときなど崩れる可能性もあります)

JSONPython objectの関係(pythonのdocumentより)

JSON Python
object dict
array list
string str
number (int)|int
number (real)|float
true True
false False
null None

uniqueness of dumped expression

別の側面から見てみましょう。jsonのserializationを「あるオブジェクトからその文字列表現を得るためのハッシュ関数」 という風に捉えてみます。例えば、pythonにおいてはdictの値などはhashableなobjectではないため、dictのkeyにすることができません。このような場合にハッシュ化した値をkeyにしたいという場合があるかもしれません。特にネストしたdictなどを文字列化する分には便利に使えるかもしれません。

以下のような実験をしてみましょう。CPythonの実装においてはdictのiterationは順序が保証されているわけではありません。それを雑に表現するためにRandomItemDictというクラスを作っています。何度も同じ処理を違うタイミングで実行された場合にはこのクラスを利用した場合と同様の状態なると思います。

from collections import Counter
import random
import json


class RandomItemDict(dict):
    def items(self):
        return iter(sorted(super(RandomItemDict, self).items(), key=lambda x: random.random()))


def make_data():
    return RandomItemDict({
        "person": RandomItemDict({"name": "foo", "age": 20}),
        "address": RandomItemDict({"foo": "bar"}),
    })

c = Counter()
for _ in range(1000):
    data = make_data()
    c[json.dumps(data)] += 1
print(c.values())
# dict_values([231, 267, 241, 261])

当然ですが json.dumps() した結果は一意になりません。では一意にできないかというとそうではなく sort_keys というオプションがあります。JSON化する前に各dictがkeyによりソート済みになるのであれば、JSON化の結果も一意になりそうです。

c = Counter()
for _ in range(1000):
    data = make_data()
    c[json.dumps(data, sort_keys=True)] += 1
print(c.values())
# dict_values([1000])

web apiのrequestなどでたまにpostする値全体のハッシュ値を渡す必要があったりする場合がありますが、その場合にもkeyでソートしてから計算するということはあったりします。同様の感じです。(たまにそうではないapi仕様があったりしてはまるというのがあったりもしますが。例えばある特定の仕様書の表に書かれた順序で連結した際のハッシュ値を渡すなどひどいものがたまにあったりします。)

ordered JSON

JSON化する前にkeyでソートすることで一意性は保証できましたが。他にも気になることはあります。順序です。順序は重要ですね。例えば、web apiのresponseなどは常に期待通りの順序で返ってきた方が気持ちが良いかもしれません。(todo: ココもう少しまともな例を挙げる) JSONに関しては文字列表現に過ぎないのですから出力される順序を調節してあげれば順序を保つことはできそうです。dictの代わりにcollections.OrderedDictなどを使うというのが良さそうです。

from collections import OrderedDict
data = OrderedDict()
data["status"] = "ok"
data["data"] = huge_huge_complex_data
json.dumps(data)  # statusは先頭に出てきて欲しい

逆はどうでしょう?JSON化に関しては文字列表現なので順序を保持することはできました。今度はJSONからobjetへの変換時にも順序を保持して欲しいということは無いでしょうか?defaultの状態で利用した場合にはJSONのオブジェクト部分はdictにマッピングされdictは順序を持たないですから順序が保たれなくなってしまいます。

そのような場合を想定しているのか、 json.load() にはhookが用意されています object_pairs_hook に OrderedDictを渡せば大丈夫です。

json.loads(json_data, object_pairs_hook=OrderedDict)

また、 json.loads() はdefaultの状態で利用した場合には作成済みのdecoderに渡すというような結構涙ぐましいドーピングをしていたりするのですが。

# 作成済みのdefault decoder
_default_decoder = JSONDecoder(object_hook=None, object_pairs_hook=None)

# docstringは除去
def loads(s, encoding=None, cls=None, object_hook=None, parse_float=None,
        parse_int=None, parse_constant=None, object_pairs_hook=None, **kw):
    if (cls is None and object_hook is None and
            parse_int is None and parse_float is None and
            parse_constant is None and object_pairs_hook is None and not kw):
        return _default_decoder.decode(s)
    # 後の処理

object_pairs_hook に OrderedDictを渡す場合には、OrderedDictによるコストの方が大きいようであまり気にしなくて良い感じでした。

個人的な話 pretty printing

個人的な話なのですが。JSON出力する際にインデントがあった方が嬉しいのでpretty printする状態をdefaultにしたいです。 このため自分が作るアプリケーションの上では、特にパフォーマンスなどを気にするような状況ではない場合に、jsonモジュールを直接使わず以下のような定義のモジュールを介して利用するということが多いかもしれません。

from functools import partial
dumps = partial(json.dumps, indent=2, ensure_ascii=False)

# dumps(make_data) の出力
{
  "person": {
    "name": "foo",
    "age": 20
  },
  "address": {
    "foo": "bar"
  }
}

indentを付けたいので indent=2 を日本語などがencodingがされていると読みづらいので ensure_ascii=False を付けています。

対応する型を増やしてみる

先程、pythonのsubsetとJSONに対してのマッピングが既定されていると言いましたが、jsonモジュールを拡張してあげることでサポートする型を増やすことができます。

default引数による拡張

例えば、以下のようにdefault引数に存在しない型の場合のhookを渡してあげる事ができます。

def support_datetime_default(o):
    if isinstance(o, datetime):
        return o.isoformat()
    raise TypeError(repr(o) + " is not JSON serializable")

person = {
    "name": "Foo",
    "age": 20,
    "created_at": datetime(2000, 1, 1)
}

json.dumps(person, default=support_datetime_default)
# {"created_at": "2000-01-01T00:00:00", "age": 20, "name": "Foo"}

もちろん、変換を施すのはserialization側だけなのでreversibleではなくなります。

Decoder, Encoder クラスの拡張

もっとも真面目に考えるならEncoder・Decoderクラスを拡張する方が良いかもしれません。自分で保証されるように定義すればreversibleにすることもできます。例えば同じdatetime型への対応ですが以下のようなコードを書いている人がいました。

# DateTimeEncoder, DateTimeDecoderは自分で定義
obj2 = json.loads(json.dumps(obj, cls=DateTimeEncoder), cls=DateTimeDecoder)
# obj2 == obj

https://gist.github.com/abhinav-upadhyay/5300137

json というmagic methodの是非(寄り道)

また寄り道します。ところでこういうのはどうなんでしょう? JSON化可能なオブジェクトには __json__() というメソッドを実装するという考えです。

class Person(object):
    # .. snip
    def __json__(self):
        return {"name": self.name, "age": self.age}

例えばフレームワークの規約の一部として __json__() というメソッドを持つオブジェクトはserialize可能という規約を設けるとします。 この時は以下のような関数をdefault引数渡すイメージです。

def default(o):
    if hasattr(o, "__json__"):
        return o.__json__()
    raise TypeError(repr(o) + " is not JSON serializable")

dumps = partial(json.dumps, default=default)

これはpythonでよくあるプロトコルを意識した実装です。例えば len() の呼び出しが内部的には __len__() というmagic methodを呼び出すという仕様からの類推です。 StackOverFlowに同じようなことを考えている人もいるようでした。もっとも、おすすめはしないという返答をもらっていますが。

python documentの以下の部分が根拠のようです。(2. Lexical analysis))

* System-defined names. These names are defined by the interpreter and its implementation (including the standard library). Current system names are discussed in the Special method names section and elsewhere. More will likely be defined in future versions of Python. Any use of * names, in any context, that does not follow explicitly documented use, is subject to breakage without warning.

「magic methodはpython本体で使われること前提なところがあるし、普通に json() メソッドでも生やしたら?」 という回答でした。

marshmallow

jsonモジュールの範囲から外れてしまいますが。JSONに対するreversibleなserialization・deserializationを考える上でひとつパッケージを紹介したいと思います。marshmallow というライブラリです。

marshmallowは例えば以下の様にして使われます(ドキュメント から抜粋)。

from datetime import date
from marshmallow import Schema, fields, pprint

class ArtistSchema(Schema):
    name = fields.Str()

class AlbumSchema(Schema):
    title = fields.Str()
    release_date = fields.Date()
    artist = fields.Nested(ArtistSchema)

bowie = dict(name='David Bowie')
album = dict(artist=bowie, title='Hunky Dory', release_date=date(1971, 12, 17))

schema = AlbumSchema()
result = schema.dump(album)
pprint(result.data, indent=2)
# { 'artist': {'name': 'David Bowie'},
#   'release_date': '1971-12-17',
#   'title': 'Hunky Dory'}

marshmallowは一言で言うとschemaライブラリです。schemaライブラリというのは今まであげたserialization・deserializationの機能の他にvalidationの機能が加わったと見ることが出来るかもしれません。marshmallowにおいてもJSONデータとserialization・deserializationという範疇で考えることができます。

marshmallowもまた dumps()loads() というメソッドを持っています。ただしvalidationが含まれた分少しインターフェイスが変わってしまいます。以下のような形でreversibleになるschemaを考えることができます。

# schema は marshmallow.Schemaのサブクラスのインスタンス
obj == schema.load(schema.dump(obj).data).data

load() は dataの他にerrorsでアクセスできるエラーに関する情報を持っているMarshmallowResultを返します(内部的にはcollections.namedtupleです)。

reversibleという性質を考える上で、どの種のフォーマットの文字列がどの種の型に対応するのかのマッピングが悩ましいところだったのですが。schemaを定義するのであれば、その際に指定したフィールドの型によって明示的に指定出来るようになります。

利用例のコードから察するに、marshmallowで定義できるのは複数の型を持つdictオブジェクトとJSONマッピングだけと感じるかもしれません。pythonオブジェクトとJSONとの間でのマッピングを考えることはできないでしょうか?これはschemaに対してのhookを追加することでできます。

例えば先程の例をdictではなくAlbum,Artistという特定のオブジェクトからのマッピングということにしてみます。

from marshmallow import Schema, fields, post_load
from collections import namedtuple

Artist = namedtuple("Artist", "name")
Album = namedtuple("Album", "title release_date artist")


class ArtistSchema(Schema):
    name = fields.Str()

    @post_load
    def lift(self, data):
        return Artist(**data)


class AlbumSchema(Schema):
    title = fields.Str()
    release_date = fields.Date()
    artist = fields.Nested(ArtistSchema)

    @post_load
    def lift(self, data):
        return Album(**data)

すると以下のような形で利用できます。

from datetime import date

bowie = Artist(name='David Bowie')
album = Album(artist=bowie, title='Hunky Dory', release_date=date(1971, 12, 17))

print(album)
# Album(title='Hunky Dory', release_date=datetime.date(1971, 12, 17), artist=Artist(name='David Bowie'))

schema = AlbumSchema()

# reverisble
print(schema.load(schema.dump(album).data).data == album)  # True

もっとも今回の場合は少しズルをしています。reversibleとコメントした箇所でTrueが返っているのは、作成したオブジェクトがnamedtupleなのでtupleの等値性の評価に従って評価されているからというところがあります(通常のオブジェクトならば参照のidを元に評価されるのでFalseになる)。

また、実質常にreversibleである必要があるのかというとそうでもないかもしれません。