nodejsでベクトルタイルサーバーを作る（mbtilesからpbfを配信する）

## はじめに
ベクトルタイルの生産、スタイル、ホスティング、最適化、消費の各分野で少しずつノウハウを積み重ねていますが、ここでは**ベクトルタイルのホスティング**について書きたいと思います。

UNVTツールには、@hfuさんが数年前に開発した [un-vector-tile-toolkit/onyx](https://github.com/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は以下の２つでした。

* 環境１：レンタルサーバー（個人の実験用）
    * 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](https://letsencrypt.org/)で取得。
    

* 環境２：ラズベリーパイ（3b）
    * OS： Debian 11.1
    * メモリ：　1 GB
    * nodejs version 16．13．1
    * ローカルでの利用なので、SSL/TLS認証なし

## 手順

### Step1: node.jsサーバーの構築（静的なホスティングのみ）
まずは以下の通り、簡単な[app0.js](https://github.com/ubukawa/server-test-01/blob/main/app0.js)というファイルを作ってみました。
ログの設定や、cors（クロスオリジンの設定）をしたのち、 **express.static(htdocsPath)** を使って、静的コンテンツのホスティングをしているだけです。（htdocsPathはコンフィグファイルで設定します。まだ使わない変数も少しはいっていてすみません。）


```javascript:app0.js
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](https://www.npmjs.com/package/config)| 3.3.6 |コンフィグ用に使います |
|[cors](https://www.npmjs.com/package/cors)| 2.8.5 |クロスオリジンでのリソースシェアのため　（参考： [ベクトルタイルはCORSしよう](https://qiita.com/hfu/items/f1dc32981f3ff4b9f4cb) by @hfu ） |
|[express](https://www.npmjs.com/package/express)| 4.17.1 |nodejsで使えるWebアプリケーションフレームワーク。ルーティングやホスティングが自由にできます。 |
|[morgan](https://www.npmjs.com/package/morgan)| 1.10.0 |httpのログ用です |
|[spdy](https://www.npmjs.com/package/spdy)| 4.0.2 |httpsのとき、http2をするのに使います。（Step 1では使いません。） |
|[sqlite3](https://www.npmjs.com/package/sqlite3)| 5.0.2 |node.js v14までで動くと書いてあるのですが、v16でも一応動きました（v4.2.0 はnodejs16で動きませんでした）。ただ、これが使っているnode-gypの使っているtar(v3.8)には脆弱性があるのでnpm auditで警告が出ます。|
|[winston](https://www.npmjs.com/package/winston)| 3.3.3 |これもログ用です。|
|[mapbox/mbtiles](https://www.npmjs.com/package/@mapbox/mbtiles)| 0.12.1 |mbtilesからpbfを取り出すのに使います。大事なモジュールなのですが、これが使っているsqlite3には前述の問題があります。また、モジュールの最初にアットマークがつきますが、qiitaでアットマークをつけると勝手にリンクしてしまうので省略しています。（Step1では使いません。）|


こうして作ったapp0.jsは、https://github.com/ubukawa/server-test-01 においてあります。以下のコマンドで実行できます。

```shell
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** がみられるか試してください。

![2021-12-07-http.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/be8c98e6-3d5b-010a-f55e-7e38c1b8cbd5.png)
↑ httpなのでnot secureと出ていますが、きちんとページが見られます。

![2021-12-07-server.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/00152b61-9f0c-62ac-a509-5e6e0c23e036.png)
↑ サーバー側はこんな感じです。

データサイズが少ないときは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に回されることになります。

```javascript:app.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](https://github.com/un-vector-tile-toolkit/onyx)を参考にしています。
（詳細説明：onyxと違うのは、ここでは簡単のため空間モジュールを使っていません。一つのデータソースに対して一つのmbtilesという条件の中でpbfを返すことにしています。また、app.jsとモジュールを分けることでapp.jsをシンプルにしておけるので、今後のAzure AD認証追加等の準備になります。）

```javascript:routes/VT.js
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](https://letsencrypt.org/)でSSL/TLS認証を取得しました。
* 必要な作業
    * config/default.hjson で指定したパスに、プライベートキーとCertificateを保存します。（拡張子pem）
    * app.jsの最後の部分を以下のように修正します。httpの部分をコメントアウトして、httpsの部分のコメントアウトを取ります。

```javascript:app.jsの一部（変更後）
/* 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です。）
![2021-12-07-http-map.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/12c64996-5c1e-7863-53e3-4fdc7ab7e93e.png)


### Step5: pm2の利用（必要に応じ）
pm2を使うことで、nodejsサーバーをバックグラウンドで実行できます。デーモンプロセスになるのでサーバーからログアウトしても大丈夫です。そして、crontabを使えば定期的にサーバーの再起動もできます。

始めるときは、pm2 start app.js -i 4 --name server-test-01 です。
![2021-12-07-pm2-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/5d524dfe-d64a-bb41-7642-ecba67675a0c.png)


止めるときは、きちんと以下のコマンドをやりましょう。
pm2 stop server-test-01
pm2 delete server-test-01
![2021-12-07-pm2-02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/bae0372f-4c4b-7795-7f91-b839338b383a.png)
コマンドはシェルスクリプトに入っています。

## まとめと課題
今回は、nodejsを使ったベクトルタイルサーバーの構築についてメモをしました。現在、npm auditで警告が出ることが気になりますが、nodejs16でもサーバーが動きそうなことを確認できてよかったです。
![2021-12-07-tar.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/70dda215-694c-3c9a-4063-407e6574fbbf.png)


ここでは基本的な動作を確認しましたが、これから以下の通りに発展します。

#### 複数のmbtilesファイルへの対応
今回は一つのベクトルタイルソースに一つのmbtilesファイルを想定しましたが、これはファイルが大きくなると難しいです。[un-vector-tile-toolkit/onyx](https://github.com/un-vector-tile-toolkit/onyx) では特定のズームレベルのタイル区画（例えばZL6）ごとにmbtilesを作り、全世界を複数のmbtilesに分けて管理しています。それに応じたタイル配信を実現するためにonyx の app.js にはルーティングが付いています。さらに、[un-vector-tile-toolkit/coesite](https://github.com/un-vector-tile-toolkit/coesite) では、区画の広さがまちまちになるので、もう少しトリッキーなことをしています。

#### Azure AD 認証の付与
本日は触れませんが、このサーバーのコンポーネントをベースにnodejsでつかえるAzure ADのモジュールもつけてみたものもあります（[un-vector-tile-toolkit/coesite](https://github.com/un-vector-tile-toolkit/coesite)）。 Azure AD認証でログインする、ウェブ地図を見るところまではいくのですが、認証をつけるとクロスオリジン（？）でタイルが共有できないので（多分ユーザーIDがセッションごとの管理になっているため）、もう少し取り組みます。

#### httpとhttpsの考察
インターネット接続を前提であればhttpsですが、[Raspberry Pi のサーバーとしての活用について](https://qiita.com/hfu/items/4a2b911d093091125879)（ @hfu , 2021）にあるように、オフライン環境やローカルネットワークと HTTPS の関係は難しいところがあります。特に、オフラインでどのようなサーバーが良いのかは今後考えていきたいです。

## 謝辞など
nodejsでのホスティングについては、全面的に @hfu さんの成果物を参考にしています。ありがとうございます。
また、この分野（ホスティング）はもう少し研究をしていきたいと思っているので、アドバイスや質問など、何かあればお気軽にお知らせいただけるとありがたいです。

## 参考
* [今回のレポジトリ ubukawa/server-test-01](https://github.com/ubukawa/server-test-01)
* [un-vector-tile-toolkit/onyx](https://github.com/un-vector-tile-toolkit/onyx)
* [un-vector-tile-toolkit/coesite](https://github.com/un-vector-tile-toolkit/coesite)
* [ベクトルタイルはCORSしよう](https://qiita.com/hfu/items/f1dc32981f3ff4b9f4cb) by @hfu 
* [Raspberry Pi のサーバーとしての活用について](https://qiita.com/hfu/items/4a2b911d093091125879)by @hfu 
* [npm](https://www.npmjs.com/)