地図上の「指定した地点から n km以内にある何か」を検索する機能を作ってみた

なんのことだかわからないと思われる方もいらっしゃると思いますので、以下のリンクを張っておきます。試してみてください。 (ドメインも、SSLも面倒でまだやっておりません・・・。)

http://152.69.199.50/ リポジトリこちらです。

こちらの記事は、作ったときにやったことやハマったことの覚え書きです。

モチベーション

家探しアプリで地図の中心から n km 以内にある物件を探す、みたいな機能がありますよね。あの機能ってどうやって高速に検索してるんだろうなーと思ったわけです。Elasticsearchの使い方も少しずつわかってきたし、それを使ったアプリを作ってみたいというのもありました。

なので、 GoogleMapを使って、地図の中心から指定した半径にある「なにか」を検索し、地図上に表示する。 というアプリケーションを作ってみることにしました。

なにか を決めた

ぶっちゃけ、地図上でどんなデータを検索するのでもよかったわけです。やりたいのはESを使って高速に地図上の範囲を検索することですから、サンプルデータはなんでもよかったのです。ただ、個数は欲しい。100個や200個ではESの高速さを実感できないと思うので、データの量は多ければ多いほど嬉しい。また、座標で検索するので、座標データは必須でした。Google MapにあるGeocoding APIを使えば住所から座標を引いてくることができるようなのですが、課金が怖いのでやめました。

色々調べてみた結果、このデータを使うことにしました。ある程度大量(日本全国を網羅して27万件)のデータであれば見た目も面白くなるだろうという考えもありました。もっと面白そうなデータがあれば置き換えたいですね。

ローカルでESを立てた

Dockerを使ってローカルでESを立てました。特にいうことありません。

ESにデータを入れた

データをぶちこむ処理をスクリプトにしました。

並列処理で実行するようにしていて、ローカルでは特に問題なしでした。が、本番環境としてOracle Cloudの無料枠にサーバー立ててたんですが(メモリ1GB)データ投入しようとすると、メモリが足りず・・・。ということで、並列処理しつつ、一度に処理する件数に制限を設けるようにしました。

データを入れる際にESのクライアントも自前で作りました。どういうリクエストをすることで検索やインデックスの作成ができるのか理解を深めることができました。

注意したこととしては、データを入れる前に refresh_interval を -1(リフレッシュしない)に設定し、入れ終えたらデフォルト値に戻す、という処理をいれたところです。これをしないと、データを入れる際に結構処理時間がかかるというのがありました。いちいちリフレッシュ処理が挟まって1リクエスト1秒くらいかかってたかも。気のせいかもしれませんが。

また、本番環境はメモリが1GBしかなく、ESに256MBしか分けてあげられませんでした。このため、リフレッシュをせずにデータを投入しまくると、メモリが足りなくて死んじゃう、という問題がありました。詳しくはわかってないのですが、リフレッシュするまではオンメモリにデータを持っているのかなと思いました。で、リフレッシュすると、ディスクに保持するのかなと。

検索の方法を考えた

まあ、単純に座標の周辺を範囲として設定して検索しただけです。ここが工夫ポイントなのかなーとか思っていたのですが、特に問題なく。ESでなく、DBでも似たようなことができたかもしれないなと思っています。最初は、座標の ±0.01° とかで計算していましたが、特に問題ありませんでした。

GoogleMapで表示できるようにした。

検索するしくみがある程度できたので、GoogleMapの中心座標を検索できるようにしてみました。今回、@react-google-maps/apiを使ったのですが、メチャ簡単でとてもよいです。驚くほど簡単にマーカーを表示したり範囲を示したりすることができました。中心座標を検索するにあたり、スクロールイベントが走る度に検索してると負荷がすごいので、debounceを使って負荷を低減しています。

ただ、ひとつ困っていることとしては、検索してマーカーを更新しようとすると、GoogleMapそのものも再レンダリングすることになるため、スクロール途中でもドラッグがキャンセルされてしまうところがあります。 まだ真面目に追っかけていませんが、スクロールしようとすると結構な頻度で引っ掛かりが発生して、けっこうウザいです。これはなんとかしたい。

検索の範囲をメートル単位で指定できるようにした

ブログに書きたかったのはここからです。これがなかなか難しく、面白かったポイントでもあります。ただまあ、数学が苦手だったのであまり深くは考えられておらず、ググるなどで解決しました。

ちょっとだけ理解したことを書いておくと、まず10メートル北に進む、10メートル東に進む、というのはそれぞれ緯度(latitude)・経度(longitude)の計算になりますが、計算式が異なります。緯度、経度の図を見てもらうとわかりますが、北極に近づくと、経度の線同士は近づきます。つまり、赤道周辺と、北極・南極周辺の経度1°あたりの距離は異なるわけです。一方で、緯度の計算は地球の丸さを考慮しなければいけないのはともかくとして、それ以外は考慮しなくてよいので、どこにいても1°は 約111.111...m というふうに計算できます。

まずは、中央の座標と一辺の長さを指定した正方形から、四隅の座標を計算するところから始めました。四隅が計算できれば、ESに範囲を食わせて検索を行います。

計算は(このあたり)https://github.com/naoki-tomita/map-searcher/blob/master/api/Domain.ts#L85-L92でやっているのですが、この計算式をどうやって導出したかというと、もうChatGPTに教えてもらいました。ほんとにすごいですねChatGPT。こちらの拙い説明でよくその回答が出せたなと感心しました。

ChatGPTに教えてもらう

続いて、範囲を正方形から円に変えることを考えました。四隅の座標で検索したあと、中央からマーカーへの距離を計算して、その距離が半径よりも大きければ範囲外(円の外にある)と考えられます。半径よりも小さければ円の内側にあると言えます。では、地球のとある座標からとある座標までの距離はどのように計算できるでしょうか?私はさっぱりわからなかったので、ChatGPTに教えてもらいました。

座標間の距離を計算する方法

ハーバーサインの公式を使うそうです。ハーバーサインさんはすごいですね。そして、この回答が出せるChatGPTもすごいです。少し苦労しましたが、結果として座標と半径を指定した範囲内に含まれる場所のリストを高速に検索できるようになりました。

私がここで工夫したことといえば、検索APIをDDDで表すことができないかと思って色々やってみたことです。ただ、数学の基礎知識が弱く、うまくドメインに落とし込めたかというとそうでもない気がします。計算処理などは、ドメインを使わず素のデータで計算しちゃってますし。でもまあ、これをドメインに落とし込むと、さらに複雑な形になって、複雑度が増すので、やらない方がよさそうだとも思いました。

Oracle Cloundにデプロイした

Oracle Cloud Computingを使うと、AMD CPUを積んだ仮想マシンを2台まで無料で利用できます。1台あたりメモリは1GBと貧弱ですが、1台でも十分にdocker composeで今回のアプリケーションをデプロイできる程度の性能はあります。 ただ、ESを使うにあたり、いくつか対処したことがあるので、書いておきます。

デフォルトではメモリが足りず(おそらく)、起動しない

おそらくというのは、何も設定せずに起動すると、一行警告ログを吐いて、黙ってアプリケーションが落ちてしまうからです。警告ログもメモリに関することでもなかったため、トラブルシューティングはなかなか難航しました。マシンのメモリがしょぼいのはわかっていたので、メモリに関係あるだろうということで、ESが使用できるメモリを大幅に制限したところ、起動するようになりました。

データを投入する際にもメモリが足りない場合があった

まず、データ投入するプログラムの実行でメモリが足りず死にました。これはあまり何も考えずにプログラムを書いたのも良くなかったのですが、並列でESにリクエストを送るという処理を書く際に、データの数だけラムダ関数を生成するような実装になっていました。データが20万件あるので、同じ数だけラムダ関数が生成されることになります。関数を使えばメモリが解放されるとはいえ、これはメモリを食う書き方だったようで、メモリが足りなくなってしまいました。対処としては、ラムダ関数の生成の上限が10000件になるようにプログラムを書きました。10000件ずつ処理するようにしたわけです。

次に、ESそのものがメモリが足りず死にました。#ESにデータを入れた でも書きましたが、リフレッシュを行わずにデータを溜め込んでいるとよくないようで、途中でメモリが足りなくなり、ESが死にました。回避する方法として、10000件ごとに明示的にリフレッシュを入れるようにしました。

ESのデータを保存するため、Volumeマウントしていたが、なんかうまくいかなかった

なぜか、VolumeマウントしたディレクトリにESのユーザーがアクセスできず、死んでいました。ググってみると、起動時にchownするといいよ!みたいなことが書いてあったのですが、そこまでするのは面倒だなと思い、Volumeマウントをやめました。ここはもう少し調べたほうがいいなと思いました。

今後やりたいこと/issue

上限1000件を取得できるようにしているが、なんか1000件取れないことが多い

ESの理解が浅いからかもしれませんが、上限1000件と設定して、検索範囲を広げても、1000件もデータが取得できないことが多いです。1000件以上あるはずなのになーと思うんですが、900件前後しか取得できないことが多いです。さらには範囲を途方もなく広げた場合になぜか検索結果が0件になってしまうことがあります(北海道だけなぜか取れたりするのも謎)。このあたりも是非とも調べて解決したいところです。

データ投入でダウンタイムが発生しないようにする

今は初回のデータ投入でおしまいなのであまり気にしていませんが、データの更新は仕組み上、データを消してから入れる方式にしています。データが入りきるまで4000秒程度かかります。つまり、ユーザーにとっては1時間程度のサービス停止になるわけです。これはよろしくありません。ESの運用を正しく知るためにも、エイリアスを張ってエイリアスの付け替えでダウンタイムゼロでデータを投入できるようにしたいと考えています。

ESの _source を使わない

DBを持たないアプリケーションであるため、座標や表示名をESから取得した _source から取り出して使っています。しかし、そもそも _source を使うのは邪道であり、ESから取得する検索結果はidのみを使うのが正式とされています(という認識)。ここはきちんとSQLiteでよいのでデータを用意して、 _source をつかうのをやめたいです。

緯度、経度が0度をまたぐところでも検索できるようにする

データがないのでできませんが、0度をまたぐところで、うまく検索ができるのか見ものです。数値がマイナスになったときに、うまく検索できるのか(いまの範囲検索だと無理)というところは、グローバルなアプリケーションを作るためにも対応が必須でしょう。今回は不要ですが、やる必要があると思います。

以上

ESの勉強のために色々とやってみましたが、なかなか勉強になったなと思います。(どちらかというと、地図上の検索という部分の知識がついた) 今後もこのアプリを改良できればと思っています。

以上です。よろしくお願いいたします。