上下文

上下文

你可以在这里找到本章的所有代码

软件经常启动长时间运行的、资源密集型的过程(通常在 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
}

返回的函数调用 storeFetch 方法来获取数据并将其写入响应。

我们在测试中使用对应的 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 的要点之一是,它是提供取消的一致方式.

来自于 Go 文档

对服务器的传入请求应该创建上下文,而对服务器的传出调用应该接受上下文。它们之间的函数调用链必须传播上下文,可以选择将其替换为使用 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 的内容是给维护人员看的,而不是给用户看的。对于文档化的或预期的结果,它不应该是必需的输入。

补充资料

原文地址 Learn Go with Tests

本文章首发在 LearnKu.com 网站上。
上一篇 下一篇
讨论数量: 0
发起讨论 只看当前版本


暂无话题~