Dev Life

Un análisis exhaustivo de la reconstrucción de la app de Mailgun: Parte 1 – Ideas excelentes

Saludos, compañeros desarrolladores. Estamos emocionados de anunciar que, después de tres largos años de planificación, diseño y desarrollo, nuestro equipo ha lanzado nuestra fantástica actualización de la app. Claro, hemos trabajado incansablemente para actualizar el aspecto de nuestra interfaz de usuario, pero hemos hecho una revisión aún más alucinante de nuestro stack tecnológico fundamental. ¿Qué […]
Image for Un análisis exhaustivo de la reconstrucción de la app de Mailgun: Parte 1 – Ideas excelentes

Saludos, compañeros desarrolladores. Estamos emocionados de anunciar que, después de tres largos años de planificación, diseño y desarrollo, nuestro equipo ha lanzado nuestra fantástica actualización de la app. Claro, hemos trabajado incansablemente para actualizar el aspecto de nuestra interfaz de usuario, pero hemos hecho una revisión aún más alucinante de nuestro stack tecnológico fundamental.

¿Qué significa esto para ti? Mejor rendimiento, mejor interfaz y escalable para el crecimiento futuro. Viajamos atrás en el tiempo para traerte el informe completo sobre cómo lo logramos. Acompáñanos en el viaje.

Por qué nos embarcamos en este viaje

¡Pero espera! Antes de viajar atrás en el tiempo, es importante presentar nuestra app mejorada. «Mejorada» es quedarse corto. Puede sorprenderte que este lavado de cara de la interfaz de usuario y el rendimiento no fuera un generador de ingresos. Entonces, ¿por qué lo hicimos?

Dos razones:

  1. La experiencia del usuario, tal como existía, inhibía el rendimiento para los clientes que enviaban grandes volúmenes de emails y dificultaba la navegación entre los productos de nuestra creciente cartera. Si no es fácil de usar, entonces no lo usarás.
  2. Necesitábamos reevaluar el flujo de datos principal de nuestra API para que estuviera más orientado al rendimiento y fuera más escalable.

Lo que hemos creado es el portal, una interfaz épica para conectarte mejor con tus productos y herramientas de entregabilidad ahora, y a medida que escalamos. El portal te permite moverte entre nuestro Mailgun y Mailgun Optimize productos con facilidad y ofrece una experiencia de alto rendimiento para nuestros usuarios avanzados que envían decenas de miles de emails al día.

Así que quédate con nosotros mientras nos ponemos frikis, nos sintonizamos y viajamos a través de los últimos tres años de nuestro proceso de desarrollo.

bill-teds-excellent-adventure-wallpaper-12__1_

Lo que queríamos construir

Empezamos creando una lista interminable de todas las cosas diferentes que queríamos: abarcaba desde la actualización de la estructura de nuestro repositorio hasta la implementación de un nuevo sistema de despliegue, y sabíamos que íbamos a tener que crearlo desde cero, ya que nuestro equipo SRE estaba migrando de AWS a Google Cloud. También queríamos alejarnos de nuestro diseño monolítico estrechamente acoplado y pasar a un diseño más distribuido. Esto facilitaría las cosas para el equipo en el futuro: desde el desarrollo de pruebas exhaustivas hasta la selección de reconstrucciones sin necesidad de bucear por todo el código o cambiarlo.

¿Qué queremos construir a largo plazo? Esta fue la primera pregunta que tuvimos que responder. La siguiente pregunta fue: «¿En qué nos quedamos cortos?»

Con cualquier rediseño, siempre queremos considerar al usuario, pero gran parte del impulso detrás de la actualización de nuestra app fue también mejorar la experiencia del desarrollador. Nuestro objetivo era mejorar la experiencia del desarrollador reduciendo significativamente la complejidad de pasar los datos de la API a la página renderizada, y mejorar la mantenibilidad reduciendo el número total de patrones en nuestro conjunto de herramientas.

Historia: Nuestro stack de front-end original

Los stacks pueden quedarse anticuados, y nuestro stack de front-end original se construyó hace casi una década, lo que equivale a 350 en años de internet. Incluía un servidor web de Python Flask que probablemente manejaba más tareas de las que debía y tenía un estado muy estrechamente acoplado compartido con el cliente. Usaba múltiples capas de abstracción, originalmente pensadas para facilitar la obtención de datos de nuestras API.

A medida que esa superficie crecía, los patrones aportaban más sobrecarga que comodidad. Se volvió muy difícil hacer cambios fundamentales porque había muchos lugares que podían verse afectados. Para entonces, nuestro cliente de front-end se había convertido en un museo de patrones obsoletos de React.

Todas nuestras interacciones con nuestras propias API públicas y privadas estaban ofuscadas a través de múltiples capas de excesivas complejidades de programación. Esto creó un abismo gigante entre los desarrolladores de front-end de Mailgun y su comprensión de nuestras API y la experiencia del cliente.

Cómo nuestro framework heredado supuso un obstáculo para nuestra escalabilidad

La chapuza de abstracciones de la API no solo resultó en un exceso de complejidad, sino que inhibió significativamente nuestra capacidad para escalar nuestra aplicación. Sabíamos que queríamos acabar introduciendo cada vez más productos en la base de código. Necesitaríamos replantearnos por completo cómo consumíamos nuestros datos.

Cuando preparábamos el plan del nuevo framework, sabíamos que queríamos poder hacer solicitudes de la API directamente desde el cliente a las API públicas con la menor cantidad de middleware posible. ¿Por qué? De esta manera, podríamos comprender y consumir nuestras API de la misma forma que lo hacen nuestros clientes, «comiendo nuestra propia comida para perros», como suele decirse. No sé vosotros, pero yo prefiero comer un filete que comida para perros.

Una nota del futuro: Hacer solicitudes de API directamente desde el cliente a las API públicas es un objetivo a largo plazo, ya que muchas de las API con las que se comunica nuestra aplicación web siguen siendo privadas. Tenemos un plan para trasladar gradualmente la mayoría de estos servicios al espacio público y tener una interfaz de API uniforme para el portal.

El problema de gestionar los estados de toda la app

Otro cambio significativo que queríamos hacer era reemplazar Redux, que gestionaba nuestro estado de toda la app (alrededor de 150 estados). Cada estado se asignaba a una llamada de red, o a una red de transformaciones de datos, y nuestra estructura heredada no establecía pautas sobre cómo usarlo. ¿El resultado? Muchas redundancias.

Cuando un desarrollador introducía una llamada para manipular datos, se integraba a través de tres niveles de abstracción. Además, teníamos nuestros propios clientes personalizados para el navegador y para nuestro servidor Flask (que interactuaba con todas nuestras API). Por lo tanto, para que un desarrollador añadiera una nueva función, tenía que pasar por unos siete niveles de abstracción en total, y es posible que haya errores en cualquier nivel.

Lo que finalmente descubrimos fue que el 95 % de nuestro estado a nivel de aplicación era solo una caché de red glorificada. Solo que ni siquiera estábamos cosechando los beneficios de los datos en caché, y a menudo hacíamos llamadas redundantes para solicitar datos que ya teníamos, causando capas anidadas de rerenderizados innecesarios en los componentes de nuestra vista. Vaya viaje tan movido.

Este desglose del sistema explica las cosas con un poco más de claridad, y aunque nunca hablaríamos mal de nuestros orígenes… una imagen jocosa vale más que mil palabras.

Sistema actual

Nuestro equipo es del tipo que construye sobre sí mismo e itera, pero para la reconstrucción de esta app tuvimos que rebobinar y reelaborar algunos componentes fundamentales de nuestra infraestructura, comenzando por el control del código fuente.

Limitaciones del polirepositorio

Un polirepositorio es un repositorio que contiene múltiples proyectos. Usábamos un estilo de organización de polirepositorio, lo que significa que cada proyecto de front-end tenía su propio repositorio, aunque muchos de los proyectos repetían las mismas tareas y reproducían las mismas funciones. Como centro principal, los polirepositorios pueden convertirse en un desafío de mantenimiento a medida que crece el número de proyectos almacenados. En muchos casos, tuvimos que desplegar cada polirepositorio simultáneamente con las mismas actualizaciones para asegurar una experiencia coherente en todos nuestros productos.

Desde la perspectiva del mantenimiento, esto dificultaba que los equipos localizaran y trabajaran con el código que necesitaban. Gestionar múltiples repositorios con características redundantes suele significar que son más lentos en responder y que consumen más recursos, lo cual es un gran problema si frecuentemente haces push y pull de código. Como las funciones pueden distribuirse en múltiples bases de código, los polirepositorios pueden hacer que encontrar errores sea más difícil que encontrar la proverbial aguja en el pajar.

Con nuestra estructura de polirepositorio previa a la reconstrucción, sabíamos que el futuro sería una realidad de buscar en los repositorios con una linterna para encontrar lo que necesitábamos, y que sería un desafío colaborar, escalar y compartir código a medida que crecíamos.

Estas eran nuestras principales preocupaciones:

  • Codesarrollo desafiante
  • Acoplamiento estrecho
  • Gestión de la escalabilidad, disponibilidad y rendimiento
  • Dificultad para compartir código
  • Duplicación
  • Herramientas inconsistentes

En cuanto a la gestión del código fuente, hay muchas soluciones, pero decidimos que una estructura modular de monorepositorio aliviaría la mayoría de nuestros problemas de desarrollo de cara al futuro.

El plan para un repositorio nuevo y reluciente: Un enfoque modular

Un enfoque de monorepositorio implica almacenar todo el código del proyecto en un solo repositorio grande. Esto resolvería los problemas de duplicación y compartición de código que experimentamos con nuestra organización de polirepositorio, pero los monorepositorios no son una solución mágica. Si el código está estrechamente acoplado, los monorepositorios también deben diseñarse de forma modular.

Un enfoque modular implica dividir un proyecto grande en módulos más pequeños e independientes que se pueden desarrollar, probar y mantener por separado. Esto permite una mayor flexibilidad y reutilización, así como la capacidad de actualizar fácilmente los módulos individuales sin afectar al resto del proyecto.

Sabíamos que una solución modular de monorepositorio nos permitiría ser más eficientes con:

  • Generación de código
  • Compartir código
  • Ejecución y orquestación distribuida de tareas
  • Caché
  • TypeScript
TypeScript es un superconjunto tipado de JavaScript que facilita a los desarrolladores la creación de proyectos a gran escala. Mejora la experiencia del desarrollador al proporcionar interfaces, alias de tipo, análisis de código estático en tiempo de desarrollo y otras herramientas, a la vez que permite a los desarrolladores añadir tipado a sus proyectos. Para nuestro equipo, TypeScript es una habilidad que sabíamos que queríamos empezar a buscar a la hora de añadir miembros a nuestro equipo.

Pisando callos: El baile del despliegue

Sin lugar a duda, el mayor desafío de vivir en un monorepositorio (monolítico o no) es que cuantas más personas contribuyan, mayor será la posibilidad de que nos pisemos los unos a los otros. ¿De qué sirve una experiencia de desarrollo renovada si tu código se sobrescribe en una mala fusión? Si realmente estamos haciendo una revisión integral de nuestro stack, también debemos estudiar cómo podemos mejorar nuestro proceso de despliegue. Estos eran los retos más urgentes:

  • Escalabilidad: Sería una mala idea reconstruir TODO el código del monorepositorio para los cambios más pequeños, por ejemplo, corregir un error tipográfico en algún texto. Eso es exactamente lo que hacíamos en nuestro antiguo sistema. El nuevo proceso aprovecharía las capacidades integradas de NX para evaluar y reconstruir únicamente el archivo o archivos afectados por el cambio, en el nivel más granular.
  • Despliegue: Mantuvimos la mayor parte de la estructura de nuestra canalización de despliegue. Una vez construida la imagen, aterriza en el repositorio de contenedores de GitHub. Nuestro equipo SRE puso a punto nuestro sistema de despliegue durante la migración de AWS a GCP. Podemos desplegar fácilmente cualquier rama desde nuestro canal de despliegue de Slack, con un mejor informe gracias a una incorporación reciente que obtuvimos durante la migración.
  • CI/CD: Nuestro antiguo sistema usaba una versión muy antigua de Jenkins para ejecutar pruebas y construir/distribuir las imágenes de nuestra app tras una VPN. Cuando la compilación fallaba, necesitábamos que un desarrollador sénior de alto nivel, familiarizado con las viejas costumbres, lo arreglara. En nuestro nuevo sistema, el plan era pasarnos a GitHub Actions, donde las configuraciones son accesibles, declarativas y están bien documentadas.

Desafíos absurdos y soluciones excelentes

Los proyectos de cualquier tipo presentan desafíos, algunos esperados y otros sorprendentes. Sabíamos que necesitábamos un plan para superar algunos problemas y que la nueva versión de la app debía centrarse en el rendimiento desde múltiples ángulos.

Problemas identificadosSoluciones propuestas
Convenciones de código poco claras
Las convenciones de código se refieren al conjunto de estilos de programación, soluciones y patrones reutilizables dentro de una base de código. Imagina un equipo de carpinteros trabajando en un proyecto. Si tienen 1000 herramientas especializadas en el lugar de trabajo, es probable que un trabajador elija la herramienta equivocada para el trabajo o la use de forma incorrecta. Y a diferencia de las herramientas físicas, las dependencias de software pueden quedar obsoletas, lo que inhibe las futuras actualizaciones de la base de código.
Guía de estilo de código
Mantener guías explícitas de estilo de programación y un conjunto mínimo de patrones simples y potentes ayuda a reducir la redundancia, las inconsistencias y la complejidad, e incluso puede resultar en un mejor rendimiento. Reutilizar un pequeño conjunto de soluciones y patrones generalizados hace que el código sea más fácil de entender y reduce la probabilidad de encontrar errores difíciles de localizar.
Estado sobrecargado
Usábamos Redux como una solución universal para el estado global de la aplicación. Sin embargo, el 90 % de nuestro almacenamiento Redux no era más que una caché de red glorificada. Redux requiere múltiples capas de sobrecarga para cada miembro del estado. Esto dio lugar a muchas redundancias y una complejidad innecesaria.
Separación de la caché de red y el estado de la aplicación
Separar las características de la caché de red del estado de la aplicación nos permite mantener una huella de estado mucho menor. El estado se puede implementar de forma más local. La reducción de la sobrecarga se traduce en un aumento del rendimiento y da a los desarrolladores más control sobre cómo se utilizan los datos.
Elegimos React Query como interfaz para las API de nuestro backend, lo que nos permite gestionar los datos de forma asíncrona y reducir la sobrecarga de la red.
Arquitectura de red multicapa
Nuestra aplicación creció de forma orgánica, desde un panel de control básico centrado en la API hasta la suite multiproducto de herramientas para el cliente que tenemos hoy. Inicialmente, el cliente se construyó sobre unas herramientas comunes de Python compartidas por muchos de los backends de nuestras API. Esto lleva a tener numerosas capas de middleware necesarias incluso para la llamada a la API más sencilla. Esto no solo dificultaba entender el origen de un dato dado, sino que también hacía que la identificación de errores en el stack de red llevara mucho tiempo.
Las API públicas primero
En lugar de pasar las solicitudes por múltiples proxies, capas de autenticación e interfaces de Python, decidimos desde el principio que, siempre que fuera posible, recopilaríamos los datos necesarios directamente desde el cliente utilizando las API disponibles públicamente. Esto no solo redujo significativamente nuestra sobrecarga y complejidad, sino que nos ayudó a percibir mejor nuestros servicios de API desde la perspectiva de nuestros clientes. Tenemos la intención de llevar esta iniciativa más allá en el futuro cercano, haciendo públicas más API nuestras.

No tires todo por la borda

Hemos escrito cientos de miles de líneas de código durante la última década. Con la escala y el alcance de este nuevo plan, se tardaría casi los mismos años en portar y reescribir nuestras funciones antiguas en el nuevo paradigma. Esto era inasumible. Necesitábamos encontrar una forma de seguir incorporando estas funciones existentes sin comprometer nuestros objetivos para el nuevo stack. Encontramos una solución en una tecnología llamada federación de módulos.

Con el plugin de federación de módulos de Webpack, pudimos crear una nueva compilación de nuestra antigua aplicación con cambios mínimos, que podría integrar las funciones existentes en el nuevo stack del portal. Seguiríamos teniendo el objetivo a largo plazo de portar el código ahora heredado a estándares más nuevos, pero esto nos permitiría empezar a escribir nuevas funciones con los nuevos beneficios inmediatamente.

Tenemos un plan, pero ¿podemos hacerlo realidad?

Sabíamos que consolidar nuestras plataformas mientras seguíamos aumentando las funciones y los conjuntos de productos requeriría un esfuerzo masivo, y nuestra planificación influyó en lo que queríamos para nuestro nuevo stack de front-end y en cómo íbamos a gestionar el interminable legado de los sistemas de estilos CSS anticuados, los patrones de componentes de clase obsoletos y la torpe gestión del estado de la aplicación.

Como lo estábamos construyendo desde cero, especialmente con el paso de AWS a Google Cloud, la mejor solución era hacer borrón y cuenta nueva. Un nuevo repositorio con mejores y más altos estándares, federación de módulos, bibliotecas modulares compartidas y una documentación más completa a nivel interno y orientada al usuario.

New front-end stack; Nx, React, Webpack, TypeScript, Cypress, Jest, ES Links, Prettier, and Mock Service Worker.

Fue una etapa intensa de planificación y preparación. Como en todos los grandes proyectos, generar las ideas y la prueba de concepto fue un esfuerzo técnico y creativo en el que solo participaron unas pocas personas seleccionadas.

¿Fase 2? Convertir la idea en realidad, y eso implicaba a muchos más equipos. Desarrolladores, creo que nuestra aventura está a punto de dar un giro muy interesante.