Criando um servidor HTTP com net/http

Criando um servidor HTTP com net/http

Servidor HTTP em Go com net/http: Handler e ServeMux, timeouts, o roteador do Go 1.22 com precedência, 405 automático e conflitos, middlewares de log, pânico, CORS e autenticação, estado global que perde dados sob concorrência, a ordem de cabeçalho, status e corpo e o ResponseController.
Golang

• • 17 min de leitura

Uma das razões pelas quais Go se tornou a linguagem preferida para backends é a qualidade do suporte HTTP na biblioteca padrão. Sem instalar nenhuma dependência externa, é possível criar um servidor HTTP production-ready, com roteamento, middlewares, timeouts, TLS e suporte a HTTP/2.

O Go 1.22 trouxe melhorias significativas ao roteador padrão — suporte a métodos HTTP e parâmetros de caminho diretamente nos padrões de rota — reduzindo ainda mais a necessidade de bibliotecas externas para casos simples.

O mínimo funcional

Um servidor HTTP em Go começa com três conceitos: o Handler, o ServeMux e o Server:

package main

import (
    "fmt"
    "net/http"
)

func main() {
    // Handler mais simples possível
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "Olá, mundo!")
    })

    fmt.Println("Servidor em http://localhost:8080")
    http.ListenAndServe(":8080", nil) // nil usa o DefaultServeMux
}

Embora funcional, esse código usa o DefaultServeMux global — um estado compartilhado que torna testes difíceis. A forma recomendada é criar um ServeMux explícito:

package main

import (
    "fmt"
    "log"
    "net/http"
    "time"
)

func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("/", handlerRaiz)
    mux.HandleFunc("/saude", handlerSaude)

    servidor := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    log.Println("Servidor iniciado em :8080")
    if err := servidor.ListenAndServe(); err != nil {
        log.Fatal(err)
    }
}

func handlerRaiz(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "API funcionando")
}

func handlerSaude(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    fmt.Fprintln(w, `{"status":"ok"}`)
}

Os timeouts são essenciais em produção. Sem eles, conexões lentas ou maliciosas podem consumir recursos indefinidamente.

A interface Handler

O coração do sistema HTTP do Go é a interface http.Handler:

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

Qualquer tipo que implemente ServeHTTP pode ser usado como handler. http.HandlerFunc é um adaptador que converte funções com a assinatura correta em handlers:

// HandlerFunc é definido na biblioteca padrão como:
type HandlerFunc func(ResponseWriter, *Request)

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

Isso permite criar handlers como structs com estado:

type HandlerProdutos struct {
    servico *ServicoProdutos
    logger  *slog.Logger
}

func (h *HandlerProdutos) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case http.MethodGet:
        h.listar(w, r)
    case http.MethodPost:
        h.criar(w, r)
    default:
        http.Error(w, "método não permitido", http.StatusMethodNotAllowed)
    }
}

Roteamento com Go 1.22

O Go 1.22 adicionou suporte a métodos HTTP e parâmetros de caminho diretamente nos padrões do ServeMux:

package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "strconv"
)

type Produto struct {
    ID    int     `json:"id"`
    Nome  string  `json:"nome"`
    Preco float64 `json:"preco"`
}

var produtos = []Produto{
    {ID: 1, Nome: "Teclado", Preco: 250.0},
    {ID: 2, Nome: "Mouse", Preco: 120.0},
    {ID: 3, Nome: "Monitor", Preco: 1500.0},
}

func main() {
    mux := http.NewServeMux()

    // Método + caminho — Go 1.22+
    mux.HandleFunc("GET /produtos", listarProdutos)
    mux.HandleFunc("POST /produtos", criarProduto)
    mux.HandleFunc("GET /produtos/{id}", buscarProduto)
    mux.HandleFunc("PUT /produtos/{id}", atualizarProduto)
    mux.HandleFunc("DELETE /produtos/{id}", deletarProduto)

    fmt.Println("API em :8080")
    http.ListenAndServe(":8080", mux)
}

func listarProdutos(w http.ResponseWriter, r *http.Request) {
    responderJSON(w, http.StatusOK, produtos)
}

func buscarProduto(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        http.Error(w, "id inválido", http.StatusBadRequest)
        return
    }

    for _, p := range produtos {
        if p.ID == id {
            responderJSON(w, http.StatusOK, p)
            return
        }
    }

    http.Error(w, "produto não encontrado", http.StatusNotFound)
}

func criarProduto(w http.ResponseWriter, r *http.Request) {
    var p Produto
    if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
        http.Error(w, "corpo inválido: "+err.Error(), http.StatusBadRequest)
        return
    }

    p.ID = len(produtos) + 1
    produtos = append(produtos, p)
    responderJSON(w, http.StatusCreated, p)
}

func atualizarProduto(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        http.Error(w, "id inválido", http.StatusBadRequest)
        return
    }

    var atualizado Produto
    if err := json.NewDecoder(r.Body).Decode(&atualizado); err != nil {
        http.Error(w, "corpo inválido", http.StatusBadRequest)
        return
    }

    for i, p := range produtos {
        if p.ID == id {
            atualizado.ID = id
            produtos[i] = atualizado
            responderJSON(w, http.StatusOK, atualizado)
            return
        }
    }

    http.Error(w, "produto não encontrado", http.StatusNotFound)
}

func deletarProduto(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        http.Error(w, "id inválido", http.StatusBadRequest)
        return
    }

    for i, p := range produtos {
        if p.ID == id {
            produtos = append(produtos[:i], produtos[i+1:]...)
            w.WriteHeader(http.StatusNoContent)
            return
        }
    }

    http.Error(w, "produto não encontrado", http.StatusNotFound)
}

func responderJSON(w http.ResponseWriter, status int, dados any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(dados)
}

Atenção: este exemplo não é seguro para uso real, por dois motivos. O primeiro é que o net/http atende cada requisição em sua própria goroutine, e os handlers leem e alteram a fatia global produtos sem sincronização. Num teste com 50 POSTs simultâneos, o go run -race acusou corridas de dados, e em vez de 53 produtos (3 iniciais mais 50) restaram 46 — sete cadastros se perderam sem erro nenhum. O segundo é que p.ID = len(produtos) + 1 repete IDs depois de uma exclusão: no mesmo teste, apagar o produto 1 e criar outro gerou um ID que já existia, e a lista ficou com dois produtos com o mesmo número. Numa aplicação real, os dados ficam num banco, que resolve as duas coisas. Num armazenamento em memória, a correção é um tipo com sync.Mutex protegendo a fatia, travado em cada leitura e escrita, e um contador proximoID que só cresce, incrementado sob o mesmo lock.

Middlewares: comportamento transversal

Middleware é uma função que envolve um handler, adicionando comportamento antes e/ou depois de sua execução. O padrão idiomático em Go retorna um http.Handler:

package main

import (
    "log/slog"
    "net/http"
    "os"
    "time"
)

// Middleware de logging
func comLogging(logger *slog.Logger, prox http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        inicio := time.Now()

        // Wrapper para capturar o status code
        rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}

        prox.ServeHTTP(rw, r)

        logger.Info("requisição",
            "método", r.Method,
            "caminho", r.URL.Path,
            "status", rw.status,
            "duração", time.Since(inicio),
            "ip", r.RemoteAddr,
        )
    })
}

// Middleware de recuperação de pânico
func comRecuperacao(prox http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                slog.Error("pânico recuperado", "erro", err, "caminho", r.URL.Path)
                http.Error(w, "erro interno do servidor", http.StatusInternalServerError)
            }
        }()
        prox.ServeHTTP(w, r)
    })
}

// Middleware de CORS
func comCORS(origemPermitida string, prox http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", origemPermitida)
        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")

        if r.Method == http.MethodOptions {
            w.WriteHeader(http.StatusNoContent)
            return
        }

        prox.ServeHTTP(w, r)
    })
}

// Middleware de autenticação por token
func comAutenticacao(token string, prox http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        auth := r.Header.Get("Authorization")
        if auth != "Bearer "+token {
            http.Error(w, "não autorizado", http.StatusUnauthorized)
            return
        }
        prox.ServeHTTP(w, r)
    })
}

// responseWriter captura o status code
type responseWriter struct {
    http.ResponseWriter
    status int
}

func (rw *responseWriter) WriteHeader(status int) {
    rw.status = status
    rw.ResponseWriter.WriteHeader(status)
}

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

    mux := http.NewServeMux()
    mux.HandleFunc("GET /api/dados", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.Write([]byte(`{"dados":"protegidos"}`))
    })

    // Encadeamento de middlewares — da camada mais externa para a mais interna
    var handler http.Handler = mux
    handler = comAutenticacao("meu-token-secreto", handler)
    handler = comCORS("*", handler)
    handler = comRecuperacao(handler)
    handler = comLogging(logger, handler)

    servidor := &http.Server{
        Addr:         ":8080",
        Handler:      handler,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
    }

    logger.Info("servidor iniciado", "addr", ":8080")
    servidor.ListenAndServe()
}

Lendo parâmetros da requisição

func handlerBusca(w http.ResponseWriter, r *http.Request) {
    // Query parameters: /busca?q=teclado&pagina=2&limite=10
    q := r.URL.Query().Get("q")
    pagina, _ := strconv.Atoi(r.URL.Query().Get("pagina"))
    limite, _ := strconv.Atoi(r.URL.Query().Get("limite"))

    if pagina <= 0 {
        pagina = 1
    }
    if limite <= 0 || limite > 100 {
        limite = 20
    }

    fmt.Fprintf(w, "busca: q=%s pagina=%d limite=%d", q, pagina, limite)
}

func handlerFormulario(w http.ResponseWriter, r *http.Request) {
    // Form data (application/x-www-form-urlencoded)
    if err := r.ParseForm(); err != nil {
        http.Error(w, "formulário inválido", http.StatusBadRequest)
        return
    }

    nome := r.FormValue("nome")
    email := r.FormValue("email")
    fmt.Fprintf(w, "nome=%s email=%s", nome, email)
}

func handlerHeaders(w http.ResponseWriter, r *http.Request) {
    // Lendo headers
    contentType := r.Header.Get("Content-Type")
    userAgent := r.Header.Get("User-Agent")
    token := r.Header.Get("Authorization")

    // Escrevendo headers na resposta
    w.Header().Set("X-Request-ID", "abc-123")
    w.Header().Set("Cache-Control", "no-cache")

    fmt.Fprintf(w, "content-type=%s user-agent=%s token=%s",
        contentType, userAgent, token)
}

Servindo arquivos estáticos

func main() {
    mux := http.NewServeMux()

    // API
    mux.HandleFunc("GET /api/versao", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte(`{"versao":"1.0.0"}`))
    })

    // Arquivos estáticos — serve o diretório "./public"
    fs := http.FileServer(http.Dir("./public"))
    mux.Handle("/static/", http.StripPrefix("/static/", fs))

    http.ListenAndServe(":8080", mux)
}

Encerramento gracioso

package main

import (
    "context"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(100 * time.Millisecond) // simula processamento
        w.Write([]byte("ok"))
    })

    servidor := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    // Inicia em goroutine separada
    go func() {
        slog.Info("servidor iniciado", "addr", ":8080")
        if err := servidor.ListenAndServe(); err != http.ErrServerClosed {
            slog.Error("erro no servidor", "err", err)
            os.Exit(1)
        }
    }()

    // Aguarda sinal de encerramento
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    slog.Info("encerrando servidor...")

    ctx, cancelar := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancelar()

    if err := servidor.Shutdown(ctx); err != nil {
        slog.Error("erro no shutdown", "err", err)
        os.Exit(1)
    }

    slog.Info("servidor encerrado com sucesso")
}

Testando handlers HTTP

package main

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestListarProdutos(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/produtos", nil)
    rr := httptest.NewRecorder()

    listarProdutos(rr, req)

    if rr.Code != http.StatusOK {
        t.Errorf("status esperado %d, obtido %d", http.StatusOK, rr.Code)
    }

    var resultado []Produto
    if err := json.NewDecoder(rr.Body).Decode(&resultado); err != nil {
        t.Fatalf("erro ao decodificar resposta: %v", err)
    }

    if len(resultado) == 0 {
        t.Error("esperava ao menos um produto")
    }
}

func TestBuscarProdutoNaoEncontrado(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/produtos/999", nil)
    req.SetPathValue("id", "999") // simula parâmetro de rota
    rr := httptest.NewRecorder()

    buscarProduto(rr, req)

    if rr.Code != http.StatusNotFound {
        t.Errorf("status esperado %d, obtido %d",
            http.StatusNotFound, rr.Code)
    }
}

O pacote httptest é fundamental para testes de handlers — permite criar requisições e respostas falsas sem iniciar um servidor real.

A biblioteca padrão do Go dá conta de um servidor HTTP de verdade: roteamento com método e parâmetros desde o Go 1.22, middlewares como funções que embrulham handlers, timeouts, encerramento gracioso e testes com httptest. O que ela não faz por você é o que vem da concorrência — cada requisição roda na sua própria goroutine, e qualquer estado compartilhado precisa de sincronização — e o que vem da ordem do protocolo: cabeçalhos antes do status, status antes do corpo. Com essas duas regras presentes, o salto para bibliotecas externas passa a ser uma escolha de conveniência, não de necessidade.

Fontes e leituras recomendadas

Exercícios

Exercício 1

A API de produtos do artigo funciona perfeitamente nos testes manuais, feitos com uma requisição de cada vez. Em produção, durante uma campanha, alguns cadastros somem e dois produtos aparecem com o mesmo ID. Explique os dois defeitos e como um armazenamento em memória deveria ser escrito.

Ver resposta

✓ Resposta: O primeiro defeito é uma corrida de dados. O net/http atende cada requisição numa goroutine própria, e criarProduto, atualizarProduto e deletarProduto alteram a fatia global produtos sem sincronização. Dois append simultâneos podem ler a mesma fatia e gravar por cima um do outro. Num teste com 50 POSTs em paralelo, o -race acusou as corridas e sobraram 46 produtos, em vez de 53. O segundo é a geração de ID por len(produtos) + 1, que só funciona enquanto nada é apagado: depois de uma exclusão, o tamanho diminui e o próximo ID repete um que já existe, o que o mesmo teste reproduziu. A forma correta é encapsular os dados num tipo, por exemplo type Loja struct { mu sync.Mutex; itens map[int]Produto; proximoID int }, com métodos que travam o mutex em toda leitura e escrita e incrementam proximoID, que nunca diminui, sob o mesmo lock. Os handlers recebem a Loja por injeção, e não por variável global, o que também facilita os testes. Com um banco de dados, as duas responsabilidades passam para ele, com transações e chaves geradas pelo próprio banco.

Exercício 2

Com o roteador do Go 1.22, uma API registra GET /produtos/{id} e GET /produtos/novo. Um colega teme que "novo" seja capturado como id. Outro registra GET /a/{x} e GET /{y}/b, e o programa cai ao iniciar. Explique as duas situações e o que acontece com um POST /produtos/7.

Ver resposta

✓ Resposta: O roteador escolhe o padrão mais específico que casa com o caminho, e não o primeiro registrado. /produtos/novo é mais específico que /produtos/{id}, porque todo caminho que casa com o primeiro também casa com o segundo, mas não o contrário. Num teste, /produtos/7 foi para o handler por ID e /produtos/novo para o formulário, independentemente da ordem de registro. No segundo caso, os dois padrões casam com /a/b e nenhum é mais específico que o outro, então o roteador não tem como decidir, e o HandleFunc entra em pânico no registro, com uma mensagem que explica o conflito e dá um exemplo de caminho ambíguo. É uma decisão boa: o erro aparece na inicialização, e não como uma rota errada em produção. Quanto ao POST /produtos/7, o caminho existe, mas só para GET, e o roteador responde sozinho 405 Method Not Allowed com o cabeçalho Allow: GET, HEAD, já que registrar GET também atende HEAD. Antes do Go 1.22, esse tratamento de método era escrito à mão em cada handler, com um switch r.Method.

Exercício 3

Um handler faz w.WriteHeader(201), depois define o cabeçalho Location e escreve o corpo. O cliente recebe 201, mas sem o Location. Em outro handler, o middleware de recuperação chama http.Error(w, ..., 500) depois de um pânico, e o cliente recebe 200. O que os dois casos têm em comum?

Ver resposta

✓ Resposta: Nos dois, algo foi alterado depois de o cabeçalho da resposta já ter sido enviado. O WriteHeader envia a linha de status e os cabeçalhos definidos até aquele momento; a partir daí, w.Header().Set altera um mapa que ninguém mais vai ler, e um segundo WriteHeader é ignorado, com apenas um aviso no log. Num teste com WriteHeader(201), seguido de um Header().Set e de um WriteHeader(500), o cliente recebeu 201 sem o cabeçalho novo, e o servidor registrou http: superfluous response.WriteHeader call. A primeira escrita no corpo tem o mesmo efeito: se nada foi definido, ela envia um 200 implícito. No caso do middleware, o handler já tinha escrito parte da resposta antes do pânico, e o 500 chegou tarde demais. A regra é definir todos os cabeçalhos, depois o status e só então o corpo, que é a ordem do responderJSON do artigo. Para que o middleware de recuperação funcione, o handler precisa montar a resposta antes de começar a escrevê-la, por exemplo gerando o JSON num buffer e só enviando se tudo der certo.

Exercício 4

O middleware comAutenticacao compara o cabeçalho com auth != "Bearer "+token. Um auditor de segurança aponta dois problemas nessa linha, embora ela funcione. Quais são, e como corrigi-los?

Ver resposta

✓ Resposta: O primeiro é o tempo da comparação. A comparação de strings pode parar no primeiro byte diferente, e um atacante que mede com precisão o tempo das respostas consegue, em tese, descobrir o token byte a byte, porque palpites com mais caracteres corretos demoram um pouco mais. A correção é subtle.ConstantTimeCompare([]byte(recebido), []byte(esperado)) == 1, do pacote crypto/subtle, que leva sempre o mesmo tempo para entradas do mesmo tamanho. O segundo é o token fixo no código, "meu-token-secreto", que acaba no controle de versão, é o mesmo em todos os ambientes e não pode ser trocado sem novo deploy. Ele deve vir de uma variável de ambiente ou de um cofre de segredos, lido na inicialização, e o programa deve se recusar a subir se ele estiver vazio. Para APIs com vários clientes, o passo seguinte é abandonar o token compartilhado e usar credenciais por cliente, como JWT assinados ou chaves de API guardadas com hash, de modo que um vazamento possa ser revogado sem derrubar todos.

Exercício 5

O middleware de logging troca o ResponseWriter por um responseWriter próprio, que embute o original e sobrescreve WriteHeader. Depois disso, um handler que faz streaming com w.(http.Flusher).Flush() passa a entrar em pânico. Por quê, e como o Go 1.20 resolveu esse problema de invólucros?

Ver resposta

✓ Resposta: O ResponseWriter real do servidor implementa várias interfaces opcionais além das três obrigatórias, como http.Flusher para enviar dados parciais e http.Hijacker para WebSocket. O invólucro do middleware embute a interface http.ResponseWriter, e embutir uma interface promove só os métodos dela: Header, Write e WriteHeader. O Flush do valor original fica escondido, a asserção w.(http.Flusher) no invólucro falha, e a forma sem vírgula entra em pânico. A solução antiga era o invólucro implementar cada interface opcional e repassar a chamada, o que multiplicava o código e ainda errava quando o original não tinha a interface. O Go 1.20 criou o http.ResponseController: o handler faz http.NewResponseController(w).Flush(), e o controlador procura o método desenrolando os invólucros, desde que cada um ofereça um método Unwrap() http.ResponseWriter que devolva o original. Basta acrescentar esse método de uma linha ao responseWriter do artigo para que Flush, Hijack e os prazos por requisição voltem a funcionar através dele.

Comentários

Mais em Golang

APIs RESTful completas: projeto do zero ao deploy
APIs RESTful completas: projeto do zero ao deploy

API RESTful completa em Go, do zero ao deploy: domínio, migrações, repositório…

Introdução ao Go: história, filosofia e os criadores da linguagem
Introdução ao Go: história, filosofia e os criadores da linguagem

A origem do Go: o build de 45 minutos que motivou Griesemer, Pike e Thompson…

Go com SQLite: banco de dados embutido para aplicações leves
Go com SQLite: banco de dados embutido para aplicações leves

Go com SQLite: modernc e mattn, o stub que compila sem cgo e falha ao rodar…