Table of Contents#
- Introduction to Java Annotation Processors
- Setting Up the Annotation Processor Project
- Understanding the Processing Environment
- Key Components: Elements, Types, and Mirrors
- How to Get the Class Being Processed
- Handling Different Scenarios
- Debugging and Testing the Annotation Processor
- Common Pitfalls and Best Practices
- Conclusion
- References
Introduction to Java Annotation Processors#
Java Annotation Processors are part of the javax.annotation.processing API (now part of jakarta.annotation.processing in Jakarta EE). They run as part of the Java compiler (javac) and analyze annotations to perform compile-time tasks. The core class is AbstractProcessor, which developers extend to create custom processors.
Key use cases include:
- Generating source code (e.g., adapters, serializers).
- Validating code (e.g., ensuring a class implements a required interface).
- Collecting metadata (e.g., mapping classes to database tables).
To interact with the class being processed, we need to understand how the compiler represents program elements (classes, methods, fields) and their types.
Setting Up the Annotation Processor Project#
We’ll use Maven for this tutorial, but the concepts apply to Gradle as well. We’ll also use auto-service (by Google) to simplify processor registration.
Project Structure#
src/
├── main/
│ ├── java/
│ │ └── com/
│ │ └── example/
│ │ ├── annotation/
│ │ │ └── ProcessMe.java // Custom annotation
│ │ └── processor/
│ │ └── ClassProcessor.java // Annotation processor
│ └── resources/
└── pom.xml
Maven Dependencies#
Add these dependencies to pom.xml:
<dependencies>
<!-- Auto-service for processor registration -->
<dependency>
<groupId>com.google.auto.service</groupId>
<artifactId>auto-service</artifactId>
<version>1.0.1</version>
<optional>true</optional>
</dependency>
<!-- Java Annotation Processing API -->
<dependency>
<groupId>jakarta.annotation.processing</groupId>
<artifactId>jakarta.annotation.processing-api</artifactId>
<version>1.1.0</version>
<scope>provided</scope>
</dependency>
</dependencies>Configure Maven Compiler Plugin#
Ensure the compiler recognizes the processor:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>17</source> <!-- Use your Java version -->
<target>17</target>
<annotationProcessorPaths>
<!-- Register the processor -->
<path>
<groupId>com.example</groupId>
<artifactId>annotation-processor-demo</artifactId>
<version>1.0-SNAPSHOT</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>Understanding the Processing Environment#
When the processor runs, it receives a ProcessingEnvironment object, which provides utilities to interact with the compiler:
| Utility | Purpose |
|---|---|
ElementUtils | Provides methods to work with program elements (classes, methods, etc.). |
TypeUtils | Provides type analysis (e.g., checking if two types are equal). |
Filer | Used to generate new source files or resources. |
Messager | Used to report errors, warnings, or notes to the compiler. |
We’ll use ElementUtils extensively to inspect the class being processed.
Key Components: Elements, Types, and Mirrors#
To work with classes at compile time, you need to understand three core abstractions:
1. Elements#
Represent program elements (classes, methods, fields, etc.). Common subinterfaces:
TypeElement: Represents a class or interface (e.g.,com.example.User).ExecutableElement: Represents a method or constructor.VariableElement: Represents a field, parameter, or local variable.
2. Types#
Represent the types of elements (e.g., String, List<Integer>). The TypeMirror interface is the base type for all type representations.
3. Mirrors#
"Mirrors" are compiler-internal representations of types and elements. For example, TypeElement has a TypeMirror that represents its type.
How to Get the Class Being Processed#
Let’s walk through a step-by-step example to extract information from a class annotated with a custom annotation.
Step 1: Define a Custom Annotation#
First, create an annotation to mark classes for processing. We’ll call it @ProcessMe:
// com/example/annotation/ProcessMe.java
package com.example.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE) // Apply to classes/interfaces
@Retention(RetentionPolicy.SOURCE) // Retained only in source code (compile-time)
public @interface ProcessMe {
}@Target(ElementType.TYPE)ensures the annotation is only applied to classes or interfaces.@Retention(RetentionPolicy.SOURCE)ensures the annotation is not present at runtime (we only need it during compilation).
Step 2: Implement the Annotation Processor#
Next, create the processor by extending AbstractProcessor and overriding the process method. We’ll use @AutoService to auto-register the processor (avoids manual META-INF/services setup).
// com/example/processor/ClassProcessor.java
package com.example.processor;
import com.example.annotation.ProcessMe;
import com.google.auto.service.AutoService;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.TypeElement;
import java.util.Set;
@AutoService(Processor.class) // Auto-register the processor
@SupportedAnnotationTypes("com.example.annotation.ProcessMe") // Annotations to process
@SupportedSourceVersion(SourceVersion.RELEASE_17) // Supported Java version
public class ClassProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
// Iterate over annotations we care about (here, only @ProcessMe)
for (TypeElement annotation : annotations) {
if (annotation.getQualifiedName().contentEquals(ProcessMe.class.getName())) {
// Get all elements annotated with @ProcessMe
Set<? extends Element> annotatedElements = roundEnv.getElementsAnnotatedWith(annotation);
for (Element element : annotatedElements) {
// Process each annotated element (class/interface)
processAnnotatedClass(element);
}
}
}
return true; // Claim the annotations (no other processor will process them)
}
private void processAnnotatedClass(Element element) {
// TODO: Extract class information here
}
}Step 3: Access the Annotated Class Element#
In processAnnotatedClass, we first verify the element is a class/interface (since @ProcessMe targets TYPE). We cast it to TypeElement to access class-specific details:
private void processAnnotatedClass(Element element) {
// Ensure the element is a class or interface
if (element.getKind() != ElementKind.CLASS && element.getKind() != ElementKind.INTERFACE) {
processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR,
"@ProcessMe can only be applied to classes or interfaces."
);
return;
}
// Cast to TypeElement to access class details
TypeElement classElement = (TypeElement) element;
processingEnv.getMessager().printMessage(
Diagnostic.Kind.NOTE,
"Processing class: " + classElement.getQualifiedName()
);
}Step 4: Extract Class Information#
From TypeElement, we can extract rich details about the class:
Example: Extract Basic Class Info#
private void processAnnotatedClass(Element element) {
if (!(element instanceof TypeElement classElement)) {
// Handle error (as above)
return;
}
// 1. Class name (qualified)
String className = classElement.getQualifiedName().toString();
processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Class name: " + className);
// 2. Modifiers (public, abstract, final, etc.)
Set<Modifier> modifiers = classElement.getModifiers();
processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Modifiers: " + modifiers);
// 3. Superclass
TypeMirror superclass = classElement.getSuperclass();
String superclassName = superclass.toString();
processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Superclass: " + superclassName);
// 4. Implemented interfaces
List<? extends TypeMirror> interfaces = classElement.getInterfaces();
processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Interfaces: " + interfaces);
}Example: Extract Fields and Methods#
To get fields and methods, iterate over classElement.getEnclosedElements() and filter by ElementKind:
// Extract fields
for (Element enclosedElement : classElement.getEnclosedElements()) {
if (enclosedElement.getKind() == ElementKind.FIELD) {
VariableElement field = (VariableElement) enclosedElement;
String fieldName = field.getSimpleName().toString();
String fieldType = field.asType().toString();
processingEnv.getMessager().printMessage(
Diagnostic.Kind.NOTE,
"Field: " + fieldType + " " + fieldName
);
}
}
// Extract methods
for (Element enclosedElement : classElement.getEnclosedElements()) {
if (enclosedElement.getKind() == ElementKind.METHOD) {
ExecutableElement method = (ExecutableElement) enclosedElement;
String methodName = method.getSimpleName().toString();
String returnType = method.getReturnType().toString();
processingEnv.getMessager().printMessage(
Diagnostic.Kind.NOTE,
"Method: " + returnType + " " + methodName + "()"
);
}
}Handling Different Scenarios#
Processing Annotated Classes vs. Other Elements#
If your annotation targets methods or fields (via @Target(ElementType.METHOD)), you’ll need to check the ElementKind and cast to ExecutableElement or VariableElement instead of TypeElement.
Dealing with Generic Types#
To handle generics (e.g., List<String>), use TypeUtils to inspect type arguments:
TypeMirror listType = ...; // e.g., from a field's type
if (processingEnv.getTypeUtils().isAssignable(listType, processingEnv.getElementUtils().getTypeElement("java.util.List").asType())) {
DeclaredType declaredType = (DeclaredType) listType;
List<? extends TypeMirror> typeArgs = declaredType.getTypeArguments();
// typeArgs will contain String's TypeMirror for List<String>
}Handling Dependencies Between Classes#
Annotation processing runs in rounds. If your processor depends on another class that hasn’t been processed yet, you may need to defer processing to a later round. Check RoundEnvironment.processingOver() to avoid infinite loops.
Debugging and Testing the Annotation Processor#
Debugging#
- Use
Messagerto print messages (e.g.,processingEnv.getMessager().printMessage(...)). - Run
javacwith flags:
javac -XprintProcessorInfo -XprintRoundsto see processor activity. - Attach a debugger to the compiler:
mvn compile -Dmaven.compiler.debug=true -Dmaven.compiler.debuglevel=source,lines,vars
Testing#
Use the Java Compiler API to run the processor programmatically in unit tests:
import javax.tools.JavaCompiler;
import javax.tools.ToolProvider;
import java.io.File;
import java.util.Arrays;
public class ProcessorTest {
public static void main(String[] args) {
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
int result = compiler.run(
null, null, null,
"-processor", "com.example.processor.ClassProcessor",
new File("src/test/java/com/example/User.java").getAbsolutePath()
);
System.out.println("Compilation result: " + result); // 0 = success
}
}Common Pitfalls and Best Practices#
Pitfalls#
- Forgetting to Register the Processor: Use
auto-serviceor manually createMETA-INF/services/javax.annotation.processing.Processor. - Casting Without Checking
ElementKind: Leads toClassCastException(e.g., casting a method toTypeElement). - Ignoring Processing Rounds: Failing to handle deferred processing can lead to incomplete data.
Best Practices#
- Limit Scope: Only process annotations you care about (use
@SupportedAnnotationTypes). - Generate Code Safely: Use
Filerto generate files (never write directly to disk). - Be Incremental: Support incremental compilation by checking
RoundEnvironmentfor changes. - Report Errors Clearly: Use
Messagerto guide users when annotations are misused.
Conclusion#
Java Annotation Processors are a powerful way to inspect and manipulate code at compile time. By leveraging TypeElement and ElementUtils, you can extract detailed information about classes, enabling tasks like code generation and validation. With the setup and techniques outlined here, you’ll be able to build robust, maintainable processors.