Hoy en día, tenemos alrededor de 1,1 millones de líneas de código TypeScript comprometido en nuestro monorepo. Su escalado ha traído consigo una serie de retos, como la lentitud en la comprobación de tipos, las importaciones infladas y la trazabilidad del código cada vez más deficiente. Durante los últimos años, hemos explorado y probado varios patrones de diseño para mejorar la escalabilidad de nuestra base de código. Uno de los patrones con los que nos topamos nos ayudó a aliviar muchos de nuestros crecientes problemas de organización del código: los registros. Es sencillo y uno de los patrones más escalables que hemos adoptado.
Nuestro código interno original para el manejo de eventos es un buen ejemplo de cómo este patrón marcó una gran diferencia. Comenzó siendo sencillo, pero con el tiempo dio lugar a uniones de tipos gigantes que ralentizaban la comprobación de tipos, archivos barril que importaban demasiado código y código difícil de rastrear debido a la concatenación excesiva de cadenas. Por último, introdujo fricciones entre los desarrolladores a la hora de mantener y añadir nuevos manejadores de eventos.
Diseño original
A alto nivel, nuestro servicio de eventos se basa en una cola de mensajes (MQ). Los eventos se registran y envían a la MQ desde cualquier lugar de nuestro backend (pods API, pods de trabajo, etc.) y son procesados por un trabajador exactamente una vez.
El diseño en sí mismo es muy sencillo; la única restricción importante es que todos los eventos deben ser totalmente serializables a través de la red. La mayor parte de la complejidad y el mantenimiento provienen de la seguridad de tipos y la experiencia del desarrollador.
1. Defina un conjunto de ayudantes básicos:
2. Defina los tipos de eventos en un archivo compartido.
3. Defina los controladores de eventos en su propio archivo.
4. Definir un archivo de acumulación
5. Definir controladores de eventos
6. Definir una función común para registrar eventos.
7. Exportar una función que procese el controlador de eventos para la cola en un trabajador.
A simple vista, este diseño parece bastante aceptable. Es seguro en cuanto al tipo, fácil de entender, sencillo de manejar y claro en cuanto a cómo crear nuevos eventos. Sin embargo, había varios problemas:
Complejidad de la comprobación de tipos: Para cada nuevo evento, el AppEvent El tipo se hace más grande. Individualmente, una sola unión no es problemática, pero a medida que aumenta el uso de este patrón en todo el código base (múltiples uniones de tipos), el rendimiento de la comprobación de tipos se degrada rápidamente.
Carga anticipada de módulos: Nuestro patrón de archivos de rollup significaba que casi todos los módulos importaban todo el código base al inicio, lo que hacía imposible la carga diferida. Esto también nos impedía dividir fácilmente el servidor en paquetes separados para diferentes implementaciones. Si bien la carga inmediata de todo el código base era aceptable en producción, afectaba gravemente al desarrollo y las pruebas, ya que solo para iniciar el servidor de desarrollo se tardaba entre 20 y 30 segundos.
Escasa trazabilidad: Responder a preguntas sencillas como «¿dónde se implementa este evento?» o «¿dónde se llama a este controlador?» resultaba difícil. Teníamos que recurrir a búsquedas de texto completo de literales de cadena. Por ejemplo, si un ingeniero decidía hacer algo como esto:
Sería muy difícil rastrear que el cable_enviado y creación_de_cable Los eventos se activan desde aquí. Buscar la cadena «wire_sent» en el código base no revelaría este uso, ya que el nombre se construye dinámicamente. Como resultado, esta información se convierte en un conocimiento «tribal» oscuro que acaba quedando en la mente de unos pocos ingenieros selectos.
Falta de límites claros entre los dominios: A medida que introdujimos interfaces más homogéneas (controladores de eventos, entidades de bases de datos, comprobaciones de estado), se fomentó la colocación de interfaces similares en la misma carpeta en lugar de agruparlas por lógica de dominio. Esto fragmentó nuestra lógica de negocio, haciendo que el cambio de contexto específico del dominio fuera más frecuente y complejo.
Iterando sobre este diseño
En la configuración anterior, colocamos todos los controladores de eventos en ./app-event-handlers/<event_type>.ts</event_type> archivos. Aunque tenerlos todos en una sola carpeta facilitaba su localización, no reflejaba cómo trabajábamos realmente. En la práctica, colocar los controladores de eventos junto con el resto de la lógica de la aplicación resultó mucho más útil que agruparlos con otros controladores.
Ahí es donde surgió la idea de añadir subextensiones a los archivos (.event-handler.ts) entró en escena. Nos permitieron colocar los archivos por dominio, al tiempo que facilitaban su localización mediante la búsqueda de la extensión. La extensión de archivo nos permitió además eliminar los archivos de acumulación mantenidos manualmente, ya que podíamos buscar todos los archivos que coincidían con la extensión en el repositorio en tiempo de ejecución.
Aquí hay una versión abreviada del código base del registro y cómo funciona. cargar módulos escaneará todos los archivos y registrará todos los objetos exportados con un $discriminador propiedad que coincide con el mismo símbolo pasado a crearRegistro.
Ahora, esto es lo que se ve al crear nuestro controlador de eventos utilizando Registries:
1. Defina un registro en un <name>.registro.ts</name> archivo:
2. Defina los controladores de eventos reales en .app-event-handler.ts archivos
3. Definir una función común para registrar eventos.
4. Exportar una función que procese el controlador de eventos para la cola en un trabajador.
Algunas diferencias importantes a tener en cuenta:
La trazabilidad del código es mucho mejor.: Cada vez que registre un evento, hágalo de la siguiente manera:
Esto significa que es fácil rastrear todos los lugares donde cardCreatedEventHandler se utiliza mediante herramientas AST como «Buscar todas las referencias» (en VS Code). Por el contrario, cuando veas un registrar evento llamada, puede «Ir a la implementación» con un solo clic para encontrar la definición del evento y su controlador.
No más sindicatos de tipoMás bien, estamos utilizando tipos básicos, que es algo que TypeScript Recomienda evitar los problemas de rendimiento relacionados con la comprobación de tipos que provocan las uniones grandes.
Los controladores de eventos se encuentran en la misma ubicación que la lógica específica del dominio.Los controladores de eventos de aplicaciones ya no se almacenan en una sola carpeta. En su lugar, se ubican junto con la lógica empresarial relevante. Por ejemplo, un servicio específico de dominio podría tener un aspecto similar al siguiente:
Trabajar con registros hoy en día
Hoy en día, trabajamos con docenas de registros para mantener todo el código junto con su lógica de aplicación. Algunos de los más destacados son:
.db.tspara registrar entidades de la base de datos.flujos de trabajo.tsy.actividades.tspor registrarse Temporal flujos de trabajo.checks.tspara registrar controles médicos (entrada de blog).main.tspara registrar servicios que agrupan la lógica empresarial específica del dominio.permiso-rol.tsy.clave-de-permiso.tspara definir los permisos RBAC en nuestro producto.buzón-de-correo.tspara registrar controladores que analizan correos electrónicos en una cuenta de Gmail.cron.tspara registrar tareas cron.saldo-contable.tspara definir nuestra primitiva «libro mayor» financiero interno.métricas.tspara definir métricas de Datadog
y varias otras extensiones específicas del dominio.
Por el momento, no hemos publicado este patrón como código abierto, pero esperamos que esta publicación proporcione una idea clara de cómo se puede implementar en otros códigos base. Si te ha resultado útil, ¡prueba a implementarlo en tus propios proyectos y cuéntanos qué tal te ha ido!