Metaprogramming in Java: The Power of Reflection and Annotations


Hi everyone! Today, let’s talk about a powerful concept known as metaprogramming in the context of Java. Then, let’s dive straight into an example of how we can use this technique to build something simple yet powerful.
Prerequisites
To follow along with this guide, you more or less just need intermediate Java knowledge. While this is more of an advanced concept, I tried to go over this thoroughly so intermediates can understand. Also, I believe that this shouldn’t be too hard to understand even without a strong Java background.
What is Metaprogramming In Java?
Wikipedia defines metaprogramming as “a computer programming technique in which computer programs have the ability to treat other programs as their data.” Yes, you read that correctly, you can write code that reads and processes other code! Java’s JDK implements this idea in the form of the Reflection API. This is a set of Java classes and functionalities used to read and process classes and potentially manipulate their members. This is how Java frameworks like Spring Framework do magical things behind the scenes that don’t otherwise look possible without somehow breaking the language. I was able to use it myself to build a couple of powerful Java Frameworks and I hope with what you learn today, you can be inspired and educated to do the same!
Diving Straight Into Metaprogramming
Now, let’s dive straight into a practical example. For our mini-project, let’s create a simple JSONSerializerService
class for serializing Java objects into JSON. First, let’s discuss how our API will work for marking classes as serializable and controlling the JSON output.
Our Annotations
To mark classes as serializable and their fields with data on how to serialize them, we can create custom Java annotations. An annotation acts as a marker over classes, fields, parameters, or other Java constructs to give extra metadata about them. Typically, annotations are used for documentation purposes or, in our case, in conjunction with reflection. Probably the most well-known example of an annotation is the @Override
annotation you’ll see on top of overridden methods to document that they are an overridden method.
Now that you know about annotations, let’s define one named @JSONSerializable
to mark a class as serializable:
package io.john.amiscaray.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface JSONSerializable {
}
In the code above, we defined our annotation. We placed some other annotations above ours to define information to the Java compiler and about how ours will work. Using the @Target
annotation, we defined that our annotation can be applied to classes/types (ElementType.TYPE
). Then, using the @Retention
annotation, we specify that this annotation should be accessible during runtime (i.e., we will be able to know if a class has this annotation at runtime using reflection). With that, let’s define other annotations to give information on a class’ fields for our JSON serializer:
package io.john.amiscaray.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.FIELD) // This annotation can be applied to fields
@Retention(RetentionPolicy.RUNTIME)
public @interface JSONIgnore {
}
package io.john.amiscaray.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface JSONRename {
String value() default ""; // An argument we can pass when we annotate a field.
}
Using the @JSONIgnore
annotation, we will mark fields that the JSON serializer should ignore when serializing (for example, if we have some data we don’t want to make publically accessible in our JSON). Additionally, with our @JSONRename
annotation, we will mark fields that we want to give a different name in our JSON body. This accepts a String
argument for the name of the field that will appear in the JSON.
Implementing Our JSONSerializerService
Now, let’s create our JSONSerializerService
class. We’ll be going through this process step-by-step to make sure everyone can understand this. Let’s first make this new JSONSerializerService
class a singleton class. This is a design pattern, typically for global utility classes, to enforce a single global instance (you can choose to ignore this part if it confuses you):
package io.john.amiscaray.service;
public class JSONSerializerService {
private static JSONSerializerService instance;
private JSONSerializerService() { }
public static JSONSerializerService getInstance() {
if (instance == null) {
instance = new JSONSerializerService();
}
return instance;
}
}
Now let’s begin implementing a serialize
method. This method will accept a single object we wish to serialize and return a String
of the resulting JSON. First, we need to make sure that the object is a member of a class marked with our@JSONSerializable
annotation:
public String serialize(Object object) {
if (!object.getClass().isAnnotationPresent(JSONSerializable.class)) {
throw new RuntimeException("The passed object is not serializable");
}
// The rest of the implementation goes here...
}
This is our first taste of the Reflection API. Using the Object#getClass
method, we can get the runtime class of the passed object. Then, we can simply call the Class#isAnnotationPresent
method to check if the class contains the @JSONSerializable
annotation. From there, let’s define a StringBuilder
which we will use to produce our output:
public String serialize(Object object) {
if (!object.getClass().isAnnotationPresent(JSONSerializable.class)) {
throw new RuntimeException("The passed object is not serializable");
}
var result = new StringBuilder("{\n"); // Initialize a StringBuilder with "{\n" at the start of the string
// The rest of the implementation goes here...
}
Since we will be doing a lot of String
appending operations to produce our JSON, it’s best we use a StringBuilder
to make sure we do so optimally (since String
appending in Java can be surprisingly costly). With that out of the way, we can get to the juicy part of reading the fields of the class to add to our JSON output. Using Java’s Reflection API, we can access the fields of the class like so:
public String serialize(Object object) {
if (!object.getClass().isAnnotationPresent(JSONSerializable.class)) {
throw new RuntimeException("The passed object is not serializable");
}
var result = new StringBuilder("{\n"); // Initialize a StringBuilder with "{\n" at the start of the string
var clazz = object.getClass(); // Naming this clazz to avoid keyword conflicts
for (var field : clazz.getDeclaredFields()) {
// Logic goes here
}
// The rest of the implementation goes here...
}
From there, let’s just see the rest of the implementation and go over some of the parts that are important to you:
public String serialize(Object object) {
if (!object.getClass().isAnnotationPresent(JSONSerializable.class)) {
throw new RuntimeException("The passed object is not serializable");
}
var result = new StringBuilder("{\n"); // Initialize a StringBuilder with "{\n" at the start of the string
var clazz = object.getClass();
for (var field : clazz.getDeclaredFields()) {
if (field.isAnnotationPresent(JSONIgnore.class)) {
continue;
}
field.setAccessible(true);
var fieldName = field.getName();
if (field.isAnnotationPresent(JSONRename.class)) {
var annotation = field.getAnnotation(JSONRename.class);
fieldName = annotation.value();
}
try {
result
.append("\t")
.append("\"").append(fieldName).append("\"")
.append(":")
.append("\"").append(field.get(object)).append("\"")
.append(",\n");
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
}
// Remove the trailing ,
result = new StringBuilder(result.substring(0, result.length() - 2));
return result.append("\n}").toString();
}
So first, we check if the field has the @JSONIgnore
annotation and skip the field if it is present. This way, we can exclude this field from our JSON output. Then, we need this line:field.setAccessible(true);
for later so we can pry open the field values of the object passed to this method (even private fields!). From there, we can extract the name of the declared field using the Field#getName
method. If the field has the @JSONRename
annotation, we instead use the name passed to the annotation (fetched by calling the JSONRename#value
method) in the JSON output. After that, we append the data to our JSON string. You’ll notice there we have a field.get(object)
statement we are setting as the value of the field in the JSON. This fetches the corresponding field value from our object. So for example, if during the current iteration of the loop field
corresponds to a String
field named name, then that statement will fetch the value of that name field in the object.
Testing Our Project
Now that we implemented our JSON serializer, let’s test it out. Here’s a sample Student
class we can serialize with our JSONSerializerService
:
package io.john.amiscaray.domain;
import io.john.amiscaray.annotation.JSONIgnore;
import io.john.amiscaray.annotation.JSONRename;
import io.john.amiscaray.annotation.JSONSerializable;
@JSONSerializable
public class Student {
private String name;
private String major;
@JSONRename("Term GPA") // Rename the field to "Term GPA" in the output
private Float gpa;
@JSONIgnore // Exclude this from the JSON output
private Integer sinNumber;
public Student(String name, String major, Float gpa, Integer sinNumber) {
this.name = name;
this.major = major;
this.gpa = gpa;
this.sinNumber = sinNumber;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getMajor() {
return major;
}
public void setMajor(String major) {
this.major = major;
}
public Float getGpa() {
return gpa;
}
public void setGpa(Float gpa) {
this.gpa = gpa;
}
public Integer getSinNumber() {
return sinNumber;
}
public void setSinNumber(Integer sinNumber) {
this.sinNumber = sinNumber;
}
}
With that, we can print out a sample Student:
package io.john.amiscaray;
import io.john.amiscaray.domain.Student;
import io.john.amiscaray.service.JSONSerializerService;
public class Main {
public static void main(String[] args) {
var serializer = JSONSerializerService.getInstance();
System.out.println(serializer.serialize(new Student("John", "Computer Science", 4.0f, 12345678)));
}
}
The output to the console should look like so:
{
"name":"John",
"major":"Computer Science",
"Term GPA":"4.0"
}
With that, we finally have our simple JSON serializer built with reflection and custom annotations!
One Caveat With Reflection
While the application works pretty well for our purposes, there’s one thing you might need to look out for in the future when it comes to reflection. If you are using Java’s JPMS (Java Package Management System) functionality to separate your project into different modules, you might have a bit of trouble doing stuff with reflection like this. This is because Java’s modules, by default, don’t allow other modules reflective access to private members. For example, if our JSONSerializerService
class was in a module named my_module
and we wanted to serialize a class in a other_module
module, then we would get an exception when we try to access private fields of the class we wanted to serialize (something our JSONSerializerService
is doing using reflection). To get around this, we would need to add a declaration to other.module
's module declaration file (module-info.java
):
module other_module {
opens pack.containing.serializable.clazz to my_module;
}
Here, we are saying that for our module other_module
, any class in a package named pack.containing.serializable.class
allows reflective access to private fields for classes in the my.module
module.
Conclusion
With that, I hope you got a good idea of what metaprogramming is in Java and how you could potentially use it to build cool things. Using the magic that is the Reflection API, you can expand the boundaries of what you could normally do with Java code and implement magic as you see from Java frameworks. Using your newfound powers, I encourage you to try it out yourself and build a cool and unique new project with it. Happy coding!
Subscribe to my newsletter
Read articles from John Amiscaray directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
