バイセル Tech Blog

バイセル Tech Blogは株式会社BuySell Technologiesのエンジニア達が知見・発見を共有する技術ブログです。

バイセル Tech Blog

HasuraのRemote Schema to Database Relationshipsを用いたElasticsearchとの検索連携

サムネイル

はじめに

こんにちは。開発2部の尾沼です。

私の所属するチームでは最近、HasuraとElasticsearchを組み合わせて検索を行えるようにしました。今回はこれを実現するために私たちが採用した方法を紹介したいと思います。

背景

私たちのチームではのHasuraとPostgreSQLを用いてシステムを構築しています。これまでフロントエンドからデータの絞り込み検索を実行する場合は、HasuraがPostgreSQLから自動生成したQueryに検索条件を指定して実行することで実現していました。

しかし、検索の要件が複雑になり、今後は上記の検索方法に加えてElasticsearchを利用した検索も必要となってきました。

Hasuraは2023年2月時点では直接Elasticsearchに繋ぐことはできません。対応予定はあるとのことですが、今回は別の方法で連携できるようにすることにしました。

そこで、私たちはRemote Schemaを用いて間接的にHasuraとElasticsearchを連携させることにしました。 また、連携の際に「Remote Schema to Database Relationships」を用いることでPostgreSQLとElasticsearchのデータの統合をスムーズに行うことができたので、今回はそちらも交えて紹介をしたいと思います。

採用した構成

今回採用した構成は以下の図のようになります。

今回採用した構成
今回採用した構成

Elasticsearchとやり取りを行うGraphQLサーバーを配置し、Remote SchemaとしてHasura経由で利用できるようにしました。フロントエンドからはHasuraのエンドポイントのみを参照するだけで済むようになっています。

また、当初はElasticsearchで取得したデータをそのままフロントエンドに返すことを想定していましたが、実際の利用シーンでは、検索のためのインデックスだけをElasticsearchに入れておいて、それ以外の実データはPostgreSQLから取得するという方法を取りました。

最初に私たちは、Elasticsearchへの問い合わせでヒットしたデータのユニークキーを受け取り、それを用いてHasuraが自動生成したQueryを叩くことで実現できると考えました。

しかし、このやり方だと2回Queryを叩かないといけなくなるため、無駄にリクエストが増えてしまいます。また、Elasticsearch側での検索結果の並び順とHasuraが自動生成したQueryの検索結果の並び順がずれてしまいます。

2回Queryを叩かないといけない

そこで、Hasuraが提供するRemote Schema to Database Relationshipsという機能を利用することで、私たちは上記の問題を解決しました。

Hasuraにはデータベース上のテーブル同士のリレーションを利用して、関連するデータを1つのQueryの中でまとめて取得することができるRelationshipsという機能があります。今回私たちが利用した「Remote Schema to Database Relationships」は、Remote Schema上のSchemaとデータベース上のテーブルの関係をマッピングすることで、Remote Schemaからのレスポンスを元にデータベース上の関連データを取得することを可能にします。

1回のQueryで済む

今回はフロントエンドからHasura経由でElasticsearchに格納されているitemのユニークキーを取得し、PostgreSQL上のitemのデータと組み合わせた上でフロントエンドにデータを返すという操作を通じて、データの流れを解説したいと思います。

前提

方針

今回の方針は以下です。

  • itemのデータは、

    • Elasticsearchでの検索に使うインデックスデータのみをElasticsearchに保存しておく。
    • Elasticsearchでの検索には使わないデータも含めた、itemの完全データをPostgreSQLに保存しておく。
  • Elasticsearchでの検索結果にPostgreSQL上の関連データを「Remote Schema to Database Relationships」を用いて結合して、フロントエンドに返す。

データの準備

Elasticsearchには以下のような形式でitem documentが格納されているとします。

// item
{
  "name": "old_coin",
  "manage_id": "xxxxxxxx"
}

上記データを事前にElasticsearchに保存しておきます。

Elasticsearchにデータを入れる
Elasticsearchにデータを入れる

PostgreSQLにはitemの完全データを保存しておきます。

カラム名
manage_id 'xxxxxxxx'
name 'old_coin'
inventory 5
selling_price 1234567

Elasticsearchと連携するGraphQLサーバーは事前に構築済みのものとします。これから紹介するサンプルではGoとgqlgenを用いてGraphQLサーバーを構築しました。

Hasuraとgqlgenで構築したGraphQLサーバーはこちらの手順に従って繋ぎ込みを実施しました。その結果、Remote SchemaとしてHasuraから利用できるようになりました。

Remote Schemaを登録した

また、Remote SchemaにはフロントエンドからElasticsearchを利用する際に実行するQueryと、Queryのresolverを定義しておきました。

type Query {
  items(where: ItemWhere): [Item!]!
}

input ItemWhere {
  name: String!
}

type Item {
  manageID: String!
}
func (r *queryResolver) Items(ctx context.Context, where *gqlmodel.ItemWhere) ([]*gqlmodel.Item, error) {
    // ...
}

// gqlmodel.ItemWhere
type ItemWhere struct {
    Name string `json:"name"`
}

// gqlmodel.Item
type Item struct {
    ManageID string `json:"manageID"`
}

Remote Schema to Database Relationshipsの設定

Hasura上のRemote Schemaの管理画面にて、Remote Schema内のどのTypeが、PostgreSQL上のどのテーブルと対応するのかのマッピングを設定できます。

Remote Schema to Database Relationshipsの設定画面

上の例の画面では、manageIDをキーにRemote SchemaのItem型がPostgreSQL上のitemsテーブルと1対1で対応していることを設定しています。また設定したRelationshipsの名前をhasuraItemとしています。

処理の流れ

フロントエンド → Hasura

フロントエンドからElasticsearchと連携するために、下記のQueryを実行します。 Remote Schemaの検索結果をもとに、hasuraItemのRelationshipsを用いてPostgreSQL上の関連データを一緒に取得するよう指定しています。

query MyQuery {
  rs {
    items(where: {name: "old_coin"}) {
      manageID
      hasuraItem {
        manage_id
        name
        inventory
        selling_price
      }
    }
  }
}

Hasura → Remote Schema

HasuraからRemote Schemaに以下のようなjsonデータと共にリクエストされます。

{
    "operationName": "MyQuery",
    "query": "query MyQuery { items(where: {name: \"old_coin\"}) { manageID  hasuraItem: __typename  } }"
}

Remote Schema → Elasticsearch

Hasuraから送られてきたパラメータを、resolver内でElasticsearch向けのデータの形に加工します。

私の所属するチームではRemote Schemaをgoで作っており、Elasticsearchクライアントとして、公式Goクライアントのgo-elasticsearchを利用しています。

なので、今回の例では以下のようにgo-elasticsearchが要求する形にデータを整形して、Elasticsearchにリクエストを行います。

func (r *queryResolver) Items(ctx context.Context, where gqlmodel.ItemsWhere) (*gqlmodel.ItemList, error) {
    var query map[string]any = map[string]any{
        "query": map[string]any{
            "bool": map[string]any{
                "must": []map[string]any{
                    map[string]any{
                        "match": map[string]any{
                            "name": where.Name,
                        },
                    },
                },
            },
        },
    }

    client, err := elasticsearch.NewClient(elasticsearch.Config{
        Addresses: []string{"http://elasticsearch:9200"},
        CloudID:   r.Config.ElasticSearch.CloudID,
        APIKey:    r.Config.ElasticSearch.APIKey,
    })

    var buf bytes.Buffer
    if err := json.NewEncoder(&buf).Encode(query); err != nil {
        return nil, err
    }

    res, err := client.Search(
        client.Search.WithContext(ctx),
        client.Search.WithIndex("item"),
        client.Search.WithBody(&buf),
        client.Search.WithTrackTotalHits(true),
    )
    if err != nil {
        return nil, err
    }
    // ...
}

データ型の変遷としては、resolverに渡ってきたデータをElasticsearchのクエリ形式のjsonに変換し、go-elasticsearchにbytes.Buffer型として渡していることがわかります。

Elasticsearch → Remote Schema

検索に成功すると、Elasticsearchから以下のようなデータが返ってきます。

{
   "took":572,
   "timed_out":false,
   "_shards":{
      "total":1,
      "successful":1,
      "skipped":0,
      "failed":0
   },
   "hits":{
      "total":{
         "value":1,
         "relation":"eq"
      },
      "max_score":0.2876821,
      "hits":[
         {
            "_index":"item",
            "_id":"1",
            "_score":0.2876821,
            "_source":{
               "name":"old_coin",
               "manage_id":"xxxxxxxx"
            }
         }
      ]
   }
}

これをresolverのresponse型に整形してHasuraに返します。

func (r *queryResolver) Items(ctx context.Context, where *gqlmodel.ItemWhere) ([]*gqlmodel.Item, error) {

    // ...

    res, err := client.Search(
        client.Search.WithContext(ctx),
        client.Search.WithIndex("item"),
        client.Search.WithBody(&buf),
        client.Search.WithTrackTotalHits(true),
    )
    if err != nil {
        return nil, err
    }

    var resp map[string]any = map[string]any{}
    if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
        return nil, err
    }

    // buildResponseの関数の中で、map[string]any型のレスポンスを[]*gqlmodel.Item型に変換する
    items, err := buildResponse(resp)
    if err != nil {
        return nil, err
    }
    return items, nil
}

Remote Schema → Hasura

Hasuraには以下のように、Remote Schemaからのjsonレスポンスが渡ってきます。

{
  "data": {
    "items": [
      {
        "manageID": "xxxxxxxx"
      }
    ]
  }
}

Hasura → フロントエンド

Remote Schemaから受け取ったデータをもとに、hasuraItemのRelationshipsを通じてPostgreSQL上のitemのデータを結合し、まとめてフロントエンドに返します。 以下のようなデータがフロントエンドに返却されます。

{
  "data": {
    "rs": {
      "items": [
        {
          "manageID": "xxxxxxxx",
          "hasuraItem": {
            "manage_id": "xxxxxxxx",
            "name": "old_coin",
            "inventory": 5,
            "selling_price": 1234567
          }
        }
      ]
    }
  }
}

このように、フロントエンドからHasuraを経由してElasticsearchでの検索を実行することができるようになりました。

まとめ

結果として、以下のようにHasuraを用いてElasticsearchからデータの検索を実行できるようになりました。また、「Remote Schema to Database Relationships」を利用することで、1回のqueryを叩くだけでElasticsearch上のデータを取得できていることがわかります。

結果

おわりに

いかがでしょうか? 「Remote Schema to Database Relationships」を用いることで、Hasuraの自動生成のメリットを活かしつつ、Elasticsearchと連携して検索を行えるようになりました。 Hasuraには他にもRemote Schema to Remote Schema Relationships という、Remote Schema間のRelationshipsを構築できる機能があるので、こちらも試してみたいなと思いました。

最後にBuySell Technologiesではエンジニアを募集しています。興味がある方はぜひご応募ください!

herp.careers