Capítulo 4. Diseccionando el monolito
Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com
El objetivo final debe ser mejorar la calidad de la vida humana mediante la innovación digital.
Pony Ma Huateng
A lo largo de la historia, el ser humano ha estado obsesionado con deconstruir ideas y conceptos en partes simples o compuestas.Es combinando el análisis y la síntesis como podemos alcanzar un mayor nivel de comprensión.
Aristóteles llamaba análisis "a la resolución de todo compuesto en aquellas cosas de las que se hace la síntesis. Pues el análisis es lo contrario de la síntesis.La síntesis es el camino que va de los principios a las cosas que derivan de los principios, y el análisis es el regreso del fin a los principios."
El desarrollo de software sigue un planteamiento similar: analizar un sistema en sus partes compuestas, identificando las entradas, las salidas deseadas y las funciones detalladas. Durante el proceso analítico del desarrollo de software, nos hemos dado cuenta de que siempre se necesita una funcionalidad no específica del negocio para procesar las entradas y comunicar o persistir las salidas. Esto hace evidente que podríamos beneficiarnos de una funcionalidad atómica reutilizable, bien definida y vinculada al contexto, que pueda compartirse, consumirse o interconectarse para simplificar la construcción de software.
Permitir que los desarrolladores se centren principalmente en implementar la lógica empresarial para cumplir propósitos -como satisfacer necesidades bien definidas de un cliente/empresa, satisfacer una necesidad percibida de algún conjunto de usuarios potenciales o utilizar la funcionalidad para necesidades personales (para automatizar tareas)- ha sido un deseo largamente acariciado. Cada día se pierde demasiado tiempo reinventando una de las ruedas más reinventadas: el código boilerplate fiable.
El patrón de microservicios ha ganado notoriedad e impulso en los últimos años porque las ventajas que promete son extraordinarias. Evitar los antipatrones conocidos, adoptar buenas prácticas y comprender los conceptos y definiciones básicos es primordial para lograr las ventajas de este patrón arquitectónico y reducir al mismo tiempo los inconvenientes de adoptarlo. Este capítulo cubre los antipatrones y contiene ejemplos de código de microservicios escritos con marcos de microservicios populares como Spring Boot, Micronaut, Quarkus y Helidon.
Tradicionalmente, una arquitectura monolítica ofrece o despliega unidades o sistemas únicos, abordando todos los requisitos desde una única aplicación de origen, y pueden identificarse dos conceptos: la aplicación monolítica y la arquitectura monolítica.
Una aplicación monolítica sólo tiene una instancia desplegada, responsable de realizar todos los pasos necesarios para una función específica. Una característica de una aplicación de este tipo es un único punto de ejecución de la interfaz.
Una arquitectura monolítica se refiere a una aplicación para la que todos los requisitos se abordan desde una única fuente y todas las partes se entregan como una unidad. Los componentes pueden haber sido diseñados para restringir la interacción con clientes externos con el fin de limitar explícitamente el acceso de funcionalidad privada. Los componentes del monolito pueden estar interconectados o ser interdependientes en lugar de estar débilmente acoplados. En otras palabras, desde la perspectiva externa o del usuario, hay poco conocimiento de las definiciones, interfaces, datos y servicios de otros componentes separados.
La granularidad es el nivel de agregación expuesto por un componente a otras partes externas cooperantes o colaboradoras del software. El nivel de granularidad en el software depende de varios factores, como el nivel de confidencialidad que debe mantenerse dentro de una serie de componentes y no estar expuesto o disponible para otros consumidores.
Las arquitecturas de software modernas se centran cada vez más en ofrecer funcionalidad agrupando o combinando componentes de software de distintas fuentes, lo que da lugar o hace hincapié en una granularidad más fina en el nivel de detalle. La funcionalidad expuesta entonces a distintos componentes, clientes o consumidores es mayor que en una aplicación monolítica.
Para calificar lo independiente o intercambiable que es un módulo, debemos fijarnos en las siguientes características:
-
Número de dependencias
-
Fuerza de estas dependencias
-
Estabilidad de los módulos de los que depende
Cualquier puntuación alta asignada a las características anteriores debe desencadenar una segunda revisión del modelado y la definición del módulo.
Computación en la nube
La computación en nube tiene varias definiciones. Peter Mell y Tim Grance la definen como un modelo que permite el acceso ubicuo, cómodo y bajo demanda a un conjunto compartido de recursos informáticos configurables (como redes, servidores, almacenamiento, aplicaciones y servicios) que se pueden aprovisionar y liberar rápidamente con un mínimo esfuerzo de gestión o interacción del proveedor de servicios.
En los últimos años, la computación en nube ha aumentado considerablemente.Por ejemplo, el gasto en servicios de infraestructura en nube aumentó un 32%, hasta 39.900 millones de dólares, en el último trimestre de 2020. El gasto total superó en más de 3.000 millones de dólares al del trimestre anterior y en casi 10.000 millones al del cuarto trimestre de 2019, según datos de Canalys.
Existen varios proveedores, pero la cuota de mercado no está distribuida uniformemente. Los tres principales proveedores de servicios son Amazon Web Services (AWS), Microsoft Azure y Google Cloud. AWS es el principal proveedor de servicios en la nube, con un 31% del gasto total en el cuarto trimestre de 2020. El ritmo de crecimiento de Azure se aceleró, un 50%, con una cuota cercana al 20%, mientras que Google Cloud representa una cuota del 7% del mercado total.
La utilización de los servicios de computación en nube se ha retrasado. Cinar Kilcioglu y Aadharsh Kannan informaron en 2017 en "Proceedings of the 26th International World Wide Web Conference" de que el uso de los recursos de la nube en los centros de datos muestra una brecha sustancial entre los recursos que los clientes de la nube asignan y por los que pagan (alquiler de máquinas virtuales), y la utilización real de los recursos (CPU, memoria, etc.) Quizás los clientes se limitan a dejar encendidas sus máquinas virtuales, pero en realidad no las utilizan.
Los servicios en la nube se dividen en categorías que se utilizan para distintos tipos de informática:
- Software como servicio (SaaS)
-
El cliente puede utilizar las aplicaciones del proveedor que se ejecutan en una infraestructura en la nube. Se puede acceder a las aplicaciones desde varios dispositivos cliente a través de una interfaz de cliente ligero, como un navegador web, o de una interfaz de programa. El cliente no gestiona ni controla la infraestructura subyacente de la nube, incluida la red, los servidores, los sistemas operativos, el almacenamiento o incluso las capacidades individuales de las aplicaciones, con la posible excepción de unos ajustes limitados de configuración de las aplicaciones específicos del usuario.
- Plataforma como servicio (PaaS)
-
El consumidor no gestiona ni controla la infraestructura subyacente de la nube, incluida la red, los servidores, los sistemas operativos o el almacenamiento, pero sí tiene control sobre las aplicaciones implementadas y, posiblemente, sobre los ajustes de configuración del entorno de alojamiento de aplicaciones.
- Infraestructura como servicio (IaaS)
-
El cliente puede aprovisionar procesamiento, almacenamiento, redes y otros recursos informáticos fundamentales. Puede desplegar y ejecutar software arbitrario, que puede incluir sistemas operativos y aplicaciones. El cliente no gestiona ni controla la infraestructura subyacente de la nube, pero tiene control sobre los sistemas operativos, el almacenamiento y las aplicaciones desplegadas, y posiblemente un control limitado de determinados componentes de red.
Microservicios
El término microservicio no es reciente. Peter Rodgers introdujo el término microservicios web en 2005, cuando defendía la idea del software como microservicios web. La arquitectura de microservicios -unaevolución de la arquitectura orientada a servicios (SOA)- organiza una aplicación como una colección de servicios modulares relativamente ligeros. Técnicamente, los microservicios son una especialización de un enfoque de implementación de SOA.
Los microservicios son componentes pequeños y débilmente acoplados.A diferencia de los monolitos, pueden desplegarse, escalarse y probarse de forma independiente, y tienen una única responsabilidad, delimitada por el contexto, y son autónomos y descentralizados. Suelen construirse en torno a capacidades empresariales, son fáciles de entender y pueden desarrollarse utilizando diferentes pilas tecnológicas.
¿Cómo de pequeño debe ser un microservicio? Debe ser lo suficientemente micro como para permitir pequeños átomos de funcionalidad autocontenidos y rígidos que puedan coexistir, evolucionar o sustituir a los anteriores según las necesidades del negocio.
Cada componente o servicio tiene poco o ningún conocimiento de las definiciones de otros componentes independientes, y toda interacción con un servicio se realiza a través de su API, que encapsula sus detalles de implementación. La mensajería entre estos microservicios utiliza protocolos sencillos y normalmente no es intensiva en datos.
Antipatrones
El patrón de microservicio da lugar a una complejidad significativa y no es ideal en todas las situaciones.El sistema se compone de muchas partes que funcionan de forma independiente, y su propia naturaleza hace que sea más difícil predecir cómo funcionará en el mundo real.
Esta mayor complejidad se debe principalmente a los (potencialmente) miles de microservicios que se ejecutan de forma asíncrona en la red informática distribuida. Ten en cuenta que los programas que son difíciles de entender también son difíciles de escribir, modificar, probar y medir. Todas estas preocupaciones aumentarán el tiempo que los equipos deben dedicar a entender, discutir, seguir y probar las interfaces y los formatos de los mensajes.
Existen varios libros, artículos y documentos sobre este tema en particular. Recomiendo visitar Microservices.io, el informe de Mark Richards Microservices AntiPatterns and Pitfalls (O'Reilly), y "On the Definition of Microservice Bad Smells" de Davide Taibi y Valentina Lenarduzz (publicado en IEEE Software en 2018).
Algunos de los antipatrones más comunes son los siguientes:
- Versionado de la API(trampa del contrato estático)
-
Las API tienen que estar semánticamente versionadas para que los servicios puedan saber si se comunican con la versión correcta del servicio o si tienen que adaptar su comunicación a un nuevo contrato.
- Interdependencia inapropiada de la privacidad del servicio
-
El microservicio requiere datos privados de otros servicios en lugar de tratar con sus propios datos, un problema que suele estar relacionado con un problema de modelado de los datos. Una solución a considerar es fusionar los microservicios.
- Megaservicio polivalente
-
Varias funciones empresariales se implementan en el mismo servicio.
- Registro
-
Los errores y la información de los microservicios se ocultan dentro de cada contenedor de microservicios. La adopción de un sistema de registro distribuido debería ser una prioridad, ya que los problemas se encuentran en todas las fases del ciclo de vida del software.
- Dependencias interservicios o circulares complejas
-
Una relación circular de servicio se define como una relación entre dos o más servicios que son interdependientes.Las dependencias circulares pueden perjudicar la capacidad de los servicios para escalar o implementarse de forma independiente, así como violar el principio de las dependencias acíclicas (ADP).
- Falta la pasarela API
-
Cuando los microservicios se comunican directamente entre sí, o cuando los consumidores de servicios se comunican directamente con cada microservicio, aumenta la complejidad y disminuye el mantenimiento del sistema. La mejor práctica en este caso es utilizar una pasarela API.
Una pasarela API recibe todas las llamadas API de los clientes y, a continuación, las dirige al microservicio adecuado mediante el enrutamiento de la solicitud, la composición y la traducción de protocolos. La pasarela suele gestionar la solicitud llamando a varios microservicios y agregando los resultados para determinar la mejor ruta. También es capaz de traducir entre protocolos web y protocolos amigables para uso interno.
Una aplicación puede utilizar una pasarela API para proporcionar un único punto final para que los clientes móviles consulten todos los datos de los productos con una única solicitud. La pasarela API consolida varios servicios, como información y reseñas de productos, y combina y expone los resultados.
La pasarela API es el guardián de las aplicaciones para acceder a datos, lógica empresarial o funciones (API RESTful o API WebSocket) que permiten aplicaciones de comunicación bidireccional en tiempo real. La pasarela API suele encargarse de todas las tareas relacionadas con la aceptación y el procesamiento de hasta cientos de miles de llamadas API simultáneas, incluida la gestión del tráfico, la compatibilidad con recursos compartidos entre orígenes (CORS), la autorización y el control de acceso, el estrangulamiento, la gestión y el control de la versión de la API.
- Compartir demasiado
-
Existe una delgada línea entre compartir suficiente funcionalidad para no repetirse y crear una maraña de dependencias que impida separar los cambios de servicio. Si hay que cambiar un servicio sobrecompartido, evaluar los cambios propuestos en las interfaces acabará provocando una tarea de organización que implicará a más equipos de desarrollo.
En algún momento, hay que analizar la opción de la redundancia o la extracción de bibliotecas en un nuevo servicio compartido que los microservicios relacionados puedan instalar y desarrollar independientemente unos de otros.
DevOps y Microservicios
Los microservicios encajan perfectamente en el ideal de DevOps de de utilizar equipos pequeños para crear cambios funcionales en los servicios de la empresa paso a paso: la idea de dividir los grandes problemas en trozos más pequeños y abordarlos sistemáticamente. Para reducir la fricción entre el desarrollo, las pruebas y la implementación de servicios independientes más pequeños, tiene que haber una serie de conductos de entrega continua para mantener un flujo constante de estas etapas.
DevOps es un factor clave en el éxito de este estilo arquitectónico, ya que proporciona los cambios organizativos necesarios para minimizar la coordinación entre los equipos responsables de cada componente y eliminar las barreras a la interacción eficaz y recíproca entre los equipos de desarrollo y operaciones.
Marcos de microservicios
El ecosistema JVM es vasto y ofrece a multitud de alternativas para un caso de uso concreto. Hay docenas de marcos y bibliotecas de microservicios disponibles, hasta el punto de que puede ser difícil elegir un ganador entre los candidatos.
Dicho esto, ciertos marcos candidatos han ganado popularidad por varias razones: experiencia del desarrollador, tiempo de comercialización, extensibilidad, consumo de recursos (CPU, memoria), velocidad de arranque, recuperación ante fallos, documentación, integraciones con terceros, etc. Estos marcos -Spring Boot, Micronaut, Quarkus y Helidon- se tratan en las siguientes secciones. Ten en cuenta que algunas de las instrucciones pueden requerir ajustes adicionales basados en versiones más recientes, ya que algunas de estas tecnologías evolucionan con bastante rapidez. Recomiendo encarecidamente revisar la documentación de cada marco.
Además, estos ejemplos requieren Java 11 como mínimo, y probar Native Image también requiere una instalación de GraalVM. Hay muchas formas de conseguir que estas versiones se instalen en tu entorno.Recomiendo utilizar SDKMAN! para instalarlas y gestionarlas. Por brevedad, me concentro sólo en el código de producción: ¡un solo framework podría llenar un libro entero! Huelga decir que también deberías ocuparte de las pruebas. El objetivo de cada ejemplo es construir un servicio REST trivial de "Hola Mundo" que pueda tomar un parámetro de nombre opcional y responder con un saludo.
Si no has trabajado antes con GraalVM, es un proyecto paraguas para un puñado de tecnologías que permiten las siguientes funciones:
-
Un compilador justo a tiempo (JIT) escrito en Java, que compila código sobre la marcha, transformando el código interpretado en código ejecutable. La plataforma Java ha tenido un puñado de JIT, la mayoría escritos utilizando una combinación de C y C++. Graal resulta ser el más moderno, escrito en Java.
-
Una máquina virtual llamada Substrate VM, capaz de ejecutar lenguajes alojados como Python, JavaScript y R sobre la JVM, de forma que el lenguaje alojado se beneficie de una mayor integración con las capacidades y características de la JVM.
-
Native Image, una utilidad que se basa en la compilación anticipada (AOT), que transforma el código de bytes en código ejecutable por la máquina. La transformación resultante produce un ejecutable binario específico de la plataforma.
Los cuatro marcos de trabajo candidatos que se cubren aquí proporcionan soporte para GraalVM de una forma u otra, principalmente basándose en GraalVM Native Image para producir binarios específicos de la plataforma con el objetivo de reducir el tamaño de la implementación y el consumo de memoria. Ten en cuenta que hay un equilibrio entre utilizar el modo Java y el modo GraalVM Native Image. Este último puede producir binarios con una menor huella de memoria y un tiempo de arranque más rápido, pero requiere un tiempo de compilación más largo; el código Java de larga ejecución acabará optimizándose (es una de las características clave de la JVM), mientras que los binarios nativos no pueden optimizarse mientras se ejecutan. La experiencia de desarrollo también varía, ya que puede que necesites utilizar herramientas adicionales para depurar, monitorizar, medir, etc.
Spring Boot
Spring Boot es quizás el más conocido entre los cuatro candidatos, ya que se basa en el legado establecido por Spring Framework. Si hay que tomar al pie de la letra las encuestas realizadas a desarrolladores, más del 60% de los desarrolladores Java tienen algún tipo de experiencia interactuando con proyectos relacionados con Spring, lo que convierte a Spring Boot en la opción más popular.
El modo Spring te permite montar aplicaciones (o microservicios, en nuestro caso) componiendo componentes existentes, personalizando su configuración y prometiendo una propiedad de código de bajo coste, ya que tu lógica personalizada es supuestamente de menor tamaño que la que aporta el framework, y para la mayoría de las organizaciones eso es cierto. El truco está en encontrar un componente existente que pueda ajustarse y configurarse antes de escribir el tuyo propio. El equipo de Spring Boot se esfuerza en añadir tantas integraciones útiles como sean necesarias, desde controladores de bases de datos a servicios de monitoreo, registro, diario, procesamiento por lotes, generación de informes y mucho más.
La forma típica de arrancar un proyecto Spring Boot es ir a Spring Initializr, seleccionar las características que necesitas en tu aplicación y hacer clic en el botón Generar. Esta acción crea un archivo ZIP que puedes descargar en tu entorno local para empezar. En la Figura 4-1, he seleccionado las características Web y Spring Native. La primera característica añade componentes que te permiten exponer datos mediante API REST; la segunda mejora la compilación con un mecanismo de empaquetado adicional que puede crear Imágenes Native con Graal.
Descomprimir el archivo ZIP y ejecutar el comando ./mvnw verify
en el directorio raíz del proyecto garantiza un buen punto de partida. Observarás que el comando descargará un conjunto de dependencias si no has creado antes una aplicación Spring Boot en tu entorno de destino. Éste es el comportamiento normal de Apache Maven. Estas dependencias no se descargarán de nuevo la próxima vez que invoques un comando de Maven, a menos que se actualicen las versiones de las dependencias en el archivo pom.xml.
La estructura del proyecto debería tener este aspecto:
. ├── HELP.md ├── mvnw ├── mvnw.cmd ├── pom.xml └── src ├── main │ ├── java │ │ └── com │ │ └── example │ │ └── demo │ │ ├── DemoApplication.java │ │ ├── Greeting.java │ │ └── GreetingController.java │ └── resources │ ├── application.properties │ ├── static │ └── templates └── test └── java
Nuestra tarea actual requiere dos fuentes adicionales que no fueron creadas por el sitio web Spring Initializr: Greeting .java y GreetingController.java. Estos dos archivos pueden crearse utilizando el editor de texto o IDE que prefieras. El primero, Greeting.java, define un objeto de datos que se utilizará para representar el contenido como Notación de Objetos JavaScript (JSON), un formato típico utilizado para exponer datos a través de REST. También se admiten otros formatos, pero la compatibilidad con JSON viene de fábrica sin necesidad de dependencias adicionales. Este archivo debería tener el siguiente aspecto:
package
com
.
example
.
demo
;
public
class
Greeting
{
private
final
String
content
;
public
Greeting
(
String
content
)
{
this
.
content
=
content
;
}
public
String
getContent
()
{
return
content
;
}
}
No hay nada especial en este soporte de datos, excepto que es inmutable; dependiendo de tu caso de uso, puede que quieras cambiar a una implementación mutable, pero por ahora esto será suficiente. A continuación está el propio punto final REST, definido como una llamada a GET
en una ruta /greeting.
Spring Boot prefiere el estereotipo controlador para este tipo de componente, sin duda rememorando los días en que Spring MVC (sí, eso es modelo-vista-controlador) era la opción preferida para crear aplicaciones web. Siéntete libre de utilizar un nombre de archivo diferente, pero la anotación del componente debe permanecer intacta:
package
com
.
example
.
demo
;
import
org.springframework.web.bind.annotation.GetMapping
;
import
org.springframework.web.bind.annotation.RequestParam
;
import
org.springframework.web.bind.annotation.RestController
;
@RestController
public
class
GreetingController
{
private
static
final
String
template
=
"Hello, %s!"
;
@GetMapping
(
"/greeting"
)
public
Greeting
greeting
(
@RequestParam
(
value
=
"name"
,
defaultValue
=
"World"
)
String
name
)
{
return
new
Greeting
(
String
.
format
(
template
,
name
));
}
}
El controlador puede tomar un parámetro name
como entrada y utilizará el valor World
cuando no se proporcione dicho parámetro. Observa que el tipo de retorno del método mapeado es un tipo Java plano; es el tipo de datos que acabamos de definir en el paso anterior. Spring Boot marshalizará automáticamente los datos desde y hacia JSON basándose en las anotaciones aplicadas al controlador y sus métodos, así como en los valores predeterminados sensatos que se hayan establecido.
Si dejamos el código como está, el valor de retorno del método greeting()
se transformará automáticamente en una carga útil JSON. Ésta es la potencia de la experiencia de desarrollador de Spring Boot, que se basa en valores predeterminados y en una configuración predefinida que puede ajustarse según sea necesario.
Puedes ejecutar la aplicación mediante invocando el comando /.mvnw spring-boot:run
, que ejecuta la aplicación como parte del proceso de compilación, o generando el JAR de la aplicación y ejecutándolo manualmente, es decir, ./mvnw package
seguido de java -jar target/demo-0.0.1.SNAPSHOT.jar
. De cualquier forma, se iniciará un servidor web embebido que escuchará en el puerto 8080; la ruta /greeting se asignará a una instancia de GreetingController. Todo lo que queda por hacer es emitir un par de consultas, como las siguientes:
// using the default name parameter $ curl http://localhost:8080/greeting {"content":"Hello, World!"} // using an explicit value for the name parameter $ curl http://localhost:8080/greeting?name=Microservices {"content":"Hello, Microservices!"}
Toma nota de la salida generada por la aplicación mientras se ejecuta. En mi entorno local, muestra (de media) que la JVM tarda 1,6 segundos en arrancar, mientras que la aplicación tarda 600 milisegundos en inicializarse. El tamaño del JAR generado es de aproximadamente 17 MB. También puedes tomar nota del consumo de CPU y memoria de esta aplicación trivial. Desde hace algún tiempo, se viene sugiriendo en que el uso de GraalVM Native Image puede reducir el tiempo de arranque y el tamaño del binario. Veamos cómo podemos conseguirlo con Spring Boot.
¿Recuerdas que seleccionamos la función Spring Native al crear el proyecto? Lamentablemente, en la versión 2.5.0 el proyecto generado no incluye todas las instrucciones necesarias en el archivo pom.xml, por lo que debemos hacer algunos ajustes.
Para empezar, el JAR creado por spring-boot-maven-plugin
requiere un clasificador; de lo contrario, es posible que la Imagen Nativa resultante no se cree correctamente. Esto se debe a que el JAR de la aplicación ya contiene todas las dependencias dentro de una ruta específica de Spring Boot que no gestiona native-image-maven-plugin
, que también tenemos que configurar. El archivo pom. xml actualizado debería tener este aspecto:
<?xml version="1.0" encoding="UTF-8"?>
<project
xmlns=
"http://maven.apache.org/POM/4.0.0"
xmlns:xsi=
"http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation=
"http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd"
>
<modelVersion>
4.0.0</modelVersion>
<parent>
<groupId>
org.springframework.boot</groupId>
<artifactId>
spring-boot-starter-parent</artifactId>
<version>
2.5.0</version>
</parent>
<groupId>
com.example</groupId>
<artifactId>
demo</artifactId>
<version>
0.0.1-SNAPSHOT</version>
<name>
demo</name>
<description>
Demo project for Spring Boot</description>
<properties>
<java.version>
11</java.version>
<spring-native.version>
0.10.0-SNAPSHOT</spring-native.version>
</properties>
<dependencies>
<dependency>
<groupId>
org.springframework.boot</groupId>
<artifactId>
spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>
org.springframework.experimental</groupId>
<artifactId>
spring-native</artifactId>
<version>
${spring-native.version}</version>
</dependency>
<dependency>
<groupId>
org.springframework.boot</groupId>
<artifactId>
spring-boot-starter-test</artifactId>
<scope>
test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>
org.springframework.boot</groupId>
<artifactId>
spring-boot-maven-plugin</artifactId>
<configuration>
<classifier>
exec</classifier>
</configuration>
</plugin>
<plugin>
<groupId>
org.springframework.experimental</groupId>
<artifactId>
spring-aot-maven-plugin</artifactId>
<version>
${spring-native.version}</version>
<executions>
<execution>
<id>
test-generate</id>
<goals>
<goal>
test-generate</goal>
</goals>
</execution>
<execution>
<id>
generate</id>
<goals>
<goal>
generate</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>
spring-release</id>
<name>
Spring release</name>
<url>
https://repo.spring.io/release</url>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>
spring-release</id>
<name>
Spring release</name>
<url>
https://repo.spring.io/release</url>
</pluginRepository>
</pluginRepositories>
<profiles>
<profile>
<id>
native-image</id>
<build>
<plugins>
<plugin>
<groupId>
org.graalvm.nativeimage</groupId>
<artifactId>
native-image-maven-plugin</artifactId>
<version>
21.1.0</version>
<configuration>
<mainClass>
com.example.demo.DemoApplication</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>
native-image</goal>
</goals>
<phase>
package</phase>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
Un paso más antes de que podamos probarlo: asegúrate de tener instalada una versión de GraalVM como tu JDK actual. La versión seleccionada debe coincidir estrechamente con la versión de native-image-maven-plugin
que se encuentra en el archivo pom.xml. El ejecutable native-image
también debe estar instalado en tu sistema; puedes hacerlo invocando a gu install native-image
. El comando gu
lo proporciona la instalación de GraalVM.
Con todas las configuraciones en su sitio, podemos generar un ejecutable nativo invocando ./mvnw -Pnative-image package
. Notarás una ráfaga de texto recorriendo la pantalla mientras se descargan nuevas dependencias, y quizás algunas advertencias relacionadas con clases que faltan; eso es normal.
La compilación también tarda más de lo habitual, y aquí radica la contrapartida de esta solución de empaquetado: estamos aumentando el tiempo de desarrollo para acelerar el tiempo de ejecución en producción. Una vez finalizado el comando, observarás un nuevo archivo com.example.demo.demoapplication dentro del directorio de destino. Éste es el ejecutable nativo. Adelante, ejecútalo.
¿Te has dado cuenta de lo rápido que ha sido el arranque? En mi entorno, obtengo de media un tiempo de arranque de 0,06 segundos, mientras que la aplicación tarda 30 milisegundos en inicializarse. Recordarás que estas cifras eran de 1,6 segundos y 600 milisegundos cuando se ejecutaba en modo Java. ¡Eso sí que es un aumento de velocidad! Ahora echa un vistazo al tamaño del ejecutable; en mi caso, es de unos 78 MB. Vaya, parece que algunas cosas han crecido a peor... ¿o no? Este ejecutable es un único binario que proporciona todo lo necesario para ejecutar la aplicación, mientras que el JAR que utilizamos antes requiere un tiempo de ejecución de Java para ejecutarse. El tamaño de un tiempo de ejecución de Java suele rondar los 200 MB y se compone de múltiples archivos y directorios. Por supuesto, se pueden crear tiempos de ejecución de Java más pequeños con jlink, en cuyo caso eso añade otro paso durante el proceso de compilación. No hay almuerzo gratis.
Dejemos Spring Boot por ahora, teniendo en cuenta que hay mucho más de lo que se ha mostrado aquí. Pasemos al siguiente framework.
Micronauta
Micronaut nació en 2017 como una reimaginación del framework Grails, pero con un aspecto moderno. Grails es uno de los pocos "clones" de éxito del framework Ruby on Rails (RoR), que aprovecha el lenguaje de programación Groovy. Grails causó sensación durante unos años, hasta que el auge de Spring Boot lo apartó de los focos, lo que llevó al equipo de Grails a buscar alternativas, que dieron lugar a Micronaut. A primera vista, Micronaut ofrece una experiencia de usuario similar a Spring Boot, ya que también permite a los desarrolladores componer aplicaciones basadas en componentes existentes y valores predeterminados razonables.
Uno de los elementos diferenciadores clave de Micronaut es el uso de la inyección de dependencias en tiempo de compilación para ensamblar la aplicación, en contraposición a la inyección de dependencias en tiempo de ejecución, que es la forma preferida de ensamblar aplicaciones con Spring Boot hasta ahora. Este cambio aparentemente trivial permite a Micronaut intercambiar un poco de tiempo de desarrollo por un aumento de velocidad en tiempo de ejecución, ya que la aplicación pasa menos tiempo arrancándose a sí misma; esto también puede conducir a un menor consumo de memoria y una menor dependencia de la reflexión de Java, que históricamente ha sido más lenta que las invocaciones directas de métodos.
Hay un puñado de formas de arrancar un proyecto Micronaut, pero la preferida es navegar hasta Micronaut Launch y seleccionar los ajustes y características que te gustaría que se añadieran al proyecto. El tipo de aplicación por defecto define los ajustes mínimos para construir una aplicación basada en REST como la que veremos en unos minutos. Una vez satisfecho con tu selección, haz clic en el botón Generar proyecto, como se muestra en la Figura 4-2, que da como resultado un archivo ZIP que puedes descargar a tu entorno de desarrollo local.
Al igual que hicimos para Spring boot, desempaquetar el archivo ZIP y ejecutar el comando ./mvnw verify
en el directorio raíz del proyecto garantiza un buen punto de partida. Esta invocación al comando descargará los plug-ins y las dependencias según sea necesario; la compilación debería tener éxito al cabo de unos segundos si todo va bien. La estructura del proyecto debería parecerse a la siguiente después de añadir un par de archivos fuente adicionales:
. ├── README.md ├── micronaut-cli.yml ├── mvnw ├── mvnw.bat ├── pom.xml └── src └── main ├── java │ └── com │ └── example │ └── demo │ ├── Application.java │ ├── Greeting.java │ └── GreetingController.java └── resources ├── application.yml └── logback.xml
El archivo fuente Application.java define el punto de entrada, que dejaremos intacto por ahora, ya que no es necesario realizar ninguna actualización. Del mismo modo, también dejaremos sin cambios el archivo de recursos application.yml; este recurso proporciona propiedades de configuración que no requieren cambios en este momento.
Necesitamos dos archivos fuente adicionales: el objeto de datos definido por Greeting.java, cuya responsabilidad es contener un mensaje enviado de vuelta al consumidor, y el punto final REST real definido por GreetingController.java. El estereotipo del controlador se remonta a las convenciones establecidas por Grails, que también siguen prácticamente todos los clones de RoR. Puedes cambiar el nombre del archivo por cualquier cosa que se adapte a tu dominio, aunque debes dejar la anotación @Controller
. El código fuente del objeto de datos debe tener este aspecto:
package
com
.
example
.
demo
;
import
io.micronaut.core.annotation.Introspected
;
@Introspected
public
class
Greeting
{
private
final
String
content
;
public
Greeting
(
String
content
)
{
this
.
content
=
content
;
}
public
String
getContent
()
{
return
content
;
}
}
Una vez más, confiamos en un diseño inmutable para esta clase. Observa el uso de la anotación@Introspected
, que indica a Micronaut que inspeccione el tipo en tiempo de compilación y lo incluya como parte del procedimiento de inyección de dependencias. Normalmente, la anotación puede omitirse, ya que Micronaut deducirá que la clase es necesaria. Pero su uso es primordial cuando se trata de generar el ejecutable nativo con GraalVM Native Image; de lo contrario, el ejecutable no estará completo. El segundo archivo debería tener este aspecto:
package
com
.
example
.
demo
;
import
io.micronaut.http.annotation.Controller
;
import
io.micronaut.http.annotation.Get
;
import
io.micronaut.http.annotation.QueryValue
;
@Controller
(
"/"
)
public
class
GreetingController
{
private
static
final
String
template
=
"Hello, %s!"
;
@Get
(
uri
=
"/greeting"
)
public
Greeting
greeting
(
@QueryValue
(
value
=
"name"
,
defaultValue
=
"World"
)
String
name
)
{
return
new
Greeting
(
String
.
format
(
template
,
name
));
}
}
Podemos apreciar que el controlador define un único endpoint mapeado a /greeting
, toma un parámetro opcional llamado name
, y devuelve una instancia del objeto de datos. Por defecto, Micronaut marshalizará el valor de retorno como JSON, por lo que no se requiere ninguna configuración extra para que esto ocurra. Ejecutar la aplicación puede hacerse de un par de maneras.
Puedes invocar a ./mvnw mn:run
, que ejecuta la aplicación como parte del proceso de compilación, o invocar a ./mvnw package
, que crea un demo-0.1.jar en el directorio de destino que se puede lanzar de la forma convencional, es decir, con java -jar target/demo-0.1.jar
. Invocar un par de consultas al punto final REST puede dar como resultado una salida similar a ésta:
// using the default name parameter $ curl http://localhost:8080/greeting {"content":"Hello, World!"} // using an explicit value for the name parameter $ curl http://localhost:8080/greeting?name=Microservices {"content":"Hello, Microservices!"}
Cualquiera de los dos comandos lanza la aplicación con bastante rapidez. En mi entorno local, la aplicación está lista para procesar peticiones en 500 milisegundos de media, o tres veces la velocidad de Spring Boot para un comportamiento equivalente. El tamaño del archivo JAR también es algo menor, de 14 MB en total. Por impresionantes que sean estas cifras, podemos obtener un aumento de velocidad si la aplicación se transformara mediante GraalVM Native Image en un ejecutable nativo. Afortunadamente para nosotros, el método Micronaut es más amigable con este tipo de configuración, ya que todo lo que necesitamos ya está configurado en el proyecto generado. Eso es todo. No es necesario actualizar el archivo de compilación con configuraciones adicionales: está todo ahí.
Sin embargo, sí se requiere una instalación de GraalVM y su ejecutable native-image
, como hicimos antes. Crear un ejecutable nativo es tan sencillo como invocar ./mvnw -Dpackaging=native-image package
, y al cabo de unos minutos deberíamos obtener un ejecutable llamado demo
(de hecho, es el artifactId
del proyecto, por si te lo estabas preguntando) dentro del directorio de destino.
Lanzar la aplicación con el ejecutable nativo da como resultado un tiempo medio de arranque de 20 milisegundos, lo que supone una ganancia de un tercio en velocidad en comparación con Spring Boot. El tamaño del ejecutable es de 60 MB, lo que se corresponde con el tamaño reducido del archivo JAR.
Dejemos de explorar Micronauta y pasemos al siguiente marco: Quarkus.
Quarkus
Aunque Quarkus se anunció a principios de 2019, el trabajo en comenzó mucho antes. Quarkus tiene muchas similitudes con los dos candidatos que hemos visto hasta ahora. Ofrece una gran experiencia de desarrollo basada en componentes, convención sobre la configuración y herramientas de productividad. Aún más, Quarkus decidió utilizar también la inyección de dependencias en tiempo de compilación como Micronaut, lo que le permite obtener las mismas ventajas, como binarios más pequeños, un inicio más rápido y menos magia en tiempo de ejecución. Al mismo tiempo, Quarkus añade su propio sabor y carácter distintivo, y quizás lo más importante para algunos desarrolladores, Quarkus se basa más en estándares que los otros dos candidatos. Quarkus implementa las especificaciones MicroProfile, que son estándares que provienen de JakartaEE (antes conocido como JavaEE), y estándares adicionales desarrollados bajo el paraguas del proyecto MicroProfile.
Puedes empezar a utilizar Quarkus accediendo a la página Quarkus Configura tu aplicación para configurar los valores y descargar un archivo ZIP. Esta página está repleta de extras, incluidas muchas extensiones entre las que elegir para configurar integraciones específicas, como bases de datos, capacidades REST, monitoreo, etc. Debe seleccionarse la extensión RESTEasy Jackson, que permite a Quarkus transferir valores sin problemas desde y hacia JSON. Al hacer clic en el botón "Genera tu aplicación", se te pedirá que guardes un archivo ZIP en tu sistema local, cuyo contenido debe tener un aspecto similar a éste:
.
├──
README
.
md
├──
mvnw
├──
mvnw
.
cmd
├──
pom
.
xml
└──
src
├──
main
│
├──
docker
│
│
├──
Dockerfile
.
jvm
│
│
├──
Dockerfile
.
legacy
-
jar
│
│
├──
Dockerfile
.
native
│
│
└──
Dockerfile
.
native
-
distroless
│
├──
java
│
│
└──
com
│
│
└──
example
│
│
└──
demo
│
│
├──
Greeting
.
java
│
│
└──
GreetingResource
.
java
│
└──
resources
│
├──
META
-
INF
│
│
└──
resources
│
│
└──
index
.
html
│
└──
application
.
properties
└──
test
└──
java
Podemos apreciar que Quarkus añade archivos de configuración Docker de fábrica, ya que se diseñó para abordar arquitecturas de microservicios en la nube a través de contenedores y Kubernetes. Pero, con el paso del tiempo, su alcance se ha ampliado al admitir tipos de aplicaciones y arquitecturas adicionales. El archivo GreetingResource.java también se crea por defecto, y es un recurso típico de los Servicios Web RESTful de Jakarta (JAX-RS). Tendremos que hacer algunos ajustes en ese recurso para que pueda manejar el objeto de datos Greeting.java. Aquí está la fuente para ello:
package
com
.
example
.
demo
;
public
class
Greeting
{
private
final
String
content
;
public
Greeting
(
String
content
)
{
this
.
content
=
content
;
}
public
String
getContent
()
{
return
content
;
}
}
El código es prácticamente idéntico a lo que hemos visto antes en este capítulo. No hay nada nuevo ni sorprendente en este objeto de datos inmutable. Ahora bien, en el caso del recurso JAX-RS, las cosas tendrán un aspecto similar pero diferente, ya que el comportamiento que buscamos es el mismo que antes, aunque la forma en que instruimos al framework para que realice su magia es mediante anotaciones JAX-RS. Así, el código tiene el siguiente aspecto:
package
com
.
example
.
demo
;
import
javax.ws.rs.DefaultValue
;
import
javax.ws.rs.GET
;
import
javax.ws.rs.Path
;
import
javax.ws.rs.QueryParam
;
@Path
(
"/greeting"
)
public
class
GreetingResource
{
private
static
final
String
template
=
"Hello, %s!"
;
@GET
public
Greeting
greeting
(
@QueryParam
(
"name"
)
@DefaultValue
(
"World"
)
String
name
)
{
return
new
Greeting
(
String
.
format
(
template
,
name
));
}
}
Si estás familiarizado con JAX-RS, este código no debería sorprenderte. Pero si no estás familiarizado con las anotaciones JAX-RS, lo que hacemos aquí es marcar el recurso con la ruta REST a la que queremos reaccionar; también indicamos que el método greeting()
gestionará una llamada a GET
, y que su parámetro name
tiene un valor por defecto. No hay que hacer nada más para indicar a Quarkus que marshale el valor de retorno en JSON, ya que eso ocurrirá por defecto.
Ejecutar la aplicación también puede hacerse de un par de formas, utilizando el modo desarrollador como parte de la compilación. Esta es una de las características que tiene un sabor único de Quarkus, ya que te permite ejecutar la aplicación y recoger automáticamente cualquier cambio que hayas hecho sin tener que reiniciar manualmente la aplicación. Puedes activar este modo invocando /.mvnw compile quarkus:dev
. Si haces algún cambio en los archivos fuente, verás que la compilación recompilará y cargará automáticamente la aplicación.
También puedes ejecutar la aplicación utilizando el intérprete java
como hemos visto antes, lo que da como resultado un comando como java -jar target/quarkus-app/quarkus-run.jar
. Ten en cuenta que estamos utilizando un JAR diferente, aunque el demo-1.0.0-SNAPSHOT.jar existe en el directorio de destino; la razón para hacerlo así es que Quarkus aplica una lógica personalizada para acelerar el proceso de arranque incluso en el modo Java.
Ejecutar la aplicación debería dar como resultado tiempos de inicio con una media de 600 milisegundos, lo que se aproxima bastante a lo que hace Micronaut. Además, el tamaño de la aplicación completa está en el rango de los 13 MB. Enviar un par de peticiones GET
a la aplicación sin y con un parámetro name
da como resultado una salida similar a la siguiente:
// using the default name parameter $ curl http://localhost:8080/greeting {"content":"Hello, World!"} // using an explicit value for the name parameter $ curl http://localhost:8080/greeting?name=Microservices {"content":"Hello, Microservices!"}
No debería sorprenderte que Quarkus también soporte la generación de ejecutables nativos mediante GraalVM Native Image, dado que se dirige a entornos en la nube en los que se recomienda un tamaño de binario pequeño. Por ello, Quarkus viene con las pilas incluidas, al igual que Micronaut, y genera todo lo que necesitas desde el primer momento. No es necesario actualizar la configuración de compilación para empezar con los ejecutables nativos.
Como en los otros ejemplos, debes asegurarte de que el JDK actual apunta a una distribución de GraalVM y de que el ejecutable native-image
se encuentra en tu ruta. Una vez superado este paso, sólo queda empaquetar la aplicación como un ejecutable nativo invocando a ./mvnw -Pnative package
. Esto activa el perfil native
, que indica a las herramientas de compilación de Quarkus que generen el ejecutable nativo.
Al cabo de un par de minutos, la compilación debería haber producido un ejecutable llamado demo-1.0.0-SNAPSHOT-runner dentro del directorio de destino. Al ejecutar este ejecutable se observa que la aplicación se inicia en 15 milisegundos de media. El tamaño del ejecutable se aproxima a los 47 MB, lo que convierte a Quarkus en el framework que ofrece el inicio más rápido y el tamaño de ejecutable más pequeño hasta el momento en comparación con los anteriores frameworks candidatos.
Hemos terminado con Quarkus por el momento, lo que nos deja con el cuarto marco candidato: Helidon.
Helidon
Por último, pero no por ello menos importante, Helidon es un framework específicamente diseñado para construir microservicios con dos sabores: SE y MP: SE y MP. El sabor MP significa MicroProfile y te permite crear aplicaciones aprovechando el poder de los estándares; este sabor es una implementación completa de las especificaciones de MicroProfile. El sabor SE, por otro lado, no implementa MicroProfile, pero ofrece una funcionalidad similar utilizando un conjunto diferente de API. Elige un sabor en función de las API con las que te gustaría interactuar y de tu preferencia por los estándares; en cualquier caso, Helidon hace su trabajo.
Dado que Helidon implementa MicroProfile, podemos utilizar otro sitio para arrancar un proyecto Helidon.El sitio MicroProfile Starter(Figura 4-3) puede utilizarse para crear proyectos para todas las implementaciones compatibles de la especificación MicroProfile por versiones.
Navega hasta el sitio, selecciona la versión de MP que te interesa, elige la implementación de MP (en nuestro caso, Helidon), y quizás personaliza algunas de las funciones disponibles. A continuación, haz clic en el botón Descargar para descargar un archivo ZIP que contiene el proyecto generado. El archivo ZIP contiene una estructura de proyecto similar a la siguiente, excepto, claro está, que yo ya he actualizado las fuentes con los dos archivos necesarios para que la aplicación funcione como queremos:
. ├── pom.xml ├── readme.md └── src └── main ├── java │ └── com │ └── example │ └── demo │ ├── Greeting.java │ └── GreetingResource.java └── resources ├── META-INF │ ├── beans.xml │ └── microprofile-config.properties ├── WEB │ └── index.html ├── logging.properties └── privateKey.pem
Resulta que los archivos fuente Greeting.java y GreetingResource.java son idénticos a los que vimos en el ejemplo de Quarkus. ¿Cómo es posible? En primer lugar, porque el código es definitivamente trivial, pero también (y más importante) porque ambos marcos de trabajo se basan en el poder de los estándares. De hecho, el archivo Greeting. java es prácticamente idéntico en todos los marcos de trabajo, excepto en Micronaut, que requiere una anotación adicional, pero sólo si te interesa generar ejecutables nativos. java es prácticamente idéntico en todos los marcos de trabajo, excepto en Micronaut, que requiere una anotación adicional, pero sólo si te interesa generar ejecutables nativos; por lo demás, es 100% idéntico. Si has decidido saltar a esta sección antes de consultar las demás, éste es el aspecto del archivo Greeting.java:
package
com
.
example
.
demo
;
import
io.helidon.common.Reflected
;
@Reflected
public
class
Greeting
{
private
final
String
content
;
public
Greeting
(
String
content
)
{
this
.
content
=
content
;
}
public
String
getContent
()
{
return
content
;
}
}
No es más que un objeto de datos inmutable normal con un único accesor. A continuación se incluye el archivo GreetingResource.java, que define las asignaciones REST necesarias para la aplicación:
package
com
.
example
.
demo
;
import
javax.ws.rs.DefaultValue
;
import
javax.ws.rs.GET
;
import
javax.ws.rs.Path
;
import
javax.ws.rs.QueryParam
;
@Path
(
"/greeting"
)
public
class
GreetingResource
{
private
static
final
String
template
=
"Hello, %s!"
;
@GET
public
Greeting
greeting
(
@QueryParam
(
"name"
)
@DefaultValue
(
"World"
)
String
name
)
{
return
new
Greeting
(
String
.
format
(
template
,
name
));
}
}
Podemos apreciar el uso de anotaciones JAX-RS, ya que podemos ver que no hay necesidad de APIs específicas de Helidon en este punto. La forma preferida de ejecutar una aplicación Helidon es empaquetar los binarios y ejecutarlos con el intérprete java
. Es decir, perdemos un poco de integración con la herramienta de compilación (por ahora), aunque podemos seguir utilizando la línea de comandos para realizar un desarrollo iterativo. Así, invocar mvn package
seguido de java -jar/demo.jar
compila, empaqueta y ejecuta la aplicación con un servidor web embebido que escucha en el puerto 8080. Podemos enviarle un par de consultas, como ésta:
// using the default name parameter $ curl http://localhost:8080/greeting {"content":"Hello, World!"} // using an explicit value for the name parameter $ curl http://localhost:8080/greeting?name=Microservices {"content":"Hello, Microservices!"}
Si observas la salida en la que se ejecuta el proceso de la aplicación, verás que la aplicación se inicia con 2,3 segundos de media, lo que la convierte en la candidata más lenta que hemos visto hasta ahora, mientras que el tamaño de los binarios se acerca a los 15 MB, lo que la sitúa en el medio de todas las mediciones. Pero, como dice el refrán, no se puede juzgar un libro por su portada. Helidon proporciona más funciones fuera de la caja configuradas automáticamente, lo que explicaría el tiempo extra de inicio y el mayor tamaño de la implementación.
Si la velocidad de arranque y el tamaño de la implementación fueran un problema, podrías reconfigurar la compilación para eliminar aquellas funciones que no fueran necesarias, así como cambiar al modo de ejecutable nativo. Afortunadamente, el equipo de Helidon ha adoptado también la Imagen Nativa de GraalVM, y todos los proyectos de Helidon, arrancados como hemos hecho nosotros, vienen con la configuración necesaria para crear binarios nativos.
No hay necesidad de retocar el archivo pom.xml si sigues las convenciones. Ejecuta el comando mvn -Pnative-image package
, y encontrarás un ejecutable binario llamado demo dentro del directorio de destino. Este ejecutable pesa unos 94 MB, el mayor hasta ahora, mientras que su tiempo de arranque es de 50 milisegundos de media, en el mismo rango que los frameworks anteriores.
Hasta ahora, hemos echado un vistazo a lo que ofrece cada framework, desde las características básicas hasta la integración de herramientas de construcción. Como recordatorio, hay varias razones para elegir un framework candidato en lugar de otro. Te animo a que escribas una matriz para cada característica/aspecto relevante que afecte a tus requisitos de desarrollo y evalúes cada uno de esos elementos con cada candidato.
Sin servidor
Este capítulo comenzó con un análisis de aplicaciones y arquitecturas monolíticas, normalmente formadas por componentes y niveles agrupados en una única unidad cohesionada. Los cambios o actualizaciones de una pieza concreta requieren la actualización y despliegue del conjunto. Un fallo en un lugar concreto podría hacer caer también el conjunto. Luego pasamos a los microservicios. La división del monolito en trozos más pequeños que pueden actualizarse y desplegarse individualmente e independientemente unos de otros debería resolver los problemas mencionados anteriormente, pero los microservicios plantean otros muchos problemas.
Antes, bastaba con ejecutar el monolito dentro de un servidor de aplicaciones alojado en big iron, con un puñado de réplicas y un equilibrador de carga por si acaso. Esta configuración tiene problemas de escalabilidad. Con el enfoque de microservicios, podemos hacer crecer o colapsar la malla de servicios en función de la carga. Eso aumenta la elasticidad, pero ahora tenemos que coordinar múltiples instancias y aprovisionar entornos de ejecución, los equilibradores de carga se hacen imprescindibles, se necesitan pasarelas API, la latencia de la red asoma su fea cabeza, ¿y he mencionado el rastreo distribuido? Sí, son muchas cosas que hay que tener en cuenta y gestionar. Pero, ¿y si no tuvieras que hacerlo tú? ¿Y si otra persona se ocupara de la infraestructura, el monitoreo y otras "minucias" necesarias para ejecutar aplicaciones a escala? Aquí es donde entra en juego el enfoque sin servidor: te concentras en la lógica empresarial y dejas que el proveedor sin servidor se ocupe de todo lo demás.
Al destilar un componente en trozos más pequeños, debería venirte a la mente un pensamiento: "¿Cuál es la pieza de código reutilizable más pequeña en la que puedo convertir este componente?". Si tu respuesta es una clase Java con un puñado de métodos y tal vez un par de colaboradores/servicios inyectados, estás cerca, pero aún no has llegado. La pieza más pequeña de código reutilizable es, de hecho, un único método. Imagina un microservicio definido como una única clase que realiza los siguientes pasos:
-
Lee los argumentos de entrada y los transforma en un formato consumible según lo requiera el siguiente paso
-
Realiza el comportamiento real requerido por el servicio, como la emisión de una consulta a una base de datos, la indexación o el registro
-
Transforma los datos procesados en un formato de salida
Ahora bien, cada uno de estos pasos puede organizarse en métodos separados. Pronto te darás cuenta de que algunos de estos métodos son reutilizables tal cual o parametrizados. Una forma típica de resolver esto sería proporcionar un supertipo común entre microservicios. Esto crea una fuerte dependencia entre tipos, y para algunos casos de uso, está bien. Pero para otros, las actualizaciones del código común tienen que producirse lo antes posible, de forma versionada, sin interrumpir el código que se está ejecutando actualmente, así que me temo que necesitaremos una alternativa.
Con este escenario en mente, si el código común se proporcionara en su lugar como un conjunto de métodos que pueden invocarse independientemente unos de otros, con sus entradas y salidas compuestas de tal manera que se establezca una tubería de transformaciones de datos, entonces llegamos a lo que ahora se conoce como funciones. Las ofertas como función como servicio (FaaS) son un tema común entre los proveedores sin servidor.
En resumen, FaaS es una forma elegante de decir que compongas aplicaciones basadas en la unidad de implementación más pequeña posible y dejes que el proveedor resuelva todos los detalles de la infraestructura por ti. En las siguientes secciones, construiremos e implementaremos una función sencilla en la nube.
Configuración
Hoy en día, todos los principales proveedores de la nube tienen una oferta de FaaS a tu disposición, con complementos que se conectan a otras herramientas de monitoreo, registro, recuperación de desastres, etc.; sólo tienes que elegir la que se adapte a tus necesidades. Para este capítulo, elegiremos AWS Lambda, que fue, después de todo, el creador de la idea de FaaS. También elegiremos Quarkus como marco de implementación, ya que es el que actualmente proporciona el tamaño de implementación más pequeño. Ten en cuenta que la configuración que se muestra aquí puede necesitar algunos ajustes o puede estar totalmente desfasada; revisa siempre las últimas versiones de las herramientas necesarias para construir y ejecutar el código. Por ahora utilizaremos Quarkus 1.13.7.
Configurar una función con Quarkus y AWS Lambda requiere tener una cuenta de AWS, la CLI de AWS instalada en tu sistema, y la CLI del Modelo de Aplicación sin Servidor (SAM) de AWS si quieres ejecutar pruebas locales.
Una vez que tengas eso cubierto, el siguiente paso es arrancar el proyecto, para lo cual nos inclinaríamos por utilizar Quarkus como antes, salvo que un proyecto de funciones requiere una configuración diferente.Así que es mejor pasar a utilizar un arquetipo de Maven:
mvn archetype:generate \ -DarchetypeGroupId=io.quarkus \ -DarchetypeArtifactId=quarkus-amazon-lambda-archetype \ -DarchetypeVersion=1.13.7.Final
Al invocar este comando en modo interactivo, se te harán algunas preguntas, como las coordenadas de grupo, artefacto y versión (GAV) del proyecto, y el paquete base. Para esta demostración, vamos con éstas:
-
groupId
: com.ejemplo.demo -
artifactId
: demo -
version
: 1.0-SNAPSHOT (por defecto) -
package
: com.example.demo (igual quegroupId
)
El resultado es una estructura de proyecto adecuada para construir, probar y desplegar un proyecto Quarkus como una función desplegable en AWS Lambda. El arquetipo crea archivos de construcción tanto para Maven como para Gradle, pero no necesitamos este último por ahora; también crea tres clases de función, pero sólo necesitamos una. Nuestro objetivo es tener una estructura de archivos similar a ésta:
. ├── payload.json ├── pom.xml └── src ├── main │ ├── java │ │ └── com │ │ └── example │ │ └── demo │ │ ├── GreetingLambda.java │ │ ├── InputObject.java │ │ ├── OutputObject.java │ │ └── ProcessingService.java │ └── resources │ └── application.properties └── test ├── java │ └── com │ └── example │ └── demo │ └── LambdaHandlerTest.java └── resources └── application.properties
La esencia de la función es capturar entradas con el tipo InputObject
, procesarlas con el tipo ProcessingService
, y luego transformar los resultados en otro tipo (OutputObject
). El tipo GreetingLambda
lo une todo. Echemos un vistazo primero a los tipos de entrada y salida; al fin y al cabo, son tipos simples que sólo se ocupan de contener datos, sin lógica alguna:
package
com
.
example
.
demo
;
public
class
InputObject
{
private
String
name
;
private
String
greeting
;
public
String
getName
()
{
return
name
;
}
public
void
setName
(
String
name
)
{
this
.
name
=
name
;
}
public
String
getGreeting
()
{
return
greeting
;
}
public
void
setGreeting
(
String
greeting
)
{
this
.
greeting
=
greeting
;
}
}
La lambda espera dos valores de entrada: un saludo y un nombre. Veremos cómo los transforma el servicio de procesamiento dentro de un momento:
package
com
.
example
.
demo
;
public
class
OutputObject
{
private
String
result
;
private
String
requestId
;
public
String
getResult
()
{
return
result
;
}
public
void
setResult
(
String
result
)
{
this
.
result
=
result
;
}
public
String
getRequestId
()
{
return
requestId
;
}
public
void
setRequestId
(
String
requestId
)
{
this
.
requestId
=
requestId
;
}
}
El objeto de salida contiene los datos transformados y una referencia al requestID. Utilizaremos este campo para mostrar cómo podemos obtener datos del contexto en ejecución.
Muy bien, el servicio de procesamiento es el siguiente; esta clase se encarga de transformar las entradas en salidas. En nuestro caso, concatena los dos valores de entrada en una sola cadena, como se muestra aquí:
package
com
.
example
.
demo
;
import
javax.enterprise.context.ApplicationScoped
;
@ApplicationScoped
public
class
ProcessingService
{
public
OutputObject
process
(
InputObject
input
)
{
OutputObject
output
=
new
OutputObject
();
output
.
setResult
(
input
.
getGreeting
()
+
" "
+
input
.
getName
());
return
output
;
}
}
Lo que queda es echar un vistazo a GreetingLambda
, la clase utilizada para montar la función en sí. Esta clase requiere implementar una interfaz conocida suministrada por Quarkus, cuya dependencia ya debería estar configurada en el archivo pom.xml creado con el arquetipo. Esta interfaz se parametriza con tipos de entrada y salida. Por suerte, ya los tenemos. Cada lambda debe tener un nombre único y puede acceder a su contexto de ejecución, como se muestra a continuación:
package
com
.
example
.
demo
;
import
com.amazonaws.services.lambda.runtime.Context
;
import
com.amazonaws.services.lambda.runtime.RequestHandler
;
import
javax.inject.Inject
;
import
javax.inject.Named
;
@Named
(
"greeting"
)
public
class
GreetingLambda
implements
RequestHandler
<
InputObject
,
OutputObject
>
{
@Inject
ProcessingService
service
;
@Override
public
OutputObject
handleRequest
(
InputObject
input
,
Context
context
)
{
OutputObject
output
=
service
.
process
(
input
);
output
.
setRequestId
(
context
.
getAwsRequestId
());
return
output
;
}
}
Todas las piezas encajan. La lambda define los tipos de entrada y salida e invoca al servicio de procesamiento de datos. A efectos de demostración, este ejemplo muestra el uso de la inyección de dependencias, pero podrías reducir el código trasladando el comportamiento de ProcessingService
a GreetingLambda
. Podemos verificar rápidamente el código ejecutando pruebas locales con mvn test
, o si lo prefieres mvn verify
, ya que también empaquetará la función.
Ten en cuenta que se colocan archivos adicionales en el directorio de destino cuando se empaqueta la función, concretamente un script llamado manage.sh, que se basa en la herramienta CLI de AWS para crear, actualizar y eliminar la función en el destino asociado a tu cuenta de AWS. Se necesitan archivos adicionales para soportar estas operaciones:
- función.zip
-
El archivo de implementación que contiene los bits binarios
- sam.jvm.yaml
-
Prueba local con AWS SAM CLI (modo Java)
- sam.nativo.yaml
-
Prueba local con AWS SAM CLI (modo nativo)
El siguiente paso requiere que tengas configurado un rol de ejecución, para lo cual lo mejor es que consultes la Guía del Desarrollador de AWS Lambda por si se ha actualizado el procedimiento. La guía te muestra cómo configurar la CLI de AWS (si aún no lo has hecho) y crear un rol de ejecución que debes añadir como variable de entorno a tu shell en ejecución. Por ejemplo:
LAMBDA_ROLE_ARN="arn:aws:iam::1234567890:role/lambda-ex"
En este caso, 1234567890
es el ID de tu cuenta de AWS, y lambda-ex
es el nombre del rol que elijas. Podemos proceder a ejecutar la función, para lo cual tenemos dos modos (Java, nativo) y dos entornos de ejecución (local, producción); abordemos primero el modo Java para ambos entornos y sigamos después con el modo nativo.
Ejecutar la función en un entorno local requiere el uso de un demonio Docker, que a estas alturas ya debería ser habitual en la caja de herramientas de un desarrollador; también requerimos el uso de la CLI de SAM de AWS para dirigir la ejecución. ¿Recuerdas el conjunto de archivos adicionales que se encuentran dentro del directorio de destino? Utilizaremos el archivo sam.jvm.yaml junto con otro archivo creado por el arquetipo al arrancar el proyecto, llamado payload.json. Situado en la raíz del directorio, su contenido debería tener este aspecto:
{
"name"
:
"Bill"
,
"greeting"
:
"hello"
}
Este archivo define valores para las entradas aceptadas por la función. Dado que la función ya está empaquetada, sólo tenemos que invocarla, así:
$ sam local invoke --template target/sam.jvm.yaml --event payload.json Invoking io.quarkus.amazon.lambda.runtime.QuarkusStreamHandler::handleRequest (java11) Decompressing /work/demo/target/function.zip Skip pulling image and use local one: amazon/aws-sam-cli-emulation-image-java11:rapid-1.24.1. Mounting /private/var/folders/p_/3h19jd792gq0zr1ckqn9jb0m0000gn/T/tmppesjj0c8 as /var/task:ro,delegated inside runtime container START RequestId: 0b8cf3de-6d0a-4e72-bf36-232af46145fa Version: $LATEST __ ____ __ _____ ___ __ ____ ______ --/ __ \/ / / / _ | / _ \/ //_/ / / / __/ -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \ --\___\_\____/_/ |_/_/|_/_/|_|\____/___/ [io.quarkus] (main) quarkus-lambda 1.0-SNAPSHOT on JVM (powered by Quarkus 1.13.7.Final) started in 2.680s. [io.quarkus] (main) Profile prod activated. [io.quarkus] (main) Installed features: [amazon-lambda, cdi] END RequestId: 0b8cf3de-6d0a-4e72-bf36-232af46145fa REPORT RequestId: 0b8cf3de-6d0a-4e72-bf36-232af46145fa Init Duration: 1.79 ms Duration: 3262.01 ms Billed Duration: 3300 ms Memory Size: 256 MB Max Memory Used: 256 MB {"result":"hello Bill","requestId":"0b8cf3de-6d0a-4e72-bf36-232af46145fa"}
El comando extraerá una imagen Docker adecuada para ejecutar la función. Fíjate en los valores indicados, que pueden variar en función de tu configuración. En mi entorno local, esta función me costaría 3,3 segundos y 256 MB para su ejecución. Esto puede darte una idea de cuánto se te facturará cuando ejecutes tu sistema como un conjunto de funciones. Sin embargo, local no es lo mismo que producción, así que vamos a desplegar la función en el entorno real. Utilizaremos el script manage.sh para llevar a cabo esta hazaña, invocando los siguientes comandos:
$ sh target/manage.sh create $ sh target/manage.sh invoke Invoking function ++ aws lambda invoke response.txt --cli-binary-format raw-in-base64-out ++ --function-name QuarkusLambda --payload file://payload.json ++ --log-type Tail --query LogResult ++ --output text base64 --decode START RequestId: df8d19ad-1e94-4bce-a54c-93b8c09361c7 Version: $LATEST END RequestId: df8d19ad-1e94-4bce-a54c-93b8c09361c7 REPORT RequestId: df8d19ad-1e94-4bce-a54c-93b8c09361c7 Duration: 273.47 ms Billed Duration: 274 ms Memory Size: 256 MB Max Memory Used: 123 MB Init Duration: 1635.69 ms {"result":"hello Bill","requestId":"df8d19ad-1e94-4bce-a54c-93b8c09361c7"}
Como puedes ver, la duración facturada y el uso de memoria disminuyeron, lo que es bueno para nuestro monedero, aunque la duración init subió a 1,6, lo que retrasaría la respuesta, aumentando el tiempo total de ejecución en todo el sistema. Veamos cómo cambian estos números cuando pasamos del modo Java al modo nativo. Como recordarás, Quarkus te permite empaquetar proyectos como ejecutables nativos de fábrica, pero recuerda que Lambda requiere ejecutables Linux, así que si por casualidad estás ejecutando en un entorno que no sea Linux, tendrás que modificar el comando de empaquetado. Esto es lo que hay que hacer:
# for linux $ mvn -Pnative package # for non-linux $ mvn package -Pnative -Dquarkus.native.container-build=true \ -Dquarkus.native.container-runtime=docker
El segundo comando invoca la compilación dentro de un contenedor Docker y coloca el ejecutable generado en tu sistema en la ubicación esperada, mientras que el primer comando ejecuta la compilación tal cual. Con el ejecutable nativo ya colocado, podemos ejecutar la nueva función tanto en entornos locales como de producción. Veamos primero el entorno local:
$ sam local invoke --template target/sam.native.yaml --event payload.json Invoking not.used.in.provided.runtime (provided) Decompressing /work/demo/target/function.zip Skip pulling image and use local one: amazon/aws-sam-cli-emulation-image-provided:rapid-1.24.1. Mounting /private/var/folders/p_/3h19jd792gq0zr1ckqn9jb0m0000gn/T/tmp1zgzkuhy as /var/task:ro,delegated inside runtime container START RequestId: 27531d6c-461b-45e6-92d3-644db6ec8df4 Version: $LATEST __ ____ __ _____ ___ __ ____ ______ --/ __ \/ / / / _ | / _ \/ //_/ / / / __/ -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \ --\___\_\____/_/ |_/_/|_/_/|_|\____/___/ [io.quarkus] (main) quarkus-lambda 1.0-SNAPSHOT native (powered by Quarkus 1.13.7.Final) started in 0.115s. [io.quarkus] (main) Profile prod activated. [io.quarkus] (main) Installed features: [amazon-lambda, cdi] END RequestId: 27531d6c-461b-45e6-92d3-644db6ec8df4 REPORT RequestId: 27531d6c-461b-45e6-92d3-644db6ec8df4 Init Duration: 0.13 ms Duration: 218.76 ms Billed Duration: 300 ms Memory Size: 128 MB Max Memory Used: 128 MB {"result":"hello Bill","requestId":"27531d6c-461b-45e6-92d3-644db6ec8df4"}
La duración facturada disminuyó en un orden de magnitud, pasando de 3300 ms a sólo 300 ms, y la memoria utilizada se redujo a la mitad; esto parece prometedor en comparación con su homólogo Java. ¿Obtendremos mejores cifras cuando se ejecute en producción? Veámoslo:
$ sh target/manage.sh native create $ sh target/manage.sh native invoke Invoking function ++ aws lambda invoke response.txt --cli-binary-format raw-in-base64-out ++ --function-name QuarkusLambdaNative ++ --payload file://payload.json --log-type Tail --query LogResult --output text ++ base64 --decode START RequestId: 19575cd3-3220-405b-afa0-76aa52e7a8b5 Version: $LATEST END RequestId: 19575cd3-3220-405b-afa0-76aa52e7a8b5 REPORT RequestId: 19575cd3-3220-405b-afa0-76aa52e7a8b5 Duration: 2.55 ms Billed Duration: 187 ms Memory Size: 256 MB Max Memory Used: 54 MB Init Duration: 183.91 ms {"result":"hello Bill","requestId":"19575cd3-3220-405b-afa0-76aa52e7a8b5"}
La duración total facturada se acelera un 30%, y el uso de memoria es menos de la mitad que antes; pero el verdadero ganador es el tiempo de inicialización, que tarda aproximadamente un 10% del tiempo anterior. Ejecutar tu función en modo nativo da como resultado un inicio más rápido y mejores números en general.
Ahora te toca a ti decidir la combinación de opciones que te dará los mejores resultados. A veces, permanecer en el modo Java es suficiente incluso para la producción, o ir de nativo hasta el final puede darte el perímetro. Sea como sea, las mediciones son la clave, ¡no adivines!
Resumen
Hemos cubierto mucho terreno en este capítulo, empezando por un monolito tradicional, dividiéndolo en partes más pequeñas con componentes reutilizables que pueden desplegarse independientemente, conocidos como microservicios, y llegando hasta la unidad de implementación más pequeña posible: una función. A lo largo del camino se producen compensaciones, ya que las arquitecturas de microservicios son intrínsecamente más complejas, al estar compuestas por más partes móviles. La latencia de la red se convierte en un verdadero problema y debe abordarse en consecuencia. Otros aspectos, como las transacciones de datos, se vuelven más complejos, ya que su alcance puede traspasar los límites del servicio, según los casos. El uso de Java y del modo ejecutable nativo produce resultados diferentes y requiere una configuración personalizada, cada uno con sus pros y sus contras. Mi recomendación, querido lector, es que evalúes, midas y luego selecciones una combinación; mantente al tanto de los números y de los acuerdos de nivel de servicio (SLA), porque puede que tengas que reevaluar las decisiones a lo largo del camino y hacer ajustes.
La Tabla 4-1 resume las mediciones obtenidas al ejecutar la aplicación de muestra en los modos Java e imagen nativa, en mi entorno local y remoto, para cada uno de los marcos candidatos. Las columnas de tamaño muestran el tamaño de la unidad de implementación, mientras que las columnas de tiempo representan el tiempo transcurrido desde el inicio hasta la primera solicitud.
Marco | Java - tamaño | Java - tiempo | Nativo - tamaño | Nativo - tiempo |
---|---|---|---|---|
Spring Boot |
17 MB |
2200 ms |
78 MB |
90 ms |
Micronauta |
14 MB |
500 ms |
60 MB |
20 ms |
Quarkus |
13 MB |
600 ms |
47 MB |
13 ms |
Helidon |
15 MB |
2300 ms |
94 MB |
50 ms |
Como recordatorio, te animamos a que realices tus propias mediciones. Los cambios en el entorno de alojamiento, la versión y la configuración de la JVM, la versión del framework, las condiciones de la red y otras características del entorno darán resultados diferentes. Las cifras mostradas deben tomarse con cautela, nunca como valores autorizados.
Get Herramientas DevOps para desarrolladores Java now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.