Websockets
Nós temos duas aplicações no nosso código-base de poquer.
- Aplicação de linha de comando. Pede ao usuário para que insira o número de jogadores. A partir daí informa os jogadores o valor da "aposta cega", que aumenta em função do tempo. A qualquer momento, um usuário pode entrar com
"{Jogador} ganhou"
para encerrar o jogo e salvar a vitória em um armazenamento. - Aplicação Web. Permite que os usuários salvem os ganhadores e mostrem uma tabela da liga. Divide o armazenamento com a aplicação de linha de comando.
A dona do produto está muito contente com a aplicação por linha de comando, mas acharia melhor se conseguíssimos levar todas essas funcionalidades para o navegador. Ela imagina uma página web com uma caixa de texto que permite que o usuário coloque o número de jogadores e, após submeter esse dado, informe o valor da "aposta cega", atualizando automaticamente quando for apropriado. Assim como a aplicação por linha de comando, ela espera que o usuário possa declarar o vencedor e que isso faça com que as devidas informações sejam salvas no banco de dados.
Descrevendo o projeto dessa forma parece bastante simples, mas sempre precisamos enfatizar que devemos ter uma abordagem iterativa pra desenvolver os nossos programas.
Em primeiro lugar, vamos precisar apresentar um HTML. Até agora, todos os nossos endpoints HTTP retornaram texto puro ou JSON. Nós poderíamos usar as mesmas técnicas que conhecemos (porque, no fim, tanto o texto puro quanto o JSON são strings), mas nós também podemos usar o pacote html/template para uma solução mais limpa.
Nós também temos que ser capazes de enviar mensagens assíncronas para o usuário dizendo
A aposta blind é *y*
sem ter que recarregar o navegador. Para facilitar isso, podemos usar WebSockets.WebSocket é uma tecnologia que permite a comunicação bidirecional por canais full-duplex sobre um único socket TCP (Transmission Control Protocol)
Como estamos adotando várias técnicas, é ainda mais importante que façamos o menor trabalho possível primeiro e só então iteramos.
Por causa disso, a primeira coisa que faremos é criar uma página web com um formulário para o usuário salvar um vencedor. Em vez de usar um formulário simples, vamos usar os WebSockets para enviar os dados para o nosso servidor o salvar.
Depois disso, iremos trabalhaor nos alertas cegos, uma vez que já teremos algum código de infraestrutura pronto.
Haverá algum JavaScript escrito para cumprir nossa tarefa, mas não vamos escrever testes para ele.
É claro que é possível, mas, em nome da breviedade, não incluíremos quaisquer explicações para isso.
Desculpem, amigos. Peçam para a O'Reilly me pagar para fazer um "Aprenda JavaScript com testes".
A primeira coisa que precisamos fazer é montar algum HTML para os usuários quando eles acessarem
/jogo
.Aqui está um lembrete do código no nosso servidor web:
type ServidorJogador struct {
armazenamento ArmazenamentoJogador
http.Handler
}
const tipoConteudoJSON = "application/json"
func NovoServidorJogador(armazenamento ArmazenamentoJogador) *ServidorJogador {
p := new(ServidorJogador)
p.armazenamento = armazenamento
roteador := http.NewServeMux()
roteador.Handle("/liga", http.HandlerFunc(p.manipulaLiga))
roteador.Handle("/jogadores/", http.HandlerFunc(p.manipulaJogadores))
p.Handler = roteador
return p
}
A maneira mais fácil que podemos fazer por agora é checar que recebemos um código
200
quando acessamos o GET /jogo
.func TestJogo(t *testing.T) {
t.Run("GET /jogo retorna 200", func(t *testing.T) {
servidor := NovoServidorJogador(&EsbocoDeArmazenamentoJogador{})
requisicao, _ := http.NewRequest(http.MethodGet, "/jogo", nil)
resposta := httptest.NewRecorder()
servidor.ServeHTTP(resposta, requisicao)
verificaStatus(t, resposta.Code, http.StatusOK)
})
}
--- FAIL: TestJogo (0.00s)
=== RUN TestJogo/GET_/game_returns_200
--- FAIL: TestJogo/GET_/game_returns_200 (0.00s)
server_test.go:109: não obteve o status correto, obtido 404, esperado 200
Nosso servidor tem um roteador definido, então deve ser relativamente fácil corrigir isso.
Adicione o seguinte no nosso roteador:
roteador.Handle("/jogo", http.HandlerFunc(p.jogo))
E então escreva o método
jogo
:func (p *ServidorJogador) jogo(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
O servidor já está bem graças às inserções que fizemos no código já bem refatorado.
Podemos ajeitar ainda mais o teste um pouco ao adicionarmos uma função auxiliar
novaRequisicaoDeJogo
para fazer a requisição para /jogo
. Tente escrever essa função você mesmo.func TestJogo(t *testing.T) {
t.Run("GET /jogo retorna 200", func(t *testing.T) {
servidor := NovoServidorJogador(&EsbocoDeArmazenamentoJogador{})
requisicao := novaRequisicaoJogo()
resposta := httptest.NewRecorder()
servidor.ServeHTTP(resposta, requisicao)
verificaStatus(t, resposta, http.StatusOK)
})
}
Você também vai notar que mudei o
verificaStatus
para aceitar resposta
ao invés de resposta.Code
já que parece combinar melhor.Agora precisamos que o endpoint retorne um pouco de HTML, e aqui está ele:
<!DOCTYPE html>
<html lang="pt-br">
<head>
<meta charset="UTF-8">
<title>Vamos jogar pôquer</title>
</head>
<corpo>
<section id="jogo">
<div id="declare-vencedor">
<label for="vencedor">Vencedor</label>
<input type="text" id="vencedor"/>
<button id="vencedor-button">Declare vencedor</button>
</div>
</section>
</corpo>
<script type="application/javascript">
const submitWinnerButton = document.getElementById('vencedor-button')
const entradaVencedor = document.getElementById('vencedor')
if (window['WebSocket']) {
const conexão = new WebSocket('ws://' + document.location.host + '/ws')
submitWinnerButton.onclick = event => {
conexão.send(entradaVencedor.value)
}
}
</script>
</html>
Temos uma página web bem simples:
- Uma entrada de texto para a pessoa inserir a vitória
- Um botão onde pode-se clicar para declarar quem venceu
- Um pouco de JavaScript para abrir uma conexão WebSocket para nosso servidor eassim gerenciar o envio dos dados ao pressionar o botão
WebSocket
é integrado na maioria dos navegadores modernos, logo não precisamos nos preocupar em instalar bibliotecas. A página web não vai funcionar em navegadores antigos, mas para nosso caso tá tudo bem.Existem algumas formas. Como foi enfatizado no decorrer do livro, é importante que os testes que você escreve têm valor o suficiente para justificar o custo.
- 1.Escreva um teste baseado no navegador, usando algo como Selenium. Esses testes são os mais "realistas" de todas as abordagens porque começam um navegador web de verdade e simula um usuário interagindo com ele. Esses testes podem te dar muita confiança de que seu sistma funciona, mas são mais difíceis e escrever que os testes unitários e muito mais lentos de serem executados. Para os propósitos do nosso produto, isso é exagero.
- 2.Fazer uma comparação exata de textos. Isso pode funcionar, mas esses tipos de testes acabam sendo muito frágeis. No momento que alguém muda a marcação, você vai ter um teste falhando quando na prática nada está de fato falhando.
- 3.Verificar que chamamos o template correto. Vamos usar uma biblioteca de template da biblioteca padrão para servir o HTML (que falamos brevemente) e podemos injetar na coisa que gera o HTML e espionar suas chamadas para verificar que estamos fazendo tudo corretamente. Isso teria um impacto no design do nosso código, mas na realidade isso não estaríamos testando algo tão crítico além de verificar se estamos chamando o arquivo de template correto. Dito isso, só vamos ter um template no nosso projeto e a chance de falha aqui parece pequena.
Então, pela primeira vez no livro "Aprenda Go com Testes", não vamos escrever nenhum teste.
Coloque a marcação em um arquivo chamado
jogo.html
.Na próxima mudança do endoint, vamos apenas escrever o seguinte:
func (p *ServidorJogador) jogo(w http.ResponseWriter, r *http.Request) {
tmpl, err := template.ParseFiles("jogo.html")
if err != nil {
http.Error(w, fmt.Sprintf("problem loading template %s", err.Error()), http.StatusInternalServerError)
return
}
tmpl.Execute(w, nil)
}
O
html/template
é um pacote do Go para criar HTML. No nosso caso, chamamos template.ParseFiles
enviando o caminho do nosso arquivo HTML. Presumindo que não há nenhum erro, chamamos a função Execute
para "executar" o template, que o escreve para um ìo.Writer
. No nosso caso, esperamos que o template seja escrito na internet, então enviamos o nosso http.ResponseWriter
.Já que não escrevemos um teste, seria prudente testar nosso servidor web manualmente só para ter certeza de que as coisas estão funcionamos como esperamos. Vá para
cmd/webserver
e execute o arquivo main.go
. Visite http://localhost:5000/jogo
.Você deve ter obtido um erro sobre não ser capaz de encontrar o template. Você pode ou mudar o caminho para ser relativo à sua pasta, ou pode ter uma cópia de
`jogo.html
no diretório cmd/webserver
. Eu escolho criar um symlink (ln -s ../../jogo.html jogo.html
) para o arquivo dentro da raiz do projeto para caso eu faça alterações, elas reflitam quando o servidor estiver sendo executado.Se fizer essa alteração e rodar novamente, deve conseguir ver a interface.
Agora precisamos testar que, quando obtemos uma string sob uma conexão WebSocket para o nosso servidor, declaramos a pessoa como vencedora de um jogo.
Pela primeia vez, vamos usar uma biblioteca externa para trabalhar com WebSockets.
Rode
go get github.com/gorilla/websocket
.Isso vai obter o código para a excelente biblioteca Gorilla WebSocket. Agora podemos atualizar nossos testes para nosso novo requerimento.
t.Run("quando recebemos uma mensagem de um websocket que é vencedor da jogo", func(t *testing.T) {
armazenamento := &EsbocoDeArmazenamentoJogador{}
vencedor := "Ruth"
servidor := httptest.NewServer(NovoServidorJogador(armazenamento))
defer servidor.Close()
wsURL := "ws" + strings.TrimPrefix(servidor.URL, "http") + "/ws"
ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("não foi possível abrir uma conexão de websocket em %s %v", wsURL, err)
}
defer ws.Close()
if err := ws.WriteMessage(websocket.TextMessage, []byte(vencedor)); err != nil {
t.Fatalf("não foi possível enviar mensagem na conexão websocket %v", err)
}
VerificaVitoriaDoVencedor(t, armazenamento, vencedor)
})
Certifique-se que tenha importado o pacote
websocket
. Minha IDE fez isso automaticamente para mim e a sua deve fazer o mesmo.Para testar o que acontece do navegador, temos que abrir nossa própria conexão WebSocket e escrever nela.
Nossos testes anteriores do servidor apenas chamavam métodos no nosso servidor, mas agora precisamos ter uma conexão persistente nele. Para fazer isso, usamos o
httptest.NewServer
, que recebe um http.Handler
que vai esperar conexões.Ao usar
websocket.DefaultDialer.Dial
, tentamos conectar no nosso servidor para então enviar uma mensagem com nosso vencedor
.Por fim, verificamos o armazenamento do jogador para certificar que o vencedor foi gravado.
=== RUN TestJogo/quando_recebemos_uma_mensagem_via_websocket_que_ha_um_vencedor_de_uma_jogo
--- FAIL: TestJogo/quando_recebemos_uma_mensagem_via_websocket_que_ha_um_vencedor_de_uma_jogo (0.00s)
server_test.go:124: não foi possível abrir uma conexão de websocket em ws://127.0.0.1:55838/ws websocket: bad handshake
Não mudamos nosso servidor para aceitar conexões WebSocket em
/ws
, então ainda não estamos apertando as mãos.Adicione outra linha no nosso roteador:
roteador.Handle("/ws", http.HandlerFunc(p.webSocket))
E adicione nosso novo manipulador
webSocket
:func (p *ServidorJogador) webSocket(w http.ResponseWriter, r *http.Request) {
upgrader := websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
upgrader.Upgrade(w, r, nil)
}
Para aceitar uma conexão WebSocker, precisamos de um método
Upgrade
para atualizar a requisição. Agora, se você executar o teste novamente, o próximo erro deve aparecer.=== RUN TestJogo/quando_recebemos_uma_mensagem_via_websocket_que_ha_um_vencedor_de_uma_jogo
--- FAIL: TestJogo/quando_recebemos_uma_mensagem_via_websocket_que_ha_um_vencedor_de_uma_jogo (0.00s)
server_test.go:132: obtido 0 chamadas paraGravarVitoria esperado 1
Agora que temos uma conexão aberta, vamos esperar por uma pensagem e então gravá-la como vencedor.
func (p *ServidorJogador) webSocket(w http.ResponseWriter, r *http.Request) {
upgrader := websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
conexão, _ := upgrader.Upgrade(w, r, nil)
_, winnerMsg, _ := conexão.ReadMessage()
p.armazenamento.GravarVitoria(string(winnerMsg))
}
(Sim, estamos ignorando vários erros nesse momento!)
conexão.ReadMessage()
bloqueia a espera por uma mensagem na conexão. Quando obtivermos uma, vamos usá-la para GravarVitoria
. Isso finalmente fecharia a conexão WebSocket.Se tentar executar o teste, ele ainda vai falhar.
O problema está no tempo. Há um atraso entre nossa conexão WebSocket ler a mensagem e gravar a vitória e nosso teste termina sua execução antes disso acontecer. Você pode testar isso colocando um
time.Sleep
curto antes da verificação final.Vamos continuar com isso por enquanto, mas saiba que colocar sleeps arbitrários em testes é uma prática muito ruim.
time.Sleep(10 * time.Millisecond)
VerificaVitoriaDoVencedor(t, armazenamento, vencedor)
Cometemos vários pecados para fazer esse teste funcionar tanto no código do servidor quanto no código do teste, mas lembre-se que essa é a forma mais fácil para fazer as coisas funcionarem.
Temos um software horrível e cheio de gambiarras funcionando apoiado por testes, então agora temos a liberdade para torná-lo elegante sabendo que não vamos quebrar nada por acidente.
Então, vamos começar com o código do servidor.
Podemos mover o
upgrader
para um valor privado dentro do nosso pacote porque não precisamos redeclará-lo em toda requisição na conexão com o WebSocket.var atualizadorDeWebsocket = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
func (p *ServidorJogador) webSocket(w http.ResponseWriter, r *http.Request) {
conexão, _ := atualizadorDeWebsocket.Upgrade(w, r, nil)
_, winnerMsg, _ := conexão.ReadMessage()
p.armazenamento.GravarVitoria(string(winnerMsg))
}
Nossa chamada para
template.ParseFiles("jogo.html")
vai ser executada a cada GET /jogo
, o que significa que vamos usar o sistema de arquivo a cada requisição apesar de não ser necessário parsear o template novamente. Vamos refatorar o código para que possamos fazer o parse do template uma vez em NovoServidorJogador
ao invés disso. Vamos ter que fazer isso para que nossa função possa retornar um erro caso tenhamos problema ao obter o template do disco ou fazer parse dele.Agora vamos às mudanças relevantes do
ServidorJogador
:type ServidorJogador struct {
armazenamento ArmazenamentoJogador
http.Handler
template *template.Template
}
const caminhoTemplateHTML = "jogo.html"
func NovoServidorJogador(armazenamento ArmazenamentoJogador) (*ServidorJogador, error) {
p := new(ServidorJogador)
tmpl, err := template.ParseFiles("jogo.html")
if err != nil {
return nil, fmt.Errorf("problema ao abrir %s %v", caminhoTemplateHTML, err)
}
p.template = tmpl
p.armazenamento = armazenamento
roteador := http.NewServeMux()
roteador.Handle("/liga", http.HandlerFunc(p.manipulaLiga))
roteador.Handle("/jogadores/", http.HandlerFunc(p.manipulaJogadores))
roteador.Handle("/jogo", http.HandlerFunc(p.jogo))
roteador.Handle("/ws", http.HandlerFunc(p.webSocket))
p.Handler = roteador
return p, nil
}
func (p *ServidorJogador) jogo(w http.ResponseWriter, r *http.Request) {
p.template.Execute(w, nil)
}
Ao alterar a assinatura de
NovoServidorJogador
, agora temos problemas de compilação. Tente corrigir por si só ou olhe para o código fonte caso para ver a solução.Para o código de teste, fiz uma função auxiliar chamada
deveFazerServidorJogador(t *testing.T, armazenamento ArmazenamentoJogador) *ServidorJogador
para que eu possa esconder o erro dos testes.func deveFazerServidorJogador(t *testing.T, armazenamento ArmazenamentoJogador) *ServidorJogador {
servidor, err := NovoServidorJogador(armazenamento)
if err != nil {
t.Fatal("problema ao criar o servidor do jogador", err)
}
return servidor
}
Da mesma forma, criei outra função auxiliar
deveConectarAoWebSocket
para que eu possa esconder um erro ao criar uma conexão de WebSocket.func deveConectarAoWebSocket(t *testing.T, url string) *websocket.Conn {
ws, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
t.Fatalf("não foi possível abrir uma conexão de websocket em %s %v", url, err)
}
return ws
}
Finalmente, podemos criar uma função auxiliar no nosso código de teste para enviar mensagens:
func escreverMensagemNoWebsocket(t *testing.T, conexão *websocket.Conn, mensagem string) {
t.Helper()
if err := conexão.WriteMessage(websocket.TextMessage, []byte(mensagem)); err != nil {
t.Fatalf("não foi possível enviar mensagem na conexão websocket %v", err)
}
}
Agora que os testes estão passando, tent executar o servidor e declarar alguns vencedores em
/jogo
. Devemos vê-los gravados em /liga
. Lembre-se que sempre que tivermos um vencedor, vamos fechar a conexão, e você vai precisar atualizar a página para abrir a conexão novamente.Fizemos um formulário simples da web que permite que usuários gravem o vencedor de uma jogo. Vamos iterar nele para fazer com que o usuário possa começar uma jogo inserindo o número de jogadores e o servidor vai mostrar mensagens para o cliente informando-o qual é o valor do blind conforme o tempo passa.
Primeiramente, atualize o
jogo.html
para atualizar o código do lado do cliente para os novos requerimentos:<!DOCTYPE html>
<html lang="pt-br">
<head>
<meta charset="UTF-8">
<title>Vamos jogar pôquer</title>
</head>
<corpo>
<section id="jogo">
<div id="jogo-start">
<label for="jogador-count">Número de jogadores</label>
<input type="number" id="jogador-count"/>
<button id="start-jogo">Começar</button>
</div>
<div id="declare-vencedor">
<label for="vencedor">Vencedor</label>
<input type="text" id="vencedor"/>
<button id="vencedor-button">Declare vencedor</button>
</div>
<div id="blind-value"/>
</section>
<section id="jogo-end">
<h1>Outra ótima jogo de pôquer, pessoal!!</h1>
<p><a href="/liga">Verifique a tabela da liga</a></p>
</section>
</corpo>
<script type="application/javascript">
const startGame = document.getElementById('jogo-start')
const declareWinner = document.getElementById('declare-vencedor')
const submitWinnerButton = document.getElementById('vencedor-button')
const entradaVencedor = document.getElementById('vencedor')
const blindContainer = document.getElementById('blind-value')
const gameContainer = document.getElementById('jogo')
const gameEndContainer = document.getElementById('jogo-end')