dayjournal memo

Total 973 articles!!

Try #016 – DBもパッケージしたサーバーレスAPIで空間検索して可視化してみた

Yasunori Kirimoto's avatar


画像

画像



画像



画像



画像



画像



画像





画像



この記事は、「FOSS4G Advent Calendar 2018」の5日目の記事です。



DBもパッケージしたサーバーレスAPIで空間検索して可視化をしてみました!



全体の構成はできるだけシンプルにしてみました。


バックエンド - Zappaを利用して空間検索可能なサーバーレスAPIをAWSにデプロイ

  • Zappa
  • Flask
  • SQLAlchemy
  • GeoAlchemy2
  • SQLite(SpatiaLite)

フロントエンド - Mapbox GL JSを利用して地図上にデータを可視化

  • Mapbox GL JS
  • webpack


バックエンド


まずは、バックエンドを構築していきます。

今回は、サーバーレスAPIを構築するために、サーバーレスフレームワークのZappaを利用します。

Zappaや事前準備については、「Serverless Advent Calendar 2018」の2日目の記事で書いた「ZappaでDBもパッケージしたサーバーレスAPIを構築してみた」を参考にして頂ければと思います。


今回は、事前準備ができている前提で説明していきます。

virtualenvで仮想環境を構築します。


仮想環境作成

pyenv virtualenv 3.6.0 sample181205

仮想環境切り替え

pyenv local sample181205


次に、アプリに必要なパッケージとZappaを仮想環境にインストールします。


各パッケージインストール

pip install Flask
pip install Flask-Cors
pip install SQLAlchemy
pip install GeoAlchemy2

Zappaインストール

pip install zappa


アプリ構築用ファイル一覧

address.db: 検索用のSQLite(SpatiaLite)ファイル
app.py: Flask、SQLAlchemy、GeoAlchemy2等を盛り込んだPythonファイル
zappa_settings.json: Zappaの設定ファイル



今回は、DBもパッケージしたサーバーレスアプリを構築するためDBはSQLite(SpatiaLite)を利用します。

SQLite(SpatiaLite)の中には、国土地理院の電子国土基本図(地名情報)「住居表示住所」データを取り込み利用します。

サンプルでは、札幌市白石区周辺のデータを約10万レコード取り込みました。

電子国土基本図(地名情報)「住居表示住所」データを利用するためには、国土地理院へ利用申請が必要です。


address.db

画像



次に、PythonでAPIを構築してみます。

今回は、指定した経緯度の近くにある地物を空間検索するAPIを構築します。


app.py


#!/usr/bin/env python
# -*- coding: utf-8 -*-

#Flask読み込み
from flask import Flask, jsonify, abort, make_response, request
from flask_cors import CORS

#SQLAlchemy読み込み
from sqlalchemy import create_engine
from sqlalchemy.event import listen
from sqlalchemy.sql import select, func
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import sessionmaker

#GeoAlchemy2読み込み
from geoalchemy2 import Geometry
from geoalchemy2 import WKTElement

#urllib読み込み
import urllib.request
import urllib.parse

#SpatiaLite反映
def load_spatialite(dbapi_conn, connection_record):
    dbapi_conn.enable_load_extension(True)
    #Mac環境
    dbapi_conn.load_extension('mod_spatialite.dylib')

#SpatiaLite読み込み
engine = create_engine('sqlite:///address.db', echo=True)
listen(engine, 'connect', load_spatialite)

#DBに接続
conn = engine.connect()
conn.execute(select([func.InitSpatialMetaData()]))
Base = declarative_base()

#addressテーブルのModel作成
class Address(Base):
    __tablename__ = 'address'
    ogc_fid = Column(Integer, primary_key=True)
    address_all = Column(String)
    geometry = Column(Geometry(geometry_type='POINT', management=True, use_st_prefix=False, srid=4326))

#セッション作成
Session = sessionmaker(bind=engine)
session = Session()

#Flaskのインスタンス作成
app = Flask(__name__)
CORS(app)

#日本語表示対応
app.config['JSON_AS_ASCII'] = False

#JSON取得処理
@app.route('/', methods=['GET'])
def get_m():
    #URLのKeyとDBのKeyを比較
    try:
        #クエリパラメータを設定
        lng_add = request.args.get('lng', default = "", type = str)
        lat_add = request.args.get('lat', default = "", type = str)

        #クエリパラメータを判断
        if lng_add == "" or lat_add == "":
            #エラーJSON作成
            result = {
                "error": "クエリパラメータを設定してください。",
                "result":False
                }
        else:
            #バッファ(約50m)
            query = session.query(Address.ogc_fid, Address.address_all, Address.geometry.ST_X().label('lng'), Address.geometry.ST_Y().label('lat'), Address.geometry.ST_AsText().label('wkt')).filter(Address.geometry.ST_Intersects(func.ST_Buffer(WKTElement("POINT (" + lng_add + " " + lat_add + ")"), 0.0005)))
            #変数初期化
            result = {}
            count = 0
            #検索結果でJSON作成
            for m in query:
                count = count + 1
                result[str(count)] = {
                    "data":{
                        "id":m.ogc_fid,
                        "address_all":m.address_all,
                        "lng":m.lng,
                        "lat":m.lat,
                        "wkt":m.wkt
                        },
                    "result":True
                    }
                #結果は100件まで
                if count == 100:
                    break
            #最後にカウントをJSONに追加
            result["count"] = count

        #JSONを出力
        return make_response(jsonify(result))

    except Address.DoesNotExist:
        abort(404)

#エラー処理
@app.errorhandler(404)
def not_found(error):
    #エラーJSON作成
    result = {
        "error": "存在しません。",
        "result":False
        }
    #エラーJSONを出力
    return make_response(jsonify(result), 404)

#app実行
if __name__ == '__main__':
    app.run()

このあたりの記述で空間検索をしています。


#バッファ(約50m)
query = session.query(Address.ogc_fid, Address.address_all, Address.geometry.ST_X().label('lng'), Address.geometry.ST_Y().label('lat'), Address.geometry.ST_AsText().label('wkt')).filter(Address.geometry.ST_Intersects(func.ST_Buffer(WKTElement("POINT (" + lng_add + " " + lat_add + ")"), 0.0005)))

ST_X(): 経度に変換
ST_Y(): 緯度に変換
ST_AsText(): WKTに変換
ST_Buffer: バッファを発生
ST_Intersects: バッファの中に含む地物を空間検索



APIを構築したら、ローカル環境でアプリの動作確認をしてみます。


ローカルサーバー起動

python app.py

画像



クエリパラメータで検索したい経緯度を指定します。
http://127.0.0.1:5000/?lng=141.3796877861023&lat=43.05537396780398

画像



表示確認ができたら、Zappaの設定ファイルを作成します。


設定ファイル作成

zappa init


作成された設定ファイルを適宜修正します。

zappa_settings.json


{
    "api": {
        "app_function": "app.app",
        "aws_region": "ap-northeast-1",
        "project_name": "spatialite_sample",
        "runtime": "python3.6",
        "s3_bucket": "zappa-iqubzbin5"
    }
}


次に、Zappaでアプリをデプロイします。ただ、このままではうまくデプロイできません。

「mod_spatialite」がOSによって違います。Lambdaにアップロードする場合は、Amazon Linuxとなるため「app.py」のコードを「mod_spatialite.so」に変更します。



#AWS環境
dbapi_conn.load_extension('/var/task/mod_spatialite.so')


デプロイ

zappa deploy


公開されたAPIで動作確認してみます。
https://xxxxxxx.amazonaws.com/api/?lng=141.3796877861023&lat=43.05537396780398
※Zappaにアップするとパスにapiが追加されます。

画像




フロントエンド


最後に、フロントエンドを構築していきます。

今回は、mapboxgljs-starterというMapbox GL JSを手軽に始めるビルド環境を利用します。

mapboxgljs-starterをダウンロードして「script.js」のみを変更します。


クリックした経緯度を指定して、地物を可視化してみます。


script.js


//MIERUNE MONO読み込み
let map = new mapboxgl.Map({
    container: "map",
    style: {
        "version": 8,
        "sources": {
            "MIERUNEMAP": {
                "type": "raster",
                "tiles": ['https://tile.mierune.co.jp/mierune_mono/{z}/{x}/{y}.png'],
                "tileSize": 256
            }
        },
        "layers": [{
            "id": "MIERUNEMAP",
            "type": "raster",
            "source": "MIERUNEMAP",
            "minzoom": 0,
            "maxzoom": 18
        }]
    },
    center: [141.3796, 43.0553],
    zoom: 14
});


map.on('load', function () {

    //地物検索クリックイベント
    map.on('click', function(e) {
        //クリック位置の経緯度取得
        let lng = e.lngLat.lng;
        let lat = e.lngLat.lat;

        //リクエストURL設定
        let URL = "https://xxxxxxx.amazonaws.com/api";
        let para = "?lng=" + lng + "&lat=" + lat;

        //API取得
        fetch(URL + para)
            .then( function(data_all) {
                //JSON取得
                return data_all.json();
            })
            .then( function(json) {
                //JSONをGeoJSONに変換
                let geojson_all = {
                    "type": "FeatureCollection",
                    "features":[]
                };
                for (let i = 1; i <= json.count; i++) {
                    let geojson_obj = {
                        "type": "Feature",
                        "geometry": {
                            "type": "Point",
                            "coordinates": [json[i].data.lng, json[i].data.lat]
                        },
                        "properties": {
                            "address_all": json[i].data.address_all,
                            "id": json[i].data.id,
                            "lat": json[i].data.lat,
                            "lng": json[i].data.lng,
                            "wkt": json[i].data.wkt
                        }
                    };
                    geojson_all["features"].push(geojson_obj);
                }
                //GeoJSONを返す
                return geojson_all
            })
            .then( function(geojson_all) {
                //既存の検索結果クリア
                if (map.getLayer('point_sample')) {
                    map.removeLayer('point_sample');
                }
                if (map.getSource('point_sample')) {
                    map.removeSource('point_sample');
                }

                // GeoJSON設定
                map.addSource('point_sample', {
                    type: 'geojson',
                    data: geojson_all
                });

                // スタイル設定
                map.addLayer({
                    "id": "point_sample",
                    "type": "circle",
                    "source": "point_sample",
                    "layout": {},
                    "paint": {
                        'circle-color': "#1253A4",
                        'circle-opacity': 0.7,
                        'circle-radius': 8
                    }
                });
            })
            .catch(function(error) {
                console.log('request failed', error)
            });
    });

    //ポイントクリックイベント
    map.on('click', 'point_sample', function (e) {
        //クリック位置の経緯度取得
        let coordinates = e.lngLat;

        //クリック位置に移動
        map.flyTo({center: coordinates});

        //属性設定
        let description =
            'address: ' + e.features[0].properties.address_all + '<br>' +
            'id: ' + e.features[0].properties.id + '<br>' +
            'lnglat: ' + e.features[0].properties.lng + ", " + e.features[0].properties.lat ;
        while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
            coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
        }
        new mapboxgl.Popup()
            .setLngLat(coordinates)
            .setHTML(description)
            .addTo(map);
    });

    //カーソルON,OFF
    map.on('mouseenter', 'point_sample', function () {
        map.getCanvas().style.cursor = 'pointer';
    });
    map.on('mouseleave', 'point_sample', function () {
        map.getCanvas().style.cursor = '';
    });

});


//コントロール表示
map.addControl(new mapboxgl.NavigationControl());


実行環境

node v10.0.0
npm v6.4.1


パッケージインストール

npm install

ビルド

npm run build

開発用

npm run dev


開発用で確認してみます。

画像




DBもパッケージしたサーバーレスAPIで、空間検索ができることを確認できました!



サーバーレスAPIの環境をGitHubで公開しました。
serverless-geospatial-api



今後の課題は、レスポンスに時間がかかってしまうことですかね。。。
現状だと、Lambdaのメモリを3GBにしても数秒待たなければいけないです。ローカル環境だと問題ないので、LambdaでのSQLite(SpatiaLite)では限界があるのかもしれません。




※「このアプリケーションの作成に当たっては、国土地理院長の承認を得て、同院発行の電子国土基本図(地名情報)住居表示住所を使用した。(承認番号 平30情使、第928号)」



book

Q&A