お題
Type-safe GraphQL for Go
を謳うGolang製GraphQLライブラリであるgqlgenを使って、GraphQL Server側のエラーハンドリングについて検討。
想定する読者
- Golangについてある程度書ける。
- 「GraphQL is 何?」ではない。
- gqlgenの getting-started で初期セットアップくらいはやったことがある。
関連記事索引
- 第12回「GraphQLにおけるRelayスタイルによるページング実装再考(Window関数使用版)」
- 第11回「Dataloadersを使ったN+1問題への対応」
- 第10回「GraphQL(gqlgen)エラーハンドリング」
- 第9回「GraphQLにおける認証認可事例(Auth0 RBAC仕立て)」
- 第8回「GraphQL/Nuxt.js(TypeScript/Vuetify/Apollo)/Golang(gqlgen)/Google Cloud Storageの組み合わせで動画ファイルアップロード実装例」
- 第7回「GraphQLにおけるRelayスタイルによるページング実装(後編:フロントエンド)」
- 第6回「GraphQLにおけるRelayスタイルによるページング実装(前編:バックエンド)」
- 第5回「DB接続付きGraphQLサーバ(by Golang)をローカルマシン上でDockerコンテナ起動」
- 第4回「graphql-codegenでフロントエンドをGraphQLスキーマファースト」
- 第3回「go+gqlgenでGraphQLサーバを作る(GORM使ってDB接続)」
- 第2回「NuxtJS(with Apollo)のTypeScript対応」
- 第1回「frontendに「nuxtjs/apollo」、backendに「go+gqlgen」の組み合わせでGraphQLサービスを作る」
開発環境
# OS - Linux(Ubuntu)
$ cat /etc/os-release
NAME="Ubuntu"
VERSION="18.04.5 LTS (Bionic Beaver)"
# バックエンド
# 言語 - Golang
$ go version
go version go1.15.2 linux/amd64
# gqlgen
v0.13.0
IDE - Goland
GoLand 2020.2.3
Build #GO-202.7319.61, built on September 16, 2020
今回の全ソース
実践
gqlgenを使ってサーバーサイド側でどうGraphQLのエラーハンドリングをするべきか、いくつかの方法を試行してみる。
1.ベーシックな方法でハンドリング
いくつかのパターンを列挙する。
server.go
package main
import (
"log"
"net/http"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/sky0621/study-gqlgen/errorhandling/graph"
"github.com/sky0621/study-gqlgen/errorhandling/graph/generated"
)
func main() {
srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{}}))
http.Handle("/", playground.Handler("GraphQL playground", "/query"))
http.Handle("/query", srv)
log.Fatal(http.ListenAndServe(":8080", nil))
}
schema.graphqls
type Query {
normalReturn: [Todo!]!
errorReturn: [Todo!]!
customErrorReturn: [Todo!]!
customErrorReturn2: [Todo!]!
customErrorReturn3: [Todo!]!
customErrorReturn4: [Todo!]!
panicReturn: [Todo!]!
}
type Todo {
id: ID!
text: String!
}
リゾルバー
package graph
// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
import (
"context"
"errors"
"fmt"
"time"
"github.com/99designs/gqlgen/graphql"
"github.com/sky0621/study-gqlgen/errorhandling/graph/generated"
"github.com/sky0621/study-gqlgen/errorhandling/graph/model"
"github.com/vektah/gqlparser/v2/gqlerror"
)
func (r *queryResolver) NormalReturn(ctx context.Context) ([]*model.Todo, error) {
return []*model.Todo{
{ID: "001", Text: "something1"},
{ID: "002", Text: "something2"},
}, nil
}
func (r *queryResolver) ErrorReturn(ctx context.Context) ([]*model.Todo, error) {
return nil, errors.New("error occurred")
}
func (r *queryResolver) CustomErrorReturn(ctx context.Context) ([]*model.Todo, error) {
return nil, gqlerror.Errorf("custom error")
}
func (r *queryResolver) CustomErrorReturn2(ctx context.Context) ([]*model.Todo, error) {
graphql.AddError(ctx, gqlerror.Errorf("add error"))
graphql.AddErrorf(ctx, "add error2: %s", time.Now().String())
return nil, nil
}
func (r *queryResolver) CustomErrorReturn3(ctx context.Context) ([]*model.Todo, error) {
return nil, &gqlerror.Error{
Extensions: map[string]interface{}{
"code": "A00001",
"field": "text",
"value": "トイレ掃除",
},
}
}
func (r *queryResolver) CustomErrorReturn4(ctx context.Context) ([]*model.Todo, error) {
return nil, &gqlerror.Error{
Extensions: map[string]interface{}{
"errors": []map[string]interface{}{
{
"code": "A00001",
"field": "text",
"value": "トイレ掃除",
},
{
"code": "A00002",
"field": "text",
"value": "トイレ掃除",
},
},
},
}
}
func (r *queryResolver) PanicReturn(ctx context.Context) ([]*model.Todo, error) {
panic(fmt.Errorf("panic occurred"))
}
// Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }
type queryResolver struct{ *Resolver }
パターン別の解説
参考
GraphQLにおけるレスポンスに関して。
正常系の場合、以下のような構造になる。
{
"data": {
〜〜〜〜
}
}
リゾルバーで何かしらエラーを返すと、以下のような構造になる。
{
"errors": [
{
"message": 〜〜〜〜,
"path": [〜〜〜〜]
}
],
"data": null
}
その他、以下、参考にされたし。
https://gqlgen.com/reference/errors/
■正常系
func (r *queryResolver) NormalReturn(ctx context.Context) ([]*model.Todo, error) {
return []*model.Todo{
{ID: "001", Text: "something1"},
{ID: "002", Text: "something2"},
}, nil
}
■go標準のエラーを返すパターン
func (r *queryResolver) ErrorReturn(ctx context.Context) ([]*model.Todo, error) {
return nil, errors.New("error occurred")
}
指定したエラーメッセージがmessage
に積まれている。
path
は勝手に付与される。
■gqlgenが用意したメソッドを介してエラーを返すパターン
func (r *queryResolver) CustomErrorReturn(ctx context.Context) ([]*model.Todo, error) {
return nil, gqlerror.Errorf("custom error")
}
やはり、指定したエラーメッセージがmessage
に積まれている。
構造はgo標準のエラーを返すパターンと同じ。
■複数のエラーを返すパターン
func (r *queryResolver) CustomErrorReturn2(ctx context.Context) ([]*model.Todo, error) {
graphql.AddError(ctx, gqlerror.Errorf("add error"))
graphql.AddErrorf(ctx, "add error2: %s", time.Now().String())
return nil, nil
}
指定した2種類のエラーがそれぞれのmessage
に積まれている。
これまでのエラー発生時と違い、data
がnull
ではなく空スライスが返されているのが、やや気になる。
(おそらくだけど、return
でerrorを返さなかったためかな。)
■個別拡張領域を使うパターン
func (r *queryResolver) CustomErrorReturn3(ctx context.Context) ([]*model.Todo, error) {
return nil, &gqlerror.Error{
Extensions: map[string]interface{}{
"code": "A00001",
"field": "text",
"value": "トイレ掃除",
},
}
}
message
には何も積まず、用意されたextensions
にサービス固有の表現でエラー内容を定義する。
map[string]interface{}
なので任意の構造が使える。
これにより、レスポンスを受けたフロントエンド側でcode
に応じたエラーメッセージの生成とエンドユーザーへの表示などが可能になる。
■個別拡張領域を使うパターン2
func (r *queryResolver) CustomErrorReturn4(ctx context.Context) ([]*model.Todo, error) {
return nil, &gqlerror.Error{
Extensions: map[string]interface{}{
"errors": []map[string]interface{}{
{
"code": "A00001",
"field": "text",
"value": "トイレ掃除",
},
{
"code": "A00002",
"field": "text",
"value": "トイレ掃除",
},
},
},
}
}
返したいエラーは1つとは限らない。このようにマップのスライスという形で保持すれば複数のエラーを返すことも当然可能。
■panicが発生した時のパターン
func (r *queryResolver) PanicReturn(ctx context.Context) ([]*model.Todo, error) {
panic(fmt.Errorf("panic occurred"))
}
panic
発生時に積まれたメッセージは無視してinternal system error
がmessage
には積まれる。
2.カスタマイズしたエラーハンドリング
よっぽど小規模なサービスでない限り、サービス固有のエラーハンドリング表現が必要になってくると思う。
gqlgenではhandler生成時に「エラー発生時」と「panic発生時」にフックして処理を追加する仕掛けがある。
この仕掛けを利用して、
リゾルバーからは(エラー発生時)サービス固有に定義したエラー構造体を返し、handlerでフックして、エラー構造体を加工してレスポンスとする実装をしてみる。
schema.graphqls
type Query {
errorPresenter: [Todo!]!
panicHandler: [Todo!]!
}
type Todo {
id: ID!
text: String!
}
schema.resolvers.go
サービス固有のエラー構造体としてAppError
を作成。リゾルバーからはその構造体を返却。
package graph
import (
"context"
"fmt"
"github.com/sky0621/study-gqlgen/errorhandling2/graph/generated"
"github.com/sky0621/study-gqlgen/errorhandling2/graph/model"
)
type ErrorCode string
const (
ErrorCodeRequired ErrorCode = "1001"
ErrorCodeUnexpectedSituation ErrorCode = "9999"
)
type AppError struct {
Code ErrorCode
Msg string
}
func (e AppError) Error() string {
return fmt.Sprintf("[%s]%s", e.Code, e.Msg)
}
func (r *queryResolver) ErrorPresenter(ctx context.Context) ([]*model.Todo, error) {
return nil, AppError{
Code: ErrorCodeRequired,
Msg: "text is none",
}
}
func (r *queryResolver) PanicHandler(ctx context.Context) ([]*model.Todo, error) {
panic("unexpected situation")
}
// Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }
type queryResolver struct{ *Resolver }
server.go
SetErrorPresenter()
でセットする関数の中で、リゾルバーで投げたエラーを受け取り、AppError
だったら*gqlerror.Error{}
の構造に編集し直している。
ちなみに、SetRecoverFunc()
も用意してpanic発生時もサービス固有の想定したエラー表現になるよう編集している。
package main
import (
"context"
"errors"
"log"
"net/http"
"github.com/99designs/gqlgen/graphql"
"github.com/vektah/gqlparser/v2/gqlerror"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/sky0621/study-gqlgen/errorhandling2/graph"
"github.com/sky0621/study-gqlgen/errorhandling2/graph/generated"
)
func main() {
srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{}}))
srv.SetErrorPresenter(func(ctx context.Context, e error) *gqlerror.Error {
err := graphql.DefaultErrorPresenter(ctx, e)
var appErr graph.AppError
if errors.As(err, &appErr) {
return &gqlerror.Error{
Message: appErr.Msg,
Extensions: map[string]interface{}{
"code": appErr.Code,
},
}
}
return err
})
srv.SetRecoverFunc(func(ctx context.Context, err interface{}) error {
return &gqlerror.Error{
Extensions: map[string]interface{}{
"code": graph.ErrorCodeUnexpectedSituation,
"cause": err,
},
}
})
http.Handle("/", playground.Handler("GraphQL playground", "/query"))
http.Handle("/query", srv)
log.Fatal(http.ListenAndServe(":8080", nil))
}
動作確認
エラーハンドリング
うまいこと、エラーコードとエラーメッセージの振り分けが出来ている。
panicハンドリング
cause
としてpanic発生時のエラーを積むようにしたため、ちゃんとレスポンスに発生元のエラー内容も乗るようになった。
3.汎用性を考慮してカスタマイズしたエラーハンドリング
エラーと一口に言っても、バリデーションエラー、認証系のエラー、DB接続エラー、等々、いろんな種類があり、エラー構造として必要な要素も変わってくると思う。
単一のエラーで返せばいい場合もあれば、(バリデーションエラーのように)エラー要素1つ1つが必要で、結果として複数のエラーを返す必要がある場合もある。
こういった状況を踏まえ、なるべく汎用的にエラーハンドリングすることを試みる。
サービス固有のエラー構造
package graph
import (
"context"
"net/http"
"github.com/vektah/gqlparser/v2/gqlerror"
"github.com/99designs/gqlgen/graphql"
)
type AppError struct {
httpStatusCode int // http.StatusCodeXXXXXXX を入れる
appErrorCode AppErrorCode // サービス固有に定義したエラーコード
/*
* 以下、全てのエラー表現に必須ではない要素(オプションとして設定可能)
*/
field string
value string
}
func (e *AppError) AddGraphQLError(ctx context.Context) {
extensions := map[string]interface{}{
"status_code": e.httpStatusCode,
"error_code": e.appErrorCode,
}
if e.field != "" {
extensions["field"] = e.field
}
if e.value != "" {
extensions["value"] = e.value
}
graphql.AddError(ctx, &gqlerror.Error{
Message: "",
Extensions: extensions,
})
}
func NewAppError(httpStatusCode int, appErrorCode AppErrorCode, opts ...AppErrorOption) *AppError {
a := &AppError{
httpStatusCode: httpStatusCode,
appErrorCode: appErrorCode,
}
for _, o := range opts {
o(a)
}
return a
}
// 認証エラー用
func NewAuthenticationError(opts ...AppErrorOption) *AppError {
return NewAppError(http.StatusUnauthorized, AppErrorCodeAuthenticationFailure, opts...)
}
// 認可エラー用
func NewAuthorizationError(opts ...AppErrorOption) *AppError {
return NewAppError(http.StatusForbidden, AppErrorCodeAuthorizationFailure, opts...)
}
// バリデーションエラー用
func NewValidationError(field, value string, opts ...AppErrorOption) *AppError {
options := []AppErrorOption{WithField(field), WithValue(value)}
for _, opt := range opts {
options = append(options, opt)
}
return NewAppError(http.StatusBadRequest, AppErrorCodeValidationFailure, options...)
}
// その他エラー用
func NewInternalServerError(opts ...AppErrorOption) *AppError {
return NewAppError(http.StatusInternalServerError, AppErrorCodeUnexpectedFailure, opts...)
}
type AppErrorCode string
// MEMO: サービスの定義によっては意味のある文字列よりもコード体系を決めるのもあり。
const (
// 認証エラー
AppErrorCodeAuthenticationFailure AppErrorCode = "AUTHENTICATION_FAILURE"
// 認可エラー
AppErrorCodeAuthorizationFailure AppErrorCode = "AUTHORIZATION_FAILURE"
// バリデーションエラー
AppErrorCodeValidationFailure AppErrorCode = "VALIDATION_FAILURE"
// その他の予期せぬエラー
AppErrorCodeUnexpectedFailure AppErrorCode = "UNEXPECTED_FAILURE"
)
type AppErrorOption func(*AppError)
func WithField(v string) AppErrorOption {
return func(a *AppError) {
a.field = v
}
}
func WithValue(v string) AppErrorOption {
return func(a *AppError) {
a.value = v
}
}
解説
まず、サービス固有のエラー構造体としてAppError
を作成。
エラー要素として何を持つかはサービスによりまちまちだとは思うけど、とりあえず以下2つはエラーの内容問わず必須として定義。
- HTTPステータスコード
- サービス固有のエラーコード
type AppError struct {
httpStatusCode int // http.StatusCodeXXXXXXX を入れる
appErrorCode AppErrorCode // サービス固有に定義したエラーコード
〜〜
}
func NewAppError(httpStatusCode int, appErrorCode AppErrorCode, opts ...AppErrorOption) *AppError {
a := &AppError{
httpStatusCode: httpStatusCode,
appErrorCode: appErrorCode,
}
〜〜
}
続いて、例えばバリデーションエラーのように「どのフィールドのどの値が」という情報も欲しくなるようなケースのために構造体内には(冗長でも)パターン別に必要な要素を持たせるようにする。
type AppError struct {
〜〜
/*
* 以下、全てのエラー表現に必須ではない要素(オプションとして設定可能)
*/
field string
value string
}
ただし、今後、こういった要素の追加が必要になるたびにNew
関数を修正(つまり、呼び元も全て修正)なんてしたくないので、Functional Option Patternを用いることにする。
オプション適用のための関数を定義し、New
関数では可変引数で渡す(つまり、ないならないでOK)ようにする。
type AppErrorOption func(*AppError)
func NewAppError(httpStatusCode int, appErrorCode AppErrorCode, opts ...AppErrorOption) *AppError {
a := &AppError{
httpStatusCode: httpStatusCode,
appErrorCode: appErrorCode,
}
for _, o := range opts {
o(a)
}
return a
}
で、AppErrorOption
の適用事例として以下2つを用意。
func WithField(v string) AppErrorOption {
return func(a *AppError) {
a.field = v
}
}
func WithValue(v string) AppErrorOption {
return func(a *AppError) {
a.value = v
}
}
こうすることで、今後、エラー構造体に追加したい要素が増えても、既存の呼び出し元を修正する必要なく拡張できる。
(説明が雑なのが一番の理由だけど)初見でこの仕組みを理解するのは、けっこうキツいと思うので、「Functional Option Pattern」でググってもらって易しい解説記事を読んでもらいたい。。。
あとは、サービス固有のエラーコードを以下のように定義して、
type AppErrorCode string
// MEMO: サービスの定義によっては意味のある文字列よりもコード体系を決めるのもあり。
const (
// 認証エラー
AppErrorCodeAuthenticationFailure AppErrorCode = "AUTHENTICATION_FAILURE"
// 認可エラー
AppErrorCodeAuthorizationFailure AppErrorCode = "AUTHORIZATION_FAILURE"
// バリデーションエラー
AppErrorCodeValidationFailure AppErrorCode = "VALIDATION_FAILURE"
// その他の予期せぬエラー
AppErrorCodeUnexpectedFailure AppErrorCode = "UNEXPECTED_FAILURE"
)
エラーのタイプ別に専用のNew
関数でも用意してあげればOK。
// 認証エラー用
func NewAuthenticationError(opts ...AppErrorOption) *AppError {
return NewAppError(http.StatusUnauthorized, AppErrorCodeAuthenticationFailure, opts...)
}
// 認可エラー用
func NewAuthorizationError(opts ...AppErrorOption) *AppError {
return NewAppError(http.StatusForbidden, AppErrorCodeAuthorizationFailure, opts...)
}
// バリデーションエラー用
func NewValidationError(field, value string, opts ...AppErrorOption) *AppError {
options := []AppErrorOption{WithField(field), WithValue(value)}
for _, opt := range opts {
options = append(options, opt)
}
return NewAppError(http.StatusBadRequest, AppErrorCodeValidationFailure, options...)
}
// その他エラー用
func NewInternalServerError(opts ...AppErrorOption) *AppError {
return NewAppError(http.StatusInternalServerError, AppErrorCodeUnexpectedFailure, opts...)
}
リゾルバー
試しに、タイプ別にエラーを生成してGraphQLエラーとして追加してあげるとこんな感じ。
(認証エラーなんかは当然、ユーザーIDなどを積んだりするだろうけど、とりあえずはサンプルなので。)
package graph
import (
"context"
"github.com/sky0621/study-gqlgen/errorhandling3/graph/generated"
"github.com/sky0621/study-gqlgen/errorhandling3/graph/model"
)
func (r *queryResolver) CustomErrorReturn(ctx context.Context) ([]*model.Todo, error) {
// 認証エラーを追加
NewAuthenticationError().AddGraphQLError(ctx)
// 認可エラーを追加
NewAuthorizationError().AddGraphQLError(ctx)
// バリデーションエラーを追加
NewValidationError("name", "taro").AddGraphQLError(ctx)
// その他のエラーを追加
NewInternalServerError().AddGraphQLError(ctx)
return nil, nil
}
// Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }
type queryResolver struct{ *Resolver }
schema.graphqls
type Query {
customErrorReturn: [Todo!]!
}
type Todo {
id: ID!
text: String!
}
server.go
今回はhandlerへの仕込みは無し。
package main
import (
"log"
"net/http"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/sky0621/study-gqlgen/errorhandling3/graph"
"github.com/sky0621/study-gqlgen/errorhandling3/graph/generated"
)
func main() {
srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{}}))
http.Handle("/", playground.Handler("GraphQL playground", "/query"))
http.Handle("/query", srv)
log.Fatal(http.ListenAndServe(":8080", nil))
}
動作確認
この通り、統一されたフォーマットになっているので、レスポンスを受け取る側でのハンドリングもしやすいはず。。。
まとめ
とりあえずエラーが返せればいいといったシンプルなものから、一応、汎用性を考慮してサービス固有のエラー構造を定義したやり方まで複数のエラーハンドリング案を提示してみた。
もちろん、ここにあるパターン以外にもあるだろうし、ここにあげたものはプロダクションレベルとしては心もとない。
1サービスとして考えるなら、ここで返したエラー内容をフロントエンドではどのようにハンドリングするかも重要な要素だと思う。