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. 发送清晰的错误消息

正文

返回适当的错误码

当然,第一点,返回适当的错误码,是我个人的不满之处。经验丰富的开发者会找到并每次都能恰当地使用适当的代码,但是像我这样,以及那些尚未熟练的开发者,随着逻辑的复杂化和调用次数的增多,可能会难以有规律地使用合适的错误码。对此可能存在多种方法,最典型的是在预先设计好 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 router 的示例。

 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: // 如果错误是 HttpError 类型
35				http.Error(writer, he.Message, he.Code)
36			case false: // 如果错误是普通 error 类型
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    "os" // 添加 os 包的导入
11    "os/signal" // 添加 os/signal 包的导入
12    "syscall" // 添加 syscall 包的导入
13
14	"github.com/gosuda/httpwrap/httperror"
15	"github.com/gosuda/httpwrap/wrapper/chiwrap"
16)
17
18func main() {
19    // 设置上下文以捕获中断和终止信号
20    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
21    defer cancel() // 在 main 函数退出时取消上下文
22
23	// 创建一个新的 router,并传入错误回调函数
24	r := chiwrap.NewRouter(func(err error) {
25		log.Printf("Router log test: Error occured: %v", err) // 在错误发生时记录日志
26	})
27	// 定义 GET /echo 路由
28	r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
29		// 从查询参数中获取 name
30		name := request.URL.Query().Get("name")
31		// 如果 name 为空,返回 BadRequest 错误
32		if name == "" {
33			return httperror.BadRequest("name is required")
34		}
35
36		// 向 writer 写入响应
37		writer.Write([]byte("Hello " + name))
38		// 成功时返回 nil
39		return nil
40	})
41
42	// 创建 HTTP 服务器
43	svr := http.Server{
44		Addr:    ":8080", // 服务器地址
45		Handler: r,       // 服务器 handler
46	}
47	// 在新的 goroutine 中启动服务器
48	go func() {
49		// 监听并服务,如果发生错误且不是 http.ErrServerClosed,则记录致命错误
50		if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
51			log.Fatalf("Failed to start server: %v", err)
52		}
53	}()
54
55    // 等待上下文完成(收到信号)
56    <-ctx.Done()
57    // 优雅地关闭服务器
58    svr.Shutdown(context.Background())
59}

感觉如何?只需将 HttpError 作为辅助函数返回,就可以在更高层级的 scope 中以适当的错误码和消息返回响应,同时通过注册回调函数,为每个实现的 service 留下适当的日志。如果需要,还可以扩展使用 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 模式创建。

 1// 创建一个新的 RFC7807Error
 2func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
 3	return &RFC7807Error{
 4		Type:   "about:blank", // 按照 RFC7807 的默认类型
 5		Title:  title,
 6		Status: status,
 7		Detail: detail,
 8	}
 9}
10
11// 创建一个 BadRequestProblem
12func BadRequestProblem(detail string, title ...string) *RFC7807Error {
13	t := "Bad Request"
14	if len(title) > 0 && title[0] != "" {
15		t = title[0]
16	}
17	return NewRFC7807Error(http.StatusBadRequest, t, detail)
18}
19
20// 设置 Type
21func (p *RFC7807Error) WithType(typeURI string) *RFC7807Error { /* ... */ return p }
22// 设置 Instance
23func (p *RFC7807Error) WithInstance(instance string) *RFC7807Error { /* ... */ return p }
24// 添加 Extension
25func (p *RFC7807Error) WithExtension(key string, value interface{}) *RFC7807Error { /* ... */ return p }

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")

通过简单的 method chaining,可以生成结构化的用户错误消息。此外,为了利用上面已经编写的集中化 router,可以支持以下方法。

 1// 将 RFC7807Error 转换为 HttpError
 2func (p *RFC7807Error) ToHttpError() *HttpError {
 3	// 将结构体 marshal 为 JSON 字节
 4	jsonBytes, err := json.Marshal(p)
 5	if err != nil {
 6		// 如果 marshal 失败,回退到只使用 detail
 7		return New(p.Status, p.Detail)
 8	}
 9	// 成功时返回新的 HttpError,消息为 JSON 字符串
10	return New(p.Status, string(jsonBytes))
11}

直接使用此方法修改上面的示例,就会变成这样。

 1package main
 2
 3import (
 4	"bytes"
 5	"context"
 6	"errors"
 7	"io"
 8	"log"
 9	"net/http"
10    "os" // 添加 os 包的导入
11    "os/signal" // 添加 os/signal 包的导入
12    "syscall" // 添加 syscall 包的导入
13
14	"github.com/gosuda/httpwrap/httperror"
15	"github.com/gosuda/httpwrap/wrapper/chiwrap"
16)
17
18func main() {
19    // 设置上下文以捕获中断和终止信号
20    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
21    defer cancel() // 在 main 函数退出时取消上下文
22
23	// 创建一个新的 router,并传入错误回调函数
24	r := chiwrap.NewRouter(func(err error) {
25		log.Printf("Router log test: Error occured: %v", err) // 在错误发生时记录日志
26	})
27	// 定义 GET /echo 路由
28	r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
29		// 从查询参数中获取 name
30		name := request.URL.Query().Get("name")
31		// 如果 name 为空
32		if name == "" {
33			// 返回一个结构化的 RFC7807 错误,并转换为 HttpError
34			return httperror.BadRequestProblem("name is required", "Bad User Input").
35                WithType("https://example.com/errors/validation").
36                WithInstance("/api/echo").
37                WithExtension("invalid_field", "name").
38                WithExtension("expected_format", "string").
39                WithExtension("actual_value", name).
40                ToHttpError()
41		}
42
43		// 向 writer 写入响应
44		writer.Write([]byte("Hello " + name))
45		// 成功时返回 nil
46		return nil
47	})
48
49	// 创建 HTTP 服务器
50	svr := http.Server{
51		Addr:    ":8080", // 服务器地址
52		Handler: r,       // 服务器 handler
53	}
54	// 在新的 goroutine 中启动服务器
55	go func() {
56		// 监听并服务,如果发生错误且不是 http.ErrServerClosed,则记录致命错误
57		if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
58			log.Fatalf("Failed to start server: %v", err)
59		}
60	}()
61
62    // 等待上下文完成(收到信号)
63    <-ctx.Done()
64    // 优雅地关闭服务器
65    svr.Shutdown(context.Background())
66}

结论

通过使用这种集中化的 router 来处理错误,可以减轻每次确认错误码和编写适当错误消息的负担。此外,利用 RFC7807 提供结构化的错误消息,可以帮助 client 理解和处理错误。通过这些方法,可以使 Go 语言编写的 HTTP API 的错误处理更加便捷和一致。

本文的代码可以在 gosuda/httpwrap repository 中查看。