🦀

国土数値情報のデータとRustで逆ジオコーディングを実装した

に公開
2025/08/17

初投稿です。個人開発の備忘録的な記事になります。何か不備等ありましたらコメントにてお知らせください。

本記事について

  • 国土数値情報の住居表示住所のデータを使って逆ジオコーディングを実装しました。
  • 普段仕事ではGoとPythonばかり使っているので、今回はRustでの実装にチャレンジしました。
  • さらに少し踏み込んで、API形式の実装にしてみました。

モチベーション

普段の業務において、ユーザの位置情報とその地点の地理情報を取り扱うことが多いため、私にとって逆ジオコーディングは非常に身近な存在です。
自作できたら面白そうだよなーと考えていたところで、幸運にもちょうど会社の先輩も逆ジオコーディングの自力実装をやっており(しかも自分の考えと同じRustを使用)、困ったらそちらを参考にしよう...という気持ちで、とりあえず作ってみることにしました。
しかし、鼻からそれを見て全く同じものを作ると本当にただの2番煎じになってしまうので、

  1. 使用するデータ
  2. API化する際のクレート
  3. 検索ロジックに何を使用するか

をあらかじめ自分で調査して決めてから実装を始めました。

ゴール

今回のゴールはシンプルに、

  • 緯度経度を住所データに変換
  • ローカルホストのAPIにリクエストを送信し、期待通りのレスポンスを得る
    • 入力は緯度経度(lon, lat)
    • レスポンスは住所情報とし、今回は市区町村コードと日本語住所表記

としました。

使用データについて

データは何を使うか悩みましたが、国土数値情報の住居表示住所というデータを利用することにしました。
データ提供サイトには以下のような、住所として存在しない地区のデータは含まれない旨の注意事項の記載がありましたが、個人開発なのでスルーしました。

電子国土基本図(地名情報)「住居表示住所」には、住居表示を実施していない地区のデータは含まれません。

ちなみにデータの拡張子はshpです。

API化する際のクレート

GoやPython同様、Rustにも様々なWebフレームワークがあります。
Githubのスター数が特に多い以下の3つをまず候補にしました。

その中で今回開発するにあたっては、Axumを使用しました。
Goにおけるechoやginとは全く書き方が異なりますが、別方向で直感的に書くことができてわかりやすかった点がまず理由の1つです。

実際に実装したコード
main.rs
use axum::{Router, routing::get};
use std::sync::Arc;
mod handler;
mod rtree;
use std::env;

/// # main
/// ## 説明
/// サーバーのエントリーポイント
/// サーバーを起動し、ルーティングを設定
#[tokio::main]
async fn main() {
    // tracing_subscriberを初期化
    tracing_subscriber::fmt()
        .json()
        .with_max_level(tracing::Level::DEBUG)
        // ログに重複した情報を削除するために必要
        .with_current_span(false)
        // 関数名をログから削除する
        .with_target(false)
        .init();

    tracing::info!("Starting server...");

    // 環境変数を取得してbase_pathに格納
    let base_path_str = env::var("SHAPEFILE_PATH").unwrap_or_else(|_| {
        // 環境変数が設定されていない場合のデフォルトパス
        String::from("")
    });

    // base_pathが空なら設定するように促して終了
    if base_path_str.is_empty() {
        tracing::error!("SHAPEFILE_PATH is not set. Please set it to the path of the shapefile.");
        return;
    }

    // RTreeを作成
    let rtree = rtree::make_rtree(base_path_str);

    // RTreeをArcでラップしてスレッド間で共有
    let state = Arc::new(rtree);

    // ルーターに状態を追加

    // ルーターを作成
    let app = Router::new()
        // `GET /healthcheck` goes to `healthcheck`
        .route("/healthcheck", get(handler::healthcheck))
        // `GET /search` goes to `search_handler`
        .route("/search", get(handler::search_handler).with_state(state));

    // サーバーを起動
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

もう1点、AxumはRocketやActix Webに比べて3年以上後発のフレームワークですが、すでに後者の2つのライブラリに匹敵するスター数を獲得しており、急速な発展が感じられたのも理由です。

検索ロジックに何を使用するか

当初私は、使用しているデータは2次元のポイントデータである(ポリゴンではない)ため、最近傍探索をイメージしKD木での実装を検討しました。
しかし、ロジックの検討段階で生成AIに「地理情報標準ではR木を使うのが普通」と言われてしまいました...。
今回だけの目的を満たすならKD木でも問題ないのですが、今後ポリゴンデータも使ってみたいですし、最近傍だけでなく範囲検索やポリゴン内判定を実装する際にはR木の方が有効になるので、生成AIに素直に従い、R木を使用する方向に切り替えました。

R木の構築にはrstarクレートを使用しました。

実際に実装したコード
rtree.go
use rstar::{AABB, PointDistance, RTree, RTreeObject};
use std::path::Path;

// 地球の平均半径 (キロメートル)
const EARTH_RADIUS_KM: f64 = 6371.0;

/// # MyPoint
/// ## 説明
/// RTreeObjectを実装するための構造体
#[derive(Debug)]
pub struct MyPoint {
    lat: f64,
    lon: f64,
    pub citycode: String,
    pub name: String,
}

/// # RTreeObject
/// ## 説明
/// RTreeObjectトレイトを実装するための構造体
/// RTreeObjectトレイトは、RTreeのオブジェクトが持つべきメソッドを定義している。
impl RTreeObject for MyPoint {
    type Envelope = AABB<[f64; 2]>;

    // RTreeObjectトレイトのメソッドを実装
    fn envelope(&self) -> Self::Envelope {
        AABB::from_point([self.lat, self.lon])
    }
}

/// # PointDistance
/// ## 説明
/// PointDistanceトレイトを実装するための構造体
/// RTreeObjectを実装した構造体に対して、Haversine距離を計算するためのトレイトを実装
impl PointDistance for MyPoint {
    fn distance_2(&self, point_coords: &[f64; 2]) -> f64 {
        // 緯度経度をラジアンに変換
        let lat1_rad = self.lat.to_radians();
        let lon1_rad = self.lon.to_radians();
        let lat2_rad = point_coords[0].to_radians();
        let lon2_rad = point_coords[1].to_radians();

        // 緯度経度の差を計算
        let dlat = lat2_rad - lat1_rad;
        let dlon = lon2_rad - lon1_rad;

        // Haversine距離を計算
        let a = (dlat / 2.0).sin().powi(2)
            + lat1_rad.cos() * lat2_rad.cos() * (dlon / 2.0).sin().powi(2);
        let c = 2.0 * a.sqrt().asin();
        let distance = EARTH_RADIUS_KM * c;

        // 距離の2乗を返す
        distance * distance
    }
}

/// # make_rtree
/// ## 引数
/// - `base_path_str`: shapefileのパスを示す文字列
/// ## 戻り値
/// - `RTree<MyPoint>`: RTreeオブジェクト
/// ## 説明
/// 指定されたパスからshapefileを読み込み、RTreeを作成
/// shapefileデータから、行政区域コード、地名を取得してMyPoint構造体に格納
/// その後、RTreeを作成して返す
/// データを取得する部分は国土数値情報の住居表示住所に関するshapefileを想定
pub fn make_rtree(base_path_str: String) -> RTree<MyPoint> {
    // base_pathをPathに変換
    let base_path = Path::new(&base_path_str);
    let shp_path = base_path.with_extension("shp");

    // shapefileのReaderを作成
    let mut shp_reader = shapefile::Reader::from_path(shp_path.clone()).unwrap();

    // 点群のベクタを作成
    let mut mypoints: Vec<MyPoint> = Vec::new();

    // shpデータから緯度経度を取得してlat_lon_vecに格納、dbfデータから地名を取得してname_vecに格納
    for result in shp_reader.iter_shapes_and_records() {
        let (shape, record) = result.unwrap();

        // shapeを型に応じて処理
        // 今回使用するshpはPoint型でデータが格納されているため、Point型の処理を実装
        match shape {
            shapefile::Shape::Point(pt) => {
                // 緯度経度を取得
                let lat = pt.y;
                let lon = pt.x;

                // Recordのjusho1フィールドを取得してname_vecに格納
                // 例: Character(Some("南加賀屋四丁目"))
                let citycode = record
                    .get("city_code")
                    .and_then(|v| match v {
                        shapefile::dbase::FieldValue::Character(Some(s)) => Some(s.clone()),
                        _ => None,
                    })
                    .unwrap_or_default();

                let name = record
                    .get("jusho1")
                    .and_then(|v| match v {
                        shapefile::dbase::FieldValue::Character(Some(s)) => Some(s.clone()),
                        _ => None,
                    })
                    .unwrap_or_default();

                mypoints.push(MyPoint {
                    lat,
                    lon,
                    citycode,
                    name,
                });
            }
            _ => {
                // 他の型は無視
                tracing::info!("Unsupported shape type");
            }
        }
    }

    tracing::debug!("Number of points loaded from shapefile: {}", mypoints.len());

    // RTreeを作成
    let rtree = RTree::bulk_load(mypoints);
    // RTreeのサイズを表示
    tracing::debug!("RTree size: {}", rtree.size());

    // RTreeの最初の要素を表示して内容を確認
    if let Some(first_point) = rtree.iter().next() {
        tracing::debug!("First point in RTree: {:?}", first_point);
    } else {
        tracing::warn!("RTree is empty.");
    }

    // 完成したRTreeを返す
    rtree
}

実行してみる

shpファイルを保存したパスを環境変数で指定し、cargo runでサーバを起動します。

export SHAPEFILE_PATH=/path/to/your/shapefile
cargo run --release

curlでAPIにリクエストを送ります。

curl "http://localhost:3000/search?lat=34.60971723615912&lon=135.4826683503556"

無事レスポンスを受け取ることができました。成功です。

{"citycode":"27125","address":"御崎三丁目"}

最後に

ここまでお読みいただきありがとうございました。
Rustはコンパイラが非常に優秀で、Visual Studio Codeの拡張機能も非常に使い心地がよいため開発していて非常に楽しい言語であり、(仕事で使っていない私からすると)遊びで使用するプログラミング言語としてちょうどよい言語だと感じているので、これからも個人開発でガンガン使っていきたいです。
DiscordのBotをRustで実装したりもしたので、いつか記事にできればなと思っています。

Discussion