JOOQ und sein Kaninchenbau. Wie man ohne Winterschlaf überlebt

In diesem Artikel werde ich nicht für JOOQ ertrinken. Ich bevorzuge den Ruhezustand und die gesamte dahinter stehende JPA-Leistung von Spring Data. Aber der Artikel wird nicht über sie sein.



Wenn wir Hibernate und Spring Data JPA verwenden, müssen wir nicht über interne Prozesse nachdenken - kennen Sie die Anmerkungen und schreiben Sie die richtigen Methodennamen in das Repository - diese beiden Monster erledigen den Rest für Sie. Im Fall von JOOQ wird es leider für viele ein wenig Mühe kosten und mehr schreiben als findAllByUserId (Long userId) .

Was ist ein JOOQ?


Schreiben wir eine einfache SQL-Abfrage:

select * from countries c where c.population > 10000000;

Wir können diese Anfrage in der Konsole ausführen. okay

Wir fühlen uns nicht wie in der Konsole. Wir möchten, dass unsere Bewerbung diese Anfrage sendet. Dass es sich nicht um ein einmaliges Skript handelte und dass es zumindest hinsichtlich der Syntax validiert wurde. Wir tun dies durch Spring Data JPA:

List<Country> findAllByPopulationAfter(Long amount);

Die gleiche Anforderung wird wie oben ausgeführt, jedoch bis zum Frühjahr.

Was sind die klaren Vorteile dieses Ansatzes? Es wird von einem leistungsstarken Framework ausgeführt und validiert auch eine Fehleranforderung. Das Abfragemanagement kümmert sich jedoch um das Framework. Und wir möchten die Anfrage vollständig verwalten, aber gleichzeitig, damit die Anfrage vollständig validiert wird.
VerwendenAbfrage::

@Query("select c from Country c where c.population > :amount")
List<Country> findAllPopulationGreaterThan(@Param("amount") Long amount);

Ein solcher Kompromiss zwischen SQL und DSL ist auch gut. Aber wenn wir uns nicht mit SQL anlegen wollen, werden wir uns freuen, etwas zu sehen wie:

return dsl.selectFrom(COUNTRIES)
                .where(COUNTRIES.POPULATION.greaterThan(amount))
                .fetch();



Hierfür
eignen sich
mehrere Bibliotheken: QueryDSL JOOQ Speedment

Über QueryDSL habe ich vor ein paar Jahren geschrieben. Ich habe Speedment nicht gefunden, aber es scheint mehr als ein einfacher DSL-Generator zu sein. Außerdem muss ich lernen, mit welchen Methoden SQL-Abfragebefehle verschlüsselt wurden. Im Allgemeinen werden wir heute über JOOQ sprechen.

JOOQ-Abfragen


Ja, wir haben oben bereits eine dieser Abfragen gesehen:

return dsl.selectFrom(COUNTRIES)
                .where(COUNTRIES.POPULATION.greaterThan(amount))
                .fetch();

Was gibt es sonst noch?

Zum Beispiel eine einfache Get-Anfrage:

return dsl.selectFrom(Countries.COUNTRIES)
                .where(Countries.COUNTRIES.ID.eq(id))
                .fetchAny();

Oder Anfrage einfügen:

return dsl.insertInto(Countries.COUNTRIES)
                .set(Countries.COUNTRIES.NAME, country.getName())
                .set(Countries.COUNTRIES.POPULATION, country.getPopulation())
                .set(Countries.COUNTRIES.GOVERNMENT_FORM, nameOrNull(country.getGovernmentForm()))
                .returning()
                .fetchOne();

Wie Sie sehen, ist alles klar, nichts weiter. Dies ist aber nur auf den ersten Blick. Finden Sie heraus, wie tief dieses Kaninchenloch mehrere Wochen dauern kann.

Aber fangen wir von vorne an.

Ja, es wird ein Maven sein


Ich bevorzuge Gradle. Aus irgendeinem Grund widmen JOOQ-Entwickler Gradle weniger Aufmerksamkeit und bieten Plugins von Drittanbietern an. Okay, das Trainingsprojekt wird auf Maven sein. Aber wenn Sie, lieber Leser, in den Tiefen des Githubs stöbern und bereit sind, ein vollständig angepasstes Projekt auf Gradle zu enthüllen, schreiben Sie mir und Sie werden Mitautor dieses Artikels. Gleiches gilt für H2 (das Trainingsprojekt wird auf PostgreSQL sein).

Warum ich mit Gradle und H2 keinen Erfolg hatte
, Gradle. jooq-generator . H2, « » H2 , , , .

Notwendige Maven-Abhängigkeiten:

<dependencies>

        <!-- Spring Starters -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jooq</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Database -->
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-core</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- Helpers -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>${commons.lang3.version}</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!--Test -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

        <!-- JOOQ Generator -->
        <dependency>
            <groupId>org.jooq</groupId>
            <artifactId>jooq</artifactId>
            <version>${jooq.version}</version>
        </dependency>
        <dependency>
            <groupId>org.jooq</groupId>
            <artifactId>jooq-meta</artifactId>
            <version>${jooq.version}</version>
        </dependency>
        <dependency>
            <groupId>org.jooq</groupId>
            <artifactId>jooq-codegen</artifactId>
            <version>${jooq.version}</version>
        </dependency>

    </dependencies>

Spring verfügt bereits über einen JOOQ-Starter, der die DataSource gemäß den angegebenen Einstellungen in application.yml selbst konfiguriert. Für die volle Arbeit von JOOQ reicht dies jedoch nicht aus. Entitäten müssen generiert werden. Ja, ja, wir schreiben keine Entitäten, sondern generieren sie. Der sogenannte Table-First-Ansatz.

Die übliche Anwendungsstruktur mit Schwerpunkt auf der Arbeit mit der Datenbank sieht folgendermaßen aus:



Bis zur Serviceschicht werden Daten in Form eines flachen Modells (DTO) durch die Anwendung übertragen. Wenn der Dienst mit der Datenbank arbeiten muss, konvertiert er das DTO in eine Entität, sendet es an das Repository und speichert es bereits in der Datenbank. JOOQ-Entwickler sehen alles anders:



Wie wir sehen können, haben sie in JOOQ das Standardmuster ernsthaft überarbeitet und beschlossen, ihren eigenen Weg zu gehen. Die Unterschiede sind signifikant:

  1. Die Konvertierung von DTO in eine Entität erfolgt bereits im Repository.
  2. Wir schreiben keine Entität als solche. Es wurde für uns vom JOOQ-Generator geschrieben.

Dies reicht bereits aus, um zu dem Schluss zu kommen, dass JOOQ nicht so einfach ist. Eine hochgradig anpassbare generierte Entität, eine unverständliche Zuordnung in DTO und andere Schwierigkeiten werden viele abschrecken. In Fällen, in denen es keinen Weg gibt, kann eine gezielte Untersuchung dieser Aspekte von JOOQ ernsthaft dauern und Tage oder sogar Wochen dauern. Ich bin sicher, dass mein Beitrag Ihre Zeit erheblich sparen wird.

Wir werden die folgenden Aspekte der Arbeit mit JOOQ behandeln:

  • Entitätsgenerierung.
  • CRUD-Abfragen schreiben.
  • Optimierung von CRUD-Anfragen.
  • Und noch eine CRUD-Anforderungsoptimierung.
  • Zuordnen von Entitäten mithilfe der Standard-JOOQ-Bibliothek.
  • und diskutieren, warum dies alles notwendig ist.

Lass uns gehen.

Was schreiben wir?


Unser Projekt wird zwei Einheiten haben:

Land:

@Data
public class Country {

    private Long id;
    private String name;
    private GovernmentForm governmentForm;
    private Integer population;

    private List<City> cities;
}

Stadt:

@Data
public class City {

    private Long id;
    private Long countryId;
    private String name;
}

Eins-zu-viele-Beziehung. Land enthält viele verwandte Stadt. Stadt enthält countryId.
Die Flyway-Migration sieht folgendermaßen aus:

create table countries
(
    id              bigserial primary key,
    name            varchar(255),
    government_form varchar(255),
    population      int
);

create table cities
(
    id         bigserial primary key,
    country_id bigint,
    name       varchar(255)
);

Beginnen wir mit der Entitätsgenerierung.


Entitäten werden, wie gesagt, separat generiert. Es gibt zwei Möglichkeiten, dies zu tun.

  • Über das Maven-Plugin.
  • In Java-Code.

Entitäten in Maven generieren


Wenn das Projekt erstellt wird, startet Maven den Generator und generiert Entitäten. Sie können den Generator jederzeit aufrufen und Entitäten generieren. Dies ist in solchen Momenten erforderlich, in denen sich beispielsweise die Struktur der Basis geändert hat. Das Plugin in Maven sieht folgendermaßen aus:

            <!-- JOOQ Generator Plugin -->
            <plugin>
                <groupId>org.jooq</groupId>
                <artifactId>jooq-codegen-maven</artifactId>
                <version>${jooq.version}</version>
                <executions>
                    <execution>
                        <phase>generate-sources</phase>
                        <goals>
                            <goal>generate</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <jdbc>  <!--    -->
                        <driver>${db.driver}</driver>
                        <url>${db.url}</url>
                        <user>${db.username}</user>
                        <password>${db.password}</password>
                    </jdbc>
                    <generator>
                        <database>
                            <includes>.*</includes>  <!--     -->
                            <excludes>  <!--     -->
                                flyway_schema_history
                            </excludes>
                            <inputSchema>public</inputSchema>  <!--  -->
                        </database>
                        <generate>
                            <records>true</records>
                        </generate>
                        <target>
                            <!--      -->
                            <packageName>ru.xpendence.jooqexample.domain</packageName>
                            <!--  .    target. -->
                            <directory>target/generated-sources/jooq</directory>
                        </target>
                    </generator>
                </configuration>
            </plugin>

Wenn Sie alles richtig gemacht haben, aktualisieren Sie Maven und unter den Plugins sehen Sie jooq-codegen.



Starte es. Es werden Entitäten für Sie generiert.



Dies sind genau die Entitäten, mit denen Sie auf die Datenbank zugreifen. Das erste Ärgernis: Sie sind unveränderlich. Das heißt, sie sind veränderbar, aber die Typänderung erfolgt so konstruiert, dass diese Prozessbeschreibung in einen separaten Artikel verschoben wird. Versuchen Sie daher, die Datentypen in der Phase der Tabellengenerierung zu überdenken.

Entitäten sehen eigenartig aus. Hier ist zum Beispiel die Signatur der CountriesRecord-Klasse:

public class CountriesRecord extends UpdatableRecordImpl<CountriesRecord> implements Record4<Long, String, String, Integer> {
    //...

Wie wir sehen können, implementiert CountriesRecord Record4, das von 4 Typen typisiert wird. Die Ländertabelle enthält 4 Spalten, daher Record4. Es gibt 3 Spalten in Städten, also Record3. Warum dies erfunden wurde, weiß ich nicht. Es gibt 22 solcher Datensätze in der JOOQ-Bibliothek, Record1 ... Record22. Daraus können wir schließen, dass die Funktionen von JOOQ auf die Verarbeitung von Tabellen mit maximal 22 Spalten beschränkt sind, dies ist jedoch nicht der Fall. Ich habe Tabellen mit Dutzenden von Spalten, und JOOQ implementiert Record sofort. Nun, das ... Das

Generieren von JOOQ-Entitäten in Java-Code sieht folgendermaßen aus:

@Component
public class AfterStartupApplicationListener implements ApplicationListener<ContextRefreshedEvent> {

    @Value("${spring.datasource.driver-class-name}")
    private String driver;

    @Value("${spring.datasource.url}")
    private String url;

    @Value("${spring.datasource.username}")
    private String username;

    @Value("${spring.datasource.password}")
    private String password;

    @Value("${jooq.generator.database.name}")
    private String databaseName;

    @Value("${jooq.generator.database.with-includes}")
    private String databaseWithIncludes;

    @Value("${jooq.generator.database.with-input-schema}")
    private String databaseWithInputSchema;

    @Value("${jooq.generator.target.package-name}")
    private String targetPackageName;

    @Value("${jooq.generator.target.directory}")
    private String targetDirectory;

    @SneakyThrows
    @Override
    public void onApplicationEvent(ContextRefreshedEvent contextRefreshedEvent) {
        new GenerationTool().run(configureGenerator());
    }

    private Configuration configureGenerator() {
        return new Configuration()
                .withJdbc(new Jdbc()
                        .withDriver(driver)
                        .withUrl(url)
                        .withUser(username)
                        .withPassword(password))
                .withGenerator(new Generator()
                        .withDatabase(new Database()
                                .withName(databaseName)
                                .withIncludes(databaseWithIncludes)
                                .withExcludes("")
                                .withInputSchema(databaseWithInputSchema))
                        .withTarget(new Target()
                                .withPackageName(targetPackageName)
                                .withDirectory(targetDirectory)));
    }
}

In meinem Fall startet der Generator nach einem vollständigen Start der Anwendung, wenn alle neuen Migrationen aufgerollt sind und die Datenbank auf dem neuesten Stand ist.

Wir haben also Modelle, wir haben Entitäten, wir haben eine Basis. Es ist Zeit, ein Repository zu erstellen und Abfragen zu schreiben.

Repository erstellen


Alle Anfragen gehen über DslContext. Wiederholen Sie den DslContext.

@Repository
@RequiredArgsConstructor
public class CountryRepository implements CrudRepository<Country> {

    private final DSLContext dsl;

} 

Das Repository ist bereit.

CRUD-Abfragen schreiben


Einfügen


Wenn wir SQL verwenden würden, wäre die Anforderung, ein weiteres Land hinzuzufügen:

insert into countries(name, government_form, population)
values (' -', 'UNITARY', 100500) returning *;

In JOOQ ist eine Abfrage so nah wie möglich an der SQL-Syntax:

    public Country insertValues(Country country) {
        return dsl.insertInto(Countries.COUNTRIES)  //insert into countries
                .values(country.getId(), country.getName(), country.getPopulation(), nameOrNull(country.getGovernmentForm()))  //values (? ? ? ?)
                .returning()  //returning
                .fetchOne()  //*
                .into(Country.class);
    }

Wie wir sehen, nichts kompliziertes. Eine solche Anfrage passt jedoch nicht zu uns, da in unserem Fall die ID in der Datenbank generiert wird und wir dies berücksichtigen müssen. Wenn wir so etwas schreiben wie:

.values(null, country.getName(), country.getPopulation(), nameOrNull(country.getGovernmentForm()))

wir werden .. bekommen

org.postgresql.util.PSQLException: :     "id"   NOT NULL

Wenn wir jedoch die ID auf der Anwendungsseite generieren würden, wäre eine solche Anfrage eine Fahrt. Wir müssen die Anfrage neu schreiben und jedem Feld einen bestimmten Wert geben. Also kannst du:

    public Country insert(Country country) {
        return dsl.insertInto(Countries.COUNTRIES)
                .set(Countries.COUNTRIES.NAME, country.getName())
                .set(Countries.COUNTRIES.POPULATION, country.getPopulation())
                .set(Countries.COUNTRIES.GOVERNMENT_FORM, nameOrNull(country.getGovernmentForm()))
                .returning()
                .fetchOne()
                .into(Country.class);
    }

In diesem Fall setzen wir das Objekt manuell in das Feld. Diese Option funktioniert ziemlich gut, aber es gibt bessere. Es ist ideal, das gesamte Objekt zum Speichern zu senden, wie in Spring Data JPA beschrieben:

repository.save(country);

Könnte so sein. Dazu müssen wir unser Modell der erweiterten Datensatzentität zuordnen und zum Speichern senden:

    public Country insert(Country country) {
        return dsl.insertInto(Countries.COUNTRIES)
                .set(dsl.newRecord(Countries.COUNTRIES, country))  //   
                .returning()
                .fetchOptional()
                .orElseThrow(() -> new DataAccessException("Error inserting entity: " + country.getId()))
                .into(Country.class);
    }

Diese Methode ist die einfachste und verständlichste für Fälle, in denen die Zuordnung nicht konfiguriert werden muss. Die Mapping-Einstellung ist jedoch niedriger.

Wie wir festgestellt haben, gibt diese Abfrage die gesamte Entität zurück. In der Methode fetch () können Sie festlegen, was genau Sie zurückgeben müssen. Es sieht aus wie das:

    public Long insertAndReturnId(Country country) {
        return dsl.insertInto(Countries.COUNTRIES)
                .set(dsl.newRecord(Countries.COUNTRIES, country))
                .returning(Countries.COUNTRIES.ID) //  Record    - id
                .fetchOptional()
                .orElseThrow(() -> new DataAccessException("Error inserting entity: " + country.getId()))
                .get(Countries.COUNTRIES.ID); // id
    }

Ein wenig umständlich, aber wieder mit dem zu vergleichen.

Wir werden den Rest der CRUD-Methoden schreiben.

Aktualisieren


SQL-Abfrage:

update countries
set name            = '',
    government_form = 'CONFEDERATE',
    population      = 100500
where id = 1
returning *;

JOOQ-Anfrage:

    public Country update(Country country) {
        return dsl.update(Countries.COUNTRIES)
                .set(dsl.newRecord(Countries.COUNTRIES, country))
                .where(Countries.COUNTRIES.ID.eq(country.getId()))
                .returning()
                .fetchOptional()
                .orElseThrow(() -> new DataAccessException("Error updating entity: " + country.getId()))
                .into(Country.class);
    }

Wählen


Eine Abfrage in SQL wäre:

select *
from countries c
where id = ?;

In JOOQ sieht die Abfrage entsprechend aus:

    public Country find(Long id) {
        return dsl.selectFrom(Countries.COUNTRIES) //select * from countries
                .where(Countries.COUNTRIES.ID.eq(id))  //where id = ?
                .fetchAny()  // ,    
                .into(Country.class);
    }

Löschen


Es gibt Anfragen, bei denen wir nichts zurückgeben können. Solche Abfragen geben die Anzahl der betroffenen Zeilen zurück. Beim Löschen haben wir nichts zurückzugeben, aber die Informationen, die wir erhalten, sind für uns weiterhin nützlich.

    public Boolean delete(Long id) {
        return dsl.deleteFrom(Countries.COUNTRIES)
                .where(Countries.COUNTRIES.ID.eq(id))
                .execute() == 1;
    }

Wir löschen eine Zeile. Die SQL-Abfrage gibt also Folgendes zurück:

1 row affected in 5 ms

Nachdem wir eine solche Antwort erhalten haben, wissen wir sicher, dass die Zeile gelöscht wurde. Dies reicht aus, um den Vorgang für erfolgreich zu erklären.

Es war kein Märchen. Es war nur ein Schmiermittel. Verwenden eines regulären Maper für die Thin-Zuordnung einer Entität in Record und umgekehrt


"Okay", sagen Sie, Leser, "wo ist eins zu viele, Emoe?" Etwas, das ich nicht gesehen habe, in dem Land mit einer Vielzahl von Städten wächst. “

Und du wirst recht haben. Dies ist kein Ruhezustand, hier müssen Sie manuell. Sie müssen lediglich eine Liste der Städte abrufen, für die id = country.getId () gilt. Ich habe bereits die into () -Methode gezeigt, die einen regulären Mapper verwendet, um Record einer Entität zuzuordnen. JOOQ verfügt jedoch über eine zusätzliche map () -Methode, mit der wir mit Record alles tun können, was wir wollen. Schauen wir uns die Funktionalität an und fügen Städte hinzu:

    public Country find(Long id) {
        return dsl.selectFrom(Countries.COUNTRIES)
                .where(Countries.COUNTRIES.ID.eq(id))
                .fetchAny()
                .map(r -> {
                    Country country = r.into(Country.class);
                    country.setCities(cityRepository.findAll(Cities.CITIES.COUNTRY_ID.eq(country.getId())));
                    return country;
                });
    }

Wie wir sehen, ordnen wir jetzt zuerst den Datensatz in Land zu und stellen dann eine weitere Anfrage in Städten. Wir erhalten alle Städte für dieses Land und setzen sie in die Essenz.

Aber es kann Dutzende solcher Sätze geben, was bedeutet, dass es Dutzende von Anfragen geben kann. Und diese Anforderungen direkt in der Methode zu beschreiben, ist so. Die richtige Lösung besteht darin, einen separaten Mapper zu schreiben und alle diese Anforderungen dort abzulegen.

@RequiredArgsConstructor
@Component
public class CountryRecordMapper implements RecordMapper<CountriesRecord, Country> {

    private final CityRepository cityRepository;

    @Override
    public Country map(CountriesRecord record) {
        Country country = record.into(Country.class);
        country.setCities(cityRepository.findAll(Cities.CITIES.COUNTRY_ID.eq(country.getId())));
        return country;
    }
}

Die Abfrage sieht nun folgendermaßen aus:

    public Country findWithCustomMapper(Long id) {
        return dsl.selectFrom(Countries.COUNTRIES)
                .where(Countries.COUNTRIES.ID.eq(id))
                .fetchAny()
                .map(r -> countryRecordMapper.map((CountriesRecord) r));
    }

Es ist prägnanter und enthält keine zusätzliche Logik.

Ok, wir haben gelernt, wie man Record einer Entität zuordnet, aber wie sieht es mit der Feinabstimmung der Zuordnung einer Entität in Record aus?


Bisher haben wir dieses Design:

.set(dsl.newRecord(Countries.COUNTRIES, country))

Schon gut, aber was ist, wenn wir ein Feld speziell zuordnen müssen? Zum Beispiel haben wir dort LocalDateTime und ein Generator für PostgreSQL wie Timestamp hat eine OffsetDateTime generiert. In diesem Fall wird das Feld einfach nicht zugeordnet und nicht in der Datenbank aufgezeichnet. In solchen Fällen benötigen wir einen anderen Mapper, der dasselbe tut, jedoch in die entgegengesetzte Richtung.

Ja, für jeden Mapper haben wir Unmapper


Er heißt so. Schreiben wir seinen Erben.

@Component
@RequiredArgsConstructor
public class CountryRecordUnmapper implements RecordUnmapper<Country, CountriesRecord> {

    private final DSLContext dsl;

    @Override
    public CountriesRecord unmap(Country country) throws MappingException {
        CountriesRecord record = dsl.newRecord(Countries.COUNTRIES, country);
        record.setPopulation(-1);
        return record;
    }
}

Das Einfügen mit seiner Anwendung sieht folgendermaßen aus:

    public Country insertWithUnmapper(Country country) {
        return dsl.insertInto(Countries.COUNTRIES)
                .set(countryRecordUnmapper.unmap(country))
                .returning()
                .fetchOptional()
                .orElseThrow(() -> new DataAccessException("Error inserting entity: " + country.getId()))
                .into(Country.class);
    }

Wie wir sehen, auch ganz.

Schlussfolgerungen und Schlussfolgerungen


Persönlich mag ich Hibernate mehr. Für 90 +% der Anwendungen ist die Verwendung wahrscheinlich gerechtfertigter. Wenn Sie als Leser jedoch jede Anforderung steuern möchten, ist JOOQ oder eine andere ähnliche Bibliothek besser für Sie geeignet.

Wie immer poste ich das Trainingsprojekt. Er liegt hier .

All Articles