
PostGIS を使って最寄りの世界遺産を探してみた。
はじめに
皆様こんにちは、あかいけです。
最近PostgreSQLの拡張機能である、PostGISというものを知りました。
そもそもPostgreSQLをしっかり触ったことのない私ですが、
今回はPostGISを利用して最寄りの世界遺産を探してみようと思います。
PostGIS is 何?
そもそもPostGISとはなんでしょうか?
めちゃくちゃ簡単に言うと、PostgreSQLで地理空間データを扱えるようにするための拡張機能であり、以下のような様々な機能を有しています。
- 空間データ ストレージ: ポイント、ライン、ポリゴン、マルチジオメトリなどのさまざまな種類の空間データを 2D データと3Dデータの両方で保存可能
- 空間インデックス:場所に基づいて空間データをすばやく検索して取得可能
- 空間関数: 空間データのフィルタリングと分析、距離と面積の測定、ジオメトリの交差、バッファリングなどを可能にする幅広い空間関数
- ジオメトリ処理:簡略化、変換、一般化など、ジオメトリ データを処理および操作するためのツール
- ラスター データのサポート:標高データや気象データなどのラスター データの保存と処理
- ジオコーディングと逆ジオコーディング:ジオコーディングと逆ジオコーディングのための関数
- 統合: QGIS、GeoServer、MapServer、ArcGIS、Tableauなどのサードパーティ ツールを使用して PostGIS操作
見ての通りめちゃめちゃ多機能であり、本記事では超基本的な機能しか利用してません。
AWSでPostGISを使う方法
RDS PostgreSQL、またAurora PostgreSQL、Aurora Serverless PostgreSQLで利用できます。
手順
1.環境構築
まずはTerraformで各リソースを作成します。
これでRDS for PostgreSQLを構築して、Terraformを実行したローカル端末からアクセスできる状態になります。
Terraform
terraform init;
terraform apply;
provider "aws" {
region = "ap-northeast-1"
default_tags {
tags = {
env = "terraform"
app = local.app_name
}
}
}
data "http" "ipv4_icanhazip" {
url = "http://ipv4.icanhazip.com/"
}
locals {
app_name = "nearest-world-heritage-site"
current_ip = chomp(data.http.ipv4_icanhazip.response_body)
allowed_cidr = "${local.current_ip}/32"
}
variable "aws_region" {
default = "ap-northeast-1"
}
variable "master_db_name" {
default = "postgisdb"
}
variable "master_db_username" {
default = "postgres"
}
variable "master_db_password" {
default = "postgres"
}
variable "postgis_db_name" {
default = "lab_gis"
}
variable "postgis_db_username" {
default = "gis_admin"
}
variable "postgis_db_password" {
default = "gis_admin"
}
# VPC
resource "aws_vpc" "postgis_vpc" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${local.app_name}-vpc"
}
}
resource "aws_internet_gateway" "postgis_igw" {
vpc_id = aws_vpc.postgis_vpc.id
tags = {
Name = "${local.app_name}-igw"
}
}
resource "aws_subnet" "public_subnet_1" {
vpc_id = aws_vpc.postgis_vpc.id
cidr_block = "10.0.1.0/24"
availability_zone = "${var.aws_region}a"
map_public_ip_on_launch = true
tags = {
Name = "${local.app_name}-public-subnet-1"
}
}
resource "aws_subnet" "public_subnet_2" {
vpc_id = aws_vpc.postgis_vpc.id
cidr_block = "10.0.2.0/24"
availability_zone = "${var.aws_region}c"
map_public_ip_on_launch = true
tags = {
Name = "${local.app_name}-public-subnet-2"
}
}
resource "aws_route_table" "public_route_table" {
vpc_id = aws_vpc.postgis_vpc.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.postgis_igw.id
}
tags = {
Name = "${local.app_name}-public-route-table"
}
}
resource "aws_route_table_association" "public_subnet_1_association" {
subnet_id = aws_subnet.public_subnet_1.id
route_table_id = aws_route_table.public_route_table.id
}
resource "aws_route_table_association" "public_subnet_2_association" {
subnet_id = aws_subnet.public_subnet_2.id
route_table_id = aws_route_table.public_route_table.id
}
resource "aws_security_group" "postgres_sg" {
name = "postgres-sg"
vpc_id = aws_vpc.postgis_vpc.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
cidr_blocks = [local.allowed_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${local.app_name}-sg"
}
}
resource "aws_db_subnet_group" "postgres_subnet_group" {
name = "postgres-subnet-group"
subnet_ids = [aws_subnet.public_subnet_1.id, aws_subnet.public_subnet_2.id]
tags = {
Name = "${local.app_name}-subnet-group"
}
}
resource "aws_db_parameter_group" "postgres_param_group" {
name = "postgres-param-group"
family = "postgres17"
tags = {
Name = "${local.app_name}-pg"
}
}
resource "aws_db_instance" "postgres" {
identifier = "${local.app_name}-rds"
engine = "postgres"
engine_version = "17.4"
instance_class = "db.t3.micro"
allocated_storage = 20
storage_type = "gp3"
storage_encrypted = true
db_name = var.master_db_name
username = var.master_db_username
password = var.master_db_password
db_subnet_group_name = aws_db_subnet_group.postgres_subnet_group.name
vpc_security_group_ids = [aws_security_group.postgres_sg.id]
parameter_group_name = aws_db_parameter_group.postgres_param_group.name
publicly_accessible = true
skip_final_snapshot = true
deletion_protection = false
tags = {
Name = "PostgreSQL with PostGIS"
}
}
output "admin_connection_command" {
description = "Command to connect to PostgreSQL"
value = "psql -h ${aws_db_instance.postgres.address} -U ${var.master_db_username} -d ${var.master_db_name}"
}
output "postgis_connection_command" {
description = "Command to connect to PostgreSQL"
value = "psql -h ${aws_db_instance.postgres.address} -U ${var.postgis_db_username} -d ${var.postgis_db_name}"
}
なお検証用途なのでRDSのパブリックアクセスを有効にしており、ローカル端末のグローバルIPからのみ接続できる設定にしています。
※ 本番環境ではパブリックアクセスを有効にしないでください
接続するときに毎回パスワードを入力するのがめんどくさいので、
pgpassを作成しておきます。
※ エンドポイントは作成したRDSのものに置き換えてください
# PostgreSQL 認証情報設定
touch ~/.pgpass;
chmod 600 ~/.pgpass;
echo "<エンドポイント>:5432:postgisdb:postgres:postgres" >> ~/.pgpass;
echo "<エンドポイント>:5432:lab_gis:gis_admin:gis_admin" >> ~/.pgpass;
2.PostGIS設定
次にPostGIS設定を行います、手順は以下の公式ドキュメントの通りです。
ドキュメントのコマンドを元に、
以下スクリプトにまとめたので、これを実行します。
psql -h <エンドポイント> -U postgres -d postgisdb -f sql/01-db-user-create.sql;
psql -h <エンドポイント> -U gis_admin -d lab_gis -f sql/02-postgis-setup.sql;
-- ステップ 1: PostGIS 拡張機能を管理するユーザー (ロール) を作成する
CREATE ROLE gis_admin LOGIN PASSWORD 'gis_admin';
GRANT rds_superuser TO gis_admin;
CREATE DATABASE lab_gis;
GRANT ALL PRIVILEGES ON DATABASE lab_gis TO gis_admin;
-- ステップ 2: PostGIS エクステンションを読み込む
CREATE EXTENSION postgis;
CREATE EXTENSION postgis_raster;
CREATE EXTENSION fuzzystrmatch;
CREATE EXTENSION postgis_tiger_geocoder;
CREATE EXTENSION postgis_topology;
CREATE EXTENSION address_standardizer_data_us;
SELECT n.nspname AS "Name",
pg_catalog.pg_get_userbyid(n.nspowner) AS "Owner"
FROM pg_catalog.pg_namespace n
WHERE n.nspname !~ '^pg_' AND n.nspname <> 'information_schema'
ORDER BY 1;
-- ステップ 3: 拡張機能スキーマの所有権を移管する
ALTER SCHEMA tiger OWNER TO gis_admin;
ALTER SCHEMA tiger_data OWNER TO gis_admin;
ALTER SCHEMA topology OWNER TO gis_admin;
SELECT n.nspname AS "Name",
pg_catalog.pg_get_userbyid(n.nspowner) AS "Owner"
FROM pg_catalog.pg_namespace n
WHERE n.nspname !~ '^pg_' AND n.nspname <> 'information_schema'
ORDER BY 1;
-- ステップ 4: PostGIS テーブルの所有権を移管する
CREATE FUNCTION exec(text) returns text language plpgsql volatile AS $f$ BEGIN EXECUTE $1; RETURN $1; END; $f$;
SELECT exec('ALTER TABLE ' || quote_ident(s.nspname) || '.' || quote_ident(s.relname) || ' OWNER TO gis_admin;')
FROM (
SELECT nspname, relname
FROM pg_class c JOIN pg_namespace n ON (c.relnamespace = n.oid)
WHERE nspname in ('tiger','topology') AND
relkind IN ('r','S','v') ORDER BY relkind = 'S')
s;
3.データ格納
次に作成したテーブルにデータを格納します。
psql -h nearest-world-heritage-site-rds.cz8oqm4aqiny.ap-northeast-1.rds.amazonaws.com -U gis_admin -d lab_gis -f sql/03-app-setup.sql;
-- 世界遺産テーブルの作成
CREATE TABLE IF NOT EXISTS world_heritage_sites (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
country VARCHAR(100) NOT NULL,
year_inscribed INTEGER,
location GEOMETRY(Point, 4326) NOT NULL,
description TEXT
);
-- 空間インデックスの作成
CREATE INDEX world_heritage_sites_location_idx ON world_heritage_sites USING GIST (location);
-- 日本の世界遺産データの挿入
INSERT INTO world_heritage_sites (name, country, year_inscribed, location, description) VALUES
('姫路城', '日本', 1993, ST_SetSRID(ST_MakePoint(134.6939, 34.8396), 4326), '白鷺城とも呼ばれる日本を代表する城郭建築'),
('法隆寺地域の仏教建造物', '日本', 1993, ST_SetSRID(ST_MakePoint(135.7356, 34.6147), 4326), '世界最古の木造建築群'),
('屋久島', '日本', 1993, ST_SetSRID(ST_MakePoint(130.5283, 30.3333), 4326), '樹齢数千年の屋久杉が生息する自然遺産'),
('白神山地', '日本', 1993, ST_SetSRID(ST_MakePoint(140.2000, 40.5000), 4326), 'ブナの原生林が広がる自然遺産'),
('古都京都の文化財', '日本', 1994, ST_SetSRID(ST_MakePoint(135.7681, 35.0116), 4326), '京都の寺院・神社・城郭などの文化財群'),
('白川郷・五箇山の合掌造り集落', '日本', 1995, ST_SetSRID(ST_MakePoint(136.9075, 36.2556), 4326), '伝統的な合掌造りの集落'),
('原爆ドーム', '日本', 1996, ST_SetSRID(ST_MakePoint(132.4536, 34.3955), 4326), '広島の原爆被害を伝える負の世界遺産'),
('厳島神社', '日本', 1996, ST_SetSRID(ST_MakePoint(132.3219, 34.2958), 4326), '海に浮かぶ朱塗りの大鳥居で有名な神社'),
('古都奈良の文化財', '日本', 1998, ST_SetSRID(ST_MakePoint(135.8450, 34.6851), 4326), '奈良の寺院・神社などの文化財群'),
('日光の社寺', '日本', 1999, ST_SetSRID(ST_MakePoint(139.6000, 36.7500), 4326), '日光東照宮を中心とする神社仏閣群'),
('琉球王国のグスク及び関連遺産群', '日本', 2000, ST_SetSRID(ST_MakePoint(127.8000, 26.2000), 4326), '沖縄の城跡と関連遺跡群'),
('紀伊山地の霊場と参詣道', '日本', 2004, ST_SetSRID(ST_MakePoint(135.9000, 34.0000), 4326), '熊野古道などの霊場と参詣道'),
('知床', '日本', 2005, ST_SetSRID(ST_MakePoint(145.0000, 44.0000), 4326), '北海道の自然遺産、野生動物の宝庫'),
('石見銀山遺跡とその文化的景観', '日本', 2007, ST_SetSRID(ST_MakePoint(132.4833, 35.1167), 4326), '銀山とその文化的景観'),
('小笠原諸島', '日本', 2011, ST_SetSRID(ST_MakePoint(142.2000, 27.0000), 4326), '独自の生態系を持つ島々'),
('平泉―仏国土(浄土)を表す建築・庭園及び考古学的遺跡群', '日本', 2011, ST_SetSRID(ST_MakePoint(141.1167, 38.9833), 4326), '浄土思想に基づく文化遺産'),
('富士山―信仰の対象と芸術の源泉', '日本', 2013, ST_SetSRID(ST_MakePoint(138.7278, 35.3606), 4326), '日本の象徴的な山とその文化的景観'),
('富岡製糸場と絹産業遺産群', '日本', 2014, ST_SetSRID(ST_MakePoint(138.8917, 36.2556), 4326), '近代製糸業の遺産'),
('明治日本の産業革命遺産 製鉄・製鋼、造船、石炭産業', '日本', 2015, ST_SetSRID(ST_MakePoint(130.4000, 33.5000), 4326), '日本の近代化を支えた産業遺産群'),
('ル・コルビュジエの建築作品―近代建築運動への顕著な貢献', '日本', 2016, ST_SetSRID(ST_MakePoint(139.6000, 35.7000), 4326), '国立西洋美術館を含む近代建築群'),
('「神宿る島」宗像・沖ノ島と関連遺産群', '日本', 2017, ST_SetSRID(ST_MakePoint(130.1000, 33.9000), 4326), '古代の信仰と交易の遺産'),
('長崎と天草地方の潜伏キリシタン関連遺産', '日本', 2018, ST_SetSRID(ST_MakePoint(129.8833, 32.7500), 4326), 'キリスト教迫害と信仰の歴史を伝える遺産'),
('百舌鳥・古市古墳群―古代日本の墳墓群', '日本', 2019, ST_SetSRID(ST_MakePoint(135.4833, 34.5667), 4326), '古代日本の巨大古墳群'),
('北海道・北東北の縄文遺跡群', '日本', 2021, ST_SetSRID(ST_MakePoint(141.0000, 41.0000), 4326), '縄文時代の生活と文化を伝える遺跡群'),
('奄美大島、徳之島、沖縄島北部及び西表島', '日本', 2021, ST_SetSRID(ST_MakePoint(129.3000, 28.3000), 4326), '亜熱帯の生態系と固有種の宝庫');
余談ですが世界遺産の緯度経度って意外と公開されておらず(範囲が広く特定の一点にならないため?)、以下のように地道にGoogle Mapで確認しました。
なのであまり正確な情報ではないですが、検証用途ということで多めに見てください…。
4.使ってみた
以下のように元となる座標情報を入れて、クエリしてみると、
-- 東京駅の座標(139.7671, 35.6812)から近い順に5件表示
SELECT
name,
country,
year_inscribed,
ST_X(location::geometry) AS longitude,
ST_Y(location::geometry) AS latitude,
ST_Distance(
location::geography,
ST_SetSRID(ST_MakePoint(139.7671, 35.6812), 4326)::geography
) / 1000 AS distance_km
FROM
world_heritage_sites
ORDER BY
location <-> ST_SetSRID(ST_MakePoint(139.7671, 35.6812), 4326)
LIMIT 5;
ちゃんと近い順から取得できています。
name | country | year_inscribed | longitude | latitude | distance_km
-------------------------------------------------------+---------+----------------+-----------+----------+--------------------
ル・コルビュジエの建築作品―近代建築運動への顕著な貢献 | 日本 | 2016 | 139.6 | 35.7 | 15.2681405938
富岡製糸場と絹産業遺産群 | 日本 | 2014 | 138.8917 | 36.2556 | 101.47272912695999
日光の社寺 | 日本 | 1999 | 139.6 | 36.75 | 119.54518999733
富士山―信仰の対象と芸術の源泉 | 日本 | 2013 | 138.7278 | 35.3606 | 100.75752323939
白川郷・五箇山の合掌造り集落 | 日本 | 1995 | 136.9075 | 36.2556 | 265.68082183473
(5 rows)
逆に遠い順から取得もできているので、問題なさそうです。
-- 東京駅の座標(139.7671, 35.6812)から遠い順に5件表示
SELECT
name,
country,
year_inscribed,
ST_X(location::geometry) AS longitude,
ST_Y(location::geometry) AS latitude,
ST_Distance(
location::geography,
ST_SetSRID(ST_MakePoint(139.7671, 35.6812), 4326)::geography
) / 1000 AS distance_km
FROM
world_heritage_sites
ORDER BY
location <-> ST_SetSRID(ST_MakePoint(139.7671, 35.6812), 4326) DESC
LIMIT 5;
name | country | year_inscribed | longitude | latitude | distance_km
----------------------------------------+---------+----------------+-----------+----------+------------------
琉球王国のグスク及び関連遺産群 | 日本 | 2000 | 127.8 | 26.2 | 1550.92144796782
奄美大島、徳之島、沖縄島北部及び西表島 | 日本 | 2021 | 129.3 | 28.3 | 1282.44370700322
屋久島 | 日本 | 1993 | 130.5283 | 30.3333 | 1046.61350008921
長崎と天草地方の潜伏キリシタン関連遺産 | 日本 | 2018 | 129.8833 | 32.75 | 966.53623856969
「神宿る島」宗像・沖ノ島と関連遺産群 | 日本 | 2017 | 130.1 | 33.9 | 906.11282827492
(5 rows)
5.アプリっぽくしてみた
ただ単純にクエリするだけだとつまらないので、ちょっとだけアプリっぽくしてみました。
こちらのコードは以下リポジトリに格納しているので、使ってみたい方はご自由にご利用ください。
(世界各国の世界遺産の緯度経度のプルリク待ってます!)
現在地を自動取得するか、郵便番号を手打ちして実行すると最寄りの世界遺産を5つ教えてくれます。
(ただし日本の世界遺産しか入れていないので、日本限定です)
以下は弊社日比谷オフィスの例です。
以下は最近移転した、弊社沖縄オフィスの例です。
さいごに
以上、PostGISを利用して最寄りの世界遺産を探してみました。
個人開発だとコスト面からAWS上でPostgreSQLをがっつり使う機会はないかもしれませんが、
大規模システムで地理情報を扱う場合はかなり有用な拡張機能かと思います。
皆さんもPostgreSQLで地理情報を扱う要件に遭遇したら、
導入を検討してみてはいかがでしょうか。