Modularización Efectiva en Java

Publicado en

El software continuamente evoluciona hacia sistemas de mayor complejidad, y no tenemos argumentos para pensar que esta tendencia cambiará. Por otro lado, cada vez se requiere que estos sistemas sean más confiables y flexibles. ¿Cómo podemos abordar este problema?

Una de las herramientas más efectivas para atacar este problema es la modularización. Esta consiste en particionar un sistema de acuerdo a ciertos principios de diseño y a una estrategia de desarrollo, gobernando las dependencias entre las partes resultantes. Sin embargo, implementar una modularización adecuada no es algo trivial; las técnicas disponibles son diversas y no existe consenso sobre cuales son las mejores en cada caso.

Adicionalmente, la modularización en sí misma introduce nuevos retos, entre los que se cuentan la dificultad de configuración y el denominado “infierno de dependencias”.

En este artículo exploraremos el estado actual de la modularización en cuanto a la plataforma Java se refiere y señalaremos algunos principios de diseño, patrones, prácticas y tecnologías que puedes emplear para introducir una estrategia de modularización efectiva en tus desarrollos.

Historia vs Status Quo

A principios de los 70s David Parnas sentó las bases de la modularización en su ensayo “On the Criteria To Be Used in Decomposing Systems into Modules” [1] que tuvo gran impacto en la manera en que construimos software desde entonces. El problema deriva en que, en general, hemos fallado en aplicar de manera efectiva este conocimiento en el software que desarrollamos día a día. Parte de esta falla proviene del hecho de que el software es cada vez más complejo y utilizado en más contextos.

Otra razón tiene que ver con lo que Markus Völter [2] señala como un énfasis en la tecnología en lugar del diseño: una gran cantidad de desarrolladores conocen los aspectos técnicos de la implementación de un EJB o un web service, pero se encuentran muy limitados al establecer criterios de granularidad, agregación y empaquetamiento que proveerán a un sistema con características tan deseadas como la facilidad para realizar cambios. La tercer causa es que los conceptos planteados por Parnas y otros pioneros son aplicados principalmente a nivel de clases y objetos. Sin embargo, el tamaño y complejidad del software desarrollado hoy en día exige nuevos niveles de agregación y una definición de módulo a un nivel de abstracción más alto.

Esta situación ha provocado que surjan esfuerzos para remediarla. Por un lado, un renovado énfasis de los gurús del software sobre buenos principios y prácticas de diseño más que sobre tecnologías de implementación. Por otro lado, el advenimiento de iniciativas como el proyecto Jigsaw [3] para modularizar el propio JDK y la explosión de interés en OSGi [4] como fundamento para implementar arquitecturas modulares sobre la JVM.

Beneficios de la modularización

El impacto principal de una estrategia efectiva de modularización es en la “mantenibilidad” del sistema de software, es decir, qué tan fácil es realizar cambios a éste para agregar y modificar funcionalidad, corregir errores o sustituir partes del mismo. Tal como nos muestra la figura 1, conforme los elementos de nuestro sistema presentan mayor cohesión y menor acoplamiento, tendremos sistemas más fáciles de mantener.

Figura 1

Figura 1. El impacto de la modularidad en los sistemas

La modularización también tiene un gran impacto en la capacidad de reuso de un sistema. Mediante una modularización adecuada es mucho más fácil localizar, cambiar o sustituir un aspecto del sistema, así como utilizar partes del mismo en otras aplicaciones.

Efectos negativos de la modularización

Maximizar la modularización y por consecuencia, el reuso, tiene otros efectos no tan deseables. Específicamente, existe la llamada paradoja
del reuso, la cual se ilustra en la figura 2. Entre más modular y reusable es un diseño, más difícil es usar partes del mismo. Esto se debe a que, a diferencia de un diseño monolítico, donde para usar el sistema simplemente debemos copiarlo o instalarlo en el lugar adecuado, en un sistema modular debemos estar conscientes de las dependencias entre y hacia otros módulos y saber manejarlas adecuadamente.

Figura 2
Figura 2. Maximizar el reuso dificulta el uso.

De no hacerlo, es probable que nos involucremos en lo que coloquialmente conocemos como “infierno de dependencias”, que puede tomar distintas formas:

  • Demasiadas dependencias. Una aplicación con muchas dependencias puede tener características negativas como dificultad de instalar y configurar, gran tamaño, inestabilidad y fragilidad relativa al cambio de estas dependencias.
  • Dependencias cíclicas. Se da cuando un módulo A depende de otro módulo B, el cual a su vez depende directa o indirectamente de A. Esta situación denota que no existe una correcta separación de las responsabilidades, ya que siempre que usemos A necesitaremos usar B o viceversa. No hay posibilidad de reuso más que en conjunto. Frecuentemente esto no quiere decir que no pueda existir reuso, solamente que el código no fue colocado en el lugar adecuado para permitirlo de manera más granular.
  • Largas cadenas de dependencias. Se da cuando una cadena de dependencias transitivas es muy larga. Una dependencia transitiva es aquella que se obtiene de manera indirecta cuando un módulo que utilizamos hace a su vez uso de otros módulos. Al tener largas cadenas de dependencias, puede resultar muy laborioso determinar cuales son todas las dependencias necesarias para poder usar el módulo que realmente es de nuestro interés.
  • Dependencias en conflicto. Se da cuando queremos usar dos módulos, cada una de los cuales tiene dependencias transitivas hacia versiones específicas pero diferentes de un tercero. En esta situación no podemos simplemente descartar una de las versiones porque, o bien el sistema no compilará, o peor aún, tendrá defectos que no serán visibles hasta el momento de ejecución, pudiendo incluso permanecer escondidos por largo tiempo.

Tener largas cadenas de dependencias o dependencias en conflicto típicamente es consecuencia de reusar componentes genéricos como librerías y frameworks desarrollados por terceros. Dichas librerías a su vez tratan de maximizar el reuso, sobre todo de componentes de bajo nivel, como los usados para el manejo de bitácoras (logging), colecciones, XML, bytecode, etc.

Y para cada una de estas tareas existe una amplia variedad de componentes, lo cual complica aun más la situación. Tener demasiadas dependencias puede ser consecuencia de la complejidad intrínseca del software que estamos desarrollando, pero al igual que las dependencias cíclicas, suele ser consecuencia de un mal diseño.

Algunos de estos problemas pueden ser manejados, al menos parcialmente, mediante ciertas prácticas y técnicas que describiremos más adelante. Pero resulta evidente que intentar hacer uso efectivo de la modularización puede introducirnos más dificultad que beneficios (en la mitología griega, la Hidra es un monstruo policefálico al que tras cortarle una cabeza, le salen dos nuevas). Es por esto que para aprovecharla y no sucumbir ante ella, primero debemos entender qué características son recurrentes en un diseño modular efectivo y posteriormente conocer técnicas que nos permiten llegar ahí.

Características de un diseño modular efectivo

Dividir un sistema en capas es un buen comienzo, pero en la mayoría de los casos no es suficiente. Más allá del tan citado y poco entendido lineamiento de “alta cohesión y bajo acoplamiento”, un diseño modular efectivo tiende a presentar las siguientes características:

  1. Cada módulo tiene un conjunto de responsabilidades muy pequeño y bien definido.
  2. Cada módulo tiene un nombre que permite identificar claramente sus responsabilidades.
  3. Cada módulo provee una inferfaz que define el contrato del mismo en términos de requerimientos y responsabilidades, y es el mecanismo a través del cual puede ser utilizado.
  4. Existen módulos abstractos, con pocas dependencias y altamente estables.
  5. Existen también módulos concretos, que presentan cierto grado de inestabilidad debido a que usan a otros módulos para llevar a cabo el trabajo real. Estos módulos presentan un nivel de cohesión alto.
  6. Existen pocas o ninguna dependencias entre módulos concretos.
  7. Los módulos de alto nivel (capas superiores) tienen dependencias hacia módulos abstractos y pocas o ninguna dependencias hacia módulos concretos.
  8. No existen dependencias cíclicas entre los módulos.
  9. Las responsabilidades bien definidas de los módulos, así como los límites y fronteras entre los mismos, facilitan que los cambios se realicen de manera local, minimizando el impacto en todo el sistema (ver figura 3).
  10. El nivel de granularidad de los módulos es tal que establece un buen balance entre potencial de reuso y facilidad de uso.

Figura 3

Figura 3. La modularización permite localizar los cambios.

¿Qué hace falta?

El concepto de modularización no es nada nuevo. Hoy en día todo desarrollador hace uso de una amplia gama de tecnologías de diseño y programación orientadas a explotarlo, entre los que se cuentan: descomposición en capas, tecnologías de objetos, tecnologías de aspectos, patrones de diseño, etc. Pero mi tesis es que en general hemos fallado en usar apropiadamente estas tecnologías para impartir una buena modularización a nuestros desarrollos y que hacerlo no es trivial debido a los problemas involucrados. ¿Qué hace falta? Guías; guías en forma de principios, prácticas y patrones, tanto de diseño como de estrategias de desarrollo.

Principios de diseño

Una reflexión sobre las características comunes a los diseños modulares efectivos, permite reconocer que son, al menos parcialmente, consecuencia de aplicar correctamente los principios de diseño conocidos en conjunto como SOLID, a un nivel de abstracción mayor que el de clases y objetos. Los principios SOLID fueron popularizados por Robert C. Martin [5], y el acrónimo se forma tomando la primera letra en inglés del nombre de cada principio. Éstos son:

  • Principio de única responsabilidad: No debería existir más de una razón para que una clase deba cambiar.
  • Principio abierto-cerrado: Los elementos de software deben estar abiertos a extensiones, pero cerrados a modificarse.
  • Principio de substitución de Liskov: Las funciones que hacen referencia a clases base deben ser capaces de trabajar con clases derivadas sin saberlo.
  • Principio de segregación de interfaces: Los elementos de software ‘cliente’ no deben ser forzados a depender de interfaces que no utilizan.
  • Principio de inversión de dependencias: Las abstracciones no deben depender de los detalles. Los detalles deben depender de las abstracciones.

Robert C. Martin indica que: “la unidad de reuso es la unidad de liberación (the granule of reuse is the granule of release)” [6]. En el caso de Java, la unidad de liberación se traduce al JAR, no clases o paquetes, tampoco el WAR o el EAR. El JAR es la unidad de instalación más común en el desarrollo en Java, y el reconocimiento de esto está en el centro del desarrollo de tecnologías que buscan llevar la práctica de la modularización a otros niveles. Es así que en Java debemos considerar que un módulo equivale a un JAR.

Patrones

Kirk Knoernschild trabaja actualmente en un compendio de patrones relacionados con la modularización [7]. Con la aplicación de estos patrones se pretende balancear adecuadamente el nivel de granularidad de los módulos, maximizar el reuso al igual que la flexibilidad y la facilidad de entender el sistema, así como minimizar las dependencias existentes entre los módulos (acoplamiento). Aunque actualmente no están formulados en la estructura convencional de patrones a la que estamos acostumbrados, son muy buena guía, y son patrones en el sentido de que no son invenciones: son soluciones efectivas y recurrentes probadas en la industria. Los patrones son:

Patrones básicos

  • Administra las relaciones. Se refiere a la práctica de diseñar explícitamente las relaciones entre los módulos, en lugar de dejar que ocurran por accidente.
  • Reuso de módulos. Enfatiza el reuso a nivel de módulos (JARs).
  • Módulos cohesivos. Los módulos deben tener un buen nivel de cohesión, de otra manera deberían formar parte de otro módulo o ser módulos separados.
  • Reuso de clases. Las clases que no son reusadas en conjunto, pertenecen a módulos distintos.

Patrones de dependencias

  • Dependencias acíclicas. Las relaciones entre los módulos deben ser acíclicas.
  • Capas físicas. Las relaciones entre módulos no deben violar las capas físicas conceptuales del sistema.
  • Independencia del contenedor. Considera las dependencias que tienen los módulos hacia el contenedor (e.g. servidor de aplicaciones) y extráelas como dependencias hacia otros módulos abstractos.
  • Despliegue independiente. Los módulos deben ser unidades desplegables de manera independiente.
  • Excepciones co-localizadas. Las excepciones deberían estar empaquetadas junto o cerca de las clases que las arrojan.

Patrones de usabilidad

  • Interfaz publicada. Haz explícita la interfaz pública de cada módulo.
  • Configuración externa. Los módulos deberían ser configurables de manera externa.
  • Fachada de módulos. Crea una fachada que sirva como un punto de entrada a los módulos que conforman la implementación.

Patrones de extensibilidad

  • Módulos estables. Los módulos hacia los que existen muchas dependencias deberían ser estables.
  • Módulos abstractos. Las dependencias deben ser hacia módulos abstractos o hacia las partes abstractas de los mismos.
  • Fábrica de implementación. Utiliza el patrón factory para crear las clases de implementación dentro de un módulo.
  • Abstracciones separadas. Separa las abstracciones de las clases que las realizan.

Patrones de utilería

  • Compilación nivelada. Ejecuta la compilación y generación del JAR en un orden coherente con la nivelación de los módulos.
  • Componente de pruebas. Para cada módulo, crea un componente de prueba correspondiente que valida su comportamiento e ilustra su uso.

Estos patrones son explicados de forma práctica en el artículo “Applied Modularity” [8], donde se describe paso a paso la transformación de un sistema con una modularización inadecuada hacia un diseño mucho más efectivo mediante la aplicación sucesiva de varios de estos patrones. Sin embargo, el camino para transformar un sistema legado de gran tamaño hacia un diseño más modular y flexible, en la práctica suele ser más complejo y requiere técnicas más sofisticadas varios de estos patrones. Sin embargo, el camino para transformar un sistema legado de gran tamaño hacia un diseño más modular y flexible, en la práctica suele ser más complejo y requiere técnicas más sofisticadas.

Prácticas

Además de la contribución de Kirk al dar nombre y sistematizar estos patrones, existen otras prácticas que resultan muy valiosas tanto para soportar el desarrollo de sistemas explícitamente modulares como para otros que, aunque semi-monolíticos, hacen uso de software altamente modular. Dichas prácticas son:

  • Esquema apropiado de versionamiento. Un cambio interno en una versión estable de un módulo debe hacerse explícito mediante su identificador de versión. Los módulos que están atravesando un periodo de desarrollo muy activo y son por tanto altamente inestables, pero son utilizados por otros módulos, deberían marcarse con alguna etiqueta especial como “SNAPSHOT”. Si ya de por sí el tener muchos módulos puede resultar complejo, no usar un buen esquema de versionamiento te traerá muchas dificultades.
     
  • Repositorios de dependencias dentro de la organización. Los repositorios de dependencias resuelven, entre otros, el problema de concentrar en un solo lugar las distintas versiones de módulos reusables, internos y externos, así como la estructura de las dependencias entre estos. Con esto se logra quitarle al desarrollador la tarea de averiguar todo el grafo de módulos y versiones de estos que debe importar cuando quiere utilizar una versión específica de un módulo particular, ya que esta información se encuentra en forma de meta-datos dentro del repositorio.
     
  • Sistema de compilación integrado con el repositorio de dependencias. El mayor provecho del repositorio se obtiene cuando la herramienta con la cual transformamos el código fuente en artefactos desplegables (JARs, documentación, reportes, etc) está integrada con él. De esta manera, la herramienta de construcción descarga las dependencias conforme son requeridas y puede incluso detectar conflictos de dependencias, ofrecer una solución de los mismos o dejar ésta en manos del desarrollador.

Tecnologías y herramientas

Las tecnologías que soportan las prácticas del diseño modular se pueden clasificar dependiendo del momento en que son efectivas: tiempo de desarrollo y tiempo de ejecución.

En tiempo de desarrollo, los repositorios de dependencias y los sistemas que automatizan la construcción de los artefactos integrándose con estos repositorios son las herramientas más relevantes, siendo Nexus, Maven y Ivy los más populares y maduros. No obstante, en esta categoría hacen falta herramientas más poderosas que ayuden a los desarrolladores a administrar mejor las dependencias. Específicamente, estas herramientas aún no incorporan nociones como “rango de versiones compatibles” o “características proporcionadas por un módulo” que facilitarían enormemente el reuso de módulos.

Otro tipo de herramienta (o extensión de las ya existentes) que hace falta es un administrador de perfiles de dependencias, que le permita a un desarrollador crear, analizar, publicar y mantener configuraciones de dependencias que a veces cuesta tanto trabajo hacer funcionar bien, debido a incompatibilidades entre los módulos. En las tecnologías para modularización usadas en tiempo de ejecución ya tenemos un estándar de facto: OSGi.

OSGi

OSGi es la especificación de un sistema dinámico de módulos para Java. Tiene una larga historia que comenzó en el mundo de los dispositivos embebidos y la automatización, pero que gradualmente ha penetrado en otros mercados, incluido el de las denominadas aplicaciones empresariales (término que no me parece adecuado, pero la mayoría parece entender a qué se refiere). OSGi no solo proporciona un entorno de ejecución en el cual una aplicación modular puede ser desplegada, versionada y administrada, sino que permite que cada módulo tenga un ciclo de vida independiente, dando una capacidad dinámica que habilita a toda una nueva generación de aplicaciones.

Al día de hoy, los principales fabricantes de servidores de aplicaciones y ESBs (Enterprise Service Bus) ya adoptaron OSGi como fundamento sobre el cual construir las últimas versiones de sus ofertas o están en proceso de hacerlo. Estos mismos fabricantes se encuentran trabajando en “completar” OSGi con las capacidades que la mayoría de los desarrolladores necesitan (transacciones distribuidas, manipulación de bytecode para utilizar aspectos, tecnologías ORM, etc), a través del OSGi Enterprise Expert Group.

En general, aún no se recomienda a la mayoría de los desarrolladores de aplicaciones utilizar OSGi de manera directa, debido a la complejidad intrínseca, la falta de herramientas que ayuden a enfrentarla y a que tecnologías a las que los desarrolladores están habituados no son sencillas de usar dentro de OSGi o sencillamente aún no están disponibles. Sin embargo, eventualmente todos los usuarios de Java Enterprise Edition nos beneficiaremos indirectamente de OSGi, y de requerirlo, también podremos explotar directamente sus beneficios. Ejemplos concretos de esto son la versión Open Alpha de Websphere 7 y Spring DM Server.

Conclusiones

La modularización es un aspecto que debemos tener presente en nuestros desarrollos. Es un tema clave en lo que a mantenibilidad y reuso se refiere. Implementar una estrategia de modularización efectiva no es sencillo y aún falta mucho soporte de herramientas, pero cuando se logra, los beneficios obtenidos superan el costo involucrado.

La plataforma Java continúa moviéndose rápidamente hacia sistemas altamente modulares a través de la enorme cantidad de componentes reusables que existen y a través de tecnologías que promueven y explotan el reuso, en desarrollo y tiempo de ejecución. No obstante, el uso de herramientas y tecnologías como OSGi no son la solución completa ni el aspecto más importante para una modularización efectiva: el diseño continúa siendo la clave.

Referencias:
[1] D. Parnas, “On the Criteria To Be Used in Decomposing Systems into Modules”, Communications of the ACM, vol.15, 1972.
[2] M. Völter. “Software Architecture Patterns”. http://bit.ly/sg27r7
[3] Project Jigsaw, http://bit.ly/sg27r8
[4] OSGi Alliance, http://www.osgi.org
[5] R.C. Martin, “The Principles of OOD”. http://bit.ly/sg27r9
[6] R.C. Martin, “Granularity”. http://bit.ly/sg27r10
[7] K. Knoernschild, “Modularity Patterns”. http://bit.ly/sg27r11
[8] K. Knoernschild, “Applied Modularity – Part 1”. http://bit.ly/sg27r12


 

Bio

Agustín T. Ramos Fonseca se desempeña como ingeniero de software en Certum. Tiene 7 años de experiencia en el desarrollo de aplicaciones corporativas y es miembro de la IEEE y ACM. Sus intereses se centran en servicios internet altamente escalables, plataformas para sistemas transaccionales masivamente distribuidos, servicios móviles y geoposicionales, así como líneas de productos y arqueología de software. @MachinesAreUs