JOOQ和他的兔子洞。如何在没有冬眠的情况下生存

在本文中,我不会淹没JOOQ。我更喜欢Hibernate及其背后的所有Spring Data JPA功能。但是本文将不涉及它们。



当我们使用Hibernate和Spring Data JPA时,我们不需要考虑内部流程-了解注释并在存储库中编写正确的方法名称-这两个怪物将为您完成其余工作。对于JOOQ而言,不幸的是,对于许多人来说,这将花费一些精力,并且要比findAllByUserId(Long userId)编写更多内容

什么是JOOQ?


让我们编写一个简单的SQL查询:

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

我们可以在控制台中执行此请求。好的

我们感觉好像不在控制台中。我们希望我们的应用程序发送此请求。它不是一次性脚本,并且至少已针对语法进行了验证。我们通过Spring Data JPA做到这一点:

List<Country> findAllByPopulationAfter(Long amount);

与上面相同的请求被执行,但是由Spring执行。

这种方法的明显优势是什么?它由功能强大的框架执行;它还验证对错误的请求。但是查询管理负责框架。我们希望同时完全管理该请求,以便完全验证该请求。
采用询问

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

在SQL和DSL之间进行这种折衷也很好。但是,如果我们不想弄乱SQL,我们将很高兴看到以下内容:

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

一些库都适合这样的:

QueryDSL
JOOQ
Speedment


关于QueryDSL我一个写了几年前。我没有研究Speedment,但它似乎不仅仅是一个简单的DSL生成器,而且我还必须学习他们加密SQL查询命令的方法。一般来说,今天我们将讨论JOOQ。

JOOQ查询


是的,我们已经在上面看到了以下查询之一:

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

还有什么?

例如,一个简单的get请求:

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

或插入请求:

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

如您所见,一切都清楚了,仅此而已。但这只是乍看之下。找出这个兔子洞有多深可能需要几个星期。

但是,让我们从头开始。

是的,它将成为专家


我更喜欢Gradle。但是出于某种原因,JOOQ开发人员对Gradle的关注度降低了,他们选择了第三方插件。好的,培训项目将在Maven上进行。但是,亲爱的读者,如果您在github的深处翻阅,并且准备在Gradle上展示一个完全自定义的项目,请写信给我,您将成为本文的合著者。H2同样适用(培训项目将在PostgreSQL上)。

为什么我在Gradle和H2上没有成功
, Gradle. jooq-generator . H2, « » H2 , , , .

必要的Maven依赖项:

<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已经有一个JOOQ启动器,它将根据application.yml中的指定设置自配置DataSource。但是对于JOOQ的全部工作来说,这还不够。必须产生实体。是的,是的,我们不编写实体,而是生成它们。所谓表优先的方法。

专注于使用数据库的常规应用程序结构如下:



在服务层之前,数据以平面模型(DTO)的形式在应用程序中传播。此外,当服务需要使用数据库时,它将DTO转换为实体,将其发送到存储库,并且该DTO已经将其保存到数据库。JOOQ开发人员对事物的看法有所不同:



正如我们所看到的,在JOOQ中,他们认真修改了标准模式并决定走自己的路。差异非常明显:

  1. DTO转换为实体已经在存储库中进行。
  2. 我们不会这样写实体。它是由JOOQ生成器为我们编写的。

已经足以得出结论,JOOQ并不是那么简单。高度可定制的生成实体,DTO中难以理解的映射以及其他困难将吓跑许多人。但是,在无处可去的情况下,对JOOQ的这些方面进行定向研究可能会严重带走并花费数天甚至数周。我相信我的帖子将大大节省您的时间。

我们将介绍与JOOQ合作的以下方面:

  • 实体生成。
  • 编写CRUD查询。
  • 优化CRUD请求。
  • 还有另一个CRUD请求优化。
  • 使用标准的JOOQ库映射实体。
  • 并讨论为什么所有这些都是必要的。

我们走吧。

我们写什么?


我们的项目将有两个实体:

国家/地区:

@Data
public class Country {

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

    private List<City> cities;
}

市:

@Data
public class City {

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

一对多关系。国家包含许多相关的城市。城市包含countryId。
Flyway迁移看起来像这样:

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

让我们从实体生成开始。


正如我所说,实体是分别生成的。有两种方法可以做到这一点。

  • 通过Maven插件。
  • 用Java代码编写。

在Maven中生成实体


当项目生成时,Maven启动生成器并生成实体。您可以在任何方便的时间调用生成器并生成实体。例如,当基座的结构发生变化时,这是必需的。Maven中的插件如下所示:

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

如果您正确执行了所有操作,请更新Maven,然后在插件中看到jooq-codegen。



运行。它将为您生成实体。



这些是用于访问数据库的实体。第一个麻烦:它们是一成不变的。也就是说,它们是可变的,但是类型更改以一种人为设计的方式发生,因此该过程描述将被拉到单独的文章中。因此,尝试在表生成阶段考虑数据类型。

实体看起来很奇怪。例如,这是CountrysRecord类的签名:

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

如我们所见,CountryRecord实现了Record4,它由4种类型来键入。国家表有4列,因此为Record4。城市中有3列,因此为Record3。我不知道为什么这样发明。JOOQ库中有22个这样的记录,即Record1 ... Record22。从中可以得出结论,JOOQ的功能仅限于处理最多22列的表,但事实并非如此。我有包含数十列的表,JOOQ只是立即实现Record。好吧,...

用Java代码生成JOOQ实体看起来像这样:

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

就我而言,当所有新的迁移都汇总并且数据库是最新的时,生成器将在应用程序完全启动后启动。

因此,我们有模型,有实体,有基础。现在该创建存储库并编写查询了。

创建存储库


所有请求都通过DslContext。重复DslContext。

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

    private final DSLContext dsl;

} 

存储库已准备就绪。

编写CRUD查询



如果使用SQL,则添加另一个国家/地区的请求将是:

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

在JOOQ中,查询尽可能接近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);
    }

如我们所见,没有什么复杂的。但是,这样的请求不适合我们,因为在我们的情况下,ID是在数据库中生成的,因此我们必须考虑到这一点。如果我们这样写:

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

我们会得到

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

但是,如果我们在应用程序端生成了ID,那么这样的请求将是一个旅程。我们将不得不重写请求,为每个字段指定一个特定值。所以你可以:

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

在这种情况下,我们在字段中手动设置对象。这个选项很有效,但是更好。像Spring Data JPA一样,发送整个对象进行保存是理想的:

repository.save(country);

可能是这样。为此,我们需要将我们的模型映射到extended Record实体并将其发送以进行保存:

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

对于不需要配置映射的情况,此方法是最简单且最容易理解的。但是映射设置会更低。

正如我们注意到的,此查询返回整个实体。您可以在fetch()方法中确定确切需要返回的内容。看起来像这样:

    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
    }

有点麻烦,但是又要比较一下。

我们将编写其余的CRUD方法。

更新资料


SQL查询:

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

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

选择


SQL中的查询为:

select *
from countries c
where id = ?;

在JOOQ中,查询将相应地显示:

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

删除


在有些请求中,我们没有任何退货。此类查询返回受影响的行数。在删除中,我们什么也没退,但是我们收到的信息仍然对我们有用。

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

我们删除一行。因此,SQL查询将返回类似以下内容的内容:

1 row affected in 5 ms

收到这样的答案后,我们肯定知道该行已被删除。这足以宣布操作成功。

这不是童话。那只是润滑剂。使用常规映射器在Record中对实体进行瘦映射,反之亦然


“好吧,”读者对读者说,“一对多在哪里?我没有看到乡村随众多城市一起成长的那一刻。”

你会是对的。这不是休眠;在这里您必须手动进行。您所需要做的就是获取ID = country.getId()的城市列表。我已经展示了into()方法,该方法使用常规映射器将Record映射到实体。但是JOOQ还有一个map()方法,使我们可以对Record进行任何操作。让我们看一下它的功能并添加城市:

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

如我们所见,现在我们首先映射“国家/地区中的记录”,然后再在城市中提出另一个请求,我们获得了该国家/地区的所有城市并将其设置为实质。

但是可以有数十个这样的集合,这意味着可以有数十个请求。并直接在方法中描述这些请求就是这样。正确的解决方案是编写一个单独的映射器,并将所有这些请求放在此处。

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

现在查询将如下所示:

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

它更加简洁,并且不包含其他逻辑。

好的,我们学习了如何将Record映射到实体,但是如何微调Record中实体的映射呢?


到目前为止,我们已经有了以下设计:

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

已经很好了,但是如果我们需要专门绘制一个字段怎么办?例如,我们在那里有LocalDateTime,而诸如Timestamp之类的PostgreSQL生成器生成了OffsetDateTime。在这种情况下,该字段将不会被映射,也不会记录在数据库中。在这种情况下,我们将需要另一个映射器执行相同的操作,但方向相反。

是的,对于每个映射器,我们都没有映射器


他被称为。让我们写他的继承人。

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

插入及其应用程序将如下所示:

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

如我们所见,也相当。

结论与结论


就个人而言,我更喜欢休眠。对于90%以上的应用程序,可能更合理。但是,如果您(读者)想要控制每个请求,则JOOQ或其他类似的库更适合您。

一如既往,我发布了培训项目。他躺在这里

All Articles