👨‍💻

GraphQL RubyのFragment Cacheの動作をコードリーディングしてみた

2023/12/23に公開

私は普段GraphQL Rubyというgemを使ってRuby on Rails上でGraphQLの開発を行っていますが、パフォーマンスを向上させるために、Fragment Cacheを使うことがよくあります。
しかし、実際にその中身についてあまり調べたことがなかったので、今回はその仕組みについてgemの内部のコードを読んでみました。

GraphQLとは

改めてにはなりますが、GraphQLはデータ取得するためのクエリ言語で、Facebookによって開発され、REST APIに代わるものとして注目を集めています。GraphQLの主な特徴は、クライアントが必要なデータの構造を指定できる点で、これにより、過剰なデータの取得や不足を防ぎ、効率よくデータを取得することができるようになります。

https://graphql.org/

弊社マイベストでも、2020年頃より導入を進めてきました。

https://zenn.dev/mybest_dev/articles/a8f3096821851c

キャッシュの重要性

Web開発においてキャッシュは、パフォーマンスの向上とサーバーの負荷軽減の両方に不可欠です。Railsにおいてもいくつかの種類のキャッシュがあります。

https://railsguides.jp/caching_with_rails.html

ここにおいても、フラグメントキャッシュについては、「ページ内のさまざまなパーツごとにキャッシュや有効期限を設定したい場合は、フラグメントキャッシュ」とという言及があります。
RailsのMVCにおいては、ページすべてをキャッシュするのではなく、どこかのコンポーネントや部分的にキャッシュを用いたいときに使用します。

GraphQLでは複雑なクエリや、クライアント側で取得するリソースをフィールド単位で指定できるため、フィールド単位でのフラグメントキャッシュを使用することで同じデータ構造を持つクエリの結果を再利用し、サーバーへの負荷を減らし、アプリケーションのレスポンス時間を短縮することができます。

GraphQL RubyにおけるFragment Cacheの実装

GraphQL Rubyを使用した場合には以下の公式のREADMEの記述に従ってFragment Cacheを導入することができます。

https://github.com/DmitryTsepelev/graphql-ruby-fragment_cache

まずはGraphQLSchemaでuse GraphQL::FragmentCacheでキャッシュを使用できるようにします。

class GraphqSchema < GraphQL::Schema
  use GraphQL::FragmentCache
  
  query QueryType
end

続いて、BaseTypeや使用している場合はResolverにも必要なモジュールをincludeします。

class BaseType < GraphQL::Schema::Object
  include GraphQL::FragmentCache::Object
end
class Resolvers::BaseResolver < GraphQL::Schema::Resolver
  include GraphQL::FragmentCache::ObjectHelpers
end

そして、フラグメントキャッシュを使用したい箇所にはcache_fragmentメソッドでキャッシュしたい値をブロックで囲みます。

class QueryType < BaseObject
  field :post, PostType, null: true do
    argument :id, ID, required: true
  end

  def post(id:)
    cache_fragment { Post.find(id) }
  end
end

GraphQL RubyにおけるFragment Cacheの仕組み

なぜこれでFragment Cacheが実現できるのかコードを読みながら理解していきます。

まず、cache_fragmentメソッドはGraphQL::FragmentCache::ObjectHelpersに定義されています。
引数としては(object_to_cache = NO_OBJECT, **options, &block)を取ることができますが、
上記の例だと、{ Post.find(id) }&blockとして渡されます。

https://github.com/DmitryTsepelev/graphql-ruby-fragment_cache/blob/d7745f527890abce55c7771ba0bfc320d9ffbd66/lib/graphql/fragment_cache/object_helpers.rb#L26-L52

また、contextを渡すこともでき、渡さない場合には下記の箇所で現在のcontextが使用されます。

context_to_use = context if context_to_use.nil? && respond_to?(:context)

contextには、現在のフィールドがGraphQLのSchemaのうちどこのフィールドで、どのようにネストされているか、などの位置の情報が格納されています。

https://graphql-ruby.org/api-doc/1.8.11/GraphQL/Query/Context.html

初回にフィールドが呼び出された際にはこのcontextをもとに、新規でFragmentが作成されます。

fragment = Fragment.new(context_to_use, **options)

ここで作成されるFragmentでは、contextと、options(渡されたコードブロック、つまりこの場合にはPostのインスタンスを含む)の情報を保持します。

https://github.com/DmitryTsepelev/graphql-ruby-fragment_cache/blob/d7745f527890abce55c7771ba0bfc320d9ffbd66/lib/graphql/fragment_cache/fragment.rb#L41C6-L46C10

そしてこれらのfragmentとcontext、そしてobject_to_cache(初回実行時にはObject.newで作成される)、キャッシュ対象のブロックを持つResolverが作成され、返されます。

GraphQL::FragmentCache::Schema::LazyCacheResolver.new(fragment, context_to_use, object_to_cache, &block)

この時点ではまだPost.find(id)ブロックは実行されておりませんし、フィールドの返り値にも含まれていません。では、タイミングで実行されるのでしょうか。
それを解決するのがLazy ExecusionというGraphQL Rubyの仕組みです。この仕組みがあるおかげで、GraphQL Rubyの内部的にデータベースなどの外部サービスへのアクセスを最適化する仕組みが備わっています。

https://graphql-ruby.org/schema/lazy_execution

このLazy Executionの仕組みがあることで、フィールドはPromiseを返し、その実行のためにはresolveを実行すればよいのですが、そのresolveメソッドがしっかり定義されています。

https://github.com/DmitryTsepelev/graphql-ruby-fragment_cache/blob/master/lib/graphql/fragment_cache/schema/lazy_cache_resolver.rb#L21-L36

参考までに昨年書いた記事も載せておきます。

https://zenn.dev/isaka102/articles/e8eb5d1c096ee0

resolveメソッドの中では、まず一度queryが呼び出されると、それはresolved_queryに追加され、キャッシュされている値が保持されます。

https://github.com/DmitryTsepelev/graphql-ruby-fragment_cache/blob/master/lib/graphql/fragment_cache/schema/lazy_cache_resolver.rb#L23-L25

一度ここで保持されれば、次回以降はそれを探索し、保存されている値を返すようになります。

https://github.com/DmitryTsepelev/graphql-ruby-fragment_cache/blob/master/lib/graphql/fragment_cache/schema/lazy_cache_resolver.rb#L28-L32

初回実行時など、まだキャッシュされていない場合には、最下部のコードが実行され、渡されてきた{ Post.find(id) }ブロックの実行結果が返されます。

(@block ? @block.call : @object_to_cache).tap do |resolved_value|
  @query_ctx.fragments << @fragment
end

ここでqueryがresolveされると、GraphQL::FragmentCache::Schema::Instrumentationにおいてhooksが仕込まれており、GraphQL::FragmentCache::Cacherによってキャッシュへの書き込みが行われます。

https://github.com/DmitryTsepelev/graphql-ruby-fragment_cache/blob/master/lib/graphql/fragment_cache/schema/instrumentation.rb#L8

https://github.com/DmitryTsepelev/graphql-ruby-fragment_cache/blob/master/lib/graphql/fragment_cache/cacher.rb#L36-L40

これでキャッシュが生成され、次回以降はqueryに対してキャッシュされた値が返るようになります。
GraphQL RubyではこのようにしてFragment Cacheの仕組みを実装していました。

感想

普段、何気なくフラグメントキャッシュを使う際にcache_fragmentメソッドを書いていましたが、その裏側にはこれほど多くの仕組みがあることを知りました。
今回、あまり読むことがないgemのコードを内の処理を辿って読んでみましたが、普段はGraphQL Rubyが上手ラップしてくれている処理を始めてみたり、、GraphQL独自のデータの取得の仕様についてコードベースで理解を深めることができました。
これを知ることでより解像度高くGraphQLの実装を行える感覚があり、ぜひまた時間を作ってコードリーディングし、理解を深めていきたいなと思います。

参考サイト

https://qiita.com/genya0407/items/1a34244cba6c3089a317

https://15dog.hatenablog.com/entry/2019/02/04/Rails_Jbuilderのcacheのキーの挙動の調査メモ

https://qiita.com/suketa/items/eeae7e2196520323f694

Discussion