Índice do curso
Lição 7 — O programa concluído
Por Dorian Chávez · fundador da Hábil e arquiteto de integração ·
Duração: 90-120 minutos, ou 2 sessões de 60. É a última lição do revisor: ao terminar você tem
um binário que roda de verdade, é configurado pela linha de comando, fala HTTP com timeouts reais,
produz dois formatos de saída, e é compilado para outra plataforma sem sair da sua máquina.
Ao terminar, você vai conseguir:
- Escrever um cliente HTTP com timeout explícito, e explicar por que
http.DefaultClienté perigoso em produção. - Separar a lógica de um programa (
ejecutar) do seu ponto de entrada (main), para poder testá-la sem tocar emos.Exit. - Definir flags de linha de comando com o pacote padrão
flag, sem nenhuma biblioteca externa. - Serializar uma estrutura para JSON com
encoding/json, e explicar que campos se perdem e por quê. - Rodar o
revisorde ponta a ponta contra um servidor real (um de teste, feito por você) e ler a sua saída. - Compilar o mesmo código para outra arquitetura e outro sistema operacional sem sair da sua máquina, e explicar por que isso é possível.
Por que isso importa
Você já tem as quatro peças do revisor construídas separadamente: servicio define o vocabulário
(lição 2-3), config lê a configuração (lição 5), revisar.Todos consulta tudo ao mesmo tempo (lição
6). A única coisa que falta é o que converte essas peças em um programa que outra pessoa possa usar sem
ler o código-fonte: que fale HTTP de verdade (até agora só o testamos com Falso), que receba
flags do terminal em vez de valores fixos no código, que produza um formato que outro
programa possa consumir, e que possa ser compilado e distribuído como um único arquivo.
Esta é a lição em que o revisor deixa de ser "código que funciona se eu o rodo, na minha máquina, com
os meus dados de teste" e se torna um binário que você pode copiar para outra pessoa, com a confiança de que ele vai
fazer exatamente o que a linha de comando pedir.
Os conceitos
7.1 O cliente HTTP: por que nunca http.DefaultClient
Foi assim que ficou o Revisor de verdade, o que de fato toca a rede (programas/revisor/internal/revisar/revisar.go):
// HTTP es el Revisor de verdad: hace una petición GET y mira qué contesta.
type HTTP struct {
Cliente *http.Client
}
// NuevoHTTP construye el Revisor con un cliente propio.
//
// 🔴 El cliente es propio y no http.DefaultClient a propósito: el cliente por
// omisión de Go NO tiene tiempo límite. Una petición contra un servicio que
// acepta la conexión y luego no contesta nada se queda colgada para siempre, sin
// error y sin síntoma.
//
// El Timeout del cliente es la red de seguridad de último recurso. El límite que
// de verdad manda es el del context, uno por servicio, que pone Todos.
func NuevoHTTP(limiteGeneral time.Duration) HTTP {
return HTTP{Cliente: &http.Client{Timeout: limiteGeneral}}
}
// Revisar consulta un servicio. Nunca devuelve error: el fracaso es parte del
// resultado, porque «este servicio no responde» es justo lo que el programa
// quiere reportar, no una excepción al trabajo.
func (r HTTP) Revisar(ctx context.Context, s servicio.Servicio) servicio.Estado {
cliente := r.Cliente
if cliente == nil {
cliente = &http.Client{Timeout: s.TimeoutEfectivo()}
}
inicio := time.Now()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.URL, nil)
if err != nil {
return servicio.Estado{
Servicio: s,
Duracion: time.Since(inicio),
Err: fmt.Errorf("armando la peticion: %w", err),
}
}
resp, err := cliente.Do(req)
if err != nil {
return servicio.Estado{
Servicio: s,
Duracion: time.Since(inicio),
Err: traducir(ctx, err),
}
}
// Después de comprobar el error, nunca antes: si err != nil, resp es nil y
// cerrarlo es un pánico.
defer resp.Body.Close()
// Se drena el cuerpo aunque no lo queramos leer. Sin esto la conexión no se
// puede reutilizar y el programa abre una nueva por cada consulta.
io.Copy(io.Discard, resp.Body)
return servicio.Estado{
Servicio: s,
Codigo: resp.StatusCode,
Duracion: time.Since(inicio),
}
}
🔴 http.DefaultClient —o que você usaria se escrevesse http.Get(url) diretamente— não tem nenhum
timeout. Se o servidor do outro lado aceita a conexão e nunca responde nada, essa chamada fica
esperando para sempre, sem erro, sem panic, só um programa que um dia deixa de avançar e ninguém
sabe por quê. É um dos erros mais caros de Go em produção justamente porque não dá nenhum
sintoma até que já esteja acontecendo.
O revisor se protege duas vezes, não uma: o http.Client próprio tem um timeout geral (o limite de
todo o relatório, passado a NuevoHTTP), e além disso cada consulta individual roda sob o
context.WithTimeout por serviço que Todos montou na lição 6 — esse segundo, mais curto, é o que
manda na prática. O timeout do cliente é a rede de segurança de último recurso, não o mecanismo
principal.
⚠️ defer resp.Body.Close() vem depois de verificar o erro, nunca antes. Se err != nil, resp
é nil, e chamar um método sobre um ponteiro nulo dá panic. E io.Copy(io.Discard, resp.Body)
antes de fechar não é decorativo: sem drenar o corpo da resposta, a conexão TCP subjacente não
pode ser reutilizada para a próxima requisição ao mesmo servidor, e o programa acaba abrindo uma
conexão nova a cada vez em vez de reutilizar as que já tem.
traducir troca o erro cru de net/http por um legível em uma tabela:
func traducir(ctx context.Context, err error) error {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("se acabo el tiempo de espera")
}
if ctx.Err() == context.Canceled {
return fmt.Errorf("consulta cancelada")
}
return fmt.Errorf("no responde: %w", err)
}
O erro que o net/http dá de fábrica traz a URL completa e a palavra Get, que em uma tabela de
relatório só ocupam espaço e não dizem nada novo ao leitor — por isso ele é traduzido antes de ser mostrado.
7.2 main não se testa; ejecutar sim
O padrão que separa o revisor de um programa de um único arquivo:
func main() {
os.Exit(ejecutar(os.Args[1:], os.Stdout, os.Stderr))
}
ejecutar é onde vive a lógica real —as seções 7.3, 7.4 e 7.6 a vão mostrando por partes—; a sua
assinatura:
func ejecutar(args []string, salida, errores *os.File) int {
// toda la lógica real vive aquí
}
go test não consegue testar uma função que chama os.Exit, porque os.Exit termina o processo
inteiro de imediato — incluindo o próprio processo de testes, que jamais chegaria a reportar o resultado.
Separando "decidir que código de saída corresponde" (ejecutar, que retorna um int) de "sair de
verdade com esse código" (main, a única linha que chama os.Exit), toda a lógica pode ser testada
chamando ejecutar diretamente, com argumentos de teste, e verificando o número que ele retorna — exatamente
o que cmd/revisor/main_test.go faz:
func TestEjecutar_faltaConfig(t *testing.T) {
var salida bytes.Buffer
codigo := correr(t, []string{}, &salida)
if codigo != 2 {
t.Errorf("código de salida = %d, quería 2 (falta --config)", codigo)
}
}
$ go test ./cmd/revisor/... -v -run TestEjecutar_faltaConfig
=== RUN TestEjecutar_faltaConfig
revisor: falta --config
Usage of revisor:
-config string
ruta al archivo de servicios (obligatorio)
-formato string
tabla o json (default "tabla")
-limite duration
tiempo máximo para todo el reporte (default 10s)
-paralelo int
cuántos servicios consultar a la vez (0 = por omisión)
--- PASS: TestEjecutar_faltaConfig (0.00s)
Essa é a saída real de --help (gerada automaticamente por flag, seção 7.3) capturada no meio
de um teste, sem abrir um terminal nem executar o binário compilado.
7.3 Flags de linha de comando, sem bibliotecas
banderas := flag.NewFlagSet("revisor", flag.ContinueOnError)
rutaConfig := banderas.String("config", "", "ruta al archivo de servicios (obligatorio)")
formato := banderas.String("formato", "tabla", "tabla o json")
paralelo := banderas.Int("paralelo", 0, "cuántos servicios consultar a la vez (0 = por omisión)")
limiteGeneral := banderas.Duration("limite", 10*time.Second, "tiempo máximo para todo el reporte")
if err := banderas.Parse(args); err != nil {
return 2 // flag ya imprimió el error y el uso
}
flag.NewFlagSet em vez do pacote flag no nível global (que você usaria com flag.String(...)
diretamente) é o que permite ter uma função ejecutar(args []string, ...) que recebe os seus argumentos
como parâmetro, em vez de lê-los sempre de os.Args — outro detalhe que existe especificamente para
poder testá-la, como na seção 7.2.
flag vem na biblioteca padrão e basta para um programa deste tamanho: quatro flags, tipos
básicos (string, int, time.Duration), ajuda automática. Se algum dia o revisor precisasse de
subcomandos (revisor check, revisor list, cada um com as suas próprias flags), aí sim valeria a pena
olhar uma biblioteca como cobra — mas não antes de precisar dela de verdade.
7.4 Os dois formatos de saída: tabela e JSON
Foi assim que ficou Tabla, a função que produz a saída legível por humanos que você viu em toda a lição
(programas/revisor/internal/reporte/tabla.go):
func Tabla(w io.Writer, estados []servicio.Estado) {
ordenados := ordenarPorNombre(estados)
anchoNombre := len("SERVICIO")
for _, e := range ordenados {
if n := len(e.Servicio.Etiqueta()); n > anchoNombre {
anchoNombre = n
}
}
fmt.Fprintf(w, "%-*s %-6s %8s %s\n", anchoNombre, "SERVICIO", "ESTADO", "TIEMPO", "DETALLE")
for _, e := range ordenados {
fmt.Fprintf(w, "%-*s %-6s %8s %s\n",
anchoNombre,
e.Servicio.Etiqueta(),
etiquetaEstado(e),
formatoTiempo(e),
detalle(e),
)
}
}
A largura da primeira coluna não é fixa no código — ela é calculada. O laço acima percorre
todos os estados uma vez antes de imprimir qualquer coisa, e fica com o nome mais longo (começando pela
largura da própria palavra "SERVICIO", caso algum nome de serviço seja mais curto que isso). Esse
número entra como o * em %-*s: um verbo de formatação de largura variável, em que a própria largura é
mais um argumento de Fprintf, não um número escrito à mão. É a razão pela qual a tabela real desta
lição tem colunas retas não importa se os nomes dos serviços são curtos (sano) ou longos
(inventario): com uma largura fixa, um dos dois casos sempre ficaria torto.
ordenarPorNombre (as seções 6.3 e 6.7 já explicaram por que a ordem de chegada não é confiável: várias
goroutines entregam resultados por um canal, e ganha quem responder primeiro) faz uma cópia do slice e a
ordena alfabeticamente antes de imprimir — sem isso, a mesma consulta produziria uma tabela em uma ordem
diferente cada vez que você rodasse o programa, ainda que os dados fossem idênticos.
etiquetaEstado, formatoTiempo e detalle são as três funções pequenas que decidem que palavra vai em
cada coluna (OK/LENTO/FALLA, o tempo arredondado ao milissegundo ou um hífen se nunca houve
resposta, e o código HTTP ou o motivo da falha) — são as que você já viu atuando na tabela real da
seção 7.5, agora com o código que as produz.
🔑 Por que não se usou text/tabwriter, que é a ferramenta que a biblioteca padrão oferece
justamente para alinhar colunas: para uma tabela de quatro colunas em que só uma tem largura variável
(o nome do serviço; as outras três são curtas e previsíveis), calcular a largura à mão é mais simples
de ler do que introduzir um tabwriter.Writer com os seus próprios Flush() e separadores por tabulação. Com
uma tabela de mais colunas variáveis, tabwriter seria sim a ferramenta correta — vale a pena conhecê-lo
(está na seção "Para ler mais" desta lição) ainda que o revisor não precise dele.
Com a tabela já entendida, o outro formato de saída — JSON — é a outra metade desta seção:
type lineaJSON struct {
Servicio string `json:"servicio"`
OK bool `json:"ok"`
Codigo int `json:"codigo,omitempty"`
TiempoMs int64 `json:"tiempo_ms"`
Detalle string `json:"detalle,omitempty"`
}
⚠️ servicio.Estado não é serializado diretamente — de propósito. Estado traz um campo Err error, e
error é uma interface: encoding/json não sabe como convertê-la em texto por si só (tentar isso produz
um objeto vazio {}, não um erro de compilação, o que é pior: falha em silêncio). O revisor resolve
isso com um tipo intermediário, lineaJSON, que só vive dentro do pacote reporte: converte o erro
no seu texto (e.Motivo()) antes de codificar, e de quebra desacopla o formato público (o que outros
programas vão ler) do modelo interno (Estado) — se amanhã Estado ganhar um campo novo, o JSON que
já circula não muda só porque algo interno mudou.
omitempty em Codigo e Detalle remove esses campos do JSON quando valem zero ou string vazia — assim um
serviço saudável não arrasta um "detalle": "" sem sentido. Codificado de verdade:
codificador := json.NewEncoder(w)
codificador.SetIndent("", " ")
return codificador.Encode(lineas)
7.5 De ponta a ponta: o revisor rodando contra um servidor real
Para testar todo o programa junto —não cada peça separadamente— construí cmd/servidor-demo: um
servidor HTTP mínimo com quatro rotas que se comportam como os quatro casos que o revisor precisa
saber reportar.
mux.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/lento", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(1500 * time.Millisecond)
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/error", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
mux.HandleFunc("/nunca-contesta", func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done() // no responde nunca por su cuenta; solo cede cuando el cliente se cansa
})
Com esse servidor no ar em :8091 e um arquivo de configuração apontando para as suas quatro rotas, rodei
o binário real:
$ /tmp/servidor-demo -puerto 8091 &
servidor-demo escuchando en :8091 (/ok, /lento, /error, /nunca-contesta)
$ cat /tmp/servicios-demo.txt
sano http://127.0.0.1:8091/ok
lento http://127.0.0.1:8091/lento
malo http://127.0.0.1:8091/error
colgado http://127.0.0.1:8091/nunca-contesta 200ms
$ /tmp/revisor --config /tmp/servicios-demo.txt --formato tabla
SERVICIO ESTADO TIEMPO DETALLE
colgado FALLA 202ms se acabo el tiempo de espera
lento LENTO 1.503s 200
malo FALLA 1ms codigo 500
sano OK 1ms 200
$ echo $?
1
Cada linha dessa tabela corresponde exatamente ao comportamento que foi programado no servidor de
teste: sano responde rápido e com 200; lento leva 1.5 segundos de verdade e por isso sai LENTO;
malo responde na hora mas com 500; colgado nunca responde, e o seu timeout individual de 200ms
—declarado no arquivo de configuração, coluna três— cortou a espera em 202ms, quase exato. O
código de saída, 1, é real: pelo menos um serviço não estava OK, então ejecutar retorna 1 em vez
de 0 (seção 7.6) — o mesmo binário, usado a partir de um script, pode dizer a quem o invocar se houve
problemas sem que ninguém precise ler a tabela.
E em JSON, o mesmo relatório:
$ /tmp/revisor --config /tmp/servicios-demo.txt --formato json
[
{"servicio": "colgado", "ok": false, "tiempo_ms": 205, "detalle": "se acabo el tiempo de espera"},
{"servicio": "lento", "ok": true, "codigo": 200, "tiempo_ms": 1503},
{"servicio": "malo", "ok": false, "codigo": 500, "tiempo_ms": 1, "detalle": "codigo 500"},
{"servicio": "sano", "ok": true, "codigo": 200, "tiempo_ms": 1}
]
(Aqui sem a indentação de SetIndent para que caiba em uma linha por serviço; o programa real a
produz com quebras de linha, como você viu na seção 7.4.)
7.6 O código de saída, com significado
for _, e := range estados {
if !e.OK() {
return 1 // al menos un servicio falló: el código de salida lo refleja
}
}
return 0
Três códigos possíveis, e cada um responde a uma pergunta diferente para quem invocar o programa a partir de um
script ou de um pipeline: 0 — tudo deu certo; 1 — o programa rodou por completo, mas pelo menos um
serviço não estava saudável; 2 — o programa nem sequer conseguiu iniciar (falta --config, o arquivo não
existe, o formato de configuração está mal escrito). É a diferença entre "fiz o trabalho e encontrei
problemas" e "não consegui nem começar a trabalhar" — e um pipeline de integração contínua pode reagir
de forma diferente a cada um.
7.7 Compilação cruzada: o mesmo código, outra plataforma
GOOS=linux GOARCH=amd64 go build -o revisor-linux-amd64 ./cmd/revisor
Confirmado de verdade, compilando a partir deste Mac (arm64) para Linux x86-64:
$ GOOS=linux GOARCH=amd64 go build -o revisor-linux-amd64 ./cmd/revisor
$ file revisor-linux-amd64
revisor-linux-amd64: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, ...
Sem Docker, sem máquina virtual, sem instalar nada adicional: duas variáveis de ambiente bastam. Isso é possível porque o compilador de Go traz, de fábrica, o código necessário para gerar binários de qualquer combinação de sistema operacional e arquitetura que ele suporte — não depende de estar rodando no sistema de destino para compilar para ele, ao contrário de como funcionam muitas outras linguagens compiladas.
O tamanho do binário, medido, com e sem símbolos de depuração:
$ go build -o revisor-normal ./cmd/revisor
$ wc -c revisor-normal
9464130 revisor-normal
$ go build -ldflags="-s -w" -o revisor-chico ./cmd/revisor
$ wc -c revisor-chico
6386194 revisor-chico
De 9.46 MB para 6.39 MB, uma redução real de 32%, removendo a tabela de símbolos (-s) e as
informações de depuração DWARF (-w) que o binário normal inclui para que um depurador possa
inspecioná-lo. Para um binário que você vai distribuir para produção e não vai depurar ali mesmo, esses dados
não servem para nada e só ocupam espaço — para um que você está desenvolvendo ativamente, conserve-os.
7.8 Testar código que faz HTTP, sem rede de verdade: httptest
Até a lição 6, os testes de concorrência usavam Falso (um Revisor que nunca toca a rede). Para
testar o programa completo —flags, leitura de configuração, o cliente HTTP real, o relatório—
sem depender de um serviço externo nem de que algo esteja escutando em uma porta fixa, net/http/httptest
sobe um servidor real, em uma porta que o sistema operacional atribui sozinho, dentro do próprio processo de
testes:
func TestEjecutar_reportaOKyFalla(t *testing.T) {
servidor := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/mal") {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}))
defer servidor.Close()
archivoConfig, err := os.CreateTemp(t.TempDir(), "servicios-*.txt")
if err != nil {
t.Fatal(err)
}
contenido := "sano " + servidor.URL + "/bien\nmalo " + servidor.URL + "/mal\n"
if _, err := archivoConfig.WriteString(contenido); err != nil {
t.Fatal(err)
}
archivoConfig.Close()
var salida, errores bytes.Buffer
codigo := correr(t, []string{"--config", archivoConfig.Name()}, &salida)
if codigo != 1 {
t.Errorf("código de salida = %d, quería 1 (hay un servicio con falla)", codigo)
}
if !strings.Contains(salida.String(), "sano") || !strings.Contains(salida.String(), "malo") {
t.Errorf("la tabla no menciona a los dos servicios:\n%s", salida.String())
}
_ = errores
}
Execução real:
$ go test ./cmd/revisor/... -run TestEjecutar_reportaOKyFalla -v
=== RUN TestEjecutar_reportaOKyFalla
--- PASS: TestEjecutar_reportaOKyFalla (0.00s)
servidor.URL é uma URL real (http://127.0.0.1:PUERTO, com uma porta livre que o sistema escolheu),
então o Revisor HTTP da seção 7.1 a consulta exatamente como consultaria qualquer
serviço de produção — a única diferença é que o "serviço" é um http.HandlerFunc de quinze
linhas que vive no mesmo processo que o teste. É isso que permite testar de ponta a ponta
—flags, configuração, cliente HTTP, relatório, código de saída— em milissegundos, sem abrir nenhuma
porta fixa que pudesse colidir com outra coisa rodando na máquina, e sem deixar nada rodando depois
que o teste termina (defer servidor.Close() cuida disso).
t.TempDir() cria uma pasta temporária que o Go apaga sozinho ao terminar o teste (diferente de
os.CreateTemp("/tmp", ...) puro e simples, que deixaria o arquivo ali para sempre se ninguém o apagasse à mão) —
a combinação de httptest para a rede e t.TempDir() para o disco é o que permite que
TestEjecutar_reportaOKyFalla não deixe nenhum rastro no sistema depois de rodar.
7.9 Por que dois limites de tempo, não um
O revisor aceita --limite (o tempo máximo para todo o relatório) e cada linha de configuração
pode declarar o seu próprio tempo limite por serviço. Não é redundante: eles resolvem duas perguntas
diferentes. --limite responde «quanto tempo, no máximo, estou disposto a esperar pelo relatório
completo?» — útil se o revisor roda dentro de outro processo com o seu próprio prazo (um health check
que um orquestrador espera de tempos em tempos, por exemplo). O tempo por serviço responde «quanto é
razoável esperar por ESTE serviço em particular?» — um serviço interno de baixa latência e outro que vai
até um fornecedor externo não deveriam compartilhar o mesmo limite.
A demonstração da seção 7.5 confirma isso com dados reais: colgado tinha um limite próprio de
200ms declarado no arquivo de configuração, e foi cortado em 202ms — muito antes que o --limite
geral (10 segundos por padrão) tivesse oportunidade de intervir. O mais curto dos dois limites é
sempre o que manda, e isso é exatamente o que você quer: que um único serviço lento não consuma todo o
orçamento de tempo do relatório completo.
7.10 O binário "não precisa de nada" — exceto uma coisa: certificados
A lição 1 demonstrou que um binário de Go roda em uma máquina Linux sem Go instalado, graças à
linkagem estática. É tentador estender essa ideia para "não precisa de nada do sistema, ponto" — e eu verifiquei isso
levando-a ao extremo: coloquei o binário do revisor em uma imagem Docker construída FROM scratch,
a base mais vazia que existe (não tem sequer um shell nem componentes do sistema operacional, só o binário).
FROM scratch
COPY revisor /revisor
COPY servicios.txt /servicios.txt
ENTRYPOINT ["/revisor", "--config", "/servicios.txt"]
Com um serviço real apontando para https://example.com:
$ docker build -t revisor-demo:scratch .
$ docker run --rm revisor-demo:scratch --formato tabla
SERVICIO ESTADO TIEMPO DETALLE
ejemplo FALLA 176ms no responde: Get "https://example.com": tls: failed to verify certificate: x509: certificate signed by unknown authority
Falhou — e não por causa do revisor, mas por algo que nenhuma imagem scratch traz: a lista de autoridades
certificadoras. Para validar um certificado TLS (o cadeado do https://), o sistema operacional
normalmente fornece uma lista de quem tem permissão para assinar certificados válidos — tipicamente em
/etc/ssl/certs/. Uma imagem scratch não tem nem essa pasta, então Go não consegue verificar nenhum
certificado e se recusa a continuar, com toda razão: seguir sem verificar seria aceitar qualquer
certificado, válido ou falso.
A solução, verificada, é copiar só esse arquivo a partir de uma imagem que o tenha, sem arrastar o resto do sistema operacional:
FROM alpine:3.20 AS certs
RUN apk add --no-cache ca-certificates
FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY revisor /revisor
COPY servicios.txt /servicios.txt
ENTRYPOINT ["/revisor", "--config", "/servicios.txt"]
$ docker build -f Dockerfile.certs -t revisor-demo:scratch-certs .
$ docker run --rm revisor-demo:scratch-certs --formato tabla
SERVICIO ESTADO TIEMPO DETALLE
ejemplo OK 238ms 200
$ docker images revisor-demo:scratch-certs --format "{{.Size}}"
14.7MB
14.7 MB no total, contra 14.4 MB sem os certificados — menos de 300 KB para resolver o problema corretamente. O binário é sim autossuficiente para tudo o que é código Go; o que ele nunca traz por si só é a confiança sobre quais autoridades são legítimas, porque isso é informação do mundo (que certificadoras existem e continuam vigentes), não algo que o compilador possa decidir por você.
O erro que você vai ver
| O sintoma | Mensagem / evidência literal | O que acontece e o que fazer |
|---|---|---|
| Falta a flag obrigatória | revisor: falta --config seguido do uso automático de flag |
ejecutar valida explicitamente que --config não venha vazio antes de fazer qualquer outra coisa, e sai com código 2 |
| Formato de saída inválido | revisor: --formato debe ser "tabla" o "json", no "xml" |
Só existem dois formatos; qualquer outro valor é rejeitado antes de tentar qualquer coisa |
| Serviço que nunca responde | Na tabela: FALLA com detalhe se acabo el tiempo de espera, tempo próximo do timeout configurado |
O context.WithTimeout por serviço (lição 6) cortou a espera; não é um bug, é o mecanismo funcionando |
| Fechar o corpo de uma resposta nula | (se fosse feito errado) panic: runtime error: invalid memory address or nil pointer dereference |
Aconteceria se defer resp.Body.Close() fosse colocado antes de verificar err; o revisor evita isso verificando o erro primeiro (seção 7.1) |
error serializado diretamente para JSON sem traduzir |
{} (objeto vazio, sem nenhum dado útil, sem nenhum erro de compilação que avise) |
encoding/json não sabe converter uma interface error; por isso reporte usa lineaJSON com o motivo já convertido em texto (seção 7.4) |
| Código de saída não verificado em um script | O script segue como se nada fosse, ainda que um serviço tenha falhado | ejecutar distingue sim 0/1/2 (seção 7.6); quem integra o revisor em um pipeline deve ler $?, não só a saída impressa |
Binário em uma imagem FROM scratch, contra HTTPS |
no responde: Get "https://...": tls: failed to verify certificate: x509: certificate signed by unknown authority |
Falta a lista de autoridades certificadoras (ca-certificates.crt); copie-a a partir de uma imagem que a tenha (seção 7.10) |
O que se faz errado
- Usar
http.DefaultClientouhttp.Getdiretamente em produção. Seção 7.1: sem timeout próprio, uma conexão que nunca responde trava o programa para sempre, sem nenhum sintoma prévio. - Colocar toda a lógica dentro de
maine chamaros.Exitno meio do código. Torna a função impossível de testar comgo test, porqueos.Exittermina o processo de testes junto com o programa. Separe "decidir o código de saída" de "sair de verdade" (seção 7.2). - Serializar
errordiretamente para JSON, esperando que "algo saia". Sai um objeto vazio, sem nenhum aviso de que algo não pôde ser convertido — o tipo de falha silenciosa mais difícil de detectar, porque o programa não quebra nem reclama. - Fechar o corpo de uma resposta HTTP antes de verificar o erro. Se a requisição falhou,
respénil, e chamar um método sobre ele dá panic. Verifique o erro primeiro, sempre. - Não drenar o corpo da resposta antes de fechá-lo. A conexão não pode ser reutilizada, e um programa que faz muitas requisições ao mesmo servidor acaba abrindo uma conexão nova a cada vez, mais lento do que o necessário sem nenhum erro que o indique.
- Ignorar o código de saída do binário a partir de um script ou de um pipeline. O
revisordistingue "rodei bem, tudo saudável" (0), "rodei bem, algo falhou" (1) e "não consegui nem iniciar" (2) — desperdiçar isso lendo só a saída impressa é jogar fora informação que o programa já está te dando de graça.
Exercícios
- Implemente (ou revise, se você já o tem) o
RevisorHTTP real da seção 7.1, com o seu próprio cliente e timeout. Confirme que compila e quego vet ./...não reclama. - Suba
cmd/servidor-demoem uma porta livre e rode o seurevisorcontra as suas quatro rotas, como na seção 7.5. Cole a tabela real que você obteve, não uma inventada. - Adicione a
Tabla(seção 7.4) uma quinta coluna,PROTOCOLO, que digahttpsouhttpconformeServicio.EsSeguro()(o método que foi definido na lição 3). Ajuste a largura fixa dessa coluna à mão, sem precisar do cálculo dinâmico queanchoNombrejá tem. - Rode o mesmo relatório com
--formato jsone valide que o resultado é JSON legítimo (por exemplo, passe-o porjq .ou cole-o em um validador de JSON). - Provoque o erro de
--formatoinválido (seção 7.6, tabela) e confirme o código de saída comecho $?. - Compile o seu
revisorparaGOOS=linux GOARCH=amd64a partir da sua máquina atual e confirme comfileque o binário resultante é o da plataforma correta. - (Um pouco mais difícil) Meça o tamanho do seu binário com e sem
-ldflags="-s -w", como na seção 7.7, e calcule a porcentagem de redução. - (Fechamento do curso) Rode a suíte completa do projeto —
go vet ./...,gofmt -l .ego test ./... -race -cover— e cole a saída completa no seu diário de bordo. Se algo não estiver verde, corrija antes de dar o curso por encerrado.
Soluções
1-7. Não há uma única saída de referência porque depende da sua própria implementação e da sua própria máquina; compare a forma do seu resultado com as seções correspondentes (7.1, 7.4, 7.5, 7.6, 7.7).
A execução real, sobre o
revisordeste curso, em 30-set-2026:$ gofmt -l . (sin salida: nada por formatear) $ go vet ./... (sin salida: limpio) $ go test ./... -race -cover ok github.com/habil/revisor/cmd/revisor 1.21s coverage: 79.2% of statements github.com/habil/revisor/cmd/servidor-demo coverage: 0.0% of statements ok github.com/habil/revisor/internal/config 1.17s coverage: 97.4% of statements ok github.com/habil/revisor/internal/reporte 1.18s coverage: 100.0% of statements ok github.com/habil/revisor/internal/revisar 1.33s coverage: 61.9% of statements ok github.com/habil/revisor/internal/servicio 1.17s coverage: 92.3% of statementscmd/servidor-demosai com 0.0% semoknemFAIL, não porque algo esteja quebrado: com-cover, um pacote sem nenhum arquivo_test.gomesmo assim é instrumentado e reportado, mas não há nenhum teste que exercite esse código. É uma ferramenta de apoio para testar o programa completo, não parte dorevisorque é distribuído, e não tem lógica própria que decida nada — só responde o que foi programado para responder — por isso não precisa de testes próprios.⚠️ Os 61.9% de
internal/revisarnesta tabela são o artefato que a seção 5.7 explica: por pacote, não se conta o queTestEjecutar_reportaOKyFalla(emcmd/revisor, um pouco mais acima nesta mesma execução) exercita deRevisarao falar HTTP de verdade contra o servidorhttptest. Medido com-coverpkg=./...sobre todo o projeto:Revisarsobe para 86.4% e o total do projeto é 81.5%, não 61.9%. Se você vai citar um número de cobertura para decidir algo, rode-coverpkg=./..., não confie só no número por pacote.
Como sei que consegui
- O meu
RevisorHTTP tem o seu próprio cliente, com timeout, e nunca usahttp.DefaultClient. - Separei
main(que só chamaos.Exit) de uma função que faz o trabalho e retorna umint. - O meu binário aceita
--config,--formato,--paraleloe--limitepela linha de comando. - Rodei o
revisorreal contra um servidor real (o meu ou oservidor-demodo curso) e a tabela que obtive reflete exatamente o que esse servidor faz. -
--formato jsonproduz JSON válido, verificado com uma ferramenta que não sou eu lendo. -
echo $?depois de rodar orevisordá 0, 1 ou 2, e sei explicar cada um. - Compilei para outra plataforma (
GOOS/GOARCH) e confirmei comfileque o binário é o correto. -
go vet ./...,gofmt -l .ego test ./... -race -coverestão limpos na minha própria cópia do projeto — não suponho, eu rodei.
Para ler mais
- net/http package — a documentação oficial completa do cliente e servidor HTTP da biblioteca padrão.
- Command flag — a documentação oficial do pacote de flags usado nesta lição.
- encoding/json: JSON and Go — o artigo oficial sobre como Go traduz
entre structs e JSON, incluindo as regras das tags (
json:"...",omitempty,-). - Build constraints e compilação cruzada — a
referência de
GOOS/GOARCHe das demais variáveis de ambiente que controlamgo build. - text/tabwriter — a ferramenta padrão para alinhar colunas quando o cálculo manual da seção 7.4 não basta (várias colunas de largura variável).
Termos desta lição
| Termo | O que significa |
|---|---|
http.Client |
o tipo que faz requisições HTTP em Go; sem configurar, não tem limite de tempo |
flag.FlagSet |
conjunto de flags de linha de comando que pode ser analisado a partir de um slice próprio, não só de os.Args |
omitempty |
opção de tag JSON que remove um campo da saída quando vale zero ou está vazio |
| código de saída | o número inteiro que um programa retorna ao terminar; 0 significa sucesso por convenção universal |
GOOS / GOARCH |
variáveis de ambiente que dizem ao go build para que sistema operacional e arquitetura compilar |
-ldflags="-s -w" |
opções de compilação que removem símbolos e dados de depuração para reduzir o tamanho do binário |
E agora, o que vem a seguir
Você tem um programa completo: consulta serviços reais, todos ao mesmo tempo, com limites de tempo, e produz um relatório que um humano ou um programa podem ler. Três coisas que o tornam mais sólido, na ordem em que mais convém atacá-las:
golangci-lint— junta dezenas de analisadores estáticos em uma única execução. Passe-o pelo seu própriorevisore leia o que ele diz: quase sempre ensina algo que nemgo vetnem os testes pegam.- Effective Go outra vez. Agora que você escreveu um programa completo, vai lê-lo de forma diferente da lição 0.
- Leia bom código escrito por outras pessoas. O próprio pacote
net/httpda biblioteca padrão é um bom ponto de partida: você já tem com o que se orientar para entender por que ele está escrito como está.
E o prometido desde o README: você vai escrever este mesmo programa outra vez, em Rust, no curso irmão. Não para comparar sintaxe, mas para ver o mesmo problema resolvido com outras ferramentas —isso é o que de verdade ensina em que as duas linguagens se diferenciam.
Prefere e-mail? Escreva para hola@habil.mx