将GraphQL的搜索源从ActiveRecord更改为Elasticsearch

因为graphql-ruby默认使用ActiveRecord作为资源的数据来源,但我想将其更改为Elasticsearch,但找不到相应的库,因此我自己实现了它。

关于环境

    • Ruby 2.7.3

 

    • Rails 6.0.3.4

 

    • Gem

graphql 1.12.12
elasticsearch-model 7.1.1

关于实施方法

在GraphQL中,Relay-Style Cursor Pagination是最常见的分页方式。此外,还可以通过使用kaminari等工具来实现分页,这方面的实现方法也在Qiita的文章中有介绍。

根据这篇文章的内容,如果只在内部使用的话,我认为使用kaminari的分页也是可以的,但由于最终目标是发布公开API,因此我选择了使用Relay-Style Cursor Pagination。

创建自定义连接

graphql-ruby在默认情况下为各种资源准备了连接类。它不仅支持ActiveRecord,还支持Sequel、MongoDB、数组等。但是,它不支持Elasticsearch。因此,您需要自己创建自定义连接。

在官方的自定义连接页面上,有关于如何创建自定义连接的示例简要介绍。

起初,我考虑边看这个并进行实现,但由于它写得很粗略,所以我无法理解。因此,我决定边阅读graphql-ruby的分页目录源代码来推进。

有一个名为ActiveRecordRelationConnection的类,它继承了RelationConnection类。最初打算继续继承RelationConnection类,但由于不太清楚,所以改为继承Connection类,并重新实现了在RelationConnection中定义的所有方法。

最终,由于大多数实现了RelationConnection的方法都可以直接使用,所以我将继承源更改为RelationConnection,但是我理解了处理的流程。

所以,这是我创建的班级。

module Connections
  class ElasticsearchRelationConnection < GraphQL::Pagination::RelationConnection

    def nodes
      @nodes ||= limited_nodes.records.to_a
    end
    # Rubocopにload_nodesメソッドが不要と言われた
    # しかし、継承元のRelationConnectionで呼ばれているのでnodesメソッドのエイリアスにしておく
    # また、元々private methodだったので変更しておく
    alias_method :load_nodes, :nodes
    private :load_nodes

    # GraphQL::Pagination::RelationConnectionの実装を改修
    # `@paged_node_offset`にオフセットが入っているので、2重で足さないようにした。
    def cursor_for(item)
      load_nodes
      # index in nodes + existing offset + 1 (because it's offset, not index)
      # offset = @nodes.index(item) + 1 + (@paged_nodes_offset || 0) + (relation_offset(items) || 0)
      offset = @nodes.index(item) + 1 + (@paged_nodes_offset || 0)
      encode(offset.to_s)
    end

    private

      # @param [Elasticsearch::Model::Response::Response]
      # @param [Integer] size LimitSize
      # @return [Boolean] sizeよりも残りが大きければtrueを返す
      def relation_larger_than(relation, size)
        initial_offset = relation_offset(relation)
        relation_count(relation) > initial_offset + size
      end

      # @param [Elasticsearch::Model::Response::Response]
      # @return [Integer] オフセットの値
      def relation_offset(relation)
        relation.search.definition.fetch(:from, 0)
      end

      # @param [Elasticsearch::Model::Response::Response]
      # @return [Integer, nil] 取得数
      def relation_limit(relation)
        relation.search.definition[:size]
      end

      # @param [Elasticsearch::Model::Response::Response]
      # @return [Integer] 総ヒット数
      def relation_count(relation)
        relation.results.total
      end

      # @param [Elasticsearch::Model::Response::Response]
      # @return [ActiveRecord::Relation]
      def null_relation(relation)
        relation.records.none
      end

      def limited_nodes
        super()
      rescue ArgumentError => _e
        # カーソルの先頭より前の要素を取得しようとするとArgumentErrorになったため、
        # 例外を補足して空のActiveRecord::Relationを返すようにした
        ApplicationRecord.none
      end
  end
end

我会让你可以使用这个。我会注册先前创建的连接,以便你可以使用它。

class MySchema < GraphQL::Schema
  connections.add(Elasticsearch::Model::Response::Response, Connections::ElasticsearchRelationConnection)
  # 省略
end

然后,我们定义使用这个模式。省略了User模型在Elasticsearch中的模式定义…。

module Types
  class QueryType < Types::BaseObject
    field :users, Objects::User.connection_type, null: false do
      argument :keyword, String, required: false # 検索キーワード
    end

    # **argsにすることで、graphqlのページング条件などを一手に引き受けさせる
    def users(keyword: nil, **args)
      query = Elasticsearch::DSL::Search::Search.new
      query.query do
        bool do
          if keyword.present?
            must do
              simple_query_string do
                query keyword
                fields ['keyword_search_field']
                default_operator :and
              end
            end
          end
        end
      end

      es_response = User.__elasticsearch__.search(query.to_hash)
      # 先ほど作ったコネクションで返す
      Connections::ElasticsearchRelationConnection.new(
        es_response,
        first: args[:first],
        last: args[:last],
        before: args[:before],
        after: args[:after],
      )
    end
  end
end

现在,我们可以通过GraphQL对Elasticsearch进行搜索了。

我现在试着写一个查询。

query {
  users(keyword: "山田", first: 3) {
    edges {
      cursor
      node {
        id
        name
      }
    }
    pageInfo {
      startCursor
      endCursor
      hasNextPage
      hasPreviousPage
    }
  }
}

以下是先前查询的结果(为了公开使用,数据已经进行了适当修改)。
pageInfo返回了光标的值以及前后页面的存在与否。

{
  "data": {
    "users": {
      "edges": [
        {
          "cursor": "MQ",
          "node": {
            "id": "34",
            "name": "山田 孝夫"
          }
        },
        {
          "cursor": "Mg",
          "node": {
            "id": "76",
            "name": "山田 孝之"
          }
        },
        {
          "cursor": "Mw",
          "node": {
            "id": "55",
            "name": "山田 太郎"
          }
        }
      ],
      "pageInfo": {
        "startCursor": "MQ",
        "endCursor": "Mw",
        "hasNextPage": true,
        "hasPreviousPage": false
      }
    }
  }
}

总结

    • graphql-rubyはデフォルトで様々なコネクションクラスを持っている

 

    • その他のリソースで検索させたい場合などはGraphQL::Pagination::Connectionクラスを継承して作ることができる

 

    • graphql-rubyでElasticsearchを使いつつ、Relay-Style Cursor Paginationを実現したければ、カスタムコネクションを作る必要がある

 

    上記に載せたElasticsearchRelationConnectionのコードが、それである。
bannerAds