Blog

Óscar Pastor
12 Abr 2023

Maven: Agregación y herencia. Análisis topológico

Tiempo de lectura 9 minutos
  • arquitectura
  • buenas prácticas
  • java
  • maven

Sumario Análisis de la organización tradicional (y habitual) de los proyectos multi módulo de Maven y los problemas potenciales que puede provocar. Comparativa con una organización alternativa.

Antes de adentrarnos en los proyectos Maven, quizás lo primero que correspondería en este artículo es explicar que se quiere decir con “análisis topológico”. Topología se define como “el estudio de las figuras geométricas en razón de sus propiedades y posiciones, sin considerar tamaño o forma”. En comunicaciones hablamos de topología de red, para describir el mapa físico o lógico de una red de datos. 

Quizás no sea muy académico hablar de topología en nuestro contexto, pero en opinión del que esto escribe, nos es útil para hacernos una imagen mental de cómo están organizados los proyectos Maven. Podríamos decir que la mayor parte de los proyectos multi módulo siguen, haciendo una analogía con una red de comunicaciones, una topología de árbol: 

Arquitectura típica de proyectos maven

Con una estructura como ésta, si preguntásemos por el agregador, muchos (aunque quizás no la mayoría) de los desarrolladores nos preguntarían a qué nos referimos con ‘agregador’, explicando que se trata de la sección <modules> nos acabarían respondiendo “haber empezado por ahí, eso es del proyecto padre (parent) ¿no? “

Hace poco, durante un curso, contando qué es Maven a alumnos que lo desconocían por completo y explicando concretamente los conceptos de agregación y herencia (proyectos POM), al proyectar una diapositiva con un esquema como el de la imagen de arriba, uno de ellos me decía “[…] pero eso es un poco raro ¿no?, que un mismo proyecto actúe como agregador y padre, tratándose de cosas diferentes […]”

Y llevaba razón, la agregación y la herencia son cosas muy diferentes:

  • Agregación: Se da cuando un proyecto llamado agregador agrupa (mediante la sección módules) varios subproyectos o módulos de manera que al ejecutar cualquier comando Maven sobre el agregador el comando se propaga a los módulos.
  • Herencia: Característica de los proyectos Maven, que nos permite declarar que un proyecto es padre (parent) de otro y éste hereda los elementos de aquel.

La fuerza de la costumbre (al fin y al cabo, también el core de Maven está así definido) puede llevarnos a asumir que ambos conceptos son lo mismo (o al menos que están fuertemente interrelacionados) y que, por tanto, nuestro proyecto multi módulo debe contar con un único proyecto con packaging ‘POM’ que actúe simultáneamente como agregador y parent de todos los demás. Tiene que venir alguien “no contaminado” a fuerza de ver docenas de proyectos así configurados para hacernos ver que, en realidad, se trata de cosas diferentes.

Un problema de formalidad

Lo primero que llama la atención en estas configuraciones es una cuestión, podríamos decir, “de concepto”.

Cuando abordamos cualquier diseño de software una de las cosas que tratamos de evitar es “mezclar cosas que no tienen relación”. Cuando estamos realizando nuestra aplicación Java parece una buena idea respetar el archiconocido “Principio de Responsabilidad Única”, que nos indica que una clase no debería responsabilizarse de más de un cometido (dicho grosso modo); pero incluso para un desarrollador que no es demasiado cuidadoso con estos principios de diseño, resultaría bastante grosero implementar en la misma clase funcionalidades, no ya diferentes, sino que directamente nada tienen que ver entre sí.

Entonces ¿Por qué aceptamos de manera natural que un mismo proyecto Maven haga las veces de agregador y parent de los módulos? ¿acaso lo proyectos Maven no son artefactos software?... por supuesto que lo son, tan software como una librería o una clase.

El lío de las versiones

Apartándonos del plano conceptual y adoptando una visión más pragmática, planteemos un caso práctico y veamos cómo afecta esa “topología en árbol” al versionado de los diferentes artefactos.

En muchos proyectos (por ejemplo, aplicaciones o portales web) la versión de todos los módulos del proyecto avanza solidariamente. Es decir, si tomamos como referencia la figura de antes, partiríamos de una versión 1.0.0 tanto en el proyecto parent como en cada uno de los módulos y al cambiar cualquier cosa, todos avanzarían hasta la 1.1.0

Sin embargo, esto no tiene por qué ser así.

Supongamos que queremos hacer una librería de envío de SMS. Se trataría de un conjunto de artefactos ‘.jar’, uno de ellos con la interfaz del servicio y los otros con las implementaciones particulares que conectan con cada uno de los proveedores comerciales, de forma que podamos seleccionar el más barato en cada caso.

Una distribución como ésta de los artefactos puede ser muy adecuada para construir, por ejemplo, módulos OSGI (en los que, por cierto, un adecuado versionado es muy relevante). Pero tampoco es necesario pensar en ese tipo de tecnologías, cualquier entorno en que consideremos que los módulos de cada proveedor deberían ser plugables, puede llevarnos a pensar en una solución así.

Las versiones de cada uno de los artefactos podrían diferir, ya que cada uno de los módulos puede potencialmente evolucionar por separado. Es posible que liberar una nueva versión de, por ejemplo, el módulo de interfaz (con nuevas funcionalidades) nos obligue a liberar versiones nuevas de cada implementación; pero resolver un bug en la implementación de proveedor2 no debería tener incidencia ni en el módulo interfaz ni en la implementación de proveedor1.

El código Maven del proyecto parent y los módulos interfaz y proveedor2 (proveedor1 sería análogo a interfaz) tendrían un aspecto parecido a este:

<project …>
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.hello.sms</groupId>
    <artifactId>smsParent</artifactId>
    <version>1.0.0</version>
    <packaging>pom</packaging>
    <modules>
        <module>smsInterfaz</module>
        <module>smsProveedor1Impl</module>
        <module>smsProveedor2Impl</module> 
    </modules>
    [… …]
</project>

<project …>
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.hello.sms</groupId>
        <artifactId>smsParent</artifactId>
        <version>1.0.0</version>
    </parent>     
    <artifactId>smsInterfaz</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>    
    [… …]
</project>
<project …>
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.hello.sms</groupId>
        <artifactId>smsParent</artifactId>
        <version>1.0.0</version>
    </parent> 
    <artifactId>smsProveedor2Impl</artifactId>
    <version>1.0.1</version>
    <packaging>jar</packaging>    
    [… …]
</project>

Supongamos ahora que aparece un tercer proveedor. Eso nos obligaría a modificar el proyecto parent añadiendo un nuevo módulo al agregador y, si queremos ser consistentes, actualizar la versión:

<project …>
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.hello.sms</groupId>
    <artifactId>smsParent</artifactId>
    <version>1.0.1</version>
    <packaging>pom</packaging>
    <modules>
        <module>smsInterfaz</module>
        <module>smsProveedor1Impl</module>
        <module>smsProveedor2Impl</module> 
        <module>smsProveedor3Impl</module> 
    </modules>
    [… …]
</project>

Lo que nos obligaría a su vez a actualizar la versión en la sección parent de cada uno de los módulos hijo preexistentes y, de nuevo para ser consistentes, la versión del propio módulo hijo.

<project …>
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.hello.sms</groupId>
        <artifactId>smsParent</artifactId>
        <version>1.0.1</version>
    </parent> 
    <artifactId>smsInterfaz</artifactId>
    <version>1.0.1</version>
    <packaging>jar</packaging>    
    [… …]
</project>

Analicemos lo que acaba de suceder: Introducir una nueva implementación, para hacer uso de un tercer proveedor, nos ha forzado a liberar una nueva versión tanto del módulo de interfaz como de las dos implementaciones de los otros proveedores

Se trata de versiones en las que el código Java es idéntico al de las versiones previas. Si comprobásemos el ‘POM efectivo’ de las nuevas versiones, comprobaríamos que tampoco en términos de Maven (no olvidemos que el código Maven es tan software como el Java) ha cambiado nada salvo, exclusivamente, la versión del proyecto parent. No parece muy razonable haber tenido que liberar nuevas versiones de esos módulos.

Podríamos tener la tentación de no actualizar las versiones. Por ejemplo, no actualizar la versión del parent o, por ejemplo, actualizar la versión del parent pero no la de los módulos. Se trataría de malas decisiones en todos los casos:

El proyecto parent (en su función de agregador) ha cambiado, no deberíamos tener circulando dos artefactos diferentes que comparten versión. Imaginemos la cara de estupefacción de un compañero nuestro a verificar que el código del proyecto smsParent:1.0.0 en su repositorio Maven local es diferente del que hay alojado en Nexus

Si en el proyecto parent actualizamos versión, pero no en los módulos, tendremos el problema de que el parent de los módulos ya no es el que debería ser. El agregador sería el proyecto 1.0.1 y el parent el 1.0.0 (es decir, proyectos diferentes).

Decíamos antes que nos íbamos a apartar del plano conceptual (las referencias al Principio de Responsabilidad Única) para acercarnos a un problema práctico (el de las versiones). ¡Como si esos principios solo habitasen en las mentes de teóricos y académicos muy alejados de la realidad del día a día!

Sin embargo, si prestamos atención, nos daremos cuenta de que, en ambos casos, realmente, estamos describiendo dos caras de una misma moneda.

El principio de responsabilidad única nos dice que una misma pieza de software (llámese clase, módulo o como queramos) no debería atender varias responsabilidades; en su definición clásica “cada módulo debería tener una única razón para cambiar”

La razón de separar las responsabilidades es que, si no lo hacemos y ponemos varias en el mismo modulo, corremos el riesgo de que el código relacionado con una de las responsabilidades esté interfiriendo con el código relativo a las otras.

Si nos fijamos en el problema de las versiones nos daremos cuenta de que el proyecto parent tiene dos “razones para cambiar”, es decir, dos responsabilidades:

  • Que cambien los módulos del agregador, esto es, que añadamos o quitemos subproyectos.
  • Que cambie algún aspecto relacionado con la herencia que el proyecto padre transmite a los proyectos hijo (lo que induce cambios ‘reales’ en los hijos y en consecuencia nuevas versiones).

Y esto está provocando que, al hacer modificaciones relativas a la primera responsabilidad, estemos impactando sobre la segunda. Al añadir (y otro tanto sucedería al quitar o sustituir) módulos en el agregador, estamos provocando una evolución indeseada de la versión de otros módulos que no han evolucionado realmente, ya que el proyecto parent, en lo que se refiere a los elementos heredables, no ha cambiado.

La alternativa

Decíamos que la fuerza de la costumbre de ver la “topología en árbol” en todos o la mayoría de los proyectos, no debería inducirnos a pensar que es la única posibilidad o la posibilidad más adecuada en general.

Veamos una alternativa

Alternativa de configuración
<project …>
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.hello.sms</groupId>
    <artifactId>smsAgregador</artifactId>
    <version>1.0.0</version>
    <packaging>pom</packaging>
    <modules>
        <module>smsParent</module>
        <module>smsInterfaz</module>
        <module>smsProveedor1Impl</module>
        <module>smsProveedor2Impl</module> 
    </modules>
    [… …]
</project>

<project …>
    <modelVersion>4.0.0</modelVersion>   
    <groupId>com.hello.sms</groupId> 
    <artifactId>smsParent</artifactId>
    <version>1.0.0</version>
    <packaging>pom</packaging>    
    [… …]
</project>
<project …>
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.hello.sms</groupId>
        <artifactId>smsParent</artifactId>
        <version>1.0.0</version>
        <relativePath>../smsParent</relativePath>
    </parent> 
    <artifactId>smsProveedor2Impl</artifactId>
    <version>1.0.1</version>
    <packaging>jar</packaging>    
    [… …]
</project>

En nuestra nueva organización del proyecto multi módulo ya no tenemos un único proyecto con el roll de agregador y parent. Dos proyectos pom hacen dichas funciones por separado, siendo además el proyecto parent un módulo más gobernado por el agregador.

Si ahora añadiésemos la implementación de un nuevo proveedor, bastaría con añadir el nuevo módulo al agregador que vería, por supuesto, incrementada su versión. Sin embargo esto no afectaría de ninguna manera a ninguno de los otros módulos (proyecto parent, interfaz o cualquier otro módulo de implementación).

Por otro lado, esta organización también nos serviría para el caso de tener un proyecto web en el que las versiones avanzan de manera conjunta.

Autor

Óscar Pastor
Óscar Pastor

Desarrollador en dev&del

Capitán en Hello, World!

Máxima especialización en tecnologías Java y J2EE.

Tiene un mixto que se llama Alejandro, canta como los ángeles. Un mixto es un cruce entre canario y jilguero. ¡Ah! Por cierto, el que canta bien es Alejandro.

¿Estás interesado?

Déjanos tus datos y contactaremos contigo lo antes posible