Hilla AutoCrud
Das Full-Stack-Web-Framework Hilla stellt mit AutoCrud eine Komponente zur Verfügung, die aus einer Tabelle (AutoGrid) und einem integrierten Formular (AutoForm) besteht. Über das Formular stehen CRUD-Funktionen zur Verfügung, d.h. Daten können erstellt, angezeigt, bearbeitet und gelöscht werden. In der Tabelle können alle Daten schnell und einfach dargestellt, sortiert und gefiltert werden. 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 AutoCrud 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 AutoCrud-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 && docker 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-autocrud-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-autocrud-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 AutoCrud-Komponente anzeigen und bearbeiten 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, Getter- und Setter-Methoden erstellt:
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDate;
@Document
public class Person {
@Id
private String id;
@NotBlank
private String firstName;
@NotBlank
private String lastName;
@NotBlank
private String street;
@NotNull
@DecimalMin("10000")
@DecimalMax("99999")
private int zipcode;
@NotBlank
private String city;
@NotBlank
private String country;
@NotBlank
@Email
private String mail;
@NotBlank
private String phone;
@NotNull
private LocalDate dateOfBirth;
@Version
private int version;
// Getter
// Setter
}
Die Felder id
und version
werden von MongoDB verwaltet und sollten daher im Frontend nur angezeigt, aber nicht verändert werden können. Dies lässt sich derzeit noch nicht fehlerfrei über passende Annotation direkt in der DTO erzielen, daher erfolgt die notwendige Konfiguration später im Frontend.
Die weiteren Felder tragen verschiedene Annotationen aus dem Package jakarta.validation.constraints
, wie bspw. @NotBlank
, @NotNull
oder @Email
. Hilla berücksichtigt diese Annotation für die Validierung der eingegebenen Daten im Formular der AutoCrud-Komponente.
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 AutoCrud-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
CrudService-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 AutoCrud-Komponente verwenden zu können, muss man im nächsten Schritt einen Service zur Verfügung stellen, der das Interface CrudService<T, ID>
implementiert und der die Methoden list
, save
und delete
für das Frontend bereitstellt, in dem sich später die AutoCrud-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.CrudService;
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 CrudService<Person, String> {
@Autowired
private MongoTemplate mongoTemplate;
@Override
public @Nonnull List<@Nonnull Person> list(Pageable pageable, @Nullable Filter filter) {
// TODO
}
@Override
public @Nullable Person save(Person person) {
// TODO
}
@Override
public void delete(String id) {
// 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 AutoCrud-Komponente erzeugt und beim Aufruf des Backends übergeben. Mit Hilfe der beiden Objekte können die Daten in der AutoCrud-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()));
};
case "dateOfBirth" ->
switch (propertyFilter.getMatcher()) {
case GREATER_THAN -> Criteria.where(propertyFilter.getPropertyId()).gt(LocalDate.parse(propertyFilter.getFilterValue()));
case LESS_THAN -> Criteria.where(propertyFilter.getPropertyId()).lt(LocalDate.parse(propertyFilter.getFilterValue()));
default -> Criteria.where(propertyFilter.getPropertyId()).is(LocalDate.parse(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 AutoCrud-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=[]]
Die verbleibenden Methoden save
und delete
sind vergleichsweise leicht zu implementieren:
@Override
public @Nullable Person save(Person person) {
return mongoTemplate.save(person);
}
@Override
public void delete(String id) {
mongoTemplate.findAndRemove(Query.query(Criteria.where("id").is(id)), Person.class);
}
In beiden Fällen kann direkt auf die passenden Methoden von MongoTemplate
zurückgegriffen werden.
Das Frontend verbinden
Der bereitgestellte PersonService
kann dank der Annotation @BrowserCallable
und der Implementierung des Interface CrudService<T, ID>
sehr komfortabel im Frontend in Verbindung mit der AutoCrud-Komponente verwendet werden. Dazu wird die Datei PersonView.tsx
im Verzeichnis frontend/views/person
erstellt:
import { AutoCrud } from "@hilla/react-crud";
import PersonModel from "Frontend/generated/com/example/application/entities/PersonModel";
import { PersonService } from "Frontend/generated/endpoints";
export default function PersonView() {
const columnsToHide = ['id']
const visibleColumns = Object.getOwnPropertyNames(PersonModel.prototype).filter((key) => columnsToHide.indexOf(key) == -1)
const fieldsToHide = ['id', 'version']
const visibleFields = Object.getOwnPropertyNames(PersonModel.prototype).filter((key) => fieldsToHide.indexOf(key) == -1)
return (
<AutoCrud service={PersonService} model={PersonModel} gridProps={{ visibleColumns }} formProps={{ visibleFields }} />
)
}
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.
Wie eingangs bereits beschrieben, werden die Felder id
und version
von MongoDB verwaltet und sollten daher in der AutoCrud-Komponente nicht verändert werden können und ggf. nur angezeigt werden. Dies kann über die Eigenschaften visibleColumns
der integrierten AutoGrid-Komponente bzw. visibleFields
der integrierten AutoForm-Komponente erreicht werden. Im vorliegenden Beispiel wird das Feld id
weder in der Tabelle noch im Formular angezeigt. Das Feld version
wird in der Tabelle, aber nicht im Formular angezeigt. Die Ermittlung der Werte für visibleColumns
und visibleFields
erfolgt hier über den Umweg mit Object.getOwnPropertyNames(PersonModel.prototype)
. Auf diese Weise müssen nur die Felder, die nicht angezeigt werden sollen, explizit benannt werden, anstatt alle Felder aufzulisten, die angezeigt werden sollen.
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 AutoCrud-Komponente sortiert werden. Die Filter können beliebig miteinander kombiniert werden, um die anzuzeigenden Daten zu filtern. Die AutoCrud-Komponente liest die anzuzeigenden Daten seitenweise, d.h. je weiter man in der Komponente nach unten scrollt, desto mehr Daten werden dynamisch nachgeladen. Wird ein Eintrag selektiert, kann dieser über das integrierte Formular bearbeitet und gelöscht werden. Neue Einträge können über die Schaltfläche + New
über das Formular erfasst werden.
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 AutoCrud-Komponente im Frontend. Mit Hilfe des Interface CrudService<T, ID>
kann die AutoCrud-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 AutoCrud-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.