En el ultimo desarrollo que hemos trabajado durante meses, se planteó una arquitectura de microservicios para crear un gestor de contenidos a medida. Separar la responsabilidad en pequeños servicios nos proporcionó ciertas ventajas respecto al legacy monolítico que heredábamos, ya que nos proporcionaba al equipo trabajar sobre ciertos aspectos independientes desacoplando cada pieza. Esto proporciona ventajas que ya conocemos de este tipo de arquitecturas, como la independencia o el despliegue independiente, sin embargo no debemos de olvidarnos del rendimiento.
Aún teniendo siempre una visión global, al comienzo del desarrollo pudimos paralelizar bien el trabajo de cada desarrollador, avanzando con gran velocidad con cada uno de los servicios. Sin embargo, nos dimos cuenta que hay que tener la madurez suficiente como equipo para orquestar todos los servicios para servir la funcionalidad necesaria a cada tercero. Aunque este tipo de arquitecturas proporciona ciertas ventajas, hay que tener en mente que hay otros aspectos que pueden penalizar como pueden ser el rendimiento.
El sistema que construimos no solo iba a servir contenido a un website, si no que iba a ser fuente de consumo de muchos otros servicios. Otro día hablaremos de la arquitectura completa, pero el objetivo de este artículo es proporcionar algunos consejos de rendimiento a la hora de conectar los microservicios a través de los aprendizajes que fuimos adquiriendo.
Utilizar el modelo CRUD en cada servicio
Los microservicios al tener una responsabilidad acotada, son realmente buenos para utilizar el patron CRUD (Create, Read, Update, Delete) para acceder a los modelos con los que trabajamos realizando llamadas independientes por cada acción, lo que permite monitorizar correctamente la traza de llamadas. Mi recomendación es utilizar alguna herramienta que permita construir rápidamente estas operaciones como puede ser API Platform, agilizando el desarrollo e independizando las operaciones.
Respecto al performance, en nuestro caso abrimos dos versiones de CRUD por cada entidad. Una versión privada con mayor serializado de información y tratamiento de datos privados para ser accesibles por los distintos roles que accedían al backoffice del gestor de contenidos. Por otro lado, construimos unos endpoints de lectura públicos mucho más ligeros con la información pública necesaria. Estos endpoints estaban optimizados para las lecturas y con una pequeña capa de cache HTTP
Proveer batch APIs
Aún construyendo las APIs con el patrón CRUD, para proporcionar un buen rendimiento es importante poder consultar por grupos de ids las entidades. Imagina una relación 1xN, por ejemplo usuario y avatar, en la que por cada usuario tengamos que consultar a otro servicio la url de cada avatar. Para estos casos existen alternativas como desnormalizar y guardar junto al usuario esa url de avatar, pero con la complejidad de proporcionar un mecanismo de las actualizaciones en el segundo servicio.
En algunos casos utilizamos esta técnica para premiar el performance, pero en otros casos permitimos esa consulta por array de ids pudiendo recuperar en una sola llamada todos los avatares y asignándolos en el servicio de pegamento a cada usuario. Esto justamente es lo que hace React Admin por defecto en sus listados, y como utilizamos esta herramienta para la construcción del backoffice dotamos a los GET de esa funcionalidad. Por ejemplo, teníamos un servicio que gestionaba todos los assets del sistema por lo que toda entidad que debiera de tener un asset en sus listados debiera de llamar de la siguiente forma:
Request
GET /api/admin/v1/images?filter={"id":[381168,381123,381055,381026,381023,380298,381126,381040,381041]}
Response
[{ "id": 381168, "name": "Image Name", "caption": "Image Caption", "slug": "image-slug", "watermakedUrl": null, "watermarked": false, "resizes": [{ "url": "https://mydomain.com/xsmall.png", "type": "xsmall", "createdAt": "2019-07-15T10:20:04+00:00", "updatedAt": "2019-07-15T10:20:04+00:00" }, { "url": "https://mydomain.com/small.png", "type": "small", "createdAt": "2019-07-15T10:20:04+00:00", "updatedAt": "2019-07-15T10:20:04+00:00" }, { "url": "https://mydomain.com/medium.png", "type": "medium", "createdAt": "2019-07-15T10:20:04+00:00", "updatedAt": "2019-07-15T10:20:04+00:00" }, { "url": "https://mydomain.com/large.png", "type": "large", "createdAt": "2019-07-15T10:20:04+00:00", "updatedAt": "2019-07-15T10:20:04+00:00" }, { "url": "https://mydomain.com/xlarge.png", "type": "xlarge", "createdAt": "2019-07-15T10:20:05+00:00", "updatedAt": "2019-07-15T10:20:05+00:00" }], "asset": { "id": 397231, "headers": { "ContentType": "image\/png" }, "fileDir": "https://mydomain.com/original.png", "filename": "filename.png", "creator": null, "createdAt": "2019-07-15T10:18:41+00:00", "updatedAt": "2019-07-15T10:18:41+00:00", "trash": false }, "metadatas": [], "createdAt": "2019-07-15T10:19:14+00:00", "updatedAt": "2019-07-15T10:19:14+00:00", "tags": ["tag1", "tag2", "tag3"], "trash": false, "publishedAt": null }]
Utiliza el modelo asíncrono de peticiones
Cuando los servicios estén listos, se necesita de un orquestador para realizar las llamadas a cada servicio y componer una feature completa. Algunos frameworks de microservicios como el de Netflix proporcionan sus propios mecanismos de orquestación. Sin embargo, si se construye uno a medida, la recomendación es si vas a interactuar con muchos microservicios para componer una respuesta, lo ideal es paralelizar las llamadas describiendo bien que requests son dependientes de cuales construyendo un modelo en el que consumamos el menor tiempo posible en construir nuestra respuesta.
En nuestro caso, además de paralelizar las peticiones, nos apoyamos en una capa sobre Redis para cachear esas responses evitando la llamada, en nuestro caso por comunicación HTTP. Para paralelizar las llamadas nos apoyamos en el modelo de promises que proporciona Guzzle. Aunque es importante decir aquí que el cliente http que ha construido Symfony ya proporciona de este mecanismo (Gist) y recomiendo echarle un vistazo.
/** * @return array */ private function executeConcurrentRequests(array $requests) { $client = new Client(); return Pool::batch($client, $requests, ['concurrency' => count($requests]); }
Usar la ruta mas corta
Si se tiene control sobre la red en la que se monta la infraestructura, es importante intentar atacar internamente las rutas mas cortas evitando redirecciones, resolución de dns y proxys intermedios que añadan milisegundos adicionales. Es muy importante trabajar de la mano de los equipos de sistemas para configurar nuestras variables de entornos de manera adecuada apuntando a los nombres de servicios adecuados depende del contexto.
Si trabajamos con un proveedor de Cloud, también es muy importante tener en cuenta los datacenter donde se despliegan cada pieza para que tampoco tengamos una gran latencia de red por la ubicación. Aunque tu desarrollo funcione correctamente en una infraestructura de desarrollo, debe de probarse cuanto antes en la infraestructura real para poder detectar este tipo de problemas.
Simplificar el modelo de seguridad
No es bueno para el rendimiento utilizar mecanismos de seguridad que implican muchas comunicaciones de ida y vuelta para autenticar una llamada. Por lo que es importante identificar donde se requiere y donde no el modelo de autenticación e intentar simplificarlo al máximo para que cada servicio no añada unos milisegundos adicionales de validación de tokens. En nuestro caso, añadir la validación del token lo antes posibles y hacer independientes a otros servicios de esta validación confiando con otros modelos de seguridad que la petición recibida del servicio X es válida.
Debuggear las llamadas http
Es importante tener visibilidad sobre las llamadas que se están haciendo y el tiempo que consumen. Hay muchas herramientas para monitorizar este aspecto en tu desarrollo y depende mucho de las tecnologías que estemos usando. En nuestro caso como usamos Guzzle para realizar las llamadas http, nos proporciona en el Profiler de Symfony una pestaña adicional donde podemos ver todas las llamadas que se han realizado en el contexto de una request y el tiempo que ha llevado en responder.
Conclusión
Cuando se elige una arquitectura de software es importante identificar al comienzo del desarrollo las ventajas e inconvenientes que nos aplica, teniendo en cuenta los posibles problemas que te vas a enfrentar. Aunque confiemos en nuestras capas de cachés, es importante que cuando se consulte el dato frio, los tiempos sean aceptables para las necesidades del software. ¡Mantén tus microservicios a dieta y paraleliza todo lo posible!