上下文
上下文
软件经常启动长时间运行的、资源密集型的过程(通常在 goroutines 中)。如果导致此操作被取消或由于某种原因失败,则需要在应用程序中以一致的方式停止这些进程。
如果你不管理它,你引以为傲的 snappy Go 应用程序可能会出现难以调试的性能问题。
在本章中,我们将使用包 context
来帮助我们管理长时间运行的流程。
我们将从一个典型的 web 服务器示例开始,启动一个潜在的长时间运行的进程来获取一些数据,以便它在响应中返回。
我们将演练一个场景,其中用户在可以检索数据之前取消请求,我们将确保流程被告知放弃。
我已经设置了一些代码来让我们开始。这是我们的服务器代码。
func Server(store Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, store.Fetch())
}
}
函数 Server
接受一个Store
并返回一个 http.HandlerFunc
。Store 被定义为:
type Store interface {
Fetch() string
}
返回的函数调用 store
的 Fetch
方法来获取数据并将其写入响应。
我们在测试中使用对应的 store
存根
type StubStore struct {
response string
}
func (s *StubStore) Fetch() string {
return s.response
}
func TestHandler(t *testing.T) {
data := "hello, world"
svr := Server(&StubStore{data})
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
svr.ServeHTTP(response, request)
if response.Body.String() != data {
t.Errorf(`got "%s", want "%s"`, response.Body.String(), data)
}
}
现在我们有了一个满意的路径,我们希望创建一个更实际的场景,在用户取消请求之前,Store
无法完成 Fetch
。
首先写测试
我们的处理程序将需要一种方式告诉 Store
取消工作,所以更新接口。
type Store interface {
Fetch() string
Cancel()
}
我们将需要调整我们的 spy,所以它需要一些时间来返回 data
和一种方式知道它已被告知取消。我们还将把它重命名为SpyStore
,因为我们现在正在观察它的命名方式。 它将不得不添加 Cancel
作为实现 Store
接口的方法。
type SpyStore struct {
response string
cancelled bool
}
func (s *SpyStore) Fetch() string {
time.Sleep(100 * time.Millisecond)
return s.response
}
func (s *SpyStore) Cancel() {
s.cancelled = true
}
让我们添加一个新的测试,在这个测试中,我们在 100 毫秒之前取消请求,并检查存储是否被取消。
t.Run("tells store to cancel work if request is cancelled", func(t *testing.T) {
store := &SpyStore{response: data}
svr := Server(store)
request := httptest.NewRequest(http.MethodGet, "/", nil)
cancellingCtx, cancel := context.WithCancel(request.Context())
time.AfterFunc(5 * time.Millisecond, cancel)
request = request.WithContext(cancellingCtx)
response := httptest.NewRecorder()
svr.ServeHTTP(response, request)
if !store.cancelled {
t.Errorf("store was not told to cancel")
}
})
来自 Go 博客: 上下文
上下文包提供了从现有上下文值派生新上下文值的函数。这些值形成一个树:当一个上下文被取消时,从它派生的所有上下文也被取消。
重要的是派生上下文,以便在给定请求的整个调用堆栈中传播取消。
我们所做的是从我们的 request
中派生出一个新的 cancellingCtx
,它将返回给我们一个 cancel
函数。然后我们使用 time.AfterFunc
来调度 5 毫秒内调用的函数。 最后,我们通过调用 request.WithContext
在我们的请求中使用这个新的上下文。
尝试运行测试
测试不出所料地失败了。
--- FAIL: TestServer (0.00s)
--- FAIL: TestServer/tells_store_to_cancel_work_if_request_is_cancelled (0.00s)
context_test.go:62: store was not told to cancel
编写足够的代码让测试通过
记住要遵守 TDD 的规则。编写 最少 数量的代码使我们的测试通过。
func Server(store Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
store.Cancel()
fmt.Fprint(w, store.Fetch())
}
}
它让这个测试通过了,但是感觉不是很好。我们当然不应该在获取 每个请求 之前取消 Store
。
在我们的测试中,它突出了一个缺陷,这是一件好事!
我们需要更新我们的happy path测试,以确保它不会被取消。
t.Run("returns data from store", func(t *testing.T) {
store := &SpyStore{response: data}
svr := Server(store)
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
svr.ServeHTTP(response, request)
if response.Body.String() != data {
t.Errorf(`got "%s", want "%s"`, response.Body.String(), data)
}
if store.cancelled {
t.Error("it should not have cancelled the store")
}
})
运行测试和 happy path 测试现在应该失败了,现在我们被迫做一个更明智的实现。
func Server(store Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
data := make(chan string, 1)
go func() {
data <- store.Fetch()
}()
select {
case d := <-data:
fmt.Fprint(w, d)
case <-ctx.Done():
store.Cancel()
}
}
}
我们在这里做了什么?
context
有一个 Done()
方法,它返回一个通道,当 context 「确认」或「取消 」时,这个通道会发送一个信号。我们想要听到这个信号并调用 store.Cancel
,我们得到了它,但如果我们的 Store
成功地在它之前 Fetch
了它,我们就想忽略它。
为了进行管理,我们在 goroutine 中运行 Fetch
,它会将结果写入一个新的通道 data
。然后我们使用 select
来有效地竞争两个异步进程,然后我们要么写一个响应,要么写一个 Cancel
。
重构
我们可以通过在 spy 上使用断言方法来重构我们的测试代码
func (s *SpyStore) assertWasCancelled() {
s.t.Helper()
if !s.cancelled {
s.t.Errorf("store was not told to cancel")
}
}
func (s *SpyStore) assertWasNotCancelled() {
s.t.Helper()
if s.cancelled {
s.t.Errorf("store was told to cancel")
}
}
在创建 spy 时,记住传递 *testing.T
。
func TestServer(t *testing.T) {
data := "hello, world"
t.Run("returns data from store", func(t *testing.T) {
store := &SpyStore{response: data, t: t}
svr := Server(store)
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
svr.ServeHTTP(response, request)
if response.Body.String() != data {
t.Errorf(`got "%s", want "%s"`, response.Body.String(), data)
}
store.assertWasNotCancelled()
})
t.Run("tells store to cancel work if request is cancelled", func(t *testing.T) {
store := &SpyStore{response: data, t: t}
svr := Server(store)
request := httptest.NewRequest(http.MethodGet, "/", nil)
cancellingCtx, cancel := context.WithCancel(request.Context())
time.AfterFunc(5*time.Millisecond, cancel)
request = request.WithContext(cancellingCtx)
response := httptest.NewRecorder()
svr.ServeHTTP(response, request)
store.assertWasCancelled()
})
}
这个方法不错,但它是习惯用法吗?
我们的 web 服务器手动取消 Store
是否有意义?如果 存储
也依赖于其他运行缓慢的进程,那该怎么办呢? 我们必须确保 Store.Cancel
正确地将取消传播到它的所有依赖项。
context
的要点之一是,它是提供取消的一致方式.
对服务器的传入请求应该创建上下文,而对服务器的传出调用应该接受上下文。它们之间的函数调用链必须传播上下文,可以选择将其替换为使用 WithCancel、WithDeadline、WithTimeout 或 WithValue 创建的派生上下文。当一个上下文被取消时,它派生的所有上下文也被取消。
再一次来自于 Go 博客: 上下文:
在谷歌中,我们要求 Go 程序员将一个上下文参数作为传入和传出请求之间调用路径上的每个函数的第一个参数。这使得许多不同团队开发的 Go 代码可以很好地互操作。它提供了对超时和取消的简单控制,并确保关键值(如安全凭据)正确地传输 Go 程序。
(暂停一下,想想每一个功能在上下文中所产生的后果)
感觉有点不舒服?好。让我们尝试并遵循这种方法,而不是通过 context
传递到我们的 Store
,让它负责任。通过这种方式,它也可以将 context
传递给依赖它的人,他们也可以负责阻止自己。
首先写测试
随着他们的职责的变化,我们将不得不改变我们现有的测试。我们的处理程序现在唯一要负责的事情是确保它将一个上下文发送到下游的 Store
,并确保它处理取消时来自 Store
的错误。
让我们更新 Store
接口来展示新的职责。
type Store interface {
Fetch(ctx context.Context) (string, error)
}
现在删除处理程序中的代码
func Server(store Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
}
}
更新我们的SpyStore
type SpyStore struct {
response string
t *testing.T
}
func (s *SpyStore) Fetch(ctx context.Context) (string, error) {
data := make(chan string, 1)
go func() {
var result string
for _, c := range s.response {
select {
case <-ctx.Done():
s.t.Log("spy store got cancelled")
return
default:
time.Sleep(10 * time.Millisecond)
result += string(c)
}
}
data <- result
}()
select {
case <-ctx.Done():
return "", ctx.Err()
case res := <-data:
return res, nil
}
}
我们必须使我们的 spy 像一个真正的方法来与 context
工作。
我们正在模拟一个缓慢的过程,在这个过程中,我们通过在 goroutine 中一个字符一个字符地添加字符串来缓慢地构建结果。当 goroutine 完成它的工作时,它将字符串写入 data
通道。goroutine 侦听 ctx.Done
,如果在该通道中发送了信号,它将停止工作。
最后,代码使用另一个 select
来等待 goroutine 完成它的工作或发生取消。
这与我们之前的方法类似,我们使用 Go 的并发原语使两个异步进程相互竞争以确定返回什么。
在编写自己的接受 context
的函数和方法时,您将采用类似的方法,因此请确保您理解了所发生的事情。
最后,我们可以更新我们的测试。注释掉我们的取消测试,这样我们就可以先修复 happy path 测试。
t.Run("returns data from store", func(t *testing.T) {
store := &SpyStore{response: data, t: t}
svr := Server(store)
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
svr.ServeHTTP(response, request)
if response.Body.String() != data {
t.Errorf(`got "%s", want "%s"`, response.Body.String(), data)
}
})
尝试运行测试
=== RUN TestServer/returns_data_from_store
--- FAIL: TestServer (0.00s)
--- FAIL: TestServer/returns_data_from_store (0.00s)
context_test.go:22: got "", want "hello, world"
编写足够的代码使测试通过
func Server(store Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, _ := store.Fetch(r.Context())
fmt.Fprint(w, data)
}
}
现在我们可以修复另一个测试。
首先写测试
我们需要测试我们没有在错误情况下编写任何类型的响应。可悲的是,httptest.ResponseRecorder
没有办法弄清楚这一点,所以我们得使用 spy 来测试这个。
type SpyResponseWriter struct {
written bool
}
func (s *SpyResponseWriter) Header() http.Header {
s.written = true
return nil
}
func (s *SpyResponseWriter) Write([]byte) (int, error) {
s.written = true
return 0, errors.New("not implemented")
}
func (s *SpyResponseWriter) WriteHeader(statusCode int) {
s.written = true
}
我们的 SpyResponseWriter
实现了 http.ResponseWriter
。因此我们可以在测试中使用它。
t.Run("tells store to cancel work if request is cancelled", func(t *testing.T) {
store := &SpyStore{response: data, t: t}
svr := Server(store)
request := httptest.NewRequest(http.MethodGet, "/", nil)
cancellingCtx, cancel := context.WithCancel(request.Context())
time.AfterFunc(5*time.Millisecond, cancel)
request = request.WithContext(cancellingCtx)
response := &SpyResponseWriter{}
svr.ServeHTTP(response, request)
if response.written {
t.Error("a response should not have been written")
}
})
尝试运行测试
=== RUN TestServer
=== RUN TestServer/tells_store_to_cancel_work_if_request_is_cancelled
--- FAIL: TestServer (0.01s)
--- FAIL: TestServer/tells_store_to_cancel_work_if_request_is_cancelled (0.01s)
context_test.go:47: a response should not have been written
编写足够的代码让测试通过
func Server(store Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, err := store.Fetch(r.Context())
if err != nil {
return // todo: log error however you like
}
fmt.Fprint(w, data)
}
}
我们可以看到,在这之后,服务器代码变得简化了,因为它不再明确地负责取消,它只是通过 context
,并依赖于下游函数来考虑可能发生的取消。
总结
我们已经讲过的
-
如何测试客户端取消请求的 HTTP 处理程序。
-
如何使用上下文来管理取消。
-
如何编写一个函数,接受
context
,并使用它来取消自己使用的 goroutines,select
和 通道。 -
遵循谷歌的指导方针,通过调用堆栈传播请求作用域上下文来管理取消。
-
如果你需要的话,如何为
http.ResponseWriter
找到你自己的 spy。
那 context.Value 呢?
Michal Štrba 和我有相同的观点。
如果你使用 ctx.Value 在我的(不存在)公司,你会被解雇
一些工程师提倡通过 context
传递值,因为使用它 感觉方便。
便捷性通常是坏代码的原因之一。
context.Values
的问题是它只是一个未类型化的映射,因此没有类型安全,您必须处理它,它实际上不包含您的值。你必须创建映射键从一个模块到另一个模块的耦合,如果有人改变了什么,就会发生故障。
简而言之,如果一个函数需要一些值,将它们作为类型化参数,而不是试图从 context.Value
获取它们。
但是
另一方面,在上下文中包含与请求正交的信息(例如跟踪 id )可能会有所帮助.可能调用堆栈中的每个函数都不需要这些信息,这会使函数签名非常混乱。
Jack Lindamood 说 Context.Value 应...
context.Value 的内容是给维护人员看的,而不是给用户看的。对于文档化的或预期的结果,它不应该是必需的输入。
补充资料
-
我非常喜欢阅读 Michal Štrba 写的 Go 2 应该没有上下文. 他的论点是,通过
context
让到处都是一个味道,它指出了语言在取消方面的缺陷。他说,如果能在语言层面解决这个问题,而不是在库层面,效果会更好。在此之前,如果想要管理长时间运行的流程,就需要context
。
原文地址 Learn Go with Tests