GraphQLのfield削除を安全に行うための検知システムの構築
はじめに
マイベストのバックエンドエンジニアの工藤です。今回は、GraphQLのfieldを削除したが、古いバージョンのモバイルアプリでまだ使われていたためにエラーになってしまう問題を未然に防ぐ方法を紹介します。
背景
マイベストではAPIとしてGraphQLを採用しています。バックエンドはGraphQL導入前からRailsで構築されていたため、GraphQLはgraphql-rubyというRailsのgemを利用して構築しています。
GraphQLは、Webフロント・管理画面・モバイルアプリで利用されていて、それらは全て1つのRailsアプリで構築されています。モバイルアプリは2023年4月にリリースされ、リリース当初からWebフロントとモバイルアプリ用のGraphQLのTypeは共有されています。
ディレクトリイメージ
このようなTypeがWebフロントとモバイルから共有されています
class ContentType < BaseObject
field :id, ID, null: false
field :title, String, null: false
field :some_old_title, String, null: false
# 省略...
end
class ProductType < BaseObject
field :id, ID, null: true
field :name, String, null: false
field :some_old_name, String, null: false
# 省略...
end
Webフロントとモバイルアプリで表示する必要がある内容は同じことが多いため、Typeを共有することで同じようなTypeが重複して作成されることがない、Webフロントで機能追加したら簡単にモバイルアプリでもその機能を利用できるようになるといったメリットがあるものの、以下の課題が発生してきました。
- Webフロントのロジックの変更により、モバイルアプリで不具合がでてしまう
- Webフロントで不要になったfieldを削除した際に、モバイルアプリの古いバージョンではまだ利用しているfieldだったためモバイルアプリで障害が発生してしまう
今回は後者の課題に対応した内容を紹介します。
class ContentType < BaseObject
field :id, ID, null: false
field :title, String, null: false
# some_old_titleがWebフロントでもモバイルアプリでも最新のコードでも使われていなかったので削除したら、古いモバイルアプリで使われていて障害に
field :some_old_title, String, null: false
end
課題と対応方針
Webフロントで利用中のfieldを削除してしまった場合は、schema.jsonを使ってgraphql-codegenですぐに検知できていました。マイベストではWebフロントとバックエンドを1つのレポジトリで管理しているため、すぐにschemaのダンプとgraphql-codegenの実行が可能です。一方、モバイルアプリのfieldの削除に関しては以下のような課題があり、検知ができていない、調べるための工数が大きい状況でした。
- モバイルアプリとバックエンドは別のレポジトリで管理されているため、モバイルで利用されているGraphQLのQuery・Mutationを確認するには別のレポジトリを参照する必要がある
- モバイルアプリは複数バージョンが存在しており、現在のソースコードのみでの確認では過去のバージョンで使用されているfieldを見落とす可能性があるため、gitを用いて過去のコードを遡る必要があり、これが漏れてしまう
- Webフロントのバックエンドエンジニアでモバイルアプリ開発に関わっていない開発者は、モバイルアプリのソースコードの調べ方がわからない
こららの課題に対応するために、以下の2つの対応方針を検討しました。
- ログを利用して使われているかどうか確認
GraphQLのfieldが呼ばれたら、呼ばれたことを保存するログを取る。一定期間ログがない場合は、使われていないfieldとみなして削除できるようにする - Query・MutationとSchemaを比較して解析
モバイルアプリで利用されているQuery・Mutationを解析して、モバイルアプリのGraphQL Schemaと比較してQuery・Mutationの該当fieldが定義されているか確認する
それぞれPros/Consを整理し、方針2を採用しました。すべてのGraphQLリクエストは自社のWebフロントおよびモバイルアプリからのみ行われ、外部には公開されていないため、当該方針を採用することが可能でした。
Pros | Cons | |
---|---|---|
1. ログを利用して使われているかどうか確認 | ・一定期間利用されているfield・利用されていないfieldを確実に見つけられる | ・計測ベースなので一定時間がかかる ・計測期間中たまたま利用されなかったが実は利用されていたfieldが発生しうる(例えば、かなり古いモバイルアプリを利用しているユーザーが久しぶりに起動した、利用頻度が極端に低いfieldが存在したなど。) |
2. Query・MutationとSchemaを比較して解析 | ・開発環境でバックエンドのコードを変更したらすぐに結果がわかる ・静的に解析するので確実に利用されているかどうかがわかる |
・ソースコードにないQuery・Mutationのfieldのリクエストがあった場合は検知できない |
要件
ここまで整理すると、課題を解決するために以下の要件を満たす必要がありました。
- モバイルアプリのソースコードで利用しているQuery・Mutationを取得する
- 取得したQuery・Mutationを保存する
- モバイルアプリのバージョンに対応するために複数バージョンのQuery・Mutaitonの取得・保存を行う
- 取得した全てのQuery・Mutaitonと現在のモバイルアプリSchemaを比較して、Query・Mutationで利用されているがSchemaに定義されていないfiledを検知する
Query・Mutationの取得・保存フロー
要件1-3を満たすため、以下のフローでQuery・Mutationの取得・保存フローを構築することにしました。
①モバイルアプリをリリースするときに、リリースタグを作成しているので、リリースタグ作成をトリガーにGraphQL側のrepository_dispatchを起動させるGithub Actionsを起動
②repository_dispatchのclient_payloadにリリースタグをパラメーターとして渡す。
③モバイルアプリのレポジトリを取得し、対象のリリースタグに対して、モバイルアプリで利用されている.graphql
ファイルを解析し、Query・Mutationごとにファイルを作成する
ファイルの解析にはgraphql-jsを利用し、ファイルを順にparseし、mutation・query・fragmentに分類し、mutation・queryに必要なfragmentを結合させてoperation別にspec/fixtures/graphql/mobile/<tag>
に保存。PRを自動で作成しています。
Query・Mutationごとにこのようにファイルを保存します
query someQuery {
content {
...SomeFragment
}
}
fragment SomeFragment on Content {
title
}
バージョンごとにディレクトリを分けて保存し、どのバージョンのアプリから利用されているか区別できるようにしています。
④PR内容を確認、Approve・Mergeする
安全を期して、人手による確認を経てからマージを行います
Schemaに定義されていないfieldを検知
次に要件4の検知部分についてです。
ここまでで、モバイルアプリの全てのバージョンで利用されているQuery・Mutationを保存できたので、全てのQuery・Mutationと現在のバックエンドのSchemaを比較して、Schemaに定義されていないQuery・Mutationがあればエラーとすれば検知できます。
マイベストではバックエンドの変更があれば必ずRSpecをGithub Actionsで実行しているため、検知はRSpecで行うことにしました。そこで、RSpecから利用するクラスGraphQLFieldUsageAnalyzer
を作成してRSpecのテストを作り、Schemaに定義されていないfieldが1つ以上あればエラーにするようにしています。
以下にGraphQLFieldUsageAnalyzer
クラスを一部抜き出して、簡略化したもの掲載します。
class GraphQLFieldUsageAnalyzer
attr_reader :undefined_fields
def initialize(schema: MybestFrontSchema, path: nil)
@schema = schema
@query_path = path
@undefined_fields = []
end
private
def analyze_query_files
Dir.glob(@query_path).each do |file|
query_string = File.read(file)
analyze(query_string)
end
end
def analyze(query_string)
graphql_ast = GraphQL::Language::Parser.parse(query_string)
@fragments = collect_fragments(graphql_ast)
operations = collect_operations(graphql_ast)
operations.each do |_name, operation|
process_graphql_node(operation, nil)
end
end
def process_graphql_node(node, parent_type = nil)
case node
when GraphQL::Language::Nodes::OperationDefinition
op_type = node.operation_type == 'query' ? @schema.query : @schema.mutation
node.selections.each { |selection| process_graphql_node(selection, op_type) }
when GraphQL::Language::Nodes::Field
find_defined_types(node, parent_type)
# 省略...
when
# 省略...end
end
def find_defined_types(node, parent_type, skip_undefine: false)
case parent_type.kind
when GraphQL::TypeKinds::UNION
# 省略...
when GraphQL::TypeKinds::OBJECT
# 省略...
if (field = parent_type.fields[node.name]).nil?
@undefined_fields << extract_object_name_field_name(parent_type, node)
end
# 省略...
end
end
end
GraphQLFieldUsageAnalyzer
では、ディレクトリ内のすべてのQuery・Mutationファイルを読み込み、それぞれに対して解析を行います。analyze_query_files
メソッドで各ファイルの内容を読み取り、analyze
メソッドを呼び出してクエリ文字列を解析します。 analyze
メソッドではクエリ文字列からGraphQL::Language::Parser.parse
を使ってASTを生成し、そのASTを基にしてfieldの使用状況を調べています。具体的には、Query・MutationやFragment定義を抽出し、それらを逐一解析しています。
process_graphql_node
メソッドでASTのノードを再帰的に訪れ、fieldの使用状況や定義の有無を確認します。未定義のフィールドが発見された場合は、それを@undefined_fields
に追加します。
RSpecではこの未定義のfieldが1つでもあればエラーとします。
require 'rails_helper'
RSpec.describe 'GraphQL Field Usage Analysis' do
describe 'Field Usage in Operations' do
context 'mobileの場合' do
it '使用されているfieldで、定義されていないfieldがないこと', :aggregate_failures do
versions = Dir[Rails.root.join('spec/fixtures/graphql/mobile/*')].select { |f| File.directory?(f) }.map { |d| File.basename(d) }
versions.each do |version|
# GraphQLFieldUsageAnalyzerを使ってバージョンごとに@undefined_fieldsを取得するクラス
analyzer = GraphQLSchemaFieldAnalysisExecutor.analyze_mobile_used_fields(version: version)
expect(analyzer.undefined_fields).to eq([]), "バージョン #{version} に未定義のfieldsが存在します"
end
end
end
end
end
おわりに
本記事で紹介した検知方法は、まだ導入して日が浅く一部(PR作成する部分)実装完了できていないのですが、継続的に運用できると不具合を未然に防ぐ良い手段になり得ると思います。またGraphQLのfieldを解析する仕組みを利用すれば、不要になったfieldを特定する仕組みも作成できるので、作成中です。実際の運用を通じて得られる新たな知見については、引き続き共有させていただきます。
株式会社マイベストのテックブログです! 採用情報はこちら > notion.so/mybestcom/mybest-information-for-Engineers-8beadd9c91ef4dc2b21171d48a4b0c49
Discussion