画像

画像




画像




画像




画像




画像




画像








画像




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









Q&A