GEDOPLAN

Moderne Geschäftsanwendungen mit Quarkus, Jakarta EE und MicroProfile entwickeln

  • Sie möchten Ihre Geschäftsanwendungen zukunftsfähig mit Hilfe von Standards entwickeln?
  • Als Laufzeitumgebung ist ein Betrieb in der Cloud angedacht?
  • Ihre Anforderungen sollten effizient in einem optimalen Entwicklungszyklus umgesetzt werden?
  • Themen wie DevOps und Integration von Bestandssystemen sind einfach realisierbar?
Dann ist Quarkus die Lösung für Ihr Softwareentwicklungsprojekt.
quarkus logo vertical 1280px default

Quarkus ist ein modernes, Cloud-natives Java-Framework, das speziell für die Entwicklung von Microservices und serverless Anwendungen entwickelt wurde.

Es eignet sich ideal für Serverless-, Cloud- und Kubernetes-Umgebungen. Es unterstützt verbreitete Java-Standards und -Bibliotheken wie Eclipse MicroProfile, RESTEasy, Hibernate, Apache Kafka, Camel und mehr. Quarkus nutzt CDI-basierte Dependency Injection und erlaubt die einfache Integration und Erweiterung über Extensions – ähnlich wie bei klassischen Java-Dependencies. Zudem bietet es Unterstützung für GraalVM zur nativen Kompilierung.

Quarkus leicht gemacht – Ihre Vorteile auf einen Blick

Quarkus setzt auf Standards
  • Java-Apps basieren meist auf Jakarta EE
  • Quarkus erweitert dies mit MicroProfile
Sehr großes Ökosystem
  • Quarkus integriert nahezu jede Java-Technologie

Entwicklerfreundlichkeit
  • Live-Reloading während der Entwicklung
  • Eingebaute Dev-Services für Datenbanken, Message Brokers etc.
  • Umfangreiche CLI-Tools
Cloud-Native
  • Kubernetes-ready out of the box
  • Unterstützung für Health Checks, Metrics, Tracing
  • Optimiert für Container-Umgebungen
Performanz
  • Extrem schnelle Startzeiten (oft unter 1 Sekunde)
  • Geringer Speicherverbrauch
  • Optimiert für Container und Kubernetes
Quarkus Seite3 png
Quarkus Seite2 png

Was bieten wir rund um Quarkus?

Wir analysieren Ihre bestehenden IT-Strukturen, beraten Sie auf dem Weg zu Quarkus und bieten Ihnen Schritt-für-Schritt-Unterstützung – von der Bewertung bis zur Umsetzung. Profitieren Sie dabei von über 20 Jahren Erfahrung in der Softwareentwicklung mit Java und Jakarta EE.

Gerne führen wir mit Ihnen erste Beratungsgespräche unverbindlich durch und besprechen Ihr Vorhaben. Planen Sie die Entwicklung einer Quarkus-Anwendung oder die Migration zu Quarkus? Wir verfügen über umfangreiche Erfahrung in der Entwicklung von Quarkus-Anwendungen und geben dieses Wissen gerne an Sie weiter.

Wir bieten Ihnen individuell zugeschnittene Workshops an. Dabei können wir beispielsweise bestehenden Code in Teilen nach Quarkus migrieren und analysieren. Der Inhalt richtet sich nach Ihren konkreten Wünschen und Zielen.

Wir entwickeln maßgeschneiderte Softwarelösungen, die exakt auf Ihre Ziele und Anforderungen abgestimmt sind. Unsere Teams setzen dabei auf Transparenz und enge Zusammenarbeit: Sie erhalten jederzeit Einblick in den Entwicklungsprozess und das nötige Know-how, um Ihre Quarkus-Anwendung später eigenständig weiterzuentwickeln und zu pflegen. Für das Frontend setzen wir bevorzugt auf Angular. Lassen Sie uns gemeinsam Ihre Anwendung realisieren.

Wir unterstützen Sie mit erfahrenen Experten und Expertinnen, damit Ihr Quarkus-Projekt schneller umgesetzt werden kann. Profitieren Sie vom Know-how und den Erfahrungen unserer Spezialisten und Spezialistinnen – ganz gleich, welche Rolle unsere Mitarbeitende in Ihrem Projekt übernehmen. So tragen wir gezielt zu Ihrem Projekterfolg bei. Hier finden Sie weitere Informationen über unsere IT-Experten und -Expertinnen.

Wir migrieren Ihre Java-, Jakarta EE- oder Spring-Boot-Anwendung zu Quarkus. In einem ersten Workshop analysieren wir Ihre Anwendung und prüfen die Möglichkeiten einer Migration. Anschließend erstellen wir ein Strategiepapier für eine erfolgreiche Umstellung. Gerne unterstützen wir Sie auch mit zusätzlichen Experten und Expertinnen bei der Umsetzung.

Ein Einblick in eines unserer Quarkus Projekte

In den letzten Jahren haben wir verschiedenste Quarkus-Projekte begleitet. Hier finden Sie einen Einblick in unser zuletzt umgesetztes Projekt.

Modernisierung der Tarifrechner

Ein bundesweit tätiger Anbieter privater Krankenversicherungen stand vor der Aufgabe, seine Tarifrechner zu modernisieren. Die bestehende Lösung war technologisch veraltet, langsam und schwer erweiterbar. Ziel war eine zukunftssichere Neuentwicklung mit besserer Performance und höherer Benutzerfreundlichkeit.

Die Lösung bestand in der kompletten Neuimplementierung: Die Tarifrechner wurden auf Basis moderner Technologien entwickelt, als Microservices umgesetzt und in die bestehenden Systeme integriert.

Analyse - GEDOPLAN

Ausgangssituation

  • Lange Reaktionszeiten

  • Veraltete Technologien

  • Fehlendes Know-how im Entwicklerteam

Lösung

  • Frontend mit PrimeFaces

  • Microservices mit Quarkus

  • Schnittstellen zu Bestandssystemen

  • Automatisierte PDF-Erstellung

Ergebnis

  • Erste Rechner produktiv im Einsatz

  • Restliche Systeme folgen sukzessive

  • Performance und Usability deutlich verbessert

Haben Sie Interesse an Quarkus?

Vereinbaren Sie einen unverbindlichen Beratungstermin mit einem unserer Quarkus-Experten:

Website Grafik14

Dirk Weil ist Geschäftsführer der GEDOPLAN GmbH und seit 1998 als Java-Berater tätig, mit Schwerpunkt auf Unternehmenslösungen mit Java EE. Er ist Fachautor, Referent und bietet Schulungen auf Basis eines eigenen Java-Curriculums an.

Website Grafik15

Markus Pauer ist Geschäftsführer der GEDOPLAN GmbH und Leiter des Bereichs Beratung und Softwareentwicklung mit über 20 Jahren Erfahrung in der Jakarta EE Front- und Backendentwicklung. Er verfolgt aktuelle Trends und blickt gerne über den Tellerrand hinaus.

Demo-Projekt

Neben der Unterstützung von Jakarta EE und Microprofile bietet Quarkus sehr viele Erweiterungsmöglichkeiten an. Im Demo Projekt haben wir versucht einige von diesen Extensions anhand kleiner Beispielanwendungen vorzustellen.

Das Demo Projekt finden Sie auf Github: https://github.com/GEDOPLAN/quarkus-demo

github invertocat logo

Unsere Kurse zum Thema Quarkus

Aus unseren IT-Projekten heraus entwickelt, bieten wir Schulungen mit hohem Praxisanteil, damit das Gelernte gefestigt und direkt angewendet werden kann. Wir bieten Ihnen folgende Quarkus-Schulungen an:

Quarkus kompakt –
Jakarta EE mit MicroProfile kombinieren, um Microservices und verteilte Systeme für Quarkus zu entwickeln.
  • Ergänzung von JEE-Anwendungen um MicroProfile-Bausteine
  • Entwicklung von Services mit Quarkus
  • Quarkus und Docker

> Zur Kursseite

Quarkus Power Workshop –
Moderne Services entwickeln, testen, überwachen.
  • Architektur: Microservice und Monolith
  • Standards: Jakarta EE (aka Java EE), MicroProfile
  • Cloud Readiness: Config, Health, Metrics, Tracing, Logging
  • Kommunikation: REST, Messaging
  • Konsistenz: LRA (aka Saga)
  • Security: JWT, OIDC

> Zur Kursseite

Sie können an einem offenen Termin teilnehmen oder für Ihr Team eine individuelle Firmenschulung anfragen. Nach der Schulung stehen wir für Fragen oder Problemen im Alltag jederzeit zur Verfügung.

Unsere kostenfreien Vorträge zu Quarkus

Der Online-Vortrag dauert ca. 60 Minuten, anschließend steht der Referent für Ihre Fragen zur Verfügung. Die Teilnahme ist kostenlos und die Teilnehmerzahl nicht begrenzt.

Vorstellung des Java Frameworks Quarkus

Wir stellen Quarkus vor u.a. mit folgenden Punkten: Project Bootstrap, Running Quarkus application, JPA (Jakarta/Java Persistence API), Hibernate, DB Extensions, Integration Tests, Continuous Testing, Native Mode, Zahlen bitte! und Quarkus vs. Spring Boot.

Battle der Enterprise Frameworks: Quarkus vs. Spring Boot

Spring Boot ist weit verbreitet und für viele „die Plattform für Server-Anwendungen und Microservices. Das jüngere Quarkus nimmt für sich in Anspruch, die Basis für ultra-schnelle Anwendungen mit kleinem Footprint zu sein. Beide Frameworks haben ein beeindruckendes Ökosystem und versprechen beste Developer Experience. In diesem Talk wird eine zwar überschaubare, aber realistische Aufgabe mit beiden Frameworks gelöst. Wir vergleichen den Code und schauen, wo Gemeinsamkeiten sind und wo die Frameworks die Nase vorn haben.

Vaadin Flow mit Quarkus

Die Webanwendungsentwicklung hat sich in den letzten Jahren stark verändert. Die JavaScript-basierten Frameworks sind bei einer Neuentwicklung meist die erste Wahl. Aber was tun, wenn ich nicht in ein anderes Ökosystem oder eine andere Programmiersprache wechseln möchte? Wenn ich Jakarta Faces nicht einsetzen kann oder will? Mit Vaadin Flow kann ich meine gesamte Oberfläche in Java entwickeln, ohne jegliche Kenntnisse von HTML, CSS oder JavaScript zu haben. Ich kann sie ähnlich wie Desktop-Oberflächen per Komponenten zusammenstellen.

In diesem Vortrag werde ich zeigen, wie ich Vaadin-Flow als Weboberfläche in einer Quarkus-Anwendung einsetzen kann. Anhand von Beispielen zeige ich einige Möglichkeiten mit den Oberflächenkomponenten zu arbeiten.

Keine Transaktionen – und jetzt? Eventual consistency mit MicroProfile LRA

In monolithischen Anwendungen lässt sich die Konsistenz der Daten in der Regel mit Hilfe von Transaktionen sicherstellen. Teilt man die Anwendungen nun in mehrere (Micro-) Services, stehen übergreifende Transaktionen nur in Ausnahmefällen zur Verfügung. Um dennoch eine Konsistenz der Systemzustände und Daten zu erreichen, wurden verschiedene Patterns entwickelt, u. a. das Saga Pattern, bei dem Kompensationsaktionen registriert werden können, die im Fehler- oder Abbruchfall ausgeführt werden. Mit MicroProfile LRA (Long Running Actions) steht nun eine standardisierte Variante des Patterns zur Verfügung, die neben den erwähnten Komponsationsaktionen auch Abschlussaktionen im Erfolgsfall anbietet. In diesem Talk schauen wir auf diese Spezifikation und implementieren damit ein kleines verteiltes System mit Quarkus und Jakarta EE. Wie immer: einige Slides, viel Code.

Eigene Quarkus Extensions nutzen

Quarkus ist ein Java Runtime Framework, dass auf den Jakarta EE Standards aufbaut. Dennoch macht es Einiges in Bezug auf Context and Dependency Injection und Ressourcenoptimierungen anders. Aus diesem Grund wurde die CDI-Spezifikation angepasst und CDI-Lite eingeführt. In diesem Zusammenhang sind auch die Build compatible extensions entstanden, die es ermöglichen zur Build-Zeit bereits Beans zu registrieren und Konfigurationen zu verwenden. Auch in Quarkus können diese BCEs verwendet werden. Diese reichen in Quarkus allerdings meist nicht aus. Quarkus selbst ist auf der Grundlage von Erweiterungen entwickelt worden. Diese erweitern die Funktionalitäten entsprechend und sorgen für die Optimierung von Ressourcen gerade in Bezug auf die native Compilierung. Wann macht es Sinn eigene Erweiterungen zu erstellen? Wie funktionieren die Mechanismen innerhalb dieser Erweiterungen. Anhand eines praktischen Beispiels möchte ich euch diese vorstellen und zeigen, dass der Weg zu einer eigenen Erweiterung gar nicht so steil ist.

Gerne halten wir die Vorträge für Sie. Schicken Sie uns eine Mail mit Ihrem Wunschtermin.

Unsere Quarkus Blog-Artikel

Ich bin es leid, mich für jede Anwendung wieder und wieder anmelden zu müssen. Wenn Sie diesen Satz auch schon gesagt haben, brauchen Sie Single Sign-on. Und wer „A“ sagt, muss auch „B“ sagen, d. h. Sie brauchen auch Single Log-out. Im Folgenden versuche ich einen kurzen Überblick, wie SSO und SLO für Jakarta-EE- und Quarkus-Anwendungen implementiert werden können, wobei wir als zentralen Identity Provider Keycloak nutzen.

Die Idee, das „Single“ zu implementieren, führt zur Nutzung eines zentralen IdP – Identity Provider -, der die eigentlichen An- und Abmeldevorgänge durchführt. Es gibt verschiedene Standards dafür. Wir nutzen hier OpenID Connect mit einem Keycloak als IdP. Es würde den Rahmen sprengen, Keycloak umfassend zu beschreiben, daher hier nur zwei seiner Konzepte:

  • Ein Realm ist ein abgegrenzter Bereich, in dem Keycloak User, Passwörter, Rollen etc. ablegt. Alle Anwendungen, die von SSO profitieren sollen, müssen den gleichen Realm referenzieren.
  • Ein Client wird in einem Realm angelegt und repräsentiert eine Anwendung. I. a. benötigt man für jede Anwendung einen Client.

Die Anwendungen sind Web-Anwendungen, d. h. sie können die zugehörigen Konzepte wie URLs, Query Parameter, Header, Cookies und Sessions nutzen. „Single“ setzt voraus, dass die Anwendungen aus einem Browser heraus bedient werden. Das Anmeldeverfahren verläuft bei diesen Anwendungen regelmäßig im sog. Authorization Code Flow, der stichwortartig so funktioniert:

  • Der Anwender navigiert im Browser auf einen Teil der Anwendung, der eine Anmeldung erforderlich macht.
  • Es erfolgt ein Redirect zum Keycloak, der einen Anmeldedialog durchführt.
  • Nach erfolgreicher Anmeldung erfolgt ein Redirect zurück zur Anwendung, wobei ein einmalig und kurzzeitig nutzbarer Authorization Code mitgegeben wird.
  • Die Anwendung wendet sich an den sog. Token Endpoint des IdP und tauscht den Code gegen Identity, Access und Refresh Tokens aus. ID und Access Tokens haben einer vergleichsweise kurze Gültigkeit (einige Minuten). Sie können mit Hilfe des Refresh Tokens erneuert werden. Die genaue Nutzung der Tokens ist im Folgenden nicht von Belang – üblicherweise nutzt eine Anwendung das ID Token für Informationen zum angemeldeten Nutzer und das Access Token als Berechtigungsstruktur.
  • In Keycloak-spezifischen Cookies merkt sich Keycloak die validierte Identität des Benutzers (unabhängig vom ID Token der Anwendung). Sollte im aktuellen Browser später eine neue Authentifizierung notwendig sein – weil die Token abgelaufen sind oder weil eine neue Anwendung im Verbund genutzt wird -, kann der Keycloak den Nutzer ohne erneuten Anmeldedialog anmelden.

Für Jakarta-EE-10-Anwendungen kann eine CDI Bean im Application Scope zur Konfiguration der Anbindung an den IdP genutzt werden. Dazu stellt die Teilspezifikation Jakarta Security die Annotation @OpenIdAuthenticationMechanismDefinition zur Verfügung:

01
02
03
04
05
06
07
08
09
10
@ApplicationScoped
@OpenIdAuthenticationMechanismDefinition(
  clientId = "jakarta-oidc-faces",
  clientSecret = "insert-password-here",
  redirectURI = "${baseURL}/post-login",
  logout = @LogoutDefinition(redirectURI = "/post-logout")
)
public class SecurityConfig {
}

Die providerURI ist die Adresse des Realms im Keycloak, clientId und clientSecret sind die Anmeldeinformationen für den Client der Anwendung im Keycloak.

Etwas komplizierter wird es mit der redirectURI: Sie ist die Anwendungs-interne Adresse eines Servlets, das den o. a. Code-zu-Token-Tausch durchführt und danach auf die ursprüngliche Seite der Anwendung weiterleitet. Während das Token Handling im Standard bereits erledigt wird (resultierend in einem injizierbaren Objekt des Typs OpenIdContext, muss man den Redirect zur Ursprungsseite noch implementieren:

01
02
03
04
05
06
07
08
09
10
11
@WebServlet("/post-login")
public class PostLoginServlet extends HttpServlet {
  @Inject
  private OpenIdContext openIdContext;
  protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    String postLoginUri = this.openIdContext
          .getStoredValue(request, response, OpenIdConstant.ORIGINAL_REQUEST)
          .orElse("/");
    response.sendRedirect(postLoginUri);

Noch komplizierter wird es für den Single Log-out. Hier ist ja die Idee, dass eine Abmeldung in einer Anwendung dazu führt, dass man auch in den anderen Anwendungen des SSO-Verbundes abgemeldet wird. Um dies zu erreichen, gibt es – mal wieder – verschiedene Verfahren. Wir wählen hier den sog. Backchannel Log-out, bei dem ein passender Endpunkt der Anwendung vom Keycloak aus aufgerufen werden kann, um eine Nutzerabmeldung in der Anwendung auszulösen. „Backchannel“ deshalb, weil der Aufruf vom Keycloak direkt auf die Anwendung erfolgt und nicht über den Browser – das wäre der „Frontchannel“. Die Backchannel Logout URL muss im Keycloak im entsprechenden Client konfiguriert werden.

Ein Log-out wird durchgeführt, indem eine der Anwendungen den Logout Endpoint des Keycloak aufruft. Alternativ kann man auch im Keycloak UI eine Session beenden. Keycloak ruft dann für jeden betroffenen Client dessen Backchannel Logout URL auf und übermittelt ein Logout Token. Im aufgerufenen Endpunkt müssen dann diese Schritte durchgeführt werden:

  • Das Logout Token wird auf Gültigkeit überprüft. Dabei wird sichergestellt, dass das Token tatsächlich vom Keycloak kam, den richtigen Client betrifft und noch nicht abgelaufen ist.
  • Im Logout Token ist eine SSO Session ID enthalten. Die zu ihr gehörenden Browser Sessions werden nun invalidiert.
    Um die Zuordnung der SSO Session zu den Browser Sessions zu ermöglichen, muss man sich im oben gezeigten Login Servlet eine entsprechende Lookup-Tabelle aufbauen.

Aus Platzgründen kann der komplette Code des Backchannel Logout Servlets hier nicht aufgeführt werden, daher nur andeutungsweise:

01
02
03
04
05
06
07
08
09
10
11
12
13
@WebServlet("/backchannel-logout")
public class BackchannelLogoutServlet extends HttpServlet {
  @Inject
  OpenIdContext openIdContext;
  protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    String logoutTokenString = request.getParameter("logout_token");
    var claims = parseAndValidateToken(logoutTokenString);
    String oidcSessionId = (String) claims.getClaim("sid");
    // ... suche HttpSession zur oidcSession und invalidiere sie ...
    response.setStatus(204);

Durch @OpenIdAuthenticationMechanismDefinition ist es also möglich geworden, JEE-Anwendungen an einen OpenID Connect Provider anzubinden, ohne die Konfiguration des genutzten Application Servers ändern zu müssen. Es bleibt aber doch noch eine Menge selbst zu implementieren. Das stellt sich für Quarkus-Anwendungen einfacher dar: Nach Einbinden der Extension io.quarkus:quarkus-oidc muss man „nur“ einige Properties setzen. Eine speziell annotierte CDI Bean wird nicht benötigt. Folgende Properties müssen gesetzt werden:

01
02
03
04
05
06
07
08
09
10
11
12
quarkus.oidc.auth-server-url=http://localhost:9090/realms/GEDOPLAN
quarkus.oidc.client-id=quarkus-oidc-faces
quarkus.oidc.credentials.secret=insert-password-here
quarkus.oidc.authentication.redirect-path=/post-login # (1)
quarkus.oidc.authentication.restore-path-after-redirect=true
quarkus.oidc.logout.path=/logout # (2)
quarkus.oidc.logout.post-logout-path=/index.xhtml
quarkus.oidc.logout.backchannel.path=/backchannel-logout # (3)

Die ersten Konfigurationswerte sind von oben bereits bekannt. Bemerkenswert sind die mit # (n) markierten URIs. Diese müssen nämlich nur deklariert werden und Quarkus (bzw. das Build Time Plugin) übernimmt die Implementierung der Endpunkte:

  1. Für den Redirect nach dem erfolgreichen Login im Keycloak wird ein Endpunkt (hier unter /post-login) bereitgestellt, der den Code-zu-Token-Tausch ausführt und zur ursprünglichen Anwendungsseite weiterleitet.
  2. Die Anwendung kann den angegebenen Logout Path (hier /logout) für eine Abmeldung aufrufen. Das führt dann automatisch zum Aufruf des Logout Endpoints des Keycloak.
  3. Die oben beschriebene Behandlung eines Backchannel Logouts implementiert Quarkus auch vollständig selbst – inkl. dem Handling der OIDC und HTTP Sessions.

Es konnten hier nicht alle Konfigurations-Properties gezeigt werden. Sie finden in https://quarkus.io/guides/all-config eine komplette Auflistung – filtern Sie nach quarkus.oidc. Aber Achtung: Die Liste ist lang …

Dieser Blog Post ist schon (zu?) lang und dennoch konnten nicht alle Details gezeigt werden. Kontaktieren Sie uns gerne, wenn Sie Fragen haben!

Quarkus ist ein Java Runtime Framework, dass auf den Jakarta EE Standards aufbaut. Dennoch macht es Einiges in Bezug auf Context and Dependency Injection und Ressourcenoptimierungen anders. Aus diesem Grund wurde die CDI-Spezifikation angepasst und CDI-Lite eingeführt. In diesem Zusammenhang sind auch die Build compatible extensions entstanden, die es ermöglichen zur Build-Zeit bereits Beans zu registrieren und Konfigurationen zu verwenden. Auch in Quarkus können diese BCEs verwendet werden. Diese reichen in Quarkus allerdings meist nicht aus. Quarkus selbst ist auf der Grundlage von Erweiterungen entwickelt worden. Diese erweitern die Funktionalitäten entsprechend und sorgen für die Optimierung von Ressourcen gerade in Bezug auf die native Compilierung. Wann macht es Sinn eigene Erweiterungen zu erstellen? Wie funktionieren die Mechanismen innerhalb dieser Erweiterungen. Anhand eines praktischen Beispiels möchte ich euch diese vorstellen und zeigen, dass der Weg zu einer eigenen Erweiterung gar nicht so steil ist.

Die Webanwendungsentwicklung hat sich in den letzten Jahren stark verändert. Die Javascript-basierten Frameworks sind bei einer Neuentwicklung meist die erste Wahl. Obwohl sich damit gestandene Java-Entwickler aus ihrem Ökosystem entfernen wird immer wieder argumentiert, dass es ja „nur“ eine andere Programmiersprache ist, die zum Einsatz kommt. Ich persönlich bin der Meinung, dass bei Verwendung eines Java-basierten Backends eigentlich Jakarta Faces die bessere Wahl für eine Webanwendung wäre. Aber das sei hier an dieser Stelle nur am Rande erwähnt.

Was mache ich aber, wenn ich mit meinem Java-Entwicklerteam eine moderne Webanwendung entwickeln möchte Jakarta Faces nicht einsetzen möchte und auf lange Weiterbildungen meiner Entwickler verzichten will? Dann ist die Antwort: Schaue dir doch mal Vaadin an.

Mit Vaadin Flow kann ich meine gesamte Oberfläche in Java entwickeln, ohne jegliche Kenntnisse von HTML, CSS oder Javascript zu haben. Ich kann sie ähnlich wie Desktop-Oberfläche per Komponenten zusammenstellen. Der Client wird dann anschließend auf dem Server erstellt und an den Browser weitergereicht. Die Kommunikation zwischen der Oberfläche und dem Server wird durch Vaadin gesteuert. Starten wir also mit einer einfachen Komponente.

1
2
3
4
5
6
7
public class CustomerDetailView extends HorizontalLayout {
  private TextField firstName = new TextField("First name");
  ...
  public CustomerDetailView() {
    add(firstName, ...);
  }
}

Wir nutzen das horizontale Layout von Komponenten, um Elemente, wie bspw. ein Eingabefeld anzeigen zu lassen. Damit die Eingaben gespeichert werden können verwenden wir eine Entität, die wir später auch in der Datenbank ablegen können.

01
02
03
04
05
06
07
08
09
10
11
@Data
@Entity
public class Customer {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  private String firstName;
  private String lastName;
  private LocalDate dateOfBirth;
  private Set<Interest> interests;
}

Als nächstes müssen wir unsere Java-Daten-Klasse mit der Oberfläche verbinden. Auch hierfür hat Vaadin Flow eine elegante Lösung. Das sogenannte Bean-Binding erledigt diese Aufgabe fast wie von selbst.

01
02
03
04
05
06
07
08
09
10
public class CustomerDetailView extends HorizontalLayout {
  private TextField firstName = new TextField("First name");
  ...
  private Binder<Customer> binder = new Binder<>(Customer.class);
  ...
  public CustomerDetailView() {
    binder.bindInstanceFields(this);
    ...
  }
}

Der Binder sorgt dafür, dass alle Attribute meiner Entität mit den Komponenten verbunden werden. Dazu müssen die Variablen für die Komponenten genauso benannt werden, wie die Attribute meiner Entität. Die hier vorgestellte Methode ist natürlich nicht die einzige Möglichkeit Daten mit den Komponenten zu verbinden. Es ist ebenso möglich die Zuordnung manuell zu machen.

Anschließend benötigen wir noch eine Aktionskomponente, damit wir die Eingaben auch verarbeiten lassen können. Zusätzlich benötigen wir noch einen Handler, der die Aktionen ausführt.

1
2
3
4
5
6
7
8
@PostConstruct
void init() {
  saveButton.addClickListener(event -> {
    customerService.create(binder.getBean());
    customerCreatedEvent.fire(binder.getBean());
    ...
  });
}

Wir übergeben hier die über den Binder verbundene Bean-Instanz an den Service. Dieser speichert die Daten über eine Repository-Klasse in der Datenbank. Anschließend feuern wir ein CDI-Event um weiteren Komponenten das Erstellen eines neuen Datensatzes anzukündigen.

1
2
3
void refresh(@Observes @Created Customer customer) {
  setItems(customerService.listAll());
}

Die empfangende Komponente beobachtet das CDI-Event und kann dann wie in unserem Beispiel die Liste der Kunden aktualisieren. Damit haben wir eine lose Kopplung unserer Komponenten erreicht.

Ich hoffe ich konnte mit diesem kleinen Beispiel zeigen, das Vaadin Flow durchaus eine Alternative zu Javascript-Frameworks oder auch Jakarta Faces darstellen kann, wenn man als Entwickler auf den Komfort des Java-Ökosystems nicht verzichten möchte. Das komplette Beispiel findet ihr wie immer in GitHub: https://github.com/GEDOPLAN/quarkus-vaadin-demo

Quarkus basiert in seinem Kern lediglich auf einem einfachen Komponentensystem. Jede weitere Funktionalität ist in einer eigenen Erweiterung gekapselt, die bei Bedarf hinzugefügt werden kann. Dies ermöglicht es Entwicklern, nur die benötigten Komponenten in ihre Anwendungen zu integrieren, was zu schlankeren und effizienteren Anwendungen führt.

Um eine eigene Quarkus Extension zu erstellen, können Sie das Maven-Archetype für Quarkus Extensions verwenden. Dieses Archetype stellt die grundlegende Struktur für die Erweiterung bereit.

1
2
3
4
5
$ mvn io.quarkus.platform:quarkus-maven-plugin:3.29.4:create-extension \
  -N \
  -DgroupId=de.gedoplan.showcase \
  -DextensionId=central-registry-extension \
  -DwithoutTests

Das Projekt besteht aus einem Deployment- und einem Runtime-Modul.

quarkus extensions 1 png

Das Runtime-Modul (central-registry-extension) ist eine ganz normale Quarkus Erweiterung und kann entsprechend weitere Erweiterungen beinhalten. Das Deployment-Modul (central-registry-extension-deployment) hat eine Abhängigkeit zum Runtime-Modul und kann ebenfalls weitere Erweiterungen enthalten.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
<dependency>
  <groupId>de.gedoplan.showcase</groupId>
  <artifactId>central-registry-extension</artifactId>
  <version>${project.version}</version>
</dependency>
<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-arc-deployment</artifactId>
</dependency>
<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-undertow-deployment</artifactId>
</dependency>

Quarkus wurde mit der Idee geschaffen die Speicher- und Ressourcennutzung der Anwendung zu verringern und damit auch einen möglichst schnellen Start der Anwendung zu ermöglichen. Daher wird in einer Quarkus Anwendung möglichst viel bereits zur Build-Zeit erledigt. In dieser Weise funktionieren auch die Quarkus Extensions. Im Deployment-Modul gibt es sogenannte Build-Schritte, in denen man sich in den Build-Prozess einklinken kann. In ihnen werden sogenannte Build-Items erzeugt, die in einen hocheffizienten Bytecode umgewandelt werden.

Möchte man beispielsweise eigene CDI-Beans integrieren, so kann man das über das AdditionalBeanBuildItem erreichen.

1
2
3
4
5
6
7
8
@BuildStep
AdditionalBeanBuildItem registerStartupListener() {
  return AdditionalBeanBuildItem.builder()
      .addBeanClass(CentralRegistryClient.class)
      .addBeanClass(StartupListener.class)
      .setUnremovable()
      .build();
}

Diese zusätzlichen Beans sind wiederum im Runtime-Modul implementiert. Warum macht man sich dann die Mühe diese Beans über eine Extension einzubinden? Das Ganze würde ja auch funktionieren, wenn man statt einer Extension einfach ein Jar mit diesen Beans in seine Anwendung einbindet. Die Antwort lautet ja, aber dadurch erreicht man nicht die zuvor beschriebene Optimierung während der Build-Zeit. Quarkus kann durch die Implementierung einer Extension entsprechenden Bytecode bereitstellen, der eine optimale Integration meiner Implementierung ermöglicht.

Da eine tiefer gehende Erklärung der Mechanismen innerhalb von Quarkus den Rahmen dieses Beitrags sprechen würden, habe ich ein ausführliches Beispiel einer Quarkus Extension auf Github bereitgestellt: https://github.com/GEDOPLAN/quarkus-custom-extensions

Wenn man Tests für Quarkus-Anwendungen schreibt, bedient man sich in der Regel des Unit Test Frameworks JUnit und der Extension @QuarkusTest.

Leider verwendet JUnit für sein internes Logging JUL (d. h. die Klassen aus der Standardbibliothek im Paket java.util.logging), was meiner Meinung nach nicht gerade das beste Logging-System ist. Das von Quarkus angebotene JBoss Logging hat ein deutlich leistungsfähigeres API und verfügt schon im Default über ein besseres Ausgabeformat. Darüber hinaus kann man dieses Logging Backend mit Quarkus Properties konfigurieren. Es ist also wünschenswert, dass der Test Code sein Protokoll auch nach JBoss Logging dirigiert.

Dies gelingt durch Setzen der System Property java.util.logging.manager auf den Wert org.jboss.logmanager.LogManager. Aber Achtung: Das muss vor der ersten Log-Ausgabe geschehen, da man den Logging Manager von JUL später nicht mehr umschalten kann. In Maven-Projekten setzt man die Property am besten in der Konfiguration der Test-Plugins:

1
2
3
4
5
6
7
<plugin>
  <artifactId>maven-surefire-plugin</artifactId>
  <configuration>
    <systemPropertyVariables>
      <java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
    </systemPropertyVariables>
  </configuration>

(analog für das Failsafe Plugin)

Für die Ausführung der Tests auf der Kommandozeile (mvn test ...) oder in IntelliJ IDEA war’s das: Die Log-Ausgaben erscheinen nun im gewohnten JBoss-Logging-Format.

Wer als IDE Visual Studio Code benutzt, muss etwas mehr tun, da die Maven-Einstellung von der IDE nicht berücksichtigt wird (Stand Oktober 2025). Man kann die System Property hier in der Datei .vscode/settings.json setzen (Erreichbar über Ctrl-P -> Preferences: Open Workspace Settings (JSON)):

1
2
3
4
{
  ...,
  "java.test.config": {"vmArgs": ["-Djava.util.logging.manager=org.jboss.logmanager.LogManager"]}
}

Häufig wird die UI unseren Jakarta EE Anwendungen als Single-Page-Application (SPA) realisiert. Beim Build kann uns z.B. das frontend-maven-plugin helfen, aber bei der Entwicklung verwenden wir häufig separate Entwicklungsserver und eine Proxy-Konfiguration. Quinoa integriert die SPA zur Entwicklungs- und Laufzeit mit wenig Konfigurationsaufwand in unsere Quarkus-Anwendung und bringt optional eine eigene NodeJS Installation mit.

Quinoa ist eine Quarkus Extension und kann zunächst ganz einfach als Dependency in unser Maven-Projekt hinzugefügt werden.

<dependency>
  <groupId>io.quarkiverse.quinoa</groupId>
  <artifactId>quarkus-quinoa</artifactId>
  <version>2.6.2</version>
</dependency>

Standardmäßig erwartet Quinoa die Web-Anwendung im Projektpfad src/main/webui, der Pfad ist aber konfigurierbar und kann auch außerhalb des Maven-Projekts liegen. Wir schauen uns im folgenden als Beispiel eine kleine Anwendung an, die Informationen über die Planeten unseres Sonnensystems darstellt.

Die Backend-Anwendung ist in diesem Fall sehr simpel gehalten und liefert nur zwei REST-Endpunkte um alle eingetragenen Planeten auszugeben. Die REST-Schnittstellen sind mit quarkus.rest.path konfiguriert unter dem Pfad /api/* erreichbar.

Das Web-Frontend ist eine Angular-Anwendung, die einfach mit NPM installiert, gestartet und gebaut werden kann. Wenn lokal keine NodeJS oder NPM Installation vorliegt, kann Quinoa konfiguriert werden eine entsprechende Installation bereitzustellen und zu nutzen.

quarkus.quinoa.package-manager-install=true
quarkus.quinoa.package-manager-install.node-version=22.20.0
quarkus.quinoa.package-manager-install.npm-version=11.6.2

Ein Vorteil von Quinoa ist nun die Bereitstellung der Frontend-Anwendung im Quarkus Dev-Modus. Wenn wir die Anwendung zur Entwicklung mit mvn quarkus:dev starten wird automatisch auch ein Entwicklungsserver für Bereitstellung der Frontend-Anwendung gestartet. Wenn wir auf die Quarkus-Anwendung zugreifen erreichen wir aber nun, anders als mit dem frontend-maven-plugin, unter http://localhost:8080/ keine statische gebaute Variante der Frontend-Anwendung. Die Anfragen werden von Quinoa an den Entwicklungsserver weitergeleitet und wir haben dieselben Live-Coding Vorteile für Front- und Backend-Anteile.

Wenn wir Front- und Backend wie in diesem Fall zusammen ausliefern gibt es noch das Problem, dass die Single-Page-Application nur unter index.html aufgerufen wird. Versuchen wir nun direkt über den Adresspfad auf Frontend-Routen zuzugreifen werden diese vom Backend nicht gefunden und wir bekommen eine Fehlermeldung. Üblicherweise definieren wir für diesen Fall eine Weiterleitung von Pfaden die vom Backend nicht behandelt werden, aber auch hier kann uns Quinoa behilflich sein. Mit der Einstellung quarkus.quinoa.enable-spa-routing=true weisen wir Quinoa an eine entsprechende Behandlung der Routen zu aktivieren.

Die Anwendung läuft nicht nur im Dev-Modus, sondern kann auch ganz normal über Maven gebaut werden. Dabei wird ein Build der Angular Anwendung von Quinoa an der passenden Stelle eingefügt um die gebaute SPA beim Paketieren mit zu berücksichtigen und als Ressource einzufügen.

Für den Zugriff auf die im Backend definierten REST-Ressourcen im Frontend nutzen wir TypeScript-Services, die auf Basis einer OpenAPI-Definition automatisch generiert werden. Die OpenAPI-Definition selbst wird beim Bauen des Backends von der quarkus-smallrye-openapi Extension auf Basis der in der Anwendung definierten (kompilierten) REST-Ressourcen erstellt. Hier muss also zunächst das Backend kompiliert, dann die OpenAPI Dokumentation erstellt werden um daraufhin vor dem Build der Angular-Anwendung die Schnittstellen-Services generieren zu können. Das Paketieren der Anwendung darf allerdings erst geschehen, wenn auch das Frontend gebaut ist um die Ressourcen an entsprechender Stelle einzufügen. Hier sorgt Quinoa standardmäßig bereits dafür, dass der Frontend-Build an der richtigen Stelle eingefügt wird und die OpenAPI-Definition vorher bereit steht.

Das Beispiel gibt es zum Ausprobieren natürlich wie immer auch auf GitHub.

Im heutigen Blog-Beitrag möchte ich gerne mit der Frage starten, warum wir in den meisten neuen Projekten, in denen eine Weboberfläche entwickelt werden soll, zunächst an die verschiedenen Javascript-Frameworks denken? Sollte ich als gestandener Java-Entwickler nicht eine Lösung in meinem Ökosystem bevorzugen?

Sicherlich spielen in den meisten Architekturentscheidungen nicht nur solche Überlegungen eine Rolle. Dennoch möchte ich zeigen, wie man mit aktuellen Enterprise-Frameworks (Quarkus), ansehnliche Weboberflächen mit Jakarta Faces und Primefaces erstellen kann.

Wir starten mit einer einfachen Quarkus-Anwendung. Die einzige Abhängigkeit, die ich zusätzlich benötige, ist die Primefaces-Extension aus dem Quarkiverse.

1
2
3
4
5
<dependency>
    <groupId>io.quarkiverse.primefaces</groupId>
    <artifactId>quarkus-primefaces</artifactId>
    <version>3.15.7</version>
</dependency>

Mit dieser Abhängigkeit kommt ebenfalls eine Jakarta Faces Implementierung mit in unser Projekt. In diesem Fall die Apache MyFaces Implementierung in der Version 4.1.1. Sie beinhaltet also den Standard 4.1 der Faces Spezifikation und ist damit kompatibel zu JakartaEE 11.

Eine weitere Konfiguration der Anwendung ist nicht notwendig. Primefaces verwendet eine automatische Konfiguration der Webanwendung, daher ist keine web.xml notwendig aber selbstverständlich für weitere Konfigurationen erlaubt. Diese Datei wird wie üblich im META-INF-Verzeichnis untergebracht. Quarkus verwendet standardmäßig das resources-Verzeichnis unterhalb vom META-INF-Verzeichnis für die Auslieferung der „statischen“ Inhalte. Wir können in diesem auch unsere xhtml-Dateien für die Faces-Anwendung ablegen.

meta inf png
01
02
03
04
05
06
07
08
09
10
11
12
13
14
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html
        PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
      xmlns:h="jakarta.faces.html">
    <h:head>
        <meta name="viewport" content="width=device-width, initial-scale=1"/>
        <title>Modern Faces App</title>
    </h:head>
    <h:body>
        ...
    </h:body>
</html>

Mit diesem minimalen Aufwand haben wir eine lauffähige Webanwendung mit Jakarta Faces erstellt. Damit wir das Ganze jetzt auch noch mit etwas Leben füllen können, erstellen wir eine Entität und einen Service.

1
2
3
4
5
6
7
8
9
@Entity
public class Training {
    @Id
    private String id;
    private String name;
    private String beschreibung;
  
    ...
}
1
2
3
4
5
6
@ApplicationScoped
public class TrainingService {
    public List<Training> getTrainings() {
        ...
    }
}

Wir stellen uns vor, dass hinter dem Service auch noch eine Repository-Klasse stecken kann und diese die Daten aus der Datenbank für uns verwaltet. Für uns reicht diese Tiefe für das einfache Beispiel aus.

Um nun die Verbindung zur Webansicht zu schließen erstellen wir noch eine weitere Klasse.

01
02
03
04
05
06
07
08
09
10
@Named
@RequestScoped
public class TrainingPresenter {
    @Inject
    TrainingService trainingService;
    
    public List<Training> getAllTrainings() {
        return trainingService.getTrainings();
    }
}

Mit der zusätzlichen Annotation @Named machen wir den Presenter unter dem Namen trainingPresenter bekannt. Damit haben wir nun die Möglichkeit die Daten in der xhtml-Datei zu verwenden.

1
2
3
4
5
6
<ui:repeat value="#{trainingPresenter.allTrainings}"
                           var="training">
    ...
    <h:outputText value="#{training.name}"/>
    ...
</ui:repeat>

Dieses einfache Beispiel zeigt, dass man auch ohne den Einsatz eines Javascript-Frameworks Webanwendungen mit JakartaEE erstellen kann. Der Vorteil von diesem Ansatz ist, dass auch Java-Entwickler ohne Javascript Know-How Webanwendungen erstellen können. Darüber hinaus entfällt die Umwandlung der Daten in Json und die Übertragung zum Client in dieser Form. Die Darstellung wird auf dem Server gerendert und direkt ausgeliefert.

Für diejenigen, die schon lange im Enterprise-Umfeld unterwegs sind, waren die meisten Dinge bekannt. Dennoch gibt es häufig viele Bedenken gegen diesen Ansatz. In diesem Beitrag haben wir uns ein einfaches Beispiel angeschaut. In einem späteren Beitrag werde ich dann den Vergleich zu Angular machen. Dazu werden wir uns dann genauer Anschauen, wo die Vor- und Nachteile der beiden Frontend-Technologien liegen.

Testen von Enterprise-Anwendungen ist i. A. eine schwierige Angelegenheit, hat man es doch mit komplexen Anwendungen zu tun, die im Kern auf einem Injektionscontainer basieren. Man muss also entweder die zu testenden Anwendungskomponenten ohne Injektionscontainer selbst konstruieren und alle Dependencies mit Mock-Objekten versorgen oder die Anwendung im Injektionscontainer testen.

Quarkus bietet für letzteres eine erfreulich einfache Vorgehensweise an: Annotiert man eine JUnit-5-Klasse mit @QuarkusTest, so wird einerseits eine Instanz der zu testenden Quarkus-Anwendung vor dem Einstieg in die Testmethoden gestartet (und am Ende wieder gestoppt) und andererseits ist dann die Testklasse eine CDI Bean, in die Services (im Code rechts: GreetingService) der zu testenden Anwendung injiziert werden können. Formal stellen die Testmethoden Unit Tests dar, tatsächlich sind es aber Multi Unit Tests oder Integrationstests.

01
02
03
04
05
06
07
08
09
10
11
12
@QuarkusTest
public class GreetingServiceTest {
  @Inject
  GreetingService greetingService;
  @Test
  public void testGetGreeting() {
    String greeting = greetingService.getGreeting();
    assertEquals("Hello world!", greeting);
  }
}

Im Test möchte man i. A. nicht mit externen Systemen arbeiten, sondern stattdessen bspw. passend vorbereitete Testdaten verwenden. Wir müssen somit Datenbanken oder REST-Api-Server „mocken“, also durch eine Testimplementierung ersetzen. Das kann in Quarkus-Tests sehr elegant mit CDI Alternatives geschehen. Dazu schreibt man eine zum zu ersetzenden Produktivservice kompatible Klasse im Test Classpath und annotiert sie mit @Alternative:

1
2
3
4
5
6
7
8
9
// Produktivservice (REST Client) im Main Classpath (src/main/java/...):
@RegisterRestClient(configKey = "CountryApi")
public interface CountryApi {
  @GET
  @Path("alpha/{code}")
  @Produces("application/json")
  Country getByCode(@PathParam("code") String code);
1
2
3
4
5
6
7
8
9
// Mockservice im Test Classpath (src/test/java/...):
@ApplicationScoped
@RestClient
@Alternative
public class CountryApiMock implements CountryApi {
  public Country getByCode(String code) {
    ...

Die Mock-Klasse muss für den Test noch aktiviert werden. Das könnte mit @Priority geschehen, was dann aber für jeden Testfall Gültigkeit hätte. Für eine dedizierte Steuerung bietet Quarkus sog. Test Profiles an. In ihnen können (u. a.) CDI Alternatives aktiviert werden:

1
2
3
public class TestWithApiAlternative implements QuarkusTestProfile {
  public Set<Class<?>> getEnabledAlternatives() {
    return Set.of(CountryApiMock.class);

Annotiert man nun noch die Testklasse mit @TestProfile, wird die zu testende Anwendung mit der aktivierten Mock-Klasse hochgefahren:

1
2
3
@QuarkusTest
@TestProfile(TestWithApiAlternative.class)
public class CountryServiceWithApiAlternativeTest {

Quarkus bietet noch viele weitere Features für die Unterstützung von Tests an, z. B. zusätzliche Mocking-Möglichkeiten, Dev Services oder Continuous Testing.

Wenn Sie Quarkus genauso begeistert wie mich (wovon ich mal ausgehe), dann kommen Sie doch mal zu unseren Vorträgen und Seminaren dazu!

Im Java Ökosystem haben wir bereits die Wahl zwischen unterschiedlichen Templating Engines von beispielsweise JSP über Thymeleaf bis zu FreeMarker. Wieso also eine weitere Option dazuholen? Wir schauen uns die Vorteile von Qute in einem kleinen Beispielprojekt an um genau das herauszufinden.

Die Bibliothek Qute ist spezifisch für die Anforderungen von Quarkus entwickelt worden und kann dort neben dem Templating für Webseiten auch z.B. in der Mailer-Erweiterung oder andere Templating Aufgaben verwendet werden. Dabei führt Qute möglichst viele Schritte bereits zur Build Zeit durch um zur Laufzeit Ladezeiten zu verringern und Fehler frühzeitig zu erkennen. Damit eignet sich Qute auch gut für nativ kompilierte Anwendungen. Die Validierung und Verarbeitung der Templates zur Build Zeit deckt Syntax- und Typfehler zuverlässig und frühzeitig auf und verringert die Zeit für manuelles Testen und Debuggen. Die Bibliothek unterstützt wahlweise ein reaktives Paradigma und bietet eine sehr gute Integration in den Quarkus Dev-Modus. Über die Quarkus IDE Tools werden Probleme auch bereits beim Entwickeln markiert und Vorschläge werden für bekannte Typen unterstützt.

Für unsere Beispielanwendung erstellen wir eine kleine Verwaltung der Planeten unseres Sonnensystems. Die Web-Schnittstelle realisieren wir mit Jakarta REST, dafür können wir folgende Maven-Dependency verwenden.

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-rest-qute</artifactId>
</dependency>

Qute Templates erstellen

Zunächst brauchen wir dann ein Template für eine Übersichtsseite. Templates werden für die quarkus-rest-qute Integration überlicherweise im Verzeichnis src/main/resources/templates und möglichen Unterverzeichnissen angelegt. Für die typensichere Integration ist standardmäßig vorgesehen, dass Templates die in der Klasse PlanetResource verwendet werden in einem Unterordner mit demselben Namen liegen. Qute-Ausdrücke werden durch geschweifte Klammern eingeleitet und geschlossen. Eine Dokumentation der verschiedenen Möglichkeiten gibt es hier. In unserem Beispiel verwenden wir diese Arten von Ausdrücken.

  • Einfache Ausdrücke: {planet.name}
  • Ternäre Ausdrücke: {planet.hasRings ? 'Ja' : 'Nein'}
  • Schleifen: {#for planet in planets} und {/for}
  • Bedingte Bereiche: {#if planets.isEmpty()} und {/if}
  • Direkter Zugriff auf benannte CDI-Beans: {cdi:vertxRequest.getParam('search', '')}
  • Automatisches Einbinden von CSS/JS Resourcen mit quarkus-web-bundler: {#bundle /}

Hier sind die entsprechenden Zeilen auch noch einmal im Kontext markiert.

<!DOCTYPE html>
<html lang="de">
<head>
  [...]
  {#bundle /}
  [...]
</head>
<body>
  [...]
  <form action="/planets/ui" method="get">
    <div class="input-group">
      <input type="text" name="search" class="form-control" placeholder="Suche Planeten..."
             value="{cdi:vertxRequest.getParam('search', '')}">
      <button class="btn btn-outline-secondary" type="submit">Suche</button>
    </div>
  </form>
  [...]
  <table class="table table-striped table-hover">
    <thead class="table-dark">
    <tr>
      <th>Name</th>
      <th>Durchmesser (km)</th>
      <th>Abstand zur Sonne (Mio. km)</th>
      <th>Hat Ringe</th>
      <th>Anzahl der Monde</th>
      <th>Aktionen</th>
    </tr>
    </thead>
    <tbody>
    {#for planet in planets}
    <tr class="planet-row">
      <td><a href="/planets/ui/{planet.name}">{planet.name}</a></td>
      <td>{planet.diameterKm}</td>
      <td>{planet.distanceFromSunMillionKm}</td>
      <td>{planet.hasRings ? 'Ja' : 'Nein'}</td>
      <td>{planet.numberOfMoons}</td>
      <td>
        <div class="btn-group btn-group-sm">
          <a href="/planets/ui/{planet.name}" class="btn btn-info">Anzeigen</a>
          <a href="/planets/ui/{planet.name}/edit" class="btn btn-warning">Bearbeiten</a>
          <form action="/planets/ui/{planet.name}/delete" method="post">
            <button type="submit" class="btn btn-danger btn-sm"
                    style="border-top-left-radius: 0; border-bottom-left-radius: 0;"
                    onclick="return confirm('Möchten Sie {planet.name} wirklich löschen?')">
              Löschen
            </button>
          </form>
        </div>
      </td>
    </tr>
    {/for}
    {#if planets.isEmpty()}
    <tr>
      <td colspan="6" class="text-center">
        Keine Planeten gefunden.
        <a href="/planets/ui/new">Fügen Sie einen Planeten hinzu</a>.
      </td>
    </tr>
    {/if}
    </tbody>
  </table>
  [...]
</body>
</html>

src/main/resources/templates/PlanetResource/planets.html

Qute Templates verwenden

Sobald wir das Template fertiggestellt haben können wir uns dem Rendern der Seite zuwenden. Die Jakarta REST Methoden müssen dafür ein Objekt vom Typ TemplateInstance zurückgeben. Um Templates typensicher zu definieren gibt es mit Qute zwei Möglichkeiten. Die erste Möglichkeit ist es die Templates über public static native Methoden innerhalb einer statischen Klasse mit der Annotation @CheckedTemplate zu referenzieren. Die Methode planets referenziert standardmäßig dann die Template Datei src/main/resources/templates/PlanetResource/planets.html. In den Methoden Parametern können die für das Rendern notwendigen Daten mit dem Parameternamen als Key definiert werden. Über diesen Namen können wir diese Daten im Template verwenden. Aufgrund des native Keywords müssen wir keine Implementierung vornehmen und können die Methode einfach verwenden um unsere TemplateInstance zu erstellen. Die Implementierung übernimmt Qute für uns.

@Path("/planets/ui")
public class PlanetResource {
  @Inject
  PlanetService planetService;
  @CheckedTemplate
  static class Templates {
    public static native TemplateInstance planets(List<Planet> planets);
    public static native TemplateInstance planet(Planet planet);
    public static native TemplateInstance error(Integer code, String title);
  }
  @GET
  @Produces(MediaType.TEXT_HTML)
  public TemplateInstance getPlanetsPage(@QueryParam("search") String search) {
      List<Planet> planetList = search != null && !search.trim().isEmpty()
          ? planetService.searchPlanets(search)
          : planetService.getAllPlanets();
      return Templates.planets(planetList);
  }
  [...]
}

src/main/java/de/gedoplan/showcase/web/PlanetResource.java

Probleme mit dem Schlüsselnamen oder einem Methodennamen fallen bereits zur Compilezeit auf und der Vorgang wird abgebrochen. Läuft die Anwendung zu diesem Zeitpunkt bereits im Dev-Modus wird nach einem Reload im Dev-Modus direkt auf die Stelle im Template verwiesen, die für den Fehler verantwortlich ist.

Eine andere Möglichkeit Templates zu referenzieren funktioniert über Records. Für das Template PlanetForm.html definieren wir einen Record PlanetForm um neue Planeten anzulegen oder bestehende zu bearbeiten. Der Record muss das Interface TemplateInstance implementieren um Qute zu signalisieren, dass es sich hierbei um eine Templatereferenz handelt, und um einen validen Rückgabewert in den Methoden darzustellen.

@Path("/planets/ui")
public class PlanetResource {
  [...]
  record PlanetForm(Planet planet, Action action) implements TemplateInstance {}
  @GET
  @Path("/new")
  @Produces(MediaType.TEXT_HTML)
  public TemplateInstance getNewPlanetForm() {
    return new PlanetForm(new Planet(), Action.NEW);
  }
  @GET
  @Path("/{name}/edit")
  @Produces(MediaType.TEXT_HTML)
  public TemplateInstance getEditPlanetForm(@PathParam("name") String name) {
    Optional<Planet> planetOpt = planetService.getPlanetByName(name);
    if (planetOpt.isPresent()) {
      return new PlanetForm(planetOpt.get(), Action.EDIT);
    } else {
      return Templates.error(404, "Planet '" + name + "' nicht gefunden");
    }
  }
  [...]
}

src/main/java/de/gedoplan/showcase/web/PlanetResource.java

Hier kommen wir ohne das Keyword native aus. Wichtig ist es hier auf Groß- und Kleinschreibung zu achten und wenn wir z.B. das Template Planet.html verwenden möchten würde die Record Definition sich standardmäßig mit dem Namen der Model-Klasse Planet überschneiden. Hier müssten wir entweder einen voll qualifizierten Namen verwenden, den Klassen- oder Dateinamen anpassen oder die Standardeinstellungen mit der Annotation @CheckedTemplate anpassen.

Mit der Annotation @TemplateData an der beim Rendern verwendeten Klasse Planet können wir Qute anweisen einen sogenannten Resolver zu generieren um einen Zugriff über Reflection zur Laufzeit zu vermeiden. Für Enumerations gibt es die Annotation @TemplateEnum, die es uns auch erlaubt im Template auf die Enum-Konstanten zuzugreifen.

@TemplateData
public record Planet(String name, double diameterKm, double distanceFromSunMillionKm,
                     boolean hasRings, int numberOfMoons, String description) { [...] }

src/main/java/de/gedoplan/showcase/model/Planet.java

// Zugriff auf Enum Konstanten im Template über Action:NEW
@TemplateEnum
public enum Action {NEW, EDIT}

src/main/java/de/gedoplan/showcase/web/PlanetResource.java

Methodenerweiterungen für Datenobjekte

Manchmal wäre es hilfreich, wenn wir bestehende Java-Klassen um neue Methoden erweitern könnten, wie man es z.B. aus Kotlin kennt. Zumindest für die Datenobjekte, die wir in den Templates verwenden, können wir mit Qute solche Methoden definieren. In diesem Fall möchten wir auf der Planetenseite gerne auch den Abstand zur Sonne in astronomischen Einheiten angeben. Wir könnten hier natürlich auch einfach unsere Klasse erweitern, aber insbesondere wenn die betreffende Klasse nicht unter unserer Kontrolle liegt können wir diesen Ansatz verwenden.

Wir definieren eine statische Methode mit der Annotation @TemplateExtension die ein Planet Objekt entgegen nimmt und einen double Wert zurückgibt. In der Methode wird dann die entsprechende Berechnung vorgenommen. Bei der Ausgabe können wir auf eine weitere, bereits vordefinierte, Erweiterung zurückgreifen und über str:fmt() die Ausgabe entsprechend formatieren.

@TemplateExtension
static double distanceFromSunAU(Planet planet) {
  return planet.distanceFromSunMillionKm() / 149.597_870_700;
}

src/main/java/de/gedoplan/showcase/web/PlanetResource.java

{str:fmt("%.3f", planet.distanceFromSunAU)}

src/main/resources/templates/PlanetResource/planet.html

Das ganze Projekt findet ihr wie immer auch auf GitHub.

In Cloud-Anwendungen – aber durchaus nicht nur dort – interessieren wir uns dafür, wie die Anwendungen laufen, ob es ihnen „gut“ geht und wie sie sich untereinander aufrufen. Technisch sind das die Aspekte Metrics und Tracing.

Die CNCF (Cloud Native Computing Foundation) hat dafür schon seit Jahren ein Open-Source-Projekt namens Open Telemery, das u. a. APIs bereitstellt, mit denen diese sog. Telemetriedaten in Anwendungen erfasst und exportiert werden können. Für Java nimmt sich nun MicroProfile Telemetry des Themas an und für Quarkus gibt es passende Extensions!

Die Extension quarkus-opentelemetry unterstützt u. a. Tracing, d. h. die Verfolgung von Requests über ggf. mehrere Anwendungen hinweg. Solange man sich im Bereich von RESTful Services befindet, die mit Jakarta RESTful Webservices implementiert sind und die andere Services bei Bedarf mit MicroProfile Rest Client aufrufen, muss die Anwendung dafür kaum konfiguriert werden. Einzig die Adresse des Tracing Collectors ist (bspw. in application.properties) anzugeben:

quarkus.otel.exporter.otlp.endpoint=http://localhost:4317

Noch bequemer wird es während der Entwicklung, wenn man den für Quarkus ebenfalls verfügbaren Dev Service nutzt, d. h. die Adresse im Dev Mode nicht definiert und mittels der Extension quarkus-observability-devservices-lgtm einen Telemetry Collector aus dem Grafana-Projekt in einem Container hochfahren lässt. Grafana LGTM ist eine All-in-One-Lösung für L(ogging), G(rafana), T(races) und M(etrics), die aus einem Open Telemetry Collector, den Backends Loki für Logging, Tempo für Tracing und Prometeus für Metrics, sowie Grafana zur Visualisierung besteht.

grafana lgtm png

Sind in derart ausgerüstenen Anwendungen einige Requests durchgelaufen, kann man sich diese in ihrem zeitlichen Zusammenhang in Grafana aus der angebundenen Tempo-Datenbank visualisieren lassen.

tempo tracing png

Die Spezifikation MicroProfile Telemetry verlangt derzeit nur die Implementierung von Tracing, aber Quarkus unterstützt auch Metrics und Logging. Bei der oben genannten Extension müssen diese Aspekte in der Konfiguration eingeschaltet werden:

quarkus.otel.metrics.enabled=true
quarkus.otel.logs.enabled=true

Die von den Anwendungen exportierten Daten können dann in Grafana in den Datenbanken Prometeus bzw. Loki zentral eingesehen werden.

prometeus metrics
loki logging png

Schon ohne wesentliche Konfiguration und ganz ohne spezielle Programmierung werden Quarkus-Anwendungen so mit ganz niedriger Einstiegshürde „observable“. Man kann aber auch recht leicht Tracing-Daten anreichern oder eigene sog. Spans einbringen. Ebenso lassen sich anwendungsspezifische Metriken bspw. als Counter oder Histogramme programmieren.

Mehr Details finden Sie in Open Telemetry und MicroProfile Telemetry – oder noch besser: Kommen Sie doch zu einem unserer Quarkus-Seminare!

Damit LLMs ihre Aufgaben möglichst zielgerichtet erfüllen ist es manchmal sinnvoll Eingabe- und Ausgabebedingungen zu definieren und zu prüfen. Die Quarkus-Integration von LangChain4j bietet genau dafür schon länger die Guardrails-Funktionalität. Nun ist diese Funktionalität mit Version 1.1.0 auch in der Upstream-Bibliothek verfügbar und kann damit auch z.B. in JSE-, JEE- oder Spring-Anwendungen verwendet werden. Wir werfen in diesem Beitrag einen Blick auf das Konzept und lernen die Quarkus-Implementierung an einer kleinen Beispielanwendung kennen. Die Upstream-Implementierung nutzt prinzipiell die gleichen Schnittstellen und häufig ist zum Übertragen nichts weiter als ein Anpassen der import-Anweisung notwendig.

Eingabebedingungen festlegen

Unsere Beispielanwendung verwendet ein LLM um Java-spezifische Fragen zu beantworten. In diesem Fall verwenden wir der Einfachheit halber ein allgemein trainiertes LLM das vermutlich auch auf Fragen zu anderen Programmiersprachen sinnvoll antworten könnte. Wenn wir aber ein spezifisch trainiertes LLM verwenden oder zu einem spezifischen Thema Kontext bereitstellen, ist es sinnvoll die Anfragen auch auf die relevante Domäne zu beschränken. Andernfalls kann das zu verstärkten „Halluzinationen“ und faktisch falschen Aussagen führen. Hier nutzen wir ein InputGuardrail um die Anfragen auf das Thema Java zu beschränken. Weitere mögliche Anwendungsfälle könnten die Einschränkung auf eine bestimmte Sprache (Deutsch, …), das automatische Zensieren von personenbezogenen Daten oder das Erkennen von böswilligen Anfragen sein.

Fangen wir mit einer simplen Implementierung an. Das Interface InputGuardrail sieht die Methode validate() vor, die im einfachsten Fall eine UserMessage entgegen nimmt. Der Rückgabewert kann eine von drei Ausprägungen annehmen:

  • success: Bei einem erfolgreichem Durchlaufen aller InputGuardrails wird die Anfrage an das LLM weitergeleitet. Über successWith(String) kann die Anfrage vor dem Weiterleiten noch angepasst werden um z.B. bestimmte Inhalte zu entfernen.
  • failure: Ein Fehlschlagen führt dazu, dass die Anfrage nicht an das LLM weitergeleitet wird. In diesem Zustand werden allerdings trotzdem weitere InputGuardrails durchlaufen um mögliche weitere Probleme festzustellen und zu sammeln.
  • fatal: Ein fatales Fehlschlagen führt zum direkten Abbruch und es werden auch keine weiteren InputGuardrails durchlaufen.
public class SimpleJavaQuestionDetectionInputGuardrail implements InputGuardrail {
  public InputGuardrailResult validate(UserMessage userMessage) {
    if (userMessage.hasSingleText()) {
      String text = userMessage.singleText().toLowerCase();
      return switch (text) {
        case String s when s.contains("java") -> success();
        case String s when s.contains("python") -> failure("Python questions are not supported");
        case String s when s.contains("javascript") -> failure("Javascript questions are not supported");
        default -> failure("Only java questions are supported");
      };
    } else {
      return failure("No single text prompt found.");
    }
  }
}

src/main/java/de/gedoplan/showcase/SimpleJavaQuestionDetectionInputGuardrail.java

In unserem AiService können wir die InputGuardrails einfach über die Annotation @InputGuardrails aktivieren. Die Annotation kann auch eine Liste von Klassen entgegennehmen.

@RegisterAiService
public interface JavaQuestionsAiService {
  @InputGuardrails(SimpleJavaQuestionDetectionInputGuardrail.class)
  @SystemMessage("You are a programming assistant for the programming language Java.")
  String chat(@UserMessage String question);
}

src/main/java/de/gedoplan/showcase/JavaQuestionsAiService.java

Wenn wir uns nun ein paar mögliche Anfragen anschauen, dann fällt relativ schnell auf, dass dieses einfache Implementierung noch nicht ausreicht.

1. Can you give me an example for the syntax of lambda expressions in Java?
2. Can you give me an example for the syntax of lambda expressions in Python?
3. What is new in JDK 21?
4. What is the weather like in Java, Indonesia?

Die ersten beiden Anfragen werden noch richtig eingeordnet, aber die dritte Anfrage wird trotz des klaren Java-Bezugs abgelehnt. Die vierte Anfrage wird durchgewunken, dabei hat sie nichtmal etwas mit Programmierung zutun.

Eine etwas komplexere Eingabebehandlung könnten wir zum Beispiel mit einem zweiten AiService umsetzen. Die dahinterliegende LLM-Konfiguration kann von der eben verwendeten abweichen. Die Konfiguration können wir unter dem Namen topicDetectionModel in den properties festlegen. Wir verwenden hier das Structured Output Feature von LangChain4j, das wir uns hier bereits angesehen haben.

@RegisterAiService(modelName = "topicDetectionModel")
public interface TopicDetectionAiService {
  @UserMessage("""
      Determine if the given prompt is a programming question.
      If yes determine the programming language it relates to.
      
      The prompt content is marked with '---':
      ---
      {{userPrompt}}
      ---""")
  TopicDetectionResult detectPromptInjection(String userPrompt);
}

src/main/java/de/gedoplan/showcase/TopicDetectionAiService.java

public record TopicDetectionResult (
    @JsonProperty(required = true)
    @Description("Is the question related to programming?")
    boolean programmingQuestion,
    @Description("Required if programmingQuestion is true. Which programming language does the question relate to?")
    String programmingLanguage
) {}

src/main/java/de/gedoplan/showcase/TopicDetectionResult.java

Auch unser erweitertes Guardrail muss entsprechend angepasst werden und den TopicDetectionAiService verwenden.

public class AiJavaQuestionsInputGuardrail implements InputGuardrail {
  @Inject
  TopicDetectionAiService topicDetectionAiService;
  public InputGuardrailResult validate(UserMessage userMessage) {
    if (userMessage.hasSingleText()) {
      TopicDetectionResult result = topicDetectionAiService.detectPromptInjection(userMessage.singleText());
      if (!result.programmingQuestion()) {
        return failure("Only programming questions are supported");
      } else {
        if (result.programmingLanguage() == null) failure("Only Java questions are supported");
        return switch (result.programmingLanguage().toLowerCase()) {
          case "java" -> success();
          case "python" -> failure("Python questions are not supported");
          case "javascript" -> failure("Javascript questions are not supported");
          default -> failure("Only java questions are supported");
        };
      }
    } else {
      return failure("No single text prompt found.");
    }
  }
}

Damit werden nun zumindest die 4 oben stehenden Beispiele richtig zugeordnet.

Nun zur Ausgabeüberprüfung

Auch für die Ausgabe können wir Regeln definieren, die erfüllt sein müssen, damit ein Ergebnis zurückgeliefert wird. Dafür ist das Interface OutputGuardrail vorgesehen, welches analog zur Eingabevariante eine validate() Methode vorsieht, die aber hier im einfachsten Fall ein AiMessage Objekt statt einer UserMessage verarbeitet. Auch hier nehmen die Rückgabewerte die oben für die Eingabevariante beschriebenen Ausprägungen an – mit einem Zusatz: Mithilfe von retry und reprompt können Anfragen automatisch zur Nachbesserung an das LLM zurückgegeben werden. Wie häufig dieser Vorgang maximal ausgeführt wird, kann in Quarkus über eine Property konfiguriert werden. In der Upstream-Bibliothek ist das beim Aktivieren der Guardrails im AiService möglich.

Für das Structured Output Feature gibt es bereits eine vorimplementierte Guardrail AbstractJsonExtractorOutputGuardrail, die wir auch hier anwenden können um die Wahrscheinlichkeit einer strukturell korrekten Antwort zu erhöhen. Wenn das LLM neben einer validen JSON-Struktur noch weiteren Text mitliefert wird zunächst versucht den überflüssigen Inhalt zu kürzen. Führt das nicht zum Erfolg wird das LLM gebeten die Ausgabe nochmal zu überprüfen und korrekt zu formatieren. Erst nach einem erfolgreichen Durchlaufen der Guardrail wird die Ausgabe zurückgegeben.

Da in der Guardrail für den Test der JSON-Struktur der konkrete Rückgabe-Typ zur Laufzeit bekannt sein muss, kann die vordefinierte Guardrail nicht einfach über eine Typenvariable generisch konfiguriert werden. Stattdessen müssen wir in der Quarkus-Integration aktuell eine eigene Implementierung der abstrakten Basisklasse bereitstellen und darin die Typinformation hinterlegen. Die Upstream-Implementierung nutzt zum Festlegen der Typinformation zur Laufzeit einen Konstruktoraufruf.

public class TopicDetectionJsonOutputGuardrail extends AbstractJsonExtractorOutputGuardrail {
  @Override
  protected Class<?> getOutputClass() {
    return TopicDetectionResult.class;
  }
}

Diese Guardrail können wir nun im TopicDetectionAiService aktivieren.

public interface TopicDetectionAiService {
  [...]
  @OutputGuardrails(TopicDetectionJsonOutputGuardrail.class)
  TopicDetectionResult detectPromptInjection(String userPrompt);
}

Für die Quarkus-Integration steht mit Release der neuen Upstream-Implementierung nun eine Integration der Neuerungen an. Es werden sich in dem Zuge vermutlich zumindest einige Paketdefinitionen ändern um auch hier zukünftig wenn möglich die Upstream-Annotationen und Klassen zu verwenden.

Einfache dynamische Ausgabeanpassung

Wenn ich garnicht prüfen möchte ob die Ausgabe valide ist, aber die Ausgabe möglicherweise dynamisch erweitern oder anderweitig verändern möchte, wäre das prinzipiell mit OutputGuardrail#successWith bereits möglich. In der Quarkus-Integration gibt es allerdings auch die Möglichkeit einen AiResponseAugmenter zu definieren. Trotz des Wortes „Augment“ hat das nicht direkt etwas mit dem Thema Retrieval Augmented Generation (RAG) zu tun (zum Blog-Beitrag). Das „Augment“ bezieht sich hier auf die Antwort des LLM und nicht die Anfrage. Nichtsdestotrotz kann mit einem AiResponseAugmenter die Ausgabe z.B. derart erweitert werden, dass tatsächlich verwendete Quellen, die als Kontext zur Anfrage hinzugefügt und letztlich in der Antwort verwendet wurden, in der Antwort zusätzlich verlinkt oder verdeutlicht werden. Das Ziel wäre hier die Erklärbarkeit und Nachvollziehbarkeit von Antworten zu erhöhen.

In unserem Fall liefert das konfigurierte LLM gemma3n:e2b leider Antworten mit mehreren nicht notwendigen Zeilenumbrüchen am Ende, die bei einer einfachen Ausgabe der Antwort die Formatierung stören. Wir definieren deshalb einen TrimResponseAugmenter der Whitespaces am Ende der Antwort abschneidet.

public class TrimResponseAugmenter implements AiResponseAugmenter<String> {
  @Override
  public String augment(String response, ResponseAugmenterParams params) {
    return response.stripTrailing();
  }
}

src/main/java/de/gedoplan/showcase/TrimResponseAugmenter.java

Im AiService kann der AiResponseAugmenter dann über die Annotation @ResponseAugmenter(TrimResponseAugmenter.class) aktiviert werden.

Das ganze Beispielprojekt gibt es wie immer auf GitHub zum Ausprobieren.

Bei der Integration von Daten aus Fremdsystemen bspw. für den Import kommen immer wieder die gleichen Anforderungen auf.

  • Wir haben hier eine Datei in einem bestimmten Format, die ihr in euer System übernehmen müsst.
  • Wir können euch zwar die Daten liefern, aber weitere Informationen dazu liegen in einem anderen System vor.
  • usw.

Diese Anforderungen gleichen meistens einem bestimmten Muster. Apache Camel ist ein Framework für solch eine Integration von Systemen und Datenformaten. Es liefert bereits sehr viele Lösungsmuster für diese Art von Implementierungen.

Ich habe mir ein ganz kleines Beispiel einer solchen Integration herausgesucht und möchte euch die Nutzung von Apache Camel in Verbindung mit einer Quarkus-Anwendung zeigen. Wir bekommen unsere Daten in Form einer CSV-Datei und möchten diese als Java Klassen in unserer Anwendung nutzen.

1
2
3
4
5
6
7
8
@Data
@CsvRecord(separator = ",", skipFirstLine = true)
public class Customer {
  @DataField(name = "id", pos = 1)
  private String id;
  @DataField(name = "name", pos = 2)
  private String name;
}

Um einen Import von CSV-Daten ohne ein Mapping oder einen Konverter zu lösen gibt es in Camel das Datenformat Bindy. Damit ist es möglich, durch den Einsatz von Annotationen, die Java Klasse entsprechend vorzubereiten.

Apache Camel arbeitet die Aufgaben in sogenannten Routen ab. Diese können in Java mit Hilfe eines Route-Builders erstellt werden. Für den Import ergibt sich somit die folgende Route:

1
2
3
4
5
6
from("file:{{import.location}}")
        .log("Processing file: ${file:name}")
        .unmarshal()
        .bindy(BindyType.Csv, Customer.class)
        .split(body())
        .to("direct:doImport");

Hierbei weisen wir Camel an die Dateien in einem bestimmten Verzeichnis zu überwachen und diese dann zunächst von CSV in unsere Customer Klasse umzuwandeln. Da es sich hierbei um eine Liste von Daten handelt, teilen wir diese Liste in ihre einzelnen Einträge auf und geben die weitere Verarbeitung an eine weitere Route.

In dieser Route übergeben wir jedes einzelne Customer Objekt in eine CDI-Bean. Diese haben wir mit einer @Named-Annotation versehen und können diesen Namen in der Route ansprechen.

1
2
from("direct:doImport")
        .bean("importService", "importCustomer");

Wir übergeben also an die CDI-Bean mit dem Namen importService und rufen die Methode importCustomer auf. Diese bekommt als Parameter ein Customer Objekt übergeben. Damit bin ich in meiner Java-Welt angekommen und kann das Objekt in meiner Anwendung nutzen.

Mit Hilfe von Apache Camel lassen sich die alltäglichen Probleme der Integration von Daten auf einfache Art und Weise lösen, ohne jedes mal das Rad neu erfinden zu müssen. Das Beispiel findet ihr wie immer auf Github.

Der Dev-Service in Quarkus bietet dem Entwickler eine sehr komfortable Möglichkeit die lokale Entwicklung zu beschleunigen. Nach einer Änderung des Quellcodes werden die angepassten Teile automatisch neu bereitgestellt und man kann die Anpassungen sofort testen. Darüber hinaus stehen in den Dev-Services weitere Unterstützung bei der Entwicklung zur Verfügung.

Sobald man bspw. einen JDBC-Treiber für seine Persistenz-Schicht einsetzt, wird je nach Treiber eine entsprechende Datenbank automatisch über Testcontainers zur Verfügung gestellt. Das funktioniert für sehr viele weitere Technologien wie bspw. Messaging über Kafka oder AMQP, aber auch für die Security in Form von Keycloak. Diese Möglichkeiten sind schon sehr vielfältig, dennoch wurden in vielen Projekten zusätzlich auch weitere Docker-Container insbesondere über Docker Compose für die lokale Entwicklung eingesetzt.

Mit dem Release 3.22 wurde diese Möglichkeit mit den Dev-Services verbunden. Es lassen sich jetzt über eine compose-devservices.yml diese Container zusätzlich mit integrieren. Damit müssen diese Container nicht mehr separat gestartet werden. Die folgende yml-Datei konfiguriert eine PostgreSQL und einen mailhog-Container.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
services:
  db:
    image: postgres:17
    healthcheck:
      test: pg_isready -U gedoplan -d education
      interval: 5s
      timeout: 3s
      retries: 3
    ports:
      - '5432'
    environment:
      POSTGRES_USER: gedoplan
      POSTGRES_PASSWORD: secret09
      POSTGRES_DB: education
    volumes:
      - ./conf:/docker-entrypoint-initdb.d
      - edu_postgres_data:/var/lib/postgresql/data
  mailhog:
    image: mailhog/mailhog
    ports:
      - "1025"
      - "8025"
    labels:
      io.quarkus.devservices.compose.config_map.port.1025: quarkus.mailer.port
volumes:
  edu_postgres_data:

Die PostgreSQL Datenbank wird von Quarkus automatisch mit der Datasource verbunden. Quarkus erkennt über das verwendete postgres-Image, dass keine vorkonfigurierte Datenbank gestartet werden muss. Bei mailhog handelt es sich um einen Dienst, der nicht automatisch mit zur Verfügung gestellt werden würde. Wird in Quarkus die Mail-Extension verwendet, wird in den Dev-Services automatisch in den Mock-Modus umgeschaltet. Damit werden alle versendeten Mails im Quarkus-Log ausgegeben und intern gespeichert. Das kann man in den application.properties deaktivieren. Dann benötigen wir jedoch eine Konfiguration für einen Mailserver. Diesen starten wir als zusätzlichen Container und verbinden den Port 1025 mit der Quarkus-Konfiguration quarkus.mailer.port.

Ich glaube durch die Integration von Docker Compose in die Dev-Services ist es in vielen Projekten einfacher eine lokale Entwicklungsumgebung bereitzustellen und damit die Entwicklung nochmals zu optimieren. Eine ausführliche Dokumentation findet ihr hier: https://quarkus.io/guides/compose-dev-services.

In diesem Teil der „AI in Java“ Serie schauen wir uns das Thema Retrieval Augmented Generation (RAG) genauer an. Damit können wir LLMs unter anderem mit spezifisch für den Geschäftsprozess relevanten Informationen versorgen ohne das Model erst mit neuen Daten zu trainieren.

In den vorherigen Blogposts dieser Serie haben wir LLMs bereits ermöglicht Schnittstellen (Tools) zu nutzen um weitere Informationen zu erhalten oder sogar Geschäftsprozesse zu steuern (mit Spring AI und mit Quarkus+LangChain4j). Bei RAG wird bei der Anfrage bereits bestimmt welche Informationen (z.B. aus einer vorbereiteten Sammlung an Dokumenten) für die Anfrage relevant scheinen und dem LLM direkt mit der Anfrage als Kontext zur Verfügung gestellt. Das LLM kann nun auf Basis dieser Informationen eine Antwort auf die Anfrage generieren. Wir probieren das ganze anhand eines einfachen Beispielprojekts mit Quarkus und LangChain4j aus. Als LLM verwenden wir wieder mistral mit Ollama.

Ähnlich wie in den bisherigen Beispielen stellen wir dem LLM auch hier wieder Verkehrs- und Wetterinformationen zur Verfügung. Konkret geschieht das in diesem Fall durch einfache Textdateien mit einem Wetterbericht oder Informationen zu Baustellen. Um einfach mal anzufangen und die Möglichkeiten auszuprobieren eignet sich das Easy RAG Feature von LangChain4j das, insbesondere mit der Quarkus Integration, sehr einfach einzubinden ist. Wir fügen einfach die quarkus-langchain4j-easy-rag Maven Dependency hinzu, legen die entsprechenden Dokumente in unseren Projekt ab und spezifizieren den Pfad in der application.properties Datei.

quarkus.langchain4j.easy-rag.path=documents
quarkus.langchain4j.easy-rag.reuse-embeddings.enabled=true

In unserem Fall legen wir die Textdateien im Ordner documents ab. Wir aktivieren zusätzlich eine weitere Property, damit die Anwendung bei einem Neustart, zum Beispiel durch eine Änderung im Dev-Modus, die Embeddings für unsere Informationen nicht neu berechnen muss. Moment — Embeddings? Embeddings sind Vektoren, die die Semantik (also die Bedeutung) von Textabschnitten darstellen sollen. Um die Relevanz eines Textabschnitts für eine bestimmte Fragestellung zu ermitteln wird dann die Ähnlichkeit der Embeddings (also zeigen die Vektoren in eine ähnliche Richtung) verglichen. Easy RAG stellt die Embeddings der Textsegmente als Default in einem in-memory store bereit, das reicht für unsere einfachen Beispiele zum Ausprobieren aus. Für weiterführende Projekte gibt es Schnittstellen für persistente Vektordatenbanken. Bestimmt werden die Embeddings durch das Model nomic-embed-text, das standardmäßig für Projekte mit Ollama Integration ausgewählt wird.

Aber was passiert nun eigentlich, wenn wir Anfragen an unser LLM stellen ohne weitere Einstellungen vorzunehmen?


Nutzerfrage:
Angereicherte (augmented) Anfrage:
Antwort:

Okay unsere Frage wurde beantwortet. Aber wie man sieht beinhaltet die angereicherte Anfrage mehr Informationen, als für die Beantwortung notwendig gewesen wären — und das LLM nutzt sie bereitwillig für eine ausschweifende Antwort. Das bedarf vielleicht doch noch etwas fine-tuning. Für einen kleinen Überblick können wir z.B. eine kleine HTTP-Schnittstelle hinzufügen, in der die Anfrage mit den gespeicherten Embeddings verglichen und die Embeddings samt einem Score für die Ähnlichkeit zurückgegeben werden. Der Score nimmt einen Wert zwischen 0 und 1 abhängig von der Ähnlichkeit ein.

@POST
@Path("/checkEmbeddingScore")
@Consumes(MediaType.TEXT_PLAIN)
@Produces(MediaType.TEXT_PLAIN)
public Response checkEmbeddingScore(String question) {
  EmbeddingSearchResult<TextSegment> embeddingSearchResults = embeddingStore.search(new EmbeddingSearchRequest(embeddingModel.embed(question).content(), 100, 0.0, null));
  return Response.ok(embeddingSearchResults.matches().stream().map(match -> match.score() + ": " + match.embedded().text()).collect(Collectors.joining("\n---\n"))).build();
}

Eine relativ einfache Konfiguration ist die Menge an Ergebnissen, die bei einer Anfrage an den Embedding-Store zurückgegeben werden, weiter zu begrenzen. Hierfür kann der Wert der Property quarkus.langchain4j.easy-rag.max-results von der Voreinstellung (5 Ergebnisse) abgeändert werden. Eine Begrenzung auf Basis des Score ist über die Property quarkus.langchain4j.easy-rag.min-score möglich. Statt des bereitgestellten RetrievalAugmentors können wir auch einen eigenen Augmentor verwenden und hier die Ergebnisse anhand des Scores oder anderen Filteranweisungen beschränken. Auch Komponenten wie der ContentInjector, der bestimmt wie der Prompt an das LLM formatiert wird, können hier angepasst werden.

@ApplicationScoped
public class DemoAugmentor implements Supplier<RetrievalAugmentor> {
  private final static String PROMPT_TEMPLATE = """
    {{userMessage}}
    
    To answer this question you can use the following information if applicable:
    {{contents}}\
    """;
  private final RetrievalAugmentor augmentor;
  public DemoAugmentor(EmbeddingModel embeddingModel, EmbeddingStore<TextSegment> embeddingStore) {
    EmbeddingStoreContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder()
      .embeddingModel(embeddingModel)
      .embeddingStore(embeddingStore)
      .minScore(0.8)
      .maxResults(3)
      .build();
    ContentInjector contentInjector = DefaultContentInjector.builder()
      .promptTemplate(new PromptTemplate(PROMPT_TEMPLATE))
      .build();
    this.augmentor = DefaultRetrievalAugmentor.builder()
      .contentRetriever(contentRetriever)
      .contentInjector(contentInjector)
      .build();
  }
  @Override
  public RetrievalAugmentor get() {
    return augmentor;
  }
}
@RegisterAiService(retrievalAugmentor = DemoAugmentor.class)
public interface RagAiService {
  String chat(@UserMessage String question);
}

Durch die Beschränkung des mitgelieferten Kontexts erreichen wird schonmal etwas bessere Ergebnisse. Durch Anpassungen des Prompts können auch hier noch weitere Optimierungen abhängig vom konkreten Anwendungsfall vorgenommen werden. Wobei auch hier gilt: LLMs sind sehr große stochastisch operierende Maschinen und jede Verarbeitung ist im Regelfall anders.


Nutzerfrage:
Angereicherte (augmented) Anfrage:
Antwort:

Auch beim Prozess des Einbettens und Speichern der Dokumenteninhalte können wir noch Optimierungen vornehmen. Über die Properties quarkus.langchain4j.easy-rag.max-segment-size und quarkus.langchain4j.easy-rag.max-overlap-size kann die maximale Größe von Segmenten und deren Überlappung beim Einlesen für den spezifischen Inhalt angepasst werden. Natürlich ist sobald es über kleine Beispiele hinausgeht auch eine persistente Vektordatenbank sehr sinnvoll. Für Quarkus sind bereits einige Integrationen wie quarkus-langchain4j-redis verfügbar, die auch Dev-Services für die Entwicklung mitbringen.

Das Beispielprojekt gibt es wie immer auch auf GitHub.

Im letzten Blog-Artikel zum Thema AI haben wir uns die Tools-Unterstützung mit Spring und Spring AI angeschaut (siehe hier). In diesem Artikel schauen wir uns nun dasselbe Beispiel mit Quarkus und LangChain4j an. Die Bibliothek ist zunächst mal an kein bestimmtes Framework gebunden, es gibt aber optionale Integrationen um die Verwendung mit Spring Boot oder Quarkus zu vereinfachen. In unserem Beispielprojekt verwenden wir wieder das Modell mistral (lokal mit Ollama) und ermöglichen dem LLM aktuelle Wetter- und Verkehrsinformationen abzurufen. Für dieses Beispiel füllen wir diese Schnittstellen aber zunächst nur mit Beispieldaten.

Im Gegensatz zur Umsetzung mit Spring AI kann man bei Langchain4j auch einen eher deklarativen Ansatz auf Basis von Annotationen verwenden um die LLM-Schnittstelle zu definieren. In den Code-Beispielen fokussieren wir uns diesmal auf die Wetterinformationen. Die Schnittstellen für die Verkehrsinformationen sind analog implementiert.

Tools (also Funktionalitäten, die das LLM verwenden kann) können mit @Tool und einer Beschreibung der Schnittstelle annotiert werden. Man kann auch hier beliebige Java-Typen als Parameter angeben, LLMs kommen aber mit einer flacheren Struktur oft besser klar. Mit der @P Annotation können die Parameter hier noch weiter beschrieben und als optional gekennzeichnet werden.

@ApplicationScoped
public class AiTools {
  @Tool("Get current weather information for location.")
  public WeatherResponse getCurrentWeather(@P("Request location") String location,
                                           @P(value = "Request unit", required = false) TempUnit unit) {
    return new MockWeatherInformationService().apply(new WeatherRequest(location, unit));
  }
  [...]
}

Die Typen sind hier dieselben wie bereits im Spring AI Beispiel.

public record WeatherRequest(String location, TempUnit unit) {}
public sealed interface WeatherResponse {
  record Success(String location, double temp, TempUnit unit) implements WeatherResponse { [...] }
  record Failure(String failureMessage) implements WeatherResponse {
  }
}
public enum TempUnit{ CELSIUS, FAHRENHEIT; [...] }

Die Mock-Schnittstelle liefert dann ein paar vordefinierte Wetterinformationen.

public class MockWeatherInformationService {
  private final Map<String, WeatherResponse> temperatureMap = Map.ofEntries(
      Map.entry("Bielefeld", new WeatherResponse.Success("Bielefeld", 20.1, TempUnit.CELSIUS)),
      Map.entry("Berlin", new WeatherResponse.Success("Berlin", 25.6, TempUnit.CELSIUS)),
      Map.entry("Köln", new WeatherResponse.Success("Köln", 27.2, TempUnit.CELSIUS))
  );
  public WeatherResponse apply(WeatherRequest request) {
    if (request == null) return new WeatherResponse.Failure("Error: Could not parse request.");
    WeatherResponse weatherResponse = temperatureMap.getOrDefault(request.location(), null);
    return switch (weatherResponse) {
      case null -> new WeatherResponse.Failure("Error: No weather information for location " + request.location() + " available.");
      case WeatherResponse.Failure failure -> failure;
      case WeatherResponse.Success success -> success.withTempUnit(request.unit());
    };
  }
}

Unsere Anwendung beinhaltet auch eine einfache HTTP-Schnittstelle zum Testen, in der eine Anfrage an das LLM gesendet wird. Die Antwort bekommen wir dann entsprechend zurück.

@Path("ai")
public class ToolsAiResource{
  @Inject
  ToolsAiService toolsAiService;
  @POST
  @Path("/tools")
  @Consumes(MediaType.TEXT_PLAIN)
  @Produces(MediaType.TEXT_PLAIN)
  public Response tools(String question) {
    return Response.ok(toolsAiService.chat(question)).build();
  }
}

Der ToolsAiService kann durch die Quarkus Integration über Annotationen bereitgestellt und konfiguriert werden.

1
2
3
4
@RegisterAiService(tools = AiTools.class)
public interface ToolsAiService {
  String chat(@UserMessage String question);
}

Eine beispielhafte Interaktion kann dann so aussehen.

1
2
Q: What is the current weather in Bielefeld?
A: Currently in Bielefeld, the temperature is 20.1 degrees Celsius.

Im Gegensatz zu Spring AI können wir hier Logging für die HTTP Kommunikation mit dem LLM einfach über die Properties quarkus.langchain4j.ollama.chat-model.log-requests und quarkus.langchain4j.ollama.chat-model.log-responses aktivieren.

Das Beispiel gibt es wie immer auf GitHub.

Im ersten Teil der Serie zum Einsatz von DMN-Engines in Enterprise Projekten habe ich euch den Gedanken der Entscheidungstabellen als dynamische Geschäftslogik versucht ein wenig näher zu bringen. Im zweiten Teil wollen wir diese Entscheidungstabellen nun in unsere Anwendung einbauen.

Die kleine Beispielanwendung ist eine Quarkus-Anwendung, die eine Integration der Camunda DMN-Engine nutzt. Ich habe mich an dieser Stelle für den Einsatz der Camunda Version 7 entschieden, da diese sehr einfach in eine Anwendung eingebunden werden kann. Wir starten natürlich ebenfalls mit einer Entscheidungstabelle, die ich mit Hilfe des Camunda Modelers erstellt habe. Das Beispiel ist ebenfalls sehr einfach gehalten. Auf Basis der Anzahl von getätigten Bestellungen soll ein Rabatt für die aktuelle Bestellung ermittelt werden.

 

Calculate discount png

 

Diese Entscheidungstabelle ist mit Hilfe der Decision Model and Notation (DMN) erstellt worden. Dabei handelt es sich um eine Spezifikation der Object Management Group (OMG). Die technische Basis bildet die DMN-Datei, welche die Entscheidungstabelle im XML-Format enthält. Um diesen Beitrag nicht unnötig aufzublähen, beschränke ich mich an dieser Stelle nur auf die Entscheidungstabellen. Im Modeler wird diese Notation entsprechend in eine grafische Form gebracht, damit eine Modellierung auch von nicht technik-affinen Personen erfolgen kann.

Um nun die so erstellte DMN-Datei auch in unserer Anwendung nutzen zu können, habe ich eine Java-Klasse implementiert, in der ich zunächst die DMN-Engine erzeuge und mit der DMN-Datei initialisiere. Anschließend kann ich mir dann die eigentliche Entscheidungstabelle aus dieser Datei geben lassen.

private DmnEngine dmnEngine;
private DmnDecision discountDecision;

@PostConstruct
void init() {
  dmnEngine = DmnEngineConfiguration
      .createDefaultDmnEngineConfiguration()
      .buildEngine();
  discountDecision = dmnEngine.parseDecision("discount", this.getClass().getResourceAsStream("/shop.dmn"));
}

Für die Ermittlung des Rabattes nutze ich nun keinen Code, der in einer Java-Methode implementiert wurde, sondern die Entscheidungstabelle. Auch diese verwendet Eingabeparameter um ein Ergebnis liefern zu können. Diese werden über eine Variablen Map in die DMN-Engine übergeben.

var variableMap = Variables.createVariables()
    .putValue("orders", numberOfOrders);
var decisionResult = dmnEngine.evaluateDecision(discountDecision, variableMap);

Durch den Einsatz der Entscheidungstabelle habe ich die notwendige Implementierung der Geschäftslogik ausgelagert. Diese Logik kann nun durch den Einsatz des Modellers auch von Personen aus der Fachabteilung gepflegt werden. Ändern sich die Anforderungen ist es jederzeit möglich die Tabelle entsprechend anzupassen und diese ohne eine weitere Codeanpassung in der Anwendung zu nutzen. Ich hoffe ich konnte an diesem kleinen Beispiel die Möglichkeiten dieser Technologie zeigen. Die Beispielanwendung ist hier zu finden.

Generative KI Modelle sind immer noch in aller Munde und mittlerweile hat vermutlich jeder schon einmal versucht sich mit ChatGPT zu unterhalten. Um die KI Modelle auch programmatisch anzufragen bieten einige Anbieter auch (meist kommerzielle) HTTP-Schnittstellen an, mit denen wir unsere Anwendungen mit den Fähigkeiten der KI Modelle ausstatten können. Das manuelle Erstellen von HTTP-Anfragen wird allerdings schnell anstrengend und fehleranfällig. Für etwas mehr Komfort gibt es mittlerweile auch für Java die ein oder andere Bibliothek, die uns den Zugriff erleichtert. Lokale Ausführungsumgebungen wie Ollama ermöglichen es uns außerdem diese Anfragen zu verarbeiten, ohne dass Informationen unseren Kontrollbereich verlassen.

LangChain4j ist bereits am längsten verfügbar und kann in Java-Projekten mit beliebigem Unterbau verwendet werden. Es gibt zudem spezifische Integrationen für Spring Boot und Quarkus für die Verwendung in dem jeweiligen Framework. Spring AI ist eine etwas neuere Entwicklung aus dem Spring Team und konkret auf die Verwendung im Spring Framework zugeschnitten. Zu guter Letzt bietet auch Microsoft eine Java-Variante seiner .NET-Bibliothek Semantic Kernel.

All diese Bibliotheken sind in sehr aktiver Entwicklung, es bleibt spannend zu beobachten ob sich hier ein Favorit durchsetzen wird. Wir werden uns in weiteren Blog-Posts einige Funktionalitäten und Umsetzungen noch genauer anschauen.

Viele Anwendungen speichern User- und Berechtigungsdaten in Datenbank-Tabellen. Mit Hilfe der Extension quarkus-elytron-security-jdbc ist es sehr einfach, in Quarkus-Anwendungen Security-Daten aus Tabellen zu nutzen. Die Daten gehen dabei in das Standard-Security-System von Jakarta EE ein und können dann bspw. programmatisch genutzt werden:

1
2
3
4
5
6
7
@GET
@Path("name")
@Produces(MediaType.TEXT_PLAIN)
public String getName(@Context SecurityContext securityContext) {
  Principal userPrincipal = securityContext.getUserPrincipal();
  return userPrincipal != null ? userPrincipal.getName() : null;
}

Mit den üblichen Annotationen ist auch eine deklarative Nutzung möglich:

1
2
3
4
5
6
7
@GET
@Path("restricted")
@RolesAllowed("demoRole")
@Produces(MediaType.TEXT_PLAIN)
public String restricted() {
  return "OK";
}

Zur Konfiguration reichen zwei SQL Queries mit ein paar Zusatz-Properties:

01
02
03
04
05
06
07
08
09
10
quarkus.security.jdbc.enabled=true
quarkus.security.jdbc.principal-query.sql=SELECT u.CRYPTED_PWD FROM SHOWCASE_USERS u WHERE u.ID=?
quarkus.security.jdbc.principal-query.bcrypt-password-mapper.enabled=true
quarkus.security.jdbc.principal-query.bcrypt-password-mapper.password-index=1
quarkus.security.jdbc.principal-query.bcrypt-password-mapper.salt-index=-1
quarkus.security.jdbc.principal-query.bcrypt-password-mapper.iteration-count-index=-1
quarkus.security.jdbc.principal-query.roles.sql=SELECT r.ROLE FROM SHOWCASE_ROLES r WHERE r.USER_ID=?
quarkus.security.jdbc.principal-query.roles.attribute-mappings.0.index=1
quarkus.security.jdbc.principal-query.roles.attribute-mappings.0.to=groups

Die Beispiele stammen aus unserer Quarkus-Demo-Anwendung quarkus-security-jdbc, die in https://github.com/GEDOPLAN/quarkus-demo enthalten ist.

Die Konfiguration unseres Java-Backend über Umgebungsvariablen zur Laufzeit ist zum Beispiel mit MicroProfile-Config im JEE/Quarkus Kontext oder ConfigurationProperties in Spring Boot ziemlich einfach. Wenn unsere Anwendung einmal gebaut ist, können wir sie in unterschiedlichen Umgebungen ausrollen und verwenden. Das ist insbesondere praktisch, wenn wir die Anwendung als Containerimage (z.B. mit Docker) bereitstellen. Hier ist es üblich den Container bei der Ausführung mit den notwendigen Konfigurationen über Umgebungsvariablen zu versorgen. So können wir unser Image auch zuerst in einer Testumgebung betreiben und nach einer erfolgreichen Prüfung in die Produktivumgebung ausrollen ohne dabei das Image anpassen zu müssen. Diese Vorgehensweise kann allerdings etwas ins Stocken geraten, wenn wir auch unser JavaScript-Frontend derart konfigurieren wollen. Das Problem tritt in unseren Projekten zum Beispiel auf, wenn die Anwendung über einen SSO-Server wie Keycloak authentifiziert wird, die URL und andere Konfigurationen für die Anmeldung sich aber je nach Einsatzumgebung unterscheiden.

In einer Angular-Anwendung geschieht die Konfiguration (ähnlich wie auch in anderen JavaScript-Anwendungen) üblicherweise zur Build-Zeit über Environments (siehe: https://angular.io/guide/build#configuring-application-environments). Hier kann man grundsätzlich auch mehrere Umgebungen unterscheiden, die Konfigurationswerte müssen allerdings bereits zum Build-Zeitpunkt festgelegt werden, unabhängig von der späteren tatsächlichen Einsatzumgebung.

Diese Beschränkung ist für den Betrieb der Anwendung als Containerimage natürlich ärgerlich. Gibt es nicht einen Weg wie wir die einfache Konfigurierbarkeit des Java-Backend nutzen können um die Umgebungsvariablen auch dem Angular-Frontend zur Verfügung zu stellen? Klar! Das Frontend fragt sowieso die darzustellenden Daten beim Backend an, da können wir auch eine Schnittstelle hinzufügen, die die Werte der Umgebungsvariablen bereitstellt.

Konfiguration des Backend

In unserem einfachen Beispielprojekt (GitHub) stellen wir im Quarkus-Backend am Endpunkt /api/envConfig die Property de.gedoplan.quinoaDemo.envConfig.runtimeEnvironment bereit. Für die Konfiguration verwenden wir die @ConfigProperties Annotation von MicroProfile-Config.

1
2
3
4
5
@ConfigProperties(prefix = "de.gedoplan.quinoaDemo.envConfig")
public class EnvConfig {
  private String runtimeEnvironment;
  [... Getter ...]
}
01
02
03
04
05
06
07
08
09
10
11
12
13
@Path("/envConfig")
public class EnvConfigResource {
  @Inject
  @ConfigProperties
  EnvConfig envConfig;
  @GET
  @Produces(MediaType.APPLICATION_JSON)
  public EnvConfig getEnvConfig() {
    return envConfig;
  }
}

Den Konfigurationswert können wir in der application.properties Datei mit default-Werten belegen und/oder zur Laufzeit zum Beispiel über Umgebungsvariablen setzen:

1
2
3
%dev.de.gedoplan.quinoaDemo.envConfig.runtimeEnvironment=default-dev
%test.de.gedoplan.quinoaDemo.envConfig.runtimeEnvironment=default-test
%prod.de.gedoplan.quinoaDemo.envConfig.runtimeEnvironment=default-prod
1
2
DE_GEDOPLAN_QUINOADEMO_ENVCONFIG_RUNTIMEENVIRONMENT=<env-name> \
java -jar target/quarkus-app/quarkus-run.jar
1
2
3
docker run -p 8080:8080 \
-e DE_GEDOPLAN_QUINOADEMO_ENVCONFIG_RUNTIMEENVIRONMENT=<env-name> \
quinoa-demo

Laden der Konfiguration im Frontend

Das Frontend im Beispielprojekt ist eine Angular 17 Anwendung mit standalone components. Dasselbe Prinzip kann allerdings auch in einer Anwendung mit @NgModule verwendet werden.

Zunächst legen wir hier ein Interface und einen Service an um die Konfiguration aus dem Backend zu laden und bereitzustellen.

1
2
3
export interface EnvConfig {
  runtimeEnvironment: string;
}
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
@Injectable()
export class EnvConfigService {
  private config!: Promise<EnvConfig>;
  constructor(private http: HttpClient) {}
  loadConfig(): void {
    this.config = firstValueFrom(this.http.get<EnvConfig>('/api/envConfig'));
  }
  getConfig(): Promise<EnvConfig> {
    if (!this.config) {
      this.loadConfig();
    }
    return this.config;
  }
}

In der ApplicationConfig, die in main.ts an bootstrapApplication() übergeben wird, können wir dafür sorgen, dass die Konfiguration beim Initialisieren der Anwendung geladen wird. In diesem Fall wird die ApplicationConfig in app.config.ts erstellt.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
export function initializeEnvConfigService(envConfigService: EnvConfigService) {
  return () => envConfigService.loadConfig();
}
export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    importProvidersFrom(HttpClientModule),
    EnvConfigService,
    {
      provide: APP_INITIALIZER,
      useFactory: initializeEnvConfigService,
      multi: true,
      deps: [EnvConfigService],
    }
  ]
};

Über den EnvConfigService können wir nun auf die Konfiguration zugreifen:

01
02
03
04
05
06
07
08
09
10
11
12
@Component({[...]})
export class DetailComponent implements OnInit {
  envConfig = {
    runtimeEnvironment: ''
  } as EnvConfig;
  constructor(private envConfigService: EnvConfigService) { }
  ngOnInit(): void {
    this.envConfigService.getConfig().then(data => this.envConfig = data)
  }
}

Anwendungsfall: Keycloak-Authentifizierung

Wie in dem einfachen Beispiel oben können wir auch eine Keycloak-Authentifizierung damit konfigurieren. Eine beispielhafte Umsetzung findet sich auf dem keycloak Branch des Repositories. Für die Authentifizierung verwenden wir keycloak-angular als Dependency. Diese muss mit der Keycloak-URL, dem Realm und der Client-Id konfiguriert werden. Vor dem Initialisieren des KeycloakService können wir in der app.config.ts sicherstellen, dass die Konfiguration geladen ist und die entsprechenden Werte verwenden:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function initializeEnvConfigAndKeycloak(keycloak: KeycloakService, envConfigService: EnvConfigService) {
  return () => new Promise<boolean>((resolve) => {
    envConfigService.getConfig().then(envConfig => {
      resolve(keycloak.init({
        config: {
          url: envConfig.keycloakFrontendUrl,
          realm: envConfig.keycloakRealm,
          clientId: envConfig.keycloakClient,
        },
        loadUserProfileAtStartUp: true,
        initOptions: {
          onLoad: 'login-required'
        }
      }));
    });
  });
}
export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    importProvidersFrom(HttpClientModule),
    KeycloakAngularModule,
    importProvidersFrom(KeycloakAngularModule),
    EnvConfigService,
    {
      provide: APP_INITIALIZER,
      useFactory: initializeEnvConfigAndKeycloak,
      multi: true,
      deps: [KeycloakService, EnvConfigService]
    }
  ]
};

Im Quarkus-Backend ist die Keycloak-Authentifizierung konfiguriert, der Endpunkt zum Laden der Konfiguration muss allerdings ungesichert erreichbar sein um die Angular-Anwendung zu initialisieren. Da hier nur Informationen übertragen werden, die sowieso öffentlich im JavaScript-Client konfiguriert werden, stellt das auch kein Sicherheitsproblem dar. Geheime Schlüssel oder ähnliches sollten natürlich über diesen Weg nicht übertragen werden.

Die Anwendung kann jetzt ohne weitere Konfigurationsanpassung im Angular-Frontend ausgeführt werden und ein erstellter Container kann bei der Ausführung zum Beispiel in einem Kubernetes-Cluster einfach über das Backend konfiguriert werden.

Das ganze Projekt inklusive Ausführungsbeispiele gibt es auf GitHub.

Der Alltag eines Entwicklers ist oftmals geprägt von sich immer wiederholenden Entwurfsmustern. Möchte man beispielsweise eine REST-Schnittstelle für persistente Datenobjekte entwickeln muss man zunächst die gewünschten Entitäten definieren. Anschließend sorgt man ggf. mit Repository-Klassen dafür, dass diese Objekte auch persistieren. Zu guter Letzt implementiert man noch die Ressourcen bzw. Endpunkte, die diese Daten nach außen bringen bzw. von dort annehmen können.

Es wäre doch schön, wenn man sich einige dieser Schritte sparen könnte und es einen automatisierten Weg gäbe, der mir diese Arbeiten abnimmt. Mit Hilfe von Quarkus und Panache ist genau das möglich. In diesem Blog Beitrag möchte ich euch das einmal anhand eines einfachen Beispiels zeigen.

Entitäten definieren

Was uns durch keinen Automatismus abgenommen werden kann ist natürlich die Definition unserer Entität. Diese können wir mit Hilfe von Jakarta Persistence (Hibernate) wie folgt definieren:

01
02
03
04
05
06
07
08
09
10
@Getter
@Setter
@Entity
public class Participant extends PanacheEntityBase {
  @Id
  private Long id;
  private String name;
  @Enumerated(EnumType.STRING)
  private Level level;
}

Wir benutzen an dieser Stelle Lombok um uns Getter- und Setter-Methoden sparen zu können. Darüber hinaus sollten die Methoden equals und hashcode natürlich JPA-konform definiert werden.

Repository anlegen

Panache bietet die Möglichkeit zwei Ansätze für den Zugriff auf die Entitäten einzusetzen. Zum Einen das Active Record Pattern und zum Anderen das Repository Pattern. Ich habe mich an dieser Stelle für Letzteres entschieden, womit es notwendig wird eine Repository Klasse zu definieren.

1
2
3
@ApplicationScoped
public class ParticipantRepository implements PanacheRepository<Participant> {
}

Panache liefert uns an dieser Stelle bereits die grundlegenden CRUD-Methoden mit. Diese könnten wir nun einsetzen um in einem Rest-Endpunkt die Zugriffe zu implementieren.

Endpunkt erzeugen

Damit Panache uns automatisiert unseren Endpunkt erzeugen kann, müssen wir lediglich ein entsprechendes Interface anlegen und mit unserer Repository-Klasse „verbinden“.

1
2
3
@ApplicationScoped
public interface ParticipantResource extends PanacheRepositoryResource<ParticipantRepository, Participant, Long> {
}

Anschließend stehen uns die üblichen Methoden zum Zugriff auf die Ressource zur verfügung. Eine Liste aller Einträge lässt sich bspw. über folgenden Aufruf abrufen.

 

1
$ curl http://localhost:8080/participant -H "Accept: application/json"

Darüber hinaus gibt es noch weitere nützliche Parameter, die man bei dem Aufruf mitgeben kann. Mit size kann man die Anzahl der gleichzeitig zurückgelieferten Elemente bestimmen. Auch eine Paginierung ist bereits vorhanden. Für weitere Informationen sei auf die Quarkus-Dokumentation verwiesen.

Neben der Nutzung von Hibernate bzw. Jakarta Persistence lässt sich ebenfalls eine MongoDB zur Speicherung der Daten nutzen.

Das Beispiel haben wir wie immer auf Github abgelegt.

Insbesondere bei der Migration einer Quarkus 2 Anwendung auf Quarkus 3 stößt man beim Testen der Anwendung im K8s-Cluster eventuell auf das Problem, dass die Anwendung über den konfigurierten Ingress nicht erreichbar ist und uns nur eine Fehlerseite mit dem HTTP Statuscode 502 Bad Gateway vom nginx Ingress erwartet. Dabei hat man doch in der Konfiguration des Ingress gar nichts verändert!

Das Problem wird klarer, wenn man sich den Log des Ingress Controllers anschaut. Hier findet sich folgender Abschnitt:

[...] upstream sent too big header while reading response header from upstream [...]

Die Authentifizierung unserer Anwendung läuft in diesem Fall über OIDC und im K8s-Cluster läuft zu diesem Zweck ein Keycloak. Quarkus 3 speichert anders als Quarkus 2 die notwendigen Tokens in den Browser Cookies nun standardmäßig verschlüsselt. Das führt dazu, dass die Cookies größer werden und auch der „Set-Cookie“ HTTP-Header, mit dem die Anwendung den Browser anweist die Tokens in einem Cookie zu speichern, an Größe zunimmt. Standardmäßig hat der häufig eingesetzte nginx als Ingress Controller eine proxy-buffer-size von 4k eingestellt, leider reißt unsere HTTP-Response nun dieses Limit und der Ingress antwortet stattdessen mit einem Fehler. Die Lösung für dieses Problem ist allerdings relativ einfach umsetzbar, denn eine Vergrößerung dieser Einstellung lässt das Problem verschwinden. Im Falle des nginx Ingress Controllers kann die Konfiguration z.B. direkt an der spezifischen K8s-Ingress Beschreibung vorgenommen werden.

1
2
3
4
5
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    nginx.ingress.kubernetes.io/proxy-buffer-size: "16k"

Hier kann dann die proxy-buffer-size erhöht werden und die Anwendung sollte nun auch in der Quarkus 3 Version erreichbar sein.

Kontakt

Brauchen Sie eine fundierte Beratung oder eine individuelle Softwareentwicklung? Dann sind Sie hier genau richtig!

Markus Pauer GEDOPLAN Geschäftsführung

Markus Pauer

Geschäftsleitung

GEDOPLAN GmbH
Stieghorster Straße 60
33605 Bielefeld

GEDOPLAN GmbH
Kantstraße 164
10623 Berlin

    Kontakt

    Markus Pauer GEDOPLAN Geschäftsführung

    Markus Pauer

    Geschäftsleitung

    GEDOPLAN GmbH
    Stieghorster Straße 60
    33605 Bielefeld

    GEDOPLAN GmbH
    Kantstraße 164
    10623 Berlin

    Brauchen Sie eine fundierte Beratung oder eine individuelle Softwareentwicklung? Dann sind Sie hier genau richtig!