この記事はTimee Advent Calendar 2023シリーズ 1の11日目の記事です。
はじめに
タイミーでバックエンドエンジニアをしている甲斐です。弊社でアドベントカレンダーをやるということで特に話すことも思いつかないなと静観していたら仕掛け人の同僚が巧みに背水の陣を組んできたのでうまく乗せられてしまい、こうしてお目汚しを頂戴する羽目になりました。Qiitaのアカウントは長らくほったらかしていましたが、多少なりともアウトプットをしていた時期がもはや6年、7年も前であるとは月日の流れを実感します。
仕事の内容をそのまま記事に書くわけにはいかない、というのは恐らく誰もが通る悩みごとであると思いますが、私が執筆から遠ざかったのもそれが原因でした。今回の記事はなんとか抽象化するからリジェクトされないといいなあと思いながら、ジオコーディングで悩まされたことをテーマにお話しようと思います。
なやみごと
ジオコーディングとは住所や地名といった情報から緯度や経度を算出することであり、逆ジオコーディングはその逆、緯度や経度から住所を算出することになります。このシチュエーションは皆様にとって馴染みがあるであろう、地図アプリの位置情報ピンと実際の住所の対応というシーンで現代では広く縁がある内容といっていいでしょう。
今回はそんなジオコーディングに関して我々を悩ませた事例をピックアップしますが、何かイカした解決策や画期的なアプローチがあるわけではありません。みんなで一緒に悩んでみようという共感型テーマです。個人として発言するのであれば雑にスクラップアンドビルドを放言しますがご覧の通り今の私の立場は企業の犬。建設的に解決策を見出さねばなりません。
読者諸兄におかれましては「まったくだらしねえな。こうすりゃ一発で解決できるんだよ」とこぞってマウントを取っていただければ私はそのネタを拝借して問題を解決できるというわけですね。しめしめ。
A blotch
皆様は大字(おおあざ)や字(あざ)のつく地名に居住したことはありますか? 私はありません。
様々な識者が指摘する通り、住所とは極めて個別性の強いデータであり、その特殊性について網羅的に頭に叩き込めている人は相当な地理マニアでしょう。地理に関する雑学教養を多少嗜む私程度では知らないことがごまんとあります。
今回のケースに関しては同僚がたまたま地元に縁深い住所をテスト環境に入力していたらピンがズレた、というのを報告してくれて明らかになりました。さすがに番地は省略しますが、以下のような住所です。
佐賀県鳥栖市原古賀町字一本松
これをジオコーディングしてみます。弊社はGeocoderのgemを介してGoogleのGeocoding APIを使用しています。以下がその結果になります。
irb(main):001> Geocoder.search "佐賀県鳥栖市原古賀町字一本松"
=>
[#<Geocoder::Result::Google:0x0000ffff945f3ba8
@cache_hit=false,
@data=
{"address_components"=>
[{"long_name"=>"原古賀町", "short_name"=>"原古賀町", "types"=>["political", "sublocality", "sublocality_level_2"]},
{"long_name"=>"鳥栖市", "short_name"=>"鳥栖市", "types"=>["locality", "political"]},
{"long_name"=>"佐賀県", "short_name"=>"佐賀県", "types"=>["administrative_area_level_1", "political"]},
{"long_name"=>"日本", "short_name"=>"JP", "types"=>["country", "political"]},
{"long_name"=>"841-0071", "short_name"=>"841-0071", "types"=>["postal_code"]}],
"formatted_address"=>"日本、〒841-0071 佐賀県鳥栖市原古賀町",
"geometry"=>{"bounds"=>{"northeast"=>{"lat"=>33.3763303, "lng"=>130.4956929}, "southwest"=>{"lat"=>33.3660957, "lng"=>130.4799932}}, "location"=>{"lat"=>33.3714595, "lng"=>130.4885438}, "location_type"=>"APPROXIMATE", "viewport"=>{"northeast"=>{"lat"=>33.3763303, "lng"=>130.4956929}, "southwest"=>{"lat"=>33.3660957, "lng"=>130.4799932}}},
"partial_match"=>true,
"place_id"=>"ChIJR_aQT0GjQTURICeDQ6m54gE",
"types"=>["political", "sublocality", "sublocality_level_2"]}>,
#<Geocoder::Result::Google:0x0000ffff945f39a0
@cache_hit=false,
@data=
{"address_components"=>
[{"long_name"=>"一本松", "short_name"=>"一本松", "types"=>["political", "sublocality", "sublocality_level_3"]},
{"long_name"=>"安積町日出山", "short_name"=>"安積町日出山", "types"=>["political", "sublocality", "sublocality_level_2"]},
{"long_name"=>"郡山市", "short_name"=>"郡山市", "types"=>["locality", "political"]},
{"long_name"=>"福島県", "short_name"=>"福島県", "types"=>["administrative_area_level_1", "political"]},
{"long_name"=>"日本", "short_name"=>"JP", "types"=>["country", "political"]},
{"long_name"=>"963-0101", "short_name"=>"963-0101", "types"=>["postal_code"]}],
"formatted_address"=>"日本、〒963-0101 福島県郡山市安積町日出山一本松",
"geometry"=>{"bounds"=>{"northeast"=>{"lat"=>37.366097, "lng"=>140.3915957}, "southwest"=>{"lat"=>37.3614109, "lng"=>140.3830468}}, "location"=>{"lat"=>37.3641999, "lng"=>140.3875492}, "location_type"=>"APPROXIMATE", "viewport"=>{"northeast"=>{"lat"=>37.366097, "lng"=>140.3915957}, "southwest"=>{"lat"=>37.3614109, "lng"=>140.3830468}}},
"partial_match"=>true,
"place_id"=>"ChIJHYoVqQdrIGAR6AT4D8w0fuM",
"types"=>["political", "sublocality", "sublocality_level_3"]}>]
見事に候補が複数出てきてしまいました。そして我々のアプリケーションの実装の問題によって、ピンが遥か遠くの福島県を指してしまっていたのでした。
住所の構造
さて、ピンがズレているという結果はわかりましたが、私は生涯で10回以上引っ越しているにもかかわらず、偶然にも字のつく住所に住んだことがないため、この住所データの構造が全くわかりません。まず大字って何? 字って何?というところから始めます。
出典:ZENRIN「地図から見えること「大字と字」」
都市部だと馴染みのない方も多いと思いますが、もともと農山村だった地域を記す住所は「大字(おおあざ)」と「字(あざ)」に分けられます。大字は江戸時代の村を継承した範囲・地名で、字は大字より小さい集落のまとまりにつけられた地名です。明治以降、小さな村は何度も合併を繰り返し、今の市町村の大きさになりますが、江戸時代の村は、今でも市町村内の大字や町名として残り、字は市町村によっては消滅しています。
なるほど、場所によってあったりなかったりするということはわかりました。今回のケースの場合、大字は表記されてないのに字が表記されていないのが余計わからないです。その構造に関してヒントを探していたところ、日常的に住所を扱うスペシャリストとも言える日本郵便のページに辿り着きました。
出典:郵便局「「大字」、「字」が住所に含まれている場合の住所の記載省略について」
「大字」、「字」が住所に含まれている場合の住所の記載省略について
町域名に先だって「大字」「字」の文字が冠されている場合。
→「大字」「字」の文字までの記載を省略することができます。
333-0823 埼玉県 川口市大字 石神976
→333-0823 石神976
「大字」が冠された町域名の後に「字」の文字が続く場合。
→「字」の文字は省略することはできません。
038-3802青森県 南津軽郡 藤崎町 大字 藤崎 字 西村井8-2
→038-3802 藤崎 字 西村井8-2
字を省略できるパターンとできないパターンがあることはわかりましたが、今回のケースの場合は大字の記載がないのでどちらなのか区別が付きません。これはどういうことでしょうか。
さらに調べます。Wikipediaがソースなのは苦しいですが、さすがに鳥栖市に赴いて郷土資料館を訪れるほどのリソースはないのでWebの調べ物ならこの精度が限界でしょう。
出典:Wikipedia「鳥栖市の地名」
1954年に三養基郡鳥栖町・田代町・基里村・麓村・旭村が合併し、鳥栖市が誕生した。当初は合併前の大字が継承され、旧鳥栖町は「鳥栖市××」、それ以外の町村は「鳥栖市○○町××」という形で表記することとなった。以下に、当時の大字を列挙する。
1959年、町名設置が行われ、大字はすべて廃止された。その後も数度にわたり町名設置が行われた。
とりあえず、鳥栖市に大字表記はないことがわかりましたが、字だけ残っている という未知のパターンであることがわかりました。他の自治体で同じようなパターンがあるのかどうか、どう探せばよいのか見当もつかないのが正直なところです。
本質はわからないが対処は簡単
少なくとも我々は今、大字が省略されて字だけ表記するパターンを一般化して考えるのは困難であるということがわかってきました。
ですが、今回のケースに特化して対処することは難しくありません。あからさまに都道府県が違うデータを結果から除外してやるだけで必要なデータが取得できるからです。本件に関しては我々のチームで対処を行い、リリースを行いました。しかし…
非正規住所が牙を剥く
明くる日、「住所の更新に失敗する場合がある」という指摘を受け、「馬鹿な…!我々のロジックは完璧なはずだ…!」とテンプレ噛ませデータキャラのように狼狽しながら原因を探っていました。
我々はわかっていなかったのです。タイミーの創業からの歴史、そしてどこに落とし穴が潜んでいるかということに…!
上記は私が弊社のデバッグアプリで使っているユーザーに入力しているデータです。写真を白抜きして省いているのは自らの肖像権が惜しいからではなく、適当に拾ってきたサカバンバスピスのネタ画像を雑に貼っているからという著作権配慮になります。
それはどうでもいいのですが、住所欄をご覧ください。「東京都」。これは省略や削除を加えていない、ありのままのデータです。テスト環境だから適当でいいだろうと何も考えずに入力したデータです。ここに重要な示唆があります。
弊社の住所欄には、正規化されていない住所が入力されることがある…!
irb(main):021> results = Geocoder.search "東京都"
=>
[#<Geocoder::Result::Google:0x0000ffff869165a0
...
irb(main):022> results
=>
[#<Geocoder::Result::Google:0x0000ffff869165a0
@cache_hit=false,
@data=
{"address_components"=>[{"long_name"=>"東京都", "short_name"=>"東京都", "types"=>["administrative_area_level_1", "political"]}, {"long_name"=>"日本", "short_name"=>"JP", "types"=>["country", "political"]}],
"formatted_address"=>"日本、東京都",
"geometry"=>{"bounds"=>{"northeast"=>{"lat"=>35.8984074, "lng"=>153.9867945}, "southwest"=>{"lat"=>20.4231216, "lng"=>136.0696826}}, "location"=>{"lat"=>35.6764225, "lng"=>139.650027}, "location_type"=>"APPROXIMATE", "viewport"=>{"northeast"=>{"lat"=>36.4408483, "lng"=>141.2405144}, "southwest"=>{"lat"=>34.5776326, "lng"=>138.2991098}}},
"place_id"=>"ChIJ51cu8IcbXWARiRtXIothAS4",
"types"=>["administrative_area_level_1", "political"]}>]
irb(main):023> results.first.state
=> "東京都"
irb(main):024> results.first.city
=> nil
この通り、このようなデータはcityを取得するときにnilになります。
先日リリースしたロジックでは、stateもcityも正常に取得できることを前提としていたロジックであったため、エラーが出てしまったというわけです。
今の私は社歴が浅い身。どういうデータを持っているのかという解像度の低さが出てしまうシーンでした。
この件に関してはstateやcityがnilのこともありえるということを踏まえて実装を修正しました。
理想としては全ての住所が正規化されているといいですが…大量に存在する既存データのことを考えるとちょっとハードルが高いですよね。
氷山の一角にすぎない
そしてここまで書いておいて何なんですが、この件、私と同僚にとってのメインのミッションじゃないんですよね。
つまり、本来の業務を進めながら片手間のリソースで対処せざるを得ない。
なので、住所のズレを完全に解決することではなく、最低限運用対処できる状態に持っていくことが今回のゴールになり、そのためには明らかに都道府県からズレていたり、エラーを吐いているような状態は潰さないといけない、というものでした。
現実に、今も住所のズレがしばしば発生してはCSや営業の皆さんがいい感じに調整してくれているという現実があり、組織の力に感謝しています。
おわりに
読者の皆様は住所を苦もなく扱えているでしょうか。
ぼくのかんがえたさいきょうのかいけつほうを実践しようにも予算と時間がなくて歯噛みしていたりするでしょうか。
弊社のように急拡大している会社であっても、高度で専門的な課題だけでなくどこでも発生しそうな普遍的な事象で頭を悩まされる、等身大の悩み事は付きません。
そして弊社はこういう悩み事に対してともに向き合い、ともに解決する仲間を探しています。
エンジニア採用に興味がおありでしたら、是非調べてみてくださると幸いです。
https://product-recruit.timee.co.jp/