Modularidad en Java 9 (1/2)

Los módulos son la principal novedad de Java 9. Un módulo agrupa código y recursos como los JARs tradicionales, pero añade además un descriptor que restringe el acceso a sus paquetes, y describe sus dependencias.

El proyecto Jigsaw incluye la implementación del sistema de módulos (JSR 376 and JEP 261), y las mejoras (JEPs) relacionadas. Un puzzle Jigsaw es una imagen dividida en cientos de piezas. De igual modo, la plataforma Java y nuestras aplicaciones, pasan en Java 9 a estar compuestos por docenas de módulos.

En este tutorial veremos

  • Las bases de la encapsulación,
  • los beneficios de la modularidad,
  • cómo escribir un descriptor de módulo,
  • y un ejemplo con varios módulos.

Contenido

Encapsulación

¿Qué es la encapsulación?

Encapsular consiste en ocultar los detalles de implementación y proporcionar una interfaz más simple. Esta estrategia es una herramienta universal para reducir la complejidad. Tu coche por ejemplo, te abstrae los detalles mecánicos bajo su capó, y ofrece una interfaz más simple en forma de pedales y volante.

encapsulación = detalles ocultos + interfaz simplificada

En informática usamos la encapsulación para construir capas de software de complejidad progresiva. En el nivel más bajo, el ordenador almacena información en forma de ceros y unos. Es un sistema parecido al de las transmisiones Morse de hace siglos. La novedad es que los ordenadores son máquinas programables, donde la información representa no solo texto y números, sino programas que realizan cálculos complejos.

Las instrucciones más cercanas a la máquina son tan simples como multiplicar dos números, o concatenar cadenas de texto. Sin embargo, al combinarlas creamos macroinstrucciones, cada vez más parecidas al lenguaje natural. La diferencia es notable:

ScalaEnsamblador
print("hola mundo")  

En lenguajes de alto nivel las instrucciones se organizan en elementos de complejidad creciente:

instrucciones > funciones > clases > librerías > frameworks > aplicaciones

Cada uno de estos elementos tiene un nombre y propósito específico. Esta es una exigencia de nuestra memoria. Cuando algo crece demasiado necesitamos dividirlo y etiquetarlo para poder recordarlo. Usamos nombres como “capa de persistencia”, “cola de mensajes”, etc. Estas abstracciones proporcionan una visión de alto nivel que facilita el razonamiento. Además, los componentes resultantes son reemplazables, y sus posibles defectos están acotados. Es sentido común que el diseño modular es preferible al monolítico.

En resumen

  • Un programa es complejo porque contiene muchas instrucciones simples.
  • La encapsulación nos permite agruparlas en componentes significativos y operar a alto nivel con ellos.

Encapsulación en Java 8

¿Qué herramientas de encapsulación nos proporciona Java 8?

  • Paquetes
  • Clases, y clases anidadas.
  • Modificadores de acceso
  • JARs que agrupan paquetes relacionados
  • Patrones de diseño que exponen un interfaz y ocultan la implementación
  • Manejadores de dependencias de terceros, como
    • Maven, que gestiona dependencias en tiempo de compilación
    • OSGi, que gestiona dependencias en tiempo de ejecución

Estas herramientas planteaban problemas:

  • Classpath Hell: el classpath (conjunto de clases cargadas) puede contener clases duplicadas, clases no disponibles, dependencias con diferentes versiones de una misma librería, o cargadores de clases anidados con comportamientos complicados.
  • Las clases cargadas carecen de información sobre su origen.
  • Una vez cargadas, todas las clases están disponibles por reflexión, y carecen de información sobre su origen.
  • El entorno de ejecución contiene la plataforma entera. En Java 8 existen profiles, pero sigue siendo una granularidad muy grande.

Estos problemas están ligados a la implementación del compilador, el runtime, y la funcionalidad del lenguaje. Para solucionarlos era necesario cambiarlos, y eso ha hecho el proyecto Jigsaw.

Modularidad en Java 9

Los módulos de Java 9 mejoran así la plataforma:

  • Encapsulación fuerte. La encapsulación se cumple durante compilación y ejecución, incluso frente a intentos de reflexión.
  • Configuración fiable. El runtime comprueba la disponibilidad de las dependencias antes de lanzar la aplicación.
  • Creación de imágenes que empaqueta la aplicación con una plataforma Java hecha a medida. Esto implica
    • Menores requerimientos de memoria y disco (útil para microservicios y dispositivos pequeños)
    • Mayor seguridad, porque el código implicado es menor.
    • Optimización mejorada (dead-code elimination, constant folding, compression, etc.).
  • Servicios desacoplados sin escaneo del classpath (las implementaciones de un interfaz se indican explícitamente).
  • Carga rápida de tipos. El sistema sabe dónde está cada paquete sin tener que escanear el classpath.
  • Preserva las fronteras establecidas por la arquitectura.

La encapsulación fuerte implica otros beneficios, como la posibilidad de realizar pruebas aisladas de un módulo, evitar la decadencia del código al introducir dependencias accidentales, y la reducción de dependencias cuando varios equipos trabajan en paralelo.

¿Qué es un módulo?

En el diccionario, un módulo es una parte de algo más complejo. En Java, llamamos módulo a un artefacto que puede contener código, recursos, y metadatos. Los metadatos describen dependencias con otros módulos, y regulan el acceso a los paquetes del módulo.

El conjunto de ficheros que forman un módulo se agrupa en uno de estos tres formatos

  • Formato explotado. Un directorio que contiene el código fuente, datos, y descriptor de módulo.
  • JAR. Ídem pero empaquetado en un JAR.
  • JMOD. Lo mismo que un JAR, pero además puede contener código nativo.

Esto es un ejemplo de módulo en formato JAR:

Descriptor

El descriptor de módulo es la meta-información sobre el módulo. Contiene lo siguiente:

  • Nombre del módulo
  • Paquetes expuestos
  • Dependencias con otros módulos
  • Servicios consumidos e implementados

Los descriptores se escriben en un fichero module-info.java en la raíz del fichero JAR o directorio. Este fichero se compila junto al resto de ficheros Java. Observa que el nombre de fichero no es un identificador legal Java porque contiene un guión. Esto es a propósito para evitar que las herramientas lo confundan con un fichero Java. Es el mismo truco que se usa con package-info.java.

Este es un fichero module-info.java de ejemplo:

  • El módulo se llama ejemplo
  • Depende del paquete java.util.logging
  • Y expone el paquete com.ejemplo

Cheatsheet

Un módulo se define con las siguientes palabras clave:

exports… to

expone un paquete, opcionalmente a un módulo concreto

import

el típico import de Java. Lo normal es usar nombres completos de paquetes en vez de imports, pero si repites mucho un tipo, puede ser de utilidad.

module

Comienza la definición de un módulo.

open

Permite la reflexión en un módulo.

opens

Permite la reflexión en un paquete concreto, para alguno o todos los paquetes.

provides…with

Indica un servicio y su implementación.

requires, static, transitive

requires indica la dependencia con un módulo. Añade static para que sea requerido durante compilación y opcional durante la ejecución. Añade transitive para indicar dependencia con las dependencias del módulo requerido.

Hola Mundo

classpath

Este es un hola mundo normal, compilado y ejecutado en el classpath. donde Makefile es

modules

Este es el mismo hola mundo compilado y ejecutado como módulo. Hay tres cambios que convierten el código en un módulo:
  • Añadimos un descriptor module-info.java en el directorio raíz del código fuente. Puede ser tan simple como module holamundo {}.
  • Movemos todo el código fuente a un directorio con el mismo nombre que dimos al módulo en el descriptor.
  • Compilamos indicando el directorio del código fuente del módulo:
Ya solo falta lanzarlo como módulo: ¿Qué pasa si lo lanzamos una aplicación modular usando el classpath? se ejecuta igualmente. El fichero module-info.java es ignorado porque lleva un guión, y por tanto no cuenta como código java. Puedes probarlo con:

Para clonar el código de este ejemplo:

Descriptor de módulo

module

module define el nombre de un módulo.

  • El nombre de un módulo debe ser un identificador válido en Java o un nombre válido de paquete.
  • El nombre debe ser único. Si hay dos módulos con el mismo nombre en diferentes directorios, solo se usa uno de ellos. Es una buena idea usar nombres inversos de dominio para garantizar que el nombre es único.
  • El nombre no debe coincidir con el de una clase, interfaz, o paquete. No porque cause errores, sino porque sería confuso.

O al menos, ese es el consejo de Oracle. Si es un módulo privado y usas ejemplo, en vez de com.ejemplo, estará más claro cuál es el módulo y cuál el paquete. Pero si es un API pública, te evitaras colisiones usando el nombre de dominio.

requires

requires indica una dependencia a un módulo.

Ten en cuenta que no están permitidas las dependencias cíclicas durante compilación por varios motivos:

  • Impediría la compilación. Un tipo solo puede compilarse si los tipos de los que dependen ya han sido compilados.
  • No sería un buen diseño. Dos módulos en un ciclo, son en la práctica equivalentes a un único módulo. Normalmente las dependencias de un sistema discurren en un único sentido, de componentes generales a más específicos y no al revés. Coloquialmente hablando, yo uso al martillo, pero el martillo no me usa a mí.

static

requires static indica una dependencia obligatoria durante compilación, pero opcional durante ejecución.

Si el módulo HelloLogger no es accesible en tiempo de ejecución, los intentos de cargarlo devolverán nulo.

Para modelar una dependencia opcional con reflexión

Para modelar una dependencia opcional con ServiceLoader

Esto lleva más trabajo. Hay que poner una línea uses en el módulo que carga la instancia:

Y una línea provides en el módulo que proporciona el tipo:

transitive

requires transitive indica dependencias con las dependencias de un módulo. Es decir, si A→B y B→C, entonces A→C.

Si quieres visualizar las dependencias que fueron requeridas transitivamente pero que no están disponibles, añade el flag -Xlint:exports a javac.

exports

exports indica que los tipos públicos de un paquete pueden usarse desde otros módulos.

Decimos que un paquete es legible si está exportado, y que un tipo es accesible si es legible y además es public.

exports to

exports to indica que los tipos públicos de un paquete están disponibles pero solo para cierto(s) paquete. En inglés lo llaman “qualified export”.

Hay un problema con este enfoque. Si tengo el módulo independiente HelloLogger y añado el qualified export a com.example.junit, estoy acoplando ambos módulos. Los qualified exports están reservados para casos especiales. Por ejemplo, el paquete sun.* estaba pensado para uso privado, pero una vez en el classpath cualquiera podía usarlo. Para programas que dependen de él podemos hacerlo de nuevo accesible con un qualified export, aunque la mejor solución es sustituirlo por sus alternativas.

Ejemplo

Teniendo esto en cuenta, en JDK 9 el significado de public depende de si el tipo está exportado o no.

¿es en un paquete exportado?

public es accesible

sí pero solo para ciertos módulos

public es accesible para esos módulos

no

public es legible pero no accesible

Supongamos un módulo “ejemplo” con el siguiente contenido:

Si un módulo requiere este módulo ejemplo

  • Clyde es accesible sólo para el módulo holamundo
  • Inky es legible pero no accesible
  • Pinky es accesible

Además, Pinky y Clyde pueden usarse con cualquier tipo del módulo al que pertenecen, de acuerdo con los modificadores de acceso.

open

open permite la reflexión en un módulo o en un paquete concreto.

Llamamos deep reflection a la reflexión de tipos privados en Java. Por defecto, en el sistema de módulos sólo es posible hacer reflexión de métodos públicos pertenecientes a módulos exportados. Si el elemento es privado salta una excepción de tipo InaccessibleObjectException, y si es público pero no exportado salta una excepción de tipo IllegalAccessException.

Para permitir deep reflection de todos los miembros de un módulo usa open.

Para permitir deep reflection de un paquete de un método usa opens.

Para permitir deep reflection de un paquete de un módulo de terceros añade el flag --add-opens al comando java. Por ejemplo, para que el paquete myframework realice deep reflection en el paquete java.lang del módulo java.base escribe:

Para realizar reflexión en módulos exportados

Para realizar reflexión en módulos no exportados

Es lo mismo que con módulos exportados, pero tienes que añadir un --add-opens desde línea de comando y usar Class.forName("com.example.whatever") en vez de referirte al nombre del tipo.

Servicios

provides with

provides with indica que el módulo proporciona un tipo que implementa el servicio.

En Java 8, para implementar un servicio sin exponer la implementación podemos usar una factoría, o escanear el classpath para buscar el servicio. El sistema de módulos de Java 9 permite declarar los servicios explícitamente en los descriptores, lo cual es más rápido que un escaneo del classpath. Un servicio de Java 9 se compone de tres elementos: servicio, proveedor, y consumidor.

Para declarar el tipo servicio puedes usar un interfaz, clase abstracta, o clase Java. Este tipo debería estar en un paquete accesible por consumidor y proveedor. En este ejemplo defino un interfaz público y expongo su paquete en el descriptor:

Para registrar el proveedor hay que

  • Requerir el módulo que contiene la definición del servicio
  • Registrar la implementación del servicio usando la formula provides SERVICE with IMPLEMENTATION; Para los nombre de servicio e implementación tienes que usar el nombre completo, incluyendo el paquete.

El siguiente ejemplo requiere el módulo algorithms.sort, donde está el interfaz que he definido antes, y registro la implementación algorithms.sort.BubbleSort. Observa que la implementación del servicio no necesita ser expuesta.

Para implementar el proveedor el tipo debe tener un constructor sin argumentos, y/o un método public static provider() que devuelva el tipo del servicio. Por ejemplo,

uses

uses indica que el módulo usa un servicio dado.

Para registrar el consumidor del servicio usa la palabra clave uses.

Para cargar el servicio usa el API ServiceLoader. Hay dos modos de hacerlo: lookup o stream.

Cualquiera de las dos maneras lleva solo unas pocas líneas, pero serían demasiadas si tuvieras que usarlo a menudo. Otra manera más es devolver la lista de implementaciones desde el propio interfaz. Por ejemplo,

Instanciarlo el Provider del servicio es útil si queremos obtener información del tipo que lo implementa, antes de instanciar el servicio en sí.

¿Porqué deberíamos instanciar un servicio de JDK 9 en vez de usar una factoría? Los servicios son más rápidos de instanciar porque tienen un descriptor explícito con el que encontrarlos. Esto permite añadir implementaciones en forma de módulos sin escanear el classpath cada vez. Además, el API de ServiceLoader proporciona funciones de cacheo de servicios.

Para listar los proveedores de un servicio puedes usar jlink:

En nuestro ejemplo:

JDK 9

Homebrew es una forma sencilla de instalar y actualizar Java:

Para añadir al path los comandos del JDK edita .bashrc con el contenido siguiente:

Si alguna vez quieres alternar entre Java 8 y 9:

javac

¿Qué nuevas opciones relativas a módulos hay para javac?

--add-exports Hace legible un paquete no exportado en el descriptor de su módulo.
--add-modules Módulos raíz a resolver.
--limit-modules Limita los módulos observables.
--module Compila solo el módulo indicado.
--module-path Directorio donde están los módulos dependientes.
--module-source-path Directorios de código fuente para los módulos.
--module-version Versión de los módulos que estamos compilando.
--processor-module-path Ruta a los módulos que contienen procesadores de anotaciones.
--upgrade-module-path Módulos actualizables.

java

¿Qué nuevas opciones relativas a módulos hay para java?

--add-exports Hace legible un paquete no exportado en el descriptor de su módulo.
--illegal-access Permite saltarse la encapsulación. Los valores posibles son permit, warn, debug, deny.
--add-modules Añade módulos al modulepath.
--add-opens Permite reflexión de un paquete a otro. El formato es
–add-opens módulo/paquete=módulo_que_realiza_el_acceso
--describe-module Describe el contenido del módulo.
--list-modules Lista los módulos observables.
--module-path Directorios conteniendo los módulos.
--upgrade-module-path Actualiza módulos en la imagen.
--validate-modules Valida los módulos del module path y termina.
Al usar –add-module-path podemos hay nombres con significados especiales como ALL-DEFAULT, ALL-SYSTEM, ALL-MODULE-PATH. Curiosamente cuando hacemos java –help no muestra las opciones illegal-access, y add-opens. Si los comandos java o javac te quedan tan largo como tu brazo, puedes llevarte las opciones a un fichero de texto, por ejemplo argumentos.txt, y ejecutar java @argumentos.txt. Esta es una novedad en Java 9. Puedes incluso pasar varios ficheros, por ejemplo java @1.txt @2.txt.

Ejemplos

Multiproyecto Maven

Voy a empezar con un multiproyecto Maven.

Creo el proyecto

Creo el proyecto padre e hijo

Al crear el segundo aparece un aviso:

El comportamiento por defecto es permitir el acceso ilegal y mostrar un warning la primera vez, así que lo que vemos es normal. Este es el equivalente de pasar --illegal-access=permit a la JVM, que es un flag descrito en este mail.

El siguiente problema que encontré fue que el Maven compila por defecto para Java 1.5. Esto se arregla fácil en el pom.xml del proyecto raíz:

mvn package compila, y ejecuta las pruebas. Renombro App.java y AppTest.java a Hello.java y HelloTest.java. Compruebo que mvn package sigue funcionando. Ahora voy a ejecutar la clase principal así que añado el plugin de ejecución al pom.xml del módulo hijo.

Ya tenemos un proyecto multimódulo en Maven donde podemos compilar, probar, y ejecutar.

Este es mi layout por ahora:

El warning de antes

Antes me salté unos detalles sobre el warning de acceso ilegal. Como experimento probé a pasar deny a la JVM de Maven de estas dos maneras pero no funcionó:

  • Crea un fichero .mvn/jvm.config que contenga --illegal-access=deny
  • Pasa la opción -DargLine=--illegal-access=deny al comando Maven

La opción illegal-access solo funciona para paquetes que existían en el JDK 8, pero este sí existía así que debería haber funcionado. No sé que magia está haciendo Maven internamente para que esto falle (lanzando un nuevo proceso?).

Las opciones en sí, funcionan. Puedes probarlas con este ejemplo:

Para denegar la reflexión: Para permitir la reflexión desde el módulo unnamed (ALL-UNNAMED):

Conversión a módulos

Para modularizar el proyecto he hecho esto:

  • Divido el proyecto y sus pruebas en proyectos diferentes.
  • Añado un module-info.java a cada uno para convertirlos en módulos.
  • Pongo el código y sus pruebas en paquetes diferentes (porque un mismo paquete no puede existir en diferentes módulos).

Los descriptores son los siguientes:

Para las pruebas necesitamos los módulos JUnit, Hello y sus dependencias, y tenemos que permitir el acceso de JUnit al código de ejemplo.

JUnit carece de descriptor de módulo, por tanto es un módulo automático. Cuando ejecutamos mvn package aparece este warning:

Se refiere a que hay módulos en el module path cuyo nombre ha sido inferido del nombre de fichero y puede que no sea el correcto. Durante una larga temporada tendremos que tratar con warnings, y plugins no actualizados para JDK 9. En este caso particular es un warning sin importancia.

Ejecución con Maven

Si ejecutamos mvn package exec:exec desde el módulo Hello, el plugin exec-maven-plugin ejecuta el módulo como un JAR normal. Eso ocurre porque por defecto usa classpath y no modulepath. Vamos a cambiarlo para que ejecute módulos.

El comando para ejecutar las pruebas en el sistema de modularidad es un poco más largo. Hasta que Maven soporte mejor el sistema de modularidad este es posiblemente el mejor modo.

Ejecución con Makefile

Para ejecutar con make, he añadido estos Makefile a Hello y HelloTest

La ejecución de pruebas requería Junit y Hamcrest así que los he descargado a un directorio lib. Podría haber hecho una referencia al repositorio de Maven pero no he querido liarme más.

Idea

Para cargarlo en Idea, arranca y sigue estos pasos

  • Import project
  • From external model > Maven, pulsa Next
  • Pulsa estas opciones:
    • Import Maven projects automatically
    • Create module groups for multi-module Maven projects
    • Next
  • Pulsa Next, Next, Finish.

Dado que el código de pruebas está en un módulo Maven aparte, se importa correctamente y no hay problemas. Si hubiéramos dejado el layout original de Maven, Idea se quejaría de que los módulos tienen el mismo directorio raíz.

requires

Voy a pintar hola mundo con la clase Logger como excusa para importar un paquete.

Observa que Logger está en el módulo java.logging, no java.util.logging. Aunque la documentación recomienda usar el nombre de paquete principal, los módulos de Java se toman libertades para abreviar el nombre del módulo.

Añadir un servicio

Para instanciar un servicio hacemos un lookup con la ServiceLoader API y a continuación instanciamos con un la clase, u obtenemos un stream de Providers. El stream de proveedores nos permite inspeccionar el servicio antes de instanciarlo.

Aquí estoy instanciado el servicio a partir del primer elemento del iterador. Este es un ejemplo trivial, pero en un proyecto real debes controlar el caso en el que el iterador no devuelva elementos.

Código de ejemplo

Casi todos los ejemplos en este tutorial están en esta aplicación: https://github.com/j4n0/SimpleHello

Puedes ejecutar este código

  • Con make: haciendo un make en el directorio raíz, o dentro del directorio de cualquiera de los módulos.
  • Con maven: haciendo un mvn install en el directorio raíz, y un mvn exec:exec dentro del directorio HelloTest.
  • Con Idea: ejecutando el módulo HelloTest. Sin embargo con Idea no me ha funcionado uno de los ejemplos con el ServiceLoader, imagino que por tema de configuración de Idea, no le dedique tiempo, se admiten sugerencias!

Referencias

Y ahora, si te gusta pulsar en enlaces y leer documentación, pulsa en estos y finalmente serás feliz:

Youtube