QGIS Server と QGIS Web Client 2 (QWC2) を中核とする統合的 Web GIS エコ・システムである qwc-services によって Web GIS を構築する作業記録の第6回です。
第1回では、qwc-docker
によって qwc-services
をインストールし、MapCache
とも連携させて、QGIS のプロジェクトを Web 地図として表示するところまでやりました。
第2回では、ユーザの認証と権限管理について調べて、地図やレイヤを閲覧できるユーザを限定したり、ビューワの機能を使用できるユーザを限定したりする方法を確認しました。
第3回では、qwc-services によって実現される「編集」関連の機能に不可欠なデータベース環境である PostGIS
を使えるようにしました。
第4回は、ちょっと寄り道をして、QWC2 Viewer
とは独立したページを Web GIS
に追加する方法として、Python
のマイクロ・ウェブ・フレームワークである Flask
の使用を検討しました。
前回は、qwc-services
と PostGIS
によって実現される編集機能について、必要となる前提条件および具体的な使用方法を説明しました。
今回は地図画像キャッシュの問題を再び扱います。
1. 編集機能と地図画像キャッシュの問題
WebGIS で編集機能が使えるようになると、地物データと地図画像キャッシュとの間に生じるズレが問題となります。
地物データが更新されたのに古い地図画像キャッシュが残っていると、
- 追加した地物が表示されない
- 削除した地物がまだ表示されている
- 位置や形状を変更した地物が古い位置や形状で表示される
- 変更した文字ラベルが古いまま表示される
というようなことが生じるのです。
2. 地図画像キャッシュの無効化
上記のズレの問題を解消するためには、地物データに更新があるたびに影響を受ける範囲の地図画像キャッシュを無効化(削除)する必要があります。
キャッシュ・サーバは当該領域の地図画像データを要求されたときに、有ればそのキャッシュを返しますが、無ければ WMS
サーバに要求して地図画像データを取得し、キャッシュを再作成して返します。(on-the-fly シーディング)
従って、旧くなったキャッシュを無効化(削除)してやりさえすれば良い、ということになりますが、リアルタイムで働くキャッシュ無効化の仕組みを作るとなると、少し工夫が必要になります。
ここでは、キャッシュ無効化の処理の流れを次のようにします。
-
PostGIS
上で地物が更新される- 更新に影響される範囲をログ・テーブルに記録する
- キャッシュ無効化サービスに更新を通知する
- キャッシュ無効化サービスが更新の通知を受ける
- ログ・テーブルから未処理レコードを読みとる
- 更新に影響される範囲のキャッシュを無効化する
- 処理済みフラグを立ててログ・テーブルを更新する
2-1. PostGIS 側の処理
2-1-1. キャッシュ無効化ログ・テーブル
次のようなキャッシュ無効化ログ・テーブル cache_invalidation_log
を作成します。
CREATE TABLE cache_invalidation_log (
id serial PRIMARY KEY,
layer_name text NOT NULL,
bbox_xmin double precision NOT NULL,
bbox_ymin double precision NOT NULL,
bbox_xmax double precision NOT NULL,
bbox_ymax double precision NOT NULL,
event_time timestamptz DEFAULT now(),
processed boolean NOT NULL DEFAULT false
);
- layer_name ... レイヤ名(タイル名)
- bbox_xmin, bbox_ymin, bbox_xmax, bbox_ymax ... 無効化する範囲(CRS = EPSG:3857, 単位 = m)
- event_time ... 更新イベント発生日時
- processed ... 処理済みフラグ
テーブルの作成等には psql
を使用して SQL
を実行するのがお奨めです。
まず、psql
を対話モードで起動します。
psql -U db_user db_name
Docker
コンテナ内にある PostgreSQL
サーバであれば、docker exec
を使って、psql
を起動します。
docker exec -it <container_id> psql -U db_user db_name
pgAdmin4
の GUI
からでもテーブル等の作成は可能ですが、設定可能なオプションが多すぎて、却って使いづらく感じます。
いったん作成したものを修正するのは、pgAdmi4
の方が楽ですけれど。
2-1-2. キャッシュ設定テーブル
レイヤごとのキャッシュ設定データを保持するテーブル cache_config
を次のように作成します。
CREATE TABLE cache_config (
layer_name text PRIMARY KEY,
buff_east_west double precision NOT NULL,
buff_north_south double precision NOT NULL
);
- buff_east_west ... 東西方向のバッファ(単位 m)
- buff_north_south ... 南北方向のバッファ(単位 m)
そして、次のように必要なデータを登録しておきます。
layer_name | buff_east_west | buff_north_south |
---|---|---|
spots | 300 | 100 |
tanada | 10 | 10 |
bld | 10 | 10 |
spots
は Point
型のデータですが、地名・建物名などのラベルを表示する地物であるため、特に左右方向のバッファをかなり大きく取っています。tanada
と bld
は Polygon
型の地物で、現状では文字ラベルを表示していませんので、それほど大きなバッファは必要ありません。
2-1-3. 共通トリガ関数
各レイヤのテーブルからトリガで呼ばれてキャッシュ無効化ログを記録するトリガ関数 log_cache_invalidation()
を作成します。
CREATE OR REPLACE FUNCTION log_cache_invalidation()
RETURNS trigger
LANGUAGE 'plpgsql'
AS $BODY$
DECLARE
bbox geometry;
layer text := TG_ARGV[0]; -- トリガ実行時に渡される
buff_x double precision;
buff_y double precision;
bbox_3857_old geometry := NULL;
bbox_3857_new geometry := NULL;
BEGIN
-- キャッシュ設定を取得
SELECT buff_east_west, buff_north_south
INTO buff_x, buff_y
FROM cache_config WHERE layer_name = layer;
IF buff_x IS NULL THEN
RAISE WARNING 'No cache config for layer %, using default values', layer;
buff_x := 10;
buff_y := 10;
END IF;
-- bbox を取得 /(EPSG:6673)→ EPSG:3857 に変換 / バッファ処理
IF TG_OP = 'DELETE' THEN
bbox_3857_old := ST_Transform(OLD.geom, 3857);
bbox_3857_old := ST_Expand(bbox_3857_old, buff_x, buff_y);
ELSEIF TG_OP = 'INSERT' THEN
bbox_3857_new := ST_Transform(NEW.geom, 3857);
bbox_3857_new := ST_Expand(bbox_3857_new, buff_x, buff_y);
ELSE
bbox_3857_old := ST_Transform(OLD.geom, 3857);
bbox_3857_old := ST_Expand(bbox_3857_old, buff_x, buff_y);
bbox_3857_new := ST_Transform(NEW.geom, 3857);
bbox_3857_new := ST_Expand(bbox_3857_new, buff_x, buff_y);
IF ST_Distance(bbox_3857_old, bbox_3857_new) < 1.0 THEN
bbox_3857_old := ST_Union(bbox_3857_old, bbox_3857_new);
bbox_3857_new := NULL;
END IF;
END IF;
-- BBOX を記録
IF bbox_3857_old IS NOT NULL THEN
INSERT INTO cache_invalidation_log (
layer_name,
bbox_xmin, bbox_ymin, bbox_xmax, bbox_ymax
)
SELECT
layer,
ST_XMin(bbox_3857_old),
ST_YMin(bbox_3857_old),
ST_XMax(bbox_3857_old),
ST_YMax(bbox_3857_old);
END IF;
IF bbox_3857_new IS NOT NULL THEN
INSERT INTO cache_invalidation_log (
layer_name,
bbox_xmin, bbox_ymin, bbox_xmax, bbox_ymax
)
SELECT
layer,
ST_XMin(bbox_3857_new),
ST_YMin(bbox_3857_new),
ST_XMax(bbox_3857_new),
ST_YMax(bbox_3857_new);
END IF;
PERFORM pg_notify('cache_invalidation', layer);
RETURN NEW;
END;
$BODY$;
-
cache_config
テーブルからレイヤのキャッシュ設定を取得- テーブルに設定が無い場合はデフォルト値を設定
- 更新された地物の
geometry
のbbox
を取得- トリガ起動操作別の処理
-
DELETE
... 旧いレコードのbbox
を取得 -
INSERT
... 新しいレコードのbbox
を取得 -
UPDATE
... 旧いレコードと新しいレコードのbbox
を取得- 距離が近ければ結合して旧い方の
bbox
だけにまとめる
- 距離が近ければ結合して旧い方の
-
- 共通の処理
- レイヤの
CRS
からMapCache
用のEPSG:3857
に変換 - バッファ分だけ
bbox
を拡大
- レイヤの
- トリガ起動操作別の処理
- ログにレイヤ名、
bbox
を記録- 旧いレコードの分、または、新しいレコードの分、または、まれに新旧両方
-
cache_invalidation
通知を発行- 以後の処理はキャッシュ無効化サービスにゆだねる
2-1-4. トリガ定義
各レイヤのテーブルに次のようにトリガを定義します。
CREATE TRIGGER trg_cache_log_xxxxxx
AFTER INSERT OR UPDATE OR DELETE ON xxxxxx
FOR EACH ROW
EXECUTE FUNCTION log_cache_invalidation('xxxxxx');
xxxxxx
の所には spots
, tanada
, bld
などのテーブル名(レイヤ名)が入ります。
2-1-5. 動作確認
psql
で次のような SQL
を実行してみます。
UPDATE xxxxxx SET geom = geom WHERE id = 1;
cache_invalidation_log
テーブルに新しいログが記録されたら、正常に動作していると見て良いでしょう。
2-2. キャッシュ無効化サービス
Python
でキャッシュ無効化サービスのスクリプトを作成します。
2-2-1. 準備作業
2-2-1-1. psycopg2-binary
作成するスクリプトが依存するライブラリ psycopg2-binary
をインストールします。
pip install psycopg2-binary
この作業の前提条件として python3
と python3-pip
を dnf
でインストールしておく必要がある場合もあります。
sudo dnf install python3 python3-pip
2-2-1-2. /etc/sudoers
/etc/sudoers
の末尾に以下の行を追加します。
## Allows people in group wheel to execute /usr/bin/mapcache_seed without password
%wheel ALL=(apache) NOPASSWD: /usr/bin/mapcache_seed
これによって、常用するユーザ(e.g. my-accout
)が sudo -u apache mapcache_seed ...
をパスワード無しで実行出来るようになります。
2-2-2. /usr/local/bin/cache_invalidate.py
次の内容で /usr/local/bin/cache_invalidate.py
を作成します。
#!/usr/bin/env python3
import psycopg2
import psycopg2.extensions
import select
import subprocess
import time
import logging
# 設定
DB_NAME = "gisdb"
DB_USER = "gisdb"
DB_PASSWORD = "xxxxxxxx"
DB_HOST = "192.168.20.11"
DB_PORT = 5432
MAPCACHE_CONFIG = "/etc/mapcache.xml"
# ログ設定
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s"
)
logger = logging.getLogger(__name__)
def invalidate_cache(layer_name, conn):
with conn.cursor() as cur:
cur.execute("""
SELECT id, bbox_xmin, bbox_ymin, bbox_xmax, bbox_ymax
FROM cache_invalidation_log
WHERE processed = false AND layer_name = %s
""", (layer_name,))
rows = cur.fetchall()
for row in rows:
id, xmin, ymin, xmax, ymax = row
logger.info(f"Invalidating {layer_name} (id={id})")
cmd = [
"sudo", "-u", "apache", "mapcache_seed",
"-c", MAPCACHE_CONFIG,
"-t", layer_name,
"-e", f"{xmin},{ymin},{xmax},{ymax}",
"-m", "delete"
]
try:
subprocess.run(cmd, check=True)
cur.execute("""
UPDATE cache_invalidation_log
SET processed = true
WHERE id = %s
""", (id,))
conn.commit()
logger.info(f"Successfully processed id={id}")
except subprocess.CalledProcessError as e:
logger.error(f"mapcache_seed failed for id={id}: {e}")
def main():
while True:
try:
conn = psycopg2.connect(
dbname=DB_NAME,
user=DB_USER,
password=DB_PASSWORD,
host=DB_HOST,
port=DB_PORT
)
break
except psycopg2.OperationalError as e:
logger.warning("DB 接続失敗。10秒後に再試行します…")
time.sleep(10)
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
cur = conn.cursor()
cur.execute("LISTEN cache_invalidation;")
logger.info("Listening for cache_invalidation notifications...")
try:
while True:
if select.select([conn], [], [], 10) == ([], [], []):
continue
conn.poll()
while conn.notifies:
notify = conn.notifies.pop(0)
layer_name = notify.payload.strip()
logger.info(f"Received notification for layer: {layer_name}")
invalidate_cache(layer_name, conn)
finally:
cur.close()
conn.close()
def outer_loop():
try:
while True:
try:
main()
except psycopg2.OperationalError as e:
logger.warning(f"DB 接続失敗または切断: {e}. 10秒後に再接続します…")
time.sleep(10)
except KeyboardInterrupt:
logger.info("Exiting by user interrupt.")
if __name__ == "__main__":
outer_loop()
やっていることは、PostGIS
サーバとの接続を開いて、cache_invalidation
通知チャンネルを LISTEN し、
- 通知を受信したら、レイヤ名を通知から取得
- レイヤ名に該当する未処理のログ・データを取得
-
mapcache_seed -m delete
でキャッシュを削除 - ログ・データの処理済みフラグを立てる
- 最初に戻って通知を待つ
という仕事です。
なお、PostGIS
サーバとの接続が確立出来なかったり、途中で切断された場合の再試行を行っているため、若干、構造が複雑になっています。
スクリプトのパーミッションを実行可能に設定しておきます。
chmod 775 /usr/local/bin/cache_invalidate.py
2-2-3. 動作確認
スクリプトを起動して、地物の編集を行って、想定通りにキャッシュが無効化されることを確認して下さい。
/usr/local/bin/cache_invalidate.py
スクリプトは Ctrl + C
で中止できます。
2-2-4. 自動起動・常駐サービス化
以下の内容で /etc/systemd/system/cache_invalidator.service
を作成します。
[Unit]
Description=PostGIS → MapCache Cache Invalidator
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/cache_invalidate.py
Restart=always
RestartSec=5
User=my-account
Group=my-group
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
そして、サービスを有効化し、起動します。
sudo systemctl daemon-reexec
sudo systemctl daemon-reload
sudo systemctl enable cache_invalidator
sudo systemctl start cache_invalidator
3. cron によるシーディング
ここまでの作業で、地物の編集によって必要になるキャッシュの無効化が実現されています。
しかし、無効化(=削除)されたキャッシュの再作成は完全には終っていないのが普通です。
と言うのは、ある領域のキャッシュを無効化すると、全ての縮尺においてその領域のキャッシュが削除されるのですが、削除されたキャッシュが全て再作成されるためには、その領域の全ての縮尺のデータを MapCache
に要求する必要があるからです。ユーザが WebGIS でアクセスしなかった縮尺のキャッシュは再作成されずに残ります。
そこで、深夜、丑三つ時に、ひっそりと補完的かつ包括的なシーディングを行うことにします。
3-1. /usr/local/bin/nigthly_seed.sh
次の内容で /usr/local/bin/nightly_seed.sh
を作成します。
#!/bin/bash
# Layer list
LAYERS=("tanada" "spots" "bld")
# MapCache config path
CONFIG="/etc/mapcache.xml"
for LAYER in "${LAYERS[@]}"; do
echo "Seeding layer: $LAYER"
sudo -u apache mapcache_seed -c "$CONFIG" -t "$LAYER"
done
LAYERS
は必要に応じて追加・修正して下さい。
このスクリプトを実行すると、指定されたレイヤの全ての縮尺において、未作成のキャッシュが作成されます。(作成済みの所はスキップされます。)
3-2. crontab への登録
sudo crontab -e
以下を追加します。
30 3 * * * /usr/local/bin/nightly_seed.sh >> /var/log/mapcache_seed.log 2>&1
- 3:30 AM に実行
- 実行ユーザは
root
- ログは
/var/log/mapcache_seed.log
以上で編集機能と地図画像キャッシュとのズレの問題は解消されます。
And so, what's next?
一連の記事はこれで一通り完了です。次の予定はありません。