Hilla AutoGrid mit MongoDB

René Wilby | 15.01.2024 Min. Lesezeit

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

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.

AutoGrid with PersonService

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.