Java Compiler Tree API y cómo manipular el árbol AST

0
2477

índice de contenidos

1. Introducción

Probablemente alguna vez te has preguntado cómo funcionan internamente algunos de los frameworks o de las tools más famosas como Sonar, Lombok o incluso IDEs como IntelliJ o Eclipse. En este post veremos qué es el AST (Abstract Syntax Tree) y cómo interactuar con él. La clasificación de estas clases podemos encontrarla en el siguiente overview oficial. Muchas de las clases citadas en el post quedaron ocultas a partir del proyecto Jigsaw (Java 9) como se especifica en la JEP 220.


** IMPORTANTE: El ejemplo que vamos a ver no está pensado para ser usado en un proyecto final ya que haremos uso de un API interno de la JDK que no está sujeto a retrocompatibilidad. Vamos a mostrar el uso de este API para poder ver cómo funciona y sus características, pero el uso de esta queda bajo nuestro propio riesgo.

2. Proceso de compilación de Java y generación del AST

Antes de entrar en materia primero debemos entender (aunque sea a alto nivel) cómo funciona el compilador de Java. El proceso de compilación consta, a grandes rasgos, de tres fases a la hora de compilar un fichero y generar el bytecode:

Parse and Enter

En la fase «Parse» se escanean todos los ficheros *.java, se tokenizan y se genera el árbol AST. Una buena analogía sería comparar el AST con un árbol DOM para aclarar el concepto. Una vez tokenizada la clase se pasa a la fase «Enter», en la cual se registran todos los símbolos y se añaden como entradas en la tabla de símbolos del compilador (palabras clave, nombres de métodos, variables, scopes, etc). Seguro que alguna vez os habéis encontrado con el típico error «Cannot find the symbol». Más sobre compiladores y tablas de símbolos aquí.

Annotation Processing

En el ejemplo propuesto usaremos esta fase como punto de entrada a la compilación. Esta fase es controlada por la clase com.sun.tools.javac.processing.JavacProcessingEnvironment. El procesado de anotaciones funciona por «rounds» o rondas, y es un paso que se realiza previamente a la compilación de las clases. Al arrancar la primera ronda se ejecutan los procesadores de las anotaciones definidos y, si alguna de estos procesadores genera nuevo código fuente, se realizarán rondas posteriores para tener en cuenta este nuevo código.
Cuando los procesadores de anotaciones han sido ejecutados se evalúa de nuevo si es necesaria una ronda posterior para compilar y procesar nuevo código fuente y, si es necesario, se generará un nuevo objeto JavaCompiler que lee y procesa el nuevo código fuente y genera una nueva ronda. Este proceso se repite hasta que no hay más anotaciones que procesar.

Analyse and Generate

Una vez que las dos fases anteriores se han ejecutado el compilador pasa a analizar la sintáxis del árbol generado contra la tabla de símbolos creada en la fase «Parse and Enter» de cara a generar los ficheros .class. El análisis y la generación de los ficheros .class se realiza a partir de una serie de visitors y no se garantiza que estos visitors se ejecuten en un orden o formato concretos por optimización de memoria, aunque sí se garantiza que cada elemento a procesar será procesado en algún momento. Aunque la fase de análisis contiene múltiples pasos internos (que pueden ser consultados aquí), en resumen, una vez completada la fase de análisis se generará el fichero .class.

3. Java Compiler API

El Java Compiler API es un conjuto de interfaces y clases que nos dan acceso al compilador de Java. Las principales acciones que podemos llevar a cabo usando este API son compilar clases programáticamente y obtener acceso al diagnóstico del compilador, también programáticamente. Esto puede ser interesante en algunas ocasiones, de hecho, en el ejemplo planteado usaremos este API para desarrollar un test unitario en el cual compilaremos una clase con el propósito de leer el bytecode que genera. El conjunto de clases e interfaces que forman este API están definidas dentro de la JSR 199.

4. Java Compiler Tree API y AST

El Java Compiler Tree API define un conjunto de clases e interfaces que nos proveen acceso al árbol AST generado en la primera fase de compilación. A través de estas interfaces y de sus clases de utilidades podemos intervenir en el proceso de compilación leyendo la estructura del árbol AST aunque no podamos modificarlo. Si nos fijamos de qué está compuesto el API (dentro de la paquetería com.sun.source.tree) en su mayoría son interfaces y solo hay unas pocas clases de utilidades que nos permiten usar un patrón Visitor, como por ejemplo la clase TreePathScanner, el cual nos habilita recorrer cualquier clase, método, constructor, imports, variables, etc.

Pero si son interfaces, ¿qué las implementa? Para responder a eso tenemos que salir del API público y analizar una librería que hasta la JDK 8 venía dentro del directorio /lib de la JDK. Hablamos de la librería tools.jar que comentaremos en el siguiente apartado.

4.1 tools.jar

El uso de esta librería nos abre una nueva puerta: la posibilidad, no solo de leer el árbol AST, sino de modificarlo. Todas las interfaces que se declaran en API público son implementadas por clases que se encuentran en esta librería y, a su vez, extienden de una clase abstracta llamada com.sun.tools.javac.tree.JCTree y cuyos tipos concretos se crean a partir de una factoría llamada com.sun.tools.javac.tree.TreeMaker. Además, veremos la clase com.sun.tools.javac.tree.TreeTranslator que extiende el com.sun.tools.javac.tree.JCTree.Visitor para facilitar la tarea de modificación del AST.

Como hemos comentado anteriormente, con la modularización que vino con Java 9 (https://openjdk.java.net/jeps/220), las librerías rt.jar y tools.jar son borradas y sus clases quedan encapsuladas dentro de un módulo que no se exportan en la JDK por lo que, a menos que se le indique lo contrario, están ocultas. Y por un buen motivo, y es que no aseguran retrocompatibilidad de dichas clases por lo que debemos tener cuidado de dónde usamos estas clases. En la JEP 261 se indica lo siguiente:

…The –add-exports and –add-opens options must be used with great care. You can use them to gain access to an internal API of a library module, or even of the JDK itself, but you do so at your own risk: If that internal API is changed or removed then your library or application will fail…

5. Ejemplo propuesto. Anotación @DTO para generar getters / setters en tiempo de compilación

A continuación vamos a ver un ejemplo completo usando todo lo explicado anteriormente y en el que desarrollaremos una anotación Java que llamaremos @DTO y cuyo procesador se encargará de leer y modificar el árbol AST para incluir métodos getter y setter de todas las variables de instancia de la clase en tiempo de compilación.

5.1 First things first. Empecemos por un test

Lo primero que vamos a implementar es un test unitario partiendo de una clase que solo define sus atributos de instancia y marcada con la anotación que hemos creado:


@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface DTO {}

@DTO
public class PersonSample {
    private String name;
    private int age;
    private UUID id;
    private Other other;
    private byte[] arr;

}

Dentro del test, obtendremos una referencia al compilador de Java para compilar la clase de prueba marcada con la anotación y posteriormente procesarla la anotación.

@Test
public void testDTOAnnotationProcessor() throws ClassNotFoundException {
    final JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
    final DiagnosticCollector diagnostics = new DiagnosticCollector();
    final StandardJavaFileManager manager = compiler.getStandardFileManager(diagnostics, null, null );

    final Path path = Paths.get("src/main/java/com/sergio/dto/PersonSample.java");
    Iterable sources = manager.getJavaFileObjects(path.toFile());
    final JavaCompiler.CompilationTask task = compiler.getTask(null, manager, diagnostics,
                    null, null, sources);

    task.setProcessors(Collections.singletonList(new DTOAnnotationProcessor()));
    final boolean compilationSuccesss = task.call();
    if (!compilationSuccesss) {
...

Si la invocación al método call() retorna false implica que la clase contiene errores de compilación, por lo que haremos uso de las clases de diagnóstico incluidas en el Compiler API para mostrar el error.

if (!compilationSuccess) {
     diagnostics.getDiagnostics().forEach(d -> {
       if (d.getKind() == Diagnostic.Kind.ERROR) {
           System.out.println(d.getMessage(null));
           System.out.println("Line error: " + d.getLineNumber());
           System.out.println("Column number: " + d.getColumnNumber());
       }
     });
     fail();
}

Si la compilación es correcta, crearemos nuestro propio ClassLoader para refrescar el bytecode generado después del procesamiento de la anotación y validaremos que existen un método getter y setter por cada atributo de la clase.

ClassLoader parentClassLoader = PersonSample.class.getClassLoader();
ReloaderClassLoader loader = new ReloaderClassLoader(parentClassLoader);
loader.setClassUrl("src/main/java/com/sergio/dto/PersonSample.class");
Class clazz = loader.loadClass("com.sergio.dto.PersonSample");
Set declaredMethods = Stream.of(clazz.getDeclaredMethods()).map(Method::getName).collect(toSet());
assertThat(declaredMethods).contains("getId", "getName");
assertThat(declaredMethods).contains("setId", "setName");

Una vez definido el test, y estando este en rojo, podemos empezar a ver el código que se encarga de procesar la anotación y añadir los nuevos métodos al AST.

5.2 El Procesador de anotaciones

Para poder intervenir en el proceso de compilación (más info aquí) podemos hacerlo de tres formas: Usando un Doclet, implementando un Plugin o usando un procesador de anotaciones. En este ejemplo usaremos la última de las opciones donde la JSR 269 es la especificación donde se define. Para crear un procesador de anotaciones basta con extender la clase javax.annotation.processing.AbstractProcessor, indicarle qué anotación(es) procesa y qué versión de Java soporta.

Al extender esta clase nos obliga a implementar el método process que, como su nombre indica, se encarga de procesar la lógica de la anotación.

@SupportedAnnotationTypes("com.sergio.dto.DTO")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class DTOAnnotationProcessor extends AbstractProcessor {

    private JavacProcessingEnvironment env;

    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {
        this.env = (JavacProcessingEnvironment) processingEnv;
        super.init(processingEnv);
    }

    @Override
    public boolean process(Set annotations, RoundEnvironment environment) {
        final Context context = env.getContext();
        final Trees trees = Trees.instance(env);

        if (!environment.processingOver()) { // in order to avoid processing twice in the last round
            for (Element codeElement : environment.getRootElements()) {
                if (codeElement.getKind() != ElementKind.CLASS) continue;
                JCTree tree = (JCTree) trees.getTree(codeElement);
                tree.accept(new GetterAndSetterJavaTranslator(context));
            }
        }

        return false; // false as default behaviour. Process subsequent annotations if exists.
    }
}

En este caso, la lógica de procesamiento será la de acceder al árbol AST para el/los elementos en cuestión y pasarlos por una implementación propia de un TreeTranslator. Una vez identificado el elemento que queremos procesar se accede al visitor a través del método accept del árbol, al cual le pasamos nuestra clase para añadir las modificaciones oportunas. Para ello extendemos la clase TreeTranslator sobrescribiendo el método visitClassDef:


public class GetterAndSetterJavaTranslator extends TreeTranslator {

    public GetterAndSetterJavaTranslator(Context context) {
        this.context = context;
    }

    @Override
    public void visitClassDef(JCTree.JCClassDecl clazz) {

        super.visitClassDef(clazz);

        final boolean isAnnotated = isClassAnnotated(clazz);

        if (isAnnotated) {

            final TreeMaker maker = TreeMaker.instance(context);
            final Names names = Names.instance(context);
            final Symtab symb = Symtab.instance(context);

            final com.sun.tools.javac.util.List getters = createGetters(maker, names, clazz);
            final com.sun.tools.javac.util.List setters = createSetters(maker, names, symb, clazz);

            clazz.defs = clazz.defs.appendList(getters).appendList(setters);
            result = clazz;

            System.out.println("class after translate = " + result);
        }

    }
 ...
}

Es importante preguntar si la clase está marcada con la anotación creada y solo procesarla en tal caso. Al extender de TreeTranslator se nos da acceso al atributo result que será el atributo al cual se le añaden los métodos getters y setters como se muestra continuación.


/**
* public <memberType> get<MemberName>() {
*  return <memberName>;
* }
*/
private com.sun.tools.javac.util.List createGetters(TreeMaker maker, Names names, ClassTree node) {
   return com.sun.tools.javac.util.List.from(node.getMembers().stream()
         .filter(m -> m.getKind() == Tree.Kind.VARIABLE)
         .map(m -> {
               final VariableTree varTree = (VariableTree) m;
               final Tree type = varTree.getType();
               final Name name = names.fromString(varTree.getName().toString());
               final Name methodName = names.fromString("get" + capitalize(name));
               final JCTree.JCExpression memberName = maker.Ident(name);
               final JCTree.JCBlock block = maker.Block(0, List.of(maker.Return(memberName)));
               return maker.MethodDef(
                     maker.Modifiers(Flags.PUBLIC),  // method access modifier
                     methodName,                     // method name
                     generateReturnType(type),       // return type
                     List.nil(),                     // generic type parameters
                     List.nil(),                     // parameter list
                     List.nil(),                     // throws clause
                     block,                          // method body
                     null                // default methods (for interface declaration)
               ).getTree();
          }).collect(Collectors.toList()));
    }

Usamos las clases de utilidades que nos proporciona la librería tools.jar para generar los métodos getter. La clase más importante aquí es com.sun.tools.javac.tree.TreeMaker, que es la factoría que nos permite crear cualquier subtipo de la clas com.sun.tools.javac.tree.JCTree. En el ejemplo usamos el maker para generar un com.sun.tools.javac.tree.MethodDef (definición de método), además de crear el bloque de código que se encuentra dentro del getter. En este caso creará un tipo com.sun.tools.javac.tree.JCReturn (una definición de tipo retorno) que nos permite devolver el miembro de la clase. Veámos cómo crear los setters:

/**
* public void set<MemberName>(<MemberType> <MemberName>) {
*   this.<member> = <MemberName>;
* }
*/
private com.sun.tools.javac.util.List<JCTree> createSetters(TreeMaker maker, Names names, Symtab symb, ClassTree node) {
    return com.sun.tools.javac.util.List.from(node.getMembers().stream()
        .filter(m -> m.getKind() == Tree.Kind.VARIABLE)
        .map(m -> {
               final VariableTree varTree = (VariableTree) m;
               final Name methodName = names.fromString("set" + capitalize(names.fromString(varTree.getName().toString())));
               final Name name = names.fromString(varTree.getName().toString());
               final JCTree.JCExpression memberName = maker.Ident(name);

               final JCTree.JCVariableDecl param = generateParameter(varTree, maker, names, symb);
               final JCTree.JCAssign assign = maker.Assign(memberName, maker.Ident(param.getName()));
               final JCTree.JCExpressionStatement exec = maker.Exec(assign);
               final JCTree.JCBlock block = maker.Block(0, List.of(exec));
                    
               return maker.MethodDef(
                     maker.Modifiers(Flags.PUBLIC),  // method access modifier
                     methodName,                     // method name
                     maker.TypeIdent(TypeTag.VOID),  // return type
                     List.nil(),                     // generic type parameters
                     List.of(param),                 // parameter list
                     List.nil(),                     // throws clause
                     block,                          // method body
                     null                // default methods (for interface declaration)    
               ).getTree();
        }).collect(Collectors.toList()));
}

La creación de los setters difiere de los getters en el bloque de código que se declara dentro del método, ya que no retornamos nada, sino que asignamos un parámetro a un miembro de la clase. Usando el TreeMaker creamos un tipo com.sun.tools.javac.tree.JCAssign el cual recibe las expresiones a asignar a la izquierda y a la derecha.

Una vez definidos los métodos y asignados al attributo result cuando ejecutemos el test unitario deberíamos ver el fichero .class de la clase de ejemplo junto con un método getter y setter por cada miembro de la clase, además de ver el test en verde.

  
// IntelliJ API Decompiler stub source generated from a class file
// Implementation of methods is not available

package com.sergio.dto;

@com.sergio.dto.DTO
public class PersonSample {
    private java.lang.String name;
    private int age;
    private java.util.UUID id;
    private com.sergio.dto.Other other;
    private byte[] arr;

    public PersonSample() { /* compiled code */ }

    public java.lang.String getName() { /* compiled code */ }

    public int getAge() { /* compiled code */ }

    public java.util.UUID getId() { /* compiled code */ }

    public com.sergio.dto.Other getOther() { /* compiled code */ }

    public byte[] getArr() { /* compiled code */ }

    public void setName(java.lang.String s) { /* compiled code */ }

    public void setAge(int i) { /* compiled code */ }

    public void setId(java.util.UUID uuid) { /* compiled code */ }

    public void setOther(com.sergio.dto.Other other) { /* compiled code */ }

    public void setArr(byte[] bytes) { /* compiled code */ }
}

6. Conclusiones

Dado que es un contenido denso para ser leído he creído necesario dar apoyo visual del contenido con un screencast, divido en dos partes, desarrollando desde cero un ejemplo muy parecido a este y que os he dejado en en apartado 7. Referencias.

Aunque no sea algo que vayamos a usar todos los días sí que merece la pena ver cómo muchas de las herramientas que usamos día a día están implementadas internamente. Conocer un poco cómo funcionan las tripas siempre nos puede aportar visión además de ser tremendamente divertido.

Y recordad: lo visto en este post puede daros más de un dolor de cabeza si lo usáis sin pensar dos veces en las consecuencias.

7. Referencias

Ejemplo completo

Vídeos relacionados

Documentación

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad