GraphQL RubyのFragment Cacheの動作をコードリーディングしてみた
私は普段GraphQL Rubyというgemを使ってRuby on Rails上でGraphQLの開発を行っていますが、パフォーマンスを向上させるために、Fragment Cacheを使うことがよくあります。
しかし、実際にその中身についてあまり調べたことがなかったので、今回はその仕組みについてgemの内部のコードを読んでみました。
GraphQLとは
改めてにはなりますが、GraphQLはデータ取得するためのクエリ言語で、Facebookによって開発され、REST APIに代わるものとして注目を集めています。GraphQLの主な特徴は、クライアントが必要なデータの構造を指定できる点で、これにより、過剰なデータの取得や不足を防ぎ、効率よくデータを取得することができるようになります。
弊社マイベストでも、2020年頃より導入を進めてきました。
キャッシュの重要性
Web開発においてキャッシュは、パフォーマンスの向上とサーバーの負荷軽減の両方に不可欠です。Railsにおいてもいくつかの種類のキャッシュがあります。
ここにおいても、フラグメントキャッシュについては、「ページ内のさまざまなパーツごとにキャッシュや有効期限を設定したい場合は、フラグメントキャッシュ」とという言及があります。
RailsのMVCにおいては、ページすべてをキャッシュするのではなく、どこかのコンポーネントや部分的にキャッシュを用いたいときに使用します。
GraphQLでは複雑なクエリや、クライアント側で取得するリソースをフィールド単位で指定できるため、フィールド単位でのフラグメントキャッシュを使用することで同じデータ構造を持つクエリの結果を再利用し、サーバーへの負荷を減らし、アプリケーションのレスポンス時間を短縮することができます。
GraphQL RubyにおけるFragment Cacheの実装
GraphQL Rubyを使用した場合には以下の公式のREADMEの記述に従って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
として渡されます。
また、contextを渡すこともでき、渡さない場合には下記の箇所で現在のcontextが使用されます。
context_to_use = context if context_to_use.nil? && respond_to?(:context)
contextには、現在のフィールドがGraphQLのSchemaのうちどこのフィールドで、どのようにネストされているか、などの位置の情報が格納されています。
初回にフィールドが呼び出された際にはこのcontextをもとに、新規でFragmentが作成されます。
fragment = Fragment.new(context_to_use, **options)
ここで作成されるFragmentでは、contextと、options(渡されたコードブロック、つまりこの場合にはPostのインスタンスを含む)の情報を保持します。
そしてこれらの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の内部的にデータベースなどの外部サービスへのアクセスを最適化する仕組みが備わっています。
このLazy Executionの仕組みがあることで、フィールドはPromiseを返し、その実行のためにはresolve
を実行すればよいのですが、そのresolve
メソッドがしっかり定義されています。
参考までに昨年書いた記事も載せておきます。
resolve
メソッドの中では、まず一度queryが呼び出されると、それはresolved_query
に追加され、キャッシュされている値が保持されます。
一度ここで保持されれば、次回以降はそれを探索し、保存されている値を返すようになります。
初回実行時など、まだキャッシュされていない場合には、最下部のコードが実行され、渡されてきた{ 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
によってキャッシュへの書き込みが行われます。
これでキャッシュが生成され、次回以降はqueryに対してキャッシュされた値が返るようになります。
GraphQL RubyではこのようにしてFragment Cacheの仕組みを実装していました。
感想
普段、何気なくフラグメントキャッシュを使う際にcache_fragment
メソッドを書いていましたが、その裏側にはこれほど多くの仕組みがあることを知りました。
今回、あまり読むことがないgemのコードを内の処理を辿って読んでみましたが、普段はGraphQL Rubyが上手ラップしてくれている処理を始めてみたり、、GraphQL独自のデータの取得の仕様についてコードベースで理解を深めることができました。
これを知ることでより解像度高くGraphQLの実装を行える感覚があり、ぜひまた時間を作ってコードリーディングし、理解を深めていきたいなと思います。
参考サイト
Discussion