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 辅助函数将返回一个 HttpError 实例,其中 StatusCode 设置为 400,Message 设置为传入的参数值。除此之外,当然也可以通过自动完成功能查询和添加诸如 NotImplementServiceUnavailableUnauthorizedPaymentRequired 等辅助函数。这比每次查阅准备好的设计文档要快,并且比每次手动输入错误代码的数字表示更稳定可靠。所有内容都在 http.StatusCode 常量中,对吧?嘘

大量的结果日志写入数量

发生错误时,理应记录日志。当记录 API 调用、请求成功与否的日志时,从开始到所有预期的终止点都记录日志会显著增加需要编写的代码量。这可以通过对 Handler 本身进行一次封装,从而实现集中管理。

以下是封装 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}

该 Router 结构体在内部持有 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 Status Code 相同。
  • detail: 对该错误的人类可读的详细描述
  • instance: 错误发生的 URI。例如,如果在 GET /user/info 中发生错误,那么 /user/info 将是其值。
  • extensions: 以 JSON Object 形式构成的、用于解释错误的次要元素。
    • 例如,对于 BadRequest 情况,可以包含用户的输入信息。
    • 或者,对于 TooManyRequest 情况,也可以包含最近一次请求的时间点。

为便于使用,我们可以在与 HttpError 处于相同位置的 httperror 包中创建一个新文件,生成 RFC7807Error 结构体,并使其能够以方法链(method chaining)模式进行创建。

 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		// 如果序列化失败,则退回到仅使用 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 仓库中查阅。