GoSuda

HTTP エラーをより煩雑でなく処理すること + RFC7807

By snowmerak
views ...

概要

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. 適切なエラーコードの返却
  2. 多くの結果ログの記述量
  3. 明確なエラーメッセージの送信

本論

適切なエラーコードの返却

もちろん1番目の適切なエラーコードの返却は、私の個人的な不満点ではあります。熟練した開発者であれば、適切なコードを探し出して毎回適切に設定するでしょうが、私自身もそうですが、まだ未熟な開発者は、ロジックが複雑になり回数が多くなるにつれて、適切なエラーコードを規則的に使用することに困難を経験する可能性があります。これに対していくつかの方法があるでしょうし、最も代表的には事前にAPIロジックのフローを設計した後、適切なエラーを返却するようにコードを記述することがあるでしょう。そうしてください

しかしながら、これはIDE(あるいはLanguage Server)の支援を受ける人間開発者にとって最適な方法には見えません。また、REST API自体がエラーコードに含まれる意味を最大限に活用するものである以上、もう一つの方式を提案することができます。HttpErrorというエラー(error)インターフェースの実装を新たに作成し、StatusCodeMessageを保持するようにします。そして次のようなヘルパー関数を提供します。

1err := httperror.BadRequest("wrong format")

BadRequestヘルパー関数は、StatusCodeとして400を、Messageを引数として受け取った値に設定したHttpErrorを返却するでしょう。この他にも当然、NotImplementServiceUnavailableUnauthorizedPaymentRequiredなどのヘルパー関数を自動補完機能によって参照および追加することができるでしょう。これは用意された設計書を毎回確認することよりも速く、毎回数字でエラーコードを入力することよりも安定的でしょう。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: どのようなエラーかについての1行説明です。status: HTTP Status Codeと同一です。detail: 当該エラーについて人間が読むことができる詳細な説明です。instance: エラーが発生したURIです。例えばGET /user/infoでエラーが発生した場合、/user/infoがその値になるでしょう。extensions: JSON Objectの形式で構成されるエラーを説明するための副次的な要素です。

  • 例えば、BadRequestである場合はユーザーの入力が含まれる可能性があります。
  • あるいはTooManyRequestである場合は最も最近のリクエスト時点を含むこともあります。

これを簡単に使用するために、HttpErrorと同じ位置であるhttperrorパッケージに新しいファイルを生成し、RFC7807Error構造体を作成し、メソッドチェイニングパターンで生成できるようにします。

 1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
 2	return &RFC7807Error{
 3		Type:   "about:blank", // 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		// マーシャリングに失敗した場合、詳細情報のみを使用するフォールバック
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リポジトリで確認することができます。