🍎

Apollo Server のライフサイクルイベントの発火タイミングとその使用例についてまとめてみた

2023/12/22に公開

はじめに

Apollo Server といえば JS で Graphql サーバーを作成するためによく使われるライブラリかと思いますが、サーバーを起動してから Query や Mutation を叩き、結果が出力され、最終的にサーバーを停止するまでの間に「ライフサイクルイベント」と呼ばれるものが存在し、それらイベントが発火するタイミングで独自に色々な処理を追加することができます。

しかし、つい最近、タスクの中でちょうどそのライフサイクルイベントに処理を仕込む必要が発生したのですが、イベントの種類が多く、公式ドキュメントを読んでもすぐにピンと来ないという経験をしました。
そこで、未来の自分や自分と同様の方の理解の助けになればと思い、今回は各イベントの発火タイミングとその使用例についてまとめてみようと思います。
使用例は有名なライブラリ、あるいは github 上でイベント名で検索をかけてヒットした中からいくつかをピックアップしています。
Apollo Server のバージョンは v4 となります。(がおそらく v2, v3 も共通しているところが多いかと思います。)

そもそもどうやってイベントに処理を仕込むか

仕込み方を知っている方は適宜読み飛ばしてください。

ApolloServer クラスのインスタンス生成時の plugins 経由で「イベント名に準拠した関数(中に差し込みたい処理を記述)を入れたオブジェクト」を渡してあげるだけで簡単に仕込むことができます。

const server = new ApolloServer({
  ...
  plugins: [
    {
      async serverWillStart() {
        console.log('Server starting!');
      },
    },
  ],
});

上記の場合、 serverWillStart というイベントにログを差し込んでいることになります。

中にはイベントの中に内包される形で更なるイベントが存在する場合もあり、その場合は関数の入れ子のような形にすることで「特定のイベントのさらに特定のイベント」に対して処理を差し込むことができます。

const server = new ApolloServer({
  ...
  plugins: [
    {
      async serverWillStart() {
        console.log('Server starting!');
	
	return {
          schemaDidLoadOrUpdate({ apiSchema }) {
	    console.log(`The API schema is ${printSchema(apiSchema)}`);
	  }
	}
      },
    },
  ],
});

上記の場合は、 serverWillStart イベントに内包された形で schemaDidLoadOrUpdate イベントが存在し、そこにログを仕込んでいます。
後者は apiSchema という引数を受け取っていますが、各イベントごとにそれまでに処理した内容や用途が異なるため、必然的に取得できる引数もイベントごとに異なります。

ライフサイクルイベントの発火タイミングとその使用例

では本題ですが、その前に、、、
Apollo Server のライフサイクルイベントはレイヤーごとに大きく以下の2種類が存在します。

  • サーバーライフサイクルイベント ... サーバー全体(起動~停止)のサイクルの中で起こるイベント
  • リクエストライフサイクルイベント ... 1リクエストのサイクルの中で起こるイベント

前者の中の requestDidStart というイベントの中に後者の大半が内包(つまり関数がネスト)された形になっています。
また同じレイヤーでも一部の関連するイベント同士はそのレイヤーの中で内包する/されるの関係になっているものも存在します。

手元で検証しながら読むと理解が深まるかと思うので、良ければ以下を参考にしてください。

https://github.com/hashiotoko/apollo-server-plugin-playground

サーバーライフサイクルイベント

serverWillStart

スキーマを読み込み切ってサーバーの起動準備時に呼ばれます。
この処理が完了しないとサーバーは起動しないのでサーバー起動直前に処理したいことなどはここに記述すると良さそうです。

(使用例)

  • サーバーが起動開始することをログとして残す
  • 何かのメトリクスの開始時間をセットする
  • DBサーバーに接続する

schemaDidLoadOrUpdate(< serverWillStart)

スキーマの読み込みや更新に成功すると呼ばれます。

(使用例)

  • スキーマの破壊的変更を検知してレポーティングする

renderLandingPage(< serverWillStart)

GraphQLのエンドポイントに遷移したときに表示したいページを構成してここで html として指定することでそのページを表示することができます。
指定しない場合、GraphQL を GUI 上で叩けるようなページが表示されます。

(使用例)

  • ページのカスタマイズ
  • 指定することで GUI 上から GraphQL を叩けないようにする

requestDidStart

リクエスト処理を開始した際に1リクエストにつき1度呼ばれます。
前述の通り、このイベントに内包される形で後述のリクエストライフサイクルイベントが存在します。

(使用例)

リクエストライフサイクルイベント側で言及します。

drainServer(< serverWillStart)

サーバーを停止し始めた時に呼ばれます。
名前の通りでサーバーの処理を流し切るために使われます。

(使用例)

  • 進行中のリクエストが完了するのを待ってから停止を進める
    • (Apollo Server 組み込みプラグイン) ApolloServerPluginDrainHttpServer

serverWillStop(< serverWillStart)

これもサーバーが停止し始めた時に呼ばれますが、 drainServer イベントの完了後に呼ばれます。
主に後片付けとしての位置付けで使われるようです。

(使用例)

  • DBサーバーとの接続を切る
  • その他、何がしかのクライアントとの接続を切る
  • GC的な処理を行う
  • 何かのメトリクスの終了時間をセットする

startupDidFail

リクエスト処理が可能になる前の段階(つまり renderLandingPage イベントまで)でエラーが発生するとこれが呼ばれます。

(使用例)

  • デバッグしやすいようにログを整形する

リクエストライフサイクルイベント

didResolveSource(< requestDidStart)

リクエストされたクエリ文字列(このイベントのsource という引数で受け取れるもの)を解決した時に呼ばれます。

(使用例)

  • どんなクエリが投げられたかをログとして残す
  • 特定の文字列が検出された場合に後続の処理を中止する

parsingDidStart(< requestDidStart)

ドキュメント構文木を生成するためにクエリ文字列を解析し始めるときに呼ばれます。
前回のクエリと同じ場合にはキャッシュから結果を返すため、このイベントは呼ばれません。
解析が終了または失敗するとこのイベント内でさらにエラーを引数とした関数が呼べます。

(使用例)

  • 解析に失敗した場合のエラーハンドリング

validationDidStart(< requestDidStart)

解析が完了してドキュメント構文木(document引数)が作られた後で、その構文木が適切か(スキーマとフィールド名が合致しているかなど)を検証をする時に呼ばれます。
parsingDidStart と同様に前回のクエリと同じ場合にはキャッシュから結果を返すためこのイベントは呼ばれません。
検証が終了または失敗するとこのイベント内でさらにエラーを引数とした関数が呼べます。
(did you mean ...? 系のエラーはここで発生する模様)

(使用例)

  • 検証に失敗した場合のエラーメッセージの加工

didResolveOperation(< requestDidStart)

検証が完了して実行可能なオペレーション(operation引数)が取得できた後に呼ばれます。
オペレーション名(operationName 引数)も取得可能です。
ここで GraphqlError を返すとステータスコードは500となります。
validationDidStart では検証できなかった処理を行う場所として最適です。

(使用例)

  • オペレーション内容から判断可能な制限系の処理を加える(筆者もここで同様の処理を加えた)
    • ex: node limit, query complexity limit...
  • その他バリデーション処理

responseForOperation(< requestDidStart)

オペレーションの実行の直前に呼ばれ、この関数の返り値が null の場合は後続のオペレーションの実行に移ります。オペレーションの実行後のレスポンスの準拠したものを返すとオペレーションの実行には移らずにそれをレスポンスとすることができるようです。
didResolveOperation よりも直接的にレスポンスを制御できるというのが特徴かなと思います。

(使用例)

  • 追加の検証をして内容次第で errors を埋めて返す

executionDidStart(< requestDidStart)

オペレーション実行時に呼ばれます。
後続の各フィールドの解決処理(willResolveField)や実行終了(executionDidEnd)イベントを内包します。

(使用例)

  • 後続の実行終了(executionDidEnd)と合わせての実行時間の計測

willResolveField(< executionDidStart)

各フィールドのリゾルバが呼ばれる直前にこのイベントが呼ばれます(つまり解決するフィールドの回数呼ばれます)。引数はリゾルバと同様(source(parent), args, context, info)のものが使えます。
このイベント関数内部で各フィールドの解決処理の終了後の結果またはエラーを引数とした関数も呼べます。

(使用例)

  • フィールドごとの処理時間の計測( => 重いフィールドの検知とか?)

executionDidEnd(< executionDidStart)

オペレーションの実行が終了した時に呼ばれます。エラーが発生した場合は引数として取得することができます。

(使用例)

  • 実行時間の計測

willSendResponse(< requestDidStart)

実行結果をレスポンスとして返す直前で呼ばれます。
基本的には正常なリクエストの最後のイベントとなります。

(使用例)

  • そのリクエスト内でのみ保持していた値のクリア
  • 処理内容に応じてリクエストボディなどへの固有の値のセット

willSendSubsequentPayload

willSendResponse と似ていますが、@defer ディレクティブなどによる遅延実行の結果を順に返していく際に呼ばれます。最初のペイロードを送信する前に willSendResponse が呼ばれます。

(使用例)

  • 後続のペイロードが存在するかどうかチェックしてよしなに処理する

contextCreationDidFail

1リクエストの中で各種フィールドの解決をする際などに共通して使用したい値などを格納しておく context を作成する際にエラーが発生するとこのイベントが呼ばれます。

(使用例)

  • リクエスト送信者側に表出させないようにエラーメッセージの加工
  • 開発者などへの通知
  • context へデフォルト値などを格納

didEncounterErrors(< requestDidStart)

ざっくり言うと requestDidStart の中で発生が予想できるエラーや開発者がエラーを発生させるであろうタイミングで起きたエラーの時にこのイベントが呼ばれます。
確認したところ、追加の検証を行うのに最適な didResolveOperation や各フィールドの解決を行う willResolveField でエラーを起こすとこのイベントが発火しました。

(使用例)

  • エラーログを見やすいように加工
    • (これは ApolloServer インスタンスの formatError 引数で設定している場合が多い?もしかしたらそれがここに処理を仕込んでいるのかもしれない。未確認。)
  • 特定のエラーに対して開発者などへの通知

didEncounterSubsequentErrors

@defer ディレクティブなどによる遅延実行を行っている際に発生したどのエラーに対してもこのイベントが発火します。この際に didEncounterErrors は発火しません。

(使用例)

  • didEncounterErrors と同様な感じ

unexpectedErrorProcessingRequest

requestDidStart の中でも didEncounterErrors イベントが発火しないところでのエラーは予測しないエラーとみなされこのイベントが呼ばれます。

(使用例)

  • contextCreationDidFail と同様な感じ

invalidRequestWasReceived

リクエスト時にCSRF攻撃などの不正なリクエストが検知された場合にこのイベントが呼ばれます。

(使用例)

  • 開発者などへの通知
  • 状況によってリクエスト送信者のBAN対応

おわりに

ざっくりですがまとめてみました。
各所でメトリクスの仕込みを入れている例が多く、アラート検知やパフォーマンスの計測などに使用している方が多いのかなという印象でした。
また、まとめている中で処理の流れの理解が少し深まったり、こういう使い方もあるのかというのが知れたのも良かったです。
数が多いのでまだ検証してきれてないところやふわっとしているところがありますが、少しでもどなたかの参考になれば幸いです。

CureApp テックブログ

Discussion