より煩わしさを減らしてHTTPエラーを処理する + RFC7807

概要
Go言語でHTTP APIを作成する際、最も煩わしいのはエラー処理です。代表的には次のようなコードがあります。
1func(w http.ResponseWriter, r *http.Request) {
2 err := doSomething()
3 if err != nil {
4 http.Error(w, err.Error(), http.StatusInternalServerError)
5 log.Printf("error occurred: %v", err)
6 return
7 }
8 // ...
9}
APIが少数であれば、このように記述しても特に不便はないでしょう。しかし、APIの数が増え、内部ロジックが複雑になるにつれて、次の三つが気になってくるようになります。
- 適切なエラーコードの返却
- 多数の結果ログの記述
- 明確なエラーメッセージの送信
本論
適切なエラーコードの返却
もちろん1番目の、適切なエラーコードの返却は私個人の不満点ではあります。熟練した開発者であれば、適切なコードを探し出して毎回正しく記入するでしょうが、私を含めまだ未熟な開発者は、ロジックが複雑になり、呼び出し箇所が多くなるほど、適切なエラーコードを一貫して記述することに困難を覚えるかもしれません。これに対しては様々な方法があるでしょうし、最も代表的なものとしては、事前にAPIのロジックの流れを設計し、適切なエラーを返すようにコードを作成することがあるでしょう。そうしてください
しかし、これはIDE(あるいはLanguage Server)の助けを借りる人間の開発者にとって最適な方法には思えません。また、REST API自体がエラーコードに込められた意味を最大限活用するものである以上、別の方式を提案することができるでしょう。HttpErrorという、errorインターフェースの実装体を新たに作り、StatusCodeとMessageを保存するようにします。そして次のようなヘルパー関数を提供します。
1err := httperror.BadRequest("wrong format")
BadRequestヘルパー関数は、StatusCodeを400、Messageを引数で受け取った値に設定したHttpErrorを返すことになるでしょう。このほかにも当然、NotImplement、ServiceUnavailable、Unauthorized、PaymentRequiredなどのヘルパー関数を、自動補完機能で参照および追加できるはずです。これは用意された設計書を毎回確認するよりも速く、毎回数値でエラーコードを入力するよりも安定しているでしょう。http.StatusCode定数に全部あるですって?シッ
多数の結果ログの記述
エラーが発生した場合は、当然ログを残すことになります。APIが呼び出され、リクエストが成功したか失敗したかについてログを残す際、最初から予想されるすべての終了地点にログを記述すると、書くべきコードの量が多くなります。ハンドラー自体を一度ラップすることで、これを中央で管理できるようになります。
以下はchiルーターをラップする例です。
1package chiwrap
2
3import (
4 "errors"
5 "net/http"
6
7 "github.com/go-chi/chi/v5"
8
9 "github.com/gosuda/httpwrap/httperror"
10)
11
12type Router struct {
13 router chi.Router
14 errCallback func(err error)
15}
16
17func NewRouter(errCallback func(err error)) *Router {
18 if errCallback == nil {
19 errCallback = func(err error) {}
20 }
21 return &Router{
22 router: chi.NewRouter(),
23 errCallback: errCallback,
24 }
25}
26
27type HandlerFunc func(writer http.ResponseWriter, request *http.Request) error
28
29func (r *Router) Get(pattern string, handler HandlerFunc) {
30 r.router.Get(pattern, func(writer http.ResponseWriter, request *http.Request) {
31 if err := handler(writer, request); err != nil {
32 he := &httperror.HttpError{}
33 switch errors.As(err, &he) {
34 case true:
35 http.Error(writer, he.Message, he.Code)
36 case false:
37 http.Error(writer, err.Error(), http.StatusInternalServerError)
38 }
39 r.errCallback(err)
40 }
41 })
42}
ルーター構造体は内部にchi.Routerを保持しており、chi.Routerの機能をそのまま利用できるように構成されています。Getメソッドを見ると、先ほど提案したヘルパー関数が返すHttpError構造体が返されたかどうかを確認し、適切に返却します。そしてerrorの場合は、一括してエラーコールバック関数に渡します。このコールバックはコンストラクタを通じて受け取ります。
以下は、このパッケージを活用して作成したコードです。
1package main
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "log"
9 "net/http"
10
11 "github.com/gosuda/httpwrap/httperror"
12 "github.com/gosuda/httpwrap/wrapper/chiwrap"
13)
14
15func main() {
16 ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
17 defer cancel()
18
19 r := chiwrap.NewRouter(func(err error) {
20 log.Printf("Router log test: Error occured: %v", err)
21 })
22 r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
23 name := request.URL.Query().Get("name")
24 if name == "" {
25 return httperror.BadRequest("name is required")
26 }
27
28 writer.Write([]byte("Hello " + name))
29 return nil
30 })
31
32 svr := http.Server{
33 Addr: ":8080",
34 Handler: r,
35 }
36 go func() {
37 if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
38 log.Fatalf("Failed to start server: %v", err)
39 }
40 }()
41
42 <-ctx.Done()
43 svr.Shutdown(context.Background())
44}
いかがでしょうか。単にHttpErrorだけをヘルパー関数で作成して返すなら、上位のスコープで適切なエラーコードとメッセージによる応答を返しつつ、サービスごとに適切なログを残せるよう、コールバックを登録して処理することができます。必要に応じてさらに拡張し、RequestIDなどを用いれば、詳細なロギングも可能になるでしょう。
明確なエラーメッセージの送信
これのための文書としてRFC7807があります。RFC7807は主に次のような要素を定義して使用します。
type:エラーの種類を識別するURI。主にエラーを説明する文書です。title:どのようなエラーであるかについての一行の説明です。status:HTTPステータスコードと同一です。detail:当該エラーについての、人が読むことのできる詳細な説明です。instance:エラーが発生したURIです。例えばGET /user/infoでエラーが発生した場合、/user/infoがその値になるでしょう。extensions:JSONオブジェクトの形で構成される、エラーを説明するための付加的な要素です。- 例えば、
BadRequestの場合にはユーザーの入力が含まれることがあります。 - あるいは
TooManyRequestの場合には、直近のリクエスト時点を含めることもできます。
- 例えば、
これを簡単に使えるように、HttpErrorと同じ位置にあるhttperrorパッケージに新しいファイルを作成し、RFC7807Error構造体を作成して、メソッドチェーンのパターンで生成できるようにします。
1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
2 return &RFC7807Error{
3 Type: "about:blank", // Default type as per RFC7807
4 Title: title,
5 Status: status,
6 Detail: detail,
7 }
8}
9
10func BadRequestProblem(detail string, title ...string) *RFC7807Error {
11 t := "Bad Request"
12 if len(title) > 0 && title[0] != "" {
13 t = title[0]
14 }
15 return NewRFC7807Error(http.StatusBadRequest, t, detail)
16}
17
18func (p *RFC7807Error) WithType(typeURI string) *RFC7807Error { ... }
19func (p *RFC7807Error) WithInstance(instance string) *RFC7807Error { ... }
20func (p *RFC7807Error) WithExtension(key string, value interface{}) *RFC7807Error { ... }
Typeの"about:blank"はデフォルト値です。存在しないページを意味します。以下は不正なリクエストに対するエラー生成の例です。
1problem := httperror.BadRequestProblem("invalid user id format", "Bad User Input")
2
3problem = problem.WithType("https://example.com/errors/validation")
4 .WithInstance("/api/users/abc")
5 .WithExtension("invalid_field", "user_id")
6 .WithExtension("expected_format", "numeric")
簡単なメソッドチェーンにより、ユーザーに対する構造化されたエラーメッセージを生成できます。また、先ほど作成した中央化されたルーターを利用するために、次のメソッドを提供することができます。
1func (p *RFC7807Error) ToHttpError() *HttpError {
2 jsonBytes, err := json.Marshal(p)
3 if err != nil {
4 // If marshaling fails, fall back to just using the detail
5 return New(p.Status, p.Detail)
6 }
7 return New(p.Status, string(jsonBytes))
8}
これをそのまま使用して、上の例を修正すると次のようになります。
1package main
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "log"
9 "net/http"
10
11 "github.com/gosuda/httpwrap/httperror"
12 "github.com/gosuda/httpwrap/wrapper/chiwrap"
13)
14
15func main() {
16 ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
17 defer cancel()
18
19 r := chiwrap.NewRouter(func(err error) {
20 log.Printf("Router log test: Error occured: %v", err)
21 })
22 r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
23 name := request.URL.Query().Get("name")
24 if name == "" {
25 return httperror.BadRequestProblem("name is required", "Bad User Input").
26 WithType("https://example.com/errors/validation").
27 WithInstance("/api/echo").
28 WithExtension("invalid_field", "name").
29 WithExtension("expected_format", "string").
30 WithExtension("actual_value", name).
31 ToHttpError()
32 }
33
34 writer.Write([]byte("Hello " + name))
35 return nil
36 })
37
38 svr := http.Server{
39 Addr: ":8080",
40 Handler: r,
41 }
42 go func() {
43 if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
44 log.Fatalf("Failed to start server: %v", err)
45 }
46 }()
47
48 <-ctx.Done()
49 svr.Shutdown(context.Background())
50}
結論
このように中央化されたルーターを用いてエラーを処理すれば、毎回エラーコードを確認し、適切なエラーメッセージを作成する負担を減らすことができます。また、RFC7807を活用して構造化されたエラーメッセージを提供することで、クライアントがエラーを理解し処理するのを助けることができます。こうした方法により、Go言語で書かれたHTTP APIのエラー処理をより簡潔かつ一貫したものにすることができます。
本記事のコードはgosuda/httpwrapリポジトリで確認いただけます。