En este segundo artículo de la serie "Validando una Arquitectura con ArchUnit" se va a tratar de justificar con el uso de la herramienta ArchUnit los puntos que se querían tratar de cubrir y que se expusieron al comienzo del primer artículo.
Recordatorio de los objetivos a cubrir
Disponer de una herramienta / tecnología para el stack tecnológico de Java que cubra los siguientes objetivos :
- Ayudar a validar la arquitectura con la que se ha pensado desarrollar una aplicación
- Que identifique las reglas de arquitectura o reglas arquitectónicas utilizadas
- Que su uso sea mayormente desatendido (que no requiera casi ninguna intervención humana mediante auditorias, revisiones o validaciones manuales)
- Que se pueda integrar fácilmente en el código
- Que se pueda usar junto al proceso de construcción
- Que trate de adelantar en una fase temprana del desarrollo cualquier problema que se detecte.
El objetivo de este artículo NO será el de hacer el clásico "Hola Mundo" de algo (que siempre funciona) y dejarlo ahí, sino que nos vamos a embarrar mucho al explicar mediante una gran variedad de ejemplos muchos de los aspectos de configuración y uso de los diferentes componentes que proporciona ArchUnit.
Vamos... que le vamos a dar caña a ArchUnit para ver hasta donde nos puede servir en su uso "real".
De esta forma, al mostrarlos en ejemplos y ver sus características de forma concreta se pueden tomar decisiones para plantear la mejor solución para cada uno de nosotros y como lo usaremos en los proyectos.
Daré a los ejemplos de este artículo casi un tratamiento de curso, asi que si os gusta eso que os lleváis :-)
Este artículo está dividido en 4 partes:
- 1. Introducción a ArchUnit
- 1.1. Aspectos de Arquitectura a cubrir con ArchUnit
- 1.2. Capas API de ArchUnit
- Capa API "Core"
- Capa API "Lang"
- Capa API "Library"
- 2. Stack Tecnológico
- 3. Ejemplos de Uso
- 4. Conclusiones
1. Introducción a ArchUnit
ArchUnit es una librería Open Source para testing de Java que tiene por objetivo validar/probar que una aplicación (en concreto su implementación de código) cumple una serie de reglas/patrones de arquitectura definidas -> Reglas Arquitectónicas.
Por lo tanto, proporciona un soporte a la evaluación de la "calidad del código" de una aplicación desde una perspectiva de Arquitectura.
Características generales:
- Uso mediante el lenguaje Java
- Funciona con otros lenguajes que traducen al código de bytes de Java (Kotlin, etc.)
- NO requiere ninguna infraestructura "especial"
- Uso con cualquier tipología y tamaño de proyecto
- Internamente hace uso del API de Reflection y el análisis de bytecodes (basado en ASM)
- Integración en proyectos como testing unitario automatizado
- Se puede ejecutar con cualquier herramienta/tecnología de testing unitario
- Proporciona un soporte extendido para JUnit 4 y JUnit 5 -> Con la clase ArchUnitRunner
- Permite definir una suite de reglas arquitectónicas -> colección de tests de arquitectura
- Una suite se compone del sistema de reglas arquitectónicas
- Permite utilizar más de una suite
- Permite definir una o varias colecciones de tests que se pueden agrupar o definir según diferentes criterios : ámbito, tecnología, especialización, capas, etc.
- Proporciona un API completo con implementaciones propias y/o personalizadas (customizadas) para el cumplimiento de las reglas arquitectónicas definidas
- Facilita la combinación y configuración de reglas
- Proporciona la posibilidad de ampliar el API con implementaciones específicas de las reglas arquitectónicas mediante el uso de "condiciones" -> personalización y extensión
- Facilita la integración con el código ya existente y lo nuevo
- Facilita la evolución de las reglas de arquitectura a la vez que evoluciona la arquitectura y/o la aplicación
- Proporciona un feedback directo
- Las reglas al ser ejecutadas automáticamente como tests permiten detectar de forma temprana problemas, cambios "NO esperados" o desviaciones derivadas de la propia evolución de la arquitectura
- No supone una gran carga sobre los proyectos -> Espacio ocupado, requerimientos de HW, etc.
- Integración como una dependencia (Maven, Gradle, etc.)
- Esta muy relacionado con el uso de logback
- Cuando el proyecto se compone de mucho código interesa cachear las clases utilizadas -> Reducción del tiempo de ejecución
- Utilizar componentes de ayuda como : ArchUnitRunner para el soporte de JUnit
- Facilita la documentación de la representación de una arquitectura
- Facilita el traspaso de conocimiento a otros desarrolladores o a otros equipos
- Integración con los procesos de construcción automática, CI/CD, etc.
- Facilita una buena documentación para su uso y ejemplos oficiales asociados
- Existe la opción de usar un plugin directo para Maven
- ...
1.1. Aspectos de Arquitectura a cubrir con ArchUnit
Validar una arquitectura incluye verificar/probar ciertos aspectos (algunos que nos sorprenderían) muy variados.
Normalmente se realiza sobre una aplicación o componente (en concreto sobre su implementación de código) y se trata de verificar si cumple una serie de reglas/patrones de arquitectura definidas -> Reglas de Arquitectura o Reglas Arquitectónicas.
Aspectos que deberían de cubrir las Reglas Arquitectónicas:
- Convenciones de nomenclatura: paquetes, clases, métodos, etc.
- Convenciones de codificación: atributos, métodos, privados, públicos, constructores, inicializadores, etc.
- Características
- Contenido : paquetes, clases, etc.
- Dependencias / Relaciones : entre capas, entre paquetes, entre clases, etc.
- Herencia
- Uso de anotaciones según el ámbito : persistencia, transaccionalidad, seguridad, custom, etc.
- Existencia de ciclos de uso
- Restricciones
- ...
Lo normal es encontrarse estas Reglas Arquitectónicas representadas en texto o diagramas en la documentación inicial de un proyecto / una arquitectura. Luego dependiendo de muchos parámetros y muchas decisiones a lo largo del tiempo estas se mantendrán, evolucionarán o se descartarán (Recordar el primer artículo)
Por lo tanto, una Regla Arquitectónica sobre ArchUnit debería ser una representación en código de uno o varios aspectos de los anteriores. Que como en el caso de la documentación o diagramas se mantendrán, evolucionarán o se descartarán con el tiempo.
1.2. Capas API de ArchUnit
ArchUnit divide su funcionalidad en 3 capas :
- Capa API "Core"
- Capa API "Lang"
- Capa API "Library"
Capa API "Core"
Esta capa proporciona unas características similares a las del API de Reflection de Java y proporciona todo lo necesario para poder ejecutar ArchUnit.
Entre sus características destaca por proporcionar :
- Los componentes para importar el código de bytes / compilados de Java -> Clase ClassFileImporter
- Diferentes mecanismos y configuraciones para la importación de clases
- Localización: paquete base, localizaciones específicas, classpath,...
- Implementación: específica por test, específica por clase, mediante anotaciones, etc.
- Opciones de configuración: incluir clases de test, incluir clases de un JAR, definir filtros propios, etc.
- ...
- La funcionalidad necesaria para poder trabajar con las clases importadas en "bruto"
- Proporcionar unos componentes específicos : JavaPackage, JavaClasses, JavaClass, JavaMethod, JavaField, etc.
- Similar a desarrollar directamente con Reflection
- La facilidad de entender qué hace el código dependerá del propio código
Ejemplo de código "Implementación de un import de clases desde un paquete con opciones de configuración"
private JavaClasses IMPORTED_CLASSES = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_ARCHIVES)
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
.importPackages("com.acme.example");
Importa en la variable IMPORTED_CLASSES del tipo JavaClasses todas las clases del paquete "com.acme.example" y de sus subpaquetes, filtrando los paquetes de Test, Archives y JARs que cumplan el matching.
Ejemplo de código "Implementación de un import de clases desde un paquete con opciones de configuración con anotación a nivel de clase"
//@RunWith(ArchUnitRunner.class) // Important: Only for JUnit 4 and not needed JUnit5
@AnalyzeClasses(packages = "com.acme.example",
importOptions = { ImportOption.DoNotIncludeTests.class, ImportOption.DoNotIncludeJars.class, ImportOption.DoNotIncludeArchives.class
}
)
public class ClassFileImporterWithAnnotationTest {
...
}
Similar al anterior ejemplo pero usando la variante de uso con Anotaciones.
No necesita declarar la variable.
Capa API "Lang"
Esta capa proporciona unas características que mejoran la forma de implementar el código desde la capa Core al incluir componentes que abstraen ciertas funcionalidades.
Entre sus características destaca por proporcionar:
- Un DSL concreto para su manejo -> facilita entender que esta pasando
- Toda su funcionalidad mediante el uso del concepto de interfaz fluida
- El IDE proporciona ayuda al desarrollar con el "autocompletado"
- Una serie de componentes específicos
- ArchUnitRunner: Runner específico para su uso que proporciona características como cacheo de clases , etc.
- DescribedPredicate: Predicado que determina las clases que han sido seleccionadas
- ArchCondition: Condición que deben cumplir las clases que han sido seleccionadas
- ArchRule: Regla que define/representa a un concepto de Arquitectura o Regla Arquitectónica
- ArchRuleDefinition: Inicializador de un ArchRule
- Una serie de ámbitos sobre los que aplica: classes(), methods(), etc.
- Sobre los componentes se puede trabajar con sus negaciones: noClasses(), etc.
- La ejecución de un ArchRule se considera como la ejecución de un test
- Si falla la ejecución se genera una excepción del tipo "AssertionError" que contiene toda la información del motivo que la ha generado
- Un ArchRule se puede diseñar de diferente forma : general /específica sobre una o varias arquitecturas, un regla compleja que lo contenga todo , una regla compleja que referencie a reglas más específicas, la ejecución de reglas específicas "adhoc", etc.
- Se pueden definir de diferentes formas :
- Utilizar campos o métodos estáticos
- Utilizar la anotación específica @ArchTest para ejecutar este tipo de test de forma directa
- Tomarse todo el tiempo del mundo para diseñarla -> Se requiere pensar
- Diseñarla de la forma más específica que sea posible
- Tener muy presente el clásico "divide y vencerás"
- Una mala definición de una regla puede tener un impacto "brutal"
- Utilizar una nomenclatura de la regla que ayude a entender que hace -> textos descriptivos
- Hay que recordar que las reglas evolucionarán junto con la evolución de la arquitectura
- Se puede crear un fichero archunit_ignore_patterns.txt con excepciones sobre las reglas
- Se puede utilizar la anotación específica @ArchIgnore para ignorar un test -> mala práctica
Ejemplo de código "Comprobar que las clases indicadas en un paquete dado son todas serializables"
...Código de importación de clases...
# Ejemplo de Regla
ArchRule entity_classes_should_be_serializable = classes()
// DescribedPredicate
.that()
.resideInAPackage("..archunit.entity")
// ArchCondition
.should()
.implement(Serializable.class);
entity_classes_should_be_serializable.check(IMPORTED_CLASSES);
Se presupone que se cargan las clases en la variable IMPORTED_CLASSES con el paquete "com.acme.example".
Se define como variable estática con nombre descriptivo "entity_classes_should_be_serializable" la regla que afecta a las clases donde :
- DescribedPredicate es la combinación de importPackages + resideInAPackage = com.acme.example..archunit.entity. y este será el paquete sobre el que se aplicará la comprobación
- ArchCondition comprueba que las clases seleccionadas deberían de implementar Serializable
Posteriormente se aplica el check de esa regla sobre las clases IMPORTED_CLASSES
Ejemplo de código "Comprobar que las clases del tipo entidad sólo pueden ser accedidas por clases de los paquetes repository y util"
...Código de importación de clases...
# Ejemplo de Regla
ArchRule entity_classes_should_be_accessed_on_repositories_and_utilities = ArchRuleDefinition.classes()
.that().resideInAPackage("..entity..")
.should().onlyBeAccessed()
.byAnyPackage("..repository..", "..util..");
# Ejecutar la regla
rule.check(classes);
Se presupone que se cargan las clases en la variable IMPORTED_CLASSES con el paquete "com.acme.example".
Se define como variable estática con nombre descriptivo "entity_classes_should_be_accessed_on_repositories_and_utilities" la regla que afecta a las clases donde :
- DescribedPredicate es la combinación de importPackages + resideInAPackage = com.acme.example..entity. y este será el paquete sobre el que se aplicará la comprobación
- ArchCondition comprueba que las clases seleccionadas sólo puede ser accedidas por otras clases que se encuentren en los paquetes indicados
Posteriormente se aplica el check de esa regla sobre las clases IMPORTED_CLASSES
Capa API "Library"
Esta capa proporciona unas características que mejoran y añaden complejidad a la hora de implementar el código en las capas anteriores.
Entre sus características destaca por proporcionar:
- Reglas predefinidas complejas
- Soporte para las arquitecturas basadas en capas -> Architectures.LayeredArchitecture
- Soporte para las arquitecturas basadas en cebolla (Onion) -> Architectures.OnionArchitecture
- Incluye las arquitecturas basadas en "puertos y adaptadores" / "hexagonal"
- Soporte para "Slides"
- Existen 2 definiciones de regla por defecto que afectan a un subconjunto de clases Java : beFreeOfCycles() y notDependOnEachOther()
- Proporciona unos componentes específicos
- SlicesRuleDefinition: Constructor e inicializador de slides, ejecuta asserts contra ellos generando SliceRules
- SliceAssignment: Clase que permite definir un slide
- Se puede utilizar para un slide o varios
- Configuración opcional desde el fichero archunit.properties
- Utilizado para cambiar el máximo nº de ciclos detectados, etc.
- Proporcionar un conjunto de reglas estáticas predefinidas -> GeneralCodingRules (Por ejemplo : CLASSES_SHOULD_THROW_GENERIC_EXCEPTIONS, CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING, etc.)
- Soporte a diagramas en formato PlantUML
Ejemplo de código "Comprobar el acceso de las capas definidas para una arquitectura basada en capas"
# Import en código
ArchRule layered_architecture_dependencies_are_respected =
layeredArchitecture()
//Layers
.layer("Entity layer").definedBy("..entity..")
.layer("Repository layer").definedBy("..repository..")
.layer("Service layer").definedBy("..service..")
.layer("Controller layer").definedBy("..controller..")
.layer("Factory layer").definedBy("..factory..")
.layer("Util layer").definedBy("..util..")
//Conditions
.whereLayer("Entity layer").mayOnlyBeAccessedByLayers("Repository layer", "Service layer", "Factory layer", "Controller layer")
.whereLayer("Repository layer").mayOnlyBeAccessedByLayers("Service layer")
.whereLayer("Service layer").mayOnlyBeAccessedByLayers("Controller layer")
.whereLayer("Controller layer").mayNotBeAccessedByAnyLayer();
En primer lugar se define las capas que conforman la arquitectura definiendo a que paquetes afecta.
En segundo lugar se establecen las relaciones/condiciones de las capas definidas entre ellas según las restricciones y características específicas que se quieran tener.
2. Stack Tecnológico
Este es el stack tecnológico elegido para implementar la funcionalidad de "Validando una Arquitectura con ArchUnit"
- Java 8
- Maven 3 - Gestor de dependencias
- JUnit 5 - Framework de Testing Unitario
- ArchUnit - Framework de Testing de Arquitectura
3. Ejemplos de Uso
Para enseñar a utilizar y configurar el framework de ArchUnit se ha creado un repositorio específico y en concreto el proyecto "demo-testing-archunit"
Este proyecto contiene :
- Fichero pom.xml de configuración y construcción del proyecto
- Fichero README.md de descripción sobre el proyecto
- Fichero DETAIL donde se describen los tests y la funcinalidad y configuración del framework para cada uno de ellos -> serían la explicación de cada uno de los test
- src/main/java: Código utilizado en los tests
- src/test/java: Tests que representan la funcionalidad de ArchUnit
Por lo tanto todos los ejemplos estan montados como test de un proyecto y tenéis el fichero DETAIL donde se describe que hace en cada uno de ellos
Algunos de los test fallan pero ese es el objetivo, explicar que fallan porque se ha incumplido alguna Regla Arquitectónica
4. Conclusiones
Creo que con los ejemplos mostrados en el "proyecto de ejemplo" quedan demostrados los puntos a cubrir.
Por lo que ArchUnit se convierte en una herramienta muy potente y sobre la que se debe apostar para su uso en los proyectos y en la definición de arquitecturas. Si se tiene alguna duda sobre lo que puede aportar aconsejo volver a leer el apartado de "características generales", ya que ayuda también a documentar, al traspaso de conocimiento, a formación, etc.
Inicialmente puede llevar un tiempo "extra" preparar / adaptar el código y los tests que son necesarios para el cumplimiento de las Reglas Arquitectónicas definidas, pero hay que recordar que todo dependerá de los aspectos a tener en cuenta, el grado de cumplimento y el grado de profundidad con el que se quieran aplicar (bueno y como se diseñen...)
Además al ser en código Java, si el equipo de desarrollo esta acostumbrado a hacer testing unitario lo puede incluir y mantener sin tener que aprender otro lenguaje.
En mi opinión, creo que a corto y sobre todo a largo plazo compensa utilizarlo, al evitar todos los problemas que se producen cuando NO se presta atención a la evolución del código de una arquitectura durante mucho tiempo.
Mi experiencia me dice que estos problemas suelen :
- No ser pocos -> suelen ser varios, estar relacionados y en algunos casos encadenados durante un largo tiempo
- No ser fáciles de resolver
- Suelen ser muy costosos -> tanto de localizar como de resolver
Una cosa que si conviene aclarar de ArchUnit y que la hare mediante una pregunta de ejemplo típica que se suele hacer :
¿ArchUnit me ayuda a verificar si tengo el microservicio "perfecto"?
NO, Archunit NO ayuda a definir y validar un microservicio perfecto de serie (según lo que diga la "normativa", lo que diga Martin Fowler, etc.), ayuda a definir lo que un microservicio es para ti y a verificar que esta implementado como tú lo necesitas o como tú lo has pensado...con todo lo bueno y malo que tiene esa decisión.
Por lo tanto, lo buenas que sean tus Reglas Arquitectónicas definidas para esa aplicación y/o arquitectura, su grado de aplicación (superficial o en profundidad) y lo bueno que sea tu código determinará lo que se parece tu microservicio al considerado "perfecto" (si existe).
Si tu entiendes que un microservicio para ti es una "patata" y todas las Reglas Arquitectónicas las has diseñado para identificar una "patata" -> al final seguiras teniendo una "patata", muy bien pensada, validada, probada pero en el fondo una "patata"...jejeje
Lo que si esta claro, es que si cumples todas tus Reglas Arquitectónica podrás verificar que cumples una "forma de hacer las cosas" con lo que ganarás muchísimo en estandarización, homogeneidad, mantenibilidad, limpieza y cumplimiento de políticas de calidad de desarrollo. Y esto ya te pone a jugar en otra liga.
Pues entonces no esta nada mal :-)
¿Te animas a incluirlo en tus desarrollos?
Espero que os haya gustado. Si quieres estar al día de las próximas entregas sobre ArchUnit, ¡síguenos en Twitter!
Saludos :-)