古事連記帖

趣味のこと、技術的なこと、適当につらつら書きます。

オレオレ逆ジオコーディングサービスを作る その2

逆ジオコーディング、ザックリ言うと緯度経度といった座標から住所を割り出す仕組み、前回は必要な情報をデータベースに入れる前にクエリファイルを準備するところまでやりました。

ayano.hateblo.jp

今回は実際にAPIサーバーを実装して動かすところまでをやってみます。
前回しれっと書いていましたが、以下の環境で実装することを目標にします。

あらかじめVisual Studio 2022と、Dockerを構成するのでDocker Desktopを導入しておきます。
なお、ここから出てくるコード例では名前空間を省いています。お試しになりたい方はご注意ください。

今回はASP.NET Core Web APIプロジェクトを使います。

追加情報の画面では「Docker を有効にする」にチェックを入れます。Docker OSについてはとりあえずLinuxで。
HTTPS 用の構成」はお好みにあわせて…ですがだいたい入れたままの方がいいかと思います。
「OpenAPI サポートを有効にする」のチェックが入っていることを確認します。

プロジェクトを作成すると自動でコンテナが作成されます。実行ボタンが「Docker」になっているので、このまま押せばコンテナ上にビルドが展開され、SwaggerのAPIリファレンスページが出てきます。

では次にプロジェクトの準備をします。
作ったプロジェクトにNuGetで必要なパッケージをインストールします。

  • Microsoft.EntityFrameworkCore
  • Pomelo.EntityFrameworkCore.MySql
  • Pomelo.EntityFrameworkCore.Mysql.NetTopologySuite
    • たぶんこのパッケージを入れれば依存関係で勝手に他2つも入りそうです

次にデータベースから読み取るテーブルの定義を作ります。適当なところにコードファイルを追加します。クラス名およびファイル名は「DbAddress」としておきます。

using Microsoft.EntityFrameworkCore;
using NetTopologySuite.Geometries;
using System.ComponentModel.DataAnnotations.Schema;

public abstract class DbAddress
{
    [Column("gid")]
    public int Id { get; init; }
    [Column("pref_name")]
    public string? Prefecture { get; init; }
    [Column("gst_name")]
    public string? County { get; init; }
    [Column("css_name")]
    public string? City { get; init; }
    [Column("s_name")]
    public string? Aza { get; init; }
    [Column("geom")]
    public MultiPolygon? Geom { get; init; }
}

テーブル定義が抽象クラスになっているのには理由があって、同じテーブル定義は一対一である必要があるっぽく、都道府県ぜんぶ同じテーブル定義だとしてもそれぞれクラスを作らないといけなかったためです。
なので、この定義を継承した各都道府県のクラスをずらっと作ります。プロパティ名がテーブル名と一致していればOKです。

public class Tokyo : DbAddress { }

あんまし作ってもめんどくさいだけなので、とりあえずお試しに一個だけ東京都だけ用意します。
テーブル定義はできました。次にそれを良い感じのデータとして返すためのモデルクラスを作ります。「AddressModel」としておきました。

public record AddressModel
{
    /// <summary>
    /// 都道府県名
    /// </summary>
    public string? Prefecture { get; init; }
    /// <summary>
    /// 郡 (存在する場合) + 市区町村名
    /// </summary>
    public string? Municipal { get; init; }
    /// <summary>
    /// 大字名
    /// </summary>
    public string? Aza { get; init; }
}

最低限この3つだけで充分かと思います。郡と市区町村を混ぜて返すようにしているので、分けたい場合はもう一つ「County」とか作っておくとよいかもしれません。

さらに、これを含むレスポンスデータとして返すためのモデルクラスも作っておきます。「ResponseModel」と名付けます。
リクエストで受け取った緯度と経度をそのまま返すのと、複数の住所が取得できる可能性があるので*1IEnumerableとして列挙できるようにしておきました。

public class ResponseModel
{
    public double Latitude { get; init; }
    public double Longitude { get; init; }
    public IEnumerable<AddressModel> Addresses { get; init; }
}

次にデータベースとやりとりするクラスを作成します。Microsoft.EntityFrameworkCore.DbContextを継承した新しいクラスは「AddressDbContext」とでもしておきましょうか。

// null許容は一旦無効化
#nullable disable

using Microsoft.EntityFrameworkCore;

public class AddressDbContext : DbContext
{
    public AddressDbContext(DbContextOptions<AddressDbContext> options)
    : base(options)
    { 
    }

    public DbSet<Tokyo> Tokyo { get; init; }
}

続いては、いよいよAPIとしてコントローラーを作成します。一般的にはここで「新規スキャフォールディングアイテム」として追加することが多いですが、まだDockerでMariaDBを動かしたりしていないのと、だいたい何がしたいかはわかっているので、シンプルにクラスを作ります。
今回は「ReverseGeocodingController」としました。

「DbAddress」クラスで定義した「Geom」プロパティがここで活躍します。これを使って座標から必要な情報を受け取ります。
今回は大きく2つのメソッドを使うことになるかと思います。

まずは「Contains」メソッド。Listでも見覚えがあると思います。「特定の座標が範囲内に含まれているか」がわかります。図にするとこんな感じかなと

Containsの例

P1は範囲AとB両方に含まれているので、例えばP1を対象にして探索すると両方ともtrueになるので両方が結果として返ってきます。

public class Area
{
    public MultiPolygon Geom { get; init; }
}

DbSet<Area> area; // 範囲AとBが入っている状態と想定
NetTopologySuite.Geometries.Point p1 = new(35, 140);

var result = area.Where(w => w.Geom.Contains(p1));

// result: { A, B }

一方で、P2とP3はそれぞれAとBにしか含まれていないので、探索すると片方だけが結果として返ってきます。

NetTopologySuite.Geometries.Point p2 = new(36, 138);
NetTopologySuite.Geometries.Point p2 = new(37, 139);

var result2 = area.Where(w => w.Geom.Contains(p2));
var result3 = area.Where(w => w.Geom.Contains(p3));

// result2: { A }
// result3: { B }

ここでは記載してませんが、仮にどこにも含まれない点P4があるとしたら、結果は空です。


次に「Distance」メソッドです。こちらは「特定の座標との距離」がわかります。図にするとこんな感じかなと

Distanceの例

主に点でやりとりする際には便利かと思います。距離の近い何点かをピックアップするなどとか。
もちろんMultiPolygonでも使えます。この場合、完全に含まれる場合は結果はゼロで返ります。

public class Area
{
    public MultiPolygon Geom { get; init; }
}

DbSet<Area> area;
NetTopologySuite.Geometries.Point p1 = new(35, 140);
NetTopologySuite.Geometries.Point p2 = new(32, 141);
NetTopologySuite.Geometries.Point p2 = new(33, 139);

var result1 = area.Select(s => s.Geom.Distance(p1));
var result2 = p1.Distance(p2);
var result3 = p2.Distance(p3);

これらを踏まえて、コントローラーは以下のようにしました。

using Microsoft.AspNetCore.Mvc;
using NetTopologySuite.Geometries;

[ApiController]
[Route("geo")]
public class ReverseGeocodingController : ControllerBase
{
    AddressDbContext dbContext;

    public ReverseGeocodingController(AddressDbContext context)
    {
        this.dbContext = context;
    }

    [HttpGet]
    public ResponseModel Get(double latitude, double longitude)
    {
        var point = new Point(longitude, latitude);

        var result = this.dbContext.Tokyo
            .Where(w => w.Geom != null && w.Geom.Contains(point))
            .Select(s => new AddressModel
            {
                Prefecture = s.Prefecture,
                Municipal = s.County + s.City,
                Aza = s.Aza
            });

        return new ResponseModel
        {
            Latitude = latitude,
            Longitude = longitude,
            Addresses = result
        };
    }
}

ここまでできたら、まわりの整備をおこないます。
appsetings.json にデータベースへの接続のための設定を書き込んでおきます。

"ConnectionStrings": {
  "DbContext": "Server=db;Database=reverse_geocoding;User=[データベースにアクセスするためのユーザー名];Password=[パスワード];CharSet=utf8mb4;"
}

ユーザー名とパスワードはあとで別の設定でも使います

Program.cs に以下を追記します。どこでもいいですが、builderのインスタンスを使うのでそのあたりに書いておくといいと思います。

var mariaDbVersion = new MariaDbServerVersion(new Version(15, 1));
var connectionString = builder.Configuration.GetConnectionString("DbContext");

builder.Services.AddDbContext<AddressDbContext>(
    options => options.UseMySql(connectionString, mariaDbVersion,
            o => o.UseNetTopologySuite()
                .EnableRetryOnFailure()));


次に、MariaDBを扱うためにDocker Composeを使います。ざっくり言えば複数のコンテナを一元的に管理するためのツールです。
ソリューションエクスプローラーのプロジェクトを右クリックし、「追加」から「コンテナー オーケストレーターのサポート」を選びます。
出てきたウインドウのプルダウンは「Docker Compose」が選ばれたまま*2なので、そのままOKを押します。次に出るウインドウではターゲットOSはLinuxでOKです。

そうすると、ソリューションエクスプローラーには「docker-compose」という新しい項目が追加され、「docker-compose.yml」が用意されていますので、そこにMariaDBを使うよう追記します。

services:
  db:
    container_name: db
    image: mariadb:latest
    restart: always
    ports:
      - 3306:3306
    command:
      - mysqld
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      - --max_allowed_packet=1G
    environment:
      - MARIADB_ROOT_PASSWORD=[rootが使うパスワード]
      - MARIADB_DATABASE=reverse_geocoding
      - MARIADB_USER=[データベースにアクセスするためのユーザー名]
      - MARIADB_PASSWORD=[パスワード]
      - TZ=Asia/Tokyo
      - LANG=C.UTF-8
    volumes:
      - "./Sql/Init:/docker-entrypoint-initdb.d"

appsetings.json に記述したUserとPasswordはそれぞれMARIADB_USERとMARIADB_PASSWORDでも同じものを使うようにします。
volumesはコンテナにマウントしたいディレクトリとそのマウント先です。こうすることで、docker-compose側に置いたSql/Initディレクトリが、コンテナ側のdocker-entrypoint-initdb.dとリンクします。
そして、MariaDBはdocker-entrypoint-initdb.dディレクトリにあるsqlファイルを見てテーブルを自動で作成してくれます。

次に docker-compose プロジェクトにSqlディレクトリと、その下にInitディレクトリを作成します。
そこに、前回作った住所のクエリファイルを入れます。特に出力後のファイル名を指定していなければ、東京都のであれば「h27ka13.sql」になっているかと思います。ファイル名で特段問題になることはないので、このままでも、わかりやすく「Tokyo.sql」などにするのもどちらでもよいと思います*3

ここまで終わればあとは実行します。実行ボタンが「Docker Compose」になっていることを確認して押します。
そうするとDocker Desktop上で「dockercompose****」といったのができあがり、SwaggerのAPIリファレンスページが出てきます。
このときにデータベースの初期化作業がおこなわれますが、完了を待たずにもう動かせる状態になるので、Docker Desktopを開いてデータベースのコンテナを探してログを開いて、必要なのがすべてロードされているのを待った方がいいかもしれないです。


Table Op Msg_type Msg_text

reverse_geocoding.Tokyo analyze status OK

クエリファイルの末尾に「ANALYZE TABLE `Tokyo`;」が入っていたことで、終わったことを確認することができるのはちょっと便利でした。


ここまでできればもうあとは試すことができます。SwaggerのAPIリファレンスページに「ReverseGeocoding」の項目と「GET | /geo」が追加されているのがわかるかと思います。

右側の「Try it out」を押してから、パラメーター「latitude」「longitude」に座標を入れます。今回は東京都だけを追加しましたので、都内の座標をGoogleマップなどから拾ってきます。
座標はアドレスバーの「@」の右側「@35.6815922,139.7704263」や、マップ上の建物やピンがない道路などをクリックして中央下に表示される簡易情報の座標が使えます。
今回は東京駅のあたりを選んでみます。座標を入力したら、「Execute」をクリックします。

35.681074, 139.766607

そうするとレスポンスが返ってきます。うまくいけば、以下のように「東京都千代田区丸の内1丁目」が得られるかと思います。

{
  "latitude": 35.681074,
  "longitude": 139.766607,
  "addresses": [
    {
      "prefecture": "東京都",
      "municipal": "千代田区",
      "aza": "丸の内1丁目"
    }
  ]
}

やりましたね!できました。
前回自分で作った道路の情報も流し込んじゃいましょう。やることは同じです。データベースのテーブル定義から必要な情報を拾うための定義クラスを作ります。

public class DbRoadName
{
    [Column("gid")]
    public int Id { get; init; }

    [Column("road_name")]
    public string? RoadName { get; init; }

    [Column("road_num")]
    public string? RoadNumber { get; init; }

    [Column("popular_road_name")]
    public string? PopularRoadName { get; init; }

    [Column("geom")]
    public MultiPolygon? Geom { get; init; }
}

この定義とマッピングするDbContextはAddressDbContextに一緒に入れちゃおうと思います。なので、AddressDbContextクラスに以下のプロパティを追記します。

public DbSet<DbRoadName> ExpressWay { get; init; }

テーブル名とプロパティ名が一致していればOKです。今回はテーブル名がExpressWayなのでこうしています。
レスポンスとして返すデータモデルも追加します。

public class RoadModel
{
    public string Name { get; init; }
    public string RoadNumber { get; init; }
    public string? PopularName { get; init; }
}
public class ResponseModel
{
    // 追加
    public IEnumerable<RoadModel> Roads { get; init; }
}

コントローラーのGetメソッドにも、道路名を取得するためのロジックを記入します。住所と同じくポリゴンなのでContainsでだいたい良いかと思います。

public ResponseModel Get(double latitude, double longitude)
{
    // 追加
    var roadNames = this.dbContext.ExpressWay
        .Where(w => w.Geom != null && w.Geom.Contains(point))
        .Select(s => new RoadModel
        {
            Name = s.RoadName ?? string.Empty,
            PopularName = s.PopularRoadName ?? string.Empty,
            RoadNumber = s.RoadNumber ?? string.Empty
        });

    return new ResponseModel
    {
        Latitude = latitude,
        Longitude = longitude,
        Addresses = result,
        Roads = roadNames
    };

あとはdocker-composeプロジェクトの「Sql/Init」ディレクトリに作った道路情報が入ったクエリファイルを追加します。
既に実行済みだと2回目以降MariaDBの初期化処理が走らないので、docker-composeプロジェクトを「クリーン」してあげると、Composeされたコンテナが消し飛びます。これを利用して初期化して立て直します。
これで実行すると以下のように道路名も含まれた状態で返ってくるかと思います。

35.702681, 139.775734

高速道路の位置を外すと道路名が空配列になります。

35.702632, 139.776227

これで動くものがついにできました!
実行速度は、前回の記事と書いたこととちょっと変わってしまいますが、感覚としては最初の1回目はデータベースのロードとかもあって4秒弱掛かる印象ですが、それ以降はすぐに応答がくるので、ずっと動かしている分には問題なさそうな気はします。
今回東京都しか取っていないので、他の道府県を追加する際は、前回の記事に書いたとおりテーブル1個に寄せるか、都道府県単位のテーブルを用意して、まず座標から大まかな位置を特定してから必要なテーブルにアクセスする方式を採るとよいと思います。後者の方が更新しやすいかも。


最後に外のウェブサーバーに公開する方法です。Azureを使わず、ASP.NET Coreが動かせる環境にしたVPSでホストするようにしました。
こうなると結構面倒で、いろいろ試行錯誤した結果、ソリューションに配置したdocker-composeは一切使わず一度フォルダーに発行したものに手を加えてVPSにアップロードしました。
発行ウインドウを表示して、フォルダーを選びます。

これでできあがったもののルートディレクトリに「docker-compose.yml」を追加します。
中身は下記記事が参考になるかと思います。

hnys.jp

これにちょっと手を加えるとこんな感じ。

version: '3.4'

services:
  db:
    container_name: db
    image: mariadb:latest
    restart: always
    ports:
      - 3306:3306
    command:
      - mysqld
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      - --max_allowed_packet=1G
    environment:
      - MARIADB_ROOT_PASSWORD=[rootが使うパスワード]
      - MARIADB_DATABASE=reverse_geocoding
      - MARIADB_USER=[データベースにアクセスするためのユーザー名]
      - MARIADB_PASSWORD=[パスワード]
      - TZ=Asia/Tokyo
      - LANG=C.UTF-8
    volumes:
      - "./Sql/Init:/docker-entrypoint-initdb.d"

  myapp:
    container_name: myapp
    image: mcr.microsoft.com/dotnet/aspnet:6.0
    restart: always
    ports:
      - 5080:80
      - 5443:443
    environment:
      ConnectionStrings__DbContext: [appsettings.jsonに書いたのを上書きしたいときに使う。使わなければ削除]
    volumes:
      - ./app:/myapp
    entrypoint: ["dotnet", "/myapp/myapp.exe"]
    depends_on:
      - db

開発中に使ったのとちょっと違うのは、ASP.NETのコンテナを作る部分が具体的に書かれているところです。これは似たようなものは「docker-compose.override.yml」にも書かれています。
「myapp」は自分で作ったものにあわせます。

できあがったら、VPSにもろもろをアップロードします。
その後ターミナルからSSHなどでアクセスして、docker-compose.ymlのあるディレクトリに移動し「docker-compose up -d」すればコンテナが立ちあがって無事に起動するはずです。
あとはnginxでリバースプロキシを書いてあげれば外から見えるようになります。書き方などはこのMicrosoft Docsあたりが参考になるかと思います。

docs.microsoft.com

Program.csで条件式「app.Environment.IsDevelopment()」を外していなければ、発行後はSwaggerのAPIリファレンスが表示されないので、

http://[addr]/geo?latitude=35.702681&longitude=139.775734

でアクセスすれば結果が返ってくるかと思います。


がんばって端折ったつもりがかなり長くなってしまいました。最初は難しそうだなあと思って敬遠していた逆ジオコーディングも、情報さえ手に入ってしまえば割と面倒なことなく作れることがわかりました。
自分ですべてを賄うので、必要な情報を付加して扱いやすいものが作れるという利点がありますが、ちゃんと定期的にメンテナンスしないと、特に道路は生き物*4なのですぐに陳腐化してしまうところが難しいところかなと思います。
まあ自分で使うだけなら、その辺はあまり気にせずのんびり対応するなりすればいいかなーって思いました。

ちなみに、デジタル庁が「アドレス・ベース・レジストリ」として住所情報のデータベース化を進めています。現状は各住所を点座標で得られるのみにとどまっていますが、今後ポリゴンデータも整備されそうなので注視していこうと思います。

www.digital.go.jp

まああまりこういうのを自分で作らなきゃいけないシーンってあまりないとは思いますが、何かの参考になれば幸いです。
僕自身も、たまに作るサーバーアプリケーションの復習とか、今回Dockerを初めて使ってコンテナ化したりしたので、Dockerの扱い方とかがなんとなくわかった気がしたところが収穫でした。

最後に、このサービスを使った自前アプリのスクショを貼って終わりにします。

いつかはこれをストアに公開したいなーって(画像の状態はエミュレーターを使った上で一部加工しています)

*1:取り方次第

*2:たぶんこれしか選択肢がないはず

*3:テーブル名がDbContextのDbSetで指定した型名と一致していればOKです

*4:高速道路なら追加ばかりですが、一般道に手を出した場合は全国で頻繁に新設・統廃合が起きるのでメンテコストが尋常じゃないはず…