JOOQ dan lubang kelincinya. Cara bertahan hidup tanpa hibernasi

Dalam artikel ini saya tidak akan tenggelam untuk JOOQ. Saya lebih suka Hibernate dan semua kekuatan Spring Data JPA di belakangnya. Tetapi artikel itu tidak akan membahas tentang mereka.



Saat kami menggunakan Hibernate dan Spring Data JPA, kami tidak perlu memikirkan proses internal - ketahui anotasi dan tulis nama metode yang benar dalam repositori - kedua monster ini akan melakukan sisanya untuk Anda. Dalam kasus JOOQ, sayangnya bagi banyak orang, ini akan membutuhkan sedikit usaha dan menulis lebih banyak daripada findAllByUserId (Long userId) .

Apa itu JOOQ?


Mari kita menulis kueri SQL sederhana:

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

Kami dapat menjalankan permintaan ini di konsol. baik

Kami tidak merasa seperti di konsol. Kami ingin aplikasi kami mengirim permintaan ini. Bahwa itu bukan skrip satu kali dan itu divalidasi setidaknya untuk sintaksis. Kami melakukan ini melalui Spring Data JPA:

List<Country> findAllByPopulationAfter(Long amount);

Permintaan yang sama dijalankan seperti di atas, tetapi pada Musim Semi.

Apa keuntungan nyata dari pendekatan ini? Itu dilakukan oleh kerangka kerja yang kuat, itu juga memvalidasi permintaan untuk kesalahan. Tetapi manajemen kueri menangani framework. Dan kami ingin sepenuhnya mengelola permintaan, tetapi pada saat yang sama, sehingga permintaan sepenuhnya divalidasi.
MenggunakanPertanyaan:

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

Pertukaran antara SQL dan DSL juga bagus. Tetapi jika kita tidak ingin mengacaukan dengan SQL, kita akan senang melihat sesuatu seperti:

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

Beberapa perpustakaan cocok untuk ini:

QueryDSL
JOOQ
Speedment


Tentang QueryDSL Saya menulis beberapa tahun yang lalu. Saya tidak menggali Speedment, tetapi tampaknya lebih dari sekadar generator DSL sederhana, ditambah lagi saya harus mempelajari metode yang digunakan untuk mengenkripsi perintah kueri SQL. Secara umum, hari ini kita akan berbicara tentang JOOQ.

Pertanyaan JOOQ


Ya, kami telah melihat salah satu pertanyaan di atas:

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

Apa lagi yang ada di sana?

Misalnya, permintaan dapatkan sederhana:

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

Atau masukkan permintaan:

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();

Seperti yang Anda lihat, semuanya jelas, tidak lebih. Tapi ini hanya sekilas. Mencari tahu seberapa dalam lubang kelinci ini bisa memakan waktu beberapa minggu.

Tapi mari kita mulai dari awal.

Ya itu akan menjadi pakar


Saya lebih suka Gradle. Tetapi untuk beberapa alasan, pengembang JOOQ kurang memperhatikan Gradle, menawarkan untuk pergi untuk plugin pihak ketiga. Oke, proyek pelatihan akan di Maven. Tetapi jika Anda, pembaca yang budiman, mencari-cari sebagaimana mestinya di kedalaman github dan siap untuk mengungkapkan proyek yang sepenuhnya disesuaikan pada Gradle - menulis kepada saya dan Anda akan menjadi co-penulis artikel ini. Hal yang sama berlaku untuk H2 (proyek pelatihan akan di PostgreSQL).

Mengapa saya tidak berhasil dengan Gradle dan H2
, Gradle. jooq-generator . H2, ยซ ยป H2 , , , .

Ketergantungan maven yang diperlukan:

<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 sudah memiliki starter JOOQ yang akan mengkonfigurasi sendiri DataSource sesuai dengan pengaturan yang ditentukan dari application.yml. Tetapi untuk kerja penuh JOOQ, ini tidak cukup. Entitas harus dihasilkan. Ya, ya, kami tidak menulis entitas, tetapi menghasilkannya. Yang disebut pendekatan table-first.

Struktur aplikasi yang biasa dengan fokus untuk bekerja dengan basis data terlihat seperti ini:



Sampai lapisan layanan, data bergerak melalui aplikasi dalam bentuk model datar (DTO). Lebih jauh, ketika layanan perlu bekerja dengan database, ia mengubah DTO menjadi entitas, mengirimkannya ke repositori, dan sudah menyimpannya ke database. Pengembang JOOQ melihat semuanya secara berbeda:



Seperti yang dapat kita lihat, JOOQ secara serius merevisi pola standar dan memutuskan untuk melakukannya sendiri. Perbedaannya signifikan:

  1. Konversi DTO menjadi entitas sudah terjadi di repositori.
  2. Kami tidak menulis entitas seperti itu. Ini ditulis untuk kita oleh generator JOOQ.

Ini sudah cukup untuk menyimpulkan bahwa JOOQ tidak begitu sederhana. Entitas yang dihasilkan sangat mudah disesuaikan, pemetaan yang tidak dapat dipahami dalam DTO dan kesulitan lainnya akan membuat takut banyak orang. Tetapi dalam kasus-kasus di mana tidak ada tempat untuk pergi, studi terfokus pada aspek-aspek JOOQ ini dapat dengan serius dilakukan dan memakan waktu berhari-hari, atau bahkan berminggu-minggu. Saya yakin posting saya akan sangat menghemat waktu Anda.

Kami akan membahas aspek-aspek bekerja dengan JOOQ berikut:

  • Generasi Entitas.
  • Menulis Pertanyaan CRUD.
  • Optimalisasi permintaan CRUD.
  • Dan optimasi permintaan CRUD lain.
  • Memetakan entitas menggunakan perpustakaan JOOQ standar.
  • dan diskusikan mengapa semua ini perlu.

Ayo pergi.

Apa yang kita tulis


Proyek kami akan memiliki dua entitas:

Negara:

@Data
public class Country {

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

    private List<City> cities;
}

Kota:

@Data
public class City {

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

Hubungan satu ke banyak. Negara berisi banyak Kota terkait. City mengandung countryId.
Migrasi jalur terbang terlihat seperti ini:

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)
);

Mari kita mulai dengan pembuatan entitas.


Entitas, seperti yang saya katakan, dihasilkan secara terpisah. Ada dua cara untuk melakukan ini.

  • Melalui plugin Maven.
  • Dalam kode Java.

Menghasilkan Entitas di Maven


Ketika proyek dibangun, Maven memulai generator dan menghasilkan entitas. Dan Anda dapat memanggil generator kapan saja dan menghasilkan entitas. Ini diperlukan pada saat-saat ketika, katakanlah, struktur pangkalan telah berubah. Plugin di Maven terlihat seperti ini:

            <!-- 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>

Jika Anda melakukan semuanya dengan benar, perbarui Maven dan di antara plugin Anda akan melihat jooq-codegen.



Menjalankannya. Ini akan menghasilkan entitas untuk Anda.



Ini adalah entitas yang akan Anda gunakan untuk mengakses database. Gangguan pertama: mereka tidak berubah. Yaitu, mereka bisa berubah, tetapi perubahan jenis terjadi sedemikian rupa sehingga deskripsi proses ini akan ditarik ke artikel terpisah. Oleh karena itu, cobalah untuk memikirkan jenis data pada tahap pembuatan tabel.

Entitas terlihat aneh. Di sini, misalnya, adalah tanda tangan dari kelas CountriesRecord:

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

Seperti yang bisa kita lihat, CountriesRecord mengimplementasikan Record4, yang diketik oleh 4 jenis. Tabel negara memiliki 4 kolom, oleh karena itu Record4. Ada 3 kolom di kota, jadi Record3. Mengapa ini ditemukan, saya tidak tahu. Ada 22 catatan seperti itu di perpustakaan JOOQ, Record1 ... Record22. Dari mana kita dapat menyimpulkan bahwa kemampuan JOOQ terbatas pada pemrosesan tabel dengan maksimum 22 kolom, tetapi tidak demikian halnya. Saya memiliki tabel dengan lusinan kolom, dan JOOQ segera mengimplementasikan Record. Ya, itu ...

Menghasilkan entitas JOOQ dalam kode Java terlihat seperti ini:

@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)));
    }
}

Dalam kasus saya, generator dimulai setelah aplikasi sepenuhnya dimulai, ketika semua migrasi baru digulung dan database diperbarui.

Jadi, kita punya model, kita punya entitas, kita punya basis. Saatnya membuat repositori dan menulis kueri.

Buat Repositori


Semua permintaan melalui DslContext. Ulangi DslContext.

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

    private final DSLContext dsl;

} 

Repositori sudah siap.

Menulis Pertanyaan CRUD


Memasukkan


Jika kami menggunakan SQL, permintaan untuk menambahkan negara lain adalah:

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

Di JOOQ, kueri sedekat mungkin dengan sintaks SQL:

    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);
    }

Seperti yang kita lihat, tidak ada yang rumit. Namun, permintaan semacam itu tidak sesuai dengan kami, karena dalam kasus kami ID dihasilkan dalam database dan kami harus mempertimbangkannya. Jika kita menulis sesuatu seperti:

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

kita akan mendapatkan

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

Namun, jika kami membuat ID di sisi aplikasi, permintaan seperti itu akan menjadi tumpangan. Kami harus menulis ulang permintaan, memberikan masing-masing bidang nilai tertentu. Jadi kamu bisa:

    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);
    }

Dalam hal ini, kami secara manual mengatur objek di bidang. Opsi ini cukup berhasil, tetapi ada yang lebih baik. Akan ideal untuk mengirim seluruh objek untuk disimpan, seperti yang dilakukan di Spring Data JPA:

repository.save(country);

Bisa jadi begitu. Untuk melakukan ini, kita perlu memetakan model kita ke entitas Record yang diperluas dan mengirimkannya untuk disimpan:

    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);
    }

Metode ini adalah yang paling sederhana dan paling dapat dipahami untuk kasus-kasus tersebut ketika kita tidak perlu mengkonfigurasi pemetaan. Namun pengaturan pemetaan akan lebih rendah.

Seperti yang kami perhatikan, permintaan ini mengembalikan seluruh entitas. Anda dapat menentukan apa yang sebenarnya Anda butuhkan untuk kembali dalam metode fetch (). Ini terlihat seperti ini:

    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
    }

Agak rumit, tapi, lagi, dengan apa yang harus dibandingkan.

Kami akan menulis sisa metode CRUD.

Memperbarui


Permintaan SQL:

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

Permintaan JOOQ:

    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);
    }

Pilih


Kueri dalam SQL adalah:

select *
from countries c
where id = ?;

Di JOOQ, kueri akan terlihat sesuai:

    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);
    }

Menghapus


Ada permintaan yang tidak ada balasannya. Kueri tersebut mengembalikan jumlah baris yang terpengaruh. Dalam menghapus, kami tidak memiliki apa pun untuk dikembalikan, tetapi informasi yang kami terima masih bermanfaat bagi kami.

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

Kami menghapus satu baris. Jadi query SQL akan mengembalikan sesuatu seperti:

1 row affected in 5 ms

Setelah menerima jawaban seperti itu, kami tahu pasti bahwa baris tersebut telah dihapus. Ini akan cukup untuk menyatakan operasi berhasil.

Itu bukan dongeng. Itu hanya pelumas. Menggunakan maper biasa untuk pemetaan tipis suatu entitas dalam Record dan sebaliknya


"Oke," katamu, pembaca, "di mana satu-ke-banyak, emoe?" Sesuatu yang tidak saya lihat saat di mana Country tumbuh dengan banyak City. โ€

Dan kamu akan benar. Ini bukan Hibernate, di sini Anda harus secara manual. Yang perlu Anda lakukan adalah mendapatkan daftar Kota yang id = country.getId (). Saya sudah menunjukkan metode into (), yang menggunakan mapper biasa untuk memetakan Rekaman ke entitas. Tetapi JOOQ memiliki metode map () tambahan yang memungkinkan kita melakukan apa pun yang kita inginkan dengan Record. Mari kita lihat fungsinya dan tambahkan penambahan kota:

    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;
                });
    }

Seperti yang kita lihat, sekarang kita pertama-tama memetakan Catatan di Negara, dan kemudian kita membuat permintaan lain di kota-kota, kita mendapatkan semua Kota untuk Negara ini dan mengaturnya menjadi esensi.

Tetapi bisa ada puluhan set seperti itu, yang berarti bisa ada puluhan permintaan. Dan untuk menggambarkan permintaan ini secara langsung dalam metode seperti itu. Solusi yang benar adalah dengan menulis mapper terpisah dan meletakkan semua permintaan ini di sana.

@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;
    }
}

Kueri sekarang akan terlihat seperti ini:

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

Itu lebih ringkas, dan tidak mengandung logika tambahan.

Oke, kami belajar cara memetakan Rekaman ke entitas, tetapi bagaimana dengan menyempurnakan pemetaan entitas di Record?


Sejauh ini kami memiliki desain ini:

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

Sudah bagus, tetapi bagaimana jika kita perlu memetakan bidang secara khusus? Sebagai contoh, kita memiliki LocalDateTime di sana, dan generator untuk PostgreSQL seperti Timestamp menghasilkan OffsetDateTime. Dalam kasus ini, bidang tidak akan dipetakan dan tidak direkam dalam database. Untuk kasus seperti itu, kita akan membutuhkan mapper lain yang akan melakukan hal yang sama, tetapi dalam arah yang berlawanan.

Ya, untuk setiap mapper, kami memiliki mapper


Dia disebut itu. Mari kita tulis ahli warisnya.

@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;
    }
}

Masukkan dengan aplikasinya akan terlihat seperti ini:

    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);
    }

Seperti yang kita lihat, juga cukup.

Kesimpulan dan Kesimpulan


Secara pribadi, saya lebih suka Hibernate. Mungkin, untuk 90+% aplikasi penggunaannya akan lebih dibenarkan. Tetapi jika Anda, pembaca, ingin mengontrol setiap permintaan, JOOQ atau perpustakaan serupa lainnya lebih cocok untuk Anda.

Seperti biasa, saya memposting proyek pelatihan. Dia berbaring di sini .

All Articles