Artikelreihe
Dies ist der erste von vier Artikeln, die verschiedene Aspekte der Auslieferung von Hilla-Anwendungen in Produktion beschreiben:
- Teil 1: Production-Build
- Teil 2: Docker-Images
- Teil 3: CI/CD
- Teil 4: Serverless Deployment
Production-Build
Eine Hilla-Anwendung kann unterschiedlich paketiert werden und auch die spätere Ausführungsumgebung kann variieren. Der gemeinsame Ausgangspunkt ist aber in allen Fällen der Production-Build. Jede Hilla-Anwendung kann mit Hilfe eines entsprechenden Konfigurationsprofils als produktionsbereites Artefakt gebaut werden. In Verbindung mit Maven übernimmt diese Aufgabe das Vaadin-Maven-Plugin. In einem Production-Profil führt dieses Plugin das Goal build-frontend
aus:
<profile>
<id>production</id>
<build>
<plugins>
<plugin>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-maven-plugin</artifactId>
<version>${vaadin.version}</version>
<executions>
<execution>
<goals>
<goal>build-frontend</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
Hilla-Anwendungen, die bspw. über https://start.vaadin.com oder mit Hilfe der Vaadin CLI über npm init vaadin
erstellt wurden, enthalten bereits alle erforderlichen Konfigurationen zur Nutzung dieses Profils. Für Hilla-Anwendungen, die die erforderliche Konfiguration noch nicht enthalten, gibt es eine Anleitung, die beschreibt, wie man dies nachholen kann.
Dieses Profil kann über folgenden Kommandozeilen-Aufruf ausgeführt werden, um ein Production-Build zu erstellen:
mvn clean package -Pproduction
Während der Erstellung des produktionsbereiten Artefakts werden unter anderem folgende Aufgaben ausgeführt:
- Das Goal
prepare-frontend
wird ausgeführt. In diesem Schritt werden die erforderlichen Voraussetzungen für das spätere Bundling des Frontends durch Vite geschaffen. - Die Anwendung wird kompiliert und der relevante TypeScript-Code für das Frontend wird generiert.
- Das Goal
build-frontend
wird ausgeführt. Dabei werden unter anderem alle erforderlichen Abhängigkeiten installiert und das Frontend-Bundle mit Vite erstellt. Im Rahmen des Bundlings werden alle ES6 JavaScript-Dateien in das ES5 JavaScript-Format transpiliert, der Code wird minimiert und verschleiert und Dateien werden zusammengefasst, damit sie später im Browser schneller geladen werden können.
Damit das erstellte Artefakt möglichst kompakt ist, sollten bestimmte Dependencies, die nur während der Entwicklung relevant sind, nicht in den Production-Build aufgenommen werden. Die Dependency com.vaadin:vaadin-dev-server
kann zu diesem Zweck bspw. folgendermaßen ausgeschlossen werden:
<profiles>
<profile>
<id>production</id>
<dependencies>
<!-- exclude development dependencies from production -->
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-core</artifactId>
<exclusions>
<exclusion>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-dev</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<!-- production build configuration from above -->
</profile>
</profiles>
Das Frontend-Bundle im produktionsbereiten Artefakt besteht aus einer Datei index.html
und zugehörigen JavaScript- und CSS-Dateien, die vom Browser nachgeladen werden.
META-INF/VAADIN/webapp/
- index.html
- VAADIN/build/
- indexhtml.js
- indexhtml.css
- FlowBootstrap.js
- FlowClient.js
- generated-flow-imports.js
Wer einen Überblick über den genauen Inhalt und die Größe des Frontend-Bundles erhalten möchte, kann sich die Datei target/classes/META-INF/VAADIN/config/bundle-size.html
im Production-Build anschauen:
Zusätzlich zum Frontend-Bundle enthält das produktionsbereite Artefakt auch den Backend-Code bestehend aus dem kompilierten Spring-Boot-Projekt mit dem anwendungsspezifischen Code der Hilla-Anwendung und allen erforderlichen Abhängigkeiten.
Production-Build als Fat-JAR mit integriertem Servlet-Container
Im Standardfall resultiert der Production-Build in einer ausführbaren JAR-Datei. Da eine Hilla-Anwendung auf Spring Boot basiert, enthält die erstellte JAR-Datei zusätzlich zum Frontend-Bundle, dem kompilierten Backend-Code und allen erforderlichen Abhängigkeiten auch einen integrierten Servlet Container, der die Hilla-Anwendung bereitstellt, sobald die JAR-Datei ausgeführt wird. Für das Deployment einer Hilla-Anwendung in Form dieser JAR-Datei ist somit nur eine Java-Laufzeitumgebung und der folgende Aufruf erforderlich:
java -jar target/my-app.jar
Die Paketierung der JAR-Datei übernimmt das Spring-Boot-Maven-Plugin. Das Plugin kümmert sich darum, aus einer regulären JAR-Datei, welche lediglich den Code der Anwendung enthält, ein Fat-JAR
(oder auch Uber-JAR
) zu erzeugen, dass alle erforderlichen Abhängigkeiten, zusätzliche Konfigurationen und den integrierten Servlet Container enthält. Das Fat-JAR einer Hilla-Anwendung, die als Production-Build erstellt wurde, hat folgenden Aufbau bzw. Inhalt:
BOOT-INF/
- classes/
- application.properties
- banner.txt
- com/application/example/
- Application.class
- services/
- hilla-openapi.json
- classpath.idx
- layers.idx
- lib/
META-INF/
- MANIFEST.MF
- maven/
- resources/
- services/
- VAADIN/
- config/
- file-routes.json
- webapp/
org/springframework/boot/loader
- Das Verzeichnis
BOOT-INF
enthält den kompilierten Backend-Code der Hilla-Anwendung (classes
) und alle erforderlichen Abhängigkeiten (lib
). - Das Verzeichnis
META-INF
enthält dieMANIFEST.MF
-Datei, zusätzliche Metadaten, statische Ressourcen und das Frontend-Bundle der Hilla-Anwendung. - Das Verzeichnis
org/springframework/boot/loader
enthält Spring Boot-spezifische Klassen und Konfigurationen, die die Ausführung des Fat-JAR ermöglichen.
Die MANIFEST.MF
-Datei spielt in einer JAR-Datei eine besondere Rolle. Neben einigen Metadaten enthält sie insbesondere die Informationen, die relevant sind, um die JAR-Datei ausführbar zu machen. Für den Production-Build einer Hilla-Anwendung kann die MANIFEST.MF
-Datei beispielsweise folgenden Inhalt haben:
Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.4.2
Build-Jdk-Spec: 21
Implementation-Title: hilla-production-build-test
Implementation-Version: 1.0-SNAPSHOT
Main-Class: org.springframework.boot.loader.launch.JarLauncher
Start-Class: com.example.application.Application
Spring-Boot-Version: 3.3.2
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Spring-Boot-Layers-Index: BOOT-INF/layers.idx
In der MANIFEST.MF
-Datei ist unter anderem die Main-Class
und die Start-Class
definiert. Die Main-Class
ist in diesem Fall org.springframework.boot.loader.launch.JarLauncher
. Diese Klasse befindet sich im Verzeichnis org.springframework/boot/loader/launch
und wird von Spring Boot bereitgestellt, um den Startvorgang der Anwendung bei Ausführung des Fat-JAR zu ermöglichen. Die Start-Class
ist in diesem Beispiel com.example.application.Application
. Diese Klasse befindet sich im Verzeichnis BOOT-INF/classes/com/application/example
und gehört zur Hilla-Anwendung. Die Einträge Spring-Boot-Classes
und Spring-Boot-Lib
verweisen auf den kompilierten Backend-Code der Hilla-Anwendung und alle erforderlichen Abhängigkeiten.
Eine besondere Rolle in dem Fat-JAR spielt darüber hinaus der integrierte Servlet-Container. Im Standard handelt es sich dabei um ein integrierten Apache Tomcat. Die Tatsache, dass ein ausführbares Fat-JAR neben der Anwendung auch einen integrierten Servlet-Container enthält, vereinfacht das Deployment einer Hilla-Anwendung ungemein. Zum Einen entfällt die Installation und Konfiguration eines eigenständigen Servlet-Containers. Zum Anderen ist sichergestellt, dass die Hilla-Anwendung sowohl während der lokalen Entwicklung, als auch während der Ausführung in CI/CD-Systemen und in Produktion stets in einer isolierten und konstanten Umgebung ausgeführt wird.
Weitere Informationen zu den besonderen Eigenschaften eines Spring Boot Fat-JAR findet man unter anderem in diesem Blog-Post von Stackademic.
Production-Build als WAR-Datei
Der Production-Build einer Hilla-Anwendung kann auch in einer WAR-Datei resultieren. Dazu sind folgende Anpassungen erforderlich:
- Die Klasse
Application.java
muss angepasst werden. Dazu muss die Erweiterung der KlasseSpringBootServletInitializer
hinzugefügt werden:
package com.example.application;
import com.vaadin.flow.component.page.AppShellConfigurator;
import com.vaadin.flow.theme.Theme;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
@SpringBootApplication
@Theme(value = "hilla-production-build-test")
public class Application extends SpringBootServletInitializer implements AppShellConfigurator {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
return application.sources(Application.class);
}
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
- In der Datei
pom.xml
muss das Format für die Paketierung vonjar
aufwar
umgestellt werden:
<!-- ... -->
<groupId>com.example.application</groupId>
<artifactId>hilla-production-build</artifactId>
<name>hilla-production-build</name>
<version>1.0-SNAPSHOT</version>
<packaging>war</packaging>
<!-- ... -->
- Es muss sichergestellt werden, dass die erstellte WAR-Datei keinen eingebetteten Servlet-Container mehr enthält. Dazu wird die entsprechende Abhängigkeit in der Datei
pom.xml
mit dem Scopeprovided
hinzugefügt:
<!-- ... -->
<dependencies>
<!-- ... -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>
<!-- ... -->
</dependencies>
<!-- ... -->
Im Anschluss an diese Anpassungen kann der Production-Build der Hilla-Anwendung erneut erzeugt werden:
mvn clean package -Pproduction
Die erzeugte WAR-Datei hat folgenden Inhalt bzw. Aufbau:
WEB-INF/
- classes/
- application.properties
- banner.txt
- com/application/example/
- Application.class
- services/
- hilla-openapi.json
- META-INF/
- resources/
- VAADIN/
- classpath.idx
- layers.idx
- lib/
- lib-provided
org/
META-INF/
- MANIFEST.MF
- maven/
- services/
- Das Verzeichnis
WEB-INF
enthält den kompilierten Backend-Code der Hilla-Anwendung (classes
), statische Ressourcen (META-INF/resources
), das Frontend-Bundle der Hilla-Anwendung (META-INF/VAADIN
) und alle erforderlichen Abhängigkeiten (lib
). - Das Verzeichnis
org/springframework/boot/loader
enthält Spring Boot-spezifische Klassen und Konfigurationen, die die Ausführung der WAR-Datei ermöglichen. - Das Verzeichnis
META-INF
enthält dieMANIFEST.MF
-Datei und zusätzliche Metadaten.
Es ist erkennbar, dass die WAR-Datei den gleichen Inhalt wie die zuvor beschriebene JAR-Datei hat. Auch ein Servlet-Container ist im Verzeichnis WEB-INF/lib-provided
vorhanden. Dies ist durchaus überraschend, wurde doch der Servlet-Container in der Datei pom.xml
als provided
konfiguriert. In der Spring Boot Dokumentation findet sich dafür folgende Erläuterung:
If you use the Spring Boot Build Tool Plugins, marking the embedded servlet container dependency as provided produces an executable war file with the provided dependencies packaged in a lib-provided directory. This means that, in addition to being deployable to a servlet container, you can also run your application by using java -jar on the command line.
Die erzeugte WAR-Datei lässt sich also auch wie eine JAR-Datei direkt ausführen. In der Regel erfolgt jedoch das Deployment einer WAR-Datei über einen eigenständigen Servlet-Container, wie bspw. Tomcat, Jetty oder Undertow.
Production-Build als Native Image
Eine weitere Option für die Erstellung eines produktionsbereiten Artefakts ist das Erzeugen eines Production-Builds in Form eines Native Image. Dazu ist ein JDK mit GraalVM-Unterstützung erforderlich. Eine Hilla-Anwendung, die als Native Image gebaut wurde, ist eine eigenständige ausführbare Datei, die ohne eine Java-Laufzeitumgebung ausgeführt werden kann. Dies vereinfacht das Deployment noch weiter. Im Vergleich zu einer Hilla-Anwendung, die als JAR-Datei gebaut wurde und ausgeführt wird, zeichnet sich eine Hilla-Anwendung, die als Native Image gebaut wurde und ausgeführt wird, in der Regel durch eine geringere Startzeit und eine geringere Speichernutzung aus. Der Production-Build einer Hilla-Anwendung kann mit Unterstützung von GraalVM wie folgt als Native Image erzeugt werden:
mvn clean package -Pproduction -Pnative native:compile
Das erstellte Native Image enthält alle erforderlichen Klassen, Abhängigkeiten und das Frontend-Bundle. Auch das Native Image enthält einen integrierten Servlet-Container, der die Hilla-Anwendung bereitstellt, sobald das Native Image ausgeführt wird.
Der erzeugte Production-Build kann als Native Image folgendermaßen ausgeführt werden:
./target/my-app
Native-Image im Detail
Die Erstellung eines Production-Build in Form eines Native Image erfolgt Ahead-of-Time
und unterscheidet sich in einigen Punkten von der Erstellung einer ausführbaren JAR- oder WAR-Datei:
- Zum Zeitpunkt der Erstellung des Native Image wird eine statische Code-Analyse beginnend von der
Main-Class
durchgeführt. Code, der bei dieser Analyse nicht erreicht werden kann, ist nicht Bestandteil des Native Image. - Dynamische Bestandteile im Code, wie Reflections, Serialisierung oder dynamische Proxies, müssen entsprechend gekennzeichnet werden, damit sie bei der Erstellung eines Native Image berücksichtigt werden können.
- Der Classpath der Anwendung wird bei der Erstellung festgelegt und kann später nicht mehr geändert werden.
- Alle Klassen werden bei Start des Native Image geladen. Das Nachladen von Klassen ist nicht möglich.
Die Auflistung lässt erkennen, dass Frameworks wie Hilla und Spring Boot zum Teil viel Arbeit investieren mussten, um Natives Images zu unterstützen. So zeichnen sich Anwendungen auf Basis von Spring Boot eigentlich dadurch aus, dass sie sehr dynamisch sind und dass ihre (Auto-)Konfiguration zur Laufzeit erfolgt. Die Unterstützung von Natives Images erreicht Spring Boot durch einen Prozess namens Spring Ahead-of-Time Processing
. Dieser Prozess wird zum Zeitpunkt der Erstellung einer Anwendung durchlaufen.
Ein wichtiger Bestandteil dieses Prozesses ist die Umwandlung von Teilen des Quellcodes, mit dem Ziel, dass der Quellcode besser vom GraalVM-Compiler analysiert werden kann. Dies betrifft bspw. Klassen mit der @Configuration
- oder @Bean
-Annotation. Normalerweise würde Spring diese Klassen zur Laufzeit analysieren und die erforderlichen Bean-Definitionen zur Laufzeit generieren. Für ein Native Image erfolgt dies jedoch bereits zum Zeitpunkt der Erstellung.
Ein weiterer Bestandteil des Prozesses ist die Erstellung von so genannten Hint
-Dateien. Diese JSON-Datei beinhalten zusätzlichen Informationen, die der GraalVM-Compiler nutzen kann, um den Code im Rahmen der statischen Analyse besser verstehen zu können. Diese Art von Informationen sind zum Beispiel erforderlich, wenn Spring die Java-Reflection-API verwendet, um private Methoden einer Klasse aufzurufen. Anhand einer entsprechenden Hint
-Datei kann der GraalVM-Compiler erkennen, dass die private Methode tatsächlich verwendet wird. Ohne diesen Mechanismus könnte der GraalVM-Compiler die Verwendung im Rahmen der statischen Analyse nicht erkennen und die Methode wäre somit nicht Bestandteil des Native Images.
Auch das Hilla-Framework hat dynamische Bestandteile, die dem GraalVM-Compiler bekannt gemacht werden müssen. Dafür nutzt Hilla so genannte Custom Hints. Im HillaHintsRegistrar werden so unter anderem Dateien und Klassen bekannt gemacht, die für die dynamische Generierung des Menüs in einer Hilla-Anwendung verantwortlich sind.
Fazit
Mit Hilfe des Production-Builds können Hilla-Anwendungen für das Deployment in Produktion optimal vorbereitet werden. Die Paketierung als ausführbare JAR-Datei oder als Native Image mit integriertem Servlet-Container schaffen eine sehr gute Grundlage für ein vereinfachtes Deployment.
Teil 2 der Artikelreihe befasst sich mit der Erstellung von passenden und effizienten Docker-Images, die benötigt werden, wenn die produktionsbereite Hilla-Anwendung in Container-Umgebungen ausgeliefert werden soll.