One Monolith to rule them all, with Symfony

David Moya de la Ossa
planetahuerto
Published in
11 min readJun 9, 2019

Os pongo en situación: equipo de 4–5 programadores; código legacy en Php; cuatro aplicativos, dos con Yii1 y otros dos con Yii2, todos accediendo a la misma base de datos; un par de años usando Pimple como contenedor de servicios y tratando de incorporar componentes Symfony para desterrar definitivamente Yii poco a poco; paquetes propios, muuuuchos paquetes propios… gestionados con Bitbucket y Satis, compartidos (en distintas versiones) por varios o todos los aplicativos; incorporando Arquitectura Hexagonal en los componentes propios para los nuevos desarrollos; productos cada vez más lentos de implementar y ramas más complejas para mezclar / unir / desplegar… 😱

Photo by Luz Fuertes on Unsplash

Evidentemente, llevábamos tiempo viendo la boca del lobo aproximarse hacia nosotros, lenta e inexorablemente. Leímos muchos libros, hicimos muchas reuniones y se plantearon muchas posibles soluciones. Y en esto llegaron los chicos de Rigor Guilds (Carlos Buenosvinos y Christian Soronellas) y en un par de días de taller, varias bofetadas en la cara (totalmente simbólicas) a lo Benny Hill, y nos iluminaros el camino hacia la salvación: ¡El Monolito Único!

Juntad todos los aplicativos en uno solo, instalad Symfony como otro aplicativo más para ir moviendo / creando casos de uso; dejad de usar los paquetes propios a través de Composer y Satis y movedlos al código común del proyecto, en el caso de que varios aplicativos utilicen un mismo componente lógico, duplicad código, cambiad el espacio de nombres y que cada actor se haga cargo de sus casos de uso… Más adelante, cuando solo dependáis de Symfony y la Arquitectura Hexagonal esté más instaurada, ya veremos si os interesa separar de nuevo en varios aplicativos y de que forma hacerlo.

Puuum, a algunos nos explotó la cabeza… Tanta charla de microservicios, que si Amazon, que si Netflix, etc; composer, packagist, gitflow, semantic versioning, etc; y ahora resulta que tenemos que desandar lo andado. Fuimos algo reticentes al principio. Pero tras varias pensadas, discusiones, viendo puntos positivos y negativos, entramos por el aro. Y tengo que decir, que los efectos positivos se vieron desde el primer momento, pasito tras pasito.

En este post, y sucesivos, os voy a contar las aventuras y desventuras protagonizadas por el equipo de desarrollo de Planeta Huerto para aplicar estos cambios arquitectónicos y alcanzar el ansiado Monolito Único que los controle a todos 💍.

Photo by Kelly Sikkema on Unsplash

Antes de nada os voy a presentar nuestros cuatro aplicativos:

  • Frontal: nuestra web, donde nuestros clientes ven el catálogo de productos y realizan sus pedidos.
  • Privado: nuestro cms, pim, intranet… Todo se gestiona a través de él.
  • Almacén: nuestro wms.
  • Api: cierta lógica común a varios aplicativos se extrajo a éste para unificar procesos.

Si os estáis preguntando el porqué de esta distribución, no os preocupéis, no sois los únicos. Decisiones que en un momento son válidas, pasado el tiempo tienen menos sentido. Que os voy a contar que no sepáis: legacy a tope.

Desafío I: mover nuestros paquetes al aplicativo

La gestión de paquetes y sus versiones nos estaba ahogando. Era el primer punto que debíamos afrontar, incluso si no estuviésemos dispuestos a la unión de aplicativos.

Ya habíamos planteado anteriormente una posible solución: nada de tags, que toda dependencia entre nuestros paquetes apuntase a “dev-master”. Pero esta era mucho más radical. Planificamos un protocolo de actuación y un checklist para no dejar cabos sueltos. En los siguientes desarrollos, en cuanto existiese la posibilidad de mover un paquete, había que ponerse a ello.

A día de hoy todavía seguimos en dicho proceso, pero los paquetes más cambiantes dentro de cada aplicativo ya se encuentran como código propio de éste, dentro de la carpeta “src/”. Esto ha aumentado exponencialmente nuestra velocidad de implementación y despliegue, tanto de nuevas características como de resolución de incidencias. Y ya no digo nada cuando había que tocar un paquete y esto implicaba una subida de versión major que desencadenaba cambios en otros paquetes: fichas y más fichas de dominó cayendo una tras otra…

Tenemos dos tipos de paquetes: los componentes lógicos y las librerías y utilidades genéricas.
Los primeros dependen de cada aplicativo, y cada uno de ellos debe tener su versión. Por ejemplo, Product para frontal tiene una lógica de negocio asociada totalmente diferente de Product para almacén. Antes se compartía en un mismo paquete. Ahora vamos a separarlo y dejar que cada aplicativo utilice su lógica propia. Además, si evolucionan (que lo harán), cada uno podrá tomar su camino independiente. En caso que un aplicativo necesite gestionar u obtener información de un componente lógico de otro aplicativo podrá hacerlo a través un ACL (anticorruption layer). Así que crearemos un directorio dentro de “src” para cada aplicativo donde moveremos sus componentes lógicos: “src/frontend”, “src/backend”, “src/warehouse” y “src/api”.
Los segundos son compartidos por todos los aplicativos, aunque en diferentes versiones que tendremos que mezclar cuando los unamos en el monolito único. Así que los moveremos a “src/common”.

Paquetes una vez movidos

Hemos tomado la decisión de no eliminar los composer.json de cada paquete, sino reducir su contenido a lo estrictamente necesario e incluirlo en el composer.json general como repositorios de tipo path. De este modo el autoload se gestiona desde cada paquete, podemos también ver las dependencias entre paquetes y esto nos sirve para ciertas métricas basadas en composer.

Un ejemplo del antes / después en un paquete sería:

Specification composer.json antes
Specification composer.json después

Gracias a éstos cambios, hemos pasado de tener nuestro código distribuido en 41 repositorios a tenerlo todo en uno sólo 💪. Todo nuevo código o mejora del actual se gestiona en un único repositorio git, eso es música celestial para nuestros oídos. Y no os imagináis como…

Ésta es la pinta de nuestro vendor ahora:

Desafío II: pasar de php 5.6 a php 7.3

Gracias a ph-cs-fixer esta tarea es mucho más sencilla. De todas formas, tuvimos que implementar una batería de llamadas a todos las rutas de Api y solventar otras incompatibilidades no detectadas por esta herramienta.

Seguro que os ha tocado realizar este cambio en alguno de vuestros proyectos y sabéis de lo que os hablo.

Desafío III: instalar Symfony Framework 4.2

Ahora que ya funcionábamos sobre Php 7.3 podíamos dar el siguiente paso: instalar el tan ansiado Symfony. Fuimos modificando las dependencias de nuestros paquetes y del aplicativo sobre los componentes Symfony: siempre poco a poco y comprobando que la batería de tests pasaban.

En esos momentos teníamos dos puntos de entrada: “public/index.php” para Symfony y “web/index.php” para Yii. La idea era tener uno solo: en primer lugar se comprobaría si la url podía gestionarla SF, si no, trataría de gestionarla Yii, como hasta ahora. Poco a poco, toda nueva característica o modificación de una antigua la gestionaríamos a través de SF. Así, llegaría el tan esperado día de dejar atrás Yii definitivamente…

Los chicos de Rigor Guilds nos hablaron de zendframework/zend-stratigility e incluso nos mostraron un ejemplo ya funcionando con SF, Yii1 y Yii2. La idea es agregar middlewares a una cadena de responsabilidad para que cada eslabón trate de gestionar la petición. El primer eslabón es SF y después uno por cada aplicativo. La primera versión para SF + Api tiene esta pinta:

Más adelante agregaremos un nuevo middleware por cada aplicativo que incorporemos al monolito. Anticipándonos a ese momento, nos surgirán ciertos problemas si cada middleware levanta su aplicativo Yii, sus rutas y trata de resolver la petición:

  • Tenemos dos aplicativos con Yii1 y dos con Yii2, y ciertas clases de ambas coinciden en su FQCN (Full Qualified Class Name).
  • La clase base de Yii a través de la que se accede a diversos servicios, funciona mediante llamadas estáticas, con lo cual, la carga de un segundo aplicativo se vería afectado por la carga previa del primero.
  • En cada aplicativo, también hay código interno con clases en las que coincide el FQCN.

Como cada aplicativo tiene uno o varios dominios http específicos asociados, optamos por implementar una clausula de guarda en cada middleware para que sólo entre en juego en el caso de que el host coincida con alguno suyo. De este modo también ganamos algo en rendimiento:

Desafío IV: mover una ruta de Api a Symfony

Ya tenemos SF + Api funcionando: lanzamos llamadas sobre el aplicativo, las peticiones pasan a través de SF, sin pena ni gloria, y Yii2 acaba resolviendo y devolviendo una respuesta. Es hora que SF también trabaje: vamos a mover una ruta y dejar que resuelva un controlador configurado mediante el contenedor de servicios symfony/dependency-injection.

Ruta

En primer lugar la ruta: la seleccionada fue “/invoice/event”. Desde Api se gestiona la generación de facturas en base a los pedidos. En este proceso se lanzan eventos de dominio que se almacenan en un EventStore. Mediante esta url se accede a los eventos almacenados, seleccionándolos por paginación y con la posibilidad de iniciar a partir de un determinado id de evento. Otros aplicativos los consumen. Pero, ¿dónde y cómo definir la ruta?

Teníamos claro que “el como” era mediante ficheros yaml. Nada de ensuciar nuestros controladores mediante anotaciones dependientes totalmente del framework. Nuestros controladores hacen su labor independientemente del framework que les llame. Reciben una Request y devuelven un Response, simplemente.

Ahora bien, ¿donde las definimos? Tenemos un PHApiBundle dentro de “src/api/api-bundle/src/” y un PHApiInvoiceBundle dentro de “src/api/invoice/src/Infrastructure/Symfony”. Después de darle algunas vueltas el equipo decidió que toda definición de ruta se haría en el bundle del aplicativo, que es el responsable de mapear urls con controladores. Estos controladores accederán a los casos de uso de los componentes lógicos, definidos en su capa de aplicación, sólo a través de un Command/Query Bus. Los bundles de los componentes lógicos serán los encargados de definir los Command/Query Handlers como servicios que se incorporarán al Bus global. Las razones dan para otro post…

Pues vamos a ello:

Como veis, en la configuración general se agrega el recurso que contendrá todas las rutas de Api, fijándolo con el host “api.planetahuerto.es”. Haremos lo mismo para el resto de aplicativos, y así evitaremos solapamientos entre urls resueltas por unos u otros.

Controlador

Vamos ahora con el controlador. Hemos comenzado por un caso en el que la implementación es bastante reciente. Por suerte, en su día iniciamos la integración de Yii mediante componentes SF. Se generan eventos de Kernel durante el flujo de procesamiento de peticiones http, tenemos EventListeners asociados a dichos eventos, los controladores pueden recibir en sus acciones Request de SF y devolver objetos Response.

Tenemos todo tipo de controladores, mucho controlador legacy trabajando solo con Yii; otros refactorizados a medias, todavía con alguna dependencia con Yii; y los minoritarios, pero más queridos 💛, los que no se enteran que están detrás del framework de Yii.

Veamos el controlador EventStoreRestController:

Este controlador tiene dos dependencias, la Request de SF que ya gestiona el framework, y el QueryBus que debemos definir nosotros en nuestros servicios. ¿Cómo nos planteamos esta transferencia de servicios de Api hacia SF?

Desafío V: mover servicios de Api a SF

En Api ya tenemos nuestro contenedor Pimple con multitud de ServiceProviders registrados:

Por tanto disponemos de dos contenedores de servicios: el de SF y el de Api mediante Pimple. En el traslado de servicios de uno a otro, se podrán dar dos casos: uno ideal en el que comencemos a mover servicios y sus dependencias, y no exista ningún otro servicio todavía no trasladado que haga uso de ellos; y el segundo, entiendo que el más común, en el que siempre exista algún servicio no movido que dependa de uno que si, por ejemplo, se me ocurre un repositorio que se utilice en varios casos de uso. Crear un nuevo caso de uso o trasladar uno ya implementado, y empezar a mover y mover servicios, y que esto nos suponga mucho trabajo extra de configuración y pruebas, no nos lo podemos permitir. El día a día del equipo no puede verse afectado en demasía por este cambio arquitectónico. Y si se retrasan los desarrollos tendremos a negocio llamando a nuestra puerta o golpeando nuestra cabeza a lo “hay alguien ahí McFly” ✊.

Así que necesitamos un modo de interconexión entre contenedores. Una opción es que el contenedor de SF sepa de Pimple y eche mano de él si no encuentra algún servicio definido en sí mismo. Esta es una mala idea: primero no queremos que SF sepa algo del código legacy o dependa de él; segundo, sería muy complejo que el flujo de construcción del contenedor SF tuviera en cuenta los servicios definidos en otro contenedor para que no lanzase excepciones por no tener definida alguna referencia; no digamos ya cuando tengamos varios aplicativos, y por tanto, varios contenedores Pimple. Una locura.

Bueno, tomemos el sentido contrario. Que los contenedores Pimple, tengan conocimiento del de SF para cargar ciertos servicios de los que ya no dispongan. Pensemos en una petición que resuelva una ruta de SF: se cargará el contenedor propio y se resolverá, punto. Pongamos que lo resuelve Api: se carga el contenedor de SF, se carga el de Api que hace uso cuando lo necesite del anterior y se resuelve la petición. Imaginemos ahora que también hemos añadido el aplicativo de Almacén y llamamos a una de sus rutas: se carga el contenedor de SF, el middleware de Api obvia la petición por el host y se gestiona por Almacén que levanta su contenedor y tiene conocimiento del primero, el de SF. Puede funcionar.

Para comunicar el primer contenedor con el resto, optamos por usar el único objeto compartido por todos los middlewares a la hora de procesar: la petición. Así que desde el middleware de SF se pasa su kernel como un atributo de dicha petición. Podíamos haber usado una variable global, o un contexto pasado como dependencia en todos los middlewares, pero esta primera versión quedó de este modo:

Cambios en ApiAppMiddleware

Ahora sólo nos queda que nuestro contenedor Pimple reciba el contenedor de SF desde el kernel y lo utilize en caso de que falle la instanciación de servicios a través de él mismo:

Vale, comencemos moviendo nuestro handler GetEventsQueryHandler (y todo el árbol de dependencias) del contenedor de Pimple al de SF:

Hemos eliminado de Pimple todos los servicios movidos. Los servicios que todavía se utilizan en el legacy les hemos agregado el alias del servicio de SF. De esta forma conseguimos que clases del legacy accedan a ellos y mover sólo la parte de código que nos interesa, sin tener que realizar traslados masivos de un contenedor a otro.

Desafío VI: conectar eventos entre legacy y SF

Un último detalle: si se lanza un evento de aplicación en la “zona SF” queremos que se entere la lógica interesada que todavía se encuentra en la “zona Legacy”, y viceversa.

Nuestra primera solución fue transformar el servicio de Pimple en un alias al servicio de SF:

Resultado:

  • Ruta resuelta por SF: 200
  • Ruta no resuelta por SF y en teoría resuelta por Yii: siempre 404

¿Que sucedía en nuestra “impecable” solución? Como ya hemos comentado, el framework de Yii lo teníamos medio tuneado para ir incorporando componentes SF e ir poco a poco separándonos del legacy (estrategia que hemos asumido que no lleva a un camino fácil ni corto). Por tanto, en el flujo de resolución de peticiones de Yii, también lanzamos eventos de kernel de SF. Como la instancia de EventDispatcher es la misma, cuando Yii lanza un evento kernel.request, el servicio RouterListener de SF trata de nuevo de resolver infructuosamente la Request. Así que tenemos a SF no resolviendo la url dos veces. 😯

Teníamos que pasar eventos de uno a otro lado, menos los del kernel. Creamos un decorador del EventDispatcher que nos filtrase dichos eventos y que contiene internamente dos dispatchers: el propiamente decorado y otro para los eventos filtrados. De este modo, todo listener legacy que “enganche” al EventDispatcher lo hará a la instancia de SF si no pertenece a los eventos del kernel; si el evento es de kernel, se añadirá al dispatcher de los filtrados. Además, el decorador lanzará los eventos que se generen en la “zona Legacy” a través del dispatcher de SF, menos los filtrados que los lanzará a través del segundo dispatcher.

Probando… ¡funciona!

Montado SF, montado stack de peticiones con SF y Api, movida primera lógica desde el legacy a SF, contenedores sincronizados y eventos de aplicación sincronizados.

Photo by Ambreen Hasan on Unsplash

Próximamente en este blog: “Cómo agregar un nuevo aplicativo a la ecuación, Almacén”.

--

--