Reactive Rest mit Spring Boot und MongoDB

Vorwort

In diesem Beitrag zeige ich wie man einen reactive RestController erstellt. Es wird die gesamte Anwendung reaktiv sein, d.h. wir verwenden hier den spring-boot-starter-data-mongodb-reactive Client mit seiner reaktiven Repository Schnittstelle.

Das Beispiel ist mit Absicht extrem einfach gehalten, um das Wesentliche besser darzustellen. In dem Beispiel soll ein RestController eine API mit den üblichen CRUD Methoden zur Verfügung stellen. Es soll in der Datenbank für Reservierungen geschaffen werden. Die Konfiguration wird nicht klassisch mit Annotationen durchgeführt, sondern ich zeige hier die Anwendung von functional configuration zum Setzen der Routen zu den entsprechenden Handlermethoden. Die Anwendung soll es ermöglichen eine Reservierung zu erstellen, lesen, löschen und zu ändern.

Die Dependencies

Um das Beispiel umzusetzen benötigen wir Abhängigkeiten für spring-boot-starter-data-mongodb-reactive und spring-boot-starter-webflux. Die weitere Abhängigkeiten (s.u.) sind optional, aber sollten dennoch mit eingebunden werden, da sie das Leben doch vereinfachen können.

dependencies {
    compile('org.springframework.boot:spring-boot-starter-data-mongodb-reactive')
    compile('org.springframework.boot:spring-boot-starter-webflux')
    compile('org.springframework.boot:spring-boot-actuator')
    runtime('org.springframework.boot:spring-boot-devtools')

    // Project Lombok
    // Since Gradle warns if an AnnotationProcessor is found on classpath, put 
    // it into annotationProcessor directive
    compileOnly('org.projectlombok:lombok')
    annotationProcessor('org.projectlombok:lombok')

    // Testing
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('io.projectreactor:reactor-test')

    // flapdoodle runs a temporary embedded instance of mongodb
    testRuntime('de.flapdoodle.embed:de.flapdoodle.embed.mongo:2.0.1')
}

Project Lombok

Ich mag keinen Boilerplate Code, da er gerade bei Entitäten den eigentlichen Verwendungszweck verdeckt. Daher bin ich ein großer Fan von dem Project Lombok und setze es hier in diesem Beispiel auch ein.

Flapdoodle

Zusätzlich für die Entwicklung und das Unittesting binde ich hier Flapdoodle ein. Flapdoodle kann eine temporäre embedded Version von MongoDB starten. Dazu lädt es ggf. MongoDB herunter und startet für jeden Test eine temporäre Instanz der MongoDB Datenbank. Es wird immer mit einer leeren Datenbank gestartet, so dass hier im Reinraum getestet werden kann, ohne das man zuvor aufräumen muss.

Actuator

Ich habe hier auch spring-boot-actuator mit aufgenommen, damit ich in der Spring Tool Suite 4 die Endpoints in den Properties der Anwendung angezeigt bekomme, bzw. die Live Hover Informationen nutzen kann (siehe Bug in Live Hover).

Die Klasse Reservation

Die Klasse Reservation bildet eine Reservierung ab. Da es hier nur um eine Demonstration handelt, ist hier natürlich genügend Spielraum vorhanden für Erweiterungen. Wie bereits beschrieben nutze ich hier Lombok Annotationen, um möglichst sauber von Boilerplate Code zu arbeiten

Die Klasse Reservation ist also kurz und knackig.

@Data
@NoArgsConstructor
@AllArgsConstructor
@Document
class Reservation {
    private String id;

    private String reservationName;
}

Die Annotation @Document beschreibt des sich um ein MongoDB Dokument handelt. Ein Dokument hält einen Datensatz. In MongoDB werden als JSON Objekte übergeben und dann intern als BSON abgespeichert.

Mono und Flux

Bei der reaktiven Programmierung gibt es die beiden Klassen Mono und Flux die das Publisher Interface implementieren. Beide Implementierungen liefern einen Stream von Elementen mit…

Mono genau 0 oder 1 Elemente
Flux 0 oder 1...n Elemente

Die Konfiguration der Routen

Die Klasse ReservationRouter übernimmt das Routing der Request zu den entsprechenden Handlermethoden in der Klasse RequestHandler.

Ein GET Aufruf auf /reservations gibt JSON mit allen Reservierungen aus der Datenbank zurück

Ein GET Aufruf auf /reservation/{id} lädt genau eine Reservierung mit der angegebenen Id

Ein DELETE Aufruf auf /reservation/{id} löscht den referenzierten Datensatz

Ein POST Aufruf auf /reservation erzeugt eine neue Reservierung

Ein PUT Aufruf auf /reservation/{id} modifiziert den referenzierten Datensatz

Die statische Methode i macht die Aufrufe CaseInsensitiv.

@Configuration
public class ReservationRouter {
    @Bean
    RouterFunction<ServerResponse> routes(RequestHandler handler) {
        return route(i(GET("/reservations")), handler::all)
            .andRoute(i(GET("/reservation/{id}")), handler::getById)
            .andRoute(i(DELETE("/reservation/{id}")), handler::deleteById)
            .andRoute(i(POST("/reservation")), handler::create)
            .andRoute(i(PUT("/reservation/{id}")), handler::updateById)
            ;
    }

    private static RequestPredicate i(RequestPredicate target) {
        return new CaseInsensitiveRequestPredicate(target);
    }
}

Die statische Methode route definiert nun mit den angegebenen RequestPredicates (GET, DELETE, POST und PUT) und einem Patternstring, welche Handlermethode den Request bearbeitet.

Warum muss ich schon wieder eine neue Methode zur Konfiguration lernen?

Sicherlich haben Sie sich auch schon gefragt, Moment Mal, das geht doch auch alles mit den bewährten Annotationen. Ja, das stimmt und daran gibt es auch nicht auszusetzen. Es gibt allerdings einen triftigen Grund warum man sich de functional configuration annehmen sollte. Es ist die Performance. Es lassen sich die Startupzeiten mit dem neuen funktionalen Ansatz drastisch, gegenüber der klassischen Methode mit Annotationen, verkürzen. Dieses ist insbesondere im Microservice Bereich, wo schnell mal ein paar zusätzliche Instanzen gespawnt werden müssen, wichtig.

Die Handler Klasse

Die Handlerklasse RequestHandler verarbeitet nun die ankommenden Requests und führt Aktionen auf dem Service durch. Die Methode getById und deleteById werden direkt durchgereicht an den Service. Es wird nur die Pathvariable id extrahiert und dem Service als Parameter übergeben.

Die Handlermethoden liefern ein Mono<ServerResponse> zurück. Spannend wird es der Aufruf von create hier wird der Übergebene ServerRequest in eine Reservation.class gemappt und der Service soll die Reservierung anlegen.

Zu bemerken ist, dass das reaktive Paradigma hier in beide Richtungen eingehalten wird. Das Anfragen die und auch Antworten als Mono oder Flux zwischen dem Server und dem Service ausgetauscht werden. Der hier gezeigte Handler ist vollkommen Asynchron und auch reaktiv, da hier keine blockierenden Operationen ausgeführt werden.

@Component
public class RequestHandler {
    private ReservationService service;

    public RequestHandler(ReservationService service) {
        this.service = service;
    }

    Mono<ServerResponse> getById(ServerRequest r) {
        return defaultReadResponse(service.getById(id(r)));
    }

    Mono<ServerResponse> deleteById(ServerRequest r) {
        return defaultReadResponse(service.deleteById(id(r)));
    }

    Mono<ServerResponse> create(ServerRequest r) {
        Flux<Reservation> flux = r
                .bodyToFlux(Reservation.class)
                .flatMap(toWrite -> service.create(toWrite.getReservationName()));
        return defaultWriteResponse(flux);
    }

...

    private static Mono<ServerResponse> defaultWriteResponse(Publisher<Reservation> reservations) {
        return Mono
                .from(reservations)
                .flatMap(r -> ServerResponse.created(URI.create("/reservation/" + r.getId()))
                .contentType(MediaType.APPLICATION_JSON_UTF8).build());
    }

    private static Mono<ServerResponse> defaultReadResponse(Publisher<Reservation> publisher) {
        return ServerResponse
                .ok()
                .contentType(MediaType.APPLICATION_JSON_UTF8)
                .body(publisher, Reservation.class);
    }

    private static String id(ServerRequest r) {
        return r.pathVariable("id");
    }
}

Der Service

Im vorherigen Abschnitt haben wir beleuchtet wie die Anfragen im Handler verarbeitet werden. Der Service bildet hier fast 1:1 das Repository ab.

@Service
public class ReservationService {
    private ReservationRepository repo;

    public ReservationService(ReservationRepository repo) {
        this.repo = repo;
    }

    public Mono<Reservation> getById(String id) {
        return repo.findById(id);
    }

    public Flux<Reservation> findAll() {
        return repo.findAll();
    }

    public Mono<Reservation> deleteById(String id) {
        return repo.findById(id)
                .flatMap(
                        r -> repo.deleteById(r.getId())
                        .thenReturn(r)
                );
    }

    public Mono<Reservation> create(String reservationName) {
        return repo.save(new Reservation(null, reservationName));
    }
}

Der Service greift auf das reaktive Repository ReservationRepository zu und es werden entsprechend Mono oder Flux<reservation> zurückgegeben. Bei der findAll() haben wir einen Stream mit allen Datensätzen aus der Datenbank. Deshalb kommt hier Flux als Implementierung des Publisher Interfaces zum Einsatz, weil es 0 bis n Datensätze sein können.

Das ReservationRepository

Das Repository ist von Typ ReactiveMongoRepository und stellt die CRUD Methoden zur Verfügung. Wir verwenden hier derived queries, um eine zusätzliche Anfrage für den Service bereitzustellen. Mit findBy+Property kann Spring Data aus dem Methodennamen den gewünschten Query erzeugen. Das heißt wir suchen mit findByReservationName(String name) nach einem Datensatz wo die Property ReservationName dem übergebenen Parameter entspricht.

Dieses ist aber ein Standard Feature von Spring Data und hat nichts mit der reaktiven Implementierung zu tun. Ansonsten gibt es nichts besonderes in dem Interface.

public interface ReservationRepository extends ReactiveMongoRepository<Reservation, String> {
    Flux<Reservation> findByReservationName(String name);
}

Mit curl die Rest API aufrufen

Das Testen der Anwendung möchte ich hier mit dem Kommandozeilenprogramm curl demonstrieren. Es sind standard HTTP Anfragen an den Server, so dass ich hier nur kurz insert und delete zeige.

Einfügen einer Reservierung

curl -i -X POST -H 'Content-Type: application/json' -d '{ "reservationName": "1. Test" }' http://localhost:8080/reservation

Es wird eine Antwort wie zum Beispiel…

HTTP/1.1 201 Created
Content-Type: application/json;charset=UTF-8
Location: /reservation/5bf68a11dbcbb837d33f4be6
content-length: 0

erzeugt, die das erfolgreiche Anlegen des Datensatze bestätigen.

Löschen einer Reservierung

curl -i -X DELETE http://localhost:8080/reservation/5bf3eda398947a5158e60200