Generación de código en tiempo de ejecución con Byte Buddy

Escrito por el , actualizado el .
java planeta-codigo programacion
Enlace permanente Comentarios

El tipado seguro y el sistema de tipos es sin duda una de las características más importante del lenguaje de programación Java que han contribuido a su éxito. Cuando no conocemos los tipos en tiempo de compilación el sistema de tipos es una limitación donde los lenguajes dinámicos son capaces de resolver el problema sin necesidad de los tipos pero perdiendo la ayuda del compilador. Usando una librería de generación de código en tiempo de compilación o ejecución tenemos la posibilidad en Java de realizar algunas tareas que los lenguajes dinámicos permiten.

Byte Buddy

Java

Java posee un sistema de tipos estricto con el que detectar errores de compilación y hace que el código sea más legible, con un IDE los errores de compilación los detectaremos inmediatamente según escribimos código. Este sistema de tipos estricto es deseable en aplicaciones de negocio y empresariales ya que ayuda a que las aplicaciones tengan menos errores o errores de compilación pasen inadvertidos y ser descubiertos incluso semanas después de haber sido desplegados en producción. Su sistema de tipos es uno de los responsables del éxito de Java. Sin embargo, el sistema de tipos estricto impone restricciones en otro tipo de ámbitos como en una biblioteca de propósito general ya que no se conocerán los tipos en tiempo de compilación y no podrán por tanto ser referenciados o alternativamente hayan ser definidos como interfaces o clases abstractas que posteriormente son implementadas o extendidas.

Para acceder a propiedades e invocar métodos de tipos desconocidos en tiempo de compilación en Java disponemos de la reflection API o API de introspección aunque tiene los siguientes inconvenientes:

  • Es lenta: más que la invocación directa de un método. La API de introspección usa JNI y requiere hacer un análisis del objeto costosa para invocar el método del objeto.
  • Inutiliza el tipado seguro: la API de introspección no es type-safe. La comprobación de los tipos de los argumentos en la invocación de un método es retrasada hasta el momento de ejecución.

Usando la API de introspección perdemos una de las grandes características de Java, el tipado seguro, adicionalmente el rendimiento será menor. Conocidas estas limitaciones hay varias librerías que las palían generando código en tiempo de ejecución, algunas de las más conocidas son Java Proxy que está incluida en el propio JDK, cglib, Javassists o ASM.

Leyendo uno de los artículos de la publicación gratuita Java Magazine de Nov/Dic 2015 conocí otra alternativa llamada Byte Buddy con la que al contrario de otras posibilidades no estamos limitados a generar clases que implementen interfaces conocidas (como en Java proxies), tiene un mantenimiento activo y soporta las nuevas características de las últimas versiones del lenguaje (al contrario de cglib), no está tan limitada (como Javassists) y no hay que tener conocimientos de byte code (como con ASM).

La generación de código se ha vuelto ubicua en muchas de las librerías más populares de Java y se usa profusamente en Spring, Hibernate o Apache Tapestry para aplicar seguridad, gestión de transacciones, mapeo modelo relacional-objeto o pruebas unitarias o de integración (mocking, …) y de manera similar a lo ofrecido por los AST de Groovy. Permite emular algunas propiedades que solo están accesibles al programar con lenguajes dinámicos sin perder las comprobaciones de tipos. Las clases generadas por Byte Buddy no se distinguen de las clases generadas por el compilador.

Un ejemplo sencillo de la definición de una nueva clase en tiempo de ejecución con el método String.toString() que devuelve un valor fijo sería la siguiente:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Create a Class
DynamicType.Unloaded<?> unloaded = new ByteBuddy()
        .subclass(Object.class)
        .name("io.github.picodotdev.blobitix.holamundobytebuddy.Object")
        .method(named("toString")).intercept(FixedValue.value("Hello World!"))
        .make();

Class<?> objectClass = unloaded.load(Main.class.getClassLoader(), ClassLoadingStrategy.Default.WRAPPER)
        .getLoaded();

Object object = objectClass.newInstance();
System.out.printf("%s: %s\n", object.getClass().getName(), object.toString());
Main-1.java

Con los métodos saveIn, inject y toJar de DynamicType.Unloaded podemos generar las clases en el momento de construcción de la aplicación previo a que sea desplegada y guardarlas en archivos .class o en librerías .jar.

1
2
3
unloaded.saveIn(file);
unloaded.inject(file);
unloaded.toJar(file);
Main-2.java

Usando los selectores adecuados como method, field, constructor, named entre muchos otros de la clase ElementMatchers seremos capaces de interceptar las llamadas a los métodos y establecerles el comportamiento que deseemos.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Intercept methods
Foo foo = new ByteBuddy()
        .subclass(Foo.class)
        .method(isDeclaredBy(Foo.class)).intercept(FixedValue.value("One!"))
        .method(named("foo")).intercept(FixedValue.value("Two!"))
        .method(named("foo").and(takesArguments(1))).intercept(FixedValue.value("Three!"))
        .make()
        .load(Main.class.getClassLoader(), ClassLoadingStrategy.Default.WRAPPER)
        .getLoaded()
        .newInstance();

System.out.println();
System.out.printf("%s.bar(): %s\n", foo.getClass().getName(), foo.bar());
System.out.printf("%s.foo(): %s\n", foo.getClass().getName(), foo.foo());
System.out.printf("%s.foo(Object o): %s\n", foo.getClass().getName(), foo.foo("¡Hello World!"));
Main-3.java
1
2
3
4
5
6
7
8
package io.github.picodotdev.blogbitix.holamundobytebuddy;

public class Foo {

    public String bar() { return null; }
    public String foo() { return null; }
    public String foo(Object o) { return null; }
}
Foo.java

Byte Buddy permite tres tipos de extensiones:

  • subclass: crea un nuevo tipo subclase de otro.
  • redefine: redefine el comportamiento de un tipo existente.
  • rebase: redefine el comportamiento de un tipo existente y renombra los métodos redefinidos de modo que siguen estando disponibles internamente.

Devolver valores fijos en un método seguramente no será lo que deseemos en muchos casos pero podemos delegar el comportamiento de un método en otro y esta es una forma muy sencilla de manipular el comportamiento de un método sin conocer absolutamente nada de bytecode ya que todo el código que proporcionamos es código Java. En el método en que se delega la llamada de uno interceptado es posible usar varias anotaciones para obtener diversos parámetros adicionales, @Argument(n), @AllArguments, @This, @Super, @Origin (Method, Constructor, Executable, Class, MethodHandle, MethodType, String o int), @SuperCall, @RuntimeType, @DefaultCall, @Default. El listado completo de anotaciones está disponible en la API Javadoc.

Podemos proporcionar implementaciones de métodos de la siguiente forma, suponiendo que queremos redefinir el método hello de la clase Source con el comportamiento implementado en la clase Target:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Delegating a method call
Class<? extends Source> sourceClass = new ByteBuddy()
        .subclass(Source.class)
        .method(named("hello")).intercept(MethodDelegation.to(Target.class))
        .make()
        .load(Main.class.getClassLoader(), ClassLoadingStrategy.Default.WRAPPER)
        .getLoaded();

String message = sourceClass.newInstance().hello("World");
System.out.println();
System.out.println(message);
Main-4.java
1
2
3
4
5
6
package io.github.picodotdev.blogbitix.holamundobytebuddy;

public class Source {

    public String hello(String name) { return null; }
}
Source.java
1
2
3
4
5
6
7
8
package io.github.picodotdev.blogbitix.holamundobytebuddy;

public class Target {

    public static String hello(String name) {
            return "Hello " + name + "!";
        }
}
Target.java

Dicho esto, la generación de código en tiempo de ejecución o compilación nos permite nuevas posibilidades que solo ofrecían lenguajes dinámicos o de resolver problemas con programación orientada a aspectos. Aún así hay que tener en cuenta que las clases Java son elementos especiales para la la máquina virtual y nunca son recolectadas por el recolector de basura mientras su ClassLoader este en uso por alguna de las clases que hay cargadas en la aplicación.

Ejecutando esta pequeña aplicación obtenemos el siguiente resultado en la terminal.

1
2
3
4
5
6
7
io.github.picodotdev.blobitix.holamundobytebuddy.Object: Hello World!

io.github.picodotdev.blogbitix.holamundobytebuddy.Foo$ByteBuddy$7lxtAhIy.bar(): One!
io.github.picodotdev.blogbitix.holamundobytebuddy.Foo$ByteBuddy$7lxtAhIy.foo(): Two!
io.github.picodotdev.blogbitix.holamundobytebuddy.Foo$ByteBuddy$7lxtAhIy.foo(Object o): Three!

Hello World!
System.out

En el tutorial de Byte Buddy encontraremos más información y más detallada de las posibilidades que nos ofrece esta interesante librería en la plataforma JVM para manipular bytecode y tipos con el lenguaje Java en tiempo de ejecución.

Terminal

El código fuente completo del ejemplo puedes descargarlo del repositorio de ejemplos de Blog Bitix alojado en GitHub y probarlo en tu equipo ejecutando siguiente comando:
./gradlew run


Comparte el artículo: