2 pontos por GN⁺ 2024-02-10 | 1 comentários | Compartilhar no WhatsApp
  • Serviços HTTP em Go que precisam ser mantidos por muito tempo ficam mais fáceis de manter e validar quando são compostos por passagem explícita de dependências, rotas reunidas em um só lugar e uma função run testável
  • Em vez de métodos de uma struct de servidor, crie handlers como funções que retornam http.Handler e recebem os valores necessários via closures; componha middlewares comuns nas etapas de criação do servidor e registro de rotas
  • Manter func main() enxuta e injetar em run() context.Context, argumentos, acesso ao ambiente e entrada/saída padrão simplifica o tratamento de encerramento e o controle em testes
  • Codificação de requisições/respostas, validação, adaptadores de middleware e inicialização preguiçosa com sync.Once reduzem código repetitivo ao mesmo tempo que preservam o fluxo padrão do Go em net/http
  • Os testes tendem a preferir uma abordagem end-to-end, mais próxima de chamadas reais à API, em vez de testar handlers individuais; cada teste sobe seu próprio servidor e verifica o estado de prontidão com /healthz ou /readyz

Criação do servidor e ponto de entrada do serviço

  • O construtor NewServer deve ser a função que cria o http.Handler central do serviço
    • Normalmente há um por serviço, e as rotas internas encaminham as requisições para cada handler
    • Todas as dependências, como logger, configuração, storage e clientes externos, são recebidas como argumentos
    • Sempre que possível, retorna http.Handler; em casos complexos, pode usar um tipo dedicado
    • Depois de configurar seu próprio muxer, passa-o para a função de registro de rotas em routes.go
  • O processamento HTTP comum necessário a todos os endpoints é agrupado em NewServer
    • CORS
    • Middleware de autenticação
    • Logging
    • Middleware de ID de rastreamento
  • Mesmo que a lista de dependências fique longa, a preferência é explicitá-las como argumentos de função
    • Se um campo de uma struct for omitido, o compilador talvez não consiga impedir isso; já uma função não pode ser chamada sem que os valores necessários sejam passados
    • Listas longas de argumentos ficam mais legíveis quando formatadas verticalmente
    • Dependências não usadas em um teste específico são passadas como nil, sinalizando que não serão utilizadas

Reunir a superfície da API em routes.go

  • routes.go deve ser o arquivo em que todas as rotas do serviço podem ser vistas em um só lugar
    • Cada projeto passa a ter um local único para inspecionar a superfície da API
    • Por causa da grande lista de dependências de NewServer, addRoutes pode acabar tendo uma lista de argumentos parecida
    • A checagem de tipos do Go captura argumentos ausentes ou em ordem incorreta
  • addRoutes deve ser mantida simples e plana sempre que possível
    • Operações que podem falhar são tratadas antes, na função run
    • Na etapa de registro de handlers, o foco fica em roteamento com mux.Handle, mux.HandleFunc, http.NotFoundHandler e similares
    • Se o design exigir que o próprio handler retorne erro, addRoutes também pode retornar erro

main deve apenas chamar run

  • func main() deve ser uma função enxuta que chama run() e, se houver erro, escreve em stderr e encerra com falha
    • run recebe como argumentos elementos básicos do sistema operacional, como context.Context, argumentos, entrada/saída e função de acesso ao ambiente
    • Como run retorna erro, é possível tratar erros como em código Go comum
  • Exemplos de valores que podem ser passados para run:
    • os.Args: usado para argumentos de execução do programa e parsing de flags
    • os.Stdin: leitura de entrada
    • os.Stdout: escrita de saída
    • os.Stderr: escrita de logs de erro
    • os.Getenv: leitura de variáveis de ambiente
    • os.Getwd: obtenção do diretório de trabalho atual
  • signal.NotifyContext é configurado dentro de run
    • Quando chega um sinal de encerramento, como Ctrl+C, o contexto é cancelado
    • Se run retornar nil, o encerramento é normal
    • Se retornar erro, main imprime o erro e encerra com código diferente de 0
  • Evitar estado global permite usar t.Parallel() em mais testes
    • Mesmo que run seja chamada várias vezes, cada execução não interfere nas outras
    • Flags são tratadas com flags.NewFlagSet dentro de run, em vez do flag global
    • Variáveis de ambiente são controladas injetando getenv func(string) string, em vez de alterar o ambiente real
    • Diferentemente de t.SetEnv, essa abordagem permite continuar usando testes paralelos

Encerramento e tratamento de prontidão

  • O contexto deve ser propagado por todas as camadas do serviço
    • Quando chega um sinal de encerramento, o contexto é cancelado
    • Tarefas longas ou repetitivas verificam ctx.Err() ou ctx.Done() e interrompem a execução
    • Mesmo ao iniciar outras goroutines, o contexto é usado para decidir quando parar
  • Ao encerrar, o servidor HTTP chama Shutdown para parar de forma graceful
    • No exemplo, uma goroutine separada aguarda ctx.Done()
    • O contexto de encerramento tem timeout de 10 * time.Second
    • Se houver erro durante o encerramento, ele é registrado em stderr
  • Para verificar em testes se o servidor está realmente pronto, exponha endpoints /healthz ou /readyz
    • Também seria possível criar um sinal de prontidão por um canal separado, mas a preferência é confirmar por meio de uma requisição HTTP real
    • O loop de verificação de prontidão faz requisições até receber 200 OK
    • Se o contexto for cancelado ou o timeout for atingido, retorna erro
    • O loop de exemplo espera 250ms entre as requisições

Forma de compor handlers

  • Funções de handler devem retornar http.Handler ou http.HandlerFunc, em vez de implementá-los diretamente
    • Ex.: func handleSomething(logger *Logger) http.Handler
    • É possível criar um ambiente de closure específico para cada handler
    • Valores inicializados podem ser usados durante o processamento das requisições
  • Dados compartilhados são seguros apenas quando usados como somente leitura
    • Se o handler modificar valores, é necessário algum mecanismo de proteção, como mutex
    • Armazenar estado do programa em closures geralmente não é recomendado
  • Em ambientes de cloud, é difícil presumir que uma instância permanecerá viva por muito tempo
    • O servidor pode ser derrubado para economizar recursos ou pode cair por outros motivos
    • Várias instâncias podem estar em execução ao mesmo tempo, e as requisições podem ser distribuídas de formas difíceis de prever
    • Em projetos reais, é melhor manter estado persistente em um banco de dados ou em uma API de armazenamento separada

Codificação/decodificação de requisições/respostas e validação

  • Todo serviço precisa decodificar corpos de requisições e codificar corpos de respostas, então é útil ter helpers encode / decode
    • O exemplo define o Content-Type como JSON, escreve o código de status e então chama json.NewEncoder(w).Encode(v)
    • A decodificação envolve json.NewDecoder(r.Body).Decode(&v) e adiciona contexto ao erro
    • Com generics, é possível inferir o tipo em chamadas como encode(w, r, http.StatusOK, obj)
    • Como decode tem tipo de retorno, é preciso explicitar o tipo esperado, como em decode[CreateSomethingRequest](https://grafana.com/blog/2024/02/09/how-i-write-http-services-in-go-after-13-years/r)
  • Para validação, usa-se uma interface de método único
    • A interface Validator tem a forma Valid(ctx context.Context) map[string]string
    • Se não houver problemas, é retornado um map de tamanho 0
    • Campos problemáticos usam o nome do campo como key e uma descrição legível por humanos como value
  • A validação é adequada para checagens rápidas de campos
    • Se campos obrigatórios não estão vazios
    • Se uma string específica, como e-mail, está no formato correto
    • Se números estão dentro do intervalo permitido
  • Verificações mais complexas, como consultas ao banco de dados, são tratadas em outro local
    • Essas verificações são importantes demais para ficarem escondidas dentro de uma função de validação rápida
    • A versão genérica decodeValid[T Validator] força o tipo T a implementar Validator
    • Chamar len(problems) em um map nil também retorna 0, sem causar panic

Padrão de adaptador de middleware

  • Um middleware recebe um http.Handler e retorna um novo http.Handler
    • Pode executar código antes ou depois de chamar o handler original
    • Também pode deixar de chamar o handler original dependendo de uma condição
    • No exemplo, adminOnly retorna HTTP 404 Not Found quando o usuário não é administrador e não chama o handler original
  • O local de aplicação de middlewares normalmente é routes.go
    • Só de olhar a lista de endpoints, é possível saber quais middlewares estão aplicados a quais rotas
    • Quando a lista de middlewares fica longa, dividi-la em várias linhas melhora a legibilidade
  • Middlewares com muitas dependências são envolvidos por uma função que retorna o middleware
    • newMiddleware(logger, db, slackClient, rroll) retorna func(http.Handler) http.Handler
    • No código de registro de rotas, o uso fica conciso, como middleware(handleSomething(...))
    • Também é possível definir um type middleware func(h http.Handler) http.Handler separado, mas escrever diretamente o tipo de retorno deixa a leitura do código mais clara

Reduzir o escopo dos tipos de requisição/resposta

  • Tipos de requisição/resposta usados apenas em um endpoint específico podem ser definidos dentro da função do handler
    • O namespace global fica mais limpo
    • Isso impede que outros handlers dependam de tipos que não têm garantia de estabilidade
  • Pode haver atrito se o código de teste precisar do mesmo tipo
    • Nesse caso, mover o tipo para fora também é justificável
    • Quando os tipos de requisição/resposta ficam dentro do handler, os testes podem declarar uma nova struct anônima ou um tipo local
  • Tipos locais em testes deixam a intenção explícita
    • Por exemplo, se o endpoint /greet precisa apenas do campo Name, e não de todo Person, a struct de entrada do teste contém apenas Name
    • Quem lê o teste entende imediatamente quais campos interessam àquele endpoint

Inicialização preguiçosa com sync.Once

  • Trabalhos caros durante a preparação de handlers são adiados para o momento da primeira requisição com sync.Once
    • O tempo de inicialização da aplicação diminui
    • Se o handler não for chamado, o trabalho caro não é executado
  • O exemplo faz o parsing de arquivos de template apenas uma vez, na primeira requisição
    • sync.Once garante que o código seja executado somente uma vez
    • Outras requisições simultâneas aguardam até que a inicialização termine
    • A verificação de erro é feita fora de init.Do para que o erro continue vindo à tona
  • Essa abordagem desloca o tempo de inicialização do startup para o primeiro acesso ao endpoint em runtime
    • Em ambientes que usam muito Google App Engine, essa abordagem pode ser adequada
    • Dependendo do ambiente de deploy, é preciso decidir onde e quando usar sync.Once

Estratégia de testes

  • Essa estrutura tem a testabilidade como um objetivo importante
    • A função run permite que o código de teste execute o programa diretamente
    • Os testes são avaliados por critérios como: se tornam o comportamento do programa fácil de entender, se reduzem o medo de quebrar algo ao mudar código e se dão confiança para implantar em produção depois de passarem
  • Também é possível testar apenas handlers de forma isolada
    • Chama-se a função de criação do handler e passam-se as dependências necessárias
    • httptest.NewRecorder e http.NewRequest compõem a requisição e a resposta
    • Código de status, corpo da resposta e headers são verificados
    • Essa abordagem pula middlewares como autenticação e entra diretamente no código do handler
  • A abordagem mais preferida é mais próxima de testes end-to-end
    • Chama-se run para subir o programa de forma próxima à execução real
    • Inclui parsing de argumentos, conexão de dependências, migrações de banco de dados e inicialização do servidor
    • Quando o teste chama a API, todas as camadas e também routes.go são validadas
    • Pode interagir com um banco de dados real
  • Essa abordagem ajuda a reduzir testes repetitivos
    • Testar todas as camadas separadamente pode verificar o mesmo conteúdo várias vezes, com pequenas variações
    • Testes end-to-end fornecem um conjunto central de testes que descreve a interação entre usuário e sistema
    • Testes unitários já criados, por exemplo via TDD, podem ser mantidos quando fizer sentido; se repetirem o mesmo conteúdo dos testes end-to-end, podem ser removidos
  • Cada teste pode executar sua própria instância do programa
    • Cada teste passa argumentos, flags, entrada/saída padrão e variáveis de ambiente diferentes
    • Cria-se uma função de cancelamento com context.WithCancel e registra-se em t.Cleanup(cancel)
    • Quando o teste termina, o contexto é cancelado e o programa é encerrado de forma graceful
    • O t.Cleanup do Go 1.14 é usado como alternativa a escrever defer diretamente

Escopo de aplicação real e contexto organizacional

  • Ao criar APIs simples, esse padrão busca código fácil de ler e de expandir
    • É fácil copiar e expandir o padrão
    • É fácil para pessoas novas trabalharem no código
    • A insegurança ao fazer mudanças diminui
    • A composição é explícita, sem comportamentos mágicos
  • Mesmo usando ferramentas de geração de código, essa abordagem pode ser mantida
    • Como exemplo, é possível usar o pacote Oto para gerar boilerplate baseado em templates
  • Em projetos ou organizações grandes, escolhas tecnológicas existentes podem mudar a decisão
    • Em organizações como a Grafana Labs, certas ferramentas e abstrações já podem ser amplamente usadas
    • gRPC é um exemplo disso
    • Quando há padrões estabelecidos e experiência acumulada, a escolha prática tende a seguir esse fluxo
  • O contexto da família de produtos Grafana IRM também está incluído
    • Grafana IRM é uma família de produtos em construção pela Grafana Labs
    • Grafana Alerting envia alertas quando métricas saem dos limites permitidos
    • Grafana OnCall automatiza o processo de contatar a pessoa adequada com escalas e regras de escalonamento
    • Grafana Incident cria salas no Zoom, canais dedicados no Slack e timelines de eventos, ajudando na resposta a incidentes
    • Itens em canais do Slack que recebem uma reação com emoji de rosto de robô são adicionados à timeline

1 comentários

 
GN⁺ 2024-02-10
Opiniões no Hacker News
  • Também já experimentei a abordagem de ter um validador separado, como um método Valid, mas depois de ler “Parse, Don’t Validate”, da Lexi Lambda [0], passei a achar que usar o verificador de tipos do Go gera muito menos erros
    Por exemplo, se você quiser impedir que um usuário jamais defina um nome de usuário inválido contendo sinais de menor/maior, na abordagem com validador é preciso chamar o validador em todos os caminhos de código em que o nome de usuário vem de uma entrada não confiável
    Em vez disso, se você tiver um tipo Username e um construtor NewUsername(username string) (Username, error), o simples fato de existir um objeto Username já garante que ele passou pela validação
    [0] https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...

    • É surpreendente, de novo, como o código melhora quando se usa bem o sistema de tipos. Não passe tudo como string; faça o parsing e atribua tipos
    • É um bom padrão de design, mas é preciso tomar cuidado com validação cedo demais
      Esse padrão permite fazer a validação tanto cedo quanto tarde, a qualquer momento, mas não diz quando ela deve ser feita. Em geral, muitas vezes é melhor fazê-la como parte do processo de parsing/validação de um objeto maior
      Para a ideia de lidar com dados não validados no contexto de UI, vale consultar “I is for Intent”, de Steven Witten [1]
      [1] https://acko.net/blog/i-is-for-intent/
    • Conceitualmente, é a mesma técnica antiga de construtor privado e método fábrica
    • Links relacionados: Parse, don't validate (2019) - https://news.ycombinator.com/item?id=35053118 - março de 2023, Parse, Don't Validate (2019) - https://news.ycombinator.com/item?id=27639890 - junho de 2021, Parse, Don’t Validate - https://news.ycombinator.com/item?id=21476261 - novembro de 2019, Parse, Don't Validate - https://news.ycombinator.com/item?id=21471753 - novembro de 2019
    • Em Go, era difícil aplicar esse padrão. Se Username estiver incluído dentro de uma struct e você esquecer de definir o valor, entra o zero value, que pode violar as restrições
  • Uma das abordagens que eu mais detesto é receber um objeto Config que representa a configuração do sistema inteiro e passá-lo de forma mutável por todo lado
    Com isso, tudo fica acoplado por meio do objeto de configuração. Em um sistema, alguém escrevia novos valores no objeto de configuração recebido, e cada parte precisava ser configurada em uma ordem específica para funcionar corretamente
    Em outro caso, um subsistema escrevia no objeto de configuração dados que outro leria depois, tornando impossível desativar uma parte do sistema
    O padrão de “configuração como um grande valor mutável” é bastante incômodo não só em Go, mas também em outras linguagens

    • O ponto central não é um objeto genérico de dados de configuração, e sim um objeto de configuração mutável
      Em projetos Python, costumo usar bastante dataclass de configuração imutável e passá-las para vários módulos; quando várias funções dependem de vários valores, em vez de passar cada um como argumento de função e definir tipos separadamente, ter todas as variáveis e definições de tipo em um único dataclass acaba sendo um padrão de design bem conveniente
    • Minha forma favorita de evitar isso é tornar a configuração realmente imutável, mas componível com funções Option
      As options internas só mudam durante a criação, e para fora são expostos apenas Config e acessores. Por exemplo, dá para criar algo como config.New(config.Name("Emanon")) e ler com cfg.Name()
    • Costumo criar uma struct Config para cada pacote, e fazer configs.Config ser uma composição das Config de cada pacote
      Talvez não seja uma prática recomendada em Go, mas no início dá para representar a configuração do sistema inteiro como uma única entidade e, para cada pacote, passar apenas as dependências mínimas necessárias, o que é bom
      Nos testes também fica um pouco mais fácil, porque não é preciso criar uma configuração inteira falsa só para testar um pacote
    • Concordo. No passado, alterei um objeto de configuração de nível superior e acabei causando um incidente grave
      Ele nunca deve ser modificado. Como não dá para saber onde e como é usado, é uma pena o esforço desperdiçado; se uma mudança for necessária, é melhor criar um valor derivado a partir do original
      O engraçado é que, por design, o objeto de configuração era imutável em certa medida, e para modificá-lo era preciso usar uma API WARNING_DO_NOT_USE; mesmo assim, usei aquilo para alterar o objeto e causei o incidente
    • Acho uma crítica válida. Fico curioso para saber qual padrão você considera mais fácil de usar
  • Gosto muito do trabalho do Mat Ryer e, desde então, apliquei à maioria dos meus projetos Go as ideias da versão de 2018 deste texto
    Mas sempre me incomodou o fato de NewServer ser um grande construtor que recebe todas as dependências como argumentos e, nos testes, receber nil para dependências desnecessárias como sinal de que não serão usadas
    O resultado é que grandes partes do código acabam tendo muito estado compartilhado desnecessário. Na prática, há muitos handlers HTTP que só precisam verificar se o usuário da requisição pode acessar um recurso e chamar uma única função do repositório de dados, mas acabam virando parte de um bloco enorme que tem acesso a todos os objetos do servidor pai e ao repositório de dados inteiro
    Mesmo querendo testar mockando só dois métodos, fica difícil escrever testes simples; o padrão do Mat Ryer é o melhor que já vi até hoje, mas ainda fica a sensação de que deve haver uma solução melhor

    • Nos repositórios em que trabalho, fiquei cada vez mais sensível a esses pontos de alto acoplamento aferente, especialmente quanto mais fundo entro no mundo do Bazel, onde o gerenciamento de dependências e o design físico têm impacto maior no código que escrevemos
      Quando possível, plugins são uma boa estratégia de fronteira de código. Uma arquitetura de plugins é, por padrão, opt-out, e só aparece quando é escolhida explicitamente, então não força todas as possibilidades sobre algum bloco de código
      Tenho chamado software com essa característica de “à la carte”. Em geral, devemos evitar a situação de “fazer tudo para poder fazer qualquer coisa”
    • Costumo escrever a maior parte da lógica como pacotes. Por exemplo, se eu fosse criar o HN, teria algo como um pacote users ou um pacote comments
      Esses pacotes não têm nenhuma interface HTTP, mas cada um tem seu próprio main e algum tipo de interface CLI. O comentário //go:build ignore nesses arquivos é útil
    • Simplesmente uso closures
      Em vez de definir um handler como func HandleX(w http.ResponseWriter, req *http.Request), faço algo no formato func HandleX(store *DataStore, dep1 Foo, dep2 Bar, commonDep Common) http.HandlerFunc, recebendo as dependências necessárias e retornando internamente o http.HandlerFunc real
      Depois inicializo isso uma vez no ponto de entrada
    • Isso significa que o objeto criado por NewServer está fazendo coisas demais. É bem possível que tipos de dados e comportamentos demais estejam acoplados
      Como exemplo simples, se você adiciona um logger às dependências do construtor, o objeto passa a fazer um pouco mais do que a implementação simples inicial. Isso em si é aceitável, mas é uma pena se você não encontrar uma forma de fazer logging sem mexer na implementação do que era simples
      Funções de ordem superior, por exemplo um decorador de logger, permitem composição, embora também tenham desvantagens. Ainda assim, é uma forma de estrutura administrável, não um erro
    • Tive essa mesma sensação por muito tempo e agora migrei para uma abordagem com struct de configuração opcional
      A ideia central é validar, dentro de NewServer, os valores da struct de configuração opcional e então copiá-los para a struct do servidor. Assim, é possível mockar menos dependências, e os testes ficam muito mais fáceis
      Também tentei bastante o padrão de opções funcionais, como muita gente recomenda, mas acabei desistindo. Achei um pouco inteligente demais e difícil de ler, além de ter mais boilerplate do que o padrão de struct de configuração + validação e cópia
      [0] https://news.ycombinator.com/item?id=39320170
  • Gostaria que esta ideia fosse mais amplamente aceita em serviços HTTP de qualquer linguagem: se um handler precisa de dependências, ele deve pedi-las diretamente como argumentos, e não ficar como um método pendurado em uma struct de servidor que cria dependências-surpresa na hora de testar
    Handlers de serviços HTTP geralmente contêm muita lógica de negócio, e essa lógica provavelmente terá muitas dependências. Na prática, vejo com frequência um único handler usando DB, cache, armazenamento de blobs, verificação de autorização específica do endpoint, verificador de licenças, fila, logger especial, cliente de métricas etc.
    O número de parâmetros pode chegar a 9 ou mais, e linters ou regras práticas normalmente tentam impedir isso, mas as dependências não desaparecem; elas apenas ficam escondidas na classe/struct server, enganando você com uma assinatura de método curta que parece ter poucas dependências
    Com o tempo, passei a achar melhor um código em que todas as dependências apareçam na assinatura da função/método, mesmo que virem 20. Assim, você não se engana sobre o aumento da complexidade do código

    • Sempre deixo os handlers como structs individuais com um método para tratar a rota/requisição
      Por exemplo, coloco em uma struct CreateUser apenas as dependências necessárias para aquela operação, como store, cache, logger, pub, e implemento ServeHTTP
      Em main.go ou no lugar que configura as dependências, crio cada operação e passo apenas as dependências necessárias. Gosto disso porque permite manter os métodos auxiliares de uma operação/handler específica como métodos privados daquela struct
      Porém, se uma operação precisa de outra, pode ficar trabalhoso, porque você começa a passá-las entre si ou precisa extraí-las para um pacote/serviço separado
    • Não precisa necessariamente haver 9 ou mais argumentos separados. Em algumas linguagens, pode ser um único objeto de contexto/env contendo apenas o que o handler precisa
      Por exemplo, usando algo como handleHello({ db, cache, blobStore, authz }, req, res), se dois handlers usarem exatamente o mesmo contexto, ele pode ser reutilizado, e também fica fácil declarar, no ponto de chamada, o contexto específico de cada handler
  • Concordo com grande parte deste texto e gostaria de acrescentar alguns pontos
    Se você passar um WaitGroup junto com o contexto da aplicação para a struct do serviço, uma interrupção pode acionar o encerramento da aplicação por meio do contexto, e a goroutine principal pode aguardar o WaitGroup antes de realmente sair
    Em um programa CLI, é útil testar stdout, stdin, stderr, args, env etc., mas acho que isso vale menos para um servidor HTTP. Eu passaria uma configuração estruturada para a função run, para que os testes fiquem mais focados
    Sou contra a ideia de fazer parsing de templates no handler com sync.Once. Na minha visão, o handler não deveria fazer parsing de templates; isso deve acontecer na inicialização da aplicação. Se os templates não puderem ser parseados, a aplicação não deve estar pronta para receber requisições e deve terminar com código de saída diferente de 0

    • O primeiro ponto é interessante. Fico me perguntando se isso não é um problema resolvido por propagação de contexto e espera pelo encerramento do servidor
  • Recentemente tenho brincado com ogen: https://github.com/ogen-go/ogen
    Você escreve uma definição OpenAPI, e ele cuida de roteamento, definições de structs, validação de schema JSON etc. Minha única tarefa é implementar o serviço
    Coisas como validação de faixa de inteiros em query string são muito tediosas, e, se eu escrever à mão, é fácil demais cometer typos
    Ainda estou na fase de experimentar, então não encontrei pontos ruins

    • O problema dessa abordagem é que escrever OpenAPI à mão desde o começo é extremamente tedioso
      Escrever uma IDL parecida, como Protobuf ou capnproto, parece muito mais produtivo
    • Se você prefere exportar a especificação OpenAPI a partir de código Go, danielgtaylor/huma[1] e swaggest/rest[2] também são boas opções
      [1] https://github.com/danielgtaylor/huma
      [2] https://github.com/swaggest/rest
    • De forma parecida, comecei com oapigen: github.com/deepmap/oapi-codegen
      Achei que escrever a especificação seria tedioso, mas foi bem melhor do que eu esperava; de qualquer forma, como a especificação é necessária, acho melhor escrevê-la antes
  • Sinto que fx(https://github.com/uber-go/fx) é uma ferramenta muito simples e versátil para projetar aplicações
    Os conselhos do texto continuam úteis, mas ela elimina completamente a parte de “como garantir que X esteja inicializado quando Y precisar dele”. O problema N*M vira um problema N: você só precisa se preocupar em como inicializar cada peça, sem se preocupar com a sincronização da inicialização entre elas
    Já usei bastante bibliotecas de injeção de dependência em várias linguagens e também já implementei as minhas, mas a simplicidade e a generalidade do fx são as que mais me agradaram até agora

    • Eu realmente detesto esses frameworks de injeção de dependência
      Em um sistema bem projetado, esse problema deveria ser trivial. Garantir que algo esteja inicializado quando você quiser usá-lo é uma questão de ele estar pronto para ser passado como argumento do construtor
      Basta criar algo como stockService := NewStockService(), orderService := NewOrderService(), orderProcessor := NewOrderProcessor(stockService, orderService)
      Não deveria haver necessidade de “sincronização” de inicialização, e, se você errar, não compila. Mesmo ao adicionar uma dependência circular, isso fica claro porque não dá para compor tudo na ordem correta
  • É um ótimo texto, com muitas ideias interessantes. Não acredito que eu não conhecia signal.NotifyContext
    Agora vou conseguir lembrar como fazer tratamento de sinais sem copiar e colar em todo projeto

  • Gosto bastante da abordagem usada aqui, mas meus testes são um pouco diferentes
    Em newTestServer(), subo um servidor com dependências falsas injetadas e, se quero testar um erro de dependência, troco aquela propriedade por uma fake que retorna erro
    Assim consigo verificar caminhos de erro, entradas de log, emissão de métricas, timeouts e até graceful shutdown
    Depois que o servidor inicia, verifico em qual porta ele fez bind. Como o padrão é :0, é preciso esperar pela porta realmente alocada
    Testes “unitários” podem ser feitos no nível do handler ou no nível HTTP, passando por todo o middleware ou por nenhum, e ainda assim testar suficientemente o código da forma como o usuário o verá. Também dá para subir N instâncias e testá-las em paralelo

  • Não uso Go, mas gosto desses padrões. Eles parecem se aplicar de forma bem universal a código testável
    Não quero mais ver guias de início rápido, especialmente de Python, que tratam dependências de forma implícita/estática/impossível de testar