自分で構築したOpenStreetMapのタイルサーバのスピードアップ

# OpenStreetMapタイルサーバを構築して、ついでに表示速度も改善しよう

## 概要
[OpenStreetMap](https://openstreetmap.jp/)はフリー(自由・無料)な地図プロジェクトです。
（2018年のGoogle Maps APIの仕様変更の際に、乗り換え先として検討した方も多いでしょう。）
タイルサーバも公開されていますが、負荷がかかる使用方法の場合、自身でタイルサーバを構築することが推奨されています。
ただし、PostgreSQLを使った、CPU的にもメモリ的にもストレージ的にも非常に重たい処理のため、リアルタイムに地図を生成すると数十秒待たされることが多いです。（特にズームレベルが小さいと遅い）
一度キャッシュを生成すれば次からは早いですが、キャッシュもすぐに消えてしまいます。

そこで、以下の方法でスピードアップを図ります。
- あらかじめ必要範囲の地図をPNG画像としてダウンロード[(参考)](https://dev.classmethod.jp/articles/batch-download-openstreetmap-tile-data/)
- 小さい画像ファイルが大量にあるためblobとしてSQLiteデータベースに格納
- PHPでクエリをたたき、DBからSELECTした画像を返す
- ズームレベルが高い(17-18)場合、負荷が比較的低いため、タイルサーバから取得


## タイルサーバの構築
タイルサーバの構築方法はOS別に公開されています。[Switch2OSM](https://switch2osm.org/serving-tiles/)
お好きなOSで構築しましょう。

本記事は以下の前提で記述しています。
- Ubuntu22.04で作成すると地図の日本語表示ができないため、Ubuntu20.04で作成しています。
- タイルサーバはHyper-V上に作成、ホスト名「tile」にして、"http://tile.mshome.net/tile/{z}/{x}/{y}.png" でアクセスできるようにしています。

## 地図画像のダウンロード
基本は[downloadosmtiles.pl - search.cpan.org](https://metacpan.org/release/ROTKRAUT/Geo-OSM-Tiles-0.02/view/downloadosmtiles.pl)を使用して地図画像をダウンロードします。
ただし、緯度経度の範囲で指定するため、単純に日本列島全域をしてしようとすると、海やユーラシア大陸が大部分を占めることになり、効率が悪いです。
そこで、日本の行政区画を指定したポリゴンデータを流用して緯度経度を指定します。
（国土地理院でも公開されていますが、今回は[地図蔵](https://japonyol.net/editor/article/47-prefectures-geojson.html)を利用させていただきました。）

ダウンロードした[geojsonファイル](https://japonyol.net/editor/article/geo/prefectures.geojson)を加工して、downloadosmtiles.pl用のシェルスクリプトを作成します。
```python:createScript.py
# -*- coding: utf-8 -*-
import os
import sys
import json
from pathlib import Path

if __name__ == "__main__":
  #タイルサーバのURL及びズームレベルを指定(適宜変更してください)
  cmdline = r'perl downloadosmtiles.pl --baseurl=http://tile.mshome.net/tile --zoom=0:16 '
  #ダウンロードしたgeojsonファイルを指定
  target = Path(r'prefectures.geojson')
  if(os.path.isfile(target)==False):
    sys.exit()
  row = []
  with open(target,'r',encoding="utf-8") as f:
    jdic = json.load(f)
    for pref in jdic['features']:
      #l = ['大阪府','東京都']#都道府県指定の場合
      l = []#全国
      if(len(l)==0 or pref['properties']['name'] in l):
        #離島も含めた島単位でスクリプトを作成
        print(pref['properties']['name'])
        for land in pref['geometry']['coordinates']:
          maxLat = 0
          maxLng = 0
          minLat = 90
          minLng = 180
          for geo in land[0]:
            if(maxLng < geo[0]):
              maxLng = geo[0]
            if(maxLat < geo[1]):
              maxLat = geo[1]
            if(minLng > geo[0]):
              minLng = geo[0]
            if(minLat > geo[1]):
              minLat = geo[1]
          lat = str(minLat) + ':' + str(maxLat)
          lng = str(minLng) + ':' + str(maxLng)
          row.append(cmdline + '--lat=' + lat + ' ' + '--lon=' + lng)
  #downloadosmtiles.plは改行コードCRLFでは動作しないので注意
  with open(target.stem + '.txt','w', newline='\n') as f:
    f.write("\n".join(row))
```
作成したシェルスクリプトを実行すると、画像ファイルが同一ディレクトリにダウンロードされます。
- 標準ではdownloadosmtiles.plの実行に必要なGeo::OSM::Tilesが入っていないので"sudo apt install libgeo-osm-tiles-perl"
- 小さいPNG画像が大量にダウンロードされるため(海など単一色PNG画像の場合は最小103バイト)、保存先の空き容量及びクラスタサイズに注意してください。
- 全国ズームレベル0-16でも、合計50GB以上になります。(クラスタギャップのため、実際のストレージ使用量はその数倍の可能性)
- ダウンロードにはマシンパワーにもよりますが、数日～１週間かかる場合があります。
    - 負荷が高いため、時間がかかるとApacheが404を返す場合があります。
    - あらかじめApache側でタイムアウトを長めにとるとよい。
    - 404が返るとその部分の画像が抜けるが、もう一度スクリプトを回すと、すでにある画像をスキップして再度ダウンロードを試行してくれる。

## 画像データベースの作成
ダウンロードした画像は小さい画像が多いうえ、同一画像(海や山の部分等、単一色の103バイト)が大量にあります。
クラスタギャップが激しいため、SQLiteのデータベースに突っ込んで容量を節約します。

- Leafletプラグインの[Leaflet.TileLayer.Fallback](https://github.com/ghybs/Leaflet.TileLayer.Fallback)等を使用することで、103バイトの単一色画像が削除可能になり、さらに容量を節約できます。

```python:createDB.py
from pathlib import Path
import sqlite3
dir = Path(r'画像ファイルのダウンロード先')

#ズームレベルごとのディレクトリを取得
def getX(X):
    tables = []
    target = dir / X
    for path in target.glob("*"):
        if not path.is_dir():
            continue
        else:
            tables.append(path.name)
    return tables

#指定ディレクトリ以下のPNG画像を取得
def getImg(Z):
    target = dir / Z
    return target.glob('**/*.png')

if __name__ == '__main__':
    #生成したいDBのズームレベルをしてい
    Z = "16"
    #DBの名称はズームレベル+.db
    db_path = Z + ".db"
    with sqlite3.connect(db_path) as conn:
        try:
            #ズームレベルでテーブルを作成（今後、DBを統一する場合に備えて）
            conn.execute('create table "' + Z + '" (id text, img blob)')
        except Exception as ex:
            print(ex)
        conn.commit()

        imgs = getImg(Z)
        for img in imgs:
            #103バイトの画像（単一色PNG）はDBに含まない。必要な場合はコメントアウト
            if(img.stat().st_size == 103):
                continue
            #検索用のIDは、X座標-Y座標
            id = img.parent.name + '-' + img.stem
            with open(img, 'rb') as f:
                blob = f.read()
                conn.execute('insert into "' + Z + '" values(?,?)', [id,blob])
        conn.commit()
        #高速化のため、indexを生成（selectしかしないDB）
        conn.execute('CREATE INDEX "idx'+Z+'" ON "'+Z+'"(id ASC)')
        conn.commit()
```
このスクリプトを実行すると、(ズームレベル).dbが生成されます。
必要なズームレベル分を作成しましょう。

## クエリ用PHPファイルの作成
### Leaflet等の地図リクエスト用URLを、作成したDBへのクエリ用PHPファイルへ変更します。
```shell:変更前
tile/{z}/{x}/{y}.png
```
```shell:変更後
tile.php?z={z}&x={x}&y={y}
```

### 作成したPHPファイルとDBを同一ディレクトリに置きます
```php:tile.php
<?php
$z = filter_input(INPUT_GET,'z',FILTER_SANITIZE_SPECIAL_CHARS);
$p = null;
//ズームレベルによってDBを振り分け
if((int)$z < 17){
  $p = $z.'.db';
}else if((int)$z < 19){
  // DBを使用せず直接タイルサーバに取りに行く場合
  $s = "http://localhost/tile/".$z."/".$x."/".$y.".png"; 
  $r = file_get_contents($s);
  //PHPは、標準でブラウザがキャッシュしてくれないため、明示的に指定
  header('Last-Modified: Fri, 01 Jul 2022 00:00:00 GMT');
  header('Pragma: cache');
  header('Cache-Control: max-age=8640000');
  header('Content-type: image/png');
  echo $r;
}else{
  header("HTTP/1.1 404 Not Found");
  return;
}

if(file_exists($p)){
  $x = filter_input(INPUT_GET,'x',FILTER_SANITIZE_SPECIAL_CHARS);
  $y = filter_input(INPUT_GET,'y',FILTER_SANITIZE_SPECIAL_CHARS);
  $db = new SQLite3($p,SQLITE3_OPEN_READONLY);
  //数値ではなく文字列でselectをかけるため、indexが効く条件に注意（完全一致）
  $sql = 'SELECT img FROM "'.$z.'" WHERE id="'.$x.'-'.$y.'"';
  $r = $db->querySingle($sql);
  if($r){
    //PHPは、標準でブラウザがキャッシュしてくれないため、明示的に指定
    header('Last-Modified: Fri, 01 Jul 2022 00:00:00 GMT');
    header('Pragma: cache');
    header('Cache-Control: max-age=8640000');
    header('Content-type: image/png');
    echo $r;
  }else{
    header("HTTP/1.1 404 Not Found");
  }
}else{
  header("HTTP/1.1 404 Not Found");
}
?>
```

## 参考
### さらなる容量削減
- 単一色が増えると、さらに容量削減が可能
- 航路、行政境界線、電力線、森林パターン等を削除すると単一色が増える
- タイルサーバのmapnik.xmlを改造して、ferry-route、power-line、admin～等の項目を削除