2 puntos por GN⁺ 2024-02-10 | 1 comentarios | Compartir por WhatsApp
  • Los servicios HTTP en Go que se mantienen por mucho tiempo son más fáciles de mantener y verificar cuando se construyen con inyección explícita de dependencias, rutas reunidas en un solo lugar y una función run que se puede probar
  • En lugar de métodos de una estructura de servidor, los handlers se crean como funciones que devuelven http.Handler y reciben por cierre los valores que necesitan; el middleware compartido se compone en la creación del servidor y en el registro de rutas
  • Mantener func main() liviana e inyectar en run() un context.Context, argumentos, acceso al entorno y entrada/salida estándar simplifica el manejo del apagado y el control en pruebas
  • Helpers para codificar/decodificar solicitudes y respuestas, validación, adaptadores de middleware e inicialización diferida con sync.Once reducen código repetido sin salir del flujo estándar de net/http en Go
  • Para pruebas, se prefiere un enfoque end-to-end más cercano a llamadas reales de API que probar handlers individuales; cada prueba levanta su propio servidor y verifica su disponibilidad con /healthz o /readyz

Creación del servidor y punto de entrada del servicio

  • El constructor NewServer se define como la función que crea el http.Handler principal del servicio
    • Normalmente hay uno por servicio, y las rutas internas desvían cada solicitud a su handler correspondiente
    • Recibe como argumentos todas las dependencias, como logger, configuración, repositorios y clientes externos
    • Siempre que sea posible, conviene devolver un http.Handler; en casos más complejos puede usarse un tipo dedicado
    • Configura su propio muxer y luego lo pasa a una función de registro de rutas en routes.go
  • Todo el procesamiento HTTP común a todos los endpoints se agrupa en NewServer
    • CORS
    • middleware de autenticación
    • logging
    • middleware de ID de trazabilidad
  • Aunque la lista de dependencias sea larga, se prefiere hacerla explícita como argumentos de función
    • Si se omite un campo de una estructura, el compilador podría no impedirlo, pero una función no se puede llamar si no recibe los valores necesarios
    • Una lista larga de argumentos sigue siendo legible si se formatea en vertical
    • Las dependencias que no se usan en una prueba específica se pasan como nil para indicar que no deberían utilizarse

Reunir la superficie de la API en routes.go

  • routes.go se mantiene como el archivo donde se pueden ver todas las rutas del servicio en un solo lugar
    • Cada proyecto gana un punto único para recorrer la superficie de la API
    • Debido a la gran lista de dependencias en NewServer, addRoutes también puede terminar con una lista similar
    • La verificación de tipos de Go detecta argumentos faltantes o en orden incorrecto
  • Siempre que sea posible, addRoutes debe mantenerse simple y plana
    • Todo lo que pueda fallar se resuelve antes en la función run
    • En la etapa de registro, el enfoque debe estar en el ruteo con mux.Handle, mux.HandleFunc y http.NotFoundHandler
    • Si el diseño exige que los handlers puedan devolver errores, addRoutes también puede devolverlos

main solo llama a run

  • func main() se deja como una función ligera que llama a run(), escribe el error en stderr si ocurre y termina de forma anormal
    • run recibe como argumentos un context.Context, argumentos, entrada/salida y funciones de acceso al entorno, es decir, los elementos base que ofrece el sistema operativo
    • Como run devuelve un error, se puede manejar como cualquier otro error en Go
  • Ejemplos de valores que pueden pasarse a run
    • os.Args: para argumentos del programa y parsing de flags
    • os.Stdin: para leer entrada
    • os.Stdout: para escribir salida
    • os.Stderr: para escribir logs de error
    • os.Getenv: para leer variables de entorno
    • os.Getwd: para obtener el directorio de trabajo actual
  • signal.NotifyContext se configura dentro de run
    • Si entra una señal de salida como Ctrl+C, el contexto se cancela
    • Si run devuelve nil, el proceso termina normalmente
    • Si devuelve un error, main lo imprime y sale con un código distinto de 0
  • Evitar estado global permite usar t.Parallel() en más pruebas
    • Incluso si run se llama varias veces, cada ejecución no interfiere con las demás
    • Los flags se manejan con flags.NewFlagSet dentro de run en lugar del flag global
    • Las variables de entorno se controlan inyectando getenv func(string) string en vez de modificar el entorno real
    • A diferencia de t.SetEnv, esto permite seguir usando pruebas en paralelo

Manejo del apagado y estado de disponibilidad

  • El contexto debe propagarse a todas las capas del servicio
    • Cuando llega una señal de salida, el contexto se cancela
    • Los trabajos largos o repetitivos deben revisar ctx.Err() o ctx.Done() y detenerse
    • Incluso si se lanzan otras goroutines, el contexto sirve para decidir cuándo deben parar
  • Al cerrar el servidor HTTP, se llama a Shutdown para detenerlo de forma ordenada
    • En el ejemplo, una goroutine separada espera ctx.Done()
    • El contexto de cierre usa un timeout de 10 * time.Second
    • Si ocurre un error durante el cierre, se registra en stderr
  • Para confirmar en pruebas que el servidor realmente está listo, se exponen endpoints como /healthz o /readyz
    • También podría usarse una señal de disponibilidad por otro canal, pero se prefiere comprobarlo con una solicitud HTTP real
    • El bucle de verificación sigue haciendo solicitudes hasta recibir 200 OK
    • Si se cancela el contexto o se alcanza el timeout, devuelve un error
    • En el ejemplo, el bucle espera 250ms entre intentos

Forma de componer handlers

  • En vez de implementar directamente http.Handler o http.HandlerFunc, las funciones handler los devuelven
    • Ejemplo: func handleSomething(logger *Logger) http.Handler
    • Esto permite construir un entorno de cierre específico para cada handler
    • Los valores inicializados pueden reutilizarse al procesar solicitudes
  • Es más seguro usar los datos compartidos solo en modo lectura
    • Si un handler modifica valores, se necesitan protecciones como un mutex
    • En general no se recomienda guardar el estado del programa dentro del cierre
  • En entornos cloud no conviene asumir que una instancia vivirá mucho tiempo
    • El servidor puede apagarse para ahorrar recursos o caerse por otros motivos
    • Puede haber varias instancias ejecutándose al mismo tiempo y distribuyendo solicitudes de forma impredecible
    • El estado persistente real del proyecto conviene guardarlo en una base de datos o en una API de almacenamiento separada

Codificación/decodificación de solicitudes y respuestas, y validación

  • Como todo servicio necesita decodificar el cuerpo de las solicitudes y codificar el de las respuestas, se definen helpers encode / decode
    • El ejemplo establece el Content-Type de JSON, escribe el código de estado y luego llama a json.NewEncoder(w).Encode(v)
    • La decodificación envuelve json.NewDecoder(r.Body).Decode(&v) y agrega contexto al error
    • Con genéricos, se puede inferir el tipo en llamadas como encode(w, r, http.StatusOK, obj)
    • Como decode devuelve un tipo, hay que especificar el tipo esperado, por ejemplo decode[CreateSomethingRequest](https://grafana.com/blog/2024/02/09/how-i-write-http-services-in-go-after-13-years/r)
  • Para validación se usa una interfaz de un solo método
    • La interfaz Validator tiene la forma Valid(ctx context.Context) map[string]string
    • Si no hay problemas, se devuelve un map de longitud 0
    • Si los hay, el nombre del campo va como key y una descripción legible por personas como value
  • Lo que se valida aquí son revisiones rápidas de campos
    • que los campos requeridos no estén vacíos
    • que una cadena específica, como un correo, tenga el formato correcto
    • que un número esté dentro del rango permitido
  • Las validaciones más complejas, como consultas a base de datos, se resuelven en otro lugar
    • Ese tipo de validaciones son demasiado importantes para ocultarlas dentro de una función de validación rápida
    • La versión genérica decodeValid[T Validator] obliga a que el tipo T implemente Validator
    • Llamar a len(problems) sobre un map nil devuelve 0, así que no produce panic

Patrón adaptador de middleware

  • El middleware recibe un http.Handler y devuelve un nuevo http.Handler
    • Puede ejecutar código antes y después de llamar al handler original
    • Según la condición, incluso puede no llamar al handler original
    • En el ejemplo, adminOnly devuelve HTTP 404 Not Found si no es administrador y no llama al handler original
  • El lugar habitual para aplicar middleware es routes.go
    • Con solo ver la lista de endpoints ya se entiende qué middleware tiene cada ruta
    • Si la lista de middleware crece, puede dividirse en varias líneas para mejorar la lectura
  • Cuando un middleware tiene muchas dependencias, se envuelve en una función que devuelve middleware
    • newMiddleware(logger, db, slackClient, rroll) devuelve func(http.Handler) http.Handler
    • En el registro de rutas se usa de forma compacta, como middleware(handleSomething(...))
    • También podría definirse un type middleware func(h http.Handler) http.Handler, pero usar el tipo de retorno explícito hace el código más claro de leer

Reducir el alcance de los tipos de solicitud/respuesta

  • Los tipos de solicitud o respuesta que solo se usan en un endpoint específico pueden definirse dentro de la función handler
    • Así se mantiene limpio el namespace global
    • También evita que otros handlers dependan de tipos cuya estabilidad no está garantizada
  • Esto puede generar fricción si el mismo tipo hace falta en las pruebas
    • En ese caso, es válido mover el tipo hacia afuera
    • Si el tipo queda dentro del handler, en las pruebas se puede declarar una nueva estructura anónima o un tipo local
  • Los tipos locales en pruebas hacen más explícita la intención
    • Por ejemplo, si el endpoint /greet solo necesita el campo Name y no un Person completo, la estructura de entrada de la prueba puede tener solo Name
    • Quien lea la prueba entiende de inmediato qué campos le importan a ese endpoint

Diferir la inicialización con sync.Once

  • Si durante la preparación del handler hay trabajo costoso, puede aplazarse hasta la primera solicitud usando sync.Once
    • Esto reduce el tiempo de arranque de la aplicación
    • Si nunca se llama al handler, el trabajo costoso nunca se ejecuta
  • En el ejemplo, el parseo del archivo de plantilla se hace una sola vez en la primera solicitud
    • sync.Once garantiza que el código se ejecute una sola vez
    • Otras solicitudes concurrentes esperan a que termine la inicialización
    • La revisión del error se hace fuera de init.Do para que el error siga saliendo a la superficie
  • Este enfoque mueve el tiempo de inicialización desde el arranque al primer acceso en runtime a ese endpoint
    • En entornos donde se usa mucho Google App Engine, este enfoque puede encajar bien
    • Según el entorno de despliegue, hay que decidir dónde y cuándo usar sync.Once

Estrategia de pruebas

  • Esta estructura pone la facilidad de prueba como un objetivo importante
    • La función run permite ejecutar directamente el programa desde el código de prueba
    • Las pruebas se evalúan según si facilitan entender el comportamiento del programa, reducen el temor a romper algo al cambiarlo y dan confianza para desplegar a producción después de pasar
  • También es posible probar handlers de forma aislada
    • Se llama a la función que crea el handler y se le pasan las dependencias necesarias
    • Se construyen solicitud y respuesta con httptest.NewRecorder y http.NewRequest
    • Luego se validan el código de estado, el cuerpo de la respuesta y los headers
    • Este enfoque entra directo al código del handler, saltándose middleware como autenticación
  • Pero se prefiere más un enfoque cercano a pruebas end-to-end
    • Se llama a run para levantar el programa de una forma cercana a su ejecución real
    • Incluye parsing de argumentos, conexión de dependencias, migraciones de base de datos e inicio del servidor
    • Cuando la prueba llama a la API, se validan todas las capas junto con routes.go
    • Incluso puede interactuar con una base de datos real
  • Este enfoque ayuda a reducir pruebas repetitivas
    • Si cada capa se prueba por separado, el mismo contenido puede verificarse varias veces de formas ligeramente distintas
    • Las pruebas end-to-end ofrecen un conjunto central de pruebas que describen la interacción entre el usuario y el sistema
    • Las pruebas unitarias ya existentes, por ejemplo surgidas de TDD, pueden mantenerse si siguen siendo útiles, pero si repiten lo mismo que las end-to-end, pueden eliminarse
  • Cada prueba puede ejecutar su propia instancia del programa
    • En cada prueba se pasan distintos argumentos, flags, entrada/salida estándar y variables de entorno
    • Se crea una función de cancelación con context.WithCancel y se registra en t.Cleanup(cancel)
    • Al terminar la prueba, el contexto se cancela y el programa se apaga de forma ordenada
    • t.Cleanup de Go 1.14 se usa como alternativa a escribir defer manualmente

Alcance real de aplicación y contexto organizacional

  • Al crear una API simple, este patrón apunta a un código fácil de leer y de ampliar
    • Es fácil copiar el patrón y extenderlo
    • Facilita que gente nueva trabaje sobre él
    • Reduce la ansiedad al hacer cambios
    • Todo se arma de forma explícita, sin comportamiento mágico
  • Incluso usando herramientas de generación de código, este enfoque puede mantenerse
    • Como ejemplo, para generar boilerplate basado en plantillas puede usarse Oto package
  • En proyectos grandes o en organizaciones grandes, las decisiones pueden cambiar según la tecnología ya adoptada
    • En organizaciones como Grafana Labs, ciertas herramientas y abstracciones pueden estar ampliamente establecidas
    • gRPC es un ejemplo de eso
    • Cuando ya existen patrones consolidados y experiencia acumulada, seguir ese flujo puede ser la elección más práctica
  • También se incluye el contexto de la línea de productos Grafana IRM
    • Grafana IRM es una línea de productos que Grafana Labs está construyendo
    • Grafana Alerting envía alertas cuando una métrica sale del rango permitido
    • Grafana OnCall automatiza el proceso de contactar a la persona adecuada mediante horarios y reglas de escalamiento
    • Grafana Incident crea una sala de Zoom, un canal dedicado de Slack y una línea de tiempo de eventos para ayudar en la respuesta a incidentes
    • Los elementos marcados con una reacción de emoji de cara de robot en un canal de Slack se agregan a la línea de tiempo

1 comentarios

 
GN⁺ 2024-02-10
Opiniones de Hacker News
  • También probé el enfoque de tener un validador aparte, como un método Valid, pero desde que leí “Parse, Don’t Validate” [0] de Lexi Lambda, siento que aprovechar el verificador de tipos de Go produce muchos menos errores
    Por ejemplo, si quieres asegurarte de que un usuario nunca pueda especificar un nombre de usuario inválido que contenga corchetes angulares, con el enfoque del validador tienes que llamar al validador en todas las rutas de código donde el nombre de usuario provenga de una entrada no confiable
    En cambio, si tienes un tipo Username y un constructor NewUsername(username string) (Username, error), el simple hecho de que exista un objeto Username ya te garantiza que pasó la validación
    [0] https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...

    • Sigue siendo sorprendente que usar bien el sistema de tipos mejore el código. No hay que pasar todo como string; hay que parsearlo y asignarle un tipo
    • Es un buen patrón de diseño, pero hay que tener cuidado con la validación demasiado temprana
      Este patrón permite hacer la validación tan temprano o tan tarde como se quiera, pero no indica cuándo conviene hacerla. Por lo general, muchas veces lo mejor es hacerla como parte del proceso de parsear/validar un objeto más grande
      Para la idea de manejar datos no validados en el contexto de una UI, vale la pena consultar “I is for Intent” [1] de Steven Witten
      [1] https://acko.net/blog/i-is-for-intent/
    • Conceptualmente, es lo mismo que la vieja técnica del constructor privado y el método factory
    • Enlaces relacionados: Parse, don't validate (2019) - https://news.ycombinator.com/item?id=35053118 - marzo de 2023, Parse, Don't Validate (2019) - https://news.ycombinator.com/item?id=27639890 - junio de 2021, Parse, Don’t Validate - https://news.ycombinator.com/item?id=21476261 - noviembre de 2019, Parse, Don't Validate - https://news.ycombinator.com/item?id=21471753 - noviembre de 2019
    • En Go era difícil aplicar este patrón. Si Username está incluido dentro de una estructura y olvidas establecer el valor, termina entrando un zero value que puede violar las restricciones
  • Uno de los patrones que más detesto es recibir un objeto Config que representa la configuración de todo el sistema y pasarlo de un lado a otro de forma mutable
    Al hacer eso, todo queda acoplado a través del objeto de configuración. En un sistema, alguien volvía a escribir valores en el objeto de configuración que recibía, de modo que, para que todo funcionara correctamente, cada parte tenía que configurarse en un orden específico
    En otro caso, un subsistema escribía en el objeto de configuración datos que otro leería después, lo que hacía imposible desactivar una parte del sistema
    El patrón de “la configuración es un único gran valor mutable” es bastante molesto no solo en Go, sino también en otros lenguajes

    • El punto clave no es un objeto genérico de datos de configuración, sino un objeto de configuración mutable
      En proyectos de Python se usan mucho dataclass de configuración inmutables y se pasan a varios módulos; cuando varias funciones dependen de varios valores, en vez de pasar cada uno como argumento de función y definir tipos por separado, tener todas las variables y definiciones de tipos en un solo dataclass termina siendo un patrón de diseño bastante práctico
    • Mi forma favorita de evitar esto es hacer que la configuración sea realmente inmutable, pero componible mediante funciones Option
      Las options internas solo se modifican durante la creación, y hacia afuera solo se exponen Config y sus accesores. Por ejemplo, se podría crear con config.New(config.Name("Emanon")) y leer con cfg.Name()
    • Suelo crear una estructura Config por cada paquete, y hacer que configs.Config sea una recopilación de las Config de cada paquete
      Quizá no sea una buena práctica en Go, pero al inicio permite construir la configuración completa del sistema como una sola entidad, y pasar a cada paquete solo las dependencias mínimas que necesita
      También facilita un poco las pruebas, porque ya no hace falta simular toda la configuración solo para probar un paquete
    • De acuerdo. Una vez causé un incidente grave por modificar un objeto de configuración de nivel superior
      Nunca debe modificarse. No se sabe dónde ni cómo se usa, así que es una lástima todo el costo humano que se desperdicia; si hace falta un cambio, es mejor crear un valor derivado del original
      Lo gracioso es que, por diseño, el objeto de configuración era en cierto modo inmutable, y para modificarlo había que usar una API WARNING_DO_NOT_USE, pero aun así la usé para cambiar el objeto y terminé causando el incidente
    • Me parece una crítica válida. Me da curiosidad qué patrón consideran más cómodo de usar
  • Me gusta mucho el trabajo de Mat Ryer, y desde entonces he aplicado la mayoría de las ideas de la versión de 2018 de este artículo a todos mis proyectos en Go.
    Pero siempre me incomodó que NewServer fuera un constructor grande que recibe todas las dependencias como argumentos, y que en las pruebas se pase nil para señalar que una dependencia no se necesita.
    Como resultado, una gran parte del código termina con mucho estado compartido innecesario. En la práctica, hay muchos handlers HTTP que solo necesitan verificar si el usuario de la solicitud puede acceder a un recurso y llamar a una única función del almacén de datos, pero terminan siendo parte de un bloque enorme con acceso a todos los objetos del servidor padre y a todo el almacén de datos.
    Incluso si solo quieres mockear dos métodos para probar, es difícil escribir una prueba simple; el patrón de Mat Ryer es el mejor que he visto hasta ahora, pero queda la sensación de que debe haber una solución mejor.

    • En el repositorio donde trabajo me volví cada vez más sensible a estos puntos de alto acoplamiento aferente, y especialmente mientras más me meto en el mundo de Bazel, más grande es el impacto de la gestión de dependencias y del diseño físico sobre el código que escribo.
      Si es posible, los plugins son una buena estrategia de límites de código. Una arquitectura de plugins está, por defecto, desactivada, y solo se hace visible cuando se opta explícitamente por usarla, así que no obliga a ningún bloque de código a cargar con todas las posibilidades.
      A este tipo de software lo estoy llamando “a la carta”. En general, hay que evitar la situación de “hacer todo para poder hacer cualquier cosa”.
    • Suelo escribir la mayor parte de la lógica como paquetes. Por ejemplo, si estuviera creando HN, tendría algo como un paquete users o un paquete comments.
      Estos paquetes no tienen ninguna interfaz HTTP, pero cada uno tiene su propio main y una especie de interfaz CLI. El comentario de archivo //go:build ignore resulta útil para eso.
    • Simplemente uso closures.
      En vez de definir un handler como func HandleX(w http.ResponseWriter, req *http.Request), hago que reciba las dependencias necesarias con una forma como func HandleX(store *DataStore, dep1 Foo, dep2 Bar, commonDep Common) http.HandlerFunc y que devuelva internamente el http.HandlerFunc real.
      Luego lo inicializo una sola vez en el punto de entrada.
    • Eso significa que el objeto creado por NewServer está haciendo demasiadas cosas. Es muy probable que se estén acoplando demasiados tipos de datos y comportamientos.
      Como ejemplo simple, si agregas un logger como dependencia del constructor, el objeto pasa a hacer un poco más que la implementación simple original. Eso en sí está bien, pero es una lástima si no puedes encontrar una forma de registrar logs sin modificar la implementación de lo simple.
      Las funciones de orden superior, por ejemplo un decorador de logger, permiten la composición, aunque también tienen desventajas. Aun así, son una forma de estructura manejable, no un error.
    • Durante mucho tiempo tuve la misma sensación, y ahora me moví a un enfoque con una estructura de configuración opcional.
      La idea central es validar los valores de la estructura de configuración opcional dentro de NewServer y luego copiarlos a la estructura del servidor. Así se pueden mockear menos dependencias y las pruebas se vuelven mucho más fáciles.
      También probé mucho el patrón de opciones funcionales, como recomiendan varias personas, pero terminé abandonándolo. Me pareció un poco demasiado ingenioso y difícil de leer, y con más boilerplate que el patrón de estructura de configuración + validación y copia.
      [0] https://news.ycombinator.com/item?id=39320170
  • Ojalá esta idea se aceptara más ampliamente para servicios HTTP en cualquier lenguaje: si un handler necesita dependencias, debería pedirlas directamente como argumentos, no quedar como un método colgado de una estructura de servidor que introduce dependencias sorpresa al probar.
    Los handlers de un servicio HTTP suelen contener bastante lógica de negocio, y es probable que esa lógica tenga muchas dependencias. En la práctica, veo con frecuencia un solo handler que usa una DB, caché, almacenamiento de blobs, verificación de permisos específica del endpoint, verificador de licencias, cola, logger especial, cliente de métricas, etc.
    Puede haber 9 o más parámetros, y los linters o las reglas empíricas suelen intentar impedirlo, pero las dependencias no desaparecen: solo se esconden en la clase/estructura server y se finge que hay pocas dependencias porque la firma del método es corta.
    Con el tiempo, aunque lleguen a ser 20, me parece mejor el código donde todas las dependencias aparecen en la firma de la función/método. Así no se disimula el crecimiento de la complejidad del código.

    • Siempre pongo los handlers como estructuras individuales con un método que maneja la ruta/solicitud.
      Por ejemplo, dentro de una estructura CreateUser pongo solo las dependencias necesarias para esa operación, como store, cache, logger, pub, e implemento ServeHTTP.
      En main.go o donde se configuran las dependencias, creo cada operación y le paso solo las dependencias que necesita. Esto es bueno porque permite mantener los métodos helper de una operación/handler específico como métodos privados de esa estructura.
      Pero si una operación necesita otra, puede volverse engorroso porque empiezas a pasarlas entre sí o tienes que extraerlas a un paquete/servicio separado.
    • No necesariamente tienen que ser 9 o más argumentos separados. En algunos lenguajes puede ser un único objeto context/env que contenga solo lo que el handler necesita.
      Por ejemplo, si escribes algo como handleHello({ db, cache, blobStore, authz }, req, res), puedes reutilizarlo cuando dos handlers usan exactamente el mismo contexto, y también es fácil declarar el contexto de cada handler en el punto de llamada.
  • Estoy de acuerdo con gran parte de este artículo y quisiera agregar algunas cosas más.
    Si pasas un WaitGroup junto con el contexto de la app a la estructura del servicio, una interrupción puede activar el cierre de la app a través del contexto y la goroutine principal puede esperar el WaitGroup antes de salir realmente.
    En un programa CLI es útil probar stdout, stdin, stderr, args, env, etc., pero en un servidor HTTP me parece menos relevante. A la función run le pasaría una configuración estructurada para que las pruebas queden más enfocadas.
    Me opongo a parsear plantillas en el handler con sync.Once. Creo que el handler no debería parsear plantillas; eso debería hacerse al iniciar la app. Si las plantillas no se pueden parsear, la app no debería quedar lista para recibir solicitudes y debería terminar con un código de salida distinto de 0.

    • El primer punto es interesante. Me pregunto si no es un problema que se resuelve con la propagación de contexto y esperando el cierre del servidor.
  • Últimamente estuve jugando con ogen: https://github.com/ogen-go/ogen
    Si escribes una definición OpenAPI, se encarga del routing, las definiciones de structs, la validación de esquemas JSON, etc. Lo único que me toca hacer es implementar el servicio.
    Cosas como validar rangos de enteros en query strings son demasiado tediosas y, si las escribes a mano, es facilísimo cometer errores de tipeo.
    Todavía estoy en etapa de probarlo, así que no he encontrado partes malas.

    • El problema de este enfoque es que escribir OpenAPI a mano desde cero es tremendamente tedioso.
      Escribir IDL similares, como Protobuf o capnproto, se siente mucho más productivo.
    • Si prefieres exportar la especificación OpenAPI desde código Go, danielgtaylor/huma[1] y swaggest/rest[2] también están bien.
      [1] https://github.com/danielgtaylor/huma
      [2] https://github.com/swaggest/rest
    • De forma parecida, empecé con oapigen: github.com/deepmap/oapi-codegen
      Pensé que escribir la especificación sería tedioso, pero resultó bastante mejor de lo esperado y, como de todos modos necesitas la especificación, creo que es mejor escribirla de antemano.
  • Siento que fx (https://github.com/uber-go/fx) es una herramienta muy simple y versátil para diseñar aplicaciones.
    Los consejos del artículo siguen siendo útiles, pero elimina por completo la parte de “¿cómo garantizo que X esté inicializado cuando Y lo necesita?”. El problema N*M se reduce a un problema N: solo tienes que preocuparte por cómo inicializar cada pieza, sin tener que preocuparte por sincronizar la inicialización entre las piezas.
    He usado bastantes bibliotecas de inyección de dependencias en varios lenguajes e incluso he implementado algunas, pero la simplicidad y generalidad de fx es lo que más me ha gustado hasta ahora.

    • Realmente detesto estos frameworks de inyección de dependencias.
      En un sistema bien diseñado, este problema debería ser trivial. Garantizar que algo esté inicializado cuando quieres usarlo es una cuestión de que esté listo para pasarlo como argumento al constructor.
      Puedes hacerlo así: stockService := NewStockService(), orderService := NewOrderService(), orderProcessor := NewOrderProcessor(stockService, orderService).
      No debería hacer falta ninguna “sincronización” de inicialización y, si te equivocas, no compila. Si agregas una dependencia circular, también queda claro porque no puedes construirlo en el orden correcto.
  • Es un gran artículo con muchas ideas interesantes. No puedo creer que no conociera signal.NotifyContext.
    Ahora podré recordar cómo hacer manejo de señales sin copiar y pegarlo en cada proyecto.

  • Me gusta mucho el enfoque que se usa aquí, pero mis pruebas son un poco distintas.
    En newTestServer() levanto un servidor con dependencias falsas y, si quiero probar un error de dependencia, cambio esa propiedad por un fake que devuelve un error.
    Así puedo verificar rutas de error, entradas de log, emisión de métricas, timeouts e incluso graceful shutdown.
    Después de que el servidor arranca, reviso a qué puerto quedó enlazado, porque el valor predeterminado es :0 y hay que esperar al puerto realmente asignado.
    Las pruebas “unitarias” pueden hacerse a nivel de handler o a nivel HTTP, pasando por todo el middleware o por ninguno, y aun así probar el código suficientemente de la forma en que lo verá el usuario. También es posible levantar N instancias y probarlas en paralelo.

  • No uso Go, pero me gustan estos patrones. Siento que se aplican de forma bastante universal al código testeable.
    No quiero volver a ver guías de inicio rápido, especialmente de Python, que tratan las dependencias de forma implícita/estática/imposible de probar.