Micronaut 1.0 RC2 and the Power of Ahead-of-Time Compilation

Micronaut 1.0 RC2 and the Power of Ahead-of-Time Compilation

MICRONAUT 1.0 RC2 AND THE POWER OF AHEAD-OF-TIME COMPILATION

By Graeme Rocher, OCI Grails & Micronaut Product Lead and Principal Software Engineer

OCTOBER 8, 2018

Following last week's Micronaut 1.0.0 RC1 release, we are pleased to announce the availability of Micronaut 1.0.0 RC2, which includes improvements following feedback from the community (thanks to all those who reported issues!).

This second RC presents a further opportunity to test Micronaut and provide feedback as we build up to the GA release, which is scheduled for the 23rd of October – just in time for Oracle Code One 2018 in San Francisco where I will be presenting on Micronaut!

In addition, if you're in Europe, and San Francisco is little far, consider attending Voxxed Days Paris, where I will be presenting Micronaut on the 30th of October.

In my previous post, I elaborated on some of the design choices we made with Micronaut with regards to avoiding the use of reflection as much as possible and how that benefits both the developer and the performance of the application.

In this post, I will elaborate a bit more on the challenges facing traditional Java frameworks and how Micronaut solves those challenges.

JAVA GIVETH AND TAKETH AWAY

Java and the JVM provide a rich platform on which to build frameworks that enhance developer productivity. Features such as annotations, reflection, the ability to create runtime proxies, and so on are the staple of how most Java frameworks work.

Unfortunately, there are some challenges and limitations framework developers have to deal with that result in compromising either memory consumption or startup time including:

  • Type Erasure – The Java generics system was added later in Java's lifecycle; since backward compatibility was a requirement, the result is type erasure. The amount of runtime logic in existing Java frameworks and tools to deal with type erasure is mind blowing.
  • Missing Annotation Metadata – Java has annotations, but a number of patterns have emerged in Java frameworks for using annotation stereotypes (or meta-annotations) that are, by default, not supported by the Java API. Computing annotation metadata is therefore left to each framework to implement.
  • No Parameter Names – By default, Java and the JVM do not retain parameter name information. It is possible to work around this by adding -parameters flag to the Java compiler, but it is disabled by default.

Why are these issues a challenge for Java frameworks like Spring and Jakarta EE? Let's take a simple example where you define an interface:

  1. interface HelloOperations<T> {
  2. @Get("/hello/{name}")
  3. T hello(@NotBlank T name);
  4.  
  5. @Get("/hello-many/{names}")
  6. T helloMany(@NotEmpty List<T> names);
  7. }
  8.  
  9. @Controller("/")
  10. class HelloController implements HelloOperations<String> {
  11.  
  12. @Override
  13. String hello(String name) {
  14. // logic here
  15. }
  16.  
  17. @Override
  18. String helloMany(List<String> names) {
  19. // logic here
  20. }
  21. }

Lines 2 and 3 on the HelloOperations interface define a route using annotations, and in addition, define a @NotBlank constraint on the name parameter. (Note that I have used Micronaut annotations, but the same example could be written in Jakarta EE or Spring).

The HelloController implements the interface and provides the logic. Now, you would think this seemingly simple example would be easy for framework developers to handle, but the tasks involved include:

  • Computing the annotation metadata and stereotypes for each method and on the class of the controller
  • Traversing the class and interface hierarchy of the HelloController class to figure out the inherited annotations on the name parameter
  • Dealing with generics and type erasure requirements that the parameter introduces on the class for both the return type and the argument
  • Generating a runtime proxy to validate the @NotBlank constraint, which reflectively calls HelloController

All of this is just for a trivial example. As you add more methods, deeper inheritance hierarchies, more interfaces, and so on, the requirements become more and more complex, and all of these requirements have to be handled at runtime

In order to support all of these features that Java developers love without adversely impacting runtime performance, traditional Java frameworks cache heavily, which leads to increasing memory consumption, since the two problems are not reconcilable – you have to choose between slow runtime performance or poor memory consumption.

The Micronaut Way

So how is this situation handled in Micronaut?

Instead of performing all of this analysis on your classes at runtime, Micronaut computes everything at compilation time using ahead-of-time (AOT) compilation.

Generic Type Information

All generic type information for beans and method arguments is computed ahead of time. For example, to retrieve the type parameter for List in the helloMany method inside an AOP interceptor, you can simply do the following:

  1. public Object intercept(MethodInvocationContext context) {
  2. Map<String, MutableArgumentValue<?>> parameters = context.getParameters();
  3. MutableArgumentValue<?> namesArgument = parameters.get("names");
  4. Argument<?> typeArgument = namesArgument.getFirstTypeVariable().orElse(Argument.OBJECT_ARGUMENT);
  5. ....
  6. }

Notice that the parameter name data is present and has not be erased. Processing at the source code level allows Micronaut to retain parameter name data.

In addition, Micronaut will also compute type arguments for types and store them in the BeanDefinition, so if you need to compute the type parameters for a type, you don't need to jump through reflective hoops either:

  1. BeanDefinition<HelloController> helloDefinition =
  2. beanContext.getBeanDefinition(HelloController.class);
  3.  
  4. List<Argument<?>> typeArguments =
  5. helloDefinition.getTypeArguments(HelloOperations.class);
  6.  
  7. // do something with the type arguments

The above example retrieves the BeanDefinition for the HelloController bean and then retrieves the type arguments used for the HelloOperations interface, all without requiring expensive reflective processing.

Annotation Metadata

The regular Java API makes you jump through hoops to retrieve the annotation metadata on the previous example. You have to traverse through the class and interface hierarchy, reflectively loading each method and potentially dealing with visibility issues to retrieve all of the java.lang.reflect.Method instances that are included the hierarchy.

You then have to process each method to merge together all the potential annotations by looking at the getParameterAnnotations() method that returns a multi-dimension array with each parameter indexed by the order they appear in the method. 

All this complexity is not needed with Micronaut, because the annotation metadata has already been computed at compile time:

  1. Map<String, MutableArgumentValue<?>> parameters = context.getParameters();
  2. MutableArgumentValue<?> namesArgument = parameters.get("names");
  3. if (namesArgument.getAnnotationMetadata().hasStereotype(NotEmpty.class)) {
  4. if (CollectionUtils.isEmpty((Collection) namesArgument.getValue())) {
  5. throw new IllegalArgumentException("Named parameter cannot be blank");
  6. }
  7. }
  8.  

Reflection-Free Proxies

Finally, Micronaut will also at compile time produce a proxy that is a regular class and not one created via Java's native proxy mechanism, eliminating the need for reflection at the proxy level.

This has a number of benefits including:

  • Less work to do at runtime, improving startup and reducing memory consumption
  • Shorter, easier to understand stack traces – since the proxy invokes your code directly, huge framework level stack traces are less of an issue
  • Easier for the JIT to optimize – the Java JIT has an easier time of optimizing direct calls than reflective calls
  • No need to cache reflection data – reading reflection data is expensive, so most frameworks cache method references, increasing further memory requirements
  • Easier compatibility with GraalVM – although proxies are possible on GraalVM native image, these have to be configured ahead of time.

This may sound complex, but the simplicity it enables for developers is a huge win. For example, if you wish to implement your AOP advice, such as introduction advice, there are only a few steps required.

As an example, say you want to implement logic from an interface at compilation time. Testing frameworks, for example, often have tools for creating stubs or mocks that return alternative values from interfaces. Let's see how you could implement stubbing in Mironaut. Step 1 is to create an annotation, for example:

  1. @Introduction
  2. @Type(StubIntroduction.class)
  3. @Bean
  4. @Documented
  5. @Retention(RUNTIME)
  6. @Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE, ElementType.METHOD})
  7. public @interface Stub {
  8. String value() default "";
  9. }

On line 1 the advice is defined as introduction advice and given a type of StubIntroduction on line 2. The StubInroduction type should implement MethodInterceptor interface. The following is a trivial implementation:

  1. @Singleton
  2. public class StubIntroduction implements MethodInterceptor<Object,Object> {
  3.  
  4. @Override
  5. public Object intercept(MethodInvocationContext<Object, Object> context) {
  6. return context.getValue(
  7. Stub.class,
  8. context.getReturnType().getType()
  9. ).orElse(null);
  10. }
  11. }

On line 6, the implementation tries to convert the value given to the @Stub annotation and return it as the result of the method call, otherwise null is returned. Now you can simply use the @Stub annotation on any interface:

  1. @Stub
  2. public interface StubExample {
  3.  
  4. @Stub("10")
  5. int getNumber();
  6.  
  7. @Stub("Fred")
  8. String getName();
  9. }

The getNumber() method will return 10 and the getName() method will return "Fred". It is that simple. There is no need to rely on a container to add this functionality, or build a ProxyFactoryBean implementation to configure anything at runtime, it just works and it works without using any reflection whatsoever.

SUMMARY

In addition to being great for microservices, Micronaut is a general-purpose application framework that has huge potential to revolutionize the efficiency of modern Java applications.

Through AOT compilation, Micronaut is able to pre-compute your application's requirements and do a lot of the heavy lifting before it's up and running. This is a complete departure from how previous generations of Java application frameworks work, and it allows Micronaut to go places traditional Java frameworks don't normally tread.

Thanks to all those who provided issue reports for RC1, keep the reports coming!

* You may unsubscribe from our mailing list at any time using the 'unsubscribe' link at the bottom of every email. You can customize your email subscriptions here. To see how we keep your personal information secure, please visit our privacy policy.

secret