以更省心的方式处理 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 本身就充分利用了错误码所承载的含义,因此可以提出另一种方案。创建一个实现 error 接口的新类型 HttpError,使其保存 StatusCode 和 Message。然后提供如下辅助函数。
1err := httperror.BadRequest("wrong format")
BadRequest 辅助函数会返回一个 HttpError,其中 StatusCode 被设置为 400,Message 被设置为传入的参数值。除此之外,当然还可以通过自动补全功能查找和添加 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 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", // 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 仓库中查看。