Using Java annotations as pure data values

Post date: Oct 14, 2016 10:39:52 PM

Annotations were introduced to the Java programming language in the 1.5 version, circa 2004. Since then, Java annotations have been adopted with gusto by framework developers, to the point where today, a Java application class might look like little more than scaffold for the annotations themselves, leaving all the actual work to be done by the framework itself.

It was not always that way. Initially they were used just to provide additional information to the programmer (@Override, @Deprecated) or the compiler (@SuppressWarnings), but new uses for them have been devised and now include ORMs, serialization, execution control (JUnit), bytecode instrumentation (metaprogramming via annotation processors), Dependency Injection, stricter type-checking and, finally, configuration. 

Arguably, configuration is the most contested use for annotations because when you give configuration values in the source code itself, that's no longer configuration unless you are fine with re-compiling your code every time you want to change your "configuration" (say, the @Path a Controller should be configured to handle).

However, as data representation (which is what configuration needs), annotations are the best abstraction Java offers without doubt.

They are almost perfect as data values because:

* annotation instances are immutable.

* support for default values.

* no null anywhere, even within String arrays.

* no inheritance, only composition.

* no logic.

* only constant types are allowed (primitive values, String, Class, enums, other annotations and arrays of the previous types).

These limitations are actually their power (similar to data classes in Kotlin). Because of them, annotations are thread-safe, referentially-transparent, and trivial to (de-)serialize.

They also implement the equals(), hashCode() and toString() methods correctly and for free! 

Here is an example of what a useful configuration annotation might look like:

 @Retention( RetentionPolicy.RUNTIME )

 @interface Server {

   /**

    * @return the name of this server.

    */

   String name() default "-";

   /**

    * @return the port the server should listen to.

    */

   int port() default 80;

   /**

    * @return the location of the Server log file.

    */

   String logFile() default "/var/log/server.log";

 }

A common use of this annotation would be to annotate an implementation class, and then let some framework magically generate code to make your Java class acquire its server capability:

 @Server(name = "My-Server")

 class MyServer {

  

   // other annotations go here

   String get() {

       return "Hello World";

   }

  

 }

Fine.. but now, your configuration is part of the source code, and as such, cannot be changed without re-compiling the code (which may actually be what you want!).

Also, it starts to make your application lean a little too far towards magic. How does the server actually get started? Does it take any other configuration? Can I really implement MyServer without knowing in detail what the @Server annotation-backing framework expects? Or, in other words, this class may look like a POJO, but it ended up with being anything but, with a hard-dependency on the annotation processor to even do anything useful.

So, here's the thing. The annotation does not need to be just something you configure right in the code! It could be the input to the class it is supposed to configure, used plain and simple as configuration data, no more, no less.

Here's what it would look like:

 class MyServer {

   private final Server serverConfig;

   public MyServer( Server serverConfig ) {

       this.serverConfig = serverConfig;

       // use the config to create an actual server instance!

       Jetty jetty = new Jetty(serverConfig.port());

   }

 }

With this, our class is again a POJO (though a nice configurable one). All we need to run this, now, is a way to create the Server annotation from actual configuration files, and a main method (or DI framework) to glue everything together.

Unfortunately, to create annotation instances programmatically in Java is not so easy.

For this reason, I wrote a tiny library to do just that, called Javanna.

To create an annotation instance at runtime with Javanna, you give it the Class of the annotation and a Map with the values for the annotation members:

 Server serverConfig = Javanna.createAnnotation( Server.class, new HashMap<String, Object>() {{

   put( "name", "My Server" );

   put( "port", 8080 );

 }} );

      

Notice that you don't need to give values for members that have a default value. If any mandatory value is missing or has the wrong type in the Map, an IllegalArgumentException is thrown with a very clear error message as to what went wrong. This means that this annotation instance is completely type-safe at runtime.

It works even with inner annotations. The following example annotations:

 @interface Hello {

   String hi();

   String bye();

 }

 @interface Language {

   String name();

   Hello hello();

 }

Can be created like this:

 Language language = Javanna.createAnnotation( Language.class, new HashMap<String, Object>() {{

   put( "name", "English" );

   put( "hello", new HashMap<String, Object>() {{

       put( "hi", "Hello!" );

       put( "bye", "See you later!" );

   }} );

 }} );

Using Javanna like this, you'd need to implement your own converter from your serialization format to/from a Java Map, then to/from the actual annotation type.

But this is not hard to do as most serialization formats can handle the constant types allowed by an annotation within a Map without trouble or any custom adapters.

To show this, I wrote a library to do it for JSON. The obviously named Javanna-Gson library uses Javanna and Gson to make it extremely simple to map between JSON and annotations.

For example, to turn the language annotation we've just created above into JSON, you do this:

 String json = javannaGson.toJson( language );

      

To write it to a file is just as easy:

 FileWriter writer = new FileWriter( new File( "language.json" ) );

 javannaGson.toJson( language, writer );

Which generates the following JSON (pretty-printed here for readability):

 {

   "name": "English",

   "hello": {

     "hi": "Hello!",

     "bye": "See you later!"

   }

 }

Similarly, to turn a JSON file into an annotation instance, you do this:

 FileReader reader = new FileReader( new File( "language.json" ) );

 Language languageConfig = javannaGson.parse( reader, Language.class );

Javanna works with arbitrary annotation types, so even if you don't know the annotation class at compile-time (because you want to define annotations in their own modules, and just use Javanna to create the annotation and pass it on back to them) as long as you have the Class<? extends Annotation> handle, Javanna will do the job.

I must say Javanna was inspired by the OSGi Declarative Services 1.3 new configuration mechanism, which uses annotations similarly to as described in this blog post to configure services in a rather smart way.

I hope this technique becomes more widespread in the Java community in the coming years, as it is a small, but nice improvement over current approaches, in my view.