Contexto

Você pode encontrar todo o código para esse capítulo aqui

Softwares geralmente iniciam processos de longa duração e de uso intensivo de recursos (muitas vezes em goroutines). Se a ação que causou isso é cancelada ou falha por algum motivo, você precisa parar esses processos de forma consistente dentro da sua aplicação.

Se você não gerenciar isso, sua aplicação Go tão ágil da qual você tem tanto orgulho pode começar a ter problemas de desempenho difíceis de depurar.

Neste capítulo vamos usar o pacote context para nos ajudar a gerenciar processos de longa duração.

Vamos começar com um exemplo clássico de um servidor web que, quando iniciado, abre um processo de longa execução que vai buscar alguns dados para devolver em uma resposta.

Colocaremos em prática um cenário em que um usuário cancela a requisição antes que os dados possam ser recuperados e faremos com que o processo seja instruído a desistir.

Criei um código no caminho feliz para começarmos. Aqui está o código do nosso servidor.

func Server(store Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, store.Fetch())
}
}

A função Server (servidor) recebe uma Store (armazenamento) e nos retorna um http.HandlerFunc. Store está definida como:

type Store interface {
Fetch() string
}

A função retornada chama o método Fetch (busca) da Store para obter os dados e escrevê-los na resposta.

Nós temos um stub correspondente para Store que usamos em um teste.

type StubStore struct {
response string
}
func (s *StubStore) Fetch() string {
return s.response
}
func TestHandler(t *testing.T) {
data := "olá, mundo"
svr := Server(&StubStore{data})
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
svr.ServeHTTP(response, request)
if response.Body.String() != data {
t.Errorf(`resultado "%s", esperado "%s"`, response.Body.String(), data)
}
}

Agora que temos um caminho feliz, queremos fazer um cenário mais realista onde a Store não consiga finalizar o Fetch antes que o usuário cancele a requisição.

Escreva o teste primeiro

Nosso handler precisará de uma maneira de dizer à Store para cancelar o trabalho, então atualize a interface.

type Store interface {
Fetch() string
Cancel()
}

Precisaremos ajustar nosso spy para que leve algum tempo para retornar data e uma maneira de saber que foi dito para cancelar. Nós também o renomearemos para SpyStore, pois agora vamos observar a forma como ele é chamado. Ele terá que adicionar Cancel como um método para implementar a interface 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
}

Vamos adicionar um novo teste onde cancelamos a requisição antes de 100 milissegundos e verificamos a store para ver se ela é cancelada.

t.Run("avisa a store para cancelar o trabalho se a requisição for cancelada", 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 não foi avisada para cancelar")
}
})

Do blog da Google novamente:

O pacote context fornece funções para derivar novos valores de contexto dos já existentes. Estes valores formam uma árvore: quando um contexto é cancelado, todos os contextos derivados dele também são cancelados.

É importante que você derive seus contextos para que os cancelamentos sejam propagados através da pilha de chamadas (call stack) para uma determinada requisição.

O que fazemos é derivar um novo cancellingCtx da nossa requisição que nos retorna uma função cancel. Nós então programamos que a função seja chamada em 5 milissegundos usando time.AfterFunc. Por fim, usamos este novo contexto em nossa requisição chamando request.WithContext.

Execute o teste

O teste falha como seria de esperar.

--- FAIL: TestServer (0.00s)
--- FAIL: TestServer/avisa_a_store_para_cancelar_o_trabalho_se_a_requisicao_for_cancelada (0.00s)
context_test.go:62: store no foi avisada para cancelar

Escreva código o suficiente para fazer o teste passar

Lembre-se de ser disciplinado com o TDD. Escreva a quantidade mínima de código para fazer nosso teste passar.

func Server(store Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
store.Cancel()
fmt.Fprint(w, store.Fetch())
}
}

Isto faz com que este teste passe, mas não parece tão bom! Certamente não deveríamos estar cancelando a Store antes do fetch em cada requisição.

Ao ser disciplinado ele destacou uma falha em nossos testes, isso é uma coisa boa!

Vamos precisar atualizar nosso teste de caminho feliz para verificar que ele não será cancelado.

t.Run("retorna dados da 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(`resultado "%s", esperado "%s"`, response.Body.String(), data)
}
if store.cancelled {
t.Error("não deveria ter cancelado a store")
}
})

Execute ambos os testes. O teste do caminho feliz deve agora estar falhando e somos forçados a fazer uma implementação mais sensata.

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()
}
}
}

O que fizemos aqui?

context tem um método Done() que retorna um canal que recebe um sinal quando o context estiver "done" (finalizado) ou "cancelled" (cancelado). Queremos ouvir esse sinal e chamar store.Cancel se o obtivermos, mas queremos ignorá-lo se a nossa Store conseguir finalizar o Fetch antes dele.

Para gerenciar isto, executamos o Fetch em uma goroutine e ele irá escrever o resultado em um novo channel data. Nós então usamos select para efetivamente correr para os dois processos assíncronos e então escrevemos uma resposta ou cancelamos com Cancel.

Refatoração

Podemos refatorar um pouco o nosso código de teste fazendo métodos de verificação no nosso spy.

func (s *SpyStore) assertWasCancelled() {
s.t.Helper()
if !s.cancelled {
s.t.Errorf("store não foi avisada para cancelar")
}
}
func (s *SpyStore) assertWasNotCancelled() {
s.t.Helper()
if s.cancelled {
s.t.Errorf("store foi avisada para cancelar")
}
}

Lembre-se de passar o *testing.T ao criar o spy.

func TestServer(t *testing.T) {
data := "olá, mundo"
t.Run("retorna dados da 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(`recebi "%s", quero "%s"`, response.Body.String(), data)
}
store.assertWasNotCancelled()
})
t.Run("avisa a store para cancelar o trabalho se a requisição for cancelada", 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()
})
}

Esta abordagem é boa, mas é idiomática?

Faz sentido para o nosso servidor web estar preocupado com o cancelamento manual da Store? E se a Store também depender de outros processos de execução lenta? Nós teremos que ter certeza que a Store.Cancel propagará corretamente o cancelamento para todos os seus dependentes.

Um dos pontos principais do context é que é uma maneira consistente de oferecer cancelamento.

Da documentação do go

As requisições de entrada para um servidor devem criar um Context e as chamadas de saída para servidores devem aceitar um Context. A cadeia de chamadas de função entre eles deve propagar o Context, substituindo-o opcionalmente por um Context derivado criado usando WithCancel, WithDeadline, WithTimeout ou WithValue. Quando um Context é cancelado, todos os Contexts derivados dele também são cancelados.

Do blog da Google novamente:

Na Google, exigimos que os programadores Go passem um parâmetro Context como o primeiro argumento para cada função no caminho de chamada entre requisições de entrada e saída. Isto permite que o código Go desenvolvido por muitas equipes diferentes interopere bem. Ele fornece um controle simples sobre timeouts e cancelamentos e garante que valores críticos, como credenciais de segurança, transitem corretamente pelos programas Go.

(Pare por um momento e pense nas ramificações de cada função tendo que enviar um context e a ergonomia disso.)

Se sentindo um pouco desconfortável? Bom. Vamos tentar seguir essa abordagem e, em vez disso, passar o context para nossa Store e deixá-la ser responsável. Dessa maneira, ela também pode passar o context para os seus dependentes e eles também podem ser responsáveis por se pararem.

Escreva o teste primeiro

Teremos de alterar os nossos testes existentes, uma vez que as suas responsabilidades estão mudando. As únicas coisas que nosso handler é responsável agora é certificar-se que emite um contexto à Store em cascata (downstream) e que trata o erro que virá da Store quando é cancelada.

Vamos atualizar nossa interface Store para mostrar as novas responsabilidades.

type Store interface {
Fetch(ctx context.Context) (string, error)
}

Apague o código dentro do nosso handler por enquanto:

func Server(store Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
}
}

Atualize nosso 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 foi cancelado")
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
}
}

Temos que fazer nosso spy agir como um método real que funciona com o context.

Estamos simulando um processo lento onde construímos o resultado lentamente adicionando a string, caractere por caractere em uma goroutine. Quando a goroutine termina seu trabalho, ela escreve a string no channel data. A goroutine escuta o ctx.Done e irá parar o trabalho se um sinal for enviado nesse channel.

Finalmente o código usa outro select para esperar que a goroutine termine seu trabalho ou que o cancelamento ocorra.

É semelhante à nossa abordagem de antes onde usamos as primitivas de concorrência do Go para fazerem dois processos assíncronos disputarem um contra o outro para determinar o que retornamos.

Você usará uma abordagem similar ao escrever suas próprias funções e métodos que aceitam um context, por isso certifique-se de que está entendendo o que está acontecendo.

Nós removemos a referência ao ctx dos campos do SpyStore porque não é mais interessante para nós. Estamos estritamente testando o comportamento agora, que preferimos em comparação aos detalhes da implementação dos testes, como "você passou um determinado valor para a função foo".

Finalmente podemos atualizar nossos testes. Comente nosso teste de cancelamento para que possamos corrigir o teste do caminho feliz primeiro.

t.Run("retorna dados da 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(`resultado "%s", esperado "%s"`, response.Body.String(), data)
}
})

Execute o teste

=== RUN TestServer/retorna_dados_da_store
--- FAIL: TestServer (0.00s)
--- FAIL: TestServer/retorna_dados_da_store (0.00s)
context_test.go:22: resultado "", esperado "olá, mundo"

Escreva código o suficiente para fazer o teste passar

func Server(store Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, _ := store.Fetch(r.Context())
fmt.Fprint(w, data)
}
}

O nosso caminho feliz deve estar... feliz. Agora podemos corrigir o outro teste.

Escreva o teste primeiro

Precisamos testar que não escrevemos qualquer tipo de resposta no caso de erro. Infelizmente o httptest.ResponseRecorder não tem uma maneira de descobrir isso, então teremos que usar nosso próprio spy para testar.

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("não implementado")
}
func (s *SpyResponseWriter) WriteHeader(statusCode int) {
s.written = true
}

Nosso SpyResponseWriter implementa http.ResponseWriter para que possamos usá-lo no teste.

t.Run("avisa a store para cancelar o trabalho se a requisição for cancelada", 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("uma resposta não deveria ter sido escrita")
}
})

Execute o teste

=== RUN TestServer
=== RUN TestServer/avisa_a_store_para_cancelar_o_trabalho_se_a_requisicao_for_cancelada
--- FAIL: TestServer (0.01s)
--- FAIL: TestServer/avisa_a_store_para_cancelar_o_trabalho_se_a_requisicao_for_cancelada (0.01s)
context_test.go:47: uma resposta não deveria ter sido escrita

Escreva código o suficiente para fazer o teste passar

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: registre o erro como você quiser
}
fmt.Fprint(w, data)
}
}

Podemos ver depois disso que o código do servidor se tornou simplificado, pois não é mais explicitamente responsável pelo cancelamento. Ele simplesmente passa o context e confia nas funções em cascata (downstream) para respeitar qualquer cancelamento que possa ocorrer.

Resumo

Sobre o que falamos

  • Como testar um handler HTTP que teve a requisição cancelada pelo cliente.

  • Como usar o contexto para gerenciar o cancelamento.

  • Como escrever uma função que aceita context e o usa para se cancelar usando goroutines, select e canais.

  • Seguir as diretrizes da Google a respeito de como controlar o cancelamento propagando o contexto escopado da requisição através da sua pilha de chamadas (call stack).

  • Como levar seu próprio spy para http.ResponseWriter se você precisar dele.

E quanto ao context.Value?

Michal Štrba e eu temos uma opinião semelhante.

Se você usar o ctx.Value na minha empresa (inexistente), você está demitido

Alguns engenheiros têm defendido a passagem de valores através do context porque parece conveniente.

A conveniência é muitas vezes a causa do código ruim.

O problema com context.Values é que ele é apenas um mapa não tipado para que você não tenha nenhum tipo de segurança e você tem que lidar com ele não realmente contendo seu valor. Você tem que criar um acoplamento de chaves de mapa de um módulo para outro e se alguém muda alguma coisa começar a quebrar.

Resumindo, se uma função necessita de alguns valores, coloque-os como parâmetros tipados em vez de tentar obtê-los a partir de context.Value. Isto torna-o estaticamente verificado e documentado para que todos o vejam.

Mas...

Por outro lado, pode ser útil incluir informações que sejam ortogonais a uma requisição em um contexto, como um identificador único. Potencialmente esta informação não seria necessária para todas as funções da sua pilha de chamadas (call stack) e tornaria as suas assinaturas funcionais muito confusas.

Jack Lindamood diz que Context.Value deve informar, não controlar

O conteúdo do context.Value é para os mantenedores e não para os usuários. Ele nunca deve ser uma entrada necessária para resultados documentados ou esperados.

Material adicional