検索サービスの構築

エンジニアの堤です。
#Rettyマイクロサービス強化月間第二弾として、検索サービスの構築についてお話します。
第一弾は、id:pikatenor の「マイクロサービスのデータぜんぶ抜く……gRPCで!」でした。

Rettyでは最近、検索機能を新たにマイクロサービスとして切り出し、Search Serviceが誕生しました。
その背景としては、Rettyのお店検索に使われているElasticsearchのバージョンが非常に古く(2.4)、バージョンアップ & アーキテクチャ刷新を行ったというのがあります。
今回はそのアーキテクチャ刷新のうち、Search Serviceの作成の背景・実施内容についてお話します。

Rettyのお店検索

Search Serviceの話をする前に、Rettyのお店検索について紹介します。

ネイティブアプリ(左)・Webページ(右)からのお店検索

Rettyには、

  • iOS/Androidのネイティブアプリからのお店検索(画像左)
  • retty.meなどWebページからのお店検索(画像右)

があります。

基本的に機能としては同じで、エリアやカテゴリ、フリーワードを指定してのお店検索や、詳細条件による絞り込みが行えるようになっています。
また、ワード入力時にキーワードやお店のサジェストが行われます。
今回マイクロサービス切り出し対象となった機能はこのお店検索・サジェスト機能です。

アーキテクチャ(マイクロサービス切り出し前)

検索周辺アーキテクチャ(一部)

マイクロサービス切り出し前のお店検索周辺のアーキテクチャについて、図をもとに説明します。
Rettyではお店検索に全文検索エンジンであるElasticsearchを利用しており、それをAPI Server経由でモノリスWebアプリケーション1やネイティブアプリクライアントに提供しています。
API Serverの提供するお店検索API(以下、旧検索API)は、Rettyお店検索に対応したパラメータを受け取り、レスポンスとしてElasticsearchから返されたお店一覧を表示用データとともにJSONで返すようになっています。
Elasticsearch周辺構成については、本記事では深くは触れません。

マイクロサービス切り出しに関わる背景課題

上で説明したお店検索で使っているElasticsearchについて、今回大きなバージョンアップを行うことになりました。
そして、そのElasticsearchを利用してお店検索を提供しているAPI Serverでは下記の課題がありました。

  1. API Serverそのものの課題として、もともとネイティブアプリのバックエンドとして作られたものだが、Webやその他サービスで使うAPIが追加され責務が肥大化していた
  2. 旧検索APIがElasticsearchのレスポンスフォーマットをほぼそのまま使っており、そのフォーマットへの依存がAPI Server経由でフロントエンドまで広がっている

1の課題解消方針については、今後の開発ロードマップとして、

  • API Serverの責務をネイティブアプリのバックエンドに抑えていくこと
  • Retty開発全体として、各機能をマイクロサービスとして切り出していくこと

があり、検索・サジェスト機能はマイクロサービスとして切り出すこととしました。

2については、もう少し課題詳細を説明します。
ElasticsearchにはSearch APIが存在していて、検索パラメータをもとに一致するドキュメントを返します。
API Serverは、そのSearch APIを利用して旧検索APIを提供しており、リクエストとしてはお店検索特有のパラメータを受け取るようになっています。
ただ、レスポンスについてはSearch APIのフォーマットをほぼそのまま使っており、それがWebアプリケーションバックエンドやWebフロントエンド、ネイティブアプリで利用されていました。

API Serverのサンプルレスポンスはこちらです(多少加工しています)

{
  "_shards": {
...(略)...
  },
  "request": null,
  "hits": {
    "hits": [
      {
        "_id": "100000044459",
        "_index": "restaurants",
        "_score": null,
        "_source": {
          "@timestamp": "2022-06-05T21:15:28.018Z",
          "@version": "1",
          "area_id": 123,
          "restaurant_latlng_modified": "12.3456,123.4567",
          "restaurant_name": "Rettyラーメン",
          "restaurant_name_ja": null,
...(略)...
          "has_parking_lot": null
        },
        "_type": "restaurant",
        "sort": [
          0.1,
          50
        ]
      },
...(略)...
    ],
    "max_score": null,
    "total": 1000
  },
  "timed_out": false,
  "took": 5
}

ElasticsearchのSearch APIのレスポンスのフォーマットがおおよそそのまま渡されています。
これにより生じる課題としては、Elasticsearchのバージョンが上がるケースやElasticsearchに投げるクエリが変更されるケース、また可能性は少ないですが検索エンジン自体を差し替えたいケースなど、様々な理由による検索エンジンレスポンスフォーマットの変更により、API Serverやフロントエンドのコードが変更を必要とすることになります。
逆に言うと、こうした制約があるために、これまでElasticsearchのバージョンアップや検索体験改善の要望がありながら、なかなか開発に着手できなかったという背景があります。
これを解消するためには、新規作成するSearch Serviceでのレスポンスフォーマット変更、そして旧検索APIのレスポンスを利用している各サービスの修正を行う必要があります。

今回のSearch Serviceの構築では、これらの課題を解消することを目指します。

検索マイクロサービスの作成

全体の作業の流れ

今回は、Elasticsearchのバージョンアップの一環としてSearch Serviceの作成を行っています。
Elasticsearchバージョンアップ全体としては下記の作業が発生し、Search Serviceとしては主に4〜6が関連します。

  1. 方針・構成設計
  2. 影響・改修範囲調査
  3. 新バージョンのElasticsearchクラスタ構築
  4. Search Serviceの構築
  5. Webアプリケーション: バックエンド & フロントエンド(スマホ/PC)改修
  6. API Server & iOS/Androidネイティブアプリケーション改修

私の所属チーム(3名)では、2〜4を担当しました。
以下では、4のSearch Serviceの構築を主軸としつつ関係作業にも触れて話していきます。

2〜4の一連の取り組みについては、2022年4月のElasticsearch勉強会で発表した際の動画があるので、よろしければご覧ください。
youtu.be

Search Serviceを取り巻く新アーキテクチャ

移行後の検索周辺アーキテクチャ

移行後の検索周辺全体像は図のようになりました。
移行前と比較すると、下記が異なっています。

  • Elasticsearchの前段にSearch Serviceが作られ、検索利用側からはこのサービスを見るように
    • Webアプリケーションからは、API Serverを見ずに直接Search Serviceを見るように
  • 検索利用アプリケーション(Web, API Server)は、検索結果表示用のお店データをDBから取得するように

今回API Serverの検索機能を切り出してSearch Serviceを作成したので、API Serverの役割や利用側であるWebアプリケーションの接続先が変わりました。
API ServerもWebアプリケーションも、今回の検索においてはいわゆるBackend For Frontend(BFF)の位置づけとなっており、Search Serviceからはお店IDのリストを受け取り(後述)、DBから表示用のデータを取得してフロントエンドに適した形に加工し渡す、という役割を担う形となりました。
これにより、検索面においてはAPI Serverから機能を剥がし、責務の縮小を行うことができました。

理想的には、表示用のお店データもお店詳細情報を提供するマイクロサービスであるRestaurant Serviceから取得したかったのですが、改修範囲が膨れ上がってしまうことから、今回プロジェクトのスコープでは直接DBから取得することになりました。

Search Serviceのインターフェイス

検索機能がAPI Serverからマイクロサービスに変わったことにより、インターフェイスも変更されました。
大きく変更があったのはレスポンスで、旧検索APIではElasticsearchから返されたお店データを含めて返却していましたが、Search Serviceでは検索結果のお店IDリストのみを返すこととしました。
理由としては、

  • これまでのレスポンスフォーマットはElasticsearchのレスポンスをそのまま投げているものであったため、Elasticsearchインターフェイスへの依存を断ち切るという観点で却下
  • Elasticsearchのindexに含まれるお店データは1日古く、DBデータの更新がすぐに反映されないものであったため、レスポンスフォーマットを変えたとしてもElasticsearchのデータを返却するのは望ましくない
    • これは、Rettyでは日次バッチ処理でElasticsearch IndexをDBから作成し、切り替えるという構成を取っているためとなります2
  • Search Serviceとして、お店の情報を返却するのはサービスの責任範囲を越えている

です。

お店データは、RettyではRestaurant Serviceが責任を持っています。表示に使いたい場合はそこから取得してもらうことを想定しています3
Search Serviceとしては、あくまで指定した検索パラメータに対してどのお店が一致するか、そしてそれはどのような順序か、という点にのみ責任を持っています。

実装

ここからは実装の話です。
検索ロジックについてはAPI Server(Java & 一部Kotlin)に存在し、それをマイクロサービス(Go)にあるがまま(As-is)移行しました。その際に検討した、ライブラリ選定と動作検証について紹介します。

ライブラリ選定

GolangからElasticsearchを利用する際のクライアントライブラリとしては、当初はElastic社が公式で提供しているgo-elasticsearchを利用予定でした。
ただ、チームで対案としてolivere/elasticが挙がり、検討した結果こちらが良さそうだったのでolivere/elasticを採用することにしました。

Search Serviceの実装内容のメインはElasticsearchのSearch APIへのクエリが大部分を占めるため、クエリの書きやすさが選定の軸となっています。
公式(go-elasticsearch)とolivere/elasticのそれぞれのクエリの書き方の特徴としては、

  • 公式: ElasticsearchのJSONの検索クエリをほぼそのままGoのinterface型(いわゆる任意型)を用いて構築する
  • olivere/elastic: builder patternを採用しており、Elasticsearch Search APIのクエリフォーマットが隠蔽されている

と異なります。
サンプルクエリを見ると違いがわかりやすいです。
go-elasticsearch

var buf bytes.Buffer
query := map[string]interface{}{
  "query": map[string]interface{}{
    "match": map[string]interface{}{
      "title": "test",
    },
  },
}
res, err = es.Search(
  es.Search.WithContext(context.Background()),
  es.Search.WithIndex("test"),
  es.Search.WithBody(&buf),
)
//...略
int(r["hits"].(map[string]interface{})["total"].(map[string]interface{})["value"].(float64))

olivere/elastic

searchResult, err := client.Search().
    Index("test").
    Query(
        elastic.NewMatchQuery("title", "test")
    ).
    Do(context.Background())

比較結果としては、

  • olivere/elasticの方が記述量が小さく簡潔に書ける
  • olivere/elasticはクエリビルダーに型があるため、論理的に間違ったクエリをコーディングの段階で弾ける
    • go-elasticsearchはGoのinterface型を使うため、クエリ記述に型の制約がなくコーディング時にミスに気付けない

ということでolivere/elasticに軍配が上がりました。

動作検証

本プロジェクトの目的はあくまで裏側の技術負債解消であったため、API ServerからSearch Serviceに切り出すにあたり、挙動の同一性を担保する必要がありました。
ここで問題としては、

  • 既存の検索に対する仕様を正確に説明できるものがない
  • あらゆるパラメータを網羅的に検査できるシナリオテストもない
  • パラメータの組み合わせが膨大なので、すべてを網羅検証するのはそもそも難しい
  • 裏側のElasticsearchのバージョンが異なるため、Elasticsearchに投げるクエリでの比較もできない

ため、動作に問題がないかを検証する方法を検討する必要がありました。

結論としては、実際のリクエストとして飛んできているパラメータをサンプル抽出し、旧検索APIと結果を比較するという方法を採用しました。
また、実装修正の度に毎回検証する必要があるため、検証ツールを作成し、機械的にチェックが行えるようにしました。

検証ツール仕組み
検証ツールの仕組みは図のようになっていて、動作としては下記です。

  1. Datadogから旧検索APIへのリクエストサンプルを取得 or 手動でテストケース追加
  2. APIへのリクエストをもとにSearch Serviceへのリクエストに変換
  3. 新・旧の検索にリクエストを飛ばして結果を得る
  4. 人が読めるようお店データを取得・付与して比較結果を出力

これを用いて検証し、動作に違いがあれば修正する、を繰り返して旧検索APIとの挙動一致を行いました。

今回のマイクロサービス切り出しで得られた恩恵

今回のマイクロサービス切り出しにより、下記の恩恵が得られました。

  • 当初の課題解消
    • API Serverから検索機能を切り出し、API Serverの責務増大を軽減した
    • ElasticsearchのインターフェイスをSearch Service外に出さないようにし、今後のElasticsearchのバージョンアップやクエリ変更に伴うレスポンス変更の影響を大きく削減できた
      • 今後の検索機能の改善をより行いやすくなった
  • その他の恩恵
    • 検索結果に表示されるお店情報がより新しいデータになり、ユーザーさんへの情報提供精度が向上した
    • Elasticsearchクライアントライブラリ選定により、検索クエリ構築のコードが大きく減って見通しが良くなり、今後の開発を行いやすくなった
      • 検索クエリビルダー箇所で 約7000行(うち自前クエリビルダー5000行含む) => 約800行

今回を機により開発しやすくなったSearch Serviceを用いて、今後はRettyのお店検索体験を向上していきます。


  1. Retty初期からWebのRettyを提供してきたPHPのアプリケーション。このモノリスから現在各機能についてマイクロサービス化を進めています

  2. 参考: https://engineer.retty.me/entry/2022/06/03/173923#%E8%89%AF%E3%81%8B%E3%81%A3%E3%81%9F%E3%81%93%E3%81%A8

  3. お店検索結果の表示には、お店詳細データのみならず、口コミや予約状況なども含まれているため、実際にはRestaurant Serviceでは完結しません