Implementing an Annotation Processor with the APTK Stack
This article demonstrates how to implement an annotation processor that is based on the APTK stack in a real life scenario.
We are going to create an annotation processor that is based on the APTK and CUTE framework that generates SPI configuration files. (There are already some existing implementations out there, like Googles auto-service — I recommend to compare both code bases to understand how APTK helps you to simplify your codebase and keep it maintainable)
The code of this tutorial can be found at GitHub.
First, we need to define our requirements for the annotation processor:
- the processor must provide a Service annotation with one Class based attribute value that defines the SPI interface to generate the spi configuration file for. The passed attribute value must represent an interface.
- the Service annotation must only be placed on a class that implements the interface passed in the Service annotations value attribute.
- the annotation processor must generate SPI configuration files in /META-INF/services/ which must be named with the fully qualified name of the interface defined in Service annotations value attribute. The file must contain all fully qualified names of all classes annotated with the Service annotation / SPI interface combination, separated by a newline.
Setting Up The Project
At first we will use the APTK Maven archetype to create a basic project structure:
mvn archetype:generate \
-DarchetypeGroupId=io.toolisticon.maven.archetypes \
-DarchetypeArtifactId=annotationprocessor-archetype \
-DarchetypeVersion=0.11.0 \
-DgroupId=io.toolisticon.myspiservice \
-DartifactId=myspiservice \
-Dversion=0.0.1-SNAPSHOT \
-Dpackage=io.toolisticon.myspiservice \
-DannotationName=MySpiService
The maven archetype will create a multi-module maven project for us that contains an api submodule for the service annotation and a processor module for the processor. We will name our service annotation MySpiService, because Service is already quite a commonly used name.
It will also create some example testcases based on the CUTE framework.
The CUTE framework provides an immutable fluent api that helps you to configure either black box or unit tests of annotation processor related code.
One hint: Some IDEs don’t handle annotation processors very well. In this case it’s often a workaround to build the project from command line inbetween. IDEs usually will automatically pick up classes from the target folder afterwards.
Let’s Get Started
Usually the implementation of an annotation processor can be broke down into the following steps:
- Declaring the Annotation(s)
- Validating if annotations are used correctly
- Processing of annotations / gathering of information needed for source or resource file generation
- Generate source or resource files — this can be done in either the current compiler round or the final round marked with processing over flag
Declaring The Annotation
The maven archetype has already created the MySpiService annotation for us. Now we have to change the value attribute type of the generated MySpiService annotation to type “Class<?>”.
@Retention(RetentionPolicy.RUNTIME)
@Target(value = {ElementType.TYPE})
@Documented
public @interface MySpiService {
/**
* The spi to be configured.
* @return the value
*/
Class<?> value();
}
We also have to make sure that the annotation can only be placed on types by setting the Target annotations value to ElementType.TYPE. Unfortunately it’s not possible to limit usage on classes via the Target annotation directly, because the targets ElementType.TYPE represents classes, interfaces and enums. That’s why our processor has to validate if the annotation is placed on a class and trigger a compiler error if not.
Implementing The Processor
The maven archetype has created the annotation processor class MySpiServiceProcessor for us. It already contains the main processor loop to process all annotated elements in the compilation round and handles the final compilation round flagged via RoundEnvironments processingOver flag.
Validation
We will start with the validation if the annotation is used correctly and if the annotated Element fulfills all of our constraints. This is a quite simple task to do when we are using the APTK framework.
First we want to validate if the annotation attribute value is an interface, otherwise an error compiler message must be written.
@DeclareCompilerMessage(code = "ERROR_002", enumValueName = "ERROR_ATTRIBUTE_VALUE_MUST_BE_INTERFACE", message = "Value must be an interface")
boolean validateUsage(TypeElementWrapper wrappedTypeElement, MySpiServiceWrapper annotation) {
// test if annotation attribute value is interface
if (!annotation.valueAsTypeMirrorWrapper().isInterface()) {
// write error compiler message pin it to annotations value attribute
annotation.valueAsAttributeWrapper().compilerMessage().asError()
.write(MySpiServiceProcessorCompilerMessages.ERROR_ATTRIBUTE_VALUE_MUST_BE_INTERFACE);
return false;
}
// more validations ...
}
As you can see the value attribute can be directly accessed via the generated annotation wrapper. It can be returned as a TypeMirrorWrapper, which then can be used to check if the value attribute represents an interface.
The compiler message will be written by using the AttributeWrappers fluent compilerMessage api. A corresponding error message can be defined by using the DeclareCompilerMessage annotation.
We must do some more validations. The annotated type must be a public class that has a public noarg constructor and that must implement the interface defined in the MySpiService annotations value attribute:
// Check if element is class, has public modifier and a public noarg constructor
// and if it is assignable to the spi
return wrappedTypeElement.validateWithFluentElementValidator()
.is(AptkCoreMatchers.IS_CLASS)
.applyValidator(AptkCoreMatchers.BY_MODIFIER).hasAllOf(Modifier.PUBLIC)
.applyValidator(AptkCoreMatchers.HAS_PUBLIC_NOARG_CONSTRUCTOR)
.applyValidator(AptkCoreMatchers.IS_ASSIGNABLE_TO_FQN).hasOneOf(annotation.valueAsFqn())
.validateAndIssueMessages();
We are using APTKs fluent element validator api for the validation which can be accessed by using the TypeElementWrapper instance. An error compiler message will be written in case of a failing validation describing the broken constraints.
Processing
Now we have to collect data about the spi service configuration file(s) to be generated.
This will happen in two distinct phases. In phase one we will collect the spi service interfaces fully qualified names and a list of fully qualified names of all of its service implementations and we will store them in a Map<String,Set<String>>.
The second phase starts once we get the processingOver Flag. In this case the processing of our annotation has been finished and the processor can generate the SPI configuration files. Our main processing loop then looks as following
Map<String, Set<String>> spiServices = new HashMap<>();
@Override
public boolean processAnnotations(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
if (!roundEnv.processingOver()) {
// process Services annotation
for (Element element : roundEnv.getElementsAnnotatedWith(MySpiService.class)) {
TypeElementWrapper wrappedTypeElement = TypeElementWrapper.wrap((TypeElement) element);
MySpiServiceWrapper annotation = MySpiServiceWrapper.wrap(element);
if (validateUsage(wrappedTypeElement, annotation)) {
processAnnotation(wrappedTypeElement, annotation);
}
}
} else {
for (Map.Entry<String, Set<String>> serviceEntry : spiServices.entrySet()) {
createServiceConfig(serviceEntry.getKey(), serviceEntry.getValue());
}
}
return false;
}
void processAnnotation(TypeElementWrapper wrappedTypeElement, MySpiServiceWrapper annotation) {
spiServices.computeIfAbsent(annotation.valueAsFqn(), e -> new HashSet<>()).add(wrappedTypeElement.getQualifiedName());
}
/**
* Generates a spi config file.
*
* @param service The TypeElement representing the annotated class
* @param serviceImplementations The MySpiService annotation
*/
@DeclareCompilerMessage(code = "ERROR_001", enumValueName = "ERROR_COULD_NOT_CREATE_CONFIG", message = "Could not create config ${0}")
private void createServiceConfig(String service, Set<String> serviceImplementations) {
Map<String, Object> model = new HashMap<String, Object>();
model.put("serviceImplementations", serviceImplementations);
// create the class
String fileName = "META-INF/services/" + service;
try {
SimpleResourceWriter resourceWriter = FilerUtils.createResource(fileName);
resourceWriter.writeTemplate("/MySpiService.tpl", model);
resourceWriter.close();
} catch (IOException e) {
MessagerUtils.error(null, MySpiServiceProcessorCompilerMessages.ERROR_COULD_NOT_CREATE_CONFIG, fileName);
}
}
The FilerUtils can be used to get a SimpleResourceWriter instance. Then content can be written by calling its writeTemplate method and passing a template file name together with a Map that represents the model to be used while processing the template.
Additionally, we have to add the template file MySpiService.tpl in the processor modules resource folder:
!{for service : serviceImplementations}${service}
!{/for}
As you can see the template is quite simple. We have just one for loop to ensure that we write out all fully qualified names of service implementations.
That’s it. We have finished the implementation of a readable and easy maintainable annotation processor. Nevertheless we still need to test it.
Testing the processor
We will use the CUTE framework to implement our tests. It provides a immutable fluent api that helps us to implement our testcases.
CUTE supports both blackbox and unit tests for testing annotation processors.
We will add some black box tests to check if our implementation is working properly. This will be done by compiling some test source files and by checking the outcome of the compilation.
The CUTE framework allows you to do various kinds of checks, for example testing if specific compiler messages have been written or if generated files exist.
In our first test we will test the happy path and check if spi config file will be written as expected. We create the source file “/src/test/resources/testcases/TestcaseValidUsage.java” for our test:
package io.toolisticon.myspiservice.processor.tests;
import io.toolisticon.myspiservice.api.MySpiService;
import io.toolisticon.myspiservice.processor.TestService;
@MySpiService(TestService.class)
public class TestcaseValidUsage implements TestService{
public String doSomething(){
return "Done!!!";
}
}
Then we need to implement the test itself. We need to check if compilation is successful and if it produces the spi configuration file for the TestService interface which contains our service class fully qualified name.
public class MySpiServiceProcessorTest {
CuteApi.BlackBoxTestSourceFilesInterface compileTestBuilder;
@Before
public void init() {
// adding the following will add the message codes to the compiler messages
MessagerUtils.setPrintMessageCodes(true);
// we willl provide a base setup used by different tests
compileTestBuilder = Cute
.blackBoxTest()
.given().processors(MySpiServiceProcessor.class);
}
@Test
public void test_valid_usage() {
compileTestBuilder
.andSourceFiles("testcases/TestcaseValidUsage.java")
.whenCompiled().thenExpectThat().compilationSucceeds()
// we will test if file was generated and contains our service implementation
.andThat().generatedResourceFile("", "META-INF/services/"+ TestService.class.getCanonicalName())
.matches(CoreGeneratedFileObjectMatchers.createContainsSubstringsMatcher("io.toolisticon.myspiservice.processor.tests.TestcaseValidUsage"))
.executeTest();
}
//...
}
Furthermore we have to add additional tests for the different validations and constraints. We will demonstrate those tests by explaining one example.
We need to check if a compiler error is triggered when the MySpiService annotation is used on an interface.
This can be done by providing the following test source file:
// The test source file
package io.toolisticon.myspiservice.processor.tests;
import io.toolisticon.myspiservice.api.MySpiService;
import io.toolisticon.myspiservice.processor.TestService;
@MySpiService(TestService.class)
public interface TestcaseInvalidUsageOnInterface extends TestService{
}
Afterwards we are able to test its compilation outcome with CUTE:
@Test
public void test_Test_invalid_usage_on_interface() {
compileTestBuilder
.andSourceFiles("testcases/TestcaseInvalidUsageOnInterface.java")
.whenCompiled().thenExpectThat().compilationFails()
.andThat().compilerMessage().ofKindError().contains(CoreMatcherValidationMessages.IS_CLASS.getCode())
.executeTest();
}
We are able to check all of our error compiler messages by searching the expected unique error code, because we have enabled message codes in the tests init method via “MessagerUtils.setPrintMessageCodes(true)”.
It is quite difficult to test the generated (re)source files via compile time tests, so it is always a good idea to provide an integration test.
In our case we just will create a service interface and one annotated service implementation in the projects integrationTest submodule. Then we will do unit tests that check if implementation is found via the SPI lookup mechanism.
Conclusion
It’s relatively simple to build readable and maintainable annotation processors using the APTK stack. Simple processors can be easily implemented and tested within a few hours (I needed less than an hour for this example).
But APTK will also help you with more difficult tasks, like reading Class based attributes and handling inheritance related issues like resolving type parameter types or getting all methods that need to be implemented.
I will provide some more articles in the near future to show you the capabilities of APTK in more detail.