Hilla AutoGrid
Das Full-Stack-Web-Framework Hilla stellt mit AutoGrid eine Komponente zur Verfügung, mit der Daten schnell und einfach in einer Tabelle dargestellt, sortiert und gefiltert werden können. Die Daten werden aus einem Java-Backend über einen sog. BrowserCallable als Service zur Verfügung gestellt. Liegen die Daten in einer relationalen Datenbank, wie bspw. MySQL oder PostgeSQL, vor, können sie mit Hilfe von Spring Data und JPA sehr einfach angebunden werden. Eine gute Anleitung dazu findet sich in der offiziellen Dokumentation von Hilla.
Hilla AutoGrid und MongoDB
Seit Version 2.5 von Hilla besteht nun eine vereinfachte Möglichkeit auch andere nicht-relationale Datenbanken, wie bspw. MongoDB, als Datenquelle für die AutoGrid-Komponente zur verwenden. Im Folgenden wird gezeigt, was genau dafür zu tun ist.
MongoDB lokal starten
Zunächst benötigt man eine laufende MongoDB-Instanz. Dies kann entweder über MongoDB Atlas oder über eine lokal ausgeführte Instanz erfolgen. Für das vorliegende Beispiel wird MongoDB lokal in einem Container ausgeführt. Dafür ist eine entsprechende Laufzeit-Umgebung für Container erforderlich. Hier bieten sich bspw. Docker Desktop oder Podman Desktop an.
Nachdem die Laufzeitumgebung installiert und gestartet wurde, kann ein neuer Container mit einer laufenden MongoDB-Instanz wie folgt gestartet werden:
podman run --name mongodb --detach --publish 27017:27017 --env MONGODB_INITDB_ROOT_USERNAME=user --env MONGODB_INITDB_ROOT_PASSWORD=password mongodb/mongodb-community-server:latest
Benutzername und -passwort werden als Umgebungsvariablen übergeben. Die MongoDB-Instanz ist anschließend über localhost
und den Port 27017 erreichbar.
Zum Stoppen und Löschen des Containers können bei Bedarf folgende Befehle verwendet werden:
podman stop mongodb && podman rm mongodb
MongoDB Compass
Mit Hilfe von MongoDB Compass kann man sich mit einer laufenden MongoDB-Instanz verbinden, Datenbanken und Collections verwalten und Queries ausführen. Um zu überprüfen, dass die lokale MongoDB-Instanz korrekt gestartet wurde und ausgeführt wird, kann man sich über MongoDB Compass mit der Instanz verbinden. Dabei ist die folgende Connection-URI zu verwenden:
mongodb://user:password@localhost:27017/
Hilla-Projekt hilla-autogrid-mongodb
Da nun die MongoDB-Instanz zur Verfügung steht, wird als Nächstes ein neues Hilla-Projekt erstellt. Dazu kann u.a. die Hilla-CLI verwendet werden:
npx @hilla/cli init hilla-autogrid-mongodb
Das erstellte Projekt ist ein reguläres Spring Boot-Projekt auf Basis von Maven. Zusätzlich hat es einen frontend
-Ordner, in dem automatisch generierter und manuell geschriebener TpyeScript- und React-Code liegt, der für das Web-Frontend zum Einsatz kommt.
Doch bevor das Frontend die Daten aus der MongoDB-Instanz in der AutoGrid-Komponente anzeigen kann, muss zunächst das Backend um die erforderlichen Funktionen erweitert werden.
Spring Data MongoDB hinzufügen
Die erforderlichen Dependencies für Spring Data und MongoDB werden in der pom.xml
hinzugefügt.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
DTO erstellen
Im vorliegenden Beispiel-Projekt kommt die Klasse Person.java
als DTO zum Einsatz. Eine Instanz der Klasse entspricht dabei einem Document in einer MongoDB-Collection. Die Klasse wird mit ihren Feldern, einem Konstruktor, Getter- und Setter-Methoden erstellt:
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
@Document
@JsonIgnoreProperties(value = { "id"})
public class Person {
@Id
private String id;
private String firstName;
private String lastName;
private String street;
private int zipcode;
private String city;
private String country;
private String mail;
private String phone;
public Person(String firstName, String lastName, String street, int zipcode, String city, String country, String mail, String phone) {
this.firstName = firstName;
this.lastName = lastName;
this.street = street;
this.zipcode = zipcode;
this.city = city;
this.country = country;
this.mail = mail;
this.phone = phone;
}
// Getter
// Setter
}
Das Feld id
enthält die von MongoDB erzeugte Id des Documents in der Collection. Dieses Feld soll später im Frontend nicht angezeigt werden, daher wird es mittels @JsonIgnoreProperties(value = { "id"})
von der Code-Generierung durch Hilla ausgeschlossen.
MongoTemplate bereitstellen
Der Zugriff auf die MongoDB-Instanz erfolgt über die Template-API. Alternativ ist der Zugriff natürlich auch über den bekannten Repositories-Ansatz von Spring Data möglich. In Verbindung mit Hilla und der AutoGrid-Komponente erweist sich die Verwendung der Template-API jedoch als flexibler.
Der erforderliche MongoClient
und das MongoTemplate
werden als Beans über die Klasse MongoConfig.java
bereitgestellt:
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.core.MongoTemplate;
@Configuration
public class MongoConfig {
@Value("${mongodb.connection-uri}")
private String connectionUri;
@Value("${mongodb.database}")
private String database;
@Bean
public MongoClient mongoClient() {
return MongoClients.create(connectionUri);
}
@Bean
public MongoTemplate mongoTemplate(MongoClient mongoClient) {
return new MongoTemplate(mongoClient, database);
}
}
Die Connection-URI und der Name der Datenbank werden aus der application.properties
gelesen und sind dort folgendermaßen hinterlegt:
mongodb.connection-uri=mongodb://user:password@localhost:27017/
mongodb.database=address-book
Um sich anzuschauen, welche Queries auf der MongoDB-Instanz ausgeführt werden, lohnt es sich, das entsprechende Log-Level anzupassen. Auch dies kann in der application.properties
erfolgen:
logging.level.org.springframework.data.mongodb.core.MongoTemplate=DEBUG
Testdaten generieren
Die MongoDB-Instanz enthält zunächst keine anwendungsspezifischen Daten. Spring Data erstellt die Datenbank mit dem Namen address-book
und die Collection mit dem Namen person
auf Basis der bereits bekannten Informationen. Beim Start der Anwendung sollen darüber hinaus einige Testdaten generiert werden, die man dann später in der AutoGrid-Komponente anzeigen kann. Die Generierung der Testdaten erfolgt in der Application.java
:
import com.example.application.entities.Person;
import com.vaadin.flow.component.page.AppShellConfigurator;
import com.vaadin.flow.theme.Theme;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Query;
@SpringBootApplication
@Theme(value = "hilla-autogrid-mongodb")
public class Application implements AppShellConfigurator {
@Autowired
private MongoTemplate mongoTemplate;
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
@PostConstruct
public void init() {
mongoTemplate.findAllAndRemove(new Query(), Person.class);
// Generate test data for 500 persons
for (int i = 1; i <= 500; i++) {
mongoTemplate.insert(new Person("First Name " + i, "Last Name " + i, "Street " + i, 10000 + i, "City " + i, "Country " + i, "mail" + i + "@example.com", "123456789" + i));
}
}
}
Die init
-Methode löscht dafür zunächst alle vorhandenen Documents in der Collection person
und fügt anschließend 500 neue Documents mit Testdaten ein.
ListService-Interface implementieren
Zum jetzigen Zeitpunkt verfügt das Beispiel über eine MongoDB-Instanz und ein MongoTemplate
, mit dem man auf die Daten zugreifen können. Um die Daten für die AutoGrid-Komponente verwenden zu können, muss man im nächsten Schritt einen Service zur Verfügung stellen, der das Interface ListService<T>
implementiert und der eine Methode list
für das Frontend bereitstellt, in dem sich später die AutoGrid-Komponente befindet.
Der erforderliche Service wird in der Klasse PersonService.java
implementiert:
import com.example.application.entities.Person;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import dev.hilla.BrowserCallable;
import dev.hilla.Nonnull;
import dev.hilla.Nullable;
import dev.hilla.crud.ListService;
import dev.hilla.crud.filter.Filter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Pageable;
import org.springframework.data.mongodb.core.MongoTemplate;
import java.util.List;
@BrowserCallable
@AnonymousAllowed
public class PersonService implements ListService<Person> {
@Autowired
private MongoTemplate mongoTemplate;
@Override
public @Nonnull List<@Nonnull Person> list(Pageable pageable, @Nullable Filter filter) {
// TODO
}
}
Die Annotation @BrowserCallable
sorgt dafür, dass Hilla den erforderlichen Endpunkt und den zugehörigen Client-seitigen TypeScript-Code automatisch generiert. Damit ist später keine weitere Programmierung erforderlich, um bspw. die Kommunikationsverbindung zwischen Frontend und Backend herzustellen und Daten zu serialisieren bzw. zu deserialisieren. Aber dazu später mehr.
Die Annotation @AnonymousAllowed
gewährleistet, dass die Methoden innerhalb dieses Services ohne Authentifizierung aufgerufen werden können. Hilla bietet eine Vielzahl verschiedener Authentifizierungsmechanismen an, die im Backend auf Spring Security basieren und die im Frontend um entsprechende Hilfsfunktionen erweitert wurden.
Die Methode list
liefert eine Liste von Personen. Als Argumente erhält sie ein Pageable
-Objekt und ein Filter
-Objekt. Beide Objekte werden automatisch von der AutoGrid-Komponente erzeugt und beim Aufruf des Backends übergeben. Mit Hilfe der beiden Objekte können die Daten in der AutoGrid-Komponente sortiert, gefiltert und seitenweise geladen werden.
Für das vorliegende Beispiel besteht die nächste Aufgabe nun darin, das Pageable
-Objekt und das Filter
-Objekt so zu verarbeiten, dass sie für eine Datenbank-Abfrage über das MongoTemplate
verwendet werden können. Dazu wird der PersonService.java
entsprechend erweitert:
@Override
public @Nonnull List<@Nonnull Person> list(Pageable pageable, @Nullable Filter filter) {
return mongoTemplate.find(createQuery(pageable, filter), Person.class);
}
private Query createQuery(Pageable pageable, Filter filter) {
Query query = new Query();
query.with(pageable);
query.addCriteria(createCriteria(filter));
return query;
}
private Criteria createCriteria(Filter filter) {
Criteria criteria = new Criteria();
if (filter instanceof AndFilter andFilter) {
if (andFilter.getChildren().isEmpty()) {
return criteria;
} else {
return criteria.andOperator(andFilter.getChildren().stream()
.map(this::createCriteria).toList());
}
} else if (filter instanceof OrFilter orFilter) {
if (orFilter.getChildren().isEmpty()) {
return criteria;
} else {
return criteria.orOperator(orFilter.getChildren().stream()
.map(this::createCriteria).toList());
}
} else if (filter instanceof PropertyStringFilter propertyFilter) {
return switch (propertyFilter.getPropertyId()) {
case "firstName", "lastName", "street", "city", "country", "mail", "phone" -> Criteria.where(propertyFilter.getPropertyId()).regex(propertyFilter.getFilterValue(), "i");
case "zipcode" ->
switch (propertyFilter.getMatcher()) {
case GREATER_THAN -> Criteria.where(propertyFilter.getPropertyId()).gt(Integer.valueOf(propertyFilter.getFilterValue()));
case LESS_THAN -> Criteria.where(propertyFilter.getPropertyId()).lt(Integer.valueOf(propertyFilter.getFilterValue()));
default -> Criteria.where(propertyFilter.getPropertyId()).is(Integer.valueOf(propertyFilter.getFilterValue()));
};
default -> throw new IllegalArgumentException("Unknown filter property " + propertyFilter.getPropertyId());
};
} else {
throw new IllegalArgumentException("Unknown filter type " + filter.getClass().getName());
}
}
In der Dokumentation von Spring Data MongoDB wird in Querying Documents beschrieben, wie mit Hilfe von Query
und Criteria
sehr flexibel Datenbankabfragen zusammengestellt werden können.
Das vorhandene Pageable
-Objekt kann ohne weitere Transformation über query.with(pageable)
an ein Query
-Objekt übergeben werden.
Das Filter
-Objekt von Hilla wird in ein Criteria
-Objekt von Spring Data MongoDB überführt. Dies erfolgt in der Methode createCriteria
. Die im Filter
-Objekt enthaltenen AndFilter
bzw. OrFilter
werden dabei so lange rekursiv aufgerufen, bis auf unterster Ebene ein PropertyStringFilter
vorhanden ist. Je nach Datentyp des enthaltenen Feldes werden dann entsprechende Criteria
-Objekte erzeugt.
Ein konkretes Filter
-Objekt, dass von der AutoGrid-Komponente gesendet wird und darin bspw. 3 PropertyStringFilter
-Objekte enthält, die über einen AndFilter
verbunden sind, sieht beispielsweise folgendermaßen aus:
AndFilter [children=[PropertyStringFilter [propertyId=firstName, matcher=CONTAINS, filterValue=99], PropertyStringFilter [propertyId=lastName, matcher=CONTAINS, filterValue=N], PropertyStringFilter [propertyId=zipcode, matcher=GREATER_THAN, filterValue=9]]]
Ein leeres Filter
-Objekt sieht hingegen so aus:
AndFilter [children=[]]
Das Frontend verbinden
Der bereitgestellte PersonService
kann dank der Annotation @BrowserCallable
und der Implementierung des Interface ListService<T>
sehr komfortabel im Frontend in Verbindung mit der AutoGrid-Komponente verwendet werden. Dazu wird die Datei PersonView.tsx
im Verzeichnis frontend/views/person
erstellt:
import { VerticalLayout } from "@hilla/react-components/VerticalLayout.js";
import { AutoGrid } from "@hilla/react-crud";
import PersonModel from "Frontend/generated/com/example/application/entities/PersonModel";
import { PersonService } from "Frontend/generated/endpoints";
export default function PersonView() {
return (
<VerticalLayout className="h-full">
<AutoGrid service={PersonService} model={PersonModel} />
</VerticalLayout>
)
}
PersonModel
und PersonService
sind TypeScript-Klassen, die Hilla automatisch auf Basis der Informationen im Backend generiert und bei Änderungen aktualisiert. Während PersonModel
die Client-seitige Repräsentanz des DTO Person.java
darstellt, kümmert sich PersonService
um die Typ-sichere Kommunikation zwischen Frontend und Backend.
Das gesamte Projekt lässt sich über ./mvnw
starten und über http://localhost:8088 im Browser öffnen.
Anhand der Spaltenüberschriften können die Einträge in der AutoGrid-Komponente sortiert werden. Die Filter können beliebig miteinander kombiniert werden, um die anzuzeigenden Daten zu filtern. Die AutoGrid-Komponente liest die anzuzeigenden Daten seitenweise, d.h. je weiter man in der Komponente nach unten scrollt, desto mehr Daten werden dynamisch nachgeladen.
Fazit
Ein Hilla-Projekt ist im Backend ein klassisches Sping Boot-Projekt. Somit kann MongoDB sehr einfach über die Dependency spring-boot-starter-data-mongodb
eingebunden werden. Die notwendige Konfiguration und auch das MongoTemplate
sind schnell erstellt. Der PersonService
bildet die Schnittstelle zwischen der Datenbank im Backend und der AutoGrid-Komponente im Frontend. Mit Hilfe des Interface ListService<T>
kann die AutoGrid-Komponente sehr leicht mit einer nicht JPA-basierten Datenbank wie MongoDB verbunden werden. Dank @BrowserCallable
ist der Service ohne zusätzliche Konfiguration oder Entwicklung sofort einsatzbereit. Für das Frontend generiert Hilla die erforderlichen TypeScript-Klassen, die die Verbindung zwischen AutoGrid-Komponente und PersonService
herstellen, automatisch. Der integrierte TypeScript-Client übernimmt dabei die Kommunikation zwischen Backend und Frontend.
Der Quellcode für das Beispiel ist bei GitHub verfügbar.