跳过正文
目录
  1. 文章/

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

·2598 字·6 分钟
POLO XUE
作者
POLO XUE
技术探索与学习记录

本系列文章写于 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 保证线程安全。

定义代码,如下所示:

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

如何存储与获取:

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 的结构体,无互斥锁。

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 解决这问题。

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。

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 变量:

var dbConn *sql.DB

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

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

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

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

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 路由