Hilla AutoCrud mit MongoDB

René Wilby | 12.03.2024 Min. Lesezeit

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 Compass - New Connection

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.

AutoCrud with PersonService

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.