0

I want to create the following Java annotation, and process it at build time:

@Target(value = FIELD)
interface @AnnotateGetter {
    Annotation[] value();
}

If a field field is annotated with @AnnotateGetter, then all of the Annotations in the value array are added to the method getField() of the same class, if such a method exists.

What is the easiest way to do this?

  1. ApectJ, which can add an annotation to a method with a declare annotation statement, but, while I know how to select a field that is annotated with @AnnotateGetter, I don't know how to select a method that corresponds to a field that is annotated with @AnnotateGetter
  2. Some other AOP framework
  3. Writing my own javax.annotation.processing.Processor that calls some library that can add an annotation to a method. What are the best options for such a library? Would it have to manipulate bytecode after javac compiled the source file, or could I somehow hook into javac & add the annotations during compilation, before the class file has been generated?
  4. something else...
XDR
  • 4,070
  • 3
  • 30
  • 54
  • First of all, arrays of `Annotation` are not supported by Java the language, so you can't compile the class declaration above. – Andrey Breslav Apr 14 '15 at 16:57
  • Thanks for the info. Since arrays of specific annotation types are supported, I just erroneously assumed that arrays of Annotation would work. I guess that I could write a processor to move annotations that have been applied directly to a field to its getter, instead of wrapping them in my proposed annotation. Are there any problems with that proposal? If that could work, I'll either ask a new question, or reword this one. Which is recommended? – XDR Apr 14 '15 at 17:23
  • I think simply moving annotations from field to a getter should work (unless they are restricted by the `@Target` annotation) – Andrey Breslav Apr 16 '15 at 04:19
  • I put quite a lot of effort into my answer. How about some feedback? – kriegaex May 01 '15 at 14:05

2 Answers2

2

Here is a solution using APT (annotation processing tool) via AspectJ. It adds the specified annotations to getter methods, but does not remove them from fields. So this is a "copy" action, not a "move".

Annotation processing support was added to AspectJ in version 1.8.2 and described in the release notes. Here is some self-consistent sample code. I compiled it from the command line because from Eclipse I failed to get it running according to AspectJ maintainer Andy Clement's description.

Okay, let's say we have one (Eclipse or other) project directory and the directory layout be like this:

SO_AJ_APT_MoveAnnotationsFromMemberToGetter
    compile_run.bat
    src
        de/scrum_master/app/Person.java
    src_apt
        de/scrum_master/app/AnnotatedGetterProcessor.java
        de/scrum_master/app/AnnotateGetter.java
        de/scrum_master/app/CollationType.java
        de/scrum_master/app/SortOrder.java
        de/scrum_master/app/Unique.java
        META-INF/services/javax.annotation.processing.Processor

Both src and src_apt are source directories, compile_run.bat is a Windows batch file building the project in two stages (annotation processor first, then the rest of the project) and running the final result in order to prove that it actually does what it should.

Annotations to be used for fields and later copied to getter methods:

package de.scrum_master.app;

import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.FIELD, ElementType.METHOD })
public @interface Unique {}
package de.scrum_master.app;

import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.FIELD, ElementType.METHOD })
public @interface SortOrder {
    String value() default "ascending";
}
package de.scrum_master.app;

import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.FIELD, ElementType.METHOD })
public @interface CollationType {
    String value() default "alphabetical";
    String language() default "EN";
}

Meta annotation designating field annotations to be copied to getter methods:

Please note that this meta annotation is only needed for annotation processing and thus has a SOURCE retention scope.

package de.scrum_master.app;

import java.lang.annotation.*;

@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.TYPE)
public @interface AnnotateGetter {
    Class<? extends Annotation>[] value();
}

Driver application:

Things to be noted:

  • There are four fields with annotations (id, firstName, lastName, fieldWithoutGetter), but only the first three have corresponding getter methods, the last one does not. So we expect fieldWithoutGetter to be handled gracefully later, either an empty or no ITD aspect being generated later via APT.

  • The meta annotation @AnnotateGetter({ Unique.class, SortOrder.class, CollationType.class }) on class Person specifies which annotations to consider for copying them to getter methods. Later you can play around with it and see how the result changes if you remove any of them.

  • We also have some dummy methods doSomething() and doSomethingElse() which should be unaffected by any annotation copying later, i.e. they should not get any new annotations via AspectJ. (It is always good to have a negative test case.)

  • The main(..) method uses reflection to print all fields and methods including their annotations.

package de.scrum_master.app;

import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Method;

@AnnotateGetter({ Unique.class, SortOrder.class, CollationType.class })
public class Person {
    @Unique
    private final int id;

    @SortOrder("descending")
    @CollationType("alphabetical")
    private final String firstName;

    @SortOrder("random")
    @CollationType(value = "alphanumeric", language = "DE")
    private final String lastName;

    @SortOrder("ascending")
    @CollationType(value = "numeric")
    private final int fieldWithoutGetter;

    public Person(int id, String firstName, String lastName, int fieldWithoutGetter) {
        this.id = id;
        this.firstName = firstName;
        this.lastName = lastName;
        this.fieldWithoutGetter = fieldWithoutGetter;
    }

    public int getId() { return id; }
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public void doSomething() {}
    public void doSomethingElse() {}

    public static void main(String[] args) {
        System.out.println("Field annotations:");
        for (Field field : Person.class.getDeclaredFields()) {
            System.out.println("  " + field.getName());
            for (Annotation annotation : field.getAnnotations())
                System.out.println("    " + annotation);
        }
        System.out.println();
        System.out.println("Method annotations:");
        for (Method method : Person.class.getDeclaredMethods()) {
            System.out.println("  " + method.getName());
            for (Annotation annotation : method.getAnnotations())
                System.out.println("    " + annotation);
        }
    }
}

Console output without APT + AspectJ:

As you can see, the field annotations are printed, but no method annotations because we have yet to define an annotation processor (see further below).

Field annotations:
  id
    @de.scrum_master.app.Unique()
  firstName
    @de.scrum_master.app.SortOrder(value=descending)
    @de.scrum_master.app.CollationType(value=alphabetical, language=EN)
  lastName
    @de.scrum_master.app.SortOrder(value=random)
    @de.scrum_master.app.CollationType(value=alphanumeric, language=DE)
  fieldWithoutGetter
    @de.scrum_master.app.SortOrder(value=ascending)
    @de.scrum_master.app.CollationType(value=numeric, language=EN)

Method annotations:
  main
  getId
  doSomething
  doSomethingElse
  getFirstName
  getLastName

Annotation processor:

Now we need an annotation processor generating an aspect for each combination of field and annotation to be copied. Such an aspect should look like this:

package de.scrum_master.app;

public aspect AnnotateGetterAspect_Person_CollationType_lastName {
    declare @method : * Person.getLastName() : @de.scrum_master.app.CollationType(value = "alphanumeric", language = "DE");
}

Very simple, is it not? The annotation processor should generate those aspects into a directory .apt_generated. The AspectJ compiler will handle that for us, as we will see later. But first here is the annotation processor (sorry for the lenghty code, but this is what you asked for):

package de.scrum_master.app;

import java.io.*;
import java.util.*;

import javax.tools.*;
import javax.annotation.processing.*;
import javax.lang.model.*;
import javax.lang.model.element.*;
import javax.lang.model.type.*;

@SupportedAnnotationTypes(value = { "*" })
@SupportedSourceVersion(SourceVersion.RELEASE_7)
public class AnnotatedGetterProcessor extends AbstractProcessor {
    private Filer filer;

    @Override
    public void init(ProcessingEnvironment env) {
        filer = env.getFiler();
    }

    @SuppressWarnings("unchecked")
    @Override
    public boolean process(
        Set<? extends TypeElement> elements,
        RoundEnvironment env
    ) {

        // Get classes annotated with something like: @AnnotateGetter({ Foo.class, Bar.class, Zot.class })
        env.getElementsAnnotatedWith(AnnotateGetter.class)
            .stream()
            .filter(annotatedClass -> annotatedClass.getKind() == ElementKind.CLASS)

            // For each filtered class, copy designated field annotations to corresponding getter method, if present
            .forEach(annotatedClass -> {
                String packageName = annotatedClass.getEnclosingElement().toString().substring(8);
                String className = annotatedClass.getSimpleName().toString();

                /*
                 * Unfortunately when we do something like this:
                 *   AnnotateGetter annotateGetter = annotatedClass.getAnnotation(AnnotateGetter.class);
                 *   Class<? extends Annotation> annotationToBeConverted = annotateGetter.value()[0];
                 * We will get this exception:
                 *   Internal compiler error:
                 *     javax.lang.model.type.MirroredTypesException:
                 *       Attempt to access Class objects for TypeMirrors
                 *       [de.scrum_master.app.Unique, de.scrum_master.app.SortOrder, de.scrum_master.app.CollationType]
                 *       at org.aspectj.org.eclipse.jdt.internal.compiler.apt.model.AnnotationMirrorImpl.getReflectionValue
                 *
                 * Thus, we have to use annotation mirrors instead of annotation classes directly,
                 * then tediously extracting annotation values from a nested data structure. :-(
                 */

                // Find @AnnotateGetter annotation and extract its array of values from deep within
                ((List<? extends AnnotationValue>) annotatedClass.getAnnotationMirrors()
                    .stream()
                    .filter(annotationMirror -> annotationMirror.getAnnotationType().toString().equals(AnnotateGetter.class.getName()))
                    .map(AnnotationMirror::getElementValues)
                    .map(Map::values)
                    .findFirst()
                    .get()
                    .stream()
                    .map(AnnotationValue::getValue)
                    .findFirst()
                    .get()
                )
                    .stream()
                    .map(annotationValueToBeCopied -> (TypeElement) ((DeclaredType) annotationValueToBeCopied.getValue()).asElement())
                    // For each annotation to be copied, get all correspondingly annotated fields
                    .forEach(annotationTypeElementToBeCopied -> {
                        env.getElementsAnnotatedWith(annotationTypeElementToBeCopied)
                            .stream()
                            .filter(annotatedField -> ((Element) annotatedField).getKind() == ElementKind.FIELD)
                            // For each annotated field create an ITD aspect
                            .forEach(annotatedField -> {
                                String fieldName = annotatedField.getSimpleName().toString();
                                String aspectName =
                                    "AnnotateGetterAspect_" + className + "_" +
                                    annotationTypeElementToBeCopied.getSimpleName() + "_" + fieldName;

                                StringBuilder annotationDeclaration = new StringBuilder()
                                    .append("@" + annotationTypeElementToBeCopied.getQualifiedName() + "(");

                                annotatedField.getAnnotationMirrors()
                                    .stream()
                                    .filter(annotationMirror -> annotationMirror.getAnnotationType().toString().equals(annotationTypeElementToBeCopied.getQualifiedName().toString()))
                                    .map(AnnotationMirror::getElementValues)
                                    .forEach(annotationParameters -> {
                                        annotationParameters.entrySet()
                                            .stream()
                                            .forEach(annotationParameter -> {
                                                ExecutableElement annotationParameterType = annotationParameter.getKey();
                                                AnnotationValue annotationParameterValue = annotationParameter.getValue();
                                                annotationDeclaration.append(annotationParameterType.getSimpleName() + " = ");
                                                if (annotationParameterType.getReturnType().toString().equals("java.lang.String"))
                                                    annotationDeclaration.append("\"" + annotationParameterValue + "\"");
                                                else
                                                    annotationDeclaration.append(annotationParameterValue);
                                                annotationDeclaration.append(", ");
                                            });
                                        if (!annotationParameters.entrySet().isEmpty())
                                            annotationDeclaration.setLength(annotationDeclaration.length() - 2);
                                        annotationDeclaration.append(")");
                                    });

                                // For each field with the current annotation, create an ITD aspect
                                // adding the same annotation to the member's getter method
                                String aspectSource = createAspectSource(
                                    annotatedClass, packageName, className,
                                    annotationDeclaration.toString(), fieldName, aspectName
                                );
                                writeAspectSourceToDisk(packageName, aspectName, aspectSource);
                            });
                    });
            });
        return true;
    }

    private String createAspectSource(
        Element parentElement,
        String packageName,
        String className,
        String annotationDeclaration,
        String fieldName,
        String aspectName
    ) {
        String getterMethodName = "get" + fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1);

        StringBuilder aspectSource = new StringBuilder()
            .append("package " + packageName + ";\n\n")
            .append("public aspect " + aspectName + " {\n");

        for (Element childElement : parentElement.getEnclosedElements()) {
            // Search for methods
            if (childElement.getKind() != ElementKind.METHOD)
                continue;
            ExecutableElement method = (ExecutableElement) childElement;

            // Search for correct getter method name
            if (!method.getSimpleName().toString().equals(getterMethodName))
                continue;
            // Parameter list for a getter method must be empty
            if (!method.getParameters().isEmpty())
                continue;
            // Getter method must be public
            if (!method.getModifiers().contains(Modifier.PUBLIC))
                continue;
            // Getter method must be non-static
            if (method.getModifiers().contains(Modifier.STATIC))
                continue;

            // Add call to found method
            aspectSource.append(
                "    declare @method : * " + className + "." + getterMethodName + "() : " +
                annotationDeclaration + ";\n"
            );
        }

        aspectSource.append("}\n");

        return aspectSource.toString();
    }

    private void writeAspectSourceToDisk(
        String packageName,
        String aspectName,
        String aspectSource
    ) {
        try {
            JavaFileObject file = filer.createSourceFile(packageName + "." + aspectName);
            file.openWriter().append(aspectSource).close();
            System.out.println("Generated aspect " + packageName + "." + aspectName);
        } catch (IOException ioe) {
            // Message "already created" can appear if processor runs more than once
            if (!ioe.getMessage().contains("already created"))
                ioe.printStackTrace();
        }
    }
}

I am not going to say much about the annotation processor, please read it carefully. I also added some source code comments, hopefully they are good enough to be understood.

src_apt/META-INF/services/javax.annotation.processing.Processor:

We need this file for the annotation processor to work later in connection with the AspectJ compiler (ajc).

de.scrum_master.app.AnnotatedGetterProcessor

Batch file building and running the project:

Sorry if this is platform-specific, but I guess you can easily convert it into a UNIX/Linux shell script, it is quite straightforward.

@echo off

set SRC_PATH=C:\Users\Alexander\Documents\java-src
set ASPECTJ_HOME=C:\Program Files\Java\AspectJ

echo Building annotation processor
cd "%SRC_PATH%\SO_AJ_APT_MoveAnnotationsFromMemberToGetter"
rmdir /s /q bin
del /q processor.jar
call "%ASPECTJ_HOME%\bin\ajc.bat" -1.8 -sourceroots src_apt -d bin -cp "%ASPECTJ_HOME%\lib\aspectjrt.jar"
jar -cvf processor.jar -C src_apt META-INF -C bin .

echo.
echo Generating aspects and building project
rmdir /s /q bin .apt_generated
call "%ASPECTJ_HOME%\bin\ajc.bat" -1.8 -sourceroots src -d bin -s .apt_generated -inpath processor.jar -cp "%ASPECTJ_HOME%\lib\aspectjrt.jar";processor.jar -showWeaveInfo

echo.
echo Running de.scrum_master.app.Person
java -cp bin;"%ASPECTJ_HOME%\lib\aspectjrt.jar" de.scrum_master.app.Person

Console log for build + run process:

Building the processor + annotation classes, then packaging them into a processor.jar:

Building annotation processor
Manifest wurde hinzugefügt
Eintrag META-INF/ wird ignoriert
META-INF/services/ wird hinzugefügt(ein = 0) (aus = 0)(0 % gespeichert)
META-INF/services/javax.annotation.processing.Processor wird hinzugefügt(ein = 45) (aus = 46)(-2 % verkleinert)
de/ wird hinzugefügt(ein = 0) (aus = 0)(0 % gespeichert)
de/scrum_master/ wird hinzugefügt(ein = 0) (aus = 0)(0 % gespeichert)
de/scrum_master/app/ wird hinzugefügt(ein = 0) (aus = 0)(0 % gespeichert)
de/scrum_master/app/AnnotatedGetterProcessor.class wird hinzugefügt(ein = 8065) (aus = 3495)(56 % verkleinert)
de/scrum_master/app/AnnotateGetter.class wird hinzugefügt(ein = 508) (aus = 287)(43 % verkleinert)
de/scrum_master/app/CollationType.class wird hinzugefügt(ein = 520) (aus = 316)(39 % verkleinert)
de/scrum_master/app/SortOrder.class wird hinzugefügt(ein = 476) (aus = 296)(37 % verkleinert)
de/scrum_master/app/Unique.class wird hinzugefügt(ein = 398) (aus = 248)(37 % verkleinert)

Aspect generation + project build (done with just one AspectJ compiler call because of its built-in annotation processing support):

Generating aspects and building project
Generated aspect de.scrum_master.app.AnnotateGetterAspect_Person_Unique_id
Generated aspect de.scrum_master.app.AnnotateGetterAspect_Person_SortOrder_fieldWithoutGetter
Generated aspect de.scrum_master.app.AnnotateGetterAspect_Person_SortOrder_firstName
Generated aspect de.scrum_master.app.AnnotateGetterAspect_Person_SortOrder_lastName
Generated aspect de.scrum_master.app.AnnotateGetterAspect_Person_CollationType_fieldWithoutGetter
Generated aspect de.scrum_master.app.AnnotateGetterAspect_Person_CollationType_firstName
Generated aspect de.scrum_master.app.AnnotateGetterAspect_Person_CollationType_lastName
'public int de.scrum_master.app.Person.getId()' (Person.java:31) is annotated with @de.scrum_master.app.Unique method annotation from 'de.scrum_master.app.AnnotateGetterAspect_Person_Unique_id' (AnnotateGetterAspect_Person_Unique_id.java:4)

'public java.lang.String de.scrum_master.app.Person.getFirstName()' (Person.java:32) is annotated with @de.scrum_master.app.SortOrder method annotation from 'de.scrum_master.app.AnnotateGetterAspect_Person_SortOrder_firstName' (AnnotateGetterAspect_Person_SortOrder_firstName.java:4)

'public java.lang.String de.scrum_master.app.Person.getFirstName()' (Person.java:32) is annotated with @de.scrum_master.app.CollationType method annotation from 'de.scrum_master.app.AnnotateGetterAspect_Person_CollationType_firstName' (AnnotateGetterAspect_Person_CollationType_firstName.java:4)

'public java.lang.String de.scrum_master.app.Person.getLastName()' (Person.java:33) is annotated with @de.scrum_master.app.CollationType method annotation from 'de.scrum_master.app.AnnotateGetterAspect_Person_CollationType_lastName' (AnnotateGetterAspect_Person_CollationType_lastName.java:4)

'public java.lang.String de.scrum_master.app.Person.getLastName()' (Person.java:33) is annotated with @de.scrum_master.app.SortOrder method annotation from 'de.scrum_master.app.AnnotateGetterAspect_Person_SortOrder_lastName' (AnnotateGetterAspect_Person_SortOrder_lastName.java:4)

Last, but not least, we run the driver application again. This time we should see the annotations copied from the annotated fields to the corresponding getter methods (if such methods exist):

Running de.scrum_master.app.Person
Field annotations:
  id
    @de.scrum_master.app.Unique()
  firstName
    @de.scrum_master.app.SortOrder(value=descending)
    @de.scrum_master.app.CollationType(value=alphabetical, language=EN)
  lastName
    @de.scrum_master.app.SortOrder(value=random)
    @de.scrum_master.app.CollationType(value=alphanumeric, language=DE)
  fieldWithoutGetter
    @de.scrum_master.app.SortOrder(value=ascending)
    @de.scrum_master.app.CollationType(value=numeric, language=EN)

Method annotations:
  main
  getId
    @de.scrum_master.app.Unique()
  doSomethingElse
  getLastName
    @de.scrum_master.app.CollationType(value=alphanumeric, language=DE)
    @de.scrum_master.app.SortOrder(value=random)
  getFirstName
    @de.scrum_master.app.SortOrder(value=descending)
    @de.scrum_master.app.CollationType(value=alphabetical, language=EN)
  doSomething

Voilà! Enjoy and feel free to ask questions. :-)

Update (2015-05-03): Attention, in my annotation processor code I initially forgot to also copy the annotation parameter values, thus only the default values were created for each annotation. I have just fixed that, making the annotation processor code even more lengthy. Because I wanted to make it worthwhile refactoring the code and learn something from it, even though you have already accepted the original answer and solved your problem in another way anyway, I played around with Java 8 stuff like lambdas, streams, filters, maps. This is not particularly readable if the concept is new to you, especially for nested forEach loops, but I wanted to try and see how far I can get with it. ;-)

kriegaex
  • 63,017
  • 15
  • 111
  • 202
  • I solved my work issue without moving annotations, and I've been swamped with work since then, so I haven't had time to look at this. I'll read it over the weekend. Thanks for the detailed answer. – XDR May 01 '15 at 20:08
  • Please note my updated code which now copies the complete annotation including parameters. – kriegaex May 03 '15 at 11:28
1

You could try my library Byte Buddy for this. You can run it within a build process at application startup or even from a Java agent. Creating an annotation can be done as follows using the recent release:

DynamicType.Unloaded<?> type = new ByteBuddy()
  .makeAnnotation()
  .name("AnnotateGetter")
  .annotateType(new Target() {
    public ElementType value() { return ElementType.FIELD; }
    public Class<? extends Annotation> annotationType() { return Target.class; }
  }).defineMethod("value", 
                  SomeAnnotation.class, 
                  Collections.emptyList(),
                  Visibility.PUBLIC)
  .withoutCode()
  .make();

You can then create or manipulate existing classes by adding instances of this generated annotation. The domain-specific language remains similar. Consult the tutorial for a detailed introduction to the library.

Rafael Winterhalter
  • 42,759
  • 13
  • 108
  • 192
  • Is there any way to remove an annotation from a field using Byte Buddy? I want to move annotations from a field to its corresponding getter, which requires 1) finding the field's annotations, 2) adding copies of them to the corresponding getter, 3) removing the original annotations from the field. To give context, I want to annotate Groovy properties with Jackson annotations, but I need to apply them to the getters instead of the fields due to GORM lazy loading. Groovy only applies property annotations to fields, so I'm writing a processor to move the annotations from fields to getters. – XDR Apr 19 '15 at 17:44
  • I will first write the processor that moves annotations from a field to a method (any method, but with getter, setter, etc. easily selectable as destinations), then I will write some annotations that specify which annotations should be moved, and some other associated settings (like how to handle missing destination methods:ignore, generate, or throw exception; etc.). – XDR Apr 19 '15 at 17:50
  • Byte Buddy is not meant for removing things when redefining a class. This way, Byte Buddy assures binary compatibility. What I would recommend you: Add any custom annotations to the fields (which you can locate easily) and then, based on observing these annotations, add the actual annotations to the corresponding getters. If this is not good enough, Byte Buddy exposes the underlying ASM API which you could use for removal. – Rafael Winterhalter Apr 20 '15 at 10:01