上下文变量值(context values)陷阱及在 Go 中如何避免或缓和这些陷阱

在 context.Context 中存储数据,或者说使用上下文变量值(context values)是在 Go 中最有争议的设计模式之一。在上下文中存储值似乎看起来不错,但是应该将什么东西存储为上下文变量值引起了广泛的讨论。

诚实地说,当我第一次使用上下文变量的时候, 显得有点天真,使用的方式有点不合适,会让每个人都会抱怨的。我曾经使用他们只是来存储每个请求相关的片段数据,以便我的 Web 应用的处理器(handlers)能够访问到这些值。这种方式有些缺点,但是总的来说这样很有效并且允许我快速写出我的应用来。

过去几个月,我试图深入研究更多关于上下文变量值的使用方式,我已经阅读了很多文章、Reddit 评论、邮件列表的回复,以及一切关于这个话题的讨论,但是这仍然困扰着我。无论我多么深挖这个话题,仍然没有人有意愿讨论真正可行的解决方案。

当然,每个人都可以提出为什么使用上下文变量值不好的理由,但是没有一个替代方案能完全取代它。相反,这些替代方案仍然很粗糙,像“自定义 structs” 或 “闭包(closures)”的方案并没有深入研究他们在复杂的应用中如何实现,或对中间件的可重用性可能如何影响。

现在我会对此问题给出自己的见解。在这篇文章中我们会讨论为什么使用上下文变量值会有问题、一些没有使用上下文变量值的替代方案和其适用场景,以及最终我们会讨论如何正确使用上下文变量值以避免或减轻其潜在不足。但是,首先我想通过为什么开发者总是轻易使用上下文变量值作出解释,正如我认为理解问题如何被解决的和问题的解决方案同样重要。

开始之前,让我们制定下基本准则

我尽力是我的例子清晰易懂,但是尽管我想要显式强调那些并不是在请求的生命周期内创建和销毁的变量值 应该从来不通过 context.Value() 管理。不应该存储一个日志接收器(logger)在 context.Value() 里,如果它并不是专门创建出来只作用于这个请求的;同样,不应该在上下文变量值里存储通用数据库连接。

有可能下面这些是与单一请求相关的:例如,你可能创建一个日志接收器用于预先在消息里加上请求ID(request ID);或者对于每个需要访问数据库连接的请求你可能创建单独的数据库事务,正好可以关联到上下文中。上面两个例子很接近我认为的正确使用上下文变量值的场景,但是关键是他们都只存活于请求的生命周期之内。

为什么人们总是轻易使用上下文变量值

在解决这个问题之前,我们需要知道为什么开发者会觉得需要存一些数据到上下文变量中,当然如果有其他方式更为容易他们也会使用的,因此使用未标识类型的 context.WithValue() 函数和 context.Value() 方法有哪些好处呢?

简要回答就是通过使用上下文变量,我们能轻易地创建可重用和可互换的中间件函数。换句话说,我们可以定义一个中间件,接收 http.Handler 作为参数,然后返回一个 http.Handler,这种方式允许我们使用任何含有路由库、中间件库或任何其他功能库的中间件的结果帮助我们处理 HTTP 请求,并且符合 http.Handler 接口。这也意味着如果想要测试不同的中间件实现或增加不同的函数功能,我们能轻易更换中间件函数(来做这件事)。

下面的例子更强有力地说明了这个问题。想象你正在构建一个 Web 服务器,然后你需要对每一个请求增加一个唯一 ID,这是一个很普遍的需求,满足这个需求的一个实现是写一个生成唯一ID的函数,然后把它存储在关联这个请求的上下文中。

var requestID = 0

func nextRequestID() int {
    requestID++
    return requestID
}

func addRequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(
    func(w http.ResponseWriter, r *http.Request) {
        ctx := context.WithValue(r.Context(), "request_id", nextRequestID())
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

警告 上述代码只用于示例,尚不足以用于生产环境

然后我们能使用任何路由包(例如 chi)利用这个函数,或者我们能用标准库中的 http.Handle() 函数利用它,如下:

func main() {
    http.Handle("/", addRequestID(http.HandlerFunc(printHi)))
    http.ListenAndServe(":3000", nil)
}

func printHi(w http.ResponseWriter, r *http.Request) {
    fmt.Println(w, "Hi! Your request ID is:", r.Context().Value("request_id"))
}

现在你可能会问自己,"如果我们需要一个请求 ID 的话,难道我们不能在代码中调用下 nextRequestID()? 这个上下文变量看起来毫无必要"。

从技术角度来说,这是正确的。我们可以直接调用,如果你正在写一个相对简单的应用我也建议你直接调用,但是如果逻辑突然变得更复杂了或者我们的应用规模增大了的话会怎样呢?如果我们不是需要一个请求ID而是需要验证用户是否登录,如果没有登录的话重定向到登录页,如果登录了的话查找用户对象并且存储下来以备之后使用我们该如何处理呢?

一个非常简单的认证逻辑可能会是如下版本:

user := lookupUser(r)
if user == nil {
    // No user so redirect to login
    http.Redirect(w, r, "/login", http.StatusFound)
    return
}

现在不再只是在我们所有的处理器中加入一行代码了,我们需要5行代码。这看起来并不糟糕,但是如果我们想要在处理器中进行四或五种不同的中间处理的时候会怎样呢?就像生成一个唯一的请求 ID,创建一个日志接收器利用这个请求 ID,验证用户是否登陆,验证用户是否是管理员?

那挺起来像是在多个处理器中不断重复的糟糕代码,也非常容易出错。不合理的访问权限控制一次又一次地出现在各种榜单上,比如 OWASP TOP 10,最终也更容易出错。一个开发者可能会忘记在一个处理器中验证一个用户是否是管理员,我们突然就有了一个只能管理员访问的页面暴露给普通用户,当然谁也不希望发生这种事。

与其产生这种缺陷,许多开发者更喜欢在他们的路由函数中使用中间件来避免这样的错误。这也帮助应用更易于清晰地理解是否需要认证。最终,这也易于解释他们的代码,因为你能轻易判断出是否用户对象会预期出现。

下面的例子展示了你可能使用上面的认证逻辑验证当访问 /dashboard/ 前缀的路径时,用户是否登录。一个相似的方法可能被用于当访问 /admin/ 前缀的路径时, 用户是否具有管理员权限。

func requireUser(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        user := lookupUser(r)
        if user == nil {
            http.Redirect(w, r, "/login", http.StatusFound)
            return
        }
        ctx := context.WithValue(r.Context(), "user", user)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}
    
func main() {
    dashboard := http.NewServeMux()
    dashboard.HandleFunc("/dashboard/hi", printHi)
    dashboard.HandleFunc("/dashboard/bye", printBye)

    mux := http.NewServeMux()
    // All routes that start with /dashboard/ require that a user is authenticated using the requireUser middleware
    mux.Handle("/dashboard/", requireUser(dashboard))
    mux.HandleFunc("/", home)

    http.ListenAndServe(":3000", addRequestID(mux))
}

你只能本地运行-- Web 服务器不允许在 Go Playground 运行

上下文变量适合在哪引入到我们的认证中间件呢?当认证用户的时候(取决于你的认证策略)你可能最终会找出这个用户对象来,尽管你已经知道这个用户了但可能会不得不再查一遍数据库,因此我们能使用上下文变量存储这个用户对象以备未来之用。

很干净,不是吗?因此如果上下文变量允许我们做像让一个用户在我们的处理器中可用这种如此酷的操作时它怎么又让人难以接受了呢?

使用上下文变量的缺点

使用 context.WithValue()context.Value() 最大的缺点时你正在主动选择放弃一些信息和编译时类型检查。你可能利用这种方法写出了通用型代码,但是也有一个值得思考的问题。我们处于某种原因在函数中使用显式类型参数,因此任何时候我们选择放弃放弃一些信息,这些信息可能值得考虑是否有那么大的收益。

我无法回答你这个问题,因为对于不同的项目结果可能不一样,但是在做决定之前,你应该确保真正理解了你要放弃的是什么。

函数需要的数据被隐藏了

当使用上下文变量的时候,我最大的关切是难以确定函数需要处理的数据。我们不会写接收任意的 maps 并且期望用户放入使我们的函数能够工作的各种变量的函数,同样我们一半不应该为自己的 Web 应用写这样的处理器。

func bad(m map[interface{}]interface{}) {
    // we don't expect m to have the keys "user" and
    // "request_id" for our code to work. If we needed those
    // we would define our function like the one below.
}

func good(user User, requestID int) {
    // Now it is clear that this function requires a user and
    // a request ID.
}

对于一些像 editUser() 这样的函数,很明显像用户对象的数据要呈现出来,但是大部分时候,函数定义不足够,因此作为开发者,我们不能期望别人根据函数的名字就能识别出哪些参数是必要的。相反,我们应该明确地在代码中指出来以更易于阅读和维护。我们的 Web 应用,尤其是哪些处理器函数和中间件函数,也不应该有任何的不同。我们不应该传递个 context 对象,期望他们从中取出他们需要的所有数据。

我们失去了编译时类型安全保障

上下文变量值本质上是一个 interface{}, interface{} 对(请查看源码)。这也是为什么我们允许存储任意数据而不会产生编译时警告的原因--键值都被定义为空类型,接收任何字面量。

这种做法的好处是 context.Context 任意的实现都能存储适用于特定应用的各种类型数据。缺点是我们无法指望编译器能替我们分辨是否产生了错误。尤其是在我们的程序中当我们存储字符串代替 User 对象时,程序仍然能编译通过,除非我们使用类型推断然后就崩溃了。有几种最小化风险的方式,但是开发者总是免不了出错,而这只会在运行时出现。

有什么方法避免吗?对于初学者,不要根据我们在以上例子中的方式使用上下文变量,而是使用特定类型。除此之外,“packages should define keys as an unexported type to avoid collisons.” --来自 Go 源码。这意味着在 context.WithValue()context.Value() 中任何以自定义类型作为作为键的变量调用不要在定义它的包外分享它。例如:

type userCtxKeyType string

const userCtxKey userCtxKeyType = "user"

func WithUser(ctx context.Context, user *User) context.Context {
    return context.WithValue(ctx, userCtxKey, user)
}

func GetUser(ctx context.Context) *User {
    user, ok := ctx.Value(userCtxKey).(*User)
    if !ok {
        // Log this issue
        return nil
    }
    return user
}

除了使用 getterr 和 settings 和非到处键,确保总是使用类型检查的较长的方式(有两个返回值的形式)。这会帮你避免在代码中产生不必要的崩溃,并且给你处理异常结果的机会。

如果你遵循以上建议,一些源于类型安全的缺陷将会被组织,因此我们不会在文章的剩余部分讨论太多这个特殊的问题,但是一定要警惕这个问题。这并不是编译器会帮你解决的问题,而是作为开发者、测试人员和代码审查人员应该要处理的错误。

context.Value() 的替代方案

我猜有很多人会说 "我使用方案 X 并且运行得不错。为什么你要写这篇文章?"。我不会试图辩论你的方案时错的,但是我并不真的相信有一个放之四海而皆准的解决方案,因此本文的剩余部分将专注于几个我认为有用的替代方案。我也会尽量谈下他们覆盖不到的方面/领域,以便你能了解到适用于自己使用场景的合适方案。

代码复制-需要时再查抄数据

我们简要讨论了什么时候和为什么开发者会使用上下文变量,但是我想在这里也谈谈之前没谈的内容。当你写一个相对简单的额应用时,或者及时你在建一个复杂的应用时,你也会几乎总是从查找你需要的数据开始。

这正是这本书所谈的内容 -- 使用 Go 进行 Web 开发。在这本书中,我们一开始直接在处理器内部写所需逻辑,然后将逻辑外移到可能每个处理器都需要调用的可重用函数中。例如,与其使用之前讨论过的 requireUser() 中间件,我们不如写一个函数,然后被 http.Handler 调用,如下所示:

func printHi(w http.ResponseWriter, r *http.Request) {
    user, err := requireUser(w, r)
    if err != nil {
        return
    }
    // do stuff w/ user
}

func requireUser(w http.ResponseWriter, r *http.Request) (*User, error) {
    user := lookupUser(r)
    if user == nil {
        // No user so redirect to login
        http.Redirect(w, r, "/login", http.StatusFound)
        return nil, errors.New("User isn't logged in")
    }
    return user, nil
}

这将会产生一些代码复制,但是还能接受。我们限制了复制代码的行数,只有一点复制要比增加额外的复杂度摇号。使这个产生问题的情况是它可能会演变成大量的代码复制,比如可能在许多不同的处理器中需要调用五到六个函数。那经常意味着你可能需要放弃这个方案并寻找新的方法。

闭包和自定义函数说明

另一个普遍的解决方案是写一些函数,这些函数能够查找必要的数据,然后利用这些数据调用你自定义的函数。为了让这个方法浅显易懂,我们经常使用闭包,包装相似的处理器来创建我们的 http.Hander,这些处理器需要相同的数据。

func requireUser(fn func(http.ResponseWriter, *http.Request, *User)) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    user := lookupUser(r)
    if user == nil {
      // No user so redirect to login
      http.Redirect(w, r, "/login", http.StatusFound)
      return
    }
    fn(w, r, user)
  }
}

func printUser(w http.ResponseWriter, r *http.Request, user *User) {
  fmt.Fprintln(w, "User is:", user)
}

func main() {
  http.HandleFunc("/user", requireUser(printUser))
  http.ListenAndServe(":3000", nil)
}

很明显 printUser() 预期需要一个用户对象,通过使用 requireUser() 函数我们能见任何函数 func(http.ResponseWriter, *http.Request, *User) 轻松转变为 http.Handler

我发现这个方案意外适用于在所有的处理器中你需要相似的特定于上下文的数据的场景。例如,如果你需要请求 ID,一个使用请求 ID 和用户对象的日志接收器时,你能使用这个方案将所有的函数转变为 http.Handler

一个人为的案例如下:

// requireUser and printUser don't change

func printReqID(w http.ResponseWriter, r *http.Request, requestID int) {
  fmt.Fprintln(w, "RequestID is:", requestID)
}

func printUserAndReqID(w http.ResponseWriter, r *http.Request, requestID int, user *User) {
  printReqID(w, r, requestID)
  printUser(w, r, user)
}

func addRequestID(fn func(http.ResponseWriter, *http.Request, int)) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    fn(w, r, nextRequestID())
  }
}

func requireUserWithReqID(fn func(http.ResponseWriter, *http.Request, int, *User)) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    addRequestID(func(w http.ResponseWriter, r *http.Request, reqID int) {
      requireUser(func(w http.ResponseWriter, r *http.Request, user *User) {
        fn(w, r, reqID, user)
      })(w, r)
    })(w, r)
  }
}

func main() {
  http.HandleFunc("/user", requireUser(printUser))
  http.HandleFunc("/reqid", addRequestID(printReqID))
  http.HandleFunc("/both", requireUserWithReqID(printUserAndReqID))
  http.ListenAndServe(":3000", nil)
}

这个方法的不足是当你需要在每个处理器中需要不同的数据,这种方法会随着应用规模的增加而变得越来越复杂。同时,这种方法消除了在路由代码引入前运行中间件的能力,使得类似“所有起于 /dashboard/ 的路径必须要求用户登录”的方案更难以表达。

尽管有这些缺陷,我仍然认为这种方案值得考虑,除非它确实本身成为了一个问题。但是这并不是说,”我们最终需要特定路由的中间件“,然后放弃这种方案;而是,除非你确实遇到了它不适宜的场景否则你应该尽量使用它。

当不适宜的场景最终发生时,我有一个想谈谈的方案。

处理上下文变量的模糊性

最终我转向的方案是在刚才回顾的方案和上下文变量的融合处理。基本思想是使用上下文变量和 http.Handler 函数,如本文开始的示例,但是在我们确实需要上下文变量提供的数据之前,我们献血一个函数从上下文变量中拉取数据,传递给需要它的函数。昨晚这些之后,我们调用的函数应该永不需从上下文变量中拉去额外的数据,否则会影响到应用的流程。

通过以上做法,我们帮助消除了使用 context.Value() 获取数据所带来的模糊性。我们不必去考虑这个问题,“一些嵌套函数调用会预期上下文中要预设某些变量吗?”,因为所有的数据总是将从上下文变量中抽取出来。

最好将此情况用案例的方式描述,因此我们再一次使用了 addRequestID() 中间件函数和一个简单的 home 处理器,在这个案例中并不明显的是,logger 也是被设计为作用于单个请求的日志接收器。

func main() {
  mux := http.NewServeMux()
  mux.HandleFunc("/", homeHandler)

  http.ListenAndServe(":3000", addRequestID(addLogger(mux)))
}

func homeHandler(w http.ResponseWriter, r *http.Request) {
  ctx := r.Context()
  reqID := GetRequestID(ctx)
  logger := GetLogger(ctx)
  home(w, r, reqID, logger)
}

func home(w http.ResponseWriter, r *http.Request, requestID int, logger *Logger) {
  logger.Println("Here is a log")
  fmt.Fprintln(w, "Homepage...")
}

使这个方案特别吸引我的是,这个方案非常容易重构那些之前使用了上下文变量值的代码,并充分利用这个特性。你不必剥离很多代码,也不必一次重构一切骨架,相反,可以通过一分为二的形式来分离原来的单个函数--一个 http.Handler 获得数据,另一个函数使用这些数据,处理原来的函数的那些业务逻辑。

这真的和一开始的例子有所不同?

最终,这个方案并没有和我们回顾的其他方案有很大不同。最值得注意的是,这似乎和我们一开始使用上下文变量值的例子近乎一致,但是两者之间还是又一些微小但是非常重要的不同之处的。

通过总是使用非导出上下文键值和其 getter、setter 函数,我们有效避免了分配给上下文变量错误类型的风险,限制了我们的数据无法被设置的风险。及时数据没有被设置,我们的 getter 函数仍然可以试图去处理它,当他们需要将处理逻辑延迟交由处理器处理时,能够选择返回一个错误。

第二个变化更为微妙;通过将我们的函数一分为二,代码更为清晰地展示了我们预期要设置的数据。最终,任何查看 home 函数的人将无需通过阅读代码就知道我们需要设置数据。这是一个对于预期能够从 context.Value() 中抽取数据方案显著的改善,这个方案无需再给其他人任何这种期望的暗示(而不是明示)。

简而言之,只要简单地将我们的处理器和中间件划分成两个函数就可以将我们模糊的需求转变为清晰且具体,帮助新人更快熟悉代码,也使代码更易于维护。

结论...

本文没有讨论到一个最终方案,那就是在你的应用和中间件中创建一个属于自己的自定义 Context。这最终看起来像某些类似于 “闭包和自定义函数说明” 的部分,但是我们有一个定义好的中等大小的上下文,将其传递给每个处理器。

这个巨型上下文(我喜欢这样叫它)有自己的优缺点,可能经常有所帮助,但是我并没有在这儿讨论它因为我想在梳理它之前试验更多的可能性。我怀疑最终会在接下来几周再写一篇文章讨论其细节。

同时,请牢记上面的任何方案都有缺陷。一些可能会导致代码复制,另一些会将类型检查延迟到运行时处理,一些限制了你在不同的多处理器中简单插入中间件的能力。最终,你需要自己决定最适合于自己的方案。

无关于你选用的路由组件,请记住在代码审查中保持警惕,确保其他人也要关注上下文变量值。

参考资料

  1. Pitfalls of context values and how to avoid or mitigate them in Go

我的博客即将同步至腾讯云开发者社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=ix804iofhkd6

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容