Hilla-Anwendungen in Produktion ausliefern. Teil 1: Production-Build

René Wilby | 16.09.2024 Min. Lesezeit

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:

Bundle-Size

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 die MANIFEST.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:

  1. Die Klasse Application.java muss angepasst werden. Dazu muss die Erweiterung der Klasse SpringBootServletInitializer 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);
    }
}
  1. In der Datei pom.xml muss das Format für die Paketierung von jar auf war 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>
<!-- ... -->
  1. 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 Scope provided 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 die MANIFEST.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 wird sich mit der Erstellung von passenden und effizienten Docker-Images befassen, die benötigt werden, wenn die produktionsbereite Hilla-Anwendung in Container-Umgebungen ausgeliefert werden soll.