DjangoでJSONを使うための一考察 SQLの最適化など

Djangoを利用して、Webサービスのバックエンドを作成したい。フロントエンドとのインターフェースには、JSONを利用したい。

その場合どのような実装としたらよいのか、データアクセスの効率化も含めて考察してみる。

1.モデルの準備

開発のプロが教える標準Django完全解説―Webアプリケーションフレームワーク (デベロッパー・ツール・シリーズ)での事例を参考に、以下のブックマークモデルを事例とする。

1.1 クラス図

  1. ユーザーはURLをブックマークできる。
  2. ユーザーはブックマークしたURLに複数のタグを付与することができる。

bookmark_class

1.2 Django モデルソース

以下のようなDjangoモデルを用意。

from django.db import models

class User(models.Model):
    user_id = models.CharField(max_length=20, db_index=True, unique=True)
    password = models.CharField(max_length=20)

class Page(models.Model):
    url = models.URLField(blank=False, max_length=1024)

class Tag(models.Model):
    name = models.CharField(max_length=100, db_index=True, unique=True)

class Bookmark(models.Model):
    page = models.ForeignKey('Page')
    user = models.ForeignKey('User')
    tags = models.ManyToManyField('Tag',blank=True)    

1.3 データベース

syncdb を実行することにより、sqlite では以下のテーブルが作成される。

テーブル名 モデル名 備考
Bookmark_user User  
Bookmark_page Page  
Bookmark_tag Tag  
Bookmark_bookmark Bookmark  
Bookmark_bookmark_tags Bookmark BookmarkとTagの関係が多対多のため、RDB上では関連テーブルが生成される。

http://sqlitebrowser.org/ でDDLを確認する。

ddl

1.4 テストデータの投入

以降の確認のために、テストデータを作成する。

    u1 = User(user_id='User1',password='pass1')
    u1.save()
    u2 = User(user_id='User2',password='pass2')
    u2.save()
    
    p1 = Page(url='http://google.co.jp')
    p1.save()
    p2 = Page(url='http://yahoo.co.jp')
    p2.save()
    
    t1 = Tag(name='search engine')
    t1.save()
    t2 = Tag(name='portal')
    t2.save()
    
    b1 = Bookmark(page=p1,user=u1)
    b1.save()
    b1.tags.add(t1)
    b1.tags.add(t2)
    b1.save()
    b2 = Bookmark(page=p2,user=u2)
    b2.save()
    b2.tags.add(t1)
    b2.tags.add(t2)
    b2.save()

2.確認方法

Django の shell で動作確認をおこなう。

python manage.py shell

Eclipse + PyDev を使用している場合、プロジェクトのコンテキストメニュー - Django -Shell with django enviroment から、Django Shellを利用できる。

pydev_console

3. Django の Serializer を使ってみる

3.1 Django モデルのメタ情報が出力されてしまう問題

まず、JSONに変換するために、Django のシリアライザー serializers.serialize(書式,オブジェクト) で変換する。

https://docs.djangoproject.com/en/1.7/topics/serialization/
>>> from Bookmark.models import User,Page,Tag,Bookmark
>>> from django.core import serializers
users = User.objects.all()
serializers.serialize("json", users)
'[{"pk": 1, "model": "Bookmark.user", "fields": {"password": "pass1", "user_id": "User1"}}, {"pk": 2, "model": "Bookmark.user", "fields": {"password": "pass2", "user_id": "User2"}}]'

変換された。ただ、見にくいので、出力先に sys.stdout 、インデントに2を指定して、JSONを整形する。

import sys
serializers.serialize("json", users, stream=sys.stdout, indent=2)
[
{
  "pk": 1, 
  "model": "Bookmark.user", 
  "fields": {
    "password": "pass1", 
    "user_id": "User1"
  }
},
{
  "pk": 2, 
  "model": "Bookmark.user", 
  "fields": {
    "password": "pass2", 
    "user_id": "User2"
  }
}
]

上の出力結果を見てわかる通り、pk、model、fields という、Djangoモデルのメタ情報が出力されている。

必要な情報は、fields 以下に設定されている。

Djangoモデルのメタ情報をJSONとしてクライアントとやり取りするのは若干違和感がある。

3.2 引数として渡すオブジェクトが、JSON 配列になっていないとエラーとなってしまう問題

単独のオブジェクトを渡すとエラーとなってしまう。

list で JSON配列にする必要があるようだ。

serializers.serialize("json", users[0], stream=sys.stdout, indent=2)
[Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "E:\virtualenv\gappori\lib\site-packages\django\core\serializers\__init__.py", line 99, in serialize
    s.serialize(queryset, **options)
  File "E:\virtualenv\gappori\lib\site-packages\django\core\serializers\base.py", line 42, in serialize
    for obj in queryset:
TypeError: 'User' object is not iterable

3.3 Pythonの組み込みオブジェクトを渡すとエラーとなる問題

Pythonの組み込みオブジェクト(リスト、タプル、マップ) を渡すとエラーとなる。Django のオブジェクト専用ということか。

>>> serializers.serialize("json", [{"test":"test"}], stream=sys.stdout, indent=2)
[Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "E:\virtualenv\gappori\lib\site-packages\django\core\serializers\__init__.py", line 99, in serialize
    s.serialize(queryset, **options)
  File "E:\virtualenv\gappori\lib\site-packages\django\core\serializers\base.py", line 46, in serialize
    concrete_model = obj._meta.concrete_model
AttributeError: 'dict' object has no attribute '_meta'

3.4 Django モデルにデシリアライズ可能という利点

メタ情報を持っているので、上記3.1の違和感を気にしなければ、JSONをDjangoのモデルにデシリアライズできるという利点がある。

users_json = serializers.serialize("json", users)
print users_json
[{"pk": 1, "model": "Bookmark.user", "fields": {"password": "pass1", "user_id": "User1"}}, {"pk": 2, "model": "Bookmark.user", "fields": {"password": "pass2", "user_id": "User2"}}]
>>> deserialized_list = serializers.deserialize("json", users_json)
>>> for deserialized in deserialized_list:
...    print deserialized.object.user_id
...    
User1
User2

3.5 Django の Serializer まとめ

Djangoのシリアライザーのみを利用して、JSONを取り扱うのは、無理がある。

しかし、Django モデルを JSONに変換するには有用。

デシリアライズができるのは魅力だが、利用できる箇所が限定されそうだ。

4. json ライブラリの利用

http://docs.python.jp/2/library/json.html

json.dump は、出力先を指定、json.dumps は、JSON文字列に変換する。

4.1 Python 組み込みオブジェクトの変換

Djangoのシリアライザーでは、エラーだったが、Pythonの組み込みオブジェクトは当然変換される。

>>> json.dump([{"test":"test"}],sys.stdout,indent=2)
[
  {
    "test": "test"
  }
]

4.2 Django モデルの変換

Djangoモデルはシリアライズできない。まぁ当然といえば当然か。

>>> users = User.objects.all()
>>> json.dumps(users)
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "E:\Programs\Python27\Lib\json\__init__.py", line 243, in dumps
    return _default_encoder.encode(obj)
  File "E:\Programs\Python27\Lib\json\encoder.py", line 207, in encode
    chunks = self.iterencode(o, _one_shot=True)
  File "E:\Programs\Python27\Lib\json\encoder.py", line 270, in iterencode
    return _iterencode(o, 0)
  File "E:\Programs\Python27\Lib\json\encoder.py", line 184, in default
    raise TypeError(repr(o) + " is not JSON serializable")
TypeError: [, ] is not JSON serializable

5.DjangoモデルをPython組み込み型に変換する

Djanogoのモデルを、Python組み込み型に変換した後に、json ライブラリによるシリアライズを試みる。

5.1 手作業で辞書化

素直に辞書を返す関数を実装する。ただ、項目が多かったりすると項目数分辞書のエントリを記述する必要がありそもそも面倒、さらに、変更が入った時に変更を漏らしそう。

class User(models.Model):
    user_id = models.CharField(max_length=20, db_index=True, unique=True)
    password = models.CharField(max_length=20)
    
    def to_dict(self):
        return {'user_id':self.user_id,
                'password':self.password}

結果がコレクションの場合に利用するには、リスト内包表記で to_dict() を呼び出し、Djangoモデル -> Python 辞書 に変換したリストを渡せば、意図した結果にはなる。

from Bookmark.models import User
import json
users = User.object.all()
json.dumps([x.to_dict() for x in users])
'[{"password": "pass1", "user_id": "User1"}, {"password": "pass2", "user_id": "User2"}]'

5.1.1 JSONEncoder を拡張

JSONEncoder を拡張すれば、いちいち、リスト内包表記 にしたり、to_dict() を呼び出したりする必要はなくなる。

http://docs.python.jp/2/library/json.html

http://d.hatena.ne.jp/shobonengine/20120119/1326949993

import json
from django.db import models as django_models
from django.db.models.query import QuerySet
class MyJsonEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, django_models.Model) and hasattr(obj, 'to_dict'):
            return obj.to_dict()
        if isinstance(obj, QuerySet):
            return list(obj)
        json.JSONEncoder.default(self, obj)

エンコーダーにより内部的に呼び出されるので、to_dict() を明示的に呼び出す必要がなくなる。

cls=MyJsonEncoder の記述がわずらわしいが、関数化してしまえばよさそうだ。

>>> from Bookmark.models import User,MyJsonEncoder
>>> import json
>>> users = User.objects.all()
>>> json.dumps(users, cls=MyJsonEncoder)
'[{"password": "pass1", "user_id": "User1"}, {"password": "pass2", "user_id": "User2"}]'

5.2 model_to_dict を使う

上記5.1 で Djangoオブジェクトを辞書化する、to_dict() 関数の実装が面倒だと書いた。

django.forms.models の model_to_dict を使うと、Django モデルの辞書化を行うことができる。

これで、項目をいちいち辞書化するわずらわしさから解放される。

from django.db import models
from django.forms.models import model_to_dict

class User(models.Model):
    user_id = models.CharField(max_length=20, db_index=True, unique=True)
    password = models.CharField(max_length=20)
    
    def to_dict(self):
        return model_to_dict(self)

結果こうなる。Django が自動生成する、id 属性が出力されるが、これは望ましい結果ではある。

>>> from Bookmark.models import User,MyJsonEncoder
>>> import json
>>> users = User.objects.all()
>>> json.dumps(users, cls=MyJsonEncoder)
'[{"password": "pass1", "user_id": "User1", "id": 1}, {"password": "pass2", "user_id": "User2", "id": 2}]'

5.3 values() を使う

実は、QuerySetに対して、vlaues() を利用することで、上記model_to_dict を使用したのと同様に、結果をDjango モデルではなく辞書で取得することができる。

>>> User.objects.all().values()
[{'password': u'pass1', 'user_id': u'User1', u'id': 1}, {'password': u'pass2', 'user_id': u'User2', u'id': 2}]

5.4 json ライブラリのまとめ

実際にはこちらを利用することになりそうだ。JSONEncoder と、Django の model_to_dict を利用することで、いい感じにJSONへシリアライズできそうだ。

6.外部キーによる結合

上記までは、単純な Django モデルの場合。外部キーを持つモデルの場合、若干事情が異なる。

まず、どのようなSQLが生成されるのかと、効率的にオブジェクトを取得する方法を見てみる。

6.1 発行されるSQLの確認

外部キーを持つテーブルを取得するメソッドがどんなSQLを出力するのかは、query で参照できる。all() で全件取得するSQLでは、ベーステーブルのみフェッチし、外部キーは解決されていない(JOINされていない)ことがわかる。

print Bookmark.objects.all().query
SELECT "Bookmark_bookmark"."id", "Bookmark_bookmark"."page_id", "Bookmark_bookmark"."user_id" FROM "Bookmark_bookmark"

ただ、取得した結果に対して、値を参照すると、そのタイミングで外部キーによる参照が解決される。どのようなSQLが発行されるのか確認してみる。

http://www.dabapps.com/blog/logging-sql-queries-django-13/

発行されるSQLをコンソールにロギングするための準備。

>>> import logging
>>> l = logging.getLogger('django.db.backends')
>>> l.setLevel(logging.DEBUG)
>>> l.addHandler(logging.StreamHandler())

コード上、all() で全件取得するSQLを発行しているように見えるのだが、外部キーは外部キー経由の値参照時に解決され、ループの中でSQLが発行されているのが、発行されたSQLを見るとわかる(遅延評価)。

>>> for b in Bookmark.objects.all():
...    print b.page.url
...    
http://google.co.jp
http://yahoo.co.jp
(0.000) SELECT "Bookmark_bookmark"."id", "Bookmark_bookmark"."page_id", "Bookmark_bookmark"."user_id" FROM "Bookmark_bookmark"; args=()
(0.000) SELECT "Bookmark_page"."id", "Bookmark_page"."url" FROM "Bookmark_page" WHERE "Bookmark_page"."id" = 1 ; args=(1,)
(0.004) SELECT "Bookmark_page"."id", "Bookmark_page"."url" FROM "Bookmark_page" WHERE "Bookmark_page"."id" = 2 ; args=(2,)

6.2 SQLの最適化

上記のSQLでは、all()の件数分、ループ内部で外部キー経由の値を参照するたびにSQLが発行されてしまう。

http://www.neustar.biz/blog/optimizing-django-queries

select_related() を利用すると、INNER JOIN したSQLが発行されるため、一回のDBアクセスですむ

以下は、外部キー page を同時に取得する例

>>> print Bookmark.objects.all().select_related('page').query
SELECT "Bookmark_bookmark"."id", "Bookmark_bookmark"."page_id", "Bookmark_bookmark"."user_id", "Bookmark_page"."id", "Bookmark_page"."url" FROM "Bookmark_bookmark" INNER JOIN "Bookmark_page" ON ("Bookmark_bookmark"."page_id" = "Bookmark_page"."id")

これによって、この例では、1回のSQL発行で同様の結果が取得できるようになっているのがわかる。

>>> for b in Bookmark.objects.all().select_related('page'):
...    print b.page.url
...    
http://google.co.jp
http://yahoo.co.jp
(0.000) SELECT "Bookmark_bookmark"."id", "Bookmark_bookmark"."page_id", "Bookmark_bookmark"."user_id", "Bookmark_page"."id", "Bookmark_page"."url" FROM "Bookmark_bookmark" INNER JOIN "Bookmark_page" ON ("Bookmark_bookmark"."page_id" = "Bookmark_page"."id"); args=()

6.3 JSON化

では、この外部キーを解決した状態のJSONをどうやって作成するか。

values() で辞書化する場合、内部的には INNER JOIN されて外部キーによる参照は解決されているのだが、辞書化されるのは、あくまで Bookmark自体であり、外部キー経由で解決されるオブジェクト(Page、Tag)は含まれていない。

>>> Bookmark.objects.all().select_related('page').values()
[{'user_id': 1, 'page_id': 1, u'id': 1}, {'user_id': 2, 'page_id': 2, u'id': 2}]
(0.000) SELECT "Bookmark_bookmark"."id", "Bookmark_bookmark"."page_id", "Bookmark_bookmark"."user_id" FROM "Bookmark_bookmark" LIMIT 21; args=()

model_to_dict() を実装してみるとどうか。

class Bookmark(models.Model):
    page = models.ForeignKey('Page')
    user = models.ForeignKey('User')
    tags = models.ManyToManyField('Tag',blank=True)    
    
    def to_dict(self):
        return model_to_dict(self)    

tag の外部キーも表示されるようになったが、外部キーからオブジェクトは解決されない。

>>> for b in Bookmark.objects.all().select_related('page'):
...    print json.dumps(b, cls=MyJsonEncoder)
...    
{"user": 1, "tags": [1, 2], "id": 1, "page": 1}
{"user": 2, "tags": [1, 2], "id": 2, "page": 2}

外部キーを含むモデル(Bookmark)に対しては、上記5.1 のように手作業で辞書を作成するのが妥当か。外部キーにより参照される側は、model_to_dict を実装しておく感じか。

class Page(models.Model):
    url = models.URLField(blank=False, max_length=1024)
    def to_dict(self):
        return model_to_dict(self)    

class Tag(models.Model):
    name = models.CharField(max_length=100, db_index=True, unique=True)
    def to_dict(self):
        return model_to_dict(self)    
    
class Bookmark(models.Model):
    page = models.ForeignKey('Page')
    user = models.ForeignKey('User')
    tags = models.ManyToManyField('Tag',blank=True)    
    
    def to_dict(self):
        #return model_to_dict(self)    
        return {
            'page':self.page.to_dict(),
            'tags':[x.to_dict() for x in self.tags]
        }

これで、実行してみると、わりあい、希望した結果が取得された。

ただ、SQLが5回発行されている。

>>> for b in Bookmark.objects.all():
...    print json.dumps(b, cls=MyJsonEncoder)
...    
{"page": {"url": "http://google.co.jp", "id": 1}, "tags": [{"id": 1, "name": "search engine"}, {"id": 2, "name": "portal"}]}
{"page": {"url": "http://yahoo.co.jp", "id": 2}, "tags": [{"id": 1, "name": "search engine"}, {"id": 2, "name": "portal"}]}
(0.000) SELECT "Bookmark_bookmark"."id", "Bookmark_bookmark"."page_id", "Bookmark_bookmark"."user_id" FROM "Bookmark_bookmark"; args=()
(0.000) SELECT "Bookmark_page"."id", "Bookmark_page"."url" FROM "Bookmark_page" WHERE "Bookmark_page"."id" = 1 ; args=(1,)
(0.000) SELECT "Bookmark_tag"."id", "Bookmark_tag"."name" FROM "Bookmark_tag" INNER JOIN "Bookmark_bookmark_tags" ON ("Bookmark_tag"."id" = "Bookmark_bookmark_tags"."tag_id") WHERE "Bookmark_bookmark_tags"."bookmark_id" = 1 ; args=(1,)
(0.000) SELECT "Bookmark_page"."id", "Bookmark_page"."url" FROM "Bookmark_page" WHERE "Bookmark_page"."id" = 2 ; args=(2,)
(0.000) SELECT "Bookmark_tag"."id", "Bookmark_tag"."name" FROM "Bookmark_tag" INNER JOIN "Bookmark_bookmark_tags" ON ("Bookmark_tag"."id" = "Bookmark_bookmark_tags"."tag_id") WHERE "Bookmark_bookmark_tags"."bookmark_id" = 2 ; args=(2,)

select_related() を指定すると、外部キーの解決が削減できる。5回のSQL発行が3回に削減された。

ただし、このテクニックは、MenyToMenyには(今回の例では、Bookmark と Tag)利用できないようだ。

http://www.neustar.biz/blog/optimizing-django-queries

>>> for b in Bookmark.objects.all().select_related('page'):
...    print json.dumps(b, cls=MyJsonEncoder, indent=2)
...    
{
  "page": {
    "url": "http://google.co.jp", 
    "id": 1
  }, 
  "tags": [
    {
      "id": 1, 
      "name": "search engine"
    }, 
    {
      "id": 2, 
      "name": "portal"
    }
  ]
}
{
  "page": {
    "url": "http://yahoo.co.jp", 
    "id": 2
  }, 
  "tags": [
    {
      "id": 1, 
      "name": "search engine"
    }, 
    {
      "id": 2, 
      "name": "portal"
    }
  ]
}
(0.000) SELECT "Bookmark_bookmark"."id", "Bookmark_bookmark"."page_id", "Bookmark_bookmark"."user_id", "Bookmark_page"."id", "Bookmark_page"."url" FROM "Bookmark_bookmark" INNER JOIN "Bookmark_page" ON ("Bookmark_bookmark"."page_id" = "Bookmark_page"."id"); args=()
(0.000) SELECT "Bookmark_tag"."id", "Bookmark_tag"."name" FROM "Bookmark_tag" INNER JOIN "Bookmark_bookmark_tags" ON ("Bookmark_tag"."id" = "Bookmark_bookmark_tags"."tag_id") WHERE "Bookmark_bookmark_tags"."bookmark_id" = 1 ; args=(1,)
(0.000) SELECT "Bookmark_tag"."id", "Bookmark_tag"."name" FROM "Bookmark_tag" INNER JOIN "Bookmark_bookmark_tags" ON ("Bookmark_tag"."id" = "Bookmark_bookmark_tags"."tag_id") WHERE "Bookmark_bookmark_tags"."bookmark_id" = 2 ; args=(2,)

7.まとめ

  • JSONへのシリアライズでは、json ライブラリを利用する。
  • Djangoモデルをシリアライズするには、JSONEncoder を拡張する。
  • Djaong モデルの辞書化には、Djangoの model_to_dict() を使用する。
  • 外部キーを持つDjangoモデルの辞書化は手作業で実施。
  • 外部キーを持つDjangoモデルのquerydでは、select_related() を利用することでSQLの発行を削減できる。

デシリアライズについては、また今度考える。