Vor einiger Zeit konnte ich mich eine Woche lang mit einer mir neuen Technologie auseinandersetzen. Ich habe Micronaut und Kotlin gewählt, weil mir beides schon bekannt war und ich mehr wissen wollte. Ich gehe auf die gewonnenen Erkenntnisse ein und ziehe einen Vergleich zu Spring.
Die Bedingung war, dass ich ein „Projekt-Ziel“ definiere, auch wenn dieses nicht in einer Woche umsetzbar ist. Daher habe ich mir eine kleine Anwendung vorgenommen, welche Abstimmungsresultate indexiert und den Index persistiert. Ein modernes Angular-Front-End sollte die Resultate darstellen.
Zustande gekommen ist bis jetzt ein Grundgerüst des Back-End. Und vor allem habe ich Erfahrungen mit Micronaut gesammelt.
Was ist Micronaut?
Micronaut ist ein JVM-basiertes Full-Stack-Framework für Microservices und Serverless-Anwendungen. Ähnliche Frameworks sind Spring Boot und Quarkus.
Micronaut fokussiert sich auf folgende Features:
- Modular durch Dependency Injection und Inversion of Control
- Leicht testbare Komponenten
- Integration bekannter Frameworks wie Hibernate, OpenAPI, Flyway
- Schneller Start und schlanker Betrieb dank ahead-of-time-Kompilierung
- Integration mit GraalVM
Unterschiede zu Spring
Der grösste Unterschied ist wohl der Verzicht auf Reflection. Denn alles wird kompiliert. So auch die Instanzierung und Verknüpfung der Komponenten. Und dies hat folgende Vorteile:
- Die Startzeit und der Arbeitsspeicherbedarf reduzieren sich beträchtlich
- Die Startzeit wirkt sich auch auf die Integrationstests positiv aus
- Der generierte Code kann eingesehen und mit Breakpoints versehen werden.
Ein kleiner Nachteil sind die längeren Build-Zeiten. Ich finde diese aber vernachlässigbar, weil die Integrationstests schneller sind.
Werden jetzt alle Probleme schon beim Kompilieren erkannt?
Leider nein. Denn zur Kompilierzeit ist über die endgültige Implementierung nichts bekannt. Und das ist ja gerade die Idee von IOC. Fehlt also zum Beispiel eine Instanz, wird dies erst beim Starten oder später bemerkt. Später, weil Micronaut die Komponenten immer erst bei Verwendung instanziert. Sie werden also immer lazy-loaded. Dies lässt sich applikationsweit ändern, sollte aber nur für Tests gemacht werden.
Unterschiede bei den Scopes – Micronaut vs Spring
In Spring wird das Verhalten einer Komponente mit @Scope definiert. Micronaut verwendet dafür verschiedene Annotationen.
@Micronaut: Annotation | Spring: @Scope value | |
@Singleton (javax) | singleton | |
@Prototype | prototype | default von Mirconaut’s @Bean |
@RequestScope | request | |
– | session | Bei Micronaut muss die Session als Parameter in die Methode injected werden |
@Context | – | Wie @Singleton, wird aber zur selben Zeit wie der BeanContext erzeugt (also nicht lazy-loaded) |
@ThreadLocal | – | Eine Instanz pro Thread |
@Refreshable | – | Wird neu erzeugt beim Event „RefreshEvent“ |
Weitere Bean-Definitionen – Micronaut vs. Spring
@Infrastructure | – | Kann nicht durch andere Beans ersetzt werden |
@ConfigurationProperties | @ConfigurationProperties | |
@Factory + @Singleton/@Prototype | @Configuration + @Bean | |
@Controller | @Controller |
Weitere Annotationen – Micronaut vs. Spring
Micronaut kommt mit einer Fülle an weiteren Annotationen. Diese steuern die Erstellung und Vernetzung der Komponenten. Eine Auswahl:
@Requires: | Komponente wird nur instanziert, wenn eine Bedingung erfüllt ist |
@Retryable: | Erneuter Methoden-Aufruf im Fall einer Exception |
@Recoverable und @Fallback: | Fallback-Implementation, wenn @Retryable erschöpft ist |
@CircuitBreaker: | Aufrufe werden eine bestimmte Zeit lang zurückgewiesen, wenn ein Fehler aufgetreten ist |
@Replaces + @Singleton/@Prototype | Ersetzt eine andere Instanz, wenn im Build vorhanden (z.B. in Test-Builds) |
Beispiele:
@Factory class ApiClientFactory { @Bean fun locationApiClient(clientConfiguration: clientConfiguration, oauthApiClient: OauthApiClient): LocationApi { ApiClient.accessToken = oauthApiClient.accessToken return LocationApi(clientConfiguration.baseUrl) } } @Singleton open class Client (private val locationApi: LocationApi){ fun getLocation(locationId: String): String { return locationApi.getLocation(locationId, "de").location.toString() } } @ConfigurationProperties("clientext.clientname") class ClientConfiguration { var baseUrl: String = "https://localhost:8080/reflect" }
Annotation Processor von Micronaut
Micronaut generiert anhand der Annotationen neue Kotlin Klassen. Aus einer Klasse LocationService werden also:
- $LocationServiceDefinition (Zur Instanzierung)
- $LocationServiceDefinitionClass (Zum Kapseln der Definition)
Das Verarbeiten der Annotationen läuft in IntelliJ nicht so geschmiert (Details weiter unten). Der Maven-Build läuft aber gut, sobald alles richtig konfiguriert ist.
Maven
In Maven muss jedes Modul mit den <annotationProcessorPaths> versehen werden. So z.B. in meinem Core-Modul:
<annotationProcessorPaths combine.self="override"> <annotationProcessorPath> <groupId>io.micronaut</groupId> <artifactId>micronaut-inject-java</artifactId> <version>${micronaut.version}</version> </annotationProcessorPath> <annotationProcessorPath> <groupId>io.micronaut</groupId> <artifactId>micronaut-validation</artifactId> <version>${micronaut.version}</version> </annotationProcessorPath> </annotationProcessorPaths> <annotationProcessorArgs> <annotationProcessorArg>micronaut.processing.group=com.noser.heuteabstimmung</annotationProcessorArg> <annotationProcessorArg>micronaut.processing.module=core</annotationProcessorArg> </annotationProcessorArgs>
Ich habe dafür ein pom-Template erstellt. Dieses enthält die wichtigsten Abhängigkeiten und Konfigurationen.
IntelliJ
Das Kotlin-Plugin muss unbedingt mit der Kotlin-Version übereinstimmen. Denn ansonsten hatte ich seltsame Fehlermeldungen wie „unresolved references“.
Weiter muss natürlich die Verarbeitung der Annotationen aktiviert werden:
Preferences: Build, Execution, Deployment -> Compiler -> Annotation Processors -> „Enable annotation processing“
Doch leider gibt es bei der Verarbeitung Probleme. Denn ändert man etwas an den Annotationen und startet die App aus IntelliJ neu, werden diese Änderungen nicht wirksam! Nur wenn zuerst ein Maven-Build gestartet wird, klappt es. Mir sind bis heute leider keine Workarounds bekannt. Der Bug liegt bei Jetbrains seit vier Jahren herum.
Mix mit Code-Generierungs-Tools
Die APIs von Abstimmungsresultaten habe ich aus einer OpenApi 3 Spec generiert. Dabei wollte ich im selben Modul die Bean-Factories haben. Und diese sollten den Client als Bean zur Verfügung stellen.
Dies hat zu Konflikten geführt, welche erst zur Laufzeit auftreten. Denn wenn Informationen fehlen, verwendet Kotlins Annotation Processor die Klasse error.NonExistentClass. Weil die Zeit fehlte, konnte ich keine Lösung finden. Also habe ich das Modul aufgeteilt:
- API: Code-Generierung anhand Spec
- Client: Factory für die Micronaut-Komponenten
Dies hat den Vorteil, dass ich die API auch auslagern und anderen Java-Lösungen zur Verfügung stellen kann.
Tests mit Kotest
Micronaut und Kotlin kommen mit guten Test-Werkzeugen daher. Diese funktionieren mit Junit 5, Spock oder Kotest von Kotlin. Die Verwendung von Mocks ist allerdings nicht so bequem wie das Zusammenspiel von Spring und Mockito.
Wegen der knappen Zeit habe ich mich mit Unit-Tests noch zu wenig beschäftigt. Jedoch sieht es so aus, dass gemockte Abhängigkeiten immer über eine Builder-Methode erzeugt werden müssen:
@MicronautTest class LocationServiceTest( private val locationService: locationService, private val client: LocationClient // Mock wird verwendet ) : StringSpec({ „test retreive location“ { … } }) { @MockBean(LocationClientImpl::class) fun locationClient(): LocationClient { return mockk() } }
Dafür sind Integrationstests gerade wegen der schnellen Startzeit ein Klacks:
@MicronautTest class DbLookUpTest( private val lookupDataUseCase: LookupDataUseCase, private val locationRepository: locationRepository ) : StringSpec() { override fun beforeTest(testCase: TestCase) { super.beforeTest(testCase) locationRepository.save(xy) } init { "test retreive location" { lookupDataUseCase.get(xy) } } }
Starten von Micronaut
Bootstrap der App
Micronaut wird über die main()-Methode gestartet. Diese ist bei Java der Einstiegspunkt:
package com.noser.heuteabstimmung import io.micronaut.runtime.Micronaut object HeuteAbstimmungApplication { @JvmStatic fun main(args: Array<String>) { Micronaut.build() .args(*args) .mainClass(HeuteAbstimmungApplication.javaClass) .start() } }
Das Kotlin-Objekt muss im Maven-POM als property referenziert sein:
<properties> <exec.mainClass>com.noser.heuteabstimmung.HeuteAbstimmungApplication</exec.mainClass> ... </properties>
Starten über die Commandline
Mit dem Maven-Build lässt sich eine ausführbare Jar-Datei erstellen. Also wie bei Spring. Und diese kann dann mit dem Java-Befehl gestartet werden. Die Umgebung wiederum wird über das System Property ‚micronaut.environments‘ gesetzt. Oder aber über die Umgebungsvariable ‚MICRONAUT_ENVIRONMENTS‘. Dadurch werden die entsprechenden Einstellungen geladen.
$> java -Dmicronaut.environments=local -jar path/to/project/app-module/target/app-0.1.jar -DDB_PASSWORD=xyz
Über das ‚micronaut-maven-plugin‘ lässt sich die App folgendermassen starten:
$> cd path/to/project/app-module $> mvn mn:run -Dmicronaut.environments=local -DDB_PASSWORD=xsz
Das ‚micronaut-maven-plugin‘ muss übrigens nur im Haupt-Maven-Modul verwendet werden. Also dort wo die main()-Methode verwendet wird.
Micronaut wird dann in einem change-detection-mode gestartet. Bei Änderungen innerhalb der Sourcen wird das Modul neu kompiliert und die App neu gestartet. Bei meinem Multi-Modul-Aufbau war das leider wenig nützlich, denn in meinem App-Modul liegt lediglich die Bootstrap-Klasse ApplicationKt.kt.
Starten über IntelliJ
In IntelliJ kann mit dem Micronaut-Plugin eine Run-Configuration erstellt werden. Es können aber (zum Zeitpunkt meiner Arbeit) keine Micronaut-Umgebungen gesetzt werden. Darum setzt man in der Run-Configuration die Umgebungs-Variable ‚MICRONAUT_ENVIRONMENTS‘.
Fazit
Eine Woche mit Micronaut und Kotlin ist natürlich schnell vorüber! Wenn die Anfangsschwierigkeiten überwunden sind und man schon Erfahrung mit Spring (Boot) hat kommt man gut voran und es macht Spass. Einzig der nötige Rebuild bei Änderungen an den Annotationen nervt gewaltig, weil er so viel Zeit frisst.
Ich hatte auch Herausforderungen mit ‚Micronaut Data‘. Und auch beim Generieren einer API mittels Open-API 3 Spec, weil Code generiert wird. Falls das Interesse vorhanden ist, würde ich erst in einem nächsten Blog-Eintrag darauf eingehen. Aber in den verlinkten Sourcen sind schon ein paar Knacknüsse in ‚Journal.md‘ erwähnt.
Links
Projekt (Github, in Arbeit): https://github.com/christianspiller/heuteabstimmung
Micronaut: https://micronaut.io/
Kotlin: https://kotlinlang.org
Versionen
- Micronaut: 2.5.0
- Kotlin: 1.5.10
- Kotest: 4.2.5
- JDK: 11
- Maven: 3.8.1
- IntelliJ: 2021.3.1 (Ultimate Edition)
- Kotlin-Plugin: 213-1.5.10
- Micronaut-Plugin: 213.6461.79
- Kotest-Plugin: 1.1.49