この記事は、「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号)」