Suche
Teil 22: Backend meets Frontend: Trainer for kids 12

Persistenz einfach in den Entwicklungsprozess integrieren

Sven Ruppert

© Shutterstock / HelenField

Auch bei unserer Webanwendung müssen wir uns mit dem Thema Persistenz auseinandersetzen. Als Basis hält das gute und bewährte PostgreSQL her. Dabei ist es wichtig im Auge zu behalten, dass die Datenbankinstanzen nicht nur einfach zu starten sein sollten, sondern auch wieder sang- und klanglos verschwinden.

Seit einiger Zeit gibt es verschiedene Systeme, die es ermöglichen, Daten persistent vorzuhalten. Zu Beginn werden wir uns damit beschäftigen, wie wir dieses auf Basis des guten alten SQL realisieren können. Die Endscheidung fällt in diesem Projekt auf das Open-Source-Produkt PostgreSQL. Um eine Instanz zu erhalten, mit der man auf dem eigenen Rechner kommunizieren und experimentieren kann, kommt Docker zum Einsatz.

Dabei sollten wir auf einige Dinge achten, die uns das Entwicklerleben erleichtern. Es ist zum Beispiel von Vorteil, wenn sich die Datenbank einfach zurücksetzen lässt. Auch mehrfache Installationen auf einem System sollten parallel möglich sein. Dabei sollten keine Rückstände auf dem Trägersystem verbleiben sowie Start und Stop zügig ablaufen. Zustände sollten sich konservieren lassen. Und zu guter Letzt sollte sich die Verwaltung der Instanzen gut in den Entwicklungsprozess integrieren lassen.

Zusätzlich zu den Quelltextbeispielen zu diesem Artikel verwende ich auch die Sourcen des Open-Source -Projekts Functional-Reactive. Die Sourcen befinden sich auf GitHub. Ebenfalls werde ich damit beginnen, funktionale Aspekte in die Entwicklung einfließen zu lassen. Hierzu stütze ich mich auf die Serie hier auf JAXenter unter dem Namen Checkpoint Java.

Das Persistenzduo: PostgreSQL und TestContainers

Einer der wichtigen Ziele ist die Integration der Persistenz in den gesamten Entwicklungsprozess: vom Start der Entwicklung an. Dazu muss es möglich sein, auf einfache Art und Weise eine Instanz einer PostgreSQL-Datenbank für eine einzige Testmethode zu erzeugen, zu verwenden und auch wieder loswerden zu können.

Da ich nicht der einzige Entwickler mit dieser Anforderung bin, gibt es schon ein komfortables Open-Source-Projekt, das wir auch schon in einem der vorherigen Teile verwendet haben. Die Rede ist von dem Projekt TestContainers. In diesem Projekt gibt es schon fertig definierte Docker-Container für verschiedene RDBMS. Eines davon ist PostgreSQL. Um dies nutzen zu können, definieren wir in der pom.xml auf oberster Projektebene die Versionsdefinitionen für Testcontainer in PosgreSQL.

      <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <version>1.4.3</version>
        <scope>test</scope>
        <exclusions>
          <exclusion>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-ext</artifactId>
          </exclusion>
        </exclusions>
      </dependency>

Und dann den JDBC-Treiber für PostgreSQL:

      <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <version>42.1.4</version>
      </dependency>

Verwendet wird es im Modul backend. Dort werden die gerade deklarierten Versionen als Abhängigkeit definiert.

Kommen wir zum ersten Test, um zu sehen, wie sich das System verwenden lässt. Das Projekt testcontainers liefert eine Klasse mit dem Namen PostgreSQLContainer. Um nun im lokalen Docker eine Instanz zu bekommen, muss diese Klasse lediglich instanziiert werden. Das wird direkt in den Lebenszyklus von jUnit 5 eingehängt.

  private Result<PostgreSQLContainer> postgreSQLContainer;

  @BeforeEach
  void setUp() {
    postgreSQLContainer = ((CheckedSupplier<PostgreSQLContainer>) PostgreSQLContainer::new).get();
    postgreSQLContainer.ifPresentOrElse(
        GenericContainer::start,
        (Runnable) Assert::fail
    );
  }

Damit haben wir schon eine Instanz, auf der wir arbeiten können. In diesem Fall wird der Default-Konstruktor aufgerufen und in einem Result verpackt als Klassenattribut vorgehalten. Sollte ein Fehler auftreten, wird der Test als failed markiert. Das manuelle Stoppen des Containers ist hier nicht nötig. Das wird automatisch erledigt, sobald die letzte JDBC-Verbindung zum Container getrennt wird.

Als nächstes wird getestet, ob eine Verbindung zum System hergestellt werden kann. Dazu ist es notwendig, eine JDBC-Verbindung zum System PostgreSQL im Container herzustellen und eine einfache SQL-Anweisung zum Testen auszuführen. Um eine DataSource zu erzeugen, benötigen wir die Zugangsdaten zu dem PostgreSQL-System. Diese können wir vom Container beziehen.

  public Function<JdbcDatabaseContainer, HikariDataSource> datasource() {
    return (container) -> {
      final HikariConfig hikariConfig = new HikariConfig();
      hikariConfig.setJdbcUrl(container.getJdbcUrl());
      hikariConfig.setUsername(container.getUsername());
      hikariConfig.setPassword(container.getPassword());
      return new HikariDataSource(hikariConfig);
    };
  }

Als DataSource oder auch Connection-Pool kommt hier das ebenfalls als Open Source verfügbare Projekt Hikari zum Einsatz. Wie gehabt, wird die Version in der obersten Projektebene definiert und die Abhängigkeit im Modul backend eingetragen.

      <dependency>
        <groupId>com.zaxxer</groupId>
        <artifactId>HikariCP</artifactId>
        <version>2.7.3</version>
      </dependency>

Nun sind wir in der Lage, einen Connection-Pool aufzubauen, um dann den ersten Test durchzuführen. Dazu erzeugen wir ein Statement basierend auf dem Connection-Pool.

  public CheckedFunction<HikariDataSource, Statement> createStatement() {
    return ds -> ds.getConnection().createStatement();
  }
  
  public Function<JdbcDatabaseContainer, Result<Statement>> statement() {
    return datasource().andThen(createStatement());
  }

  private Supplier<Result<Statement>> stmt() {
    return () -> statement().apply(postgreSQLContainer.get());
  }

Viele RDBMS geben eine eigene Art und Weise vor, wie eine Testabfrage gestellt werden soll. Hier hat der Container schon eine generische Methode, die diesen SQL-String liefert.

    final String testQueryString = postgreSQLContainer.get().getTestQueryString();
    logger().info("testQueryString -> " + testQueryString);

Der SQL-String soll nun mittels Statement auf der Instanz der PostgreSQL ausgeführt werden. Das Durchführen einer Abfrage ist generisch und lässt sich demnach einfach in einer Funktion abbilden.

  private CheckedBiFunction<Statement, String, ResultSet> query(){
    return (statement, sql) -> {
      assertEquals("sql was executed", statement.execute(sql), true);
      return statement.getResultSet();
    };
  }

Nun können wir beides kombinieren. Dazu kann man die Methode thenCombine der Klasse Result verwenden. Zur Erinnerung: Die Implementierung der Methode sieht wie folgt aus:

  default <V, R> Result<R> thenCombine(V value , BiFunction<T, V, Result<R>> func) {
    return func.apply(get() , value);
  }

Wer das nochmal genauer nachlesen möchte, dem empfehle ich die beiden Teile aus der Kolumne CheckPoint Java Funktionaler mit Java 9 und Am funktionalsten mit Java 9.

    final String testQueryString = postgreSQLContainer.get().getTestQueryString();
    logger().info("testQueryString -> " + testQueryString);

    stmt()
        .get()
        .thenCombine(testQueryString, query())
        //next step

Das Ergebnis dieser Funktion ist eine Instanz der Klasse ResultSet, das ein Ergebnis der Testabfrage enthält. Auf diesem ResultSet muss nun einmal die Methode next() aufgerufen werden.

  private CheckedFunction<ResultSet, ResultSet> next(){
    return (rs) -> {
      rs.next();
      return rs;
    };
  }

Danach kann das Ergebnis ausgelesen werden.

(CheckedFunction<Result<ResultSet>, Integer>) r -> r.get().getInt(1)

Nun haben wir alles zusammen und das finale Ergebnis liegt in einem Result vor. Somit ist nur noch das Ergebnis selbst zu prüfen. Alles zusammen ergibt dann den folgenden Test:

  @Test
  void test001() {
    final String testQueryString = postgreSQLContainer.get().getTestQueryString();
    logger().info("testQueryString -> " + testQueryString);

    stmt()
        .get()
        .thenCombine(testQueryString, query())
        .map(next())
        .flatMap((CheckedFunction<Result<ResultSet>, Integer>) r -> r.get().getInt(1))
        .ifPresentOrElse(
            success -> assertEquals("A basic SELECT query succeeds", 1, success),
            (Consumer<String>) Assert::fail
        );
  }

Wenn nun alles so abläuft, wie es geplant ist, sollten unser nächster Schritt sein, einen Docker-Containers mit einer laufen Instanz von PostgreSQl zu erzeugen. Dann erzeugen wir einen Connection-Pool und verbinden ihn mit dem PostgreSQl-System. Zu guter Letzt erzeugen wir eine Instanz vom Typ Statement, führen eine Test-SQL-Anweisung aus, überprüfen das Ergebnis und entfernen alle allokierten Ressourcen. Für einen so komplexen Vorgang ist das ziemlich wenig Quelltext. Wenn der Test ausgeführt wird, sollte unser Bildschirm wie in Abbildung 1 aussehen. Auf dem von mir verwendeten MacBook Pro von 2016 hat der Test ganze fünf Sekunden gedauert. Nun sind wir also in der Lage, jUnit-Tests gegen eine Instanz einer PostgreSQL zu erstellen und laufen zu lassen.

Abb. 1: Unser Test läuft

Datenbankschema mit Flyway verwalten

Die nächste Aufgabe besteht darin, eine Struktur in der Datenbank zu erzeugen, so dass die gewünschten Werte gespeichert werden können. Wir wissen, das dieser Prozess nicht in einem Schritt erledigt wird. Es werden mehrfache Iterationen benötigt. Um bei jedem Test wieder eine saubere Struktur in der Datenbank vorzufinden, wird diese bei jedem Start vollständig neu erzeugt. damit wir komfortabel und reproduzierbar eine Tabelle erzeugen können, müssen wir auf jeden Fall eine DDL-Anweisung nutzen. Das könnte so aussehen:

CREATE SEQUENCE comp_math_basic_id_seq;
CREATE TABLE comp_math_basic (
  ID             SMALLINT PRIMARY KEY DEFAULT nextval('comp_math_basic_id_seq'),
  OP_A           FLOAT       NOT NULL,
  OP             VARCHAR(15) NOT NULL,
  OP_B           CHARACTER   NOT NULL,
  RESULT_MACHINE FLOAT       NOT NULL,
  RESULT_HUMAN   VARCHAR(15),
  RESULT_OK      BOOLEAN     NOT NULL,
  CREATED        TIMESTAMP   NOT NULL
);
COMMIT;

In dieser Tabelle sollen alle Aufgaben inklusive der vom Benutzer berechneten Ergebnisse gespeichert werden. Um diese Statements auszuführen, kann man natürlich den gleichen Weg gehen, den wir gerade gegangen sind, um ein Test-Statement auszuführen. Aber mit steigender Anzahl an Tabellen und anderen Strukturelementen wird das recht zügig sehr unhandlich.

Hier kommt Flyway zu Hilfe. Auch hierbei handelt es sich um ein Open-Source-Projekt, das sich zum Ziel gesetzt hat, Entwickler dabei zu unterstützen, Datenbankschemata in der Erzeugung und Migration zu verwalten. Es gibt verschieden Wege, um Flyway zu verwenden. Für die Verwendung in den Tests nehmen wir den programmatische Ansatz. Die Definitionen der Version und Abhängigkeiten erfolgen wie bei den vorherigen Abhängigkeiten auch in den jeweiligen pom.xml-Dateien.

      <dependency>
        <groupId>org.flywaydb</groupId>
        <artifactId>flyway-core</artifactId>
        <version>${flyway.version}</version>
      </dependency>

Der Vorgang selbst ist einfach. Es wird eine Instanz der Klasse Flyway benötigt. Auf dieser Instanz werden die beiden Methoden clean und migrate aufgerufen. Was hierbei passiert, ist schon anhand der Methodennamen zu erkennen. Die Methode clean löscht eventuell vorhanden Inhalte und Strukturen. Und die Methode migrate migriert auf die finale Version der Datenbankstruktur. Das bedeutet, dass im Verzeichnis resources/db/migration nachgesehen wird, ob es Dateien mit der Endung .sql gibt. Die Namen der Dateien müssen einem Schema entsprechen, das zum einem auf der Homepage beschrieben ist und zum anderen dafür sorgt, dass die Skripte in der richtigen Reihenfolge ausgeführt werden. Die lexikographische Reihenfolge ist hier also von Bedeutung. Eine Datei entspricht einer Version. Sind also drei Dateien vorhanden, wird das Schema von Version null auf Version drei gehoben. Um eine Tabelle zu erzeugen und dann einen einzigen Datensatz einzufügen, wurden zwei Dateien erzeugt: V00_00_01__init.sql und V00_00_02__insert.sql.

CREATE SEQUENCE comp_math_basic_id_seq;
CREATE TABLE comp_math_basic (
  ID             SMALLINT PRIMARY KEY DEFAULT nextval('comp_math_basic_id_seq'),
  OP_A           FLOAT       NOT NULL,
  OP             VARCHAR(15) NOT NULL,
  OP_B           CHARACTER   NOT NULL,
  RESULT_MACHINE FLOAT       NOT NULL,
  RESULT_HUMAN   VARCHAR(15),
  RESULT_OK      BOOLEAN     NOT NULL,
  CREATED        TIMESTAMP   NOT NULL
);
COMMIT;
INSERT
INTO comp_math_basic(OP_A, OP, OP_B, RESULT_MACHINE, RESULT_HUMAN, RESULT_OK, CREATED)
VALUES('1', '+', '1', '2', '2', '1', '2018.11.27' );

Die Datenbankstrukturen inklusive Inhalt werden bei jedem Test erneut erzeugt. Ausgelöst wird das innerhalb der Methode setUp().

  @BeforeEach
  void setUp() {
    postgreSQLContainer = ((CheckedSupplier<PostgreSQLContainer>) PostgreSQLContainer::new).get();
    postgreSQLContainer.ifPresentOrElse(
        GenericContainer::start,
        (Runnable) Assert::fail
    );
    postgreSQLContainer.ifPresent(c -> {
      final Flyway flyway = flyway().apply(c);
      flyway.clean();
      flyway.migrate();
    });
  }

Nun fehlt noch der Test, um eine Abfrage auf genau diese Tabelle zu stellen und nachzusehen, ob genau ein Datensatz enthalten ist.

  @Test
  void test002() {

    final String sql = "SELECT count(*) FROM comp_math_basic";
    logger().info("sql -> " + sql);

    stmt()
        .get()
        .thenCombine(sql, query())
        .map(next())
        .flatMap((CheckedFunction<Result<ResultSet>, Integer>) r -> r.get().getInt(1))
        .ifPresentOrElse(
            success -> assertEquals("Count must be 1", success, 1),
            (Consumer<String>) Assert::fail
        );
  }

Die Durchführung ergibt dann die Ausgabe, die in Abbildung 2 zu sehen ist. Auch dieser Test hat nur fünf Sekunden auf meinem Rechner benötigt.

Abb. 2: Ist unser Datensatz in der Tabelle?

Fazit

Wir haben alles zusammen, um kompakt Anfragen an eine RDBMS-Instanz zu senden und auszuwerten. Die Unterstützung durch die Entwicklungswerkzeuge ist sehr gut und kann auch auf CI-Servern direkt zum Einsatz kommen. Was noch fehlt, ist ein komfortabler Umgang mit SQL selbst. Aber dazu kommen wir noch. Den Quelltext findet ihr auf GitHub. Bei Fragen oder Anregungen einfach melden unter sven@vaadin.com oder per Twitter @SvenRuppert.

Happy Coding!

Backend meets Frontend

In der Artikelserie Backend meets Frontend stellt Sven Ruppert (Vaadin) Konzepte und Technologien rund um das UI-Framework Vaadin vor. Sein Fokus liegt dabei auf modernem Web-Design für Java-Backend-Entwickler.

Zum ersten Teil und damit dem Start der Tutorien rund um die UI-Entwicklung mit Java geht es hier entlang. Alle Teile der Serie Backend meets Frontend finden sich hier.

Geschrieben von
Sven Ruppert
Sven Ruppert
Sven Ruppert arbeitet seit 1996 mit Java und ist Developer Advocate bei Vaadin. In seiner Freizeit spricht er auf internationalen und nationalen Konferenzen, schreibt für IT-Magazine und für Tech-Portale. Twitter: @SvenRuppert
Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.