06/09/2019 10 Minutes read Tech 

Micronaut : Le microframework pour microservices

Présentation de Micronaut, un microframework pour la JVM taillé pour les microservices, à travers plusieurs exemples et applications de démonstration.

Présentation de Micronaut, un microframework pour la JVM taillé pour les microservices, à travers plusieurs exemples et applications de démonstration.

Chez ekino, nous essayons toujours de trouver les meilleurs outils pour nos réalisations.

Dans ce cadre, nous avons cherché un framework Java / JVM léger pour développer rapidement des petites applications en microservices et sortir de la stack Spring Boot, certes efficace, mais un peu excessive.

Nous avons entendu parlé d’une solution qui coïncidait avec nos besoins : Non bloquante par défaut, proposant une découverte de services et un traçage de requêtes très simple à mettre en place et en bonus un microframework léger en mémoire et au démarrage, la recette parfaite pour des microservices !

C’est ainsi que nous avons décidé d’essayer Micronaut à travers une petite démo.
Voici nos retours après deux semaines d’immersion.

Une brève analyse du projet

L’information importante à prendre en compte avant de céder à la hype d’un framework, c’est la pérennité du projet.

Micronaut est un projet libre depuis le 23 mai 2018 et disponible sur GitHub.

Il est supporté officiellement par OCI (Object Computing, Inc.), aussi sponsor officiel de Grails. Ses mainteneurs principaux sont tous ingénieurs à OCI et contributeurs du projet Grails : Graeme Rocher (co-créateur de Grails), James Kleeh, Sergio del Amo, Zachary Klein et Iván López.

Bien que le projet soit encore jeune (1.0.0 à l’écriture de cet article), sa vingtaine de contributeurs mensuels et sa moyenne d’environ 75 commits par semaine le placent presque au niveau de projets connus en rythme de croisière, comme React ou Spring Boot par exemple, mais très loin de jeunes projets hyperactifs comme React Native.

Son rythme actuel lui permet d’évoluer plutôt rapidement, avec des issues et des pull requests traitées dans les deux jours, et de nouvelles fonctionnalités régulièrement ajoutées après consensus.

Le projet dans son état actuel est donc plutôt stable et va dans la bonne direction, ce qui nous conforte dans notre choix, même s’il lui faudrait plus d’engouement pour vraiment percer.

Les fonctionnalités phares de Micronaut

Réactivité

L’une des fonctionnalités phares de Micronaut est d’être complètement réactif par défaut.

Pour rappel, le Reactive Programming est un paradigme de programmation bâti sur les patrons de conception Observateur et Itérateur et la programmation fonctionnelle.
Le principe est de décrire les opérations qui seront effectuées sur les éléments émis par vos différents observables, y compris les erreurs, puis d’y souscrire afin de réagir aux émissions (pushs) d’éléments.

Si ce paradigme a de nombreux intérêts, le principal est qu’il apporte un moyen d’effectuer simplement des traitements asynchrones. Dans un contexte de microservices cela peut avoir un avantage certain en temps de traitement global, puisqu’il n’est pas nécessaire “d’attendre” les services externes si les informations demandées ne sont pas nécessaires pour les opérations ultérieures, telles que des appels à d’autres services.

Appels synchrones VS asynchrones

Pour plus d’informations sur ce paradigme, ses intérêts et son fonctionnement, nous vous conseillons ces références : Le site de l’initiative Reactive Streams et le référencement de tutoriels par ReactiveX (qui propose notamment cette excellente introduction à la programmation réactive).

En pratique, il suffit de faire retourner des implémentations de Publisher (comme Flux et Mono) à nos clients HTTP et nos contrôleurs, sans bloquer notre flux de données, pour que notre application soit réactive. Micronaut se charge du reste, ce qui rend l’exercice particulièrement simple.

Découverte de services

Un service registry (aussi appelé service d’annuaire ou discovery service) répertorie les informations nécessaires à l’envoi de requêtes aux instances des microservices. Il existe deux types d’interactions avec cet annuaire : l’enregistrement de services (registration) et la découverte de services (discovery).

La découverte de service en trois étapes

Une fois un système de découverte de services en place, les services sont capables d’appeler l’ensemble des services enregistrés sans connaître directement leurs adresses IP et ports.

Pour ce faire, il faut que chaque service soit enregistré puis que chaque appel à un service soit précédé d’une découverte. Si ce mécanisme peut paraître difficile à mettre en place, il est en réalité particulièrement simple à implémenter sur un service Micronaut.

Dans le cas d’une découverte de services avec Eureka et une fois un serveur Eureka en place, il suffit de quelques lignes de configuration pour que l’application s’enregistre automatiquement au démarrage :

eureka:
client:
registration:
enabled: true
defaultZone: "${EUREKA_HOST:localhost}:${EUREKA_PORT:8761}"

Il reste alors simplement à renseigner le nom du service ciblé (défini par la propriété micronaut.application.name) dans le champs “id” de l’annotation d’en-tête de votre client HTTP pour pouvoir l’appeler : @Client(id = "book", path = "/books").

Retry, Circuit Breaker et Fallback

Le mécanisme de retry permet de retenter une opération qui a échoué, le plus souvent l’appel à une API externe. Ce mécanisme est notamment associé à un nombre de tentatives et un temps d’attente entre ces tentatives. Mettre en place un mécanisme de retry et le paramétrer se fait simplement avec Micronaut :

@Retryable(attempts = "2", delay = "2s")
@Get("/{id}")
Mono<BookDto> findById(UUID id);

L’annotation précise ici qu’il y aura deux tentatives séparées par deux secondes. Si la dernière tentative effectuée a échoué, alors une exception sera levée sauf si un fallback existe.

Un fallback est une stratégie de repli mise en place si une exception est levée lors de l’appel à un service externe. Pour définir un fallback avec Micronaut, cela reste simple, il suffit :

  • D’avoir une interface parente des deux classes : La classe nécessitant un fallback et la classe représentant le fallback ;
  • D’annoter la classe nécessitant le fallback avec l’annotation @Recoverable ;
  • D’annoter la classe correspondant au fallback avec l’annotation @Fallback.

Par exemple, les deux clients dans le microservice Borrowing :

Diagramme de classe avec fallback et recoverable

Notre exemple de fallback est constitué de trois éléments :

  • L’interface BookOperations définie les méthodes du client, qui portent les annotations HTTP (@Get, @Post, etc.) ;
  • L’interface BookClient héritant de BookOperations est notre réel client, porte l’annotation @Client et sera automatiquement implémentée et exposée en tant que bean par Micronaut ;
  • La classe BookFallback implémentant BookOperations est notre client de fallback, porte l’annotation @Fallback, et sera exposée en tant que bean de fallback par Micronaut.

Si l’appel d’une méthode de BookClient échoue, Micronaut utilisera automatiquement BookFallback, qui implémente la même interface.

À noter que l’annotation @Recoverable est incluse dans l’annotation @Client.

Dans le cas d’un problème plus persistant, le retry va générer beaucoup de latence. Il est alors plus intéressant de passer temporairement sur le fallback par défaut, en vérifiant régulièrement si le service est rétabli. Il s’agit du patron de conception circuit breaker, ou coupe-circuit.

Patron de conception circuit breaker

Cette implémentation est présente dans Micronaut et est aussi simple à mettre en place que le mécanisme de retry. Il s’agit d’utiliser une annotation qui est une variante de @Retryable (il n’y a donc pas besoin de les combiner!) permettant en plus de définir le temps d’attente pour passer de l’état “ouvert” à l’état “semi-ouvert” :

@CircuitBreaker(reset = "30s", attempts = "2", delay = "2s")
@Get("/{id}")
Mono<BookDto> findById(UUID id);

Dans cet exemple, le temps avant de sortir de l’état “ouvert” pour aller dans l’état “semi-ouvert” est de trente secondes.

Que ce soit l’annotation @Retryable ou @CircuitBreaker, il est possible d’utiliser ce mécanisme sur n’importe quelle méthode, qu’elle retourne un objet réactif ou pas, et ce même si elle ne fait pas partie d’un client HTTP.

Les fonctionnalités bonus

Consommation mémoire et temps de démarrage

Son mécanisme de compilation AOT (Ahead-of-Time) permet à Micronaut de s’occuper de l’injection de dépendances et l’AOP (Aspect-Oriented Programming) à la compilation. Il n’utiliserait même pas de réflexion au runtime du tout.

D’après Micronaut, le framework permet donc de réaliser des applications au démarrage rapide et au faible coût en mémoire.

Afin d’avoir un bref aperçu de cette différence, nous avons réalisé deux Hello World : l’un avec Micronaut et l’autre avec Spring Boot.
Voilà ce que nous avons pu observer sur notre machine de test (CPU Intel i7-6700HQ et 16 Go de RAM) :

Nous avons utilisé Micrometer pour effectuer les mesures de mémoire.

Si on ajoute ces différents points, Micronaut devrait en théorie avoir une empreinte beaucoup plus réduite que Spring Boot :

  • Les beans en singleton sont initialisés à la demande plutôt qu’au démarrage ;
  • L’absence de réflexion limite la consommation mémoire et réduit le temps de démarrage relativement à la taille de la codebase, à cause du cache qu’elle aurait nécessité.

Nous avons de plus fait un troisième Hello World avec Micronaut, compilé en image native avec GraalVM et avons constaté un temps de démarrage impressionnant : Seulement 35 ms en moyenne !
L’utilisation de GraalVM, qui nécessite de tout gérer à la compilation, n’est cependant pas très stable. De nombreuses librairies tierces généreront des erreurs d’exécution liées à la réflexion ou à l’utilisation de l’interface native Java : Nous n’avons par exemple pas pu intégrer Micrometer à notre Hello World.

C’est néanmoins un énorme avantage par rapport à Spring Boot : GraalVM est clairement un des objectifs phares de Micronaut, et l’une des raisons pour laquelle le microframework tente d’éliminer toute réflexion ou interprétation dynamique.

Les tests d’intégration

Micronaut propose pour les tests d’intégration un serveur intégré ainsi qu’un client HTTP non bloquant par défaut. Le tout est plutôt simple d’utilisation.

Pour lancer le serveur à tester, quelques lignes suffisent :

@CircuitBreaker(reset = "30s", attempts = "2", delay = "2s")
@Get("/{id}")
Mono<BookDto> findById(UUID id);

Nous avons ici précisé le package du projet afin d’éviter une erreur de type NoSuchBeanException qui survient si le fichier de test en question se trouve dans un package différent de celui des entités. Sans cela, le bean EntityManager n’est pas injecté.

Il ne reste plus qu’à lancer votre client HTTP, et cette fois-ci deux lignes suffisent :

HttpClient client = server.getApplicationContext()
.createBean(HttpClient.class, server.getURL());

Attention par contre dans le cas d’un appel censé échouer (en 404 par exemple), le client ne renvoie pas une réponse contenant le statut et le body, mais lève une exception. Pour pouvoir tester que votre erreur est bien celle attendue, il faudra passer par le onError ou le doOnError de votre publisher, ou utiliser StepVerifyer si vous utilisez Reactor.

Enfin, avec la nouvelle dépendance Micronaut Test (aujourd’hui en version 1.0.0.RC1) et son annotation @MicronautTest, il vous est possible de tout simplement injecter votre client et même de mocker vos services avec l’annotation @MockBean.

Les fonctionnalités que nous voulions retrouver

Quand on vient de Spring Boot, et qu’on utilise Spring Data, plein de starters et des annotations “magiques”, on est habitué à fournir le minimum d’effort pour réaliser des applications au comportement conventionnel.

Ici nous traitons des fonctionnalités que nous avions l’habitude d’avoir sur Spring et que nous voulons retrouver sur Micronaut.

Persistence des données avec Hibernate

Vous devrez obligatoirement passer par une implémentation manuelle du repository (ou même ne pas faire d’interface du tout si vous êtes contre la surexploitation des interfaces).

Il est bien sûr possible d’utiliser l’EntityManager et Micronaut fournit l’annotation @Transactional qui est similaire à celle de Spring (mais attention : Elle ne se place que sur les méthodes).

Micronaut ne propose pas encore toutes les fonctionnalités « standards » comme la pagination, on peut donc rapidement arriver à une boilerplate conséquente pour des besoins courants.

C’est peut-être l’occasion de découvrir d’autres abstractions SQL, comme QueryDSL par exemple qui fonctionne très bien avec Hibernate, voire adopter complètement le non bloquant et utiliser le client réactif Postgres ou MongoDB (qui supporte les transactions ACID depuis peu !).

Les jeux de données de tests

Vous aurez le droit à encore un peu plus de boilerplate (mais raisonnable) puisqu’il n’y a pas d’équivalent au @Sql de Spring pour exécuter vos scripts SQL.

Comme nous l’avons à disposition, nous utilisons l’EntityManager et sa méthode createNativeQuery.

Il n’est cependant pas possible de le récupérer avec un simple @Inject puisque, rappelez-vous, les beans singletons sont initialisés à la demande : L’EntityManager n’est pas disponible tant qu’aucun test n’est passé.

Nous avons donc été obligé de passer par la factory :

EntityManager entityManager = server.getApplicationContext()
.getBean(EntityManagerFactory.class)
.createEntityManager();

La gestion des erreurs

Bonne nouvelle cette fois-ci : Il est possible de mettre simplement en place un gestionnaire d’exceptions pour construire les réponses HTTP à partir de vos exceptions.

@Produces
@Singleton
@Requires(classes = { RestRuntimeException.class, ExceptionHandler.class })
public class RestRuntimeExceptionHandler implements ExceptionHandler<RestRuntimeException, HttpResponse> {

@Override
public HttpResponse handle(HttpRequest request, RestRuntimeException exception) {
ExceptionBody exceptionBody = ExceptionBody.builder()
.code(exception.getErrorCode())
.description(exception.getMessage())
.build();
return HttpResponse.status(exception.getHttpStatus())
.body(exceptionBody);

}
}

A titre de comparaison, voici un gestionnaire du même type d’exception pour une application Spring Boot :

@ControllerAdvice
public class RestExceptionHandler extends ResponseEntityExceptionHandler {

@ExceptionHandler(RestRuntimeException.class)
@ResponseBody
public ResponseEntity handleRestRuntimeException(RestRuntimeException exception) {
ErrorBody errorBody = ErrorBody.builder()
.code(exception.getErrorCode())
.description(exception.getMessage())
.build();
return new ResponseEntity<>(errorBody, new HttpHeaders(), exception.getReturnCode());

}
}

Comme pour Spring, il est possible de surcharger la gestion des erreurs Micronaut en surchargeant les singletons existant, comme celui des violations de contraintes, avec un simple @Replace.

Le reste

Nous avons pu facilement mettre en place plusieurs autres librairies souvent utilisées dans nos projets :

  • Lombok et Mapstruct fonctionnent nativement, du moment que leurs annotationProcessors respectifs sont placés avant celui de inject-java de Micronaut ;
  • Flyway fonctionne aussi comme sur Spring Boot, il suffit de déclarer une classe dédiée avec l’annotation Micronaut @Singleton ;
  • Les ressources de management sont aussi simples à mettre en place que les actuators de Spring Boot en ajoutant la dépendance micronaut-management;
  • L’upload de fichier, la Bean Validation via javax et le logging sont quasiment identiques à ce que vous pouvez retrouver sur Spring Boot ;
  • La génération de documentation OpenAPI est supportée depuis la version RC1 de Micronaut.

Java 11 et Micronaut : Fonctionnel mais non supporté

Nous sommes plutôt habitués à travailler en Java sur nos applications back-end destinées à la JVM, nous intéressons de plus en plus à Kotlin, et n’utilisons Groovy en général que pour Gradle (certains passionnés font aussi parfois du Scala mais c’est un autre sujet).

Nous avons donc intuitivement choisi Java 11 pour tester Micronaut et ainsi écarter l’exploration du langage et nous concentrer sur le framework.

Si Micronaut, qui ne supporte officiellement que Java 8, dit fonctionner avec Java 9 et plus, nous avons tout de même noté plusieurs limitations :

  • L’impossibilité de retourner un Flow dans un contrôleur (mais qui utilise directement l’implémentation Java ?) ;
  • Des alertes à la compilation signalant que JDK 11 n’est pas supporté :
    warning: Supported source version 'RELEASE_8' from annotation processor 'org.gradle.api.internal.tasks.compile.processing.NonIncrementalProcessor' less than -source '11'
    Ces alertes viennent des trois processeurs d’annotations BeanDefinitionInjectProcessor, TypeElementVisitorProcessor et PackageConfigurationInjectProcessor utilisés pour le mécanisme de compilation AOT de Micronaut ;
  • La nécessité d’importer nous-même les dépendances retirées depuis Java 8, comme les javax par exemple. Si Spring Boot les a directement intégrées dans ses starters en 2.1.0, ce n’est pas le cas de Micronaut. Nous avons été par exemple obligés d’importer javax.xml.bind:jaxb-api pour utiliser io.micronaut.configuration:hibernate-jpa.

Vous l’avez compris, vous rencontrerez peut-être quelques problèmes en utilisant Java 11, du moins si vos dépendances ne gèrent pas elle-même les sous-dépendances retirées de la JDK.
Nous avons pour notre part décidé de basculer sur Java 8 pour avoir un jugement sur la manière “officielle” de faire du Micronaut.

Attention si vous faites du Java 10 : Nous vous conseillons absolument de basculer soit sur Java 8, soit sur Java 11.
Avec Java 10, nous avons rencontré une erreur de compilation après avoir ajouté nos clients :

UserClient.java:12: error: duplicate class: com.ekino.micronaut.borrowing.client.UserClient
interface UserClient {
^

Nous vous recommandons plutôt Kotlin + Spek si vous souhaitez bénéficier de fonctionnalités plus modernes sans complications.
Il sera tout de même possible de développer en Java 8 si votre équipe n’est pas prête à écrire en Kotlin ou si vos contraintes projet ne le permettent pas.

Le mot de la fin

Si vous souhaitez rapidement réaliser une application suivant une architecture microservices, avec des besoins conventionnels et des fonctionnalités récentes, Micronaut est peut-être le framework qu’il vous faut.

Nous vous le déconseillons par contre si vos besoins sont plus « legacy » ou si vous dépendez fortement de librairies tierces incompatibles. À moins que votre projet n’atteigne une taille considérable, auquel cas l’impact de la boilerplate deviendrait négligeable, nous vous conseillons plutôt un bon vieux Spring Boot.

Les fonctionnalités de Micronaut sont presque stabilisées et il sera bientôt justifiable de l’utiliser sur de véritables projets. Le framework brillera notamment sur des microservices très ciblés de petite taille.

Avec son empreinte réduite, les outils qu’il propose et ses deux annotations @FunctionBean et @FunctionClient, Micronaut pourrait même être la meilleure réponse au besoin de microframework orienté Serverless pour la JVM. Nous en parlerons sûrement dans un prochain article.

Contact

Vous avez des questions ? Un projet ?

Contactez-nous