Annotation processing is a Java compilation option which has been around since Java 5. It enables the generation of additional files during compilation, such as classes or documentation. Gradle abstracts the complexity of passing the correct compilation options to Java with the annotationProcessor
dependency configuration, which we’ll explore in detail in this article with a full working example.
An example of a popular annotation processor we’ll look at today is mapstruct, which automatically generates mapper classes to map data between Java data objects.
Understanding annotation processing with javac
The javac
command runs the Java compiler, and it’s called during any Gradle Java compilation task. Specifically for annotation processing, javac
includes this option:
--processor-path path
or-processorpath path
Specifies where to find annotation processors. If this option isn’t used, then the class path is searched for processors.
An annotation processor is a class which extends AbstractProcessor
(in the package javax.annotation.processing
) providing the functionality needed to generate whatever it needs to generate, such as classes in the case of mapstruct.
Whatever annotation processing library we want to use can be passed with --processor-path
or -processorpath
and it will be called by the Java compiler to generate classes.
Annotation processing in Gradle
Fortunately the clever people at Gradle HQ have provided us with a very easy way to specify the above -processorpath
compiler option. We do this by marking a particular dependency as part of the annotationProcessor
dependency configuration:
dependencies {
annotationProcessor 'org.mapstruct:mapstruct-processor:1.3.1.Final'
...
}
What does this do? Well much like the other dependency configurations (implementation, compileOnly etc.), all we’re really doing is defining what will get passed to the Java compiler when the compileJava
Gradle task get executed. In the case of annotationProcessor
we’re defining what libraries get passed to the -processorpath
javac
option.
With a build.gradle as above, the -processorpath
calculated by Gradle can be demonstrated if we add this small snippet which prints out the value:
tasks.withType(JavaCompile) {
doFirst {
println "AnnotationProcessorPath for $name is ${options.getAnnotationProcessorPath().getFiles()}"
}
}
If we run ./gradlew compileJava
we’ll get output like this:
> Task :compileJava
AnnotationProcessorPath for compileJava is [C:\Users\Tom\.gradle\caches\modules-2\files-2.1\org.mapstruct\mapstruct-processor\1.3.1.Final\a5e2a807eee3372cbfba0685b70505d7c0b7ae9a\mapstruct-
processor-1.3.1.Final.jar]
This clearly shows that the dependency which was specified with annotationProcessor
has ended up on the -processorpath
of the Java compiler. ✅
A full end-to-end working example
To demonstrate annotation processing with Gradle in action, we’re going to use mapstruct, which again is a library which helps with mapping data objects from one representation to another. In our example, we’ll put together a project to map from a CarEntity
class to a CarDto
class.
We’ll use this GitHub repository which has everything already set up for you, including:
CarEntity
andCarDto
classes- a
CarMapper
interface - a
CarMapperTest
to ensure the mapping works - a build.gradle to build the project and run the test
Feel free to clone the project and try it out as we run through it piece-by-piece. 👌
The way mapstruct works is that you provide it with an interface of a mapper, then during the annotation processing stage it generates an implementation of that interface which you can use at runtime to map between classes.
In our case the CarMapper
interface looks like this:
package com.tomgregory;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper
public interface CarMapper {
@Mapping(source = "numberOfSeats", target = "seatCount")
CarDto carToCarDto(CarEntity car);
}
The details of this interface aren’t too important here as we’re just demonstrating the annotation processing, but any fields that match exactly by name will be mapped automatically by mapstruct. Any others can be specified using the @Mapping
annotation.
Configuring the mapstruct annotation processor
You can see in the build.gradle below that we have the mapstruct-processor library specified in the annotationProcessor
dependency configuration .
plugins {
id 'java'
}
group 'org.example'
version '1.0-SNAPSHOT'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.mapstruct:mapstruct:1.3.1.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.3.1.Final'
testImplementation group: 'junit', name: 'junit', version: '4.12'
}
- as well as mapstruct-processor we also include mapstruct as this is the library we use to actually call our generated mapper class (see line 16 of
CarMapperTest
below) - junit is needed for the test we’ll write in the next section
With this configuration when the Java compiler runs it should run the mapstruct-processor annotation processor, which in turn should generate a CarMapperImpl
class based on the CarMapper
interface. 🏎️
Demonstration using a unit test
To show that this example works with mapstruct, a unit test is included in the GitHub repository:
package com.tomgregory;
import org.junit.Test;
import org.mapstruct.factory.Mappers;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThat;
public class CarMapperTest {
@Test
public void mapsCar() {
CarEntity lamborghini = new CarEntity("Lamborghini", 2);
CarMapper mapper = Mappers.getMapper( CarMapper.class );
CarDto carDto = mapper.carToCarDto(lamborghini);
assertThat(carDto.getMake(), is("Lamborghini"));
assertThat(carDto.getSeatCount(), is(2));
}
}
- we create an instance of the
CarMapper
, which under the hood uses the newly generatedCarMapperImpl
class - we then use that to convert between a
CarEntity
andCarDto
class - we assert the fields have been mapped correctly
The test can be run by running ./gradlew test
. In build/reports/tests/index.html you’ll see this report showing that the test passed:
Viewing the generated class
We can see the generated CarMapperImpl
class in build/generated/sources/annotationProcessor then java/main/com/tomgregory:
The details of this class are not important, after all that’s why libraries such as mapstruct exist, to hide these complexities. What’s important is that the class has been generated during the compileJava
stage of our build.
We can validate this if we comment out line 14 of build.gradle:
dependencies {
implementation 'org.mapstruct:mapstruct:1.3.1.Final'
// annotationProcessor 'org.mapstruct:mapstruct-processor:1.3.1.Final'
testImplementation group: 'junit', name: 'junit', version: '4.12'
}
Now when we run ./gradlew clean compileJava
we’ll see an empty generated sources directory because the annotation processor has not been run by the Java compiler:
It’s worth noting that the location into which this class is generated at generated/sources/annotationProcessor is a standard set by the Gradle Java Plugin.
Resources
GitHub repository
Follow along with this article using the example provided in this repository
javac documentation
Read about the -processorpath
option in javac
in this Oracle documentation
mapstruct documentation
Learn more about the mapstruct library with the official documentation
Other Gradle dependency configurations
To learn more check out the article How to use Gradle api vs. implementation dependencies with the Java Library plugin
Missing Gradle knowledge makes you slow
A broken build is easy to fix with a quick Google search, but a misconfigured build costs hours of time each week.
Time to build. Time to test. Time to fix.
Knowing the "right" way to build a project isn't obvious, especially with pages of hard-to-follow documentation. But for your project to scale, you must master the build script and configure it effectively.
That's now a lot easier with Gradle Build Bible.