POLOXUE's BLOG

POLOXUE's BLOG

29 Sep 2023

从头构建 Go Web 框架(三):中间件的数据共享

本系列文章写于 2014 年,相较于 golang 极短的发展历程,这已经是古董级别的一篇文章了,但 web 框架思想概念依然有效。系统通过这个系列文章,能让大家都现有 Go Web 框架有更深的认识。

本文是 “构建属于自己的 Web 框架” 系列文章中的第二篇,将介绍中间件的最佳实践。

我们在 上文 中介绍了 middleware 的实现,通过创建 func (http.Handler) http.Handler 类型函数,实现了路由或应用间共享代码。案例: 日志(logging) 和 异常恢复(panic recovery)。但是,这两个案例的功能还是相对单一,多数中间件要比它们复杂,需要在中间件间共享数据。

本文将以用户身份验证为例,介绍如何在 middleware 间共享数据。

前言

对于面向用户的 web 服务而言,用户认证是几乎每个 request handler 都需要的能力,而 middleware 正是为此而生。

如果没有 middleware,则需要在每个 handler 中查询数据库得到用户信息,如用户 ID、名称等,认证用户是否合法等。而利用 middleware,则能统一处理。但问题是,下游 handler 还能得到登录用户信息。

如果能解决了 middleware 与 handler 间的数据共享问题,就能实现我们的目标。

常见思路

Ruby 中的 Rack 可通过 hash map 类型 env 变量存储一个请求周期内的信息。我们也可以用它保存信息。

Javascript 的 Connect.js 中间件中有 2 个参数,分别是 requestresponserequest 是个 object,在 javascript 也可以是 map,我们可以用它来存储请求范围内的中间信息。

静态语言也没有什么不同,如 Java 的 Netty 也是通过 map 实现。handler 通过访问 context 中的一个 map 属性实现数据共享。

Go 中的一些框架和包是如何做的呢?

Gorilla

和上面的类似,Gorilla 的 gorilla/context 中定义了一个变量 map[interface{}]interface{},可用于存储数据,同时通过 mutex 保证线程安全。

定义代码,如下所示:

1
2
3
4
var (
  mutex sync.RWMutex
  data  = make(map[*http.Request]map[interface{}]interface{})
)

如何存储与获取:

1
2
3
4
5
6
7
func myHandler(w http.ResponseWriter, r *http.Request) {
  context.Set(r, "foo", "bar")
}

func myOtherHandler(w http.ResponseWriter, r *http.Request) {
  val := context.Get(r, "foo").(string)
}

因为 context 中 data 中不会被自动清理。如果每个请求都向其新增数据,它将会无限增加。因而,在请求结束后要对 map 进行清理。

优点:

  • handler 和 middleware 保持不变,调用函数存储和读取即可。

缺点:

  • 基准测试显示它是性能最差的方案,但实际应用中,性能通常不是核心点;
  • 使用 mutex,而非 channel, 但 Go 文档并不反对 mutex
  • 对于 interface{} 类型,需要经过类型断言;

Goji

Goji 使用一个含有 map[string]interface{} 名为 Env 的结构体,无互斥锁。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func myMiddleware(c *web.C, h http.Handler) http.Handler {
  fn := func (w http.ResponseWriter, r *http.Request) {
    c.Env["name"] = "world"
    h.ServeHTTP(w, r)
  }

  return http.HandlerFunc(fn)
}

func hello(c web.C, w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, %s!", c.Env["name"].(string))
}

优点:

  • 无互斥锁;

缺点:

  • 无法与我们现在的中间件兼容;
  • 需要类型断言;

gocraft/web

gocraft/web 中可以用任何类型作为 context 共享数据。没有 map,无类型断言。对于性能来说非常友好,但它是最不兼容的方案。每个应用的 context 都不同的,这将导致你的中间件无法被不同系统复用。

优点:

  • 无互斥锁;
  • 无类型断言;

缺点:

  • 无法与我们的中间件兼容;
  • 为此框架而写的代码难以复用;

go.net/context

Google 内部 context 包。阅读 context。一个非常好的解决方案,他们想统一 context 的实现。问题是它是否能与我们的系统很好地配合呢?

汇总对比

不同方案对比,如下所示:

packagemutexmapstruct
go.net/contextny*n
gojinyn
ginnyn
martininyn
gorillayyn
tigertonicyny
gocraft/webnny

go.net/context 未使用 map,但方法类似。

context 的实现思路都是类似的,但也各有优劣。

为提高性能,一些方案在尽量避免 type assertion、map 或 mutex,但其实性能差异不到 10%。虽然,struct 是最快的,但如果一个请求要 10 ms 的处理时间,这种影响将不到 1%。

毫无疑问,最灵活的实现方案是 map。使用 map 的 context 是 gorilla/context 和 Goji。

gorilla/context 是某位 Go 创建者的方案,也是最容易实现的。

本文将基于 gorilla/context 实现。

如果想将自己的成果分享他人,则要尽量采用标准接口。如 nosurf 的 context,与 gorilla/context 是相同概念。在几乎所有框架的项目中使用,而不会出现问题。

这也是为什么 gorilla/context 系统更优秀,它更易重用中间件。

context 集成

使用 gorilla/context,我们几乎无需对代码进行任何修改。

在介绍 gorilla/context 时,我说过 context 中的 map 不会自动清理,因此,对于每个 request,map 中都会有新的内容且无限增加。该 package 提供了一个 ClearHandler 解决这问题。

1
2
3
4
5
6
func main() {
  commonHandlers := alice.New(context.ClearHandler, loggingHandler, recoverHandler)
  http.Handle("/about", commonHandlers.ThenFunc(aboutHandler))
  http.Handle("/", commonHandlers.ThenFunc(indexHandler))
  http.ListenAndServe(":8080", nil)
}

让我们创建一个身份认证 middleware,authhandler,用于用户验证并存储用户信息。

从 header 中获取 token,基于 token 从 database 查找用户。如果认证失败,将返回错误,而不是继续执行下一个 middleware。反之,它将把所获得的用户信息存储在 context,并调用下一个 middleware。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func authHandler(next http.Handler) http.Handler {
  fn := func(w http.ResponseWriter, r *http.Request) {
    authToken := r.Header().Get("Authorization")
    user, err := getUser(authToken)

    if err != nil {
      http.Error(w, http.StatusText(401), 401)
      return
    }

    context.Set(r, "user", user)
    next.ServeHTTP(w, r)
  }

  return http.HandlerFunc(fn)
}

func adminHandler(w http.ResponseWriter, r *http.Requests) {
  user := context.Get(r, "user")
  json.NewEncoder(w).Encode(user)
}

func main() {
  commonHandlers := alice.New(context.ClearHandler, loggingHandler, recoverHandler)
  http.Handle("/admin", commonHandlers.Append(authHandler).ThenFunc(adminHandler))
  http.Handle("/about", commonHandlers.ThenFunc(aboutHandler))
  http.Handle("/", commonHandlers.ThenFunc(indexHandler))
  http.ListenAndServe(":8080", nil)
}

为了简化演示,假设 user 类型是 map[string]interface{}

AdminHandler 中,我们获取 context 中的用户信息,并写入 Response 中。

应用级别变量

这种方式的问题是, getUser 要访问数据库,而 context 是 request 级别,在每个 request 的 context 中存储数据库连接不是一种好的实现方式。最好的方式是,所有请求共享一个 DB 连接。

如下所示,使用 global/package 变量:

1
2
3
4
5
var dbConn *sql.DB

func main() {
  dbConn := sql.Open("postgres", "...")
}

应用程序中的任何地方都可访问 dbConn,且 *sql.DB 是一个并发安全的连接池。以往经验,global 变量不利于维护,存在容易修改,无法追踪的问题。即使有完全的测试,也存在难以重构的问题。

另一个方案是,创建一个 struct 变量,管理类似 dbConn 的变量,同时将 handler 和 middle 作为它的方法。为解决上述问题,它将包含 db 字段,方法有 authHandleradminHandler

稍作变动,即将 db 用于 getUser 函数,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
type appContext struct {
  db *sql.DB
}

func (c *appContext) authHandler(next http.Handler) http.Handler {
  fn := func(w http.ResponseWriter, r *http.Request) {
    authToken := r.Header.Get("Authorization")
    user, err := getUser(c.db, authToken)

    if err != nil {
      http.Error(w, http.StatusText(401), 401)
      return
    }

    context.Set(r, "user", user)
    next.ServeHTTP(w, r)
  }

  return http.HandlerFunc(fn)
}

func (c *appContext) adminHandler(w http.ResponseWriter, r *http.Request) {
  user := context.Get(r, "user")
  // Maybe other operations on the database
  json.NewEncoder(w).Encode(user)
}

func main() {
  db := sql.Open("postgres", "...")
  appC := appContext{db}
  commonHandlers := alice.New(context.ClearHandler, loggingHandler, recoverHandler)
  http.Handle("/admin", commonHandlers.Append(appC.authHandler).ThenFunc(appC.adminHandler))
  http.Handle("/about", commonHandlers.ThenFunc(aboutHandler))
  http.Handle("/", commonHandlers.ThenFunc(indexHandler))
  http.ListenAndServe(":8080", nil)
}

它与中间件系统很好地契合,代码变动很小。类似 gocraft/web 的视线,使用一个 struct 管理应用级别变量,但仅适用于应用程序范围。

可以将 getUser 挂到 appContext,使代码更加简洁。或将 *sql.DB 包含在其他自定义结构中,将 getUser 添加为这个自定义结构的方法,使用 c.db.getUser(token) 即能完成调用。

最后

本文通过 middleware 的数据共享,实现了将用户数据传递到主 handler。实现了以最小的修改将 middleare 和支持 context 的中间件相结合。

下一章主题:router 路由