Django Rest Framework GISで誰でも簡単RESTful Geo API

※この投稿は、FOSS4G Advent Calendar 2015 - Qiitaの15日目の記事です。


こんにちは、本格派アフロの非本格派プログラマmonomotiです。久しぶりにFOSS4G Advent Calendarに参加します。

はじめにお断りしておきます。当記事には残念ながら何一つ新しい/オリジナルなネタはございません。
便利なジオ系フレームワークのご紹介です。周りにGeoDjangoを使っている人がいなくて、かなり寂しいので、ユーザを増やしてキャッキャしたいなというだけです。
公式ドキュメントに書いてある事ばかりですが、「あちこち英語のドキュメントを見るのがめんどくさい」という方が使い始める切っ掛けになれば幸いです。

さて、今回ご紹介するのは、PythonのWebアプリケーションフレームワークDjangoの地理空間系モジュールであるGeoDjangoと、Django Rest Framework GISを用いた、RESTfulなWeb APIの作り方です。
作成するAPIは、httpクライアントでjsonフォマットのデータを送受信して、サーバの地理空間データの作成・取得・更新・削除を行うものとします。
また、RESTfulとはなんぞや、という件についてはこちら(https://ja.wikipedia.org/wiki/REST)をご覧下さい。

以下、pyenv + virtualenvでpython3.4、データベースはPostgreSQLPostGISがインストール済みという環境を前提に、ご説明します。


GeoDjangoのセットアップと初期データの準備

では、本題のAPI作成の前に、GeoDjangoのチュートリアルの流れに沿って、さっくりとGeoDjangoアプリケーションの作成から初期データの準備までやってしまいます。

Djangoをインストールします。

$ pip install django
>> Collecting django
>>   Using cached Django-1.8.7-py2.py3-none-any.whl
>> Installing collected packages: django
>> Successfully installed django-1.8.7

データベースを作成します。名前はAdvent Calendar 2015ということでac2015にします。

$ createdb -U postgres -E UTF-8 ac2015
$ psql -U postgres -d ac2015 -c "CREATE EXTENSION postgis;"
>> CREATE EXTENSION

適当なディレクトリを作ってそこに移動し、Djangoのプロジェクトを作成します。プロジェクトの名前は何でもよいですが、ここではチュートリアルと同じgeodjangoにします。

$ mkdir blog20151215
$ cd blog20151215
$ django-admin startproject geodjango

このようなディレクトリ・ファイル群が作成されます。

geodjango/
├── geodjango
│    ├── __init__.py
│    ├── settings.py
│    ├── urls.py
│    └── wsgi.py
└── manage.py

geodjangoに移動します。

$ cd geodjango

Webアプリケーションを作成します。ここも名前は何でもよいですが、ac2015にします。

$ python manage.py startapp ac2015

すると一番上のgeodjangoディレクトリ以下、こういうディレクトリ・ファイル群になります。

geodjango/
├── manage.py
├── geodjango
│    ├── __init__.py
│    ├── settings.py
│    ├── urls.py
│    └── wsgi.py
│           
└── ac2015
      ├── __init__.py
      ├── admin.py
      ├── migrations
      │    └── __init__.py
      ├── models.py
      ├── tests.py
      └── views.py

以降、一番上のgeodjangoディレクトリからの相対パスで説明します。

geodjango/settings.pyを開いて、DATABASESとINSTALLED_APPSを次のように修正します。

# geodjango/settings.py

DATABASES = {
    'default': {
        'ENGINE': 'django.contrib.gis.db.backends.postgis',
        'NAME': 'ac2015',
        'USER': 'postgres',
        'HOST':'localhost',        
    }
}

#'django.contrib.gis'と'ac2015'を追加。
INSTALLED_APPS = (
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'django.contrib.gis',
    'ac2015',
)


初期データをインポートします。
チュートリアルのWorldBorderだとMultiPolygonでAPIの説明がちょっと面倒になるので、ここではこんな感じの自作のPointデータを使用します。僕が「行きたい飲食店リスト」です。
gist.github.com



まずはDBとアプリケーション間のデータを仲介する為の、データモデルをac2015/models.pyに定義します。

#ac2015/models.py

from django.contrib.gis.db import models
class IkitaiOmise(models.Model):
    name = models.CharField(max_length=50)
    category = models.CharField(max_length=50,null=False,blank=True,default="")
    geom = models.PointField(srid=4326)

    def __str__(self):
        return self.name

下記コマンドでマイグレーションファイルを作成します。マイグレーションファイルとは、データモデルの変更内容をデータベースに反映させる為の命令を記述するファイルです。
#実行時、pythonのライブラリ(psycopg2等)が無いと怒られるかもしれません。その場合は適宜pipでインストールして下さい。

$ ./manage.py makemigrations
>> Migrations for 'ac2015':
>>  0001_initial.py:
>>    - Create model IkitaiOmise


下記コマンドで、データモデルの変更内容をデータベースに反映させます。これをマイグレーションと言います。

$ ./manage.py migrate
>> Operations to perform:
>>   Synchronize unmigrated apps: messages, staticfiles, gis
>>   Apply all migrations: sessions, ac2015, contenttypes, admin, auth
>> Synchronizing apps without migrations:
>>   Creating tables...
>>     Running deferred SQL...
>>   Installing custom SQL...
>> Running migrations:
>>   Rendering model states... DONE
>>   Applying ac2015.0001_initial... OK
>>   Applying contenttypes.0001_initial... OK
>>   Applying auth.0001_initial... OK
>>   Applying admin.0001_initial... OK
>>   Applying contenttypes.0002_remove_content_type_name... OK
>>   Applying auth.0002_alter_permission_name_max_length... OK
>>   Applying auth.0003_alter_user_email_max_length... OK
>>   Applying auth.0004_alter_user_username_opts... OK
>>   Applying auth.0005_alter_user_last_login_null... OK
>>   Applying auth.0006_require_contenttypes_0002... OK
>>   Applying sessions.0001_initial... OK

インポートするgeojsonファイル(ikitai_omise.json)を適当な場所配置します。どこでも良いのですが、ここではac2015/dataにしておきます。


ac2015ディレクトリの下に、データのインポートをするスクリプトファイルを作成します。ここではチュートリアルと同じく、load.pyという名前にします。

#load.py

import os
from django.contrib.gis.utils import LayerMapping
from ac2015.models import IkitaiOmise

ikitai_omise_mapping = {
    'name' : 'name',
    'category' : 'category',
    'geom' : 'POINT',
}

ikitai_omise_geojson = os.path.abspath(os.path.join(os.path.dirname(__file__), 'data', 'ikitai_omise.json'))

def run(verbose=True):
    lm = LayerMapping(IkitaiOmise, ikitai_omise_geojson, ikitai_omise_mapping,
                      transform=False, encoding='UTF-8')

    lm.save(strict=True, verbose=verbose)


djangoのshellで、上で作成したスクリプトを実行します。これでikitai_omise.jsonの内容がデータベースにインポートされます。

$ ./manage.py shell
>>> from ac2015 import load
>>> load.run()
>>> ...
>>> Saved: インドラディップ INDRADIP
>>> Saved: Buci boccheno
>>> Saved: Pie and Beer PUB ハバ
>>> Saved: トタンにライスカレー
>>> Saved: いちにいさん
>>> Saved: らーめん処さんさん
>>> Saved: (株)ダンディー 二号店
>>> Saved: ベビーフェイスプラネッツ 橿原店
>>> Saved: 葛城ガーデン
>>> Saved: アローム
>>> Saved: 奈良県葛城市當麻 中将堂本舗
>>> Saved: エアーズカフェ岡谷店
>>> Saved: 麺屋 誠和
>>> Saved: Barm's
>>> Saved: 浜勝
>>> Saved: お好み鉄板がるぼ
>>> Saved: 二刀流

このdjango.contrib.gis.utils.LayerMapping、超便利です。もう少し突っ込んだ使い方については、また別の機会にご紹介したいと思います。

シェルを終了します。

>>> exit()
$

Djangoのとても便利な点の一つは、最初からデータ管理画面が用意されているので、何もプログラミングしなくてもデータの入力・編集を行える事です。
先ほどロードしたデータをこのデータ管理画面で確認してみましょう。

ac2015/admin.pyを下記の通りに修正します。

#ac2015/admin.py

from django.contrib.gis import admin
from ac2015.models import IkitaiOmise

admin.site.register(IkitaiOmise, admin.GeoModelAdmin)

geodjango/urls.pyの

from django.contrib import admin

from django.contrib.gis import admin

に修正します。

djangoアプリケーションの管理者権限を持つユーザを作成します。

$ ./manage.py createsuperuser
>> Username (leave blank to use 'myname'): myname
>> Email address: mymail@mymail.com
>> Password: 
>> Password (again): 
>> Superuser created successfully.

djangoアプリケーションを起動します。

$ ./manage.py runserver
>> December 15, 2015 - 07:09:57
>> Django version 1.8.7, using settings 'geodjango.settings'
>> Starting development server at http://127.0.0.1:8000/
>> Quit the server with CONTROL-C.

Webブラウザで http://127.0.0.1:8000/adminを開き、先ほど作成したユーザ名とパスワードでログインします。

「Ac2015」の 「Ikitai omises」をクリックすると、ロードしたデータのリストが表示されます。

リスト中のデータのリンクをクリックすると、詳細が表示されます。

geomフィールドのデータがOpenLayersで表示されています。

djangoアプリを終了する時は、コンソールでCONTROL-Cを入力します。

超簡単!RESTful APIの作成

さて、やっと本題です。RESTfulなジオAPIを作成します!

Django Rest Frameworkのジオ拡張であるdjango-rest-framework-gisと、検索機能で使うdjango-filterをインストールします。
また、APIの実行結果をブラウザで表示する為に、markdownもインストールします。

$ pip install djangorestframework-gis
$ pip install django-filter
$ pip install markdown

ac2015/setting.pyのINSTALLED_APPAに、'rest_framework'と'rest_framework_gis'を追加します。

#ac2015/setting.py

INSTALLED_APPS = (
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'django.contrib.gis',
    'ac2015',
    'rest_framework',
    'rest_framework_gis',
)

リアライザを作成します。シリアライザとは、データベースのレコードとAPIで送受信するデータフォーマットの(ここではjson)の仲介役の事です。シリアライザは、APIでデータを呼び出す時はレコードの内容をjsonにして返してくれます。APIjsonを送信すると、jsonの内容で既存のレコードが更新されたり、新しいレコードが作られたりします。
ac2015ディレクトリの下に、下記内容のserializers.pyを作成します。

#ac2015/serializers.py

from rest_framework.serializers import ModelSerializer
from ac2015.models import IkitaiOmise

class IkitaiOmiseSerializer(ModelSerializer):
	class Meta:
		model = IkitaiOmise

ここで作成したのは、rest_frameworkが提供するModelSerializerのサブクラスです。ac2015/models.pyで定義したデータモデル(IkitaiOmise)とこのシリアライザを関連づけています。やっていることはこれだけです。

次に、REST Frameworkのビューを作成します。ここでいうビューとは、httpリクエストに対してのどう振る舞って、どういうレスポンスを返すのかを定義するもののことを言います。
もう少し具体的に言うと、「データを更新するのか、作成するのか、削除するのか、1取得するのか、複数取得するのか、複数取得するならどういう絞り込みをするのか」という事を定義します。

ここでは、複数件を取得する時は、任意の緯度経度から任意の距離内にあるお店だけを取得するビューを定義してみます。
上のほうでac2015アプリケーションを作成したときに、ac2015/view.pyというファイルが作成されているので、これを下記のように編集します。

# ac2015/view.py

from rest_framework_gis.filters import DistanceToPointFilter
from rest_framework import generics,viewsets
from ac2015 import models,serializers
from rest_framework.pagination import PageNumberPagination


class MyPagination(PageNumberPagination):
    page_size_query_param = 'page_size'

class IkitaiOmiseViewSet(viewsets.ModelViewSet):
    queryset = models.IkitaiOmise.objects.all()
    serializer_class = serializers.IkitaiOmiseSerializer
    pagination_class = MyPagination
    filter_backends = (DistanceToPointFilter,)
    distance_filter_field = 'geom'
    distance_filter_convert_meters = True

ここでやっているのことを大まかに説明すると、以下の通りです。

  • serializer_classで、どのシリアライザを使うかを設定。
  • pagination_classで、どういうページングをするかを設定。ここではpage_sizeでURLクエリパラメータでページングのサイズを指定出来るPageNumberPaginationクラスのサブクラス(MyPagination)を使っています。
  • filter_backendsで、データを絞り込む方法を設定。ここでは、指定した点からの距離で絞り込むDistanceToPointというフィルタを使っています。
  • distance_filter_fieldで、データモデルのどのフィールドを絞り込みの対象とするかを設定しています。


次に上で作成したビューを、urlと対応付けます。
まず、geodjango/urls.pyを次のように修正します。

#geodjango/urls.py

from django.conf.urls import include, url
from django.contrib.gis import admin

urlpatterns = [
    url(r'^admin/', include(admin.site.urls)),
    url(r'^', include('ac2015.urls',namespace='ac2015')),
]


つぎに下記内容のac2015/urls.pyを作成します。

from django.conf.urls import patterns, include, url
from rest_framework import routers
from ac2015 import views

router = routers.DefaultRouter()
router.register(r'places', views.IkitaiOmiseViewSet)

urlpatterns = patterns('',

	url(r'^api/', include(router.urls)),
)

これでビューとurlの関連付けが出来ました。
そしてこれで、地理空間データの作成・取得・更新・削除を行うRESTfulなAPIが出来ました。
はい、これだけです。超簡単!

さっそくAPIを試してみましょう。
djangoアプリケーションを起動します。

$ ./manage.py runserver


それでは、近年話題のウラなんば、緯度34.66542、経度135.50324の半径300m内にあるお店を検索してみましょう。

Django Rest API Frameworkは、APIをwebブラウザでテストする為のUIが用意されているので、いちいちコンソールから

$ curl -X GET -H "ContentType:application/json" http://localhost:8000/api/places/?dist=300&point=135.50324,34.66542 

なんてタイプする必要がありません。Webブラウザで、下記のURLを開いてみて下さい。

http://localhost:8000/api/places/?dist=300&point=135.50324,34.66542


お店が出てきました。超簡単でしょう?
全くプログラミングはしていないのに、ちょっとビューを設定するだけでここまで出来てしまいます。
ごらんのとおり、位置情報であるgeomカラムのデータは、GeoJSONのgeometry属性のフォーマットで表現されています。

{
        "id": 67,
        "name": "かき小屋フィーバー ボーボーアフロ",
        "category": "レストラン",
        "geom": {
            "type": "Point",
            "coordinates": [
                135.505652,
                34.664452
            ]
        }
    }


「仕様でPointだと分かっているのに"type": "Point"は冗長だ」とか「Leafletで扱いやすい[lat,lng]の方がいい」という場合は、シリアライザのto_representation(シリアライズ側)とto_internal_value(デシリアライズ側)というメソッドに手を加えれば、

    {
        "id": 67,
        "name": "牡蠣小屋フィーバー ボーボーアフロ",
        "category": "レストラン",
        "geom": [
            34.664452,
            135.505652
        ]
    },

という様にすることができます。ですが今回は「プログラミング無し!」にしたいので、このままにします。

検索によってデータのIDが分かったので、任意の1件取得してみましょう。

http://localhost:8000/api/places/{id}/ の書式でIDを指定します。

例えば、id=67なら、下記URLになります。

http://localhost:8000/api/places/67/

あれ?お店の名前が間違っている事に気づきました。大変失礼致しました。「牡蠣小屋」ではなく「かき小屋」ですので修正します。

データの更新はPUTメソッドです。PUTボタンクリックすると...

修正されました。

次はデータの追加を試してみましょう。
谷町九丁目駅から1km内で検索してみると(http://localhost:8000/api/places/?dist=1000&point=135.5157,34.66647)...

お店が1件しか無いのは寂しいので、追加します。ブラウザからでも出来ますが、Web APIであることを実感するために、こんどはコンソールからやってみましょう。
次のコマンドをコピー&ペーストして実行してみて下さい。

curl -X POST  -H "Accept: application/json" -H "Content-type: application/json" -d '{"name": "旧ヤム邸宅","category": "レストラン","geom":{"type": "Point","coordinates": [135.5129899,34.6736539]}}' http://localhost:8000/api/places/

下のような応答があれば成功です。

>> {"id":109,"name":"旧ヤム邸宅","category":"レストラン","geom":{"type":"Point","coordinates":[135.5129899,34.6736539]}}

先ほどの検索結果をリロードしてみましょう。

新しいデータがid=109で作成されていますね。

因に、検索の際、結果の件数が多かったのでページングはしていていませんでしたが、件数が多い場合はページングするのが普通です。
ページングは、URLクエリパラメータにpage_size={1ページ当たりの件数}&page={ページ番号}を追加する事で行います。
例えば、大阪梅田から1km内のお店を10件ずつ取得したい場合は、URLは

http://localhost:8000/api/places/?dist=1000&point=135.4953,34.7022&page_size=10&page=1
http://localhost:8000/api/places/?dist=1000&point=135.4953,34.7022&page_size=10&page=2

のようになります。

さて、ここまで任意の点からの距離で検索してきましたが、他にもいろいろなフィルタリング方法が用意されています。
最後にバウンダリでのフィルタリングをご紹介します。

ac2015/views.pyを下記のように編集します。

# ac2015/views.py

from rest_framework_gis.filters import DistanceToPointFilter, InBBoxFilter # <=ここを変更
from rest_framework import generics,viewsets
from ac2015 import models,serializers
from rest_framework.pagination import PageNumberPagination


class MyPagination(PageNumberPagination):
    page_size_query_param = 'page_size'

class IkitaiOmiseViewSet(viewsets.ModelViewSet):
    queryset = models.IkitaiOmise.objects.all()
    serializer_class = serializers.IkitaiOmiseSerializer
    pagination_class = MyPagination
    filter_backends = (DistanceToPointFilter, InBBoxFilter)  # <=ここを変更
    distance_filter_field = bbox_filter_field = 'geom' #  <=ここを変更
    distance_filter_convert_meters = True

変更の内容は、以下のとおりです。

  • filter_backendsにInBBoxFilterを追加して、バウンダリでの絞り込みも出来る様に設定。
  • bbox_filter_fieldで、絞り込み対象のフィールドを設定。

では、経度135.48,緯度34.6969を南西端、経度135.4984,緯度34.7055を北東端とするバウンダリで検索してみましょう。
URLは下記の様に指定します。

http://localhost:8000/api/places/?in_bbox=135.48,34.6969,135.4984,34.7055

すごい!簡単ですね!

こんな感じで、プログラミングなしでも、GeoJSONとDjango REST Frameworkで色々出来てしまいます。
皆さん是非使ってみて下さい!

明日はZero_Kohakuさんです!


追記:
ちなみに、Django Rest Framework GISには完全なGeoJSONを扱うシリアライザがあり、TMSTileFilterというフィルタもあるので、「これでGeoJSONタイルサービスができるのかな?」と思ったのですが、ソースを見た所TMSTileFilterはその名のとおり絞り込みを行うだけで、タイル上にベクターをカットしてくれる訳ではありませんでした。
GeoJSONタイルサービスを作りたいなら、下記で紹介されているdjgeojsonというモジュールを使うのがよさそうです(1年も前の記事なのか...汗)。

PostGISとGeoDjangoを使ってLeafletでGeoJSON Tile Layerを表示してみる(5) – GeoDjangoでTiled GeoJSONを出力する – – ビットログ