JOOQ e sua toca de coelho. Como sobreviver sem hibernação

Neste artigo, não vou me afogar no JOOQ. Prefiro o Hibernate e todo o poder JPA do Spring Data por trás dele. Mas o artigo não será sobre eles.



Quando usamos o Hibernate e o Spring Data JPA, não precisamos pensar em processos internos - conhecer as anotações e escrever os nomes de métodos corretos no repositório - esses dois monstros farão o resto por você. No caso do JOOQ, infelizmente para muitos, você precisa se esforçar um pouco e escrever mais do que findAllByUserId (Long userId) .

O que é um JOOQ?


Vamos escrever uma consulta SQL simples:

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

Nós podemos executar esta solicitação no console. OK

Não nos sentimos no console. Queremos que nosso aplicativo envie essa solicitação. Que não era um script único e que era validado pelo menos para sintaxe. Fazemos isso através do Spring Data JPA:

List<Country> findAllByPopulationAfter(Long amount);

A mesma solicitação é executada como acima, mas no Spring.

Quais são as vantagens claras dessa abordagem? É realizado por uma estrutura poderosa, mas também valida uma solicitação de erros. Mas o gerenciamento de consultas cuida da estrutura. E queremos gerenciar completamente a solicitação, mas ao mesmo tempo, para que a solicitação seja completamente validada.
UsarInquerir:

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

Essa troca entre SQL e DSL também é boa. Mas se não quisermos mexer com o SQL, teremos o prazer de ver algo como:

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

Diversas bibliotecas são adequadas para isso:

QueryDSL
JOOQ
Speedment


Sobre o QueryDSL Escrevi alguns anos atrás. Eu não gostei de Speedment, mas parece ser algo mais do que um simples gerador de DSL, além de ter que aprender os métodos pelos quais eles criptografaram comandos de consulta SQL. Em geral, hoje vamos falar sobre o JOOQ.

Consultas JOOQ


Sim, já vimos uma dessas consultas acima:

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

O que mais há?

Por exemplo, uma simples solicitação get:

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

Ou insira a solicitação:

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

Como você pode ver, tudo está claro, nada mais. Mas isso é apenas à primeira vista. Descobrir a profundidade dessa toca de coelho pode levar várias semanas.

Mas vamos começar do começo.

Sim, será maven


Eu prefiro Gradle. Mas, por alguma razão, os desenvolvedores do JOOQ prestam menos atenção ao Gradle, oferecendo-se para plugins de terceiros. Ok, o projeto de treinamento será no Maven. Mas se você, caro leitor, vasculhou as profundezas do github e está pronto para revelar um projeto totalmente personalizado no Gradle - escreva para mim e você se tornará co-autor deste artigo. O mesmo se aplica ao H2 (o projeto de treinamento será no PostgreSQL).

Por que não tive sucesso com Gradle e H2
, Gradle. jooq-generator . H2, « » H2 , , , .

Dependências necessárias do 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>

O Spring já possui um iniciador JOOQ que irá configurar automaticamente o DataSource de acordo com as configurações especificadas em application.yml. Mas para o trabalho completo da JOOQ, isso não é suficiente. As entidades devem ser geradas. Sim, sim, não escrevemos entidades, mas as geramos. A chamada abordagem de tabela em primeiro lugar.

A estrutura usual do aplicativo, com foco no trabalho com o banco de dados, fica assim:



Até a camada de serviço, os dados trafegam pelo aplicativo na forma de um modelo plano (DTO). Além disso, quando o serviço precisa trabalhar com o banco de dados, ele converte o DTO em uma entidade, o envia ao repositório e esse já o salva no banco de dados. Os desenvolvedores da JOOQ veem tudo de maneira diferente:



como podemos ver, a JOOQ revisou seriamente o padrão padrão e decidiu seguir seu próprio caminho. As diferenças são significativas:

  1. A conversão do DTO em uma entidade já ocorre no repositório.
  2. Nós não escrevemos uma entidade como tal. Foi escrito para nós pelo gerador JOOQ.

Isso já é suficiente para concluir que a JOOQ não é tão simples. Uma entidade gerada altamente personalizável, mapeamento incompreensível no DTO e outras dificuldades assustarão muitos. Mas nos casos em que não há para onde ir, um estudo focado desses aspectos do JOOQ pode levar a sério e levar dias ou até semanas. Tenho certeza de que minha postagem economizará significativamente seu tempo.

Abordaremos os seguintes aspectos do trabalho com a JOOQ:

  • Geração de Entidades.
  • Escrevendo consultas CRUD.
  • Otimização de solicitações CRUD.
  • E outra otimização de solicitação CRUD.
  • Mapeando entidades usando a biblioteca JOOQ padrão.
  • e discuta por que tudo isso é necessário.

Vamos lá.

O que escrevemos?


Nosso projeto terá duas entidades:

País:

@Data
public class Country {

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

    private List<City> cities;
}

Cidade:

@Data
public class City {

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

Relacionamento um para muitos. O país contém muitas cidades relacionadas. A cidade contém countryId.
A migração do Flyway é assim:

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

Vamos começar com a geração de entidades.


Entidades, como eu disse, são geradas separadamente. Existem duas maneiras de fazer isso.

  • Através do plugin Maven.
  • No código Java.

Gerando entidades no Maven


Quando o projeto é desenvolvido, o Maven inicia o gerador e gera entidades. E você pode ligar para o gerador a qualquer momento e gerar entidades. Isso é necessário nos momentos em que, digamos, a estrutura da base mudou. O plugin no Maven se parece com isso:

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

Se você fez tudo corretamente, atualize o Maven e, entre os plugins, você verá jooq-codegen.



Executá-lo. Isso irá gerar entidades para você.



Essas são as mesmas entidades que você usará para acessar o banco de dados. O primeiro incômodo: eles são imutáveis. Ou seja, eles são mutáveis, mas a alteração de tipo ocorre de maneira tão artificial que essa descrição do processo será puxada para um artigo separado. Portanto, tente pensar sobre os tipos de dados no estágio de geração da tabela.

As entidades parecem peculiares. Aqui, por exemplo, está a assinatura da classe CountriesRecord:

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

Como podemos ver, o CountriesRecord implementa o Record4, digitado por 4 tipos. A tabela de países possui 4 colunas, portanto, Record4. Existem 3 colunas nas cidades, então Record3. Por que isso foi inventado, eu não sei. Existem 22 desses registros na biblioteca JOOQ, Registro1 ... Registro22. A partir do qual podemos concluir que os recursos do JOOQ estão limitados ao processamento de tabelas com no máximo 22 colunas, mas não é assim. Eu tenho tabelas com dezenas de colunas e o JOOQ implementa imediatamente o Record. Bem, isso ... A

geração de entidades JOOQ no código Java se parece com isso:

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

No meu caso, o gerador inicia após o início completo do aplicativo, quando todas as novas migrações são acumuladas e o banco de dados atualizado.

Então, temos modelos, temos entidades, temos uma base. É hora de criar um repositório e escrever consultas.

Criar Repositório


Todos os pedidos passam por DslContext. Repita o DslContext.

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

    private final DSLContext dsl;

} 

O repositório está pronto.

Escrevendo consultas CRUD


Inserir


Se usamos o SQL, a solicitação para adicionar outro país seria:

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

No JOOQ, uma consulta é o mais próxima possível da sintaxe 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);
    }

Como vemos, nada complicado. No entanto, essa solicitação não nos convém, porque, no nosso caso, o ID é gerado no banco de dados e devemos levar isso em consideração. Se escrevermos algo como:

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

nós conseguiremos

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

No entanto, se gerássemos o ID no lado do aplicativo, essa solicitação seria uma atração. Teremos que reescrever a solicitação, atribuindo a cada campo um valor específico. Então você pode:

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

Nesse caso, definimos manualmente o objeto no campo. Esta opção está funcionando, mas existe melhor. Seria ideal enviar o objeto inteiro para salvar, como é feito no Spring Data JPA:

repository.save(country);

Poderia ser assim. Para fazer isso, precisamos mapear nosso modelo na entidade Record extends e enviá-lo para salvar:

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

Este método é o mais simples e mais compreensível para os casos em que não precisamos configurar o mapeamento. Mas a configuração do mapeamento será menor.

Como observamos, essa consulta retorna toda a entidade. Você pode determinar exatamente o que precisa retornar no método fetch (). Se parece com isso:

    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
    }

Um pouco complicado, mas, novamente, com o que comparar.

Vamos escrever o restante dos métodos CRUD.

Atualizar


Consulta SQL:

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

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

Selecione


Uma consulta no SQL seria:

select *
from countries c
where id = ?;

No JOOQ, a consulta parecerá adequadamente:

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

Excluir


Existem pedidos em que não temos nada a devolver. Essas consultas retornam o número de linhas afetadas. Na exclusão, não temos nada para retornar, mas as informações que recebermos ainda serão úteis para nós.

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

Excluímos uma linha. Portanto, a consulta SQL retornará algo como:

1 row affected in 5 ms

Depois de receber essa resposta, sabemos com certeza que a linha foi excluída. Isso será suficiente para declarar a operação bem-sucedida.

Não era um conto de fadas. Era apenas um lubrificante. Usando um mapeador regular para o mapeamento fino de uma entidade no Registro e vice-versa


"Ok", você diz, leitor, "onde está um para muitos, emoe?" Algo que não vi no momento em que o País cresce com uma multidão de cidades. ”

E você estará certo. Isso não é o Hibernate; aqui você precisa manualmente. Tudo o que você precisa fazer é obter uma lista da cidade para a qual id = country.getId (). Eu já mostrei o método into (), que usa um mapeador regular para mapear Record em uma entidade. Mas o JOOQ possui um método map () adicional que nos permite fazer o que quisermos com o Record. Vamos dar uma olhada em sua funcionalidade e adicionar a adição de cidades:

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

Como vemos, agora mapeamos primeiro o Registro no país e, em seguida, fazemos outra solicitação nas cidades, obtemos todas as cidades desse país e as definimos em essência.

Mas pode haver dezenas desses conjuntos, o que significa que pode haver dezenas de solicitações. E descrever essas solicitações diretamente no método é assim. A solução correta é escrever um mapeador separado e colocar todos esses pedidos lá.

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

A consulta agora terá a seguinte aparência:

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

É mais conciso e não contém lógica adicional.

Ok, aprendemos como mapear o Record em uma entidade, mas e o ajuste fino do mapeamento de uma entidade no Record?


Até agora, temos este design:

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

Já é bom, mas e se precisarmos mapear especificamente um campo? Por exemplo, temos LocalDateTime lá e um gerador para PostgreSQL como Timestamp gerou um OffsetDateTime. Nesse caso, o campo simplesmente não será mapeado e não será registrado no banco de dados. Para tais casos, precisaremos de outro mapeador que faça o mesmo, mas na direção oposta.

Sim, para cada mapeador que temos unmapper


Ele é chamado assim. Vamos escrever o herdeiro dele.

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

Inserir com seu aplicativo terá a seguinte aparência:

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

Como vemos, também bastante.

Conclusões e Conclusões


Pessoalmente, eu gosto mais do Hibernate. Provavelmente, para mais de 90% dos aplicativos, seu uso será mais justificado. Mas se você, leitor, deseja controlar cada solicitação, o JOOQ ou outra biblioteca semelhante é mais adequado para você.

Como sempre, eu posto o projeto de treinamento. Ele está deitado aqui .

All Articles