はじめに
ベクトルタイルの生産、スタイル、ホスティング、最適化、消費の各分野で少しずつノウハウを積み重ねていますが、ここではベクトルタイルのホスティングについて書きたいと思います。
UNVTツールには、@hfuさんが数年前に開発した un-vector-tile-toolkit/onyx というパッケージがあり、nodejsでのベクトルタイル配信を実現しています。タイルのホスティングにはtileserver-glやGitHubページなどいろいろ選択肢がありますが、nodejsサーバーを使った場合の利点については、以下のように考えています。
- シンプルで反応が早い。スケーラブル。
- 性能のよいサーバーにも適用できるし、ラズベリーパイのような小型PCでも実装可能。
- mapboxのnpmモジュールを使うことで、mbtilesからpbfを配信できる。
- 大容量サイズのデータ配信が容易(このツールは大容量のデータを配信したい場合に向いている。少しのデータならウェブサーバーなどで十分。)。
- nodejsのスクリプトと、pm2を組み合わせることで、デーモンプロセスとして実行するのも簡単。
今回の作業の目標
- onyxのスクリプトをもとにして、nodejs/expressでつくったサーバーを立てる。(その過程を記録する)
- わかりやすさのために、ベクトルタイルを配信するモジュールは./route/VT.jsとして切り分ける。
(onyxも開発から3年くらいたっています。私が使っているサーバーでも、各npmモジュールの新しいバージョンでも動くかの確認と、nodejs 16でサーバーが動くかの確認をする必要がありました。今回、シンプルなサーバーをつくって動作を確認した際についでにメモしたものです。)
環境
開発はGitHubを使って、Windows PCのテキストエディタで実施しました。実験用のLinuxは以下の2つでした。
-
環境1:レンタルサーバー(個人の実験用)
- OS: CentOS Linux release 7.8.2003
- メモリ: 1 GB
- nodejs version: 16.13.1 ※nvmで管理
- npm version 8.1.2 ※nvmで管理
- pm2 version 4.4.0
- ドメインは取得済み。SSL/TLS認証はLet's Encryptで取得。
-
環境2:ラズベリーパイ(3b)
- OS: Debian 11.1
- メモリ: 1 GB
- nodejs version 16.13.1
- ローカルでの利用なので、SSL/TLS認証なし
手順
Step1: node.jsサーバーの構築(静的なホスティングのみ)
まずは以下の通り、簡単なapp0.jsというファイルを作ってみました。
ログの設定や、cors(クロスオリジンの設定)をしたのち、 express.static(htdocsPath) を使って、静的コンテンツのホスティングをしているだけです。(htdocsPathはコンフィグファイルで設定します。まだ使わない変数も少しはいっていてすみません。)
const config = require('config')
const fs = require('fs')
const express = require('express')
const spdy = require('spdy') //for https
const cors = require('cors')
const morgan = require('morgan')
//const TimeFormat = require('hh-mm-ss')
const winston = require('winston')
const DailyRotateFile = require('winston-daily-rotate-file')
// config constants
const morganFormat = config.get('morganFormat')
const htdocsPath = config.get('htdocsPath')
const privkeyPath = config.get('privkeyPath')
const fullchainPath = config.get('fullchainPath')
const port = config.get('port')
const mbtilesDir = config.get('mbtilesDir')
const logDirPath = config.get('logDirPath')
// logger configuration
const logger = winston.createLogger({
transports: [
new winston.transports.Console(),
new DailyRotateFile({
filename: `${logDirPath}/server-%DATE%.log`,
datePattern: 'YYYY-MM-DD'
})
]
})
logger.stream = {
write: (message) => { logger.info(message.trim()) }
}
// app
const app = express()
app.use(cors())
app.use(morgan(morganFormat, {
stream: logger.stream
}))
app.use(express.static(htdocsPath))
//for http
app.listen(port, () => {
console.log(`Running at Port ${port} ...`)
})
/* for https
spdy.createServer({
key: fs.readFileSync(privkeyPath),
cert: fs.readFileSync(fullchainPath)
}, app).listen(port)
*/
これとは別に、以下のようなconfigファイル(config/default.hjson)を準備しています。privkeyPath と fullchainPathはHTTPS用なのでStep 1ではまだ使いません。mbtilesDirもStep 1では使いません。(そのままで大丈夫です。)
{
morganFormat: tiny
htdocsPath: htdocs
privkeyPath: ./key/privkey.pem
fullchainPath: ./key/cert.pem
logDirPath: log
port: 8836
mbtilesDir: mbtiles
}
使ったnpmパッケージで主なものは以下の通りです。
名前 | version | 私の感想など |
---|---|---|
config | 3.3.6 | コンフィグ用に使います |
cors | 2.8.5 | クロスオリジンでのリソースシェアのため (参考: ベクトルタイルはCORSしよう by @hfu ) |
express | 4.17.1 | nodejsで使えるWebアプリケーションフレームワーク。ルーティングやホスティングが自由にできます。 |
morgan | 1.10.0 | httpのログ用です |
spdy | 4.0.2 | httpsのとき、http2をするのに使います。(Step 1では使いません。) |
sqlite3 | 5.0.2 | node.js v14までで動くと書いてあるのですが、v16でも一応動きました(v4.2.0 はnodejs16で動きませんでした)。ただ、これが使っているnode-gypの使っているtar(v3.8)には脆弱性があるのでnpm auditで警告が出ます。 |
winston | 3.3.3 | これもログ用です。 |
mapbox/mbtiles | 0.12.1 | mbtilesからpbfを取り出すのに使います。大事なモジュールなのですが、これが使っているsqlite3には前述の問題があります。また、モジュールの最初にアットマークがつきますが、qiitaでアットマークをつけると勝手にリンクしてしまうので省略しています。(Step1では使いません。) |
こうして作ったapp0.jsは、https://github.com/ubukawa/server-test-01 においてあります。以下のコマンドで実行できます。
git clone https://github.com/ubukawa/server-test-01
cd server-test-01
npm install
node app0.js
これをするだけで、htdocsに入れたファイルが、ポート8836からhttpでホストされていると思います。http(s)://(サーバーのルート):8836/index.html がみられるか試してください。
↑ httpなのでnot secureと出ていますが、きちんとページが見られます。
データサイズが少ないときはpbf形式で静的にホスティングすれば十分なので、これで十分です(そもそもnodejsサーバーにしなくてよいですが・・・)。例えば、このレポジトリでは練習用に、htdocs/pbf_tilesというフォルダにpbfファイルをいれているので、http(s)://(サーバーのルート):8836/pbf_tiles/ne-test/{z}/{x}/{y}.pbf でタイルにアクセスできると思います。
Step2: mbtilesからpbfタイルを返す機能を追加する
Step 1で作った簡単なnodejsサーバーに、mbtilesからpbfタイルを返すためのルートを追加します。
const app = express()の下に、var VTRouter = require('./routes/VT') を追加します。そして、もう少し下の app.useのところに、app.use('/VT', VTRouter)を追加します。これで、/VT/というパスできたリクエストはroutes/VT.jsに回されることになります。
const app = express()
var VTRouter = require('./routes/VT') //tiling
app.use(cors())
app.use(morgan(morganFormat, {
stream: logger.stream
}))
app.use(express.static(htdocsPath))
app.use('/VT', VTRouter)
そして、routes/VT.jsというファイルを準備します。これは、リクエストのパスからタイルの名前、z、x、yを特定し、さらにmapbox/mbtiles(ver 0.12.1)というnpmモジュールを使って、mbtilesから取り出したpbfタイルを返すというものです。基本的な仕組みはunvt/onyxを参考にしています。
(詳細説明:onyxと違うのは、ここでは簡単のため空間モジュールを使っていません。一つのデータソースに対して一つのmbtilesという条件の中でpbfを返すことにしています。また、app.jsとモジュールを分けることでapp.jsをシンプルにしておけるので、今後のAzure AD認証追加等の準備になります。)
var express = require('express')
var router = express.Router()
const config = require('config')
const fs = require('fs')
const cors = require('cors')
const MBTiles = require('@mapbox/mbtiles')
const TimeFormat = require('hh-mm-ss')
// config constants
const mbtilesDir = config.get('mbtilesDir')
// global variables
let mbtilesPool = {}
let busy = false
var app = express()
app.use(cors())
//specify the target mbtiles from the path
const getMBTiles = async (t, z, x, y) => {
let mbtilesPath = `${mbtilesDir}/${t}.mbtiles`
return new Promise((resolve, reject) => {
if (mbtilesPool[mbtilesPath]) {
resolve(mbtilesPool[mbtilesPath].mbtiles)
} else {
if (fs.existsSync(mbtilesPath)) {
new MBTiles(`${mbtilesPath}?mode=ro`, (err, mbtiles) => {
if (err) {
reject(new Error(`${mbtilesPath} could not open.`))
} else {
mbtilesPool[mbtilesPath] = {
mbtiles: mbtiles, openTime: new Date()
}
resolve(mbtilesPool[mbtilesPath].mbtiles)
}
})
} else {
reject(new Error(`${mbtilesPath} was not found.`))
}
}
})
}
//Get tile from mbtiles with z,x,y
const getTile = async (mbtiles, z, x, y) => {
return new Promise((resolve, reject) => {
mbtiles.getTile(z, x, y, (err, tile, headers) => {
if (err) {
reject()
} else {
resolve({tile: tile, headers: headers})
}
})
})
}
//GET Tile(router)- t,z,x,y are extracted from the path
router.get(`/zxy/:t/:z/:x/:y.pbf`,
async function(req, res) {
busy = true
const t = req.params.t
const z = parseInt(req.params.z)
const x = parseInt(req.params.x)
const y = parseInt(req.params.y)
getMBTiles(t, z, x, y).then(mbtiles => {
getTile(mbtiles, z, x, y).then(r => {
if (r.tile) {
res.set('content-type', 'application/vnd.mapbox-vector-tile')
res.set('content-encoding', 'gzip')
res.set('last-modified', r.headers['Last-Modified'])
res.set('etag', r.headers['ETag'])
res.send(r.tile)
busy = false
} else {
res.status(404).send(`tile not found: /zxy/${t}/${z}/${x}/${y}.pbf`)
busy = false
}
}).catch(e => {
res.status(404).send(`tile not found (getTile error): /zxy/${t}/${z}/${x}/${y}.pbf`)
busy = false
})
}).catch(e => {
res.status(404).send(`mbtiles not found for /zxy/${t}/${z}/${x}/${y}.pbf`)
})
}
);
module.exports = router;
出来たファイルは、同じようにhttps://github.com/ubukawa/server-test-01 においてあります。自分のサーバーにコピーすれば node app.js で実行できます。
これで、例えば、mbtiles フォルダに ne-test.mbtiles というベクトルタイルがあれば、
http(s)://(サーバーのルート):8836/VT/ZXY/ne-test/{z}/{x}/{y}.pbf
でアクセスできるようになります。
Step3: httpからhttpsにする(必要に応じ)
もしサーバーがドメインを取得していて、そのドメインについてSSH/TLSの認証が取れていたら簡単にhttpsにできます。試したい方はこのStep 3をやってみてください。
- 事前準備
- 私は個人のテストサーバーでドメインを取得し、Let's EncryptでSSL/TLS認証を取得しました。
- 必要な作業
- config/default.hjson で指定したパスに、プライベートキーとCertificateを保存します。(拡張子pem)
- app.jsの最後の部分を以下のように修正します。httpの部分をコメントアウトして、httpsの部分のコメントアウトを取ります。
/* for http
app.listen(port, () => {
console.log(`Running at Port ${port} ...`)
})
*/
// for https
spdy.createServer({
key: fs.readFileSync(privkeyPath),
cert: fs.readFileSync(fullchainPath)
}, app).listen(port)
Step4: レポジトリのサンプル地図がみられるか試してみる。
このレポジトリ( https://github.com/ubukawa/server-test-01 )には、サンプル用のベクトルタイルとウェブ地図(ライブラリは mapbox gl js ver 1.x)が入っています。
htdocs/mapsの下にある二つのスタイルファイル( style-mbtiles.json と style-pbf.json )にある、それぞれのタイルのURLを自分のベクトルライルサーバーに直してみてください。それからapp.jsをスタートすると、map-mbtiles.html と map-pbf.htmlから、簡単なウェブ地図がみられると思います。(ソースはNaturalEarthです。)
Step5: pm2の利用(必要に応じ)
pm2を使うことで、nodejsサーバーをバックグラウンドで実行できます。デーモンプロセスになるのでサーバーからログアウトしても大丈夫です。そして、crontabを使えば定期的にサーバーの再起動もできます。
始めるときは、pm2 start app.js -i 4 --name server-test-01 です。
止めるときは、きちんと以下のコマンドをやりましょう。
pm2 stop server-test-01
pm2 delete server-test-01
コマンドはシェルスクリプトに入っています。
まとめと課題
今回は、nodejsを使ったベクトルタイルサーバーの構築についてメモをしました。現在、npm auditで警告が出ることが気になりますが、nodejs16でもサーバーが動きそうなことを確認できてよかったです。
ここでは基本的な動作を確認しましたが、これから以下の通りに発展します。
複数のmbtilesファイルへの対応
今回は一つのベクトルタイルソースに一つのmbtilesファイルを想定しましたが、これはファイルが大きくなると難しいです。un-vector-tile-toolkit/onyx では特定のズームレベルのタイル区画(例えばZL6)ごとにmbtilesを作り、全世界を複数のmbtilesに分けて管理しています。それに応じたタイル配信を実現するためにonyx の app.js にはルーティングが付いています。さらに、un-vector-tile-toolkit/coesite では、区画の広さがまちまちになるので、もう少しトリッキーなことをしています。
Azure AD 認証の付与
本日は触れませんが、このサーバーのコンポーネントをベースにnodejsでつかえるAzure ADのモジュールもつけてみたものもあります(un-vector-tile-toolkit/coesite)。 Azure AD認証でログインする、ウェブ地図を見るところまではいくのですが、認証をつけるとクロスオリジン(?)でタイルが共有できないので(多分ユーザーIDがセッションごとの管理になっているため)、もう少し取り組みます。
httpとhttpsの考察
インターネット接続を前提であればhttpsですが、Raspberry Pi のサーバーとしての活用について( @hfu , 2021)にあるように、オフライン環境やローカルネットワークと HTTPS の関係は難しいところがあります。特に、オフラインでどのようなサーバーが良いのかは今後考えていきたいです。
謝辞など
nodejsでのホスティングについては、全面的に @hfu さんの成果物を参考にしています。ありがとうございます。
また、この分野(ホスティング)はもう少し研究をしていきたいと思っているので、アドバイスや質問など、何かあればお気軽にお知らせいただけるとありがたいです。