はじめに
今回は、GraphQLサーバをSpring Bootで実装する際のエラーハンドリングをどう行うか紹介したいと思います。
GraphQLの一般的なエラーハンドリングの仕方から、その方針に応じたSpring Bootでの実装方法を説明します。
想定読者
本記事は以下の項目に当てはまっている人を想定読者としています。
- GraphQLサーバでエラーハンドリングを行いたい人
- GraphQLの基礎知識を理解している人
- Spring Bootでサーバを構築した経験がある人
- Spring BootでGraphQLサーバを構築した経験がある人
サンプルコード (参考)
本記事では実装したものに焦点を当てませんが、サンプルコードを実行しながら読むと理解が深まると思います。今回はSpring for GraphQLが公式で出しているコードを変更・拡張する形で実装しました。公式コードはGraphQLのクエリから本(Book)の情報および本著者(Author)の情報を取得するサーバの構築するコードです。
サンプルコードのGitHubリポジトリ
実装したコードはGitHub上から閲覧できます。実行方法はgithubリポジトリを参照してください。
変更点は以下に示すとおりになっています。
- Controller, Service, Repository層の3層アーキテクチャによる実装に変更
- 各層のデータのやり取りをDTOによって行うように変更
- エラーに関するコードの追加
環境
環境は以下のようになっています。
- JDK: OpenJDK 17
- Spring Boot: 3.1.5
- Gradle: 8.4
GraphQLのエラー
概要
エラーは大きく分けて二つに分類できます。
- 業務エラー
- システムエラー
それぞれのエラーに応じてエラーハンドリングの仕方も変えることが望ましいです。以降の章で、それぞれのエラーについて詳細に触れていきます。
業務エラー
業務エラーとは、ユーザ側の操作の変更で復帰できるエラーのことを指します。
以下のような場合が業務エラーに該当します。
- 必須項目に項目が指定されていない場合に発生するエラー
- 入力された項目の型が不正な場合に発生するエラー
- すでに登録されている項目を入力したときに発生するエラー
業務エラーが発生した場合には、クライアント側がユーザに再度入力を促し、サーバにアクセスすることで解決することができます。
システムエラー
システムエラーとはユーザがいかなる操作をしても復帰できないエラーのことを指します。
以下のような場合がシステムエラーに該当します。
- サーバがダウンしている際のエラー
- サーバがデータベースへのアクセスに失敗したときに発生するエラー
システムエラーが発生した際には、サーバ管理者がエラー時のログ等をみて解決することになります。
エラーレスポンスの方針
GraphQLでは、エラーが発生すると取得したいデータを入れるdata部のほかにerror部にエラー情報が格納されます。GraphQLではHTTPのステータスコードは200(OK)を使うことが推奨されています。
理由は以下のようなものが挙げられます。
- error部にエラーメッセージを入れることができるので、ステータスコードを指定するメリットがほとんどないため
- 一つのリクエストで複数の操作が可能で、複数のステータスコードが異なるエラーが発生する可能性があるため
GraphQLの標準のエラーフォーマット
GraphQLのerror部には標準フォーマットが存在します。
フォーマットは以下のような形式です。
{
"errors": [
"message": "error message"
"locations": {
"line": "15"
"column": "10"
}
"path": []
"extensions": {
"classification": "errorType"
...
}
]
}
各項目を簡単に表にまとめると次のようになります。
エントリ名 | サブエントリ名 | 説明 |
---|---|---|
message |
エラーメッセージ | |
locations |
line |
エラーが起きたクエリの行数 |
locations |
column |
エラーが起きたクエリの列数 |
path |
エラーが起きたフィールドのパス情報 | |
extensions |
ユーザが定義できる拡張項目 |
extensions
のサブエントリのclassification
はデフォルトのエラーメッセージでエラータイプとして表示されます。
業務エラーにおけるエラーレスポンスの設計
GraphQLのerror部はスキーマ定義できないため、クライアントはerror部のデータを見て柔軟に対応することが難しいです。そこで、復帰が可能な業務エラーにおいてはerror部を用いずdata部を用いる方針があります。この方針のメリットは次のようなものがあります。
- クライアントとサーバの干渉が少なくエラーハンドリングの実装ができる
- クライアントは欲しいエラー情報のみを指定することができる
- GraphQLのdata部だけを見てエラーハンドリングできる
- 各エラーに対して柔軟に定義できる
- 警告レスポンスも定義できる
GraphQLのエラーに関するスキーマ定義
GraphQLのエラー定義は次に示すように定義します。複数の業務エラーが発生しうるため、エラーはコレクションでまとめます。
type SelectBookPayload {
book: Book!
errors: [GraphQLError]
}
interface Error {
message: String!
}
type InputValidationError implements Error {
message: String!
validationType: String
}
type NotFoundError implements Error {
message: String!
resourceId: Int
}
union GraphQLError = InputValidationError | NotFoundError
union型のエラーを定義することで、エラーの一覧リストを定義することができます。unionについての詳細は以下のドキュメントを参照して下さい。
業務エラーに関するクエリ
先ほど定義したスキーマに基づいたクエリは次のようになります。各エラー種別に応じて取得したデータを選択します。errors
はunion型のコレクションであるため、それぞれの具象型に応じてどのフィールドを取得するか選択する必要あります(... on
で指定した箇所)。
query GetBook {
bookById(id: "book-1") {
book {
name
pageCount
author {
firstName
lastName
}
}
errors {
... on InputValidationError {
message
validationType
}
... on NotFoundError {
message
resourceId
}
}
}
}
業務エラーに関するレスポンス
先ほど定義したスキーマに基づいたGraphQLサーバからのレスポンスは次のようになります。
{
"data": {
"bookById": {
"book": null,
"errors": [
{
"message": "Custom Not Found Error",
"resourceId": "book-1"
},
{
"message": "Custom Input Validation Error",
"validationType": "miss type input: book-1"
}
]
}
}
}
システムエラーにおけるエラーレスポンスの設計
システムエラーが発生した場合はerror部にエラーの詳細を記載していきます。システムエラーをerror部に記載するメリットは次のようなものがあります。
- extensionsエントリにサーバ側が自由に設定したデータを入れることができる
- クライアント側のエラーハンドリングは基本的にdata部だけ見て処理すればよくなる
システムエラーに関するクエリ
システムエラーは特にスキーマ定義が必要ありません。そのため、クエリに関しても以下に示すように特にエラーに関する情報を含みません。
query GetBook {
bookById(id: "book-1") {
book {
name
pageCount
author {
firstName
lastName
}
}
}
}
システムエラーに関するレスポンス
システムエラーになるとerror部にエラー情報が含まれます。デフォルトだと以下に示すようにINTERNAL_ERROR
となります。data部のデータは一部取得できた場合は一部返ってきます。すべてのエラーがINTERNAL_ERROR
と、ひとくくりになってしまうため、GraphQLサーバではerror部を変更して対処できるようにすることが求められます。
{
"errors": [
{
"message": "INTERNAL_ERROR for df936d34-ec18-732a-1f3e-a2564cc06d29",
"locations": [
{
"line": 37,
"column": 7
}
],
"path": [
"bookById",
"book",
"author"
],
"extensions": {
"classification": "INTERNAL_ERROR"
}
}
],
"data": {
"bookById": {
"book": {
"id": "book-1",
"pageCount": 416,
"author": null
}
}
}
}
エラーハンドリングする箇所
Spring でGraphQLサーバを構築すると構成は下の画像のようになっています。 GraphQLのリクエスト・レスポンスを制御するのがInterceptorにあたります。サーバ処理のメインはController・Service・Repository層にあたり、この層で例外が発生するとExceptionHandlerを用いてエラーハンドリングします。この結果はインターセプタを介してクライアントに結果を返します。各エラーに応じて、どのフェーズでハンドリングするか異なります。
業務エラー時のエラーハンドリング箇所
業務エラーはdata部にerror情報を入れるためインターセプタやExceptionHandlerで十分に対処できません。インターセプタでの処理は、スキーマ定義に依存せずにエラー情報を入れることができるため、今回のエラーレスポンス方針に適していません。また、後ほど詳細に説明しますが、GraphQLのExceptionHandlerは戻り値がerror部のみとなっておりdata部にエラー情報を詰める処理を入れることができません。そのため、Controller・Service・Repository層のいずれかの層でtry-catch
構文等を用いてエラーハンドリングを行います。
システムエラー時のエラーハンドリング箇所
Controller・Service・Repository層内での処理は基本的にGraphQLのdata部のみを対象にした処理であるため、error部処理に適しません。そこで、システムエラーはController・Service・Repository層で発生した例外に関するエラーハンドリングはExceptionHandlerを用いて対処します。それ以外の例外はインターセプタを用いてハンドリングします。
業務エラーにおけるSpring Bootの実装
業務エラーの定義
GraphQLのリスポンスのdata部と同様のコードを実装していきます。まずは、GraphQLのスキーマ定義したクラスに対応するようにPOJOを定義していきます。GraphQLError
クラスのオブジェクトはGraphQLにおけるinterface
およびunion
に対応づきます。
public class GraphQLError {
private String message;
}
public class GraphQLInputValidationError extends GraphQLError {
private String validationType;
public GraphQLInputValidationError(String message, String validationType) {
super(message);
this.validationType = validationType;
}
}
public class GraphQLNotFoundError extends GraphQLError {
private final String resourceId;
public GraphQLNotFoundError(String message, String resourceId) {
super(message);
this.resourceId = resourceId;
}
}
業務エラーをPOJOで定義したら、レスポンスにあたるPayloadの定義を行います。
@Data
@Builder
public class SelectBookPayload {
private Book book;
private List<GraphQLError> errors;
}
エラーのUnion型への対応
GraphQLのUnion型とJavaにおけるクラスの対応関係が存在していないため、何も設定していない場合は次のようなエラーが出てきてしまいます。
java.util.concurrent.CompletionException: graphql.AssertException: You have asked for named object type 'GraphQLError' but it's not an object type but rather a 'graphql.schema.GraphQLUnionType'
そこで、GraphQLのUnion型がどのクラスに対応しているのかをConfigファイルに設定していく必要があります。次のコードはGraphQLConfig.java
というConfigファイルを作成してUnion型とJavaクラスの対応関係を行ったサンプルコードになります。
@Configuration
public class GraphQLConfig {
@Bean
public GraphQlSourceBuilderCustomizer sourceBuilderCustomizer() {
return (builder) -> {
ClassNameTypeResolver classNameTypeResolver = new ClassNameTypeResolver();
classNameTypeResolver.addMapping(CustomNotFoundGraphQLError.class, "NotFoundError");
classNameTypeResolver.addMapping(CustomInputValidationGraphQLError.class, "InputValidationError");
builder.defaultTypeResolver(classNameTypeResolver);
};
}
}
実装コードの概要は次のようなものです。
- Javaの
CustomNotFoundGraphQLError
クラスをGraphQLスキーマのNotFoundError
と紐づける - Javaの
CustomInputValidationGraphQLError
クラスをGraphQLスキーマのInputValidationError
に紐づける
このsourceBuilderCustomizerメソッドのコードはスキーマを拡張できる機能です。SchemaResourceBuilder
クラスのオブジェクトbuilderを受け取り、builderに対して拡張していきます。
業務エラーの捕捉
業務エラーはController・Service・Repository層でハンドリングします。現在業務エラーに対するベストプラクティスが存在しないため、ここでは実装の一例を紹介します。try-catch
構文を用いてdata部内のerror型にエラー情報を詰め込みます。try-catch
構文を用いることで、複数の例外を捕捉することが可能になり、クライアント側は複数のエラーを取得できるようになります。
public SelectBookRepositoryOutDto bookById(SelectBookRepositoryInDto selectBookRepositoryInDto) {
Book foundBook = null;
List<CustomGraphQLError> errors = new ArrayList<>();
try {
foundBook = books.stream()
.filter(book -> book.getId().equals(selectBookRepositoryInDto.getId()))
.findFirst()
.orElse(null);
} catch(Exception e) {
errors.add(new CustomNotFoundGraphQLError("book is not found", 404));
}
return SelectBookRepositoryOutDto.builder()
.book(foundBook)
.errors(errors)
.build();
}
システムエラーにおけるSpring Bootの実装
GraphQLのerror部に対応するJavaオブジェクト
Spring Bootではerror部のデータはGraphQLError
オブジェクトで実装できます。GraphQLError
オブジェクトは次のように定義して生成します。newErorr
メソッドはbuilder()
に対応しています。そのため、設定が必要なフィールドのみをメソッドチェーン方式で設定しオブジェクトを生成します。
GraphQLError graphQLError = GraphQLError.newError()
.errorType(ErrorType.INTERNAL_ERROR)
.message(e.getMessage())
.path(List.of("path1")) // オプション
.locations(List.of(new SourceLocation(1,10))) // オプション
.extensions(Map.of("classification", "errorType")) // オプション
.build();
各JSONエントリに対応するメソッドの説明を次の表で示します。
メソッド名(JSON エントリ名) | 引数の型 |
---|---|
errorType |
ErrorClassification |
message |
String |
path |
List<Object> |
location または locations
|
SourceLocation または List<SourceLocation>
|
extensions |
Map<String, Object> |
以降、型の詳細に触れていきます。
ErrorClassification
errorType
メソッドの引数はErrorClassification
インタフェースですが、そのままでは使用できません。使用する際はErrorType
という列挙型を用います。GraphQLのErrorType
は二つ存在します。
-
graphql.ErrorType
- エラー時のデフォルトのerror部のextensionsエントリの
classification
に入っている -
InvalidSyntax
,ValidationError
,DataFetchingException
,NullValueInNonNullableField
,OperationNotSupported
,ExecutionAborted
- エラー時のデフォルトのerror部のextensionsエントリの
-
org.springframework.graphql.execution.ErrorType
- HTTPレスポンスのステータスコードに対応
-
BAD_REQUEST
,UNAUTHORIZED
,FORBIDDEN
,NOT_FOUND
,INTERNAL_ERROR
SourceLocation
SourceLocation
はエラーが発生したクエリの詳細位置を設定するためのクラスです。このクラスのフィールド変数はline
, column
があります。ログを記録するために3つめのフィールド変数としてsourceName
があります。状況に応じてどちらのコンストラクタを用いるか選択してください。
@PublicApi
public class SourceLocation implements Serializable {
public static final SourceLocation EMPTY = new SourceLocation(-1, -1);
private final int line;
private final int column;
private final String sourceName;
public SourceLocation(int line, int column) {
this(line, column, null);
}
public SourceLocation(int line, int column, String sourceName) {
this.line = line;
this.column = column;
this.sourceName = sourceName;
}
...
}
GraphQLExceptionHandlerを用いた実装
REST APIでエラーハンドリングする際にExceptionHandlerを用いたのと同じように、GraphQLでもGraphQLExceptionHandlerを用いて簡単にエラーハンドリングできます。
@ControllerAdvice
public class GraphQLExceptionHandler {
@GraphQlExceptionHandler
public GraphQLError exceptionHandle(Exception e) {
return GraphQLError.newError().errorType(ErrorType.BAD_REQUEST).message(e.getMessage()).build();
}
}
他の方法として例外リゾルバを実装してハンドリングする方法があります。内容は割愛しますが、以下のように実装して使用することができます。resolveToSingleError
メソッドをオーバライドして定義します。
@Component
public class CustomExceptionResolver extends DataFetcherExceptionResolverAdapter {
@Override
protected GraphQLError resolveToSingleError(@NonNull Throwable e, @NonNull DataFetchingEnvironment env) {
if (e instanceof RuntimeJsonMappingException) {
return GraphQLError.newError().message(e.getMessage()).errorType(ErrorType.FORBIDDEN).build();
}
return GraphQLError.newError().message(e.getMessage()).errorType(ErrorType.INTERNAL_ERROR).build();
}
}
Interceptorを用いた実装
Controller・Service・Repository層で発生した例外はGraphQLExceptionHandlerでエラーハンドリングできますが、それ以前での例外はエラーハンドリングできません。例えば
といったものの場合は、エラーハンドリングできません。こういうときのエラーはデフォルトのエラーが返ってきます。このようなエラーの場合にもエラーハンドリングしたい場合はインターセプタを実装します。
インターセプタの実装は以下のように行います。
@Component
public class RequestErrorInterceptor implements WebGraphQlInterceptor {
@Override
public @NonNull Mono<WebGraphQlResponse> intercept(@NonNull WebGraphQlRequest request, Chain chain) {
return chain.next(request).map(response -> {
if (response.isValid())
return response;
List<GraphQLError> errors = response.getErrors().stream()
.map(error -> GraphQLError.newError()
.errorType(ErrorType.BAD_REQUEST)
.message(error.getMessage())
.extensions(error.getExtensions())
.locations(error.getLocations())
.build()).toList();
return response.transform(builder -> builder.errors(errors).build());
});
}
}
このコードは次のような処理を行っています。
- リスポンスにエラーがない場合はそのまま返す
- エラーが存在する場合はエラータイプを
BAD_REQUEST
に書き換えて返す
インターセプタでは、エラーハンドリングだけでなくレスポンスの形式を大幅に変更することが可能であるため注意が必要です。
まとめ
Spring Bootで実装したGraphQLサーバのエラーハンドリングの仕方について紹介しました。ハンドリングを行う際には、エラーの種類やどこでエラーが発生するかで分類しました。まとめると以下のようになります。
- 業務エラーならば、スキーマ定義しdata部にエラーの内容を付加する
- システムエラーならば、二つのパターンによってerror部にエラー内容を付加する
- Controller層で捕捉できるものはGraphQLExceptionHandlerによってハンドリングする
- それ以外の場合はインターセプタを実装してエラーハンドリングする
参考文献
最後に本記事を執筆するにあたり参考にした文献を載せます。