🗂

GraphQLのfield削除を安全に行うための検知システムの構築

2024/04/24に公開

はじめに

マイベストのバックエンドエンジニアの工藤です。今回は、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つの対応方針を検討しました。

  1. ログを利用して使われているかどうか確認
    GraphQLのfieldが呼ばれたら、呼ばれたことを保存するログを取る。一定期間ログがない場合は、使われていないfieldとみなして削除できるようにする
  2. 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のリクエストがあった場合は検知できない

要件

ここまで整理すると、課題を解決するために以下の要件を満たす必要がありました。

  1. モバイルアプリのソースコードで利用しているQuery・Mutationを取得する
  2. 取得したQuery・Mutationを保存する
  3. モバイルアプリのバージョンに対応するために複数バージョンのQuery・Mutaitonの取得・保存を行う
  4. 取得した全ての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ごとにこのようにファイルを保存します

some_query.graphql
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クラスを一部抜き出して、簡略化したもの掲載します。

graphql_field_usage_analyzer.rb
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つでもあればエラーとします。

graphql_usage_spec.rb
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を特定する仕組みも作成できるので、作成中です。実際の運用を通じて得られる新たな知見については、引き続き共有させていただきます。

Discussion