inSmartBank

AI家計簿アプリ「ワンバンク」を開発・運営する株式会社スマートバンクの Tech Blog です。より幅広いテーマはnoteで発信中です https://note.com/smartbankinc

Hono + Deno で住所分割APIサーバーを2日で爆速実装する

はじめに

サーバーサイドエンジニアの mokuo です。普段はカード決済やあとばらいチャージに関連する機能の開発や運用を行っております。

本記事でお話すること

日本の住所を都道府県、市区町村、丁目番地、それ以降などに分割する方法の検討から技術選定、実際に動いているコード(ほぼそのまま)をお見せします。

想定読者

サーバーサイドエンジニアを主な読者として想定しています。

  • 日本の住所を分割する機能の実装方法を知りたい方
  • Deno*1や Hono*2 の採用事例、実装例を知りたい方

🤔 背景と課題

先日、公的個人認証サービス(JPKI)を導入しました 🎉

これにより、マイナンバーカードを読み取るだけで本人確認できるようになりました。

prtimes.jp

上記の公的個人認証サービス(JPKI)で取得できる住所情報は1つの文字列になっています。

例) 東京都品川区西五反田7-22-17 五反田TOCビル11F

しかし、弊社のサービスでは住所を以下の4つに分割して扱っているため、取得した住所をそのまま扱うことができないという問題がありました。

  • 都道府県
    • 例) 東京都
  • 市区町村
    • 例) 品川区
  • 丁目番地
    • 例) 西五反田七丁目22-17
  • それ以降
    • 例) 五反田TOCビル11F

🎯 住所分割の方針検討

上記の住所に関する課題に対して、主に2つの方針を検討しました。

  1. システムを改修し住所を分割せず扱えるようにする
  2. 住所を分割する

前職で住所を扱う機能を開発していた経験から、日本の住所には複雑なパターンがあり、住所分割の精度を100%にすることは不可能だと考えています。*3 よって、まずは住所を分割せずに済む方法を検討しました。

しかし、弊社のシステムには住所が分割されている前提の処理や外部のシステムに依存している箇所も多くあることから、既存の処理に極力影響を与えないよう住所を分割する方針としました。

✂️ 住所分割方法: normalize-japanese-addresses に決定

弊社のコアとなるサービスは Ruby on Rails で実装されていて、このサービスから住所分割処理を呼び出す必要があります。

core-api で住所分割処理を呼び出したい

住所分割方法として、主に4つの手段を検討しました。

  • ① Ruby で独自実装
  • ② Ruby の住所分割ライブラリ
  • ③ Ruby 以外の住所分割ライブラリ
  • ④ 外部サービスAPI

① Ruby で独自実装

ロジックを検討する上で、分割対象の住所がどのような性質のものかを調査する必要があります。 また、対象の住所を少なくとも数百件はランダムサンプリングして精度検証を行う必要があるでしょう。

しかし、マイナンバーカードから取得できる住所のパターンについては、本番運用が始まるまでは分からないという問題がありました。(注釈の通り)

マイナンバーカードからどのような住所が取得できるか分からない

住所分割がユーザーに提供するコア価値であれば独自実装をして工数をかけて精度を改良する選択もあり得るかもしれません。しかし今回はシステム都合で分割する側面が強いため、独自実装は行わないことにしました。

参考:

qiita.com

qiita.com

② Ruby の住所分割ライブラリ

調査の結果、以下の2つのライブラリが候補に上がりました。

採用するなら前者だと考えましたが、Rails の Docker イメージに node の実行環境を追加する必要が生じるため、見送ることにしました。

③ Ruby 以外の住所分割ライブラリ

Geolonia さんが公開されているnormalize-japanese-addressesライブラリが最有力候補として上がりました。 *4 使い方等の詳細は以下のサイトをご覧ください。

github.com

デジタル庁が公開している住所データを加工・整理した市区町村や丁目番地までのデータを用いて、高い精度で住所分割ができます。また前職での利用実績もあり、一部のエッジケースを除いて問題なく住所分割できることが分かっていました。

blog.geolonia.com

④ 外部サービスAPI

郵便番号に関する API などは見つかりましたが、住所分割機能を提供している有力なサービスは発見できませんでした。

✅️ 方針まとめ

Ruby で独自実装する案を除くと、ほぼ https://github.com/geolonia/normalize-japanese-addresses しか選択肢がない状況と判断して、こちらのライブラリを採用することにしました。

🧩 npm ライブラリの組み込み: Hono + Deno に決定

前提条件

Rails の Docker イメージに node の実行環境を追加する必要が生じるため、Ruby ランタイムから npm ライブラリを呼び出すという方法は行わないことにしました。 よって、新規に API サーバーを構築することにしました。

Rails から住所分割APIを呼び出す

今回は RDB などのデータベースを持たない小さな API なので、丸ごと作り直すことも可能であり新しい技術スタックを試す良い機会だと考えました。

npm ライブラリを動かすことができる環境を前提として、主に2つの観点で技術選定を行いました。

  • ① Webフレームワーク
  • ② JavaScript ランタイム

① Webフレームワーク

無難に行くなら Express.js だと考えましたが、前述の通り新しい技術スタックを試す良い機会であったことと、触ったことがあったメンバーがいて好感触だったため Hono を採用することにしました。 hono.dev

Hono は様々な JavaScript ランタイムをサポートしていて、AWS Lambda で実行することも可能です。

Hono について気になる方は以下のポッドキャストもぜひ聴いてみてください! audee.jp

② JavaScript ランタイム

Node.js, Deno, Bun を比較検討しました。

パフォーマンス

Hono との相性という観点では、Deno か Bun が良さそうです。

x.com https://dev.to/probir-sarkar/honojs-benchmark-nodejs-vs-deno-20-vs-bun-which-is-the-fastest-413jdev.to

実際に触ってみる

公式ドキュメントなどを参考にプロトタイプ実装を行って比較しました。

import { Hono } from 'hono'
import { normalize, config } from "@geolonia/normalize-japanese-addresses";

config.japaneseAddressesApi = "file:///path/to/address_data"

const app = new Hono()

app.get('/', async (c) => {
  const input = "品川区東五反田1-8-12 小原サンデンビル4F"
  const result = await normalize(input)
  return c.json(result)
})

Deno.serve(app.fetch)

config.japaneseAddressesApifile:// 形式で指定することで normalize-japanese-addresses ライブラリから参照する Geolonia住所データ のパスを指定できます。

たった数行のコードで、住所分割した結果を返す HTTP API を実装することができました。

住所分割結果

✅️ Deno を採用

実際に触ってみたり、以下のようなサイトを参考にして Deno と Bun のメリットは以下だと感じました。

  • Deno
    • テストツールや Linter、Formatter などが標準で用意されていたり tsconfig.json がない(作成することもできる)など、最小構成で始めやすい
  • Bun
    • Node.js との互換性が高くマイグレーションコストが低い

前述の通り今回は小さな API で作り直すことも可能なため、マイグレーションコストは重要ではありません。

最小構成で素早く実装できること、依存するライブラリを減らしてメンテナンスコストを抑えられるメリットなどを考慮して、Deno を採用することにしました。

🏠️ インフラ構成: ECS + EFS に決定

SRE(Site Reliability Engineering)チームと相談し、主に2つの観点でインフラ構成を検討しました。

  • ① API サーバーをどこで動かすか
  • ② normalize-japanese-addresses ライブラリから参照する住所データをどこに置くか

① API サーバーをどこで動かすか

ECS, Lambda, App Runner 等が候補に上がりましたが、今回はスケジュールに限りがあったため、社内で実績があり構成が流用しやすい ECS を選択しました。

また、参考情報として日次や時間帯ごとの API 利用頻度などの予測値を算出して判断しました。

既存機能の利用頻度から新規 API の利用頻度を予測

② normalize-japanese-addresses ライブラリから参照する住所データをどこに置くか

S3 に置いて ECS インスタンス起動時にダウンロードする方法なども検討しましたが、住所データの容量が 5GB と大きめだったため、EFS(Elastic File System)を使ってマウントする構成としました。*5

https://aws.amazon.com/jp/efs/ より引用

💪 2日で実装する

🏃 deno init を実行

Deno - Hono に従い deno init --npm hono xxx を実行しました。

% deno init --npm hono xxx
⚠️ Do you fully trust npm:create-hono package? Deno will invoke code from it with all permissions. Do you want to continue? [y/n]
> y
create-hono version 0.14.3
✔ Using target directory … xxx
? Which template do you want to use? deno
✔ Cloning the template

生成されたファイルは以下の通りで、非常にシンプルな構成でした。

.
├── .vscode
│   ├── extensions.json
│   └── settings.json
├── README.md
├── deno.json
├── deno.lock
└── main.ts
// deno.json
{
  "imports": {
    "hono": "jsr:@hono/hono@^4.6.20"
  },
  "tasks": {
    "start": "deno run --allow-net main.ts"
  },
  "compilerOptions": {
    "jsx": "precompile",
    "jsxImportSource": "hono/jsx"
  }
}
# main.ts
import { Hono } from 'hono'

const app = new Hono()

app.get('/', (c) => {
  return c.text('Hello Hono!')
})

Deno.serve(app.fetch)

📂 ディレクトリ構成

レイヤードアーキテクチャなどを参考に、プレゼンテーション層とドメイン層を切って実装を行いました。

.
├── README.md
├── deno.json
├── deno.lock
├── domain/
├── main.test.ts
├── main.ts
└── presentation/

👨‍💻 実装

一部簡略化していますが、概ね以下の通りに実装しています。

main.ts

import { OpenAPIHono } from "@hono/zod-openapi";
import { swaggerUI } from "@hono/swagger-ui";
import {
  addressNormalizeHandler,
  addressNormalizeRoute,
} from "./presentation/address/address_normalize_handler.ts";

export const app = new OpenAPIHono();

app.openapi(addressNormalizeRoute, addressNormalizeHandler);

app.doc("/doc", {
  openapi: "3.0.0",
  info: {
    version: "1.0.0",
    title: "Address Normalize API",
  },
});

app.get("/ui", swaggerUI({ url: "/doc" }));

Deno.serve(app.fetch);

Hono 公式ドキュメントに掲載されているミドルウェアを利用しています。

deno run でサーバーを起動して http://0.0.0.0:8000/ui にアクセスすると OpenAPIドキュメントを参照することができます。

また、http://0.0.0.0:8000/doc から OpenAPIドキュメントの JSON を取得することもできます。

/ui から OpenAPI ドキュメントを参照できる

presentation/address/address_normalize_handler.ts

import { z } from "@hono/zod-openapi";
import { createRoute, RouteHandler } from "@hono/zod-openapi";
import { normalize } from "../../domain/address/normalize.ts";
import { ErrorSchema } from "../error_schema.ts";

const AddressParams = z.object({
  address: z.string().openapi({
    description: "分割対象の住所",
    example: "東京都品川区東五反田1-8-12 小原サンデンビル4F",
  }),
});

// ref: <https://github.com/geolonia/normalize-japanese-addresses?tab=readme-ov-file#normalizeaddress-string-option-option>
const NormalizedAddress = z.object({
  pref: z.string().nullable().openapi({
    description: "都道府県",
    example: "東京都",
  }),
  city: z.string().nullable().openapi({
    description: "市区町村",
    example: "品川区",
  }),
  town: z.string().nullable().openapi({
    description: "大字・丁目",
    example: "東五反田一丁目",
  }),
  addr: z.string().nullable().openapi({
    description: "街区符号・住居符号または地番",
    example: "8-12",
  }),
  other: z.string().openapi({
    description: "正規化できなかった文字列",
    example: "小原サンデンビル4F",
  }),
  level: z.number().openapi({
    description: "住所正規化レベル(住所文字列のどこまでを判別できたか)",
    example: 8,
  }),
});

export const addressNormalizeRoute = createRoute({
  method: "post",
  tags: ["address"],
  path: "/api/v1/addresses/normalize",
  request: {
    body: {
      content: {
        "application/json": {
          schema: AddressParams,
        },
      },
    },
  },
  responses: {
    200: {
      content: {
        "application/json": {
          schema: NormalizedAddress,
        },
      },
      description: "受け取った住所を正規化して返却する",
    },
    500: {
      content: {
        "application/json": {
          schema: ErrorSchema,
        },
      },
      description: "想定外のエラー",
    },
  },
});

export const addressNormalizeHandler: RouteHandler<
  typeof addressNormalizeRoute
> = async (c) => {
  const reqJson = c.req.valid("json");
  try {
    const normalizedAddress = await normalize(reqJson.address);
    return c.json(normalizedAddress, { status: 200 });
  } catch (e) {
    return c.json({
      error: {
        code: "internal_server_error" as const,
        message: "想定外のエラーが発生しました",
      },
    }, { status: 500 });
  }
};

実装に当たって以下のドキュメントを参考にして、住所分割APIのエンドポイントに関する情報は全て address_normalize_handler.ts にまとめる構成にしました。

domain/address/normalize.ts

import {
  config,
  normalize as geoloniaNormalize,
} from "@geolonia/normalize-japanese-addresses";

config.japaneseAddressesApi = `file://${Deno.env.get("ADDRESS_DATA_PATH")}`;

export type NormalizedAddress = {
  pref: string | null;
  city: string | null;
  town: string | null;
  addr: string | null;
  other: string;
  level: number;
};

export async function normalize(address: string): Promise<NormalizedAddress> {
  const result = await geoloniaNormalize(address);
  return {
    pref: result.pref || null,
    city: result.city || null,
    town: result.town || null,
    addr: result.addr || null,
    other: result.other.trim(), // NOTE: パースできなかった文字列がここに入るので、前後にスペースが入ることがある
    level: result.level,
  };
}

normalize-japanese-addressesライブラリの戻り値をなるべく素朴に返す実装にしました。以下を参考に住所データのパスを環境変数で受け取るようにしています。

docs.deno.com

おわりに

プロトタイプ実装を行っていたこともありますが、シンプルでドキュメントも充実している Hono と Deno を組み合わせることで、住所分割 API サーバーを2日かからず実装することができました。

TypeScript の型推論による VSCode のコード補完、OpenAPI ドキュメントの自動生成など、開発していて気持ち良い体験でした。

方針検討の過程や技術選定、実装コードなど参考になれば幸いです。

株式会社スマートバンクではエンジニアを募集しています。気になる方は以下のページなどもご覧ください。ぜひご連絡お待ちしております!

smartbank.co.jp

*1:JavaScriptランタイム(https://deno.com/)

*2:JavaScript ランタイムで動く Web フレームワーク(https://hono.dev/)

*3:アドレス・ベース・レジストリ|デジタル庁 の「取組の背景」に記載の通り、住所・所在地には市区町村が管理する住居表示と登記所が管理する地番があり、地域によっても特殊なケースがあるようです。例えば正規表現で住所分割処理を実装した場合、運用していくうちに想定外のパターンが増えて秘伝のタレのような正規表現になるでしょう。経験上、それでも対応できない住所については辞書データを整備して対応する可能性が高いと思います。

*4:その他に有力なものは Java 製のライブラリ以外見つけることができませんでした。

*5:Dockerイメージに住所データを含める案も検討しましたが、住所データは今のところ月単位の定期更新の予定で、API サーバー自身のデプロイサイクルと異なる(コード修正やライブラリ更新など)ことを考慮して、別々で更新できる構成としました。

We create the new normal of easy budgeting, easy banking, and easy living.
In this tech blog, engineers and other members will share their insights.