Jimmer 快速上手(1) 新项目在选技术栈?试试这款国产 ORM 吧

项目文档

仓库地址

简介

Jimmer 是一款新兴的 Java / Kotlin ORM ,以及相关的一整套的集成方案,除了数据库业务实现,还包含多视角缓存前端对接代码生成等功能。

Jimmer 能够带来流畅舒适的全新开发体验,用优雅的方案解决很多看起来繁琐的问题。无论是前端要的各种凌乱散碎的实体形状还是各种复杂表单保存,Jimmer都能给予你合适的解决方案。

现在,摆在面前的只有一道障碍,愿不愿意推开门去看一下这个"新风景"了。

使用前提示:Jimmer 确实有很多乍看很颠覆的设计,这导致旧项目的迁移相对有难度,建议直接从新项目入手。而当了解了 Jimmer 的基本使用后,你就会逐渐发现这些设计的必要性与美感。

下面,我会从项目和实体创建开始,演示如何在 Java + SpringBoot 中使用 Jimmer 完成增改查删等基本操作。

基本项目创建

环境(不用担心,这个项目没有用上新版本特性,使用 Java 8 和 SpringBoot 2.7 版本也不会有问题,只是以下是 IDEA 当前默认的版本):

  • IDEA 2023.2.5
  • Java 17
  • SpringBoot 3.2
  • Maven 3.6.3
  • MySQL 8(只有这个是本练手 demo 的强要求)

以上是当前版本 IDEA 使用 Spring Initializr 基本相当于默认的配置,但这个项目不会用到高级特性,所以也不用为版本担心,加上这些配置直接走即可。

接下来我们需要引入 Jimmer 以及其他 web 项目的基础依赖(jimmer 版本最好保持一致,此处是 0.8.51,mysql connector 版本自行调整)

xml 复制代码
<dependency>
    <groupId>org.babyfish.jimmer</groupId>
    <artifactId>jimmer-spring-boot-starter</artifactId>
    <version>0.8.51</version>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.30</version>
    <scope>runtime</scope>
</dependency>

并且我们还需要引入 jimmer-apt 插件,用于在编译期生成一些辅助类(关于这个,在下一步就会解释这个了)

xml 复制代码
<build>
    <plugins>
    	<plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>

        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.10.1</version>
            <configuration>
                <annotationProcessorPaths>
                    <path>
                        <groupId>org.babyfish.jimmer</groupId>
                        <artifactId>jimmer-apt</artifactId>
                        <version>0.8.51</version>
                    </path>
                </annotationProcessorPaths>
            </configuration>
        </plugin>
    </plugins>
</build>

之后,我们还需要在 application.yml 中配置好项目的数据库连接、Jimmer 的数据库方言并且开启 SQL 日志。

yaml 复制代码
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/demo # 自行配置 url
    username: # 自行配置 username
    password: # 自行配置 password

jimmer:
  dialect: org.babyfish.jimmer.sql.dialect.MySqlDialect
  show-sql: on
  pretty-sql: true

单表基本操作

库表与实体创建

完成项目的基本配置后,我们就要开始进入正题了。

Jimmer 是 ORM(Object Relational Mapping,对象关联映射)框架,自然对实体与数据库表结构是有绝对一致的要求的。在 Jimmer 中,实体就是数据库表结构在项目中的映射。

下面我们就整一个极为简单的实体 User 吧:

sql 复制代码
create table user
(
    id   int auto_increment,
    name varchar(50) not null,
    age int,
    primary key (id)
);
java 复制代码
package com.example.demo.entity;

import org.babyfish.jimmer.sql.Entity;
import org.babyfish.jimmer.sql.GeneratedValue;
import org.babyfish.jimmer.sql.GenerationType;
import org.babyfish.jimmer.sql.Id;
import org.jetbrains.annotations.Nullable;

@Entity
@Table(name = "demo.user") // 对应寻找唯一 table,user 这个名字可太容易重名了,所以要加上数据库名/模式名进行约束
interface User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) // id 自增
    int id();

    String name();

    @Nullable
    Integer age();
}

嗯?为什么实体是个接口?

这是个初看极其怪异的地方,也是 Jimmer 框架得以轻松解决大多数场景痛点极其必要的一个需求。

关于这一点,文档中有非常明确详细的介绍,具体参见 不一样的实体对象篇

简单的说,是实体想要实现一些功能就必须具备两个特性,动态性不可变性。(不用担心,二者均会在下面的例子中有所说明。其中前者将在单表的修改与查询中得到说明,后者也会在主子表例子中得到介绍)

先抛开是接口这回事不提,我们先仔细看一下这个实体。

可以看到,属性的类型以及那个 Nullable 注解,是严格对应了表结构的,这样可以保证与数据库交互过程中不会出现空安全问题。比如,id 便是一定不可为空,所以必须强制使用 int,而 age 是个可 null 的字段,自然类型需要 Integer,而且要求标注 @Nullable 注解。

并且,为了保证实体与模型的绝对一致,Jimmer 提供了数据库结构验证的配置,下面我们也把它加到 application.yml 中吧。

yaml 复制代码
jimmer:
  # 省略其他配置
  database-validation:
    mode: ERROR
    schema: demo # 连接到的数据库名/模式名

以及,表名映射和列名映射 Jimmer 默认采用了下划线转驼峰的映射,如有需要都可以通过 @Table 和 @Column 注解进行覆盖

这时候,又一个问题冒出来了,那我们要怎样创建一个对象呢?以往 class 可是可以直接 new 的,一个 interface 要怎样创建呢?

不要急,Jimmer 自然提供了办法。不过那之前,我们要先启动一下这个项目,运用上面的 apt 插件在项目编译时生成这个 User 的实现类和其他辅助类型。

编译成功后,我们就可以在 target 下看到它们了:

它们各自的作用会在下面一一得到说明。

而现在,我们就需要使用其中一个辅助类型, 草稿 UserDraft

正常情况下,自动生产的代码目录(java/kt包所在的父目录)会被Intellij识别,但目前没有,这样会导致生成的代码无法被用于后续开发,所以此时我们需要把生成的这个 annotations 标注成生成的源代码根目录。

之后,通过 UserDraft 就可以创建实体了。具体可以参照文档中的 Draft

java 复制代码
User user = UserDraft.$.produce(draft -> {
    draft.setName("name");
    draft.setAge(11);
});

新增和修改,也即保存

下面,就让我们起一个基本的测试类,看看 Jimmer 是怎样进行新增和修改的:

java 复制代码
package com.example.demo;

import com.example.demo.entity.User;
import com.example.demo.entity.UserDraft;
import org.babyfish.jimmer.sql.JSqlClient;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class UserTest {
    @Autowired
    JSqlClient sqlClient;

    @Test
    void insert() {
        User user = UserDraft.$.produce(draft -> {
            draft.setName("name");
            draft.setAge(11);
        });

        sqlClient.insert(user);
    }
}

此处自动注入了 JSqlClient,这就是 Jimmer 的操作入口,后续的增删改查都将经由它完成。

执行这个测试,我们就将正式的插入了这个实体。不出意外,我们应该可以看到这样的日志:

sql 复制代码
Execute SQL===>
Purpose: MUTATE
SQL: insert into demo.user(NAME, AGE)
values
    (? /* name */, ? /* 11 */)
JDBC response status: success
Time cost: 10ms
<===Execute SQL

而修改同样简单,我们只需要多给个 id 就行了:

java 复制代码
	@Test
    void update() {
        User user = UserDraft.$.produce(draft -> {
            draft.setName("a");
            draft.setAge(11);
        });

        int id = sqlClient.insert(user).getModifiedEntity().id();

        User updateUser = UserDraft.$.produce(draft -> {
            draft.setId(id);
            draft.setName("b");
            draft.setAge(32);
        });

        sqlClient.update(updateUser);
    }
sql 复制代码
Execute SQL===>
Purpose: MUTATE
SQL: update demo.user
set
    NAME = ? /* b */,
    AGE = ? /* 32 */
where
    ID = ? /* 4 */
Affected row count: 1
JDBC response status: success
Time cost: 3ms
<===Execute SQL

使用 save 作为统一入口

其实,insert 和 update 可以更加简单,因为我们的目的通常是差量更新(upsert),如果不存在,插入,如果存在,就直接保存,应用 save 就可以搞定以上的部分了。

java 复制代码
	@Test
    void save() {
        User userWithoutId = UserDraft.$.produce(draft -> {
            draft.setName("a");
            draft.setAge(11);
        });

        User insertedUser = sqlClient.save(userWithoutId).getModifiedEntity();

        User userWithId = UserDraft.$.produce(draft -> {
            draft.setId(insertedUser.id());
            draft.setName("b");
            draft.setAge(32);
        });

        sqlClient.save(userWithId);
    }
sql 复制代码
Execute SQL===>
Purpose: MUTATE
SQL: insert into demo.user(NAME, AGE)
values
    (? /* a */, ? /* 11 */)
JDBC response status: success
Time cost: 11ms
<===Execute SQL


Execute SQL===>
Purpose: MUTATE
SQL: select
    tb_1_.ID
from demo.user tb_1_
where
    tb_1_.ID = ? /* 6 */
JDBC response status: success
Time cost: 6ms
<===Execute SQL


Execute SQL===>
Purpose: MUTATE
SQL: update demo.user
set
    NAME = ? /* b */,
    AGE = ? /* 32 */
where
    ID = ? /* 6 */
Affected row count: 1
JDBC response status: success
Time cost: 4ms
<===Execute SQL

可以看到,第一次没有设置 id,save 直接执行了 insert。

而当设置 id 后,save 先执行了查询判断是否存在,之后根据结果执行了 update。

乍看 save 有些多此一举,这个查询完全可以由用户自己去操作,那么为什么要这么干呢?

答案很简单,为了复杂关联数据的差量更新可以全自动进行。

不过目前实体模型简单,save 的作用仅仅是单表的 insert 和 update 两个操作的统一入口而已。

更复杂的场景,比如下面的一对多,就可以真正体会 save 这短短一行命令的威力了。

不完全更新

接下来,试试空出一些字段进行 update,。

java 复制代码
    @Test
    void diffUpdate() {
        User user = UserDraft.$.produce(draft -> {
            draft.setName("a");
            draft.setAge(11);
        });

        int id = sqlClient.insert(user).getModifiedEntity().id();

        User updateUser = UserDraft.$.produce(draft -> {
            draft.setId(id);
            draft.setAge(null);
        });

        sqlClient.update(updateUser);
    }
sql 复制代码
Execute SQL===>
Purpose: MUTATE
SQL: update demo.user
set
    AGE = ? /* <null: Integer> */
where
    ID = ? /* 8 */
Affected row count: 1
JDBC response status: success
Time cost: 3ms
<===Execute SQL

可以看到,update 时除了 id 这个查询条件,只设置了 age,sql 就也只更新了 age,这就是不完全更新。

为了实现这一点,jimmer 才需要实体以接口的形式出现,使字段多出一种 unload 状态 表达缺失/未设置的含义,让属性中承载的 null 不再代表未加载的含义,而是真正完全和数据库中字段的 null 状态对应。这样保证了更新操作完全可控,也就不需要每次在 update 前都要重新查询来保证不影响其他原字段了。

这就是 Jimmer 实体的一大特性动态性在不完整对象这一场景中的体现了。

查询

Jimmer 的查询语法也同样直白,只是这里会再用到一个生成的辅助类型 表 DSL UserTable

java 复制代码
	@Test
    void query() {
        UserTable table = UserTable.$;

        List<User> users = sqlClient.createQuery(table)
                .where(table.id().eq(1))
                .select(table)
                .execute();


        users.forEach(System.out::println);
    }
sql 复制代码
Execute SQL===>
Purpose: QUERY
SQL: select
    tb_1_.ID,
    tb_1_.NAME,
    tb_1_.AGE
from demo.user tb_1_
where
    tb_1_.ID = ? /* 1 */
JDBC response status: success
Time cost: 7ms
<===Execute SQL

{"id":1,"name":"name","age":11}

可以看到,我们上面的代码是完全依赖 UserTable 构建的,它所对应的成分就是标准 SQL 的表和内部的列。经过 UserTable 这样一个强类型入口,我们就可以编写出强类型的业务代码来替代更难维护的标准 SQL 了。

关于更具体详细的 SQL DSL,请查看文档的此处:DSL表达式

如果想调用原生 SQL 的函数,请查看文档的此处:Native表达式

子查询和排序也均有在 查询篇 进行介绍和演示,请自行查阅。

查询仅选取列

除了抓取一整个 table,select 自然也支持对特定字段进行抓取:

java 复制代码
	@Test
    void queryColumn() {
        UserTable table = UserTable.$;

        List<Tuple2<Integer, String>> idAndNames = sqlClient.createQuery(table)
                .where(table.id().eq(1))
                .select(table.id(), table.name())
                .execute();

        idAndNames.forEach(System.out::println);
    }
sql 复制代码
Execute SQL===>
Purpose: QUERY
SQL: select
    tb_1_.ID,
    tb_1_.NAME
from demo.user tb_1_
where
    tb_1_.ID = ? /* 1 */
JDBC response status: success
Time cost: 5ms
<===Execute SQL

Tuple2(_1=1, _2=name)

依靠 Tuple 类型作为承接,我们就可以获取对应的强类型列数据查询结果了。

列抓取

有时,我们想要查询表中部分数据,但又希望用一整个实体接收,此时就可以用上另一个辅助类型 抓取器 UserFetcher 了:

java 复制代码
    @Test
    void fetchColumn() {
        UserTable table = UserTable.$;

        UserFetcher nameFetcher = UserFetcher.$.name();

        List<User> onlyIdNameUser = sqlClient.createQuery(table)
                .where(table.id().eq(1))
                .select(table.fetch(nameFetcher))
                .execute();

        onlyIdNameUser.forEach(System.out::println);
    }
sql 复制代码
Execute SQL===>
Purpose: QUERY
SQL: select
    tb_1_.ID,
    tb_1_.NAME
from demo.user tb_1_
where
    tb_1_.ID = ? /* 1 */
JDBC response status: success
Time cost: 8ms
<===Execute SQL

{"id":1,"name":"name"}

可以看到,现在查询的结果就是只有 id 和 name 的 User 了。

为什么没有 age / age 为什么不是 null 的原因已经在上面关于动态性处有所提及了,此处引用上文:

jimmer 需要实体以接口的形式出现,通过实现接口使字段多出一种 unload 状态 表达缺失/未设置的含义,让属性中承载的 null 不再代表未加载的含义,而是真正完全和数据库中字段的 null 状态对应。

因此当我们通过 Fetcher 抓取字段时,除了一定会存在的 id 和目标字段外,其他字段就都将不被查询,保持 unload 状态。此时 toString 时,也就一目了然了。

比之上面仅对列 select,相信直接使用 fetch 可以满足更多的需求。

删除

删除可以看作是查询的后置操作,因此简单修改查询的例子就可以得到删除的语句

java 复制代码
	@Test
    void delete() {
        UserTable table = UserTable.$;

        Integer count = sqlClient.createDelete(table)
                .where(table.id().eq(1))
                .execute();

        System.out.println(count);
    }
sql 复制代码
Execute SQL===>
Purpose: DELETE
SQL: delete tb_1_
from demo.user tb_1_
where
    tb_1_.ID = ? /* 1 */
Affected row count: 1
JDBC response status: success
Time cost: 6ms
<===Execute SQL

当然,对于 id 删除自然也有些简单的写法:

java 复制代码
	@Test
    void simpleDelete() {
        DeleteResult deleteResult = sqlClient.deleteById(User.class, 2);

        System.out.println(deleteResult.getTotalAffectedRowCount());
    }

其实 sqlClient 中有很多这种简单的方法,可自行查看调用进行尝试。

主子表基本操作

啊,花了挺长的篇幅才介绍完单表的操作,接下来就是 Jimmer 真正优势的体现了。

沿用单表操作的项目,接下来还是依照上面的流程,我们尝试使用 Jimmer 搞定存在关联的一对主子实体,订单(Order)和订单明细(OrderDetail)。

库表与实体创建

sql 复制代码
CREATE TABLE `order` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(50) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

CREATE TABLE `order_detail` (
  `id` int NOT NULL AUTO_INCREMENT,
  `order_id` int NOT NULL,
  `product` varchar(50) NOT NULL,
  `product_count` int DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

Order.java

less 复制代码
@Entity
@Table(name = "demo.order")
public interface Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    int id();

    String name();
}

OrderDetail.java

java 复制代码
package com.example.demo.entity;

import org.babyfish.jimmer.sql.Column;
import org.babyfish.jimmer.sql.Entity;
import org.babyfish.jimmer.sql.GeneratedValue;
import org.babyfish.jimmer.sql.GenerationType;
import org.babyfish.jimmer.sql.Id;
import org.babyfish.jimmer.sql.Table;
import org.jetbrains.annotations.Nullable;

@Entity
@Table(name = "demo.order_detail")
public interface OrderDetail {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    int id();

    int orderId();

    String product();

    @Nullable
    Integer productCount();
}

以上就是直接根据表创建出的实体了。

可以看出表达关联的 order_id 这一字段实质应是一个引用 order 表 id 字段的外键,但在阿里规范中为避免级联删除等问题要求尽量不使用外键,这就要求我们要在业务代码层面进行很多额外的处理来维护这层关联。

而在 Jimmer 中,我们仅需要在实体层面将数据模型层面的关联还原出来,就可以在业务层面轻松搞定包括过去相当繁琐的一系列问题了(包括子表修改删除、级联删除、跨表关联查询等等)。

关联创建

按照 ER 关系,这就是一个典型的一对多关系。自然,Jimmer 中当然也提供了这种基本映射,那就是 @OneToMany 和 @ManyToOne 这对关联注解。

可以注意到,从模型角度,这个关联其实是 Order(主) 一对多 Detail(从),但在数据库层面,却是 detail.order_id -> order.id,二者的主动方从动方发生了反转,所以此处我们需要维护的关系必然是双向的。

首先,我们自然要从实际存在的 int orderId(); 这个部分入手开始改造:

java 复制代码
	@ManyToOne
    @JoinColumn(
        name = "order_id",
        foreignKeyType = ForeignKeyType.FAKE
    )
    @Nullable
    Order order();

    @IdView("order")
    Integer orderId();

我们需要将原本的 orderId 替换为如上两个关联属性。

尽管实质上数据库中的 order_id 是非空的,但因为 OrderDetail 存在子关联保存时自动装填父实体这样的场景,所以将要求必须为非空。这一点在下面的保存会详细介绍。

除此以外,相信上面的代码并不难理解,order_id 这一数据库字段在实体层面将分割为两个部分,一个是表达关联实体 Order,一个则是这个关联实体的 id 兼数据库层面的关联字段值。正如 IdView 的字面意思,后者在实体层面只是前者的一个视图属性。

下一步,我们就要对 Order 这个父实体下手了:

java 复制代码
@Entity
@Table(name = "demo.order")
public interface Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    int id();

    String name();
    
    @OneToMany(mappedBy = "order")
    List<OrderDetail> details();
}

多补充一个 OneToMany 的 details 属性,相信这个就是大多数场景下所需要的了。

上面的 mappedBy 代表这个列表属性映射翻转自 OrderDetail 这个类中的 order 属性。

毕竟从数据库层面除非使用列表字段,不然关联字段必定就在子表中了。

补充完了这两个实体的关联属性,现在就可以随意去像捏泥巴一样创建聚合根对象了。( 当然,别忘了重新编译一遍项目,生成一下那些辅助类型。)

java 复制代码
Order baseOrder = OrderDraft.$.produce(draft -> {
    draft.setName("name");
});

OrderDetail mouse = OrderDetailDraft.$.produce(draft -> {
    draft.setProduct("鼠标");
    draft.setProductCount(1);
});
OrderDetail box = OrderDetailDraft.$.produce(draft -> {
    draft.setProduct("机箱");
    draft.setProductCount(1);
});

List<OrderDetail> details = new ArrayList<>();
details.add(mouse);
details.add(box);

Order order = OrderDraft.$.produce(baseOrder, draft -> {
    draft.setDetails(details);
});

这样就得到了如下实体:

json 复制代码
{
    "name": "name",
    "details": [
        {
            "product": "鼠标",
            "productCount": 1
        },
        {
            "product": "机箱",
            "productCount": 1
        }
    ]
}

保存

上面的单表操作部分对新增和修改统一至 save 已经有相对完整的说明,此处就不再赘述了,让我们直接开始吧。

首先还是再准备一个测试类:

java 复制代码
@SpringBootTest
class OrderTest {
    @Autowired
    JSqlClient sqlClient;

    @Test
    void save() {
        Order baseOrder = OrderDraft.$.produce(draft -> {
            draft.setName("name");
        });

        OrderDetail mouse = OrderDetailDraft.$.produce(draft -> {
            draft.setProduct("鼠标");
            draft.setProductCount(1);
        });
        OrderDetail box = OrderDetailDraft.$.produce(draft -> {
            draft.setProduct("机箱");
            draft.setProductCount(1);
        });

        List<OrderDetail> details = new ArrayList<>();

        details.add(mouse);
        details.add(box);

        Order order = OrderDraft.$.produce(baseOrder, draft -> {
            draft.setDetails(details);
        });

        sqlClient.save(order);
    }
}

此处,可能按照直觉我们直接进行保存就可以了,但 save 还有额外的要求。

更具体的介绍同样请参考文档 保存指令 ,此处我只是简单介绍一下。

save 与 key,实体的业务层面唯一性

在上面单表的场景中,save 只需要判断 id 是否存在就可以区分 insert 和 update。但这会儿情况就不一样了,details 不给 id ,判断不了到底该做什么操作。

比如数据库里本来就有这份订单,订单现在的数据是这样的:

json 复制代码
{
    "name": "采购1",
    "details": [
        {
            "product": "显示器",
            "productCount": 2
        },
        {
            "product": "硬盘",
            "productCount": 4
        }
    ]
}

然后这份订单需要修改成:

json 复制代码
{
    "name": "采购1",
    "details": [
        {
            "product": "显示器",
            "productCount": 1
        },
        {
            "product": "音响",
            "productCount": 2
        }
    ]
}

这时候,如果直接拿上面这份数据去保存,那么之前说要买的硬盘是删除呢,还是留着呢?显示器又是该更新呢,还是删除呢?

通常业务层面为了简化,我们最常用的就是删除之前所有的关联子表数据,重新保存一份。但这时候如果子表关联到财务报表,重新插入势必会导致同一产品明细的 id 变更。

jimmer 提供的解决方案就是根据实体业务层面唯一性去做差量更新。

对此时的 OrderDetail 来说,它在业务层面的唯一性由 product 这个本身的属性和 order 这个父级决定。

对要 save 的新数据而言,当有业务 Key 和自身一致的旧数据时,需要执行的就是更新,不一致,执行的就是插入。而剩下的没和新数据对上的旧 Detail,就被执行脱钩(Dissociate),即和 Order 断开关系了。

也即通过实体的业务层面唯一性去保证保存操作可以真正实现差量更新。

所以,为了让我们摆脱老办法用单表操作带来的一系列复杂的为了维护业务一致性的麻烦代码,只需要在实体上标注业务 key 就可以了。

最终,我们的 OrderDetail 业务实体将变成下面的样子:

java 复制代码
@Entity
@Table(name = "demo.order_detail")
public interface OrderDetail {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    int id();

    @ManyToOne
    @JoinColumn(
        name = "order_id",
        foreignKeyType = ForeignKeyType.FAKE
    )
    @Key // 父级自然是一个业务键
    @OnDissociate(DissociateAction.DELETE) // 如果脱钩了,就把自身删除
    @Nullable
    Order order();

    @IdView("order")
    Integer orderId();

    @Key // 自己的核心数据自然就是第二个业务键
    String product();

    @Nullable
    Integer productCount();
}

看上去有点复杂?别担心,聚焦到单个属性上,其实含义都是明确的,JoinColumn 只是对 ManyToOne 的实现的说明,而 Key 也只是在标注实体层面的唯一性。

这样我们终于可以执行那个保存操作了:

sql 复制代码
Execute SQL===>
Purpose: MUTATE
SQL: insert into demo.order(NAME)
values
    (? /* name */)
JDBC response status: success
Time cost: 7ms
<===Execute SQL


// 鼠标,Detail 1 的判断
Execute SQL===>
Purpose: MUTATE
SQL: select
    tb_1_.ID,
    tb_1_.order_id,
    tb_1_.PRODUCT
from demo.order_detail tb_1_
where
        tb_1_.order_id = ? /* 4 */
    and
        tb_1_.PRODUCT = ? /* 鼠标 */
JDBC response status: success
Time cost: 12ms
<===Execute SQL


Execute SQL===>
Purpose: MUTATE
SQL: insert into demo.order_detail(order_id, PRODUCT, PRODUCT_COUNT)
values
    (? /* 4 */, ? /* 鼠标 */, ? /* 1 */)
JDBC response status: success
Time cost: 2ms
<===Execute SQL


// 机箱,Detail 2 的判断
Execute SQL===>
Purpose: MUTATE
SQL: select
    tb_1_.ID,
    tb_1_.order_id,
    tb_1_.PRODUCT
from demo.order_detail tb_1_
where
        tb_1_.order_id = ? /* 4 */
    and
        tb_1_.PRODUCT = ? /* 机箱 */
JDBC response status: success
Time cost: 0ms
<===Execute SQL


Execute SQL===>
Purpose: MUTATE
SQL: insert into demo.order_detail(order_id, PRODUCT, PRODUCT_COUNT)
values
    (? /* 4 */, ? /* 机箱 */, ? /* 1 */)
JDBC response status: success
Time cost: 1ms
<===Execute SQL

可以看到,现在我们数据库是没有数据的,所以两个 Detail 最终都执行了 insert。

接下来,让我们把 Order 的 name 设置为 Key 并补充一个唯一索引,这样就可以通过 name 锁定唯一一个 Order 了。

(上面的子表其实按照要求也需要补充一个复合唯一索引,但是可能会有多种唯一约束的情况,key 在目前这个版本还是不能做到完全对应唯一索引)

java 复制代码
    @Key
    String name();
sql 复制代码
CREATE UNIQUE INDEX `uidx_order_name` on `order` (`name`);

对数据稍作修改,再次执行 save:

java 复制代码
    @Test
    void save() {
        Order baseOrder = OrderDraft.$.produce(draft -> {
            draft.setName("name");
        });

        OrderDetail sound = OrderDetailDraft.$.produce(draft -> {
            draft.setProduct("音响");
            draft.setProductCount(1);
        });

        List<OrderDetail> details = new ArrayList<>();

        details.add(sound);

        Order order = OrderDraft.$.produce(baseOrder, draft -> {
            draft.setDetails(details);
        });

        sqlClient.save(order);
    }
sql 复制代码
Execute SQL===>
Purpose: MUTATE
SQL: select
    tb_1_.ID,
    tb_1_.NAME
from demo.order tb_1_
where
    tb_1_.NAME = ? /* name */
JDBC response status: success
Time cost: 5ms
<===Execute SQL


Execute SQL===>
Purpose: MUTATE
SQL: select
    tb_1_.ID,
    tb_1_.order_id,
    tb_1_.PRODUCT
from demo.order_detail tb_1_
where
        tb_1_.order_id = ? /* 1 */
    and
        tb_1_.PRODUCT = ? /* 音响 */
JDBC response status: success
Time cost: 1ms
<===Execute SQL


Execute SQL===>
Purpose: MUTATE
SQL: insert into demo.order_detail(order_id, PRODUCT, PRODUCT_COUNT)
values
    (? /* 1 */, ? /* 音响 */, ? /* 1 */)
JDBC response status: success
Time cost: 3ms
<===Execute SQL


Execute SQL===>
Purpose: MUTATE
SQL: select
    ID
from demo.order_detail
where
    order_id = ? /* 1 */
    and
    ID <> ? /* 6 */
JDBC response status: success
Time cost: 1ms
<===Execute SQL


Execute SQL===>
Purpose: DELETE
SQL: delete from demo.order_detail
where
    ID in (
        ? /* 4 */, ? /* 5 */
    )
Affected row count: 2
JDBC response status: success
Time cost: 1ms
<===Execute SQL

短短一行 save 就完成了主子表整个差量更新。

观察执行的 SQL,可以看到一个完整清晰的对子表查询 -> 差量保存 -> 删除行为流程。并且,其中对于找寻的部分,对于用户而言是完全可控制的。

查询

承接保存,让我们查询一下这张 Order 吧:

java 复制代码
	@Test
    void query() {
        OrderTable table = OrderTable.$;

        OrderDetailFetcher detailFetcher = OrderDetailFetcher.$
                .orderId()
                .product()
                .productCount();

        OrderFetcher OrderWithDetailsFetcher = OrderFetcher.$
                .name()
                .details(detailFetcher);

        List<Order> orders = sqlClient.createQuery(table)
                .where(table.name().like("name"))
                .select(table.fetch(OrderWithDetailsFetcher))
                .execute();

        orders.forEach(System.out::println);
    }
sql 复制代码
Execute SQL===>
Purpose: QUERY
SQL: select
    tb_1_.ID,
    tb_1_.NAME
from demo.order tb_1_
where
    tb_1_.NAME like ? /* %name% */
JDBC response status: success
Time cost: 10ms
<===Execute SQL


Execute SQL===>
Purpose: LOAD
SQL: select
    tb_1_.ID,
    tb_1_.PRODUCT,
    tb_1_.PRODUCT_COUNT,
    tb_1_.order_id
from demo.order_detail tb_1_
where
    tb_1_.order_id = ? /* 1 */
JDBC response status: success
Time cost: 6ms
<===Execute SQL


{
	"id": 1,
	"name": "name",
	"details": [
        {
        	"id": 6,
        	"orderId": 1,
        	"product": "音响",
        	"productCount": 1
        }
    ]
}

可以看到,只需要书写简单的 Fetcher,我们轻松的就获取到了关联实体集合,并且响应实体的形状也就是 Fetcher 可以随时按照业务需要进行揉捏,我们无需再担心业务上出现的形形色色的 DTO 了。

并且,如果只需要查询所有的标量字段(非关联字段)或者全表字段,Fetcher 还有两个更简单的写法:

java 复制代码
OrderDetailFetcher.$
                // 全标量字段
                .allScalarFields(); // product、product_count

OrderDetailFetcher.$
    			// 全表字段
                .allTableFields(); // order、product、product_count

并且在使用如上时也可以使用负属性去排除字段:

java 复制代码
OrderDetailFetcher.$
                .allTableFields()
                .productCount(false)

删除

删除和查询一致:

java 复制代码
	@Test
    void delete() {
        OrderTable table = OrderTable.$;

        Integer count = sqlClient.createDelete(table)
                .where(table.name().like("name"))
                .execute();

        System.out.println(count);
    }
sql 复制代码
Execute SQL===>
Purpose: DELETE
SQL: select distinct
    tb_1_.ID
from demo.order tb_1_
where
    tb_1_.NAME like ? /* %name% */
JDBC response status: success
Time cost: 5ms
<===Execute SQL


Execute SQL===>
Purpose: DELETE
SQL: select
    ID
from demo.order_detail
where
    order_id = ? /* 1 */
JDBC response status: success
Time cost: 1ms
<===Execute SQL


Execute SQL===>
Purpose: DELETE
SQL: delete from demo.order_detail
where
    ID = ? /* 6 */
Affected row count: 1
JDBC response status: success
Time cost: 4ms
<===Execute SQL


Execute SQL===>
Purpose: DELETE
SQL: delete from demo.order
where
    ID = ? /* 1 */
Affected row count: 1
JDBC response status: success
Time cost: 3ms
<===Execute SQL


2

可以看到,最后的 count 统计了主子表删除的总数,因此是 2。此处级联删除的行为正是遵从于 OrderDetail 中脱钩操作的配置。

如果不需要脱钩,也可以,但这样的风险会被无限放大,非常非常不推荐使用!。此处仅供参考。

java 复制代码
    @Test
    void delete() {
        OrderTable table = OrderTable.$;

        Integer count = sqlClient.createDelete(table)
                .where(table.name().like("name"))
                .disableDissociation()
                .execute();

        System.out.println(count);
    }
sql 复制代码
Execute SQL===>
Purpose: DELETE
SQL: delete tb_1_
from demo.order tb_1_
where
    tb_1_.NAME like ? /* %name% */
Affected row count: 1
JDBC response status: success
Time cost: 6ms
<===Execute SQL

1

总结

关注高价值的实体关联模型

业务总是千奇百怪,但实体模型总是需要带上关联才可以真正实现业务。

经由上面两种简单场景增删查改的演示,相信各位可以明白 Jimmer 可以何等便捷的经由实体层面的关联模型完成过去那些浪费时间的普通业务功能。

Jimmer 为我们提供了一条直白明确的道路,让我们把重点真正放在维护高价值的实体关联模型 ,而非浪费大量时间在为维护由于不存在明确关联而脆弱的低质量业务代码中。

我仅代表个人相信,只有像 Jimmer 这样着眼于更高级别抽象、更好使用体验的框架,才能打破僵硬的堆人力解决业务的困局,带来软件行业且不止于软件行业的新生。

相对繁杂的操作语法

不可否认,在上面的演示代码中,创建与修改这种实体比起直接使用 java bean 实体要繁琐的多,需要大量 produce 流程,Table 信息获取需要通过不那么和谐的 $ 来拿到单例。

但以上是 Jimmer 在 Java 语法限制下的妥协。

如果你追求更优雅简洁高效的写法,可以尝试 Jimmer 的 Kotlin API。Kotlin 与 Java 从使用层面其实差距不大,但更现代化的 Kotlin 特性与灵活的 Jimmer 框架结合,将如虎添翼。

如有兴趣,强烈推荐仔细翻阅 Jimmer 文档

这第一篇教程仅仅只是展示了这个框架兼有高自动化高可控性 的冰山一角。虽然后续关于 DSL、Fetcher 及 DTO 等基础功能都会有更详细的介绍与上手指引,但在文档中包含了更多更深入细致的介绍。我强烈推荐各位从 映射篇 开始,完整的认识一下 Jimmer 的全貌。

这个框架总能带来各种惊喜。相信在未来的使用中,你会真正爱上使用 Jimmer。

相关推荐
沛沛老爹11 天前
Python Django全功能框架开发秘籍
python·django·orm·web开发·模板引擎·项目部署·表单处理
掉头发的王富贵17 天前
【JOOQ】同事凭什么说它是世界上最好用的ORM框架
后端·mybatis·orm
o0向阳而生0o21 天前
68、.NET Entity Framework(EF)
.net·orm·ef
xiangji25 天前
.net 实现 CQRS 的的一个设想
orm·sqlbuilder
却尘1 个月前
当全世界都在用 Rust 重写一切时,Prisma 却选择了反方向
前端·数据库·orm
喵个咪1 个月前
开箱即用的GO后台管理系统 Kratos Admin - 代码生成工具集
微服务·orm·protobuf
xiangji1 个月前
ShadowSql.net之正确使用方式
orm·dapper·可扩展·sqlbuilder·面向接口
MyikJ1 个月前
Java求职面试:从Spring到微服务的技术挑战
java·数据库·spring boot·spring cloud·微服务·orm·面试技巧
Jaising6661 个月前
Mybatis Plus 多租户实现思路分析
spring boot·mybatis·orm
xiangji1 个月前
ShadowSql之表达式树
orm·sqlbuilder