前言

Spring 为了支持以统一的方式访问不同类型的数据库,提供了一个 Spring Data 框架,这个框架根据不同的数据库访问技术划分了不同的模块。上篇 《Spring 加强版 ORM 框架 Spring Data 入门》 介绍了不同模块遵循的通用规范,这篇我们来介绍下基于 JDBC 技术实现的 spring-data-jdbc 模块。

一、入门

基本的概念这里就不多说了,如果你在本篇遇到不明白的地方可以移步上一篇文章查看相关内容。

Spring Boot 内置了对 spring-data-jdbc 的支持,我们先通过一个 Spring Boot 项目了解 spring-data-jdbc 框架。首先引入相关 starter。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jdbc</artifactId>
    <version>2.3.7.RELEASE</version>
</dependency>

当然了,必要的数据库驱动也是不可缺少的。

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.27</version>
</dependency>

再来配置一个数据源。

spring.datasource.url=jdbc:mysql://127.0.0.1:3306/test
spring.datasource.username=root
spring.datasource.password=12345678
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.type=com.zaxxer.hikari.HikariDataSource

这样必要的配置就搞定了,Spring Boot 会自动开启 spring-data-jdbc 的一些特性。

看下我们这里要操作的数据库表。

create table user
(
    id          bigint unsigned auto_increment comment '主键'
        primary key,
    username    varchar(20)  null comment '用户名',
    password    varchar(20)  null comment '密码',
    version     int unsigned null comment '版本号',
    create_by   varchar(20)  null comment '创建人',
    create_time datetime     null comment '创建时间',
    update_by   varchar(20)  null comment '修改人',
    update_time datetime     null comment '修改时间'
)

每个数据库表都映射到 Java 中的一个类,这里 User 类定义如下。

@Data
public class User {

    @Id
    private Long id;

    private String username;
    private String password;

    private Integer version;

    private String createBy;
    private Date createTime;
    private String updateBy;
    private Date updateTime;
}

Java 类遵循驼峰命名规范,数据库表遵循下划线命名规范,这样 Spring Data 会自动将两者映射。唯一要注意的是 @Id 注解是必须的,这个注解表示数据库表的主键。

Spring Data 中使用 Repository 操作 Domain,我们还需要定义一个 Repository。

public interface UserRepository extends PagingAndSortingRepository<User,Long> {
    
}

再来个测试用例。

@RunWith(SpringRunner.class)
@SpringBootTest(classes = Application.class)
public class SpringDataJdbcTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    public void testRepository() {
        User user = new User();
        user.setUsername("hkp");
        user.setPassword("123");
        User result = userRepository.save(user);
        System.out.println(result);
    }

}

执行后打印如下。

User(id=1, username=hkp, password=123, version=null, createBy=null, createTime=null, updateBy=null, updateTime=null)

数据成功插入到数据库,并返回了插入的数据。那么背后有何奥秘呢?这里简单进行总结。

Spring Boot 内置了对 Spring Data 的支持,引入 spring-boot-starter-data-jdbc、配置数据源之后,Spring Boot 进行一些自动化的配置,最重要的是会自动将 Repository 的子接口注册为 bean,方法执行时解析接口方法为具体的 SQL,使用 JdbcTemplate 操作数据库。

二、对象映射

一般情况,ORM 框架内部会实现 JDBC 操作数据库的通用流程,例如 Connection 的获取与关闭、Statement 的创建与关闭、参数设置、SQL 的执行等,而将一些不确定的部分交给用户控制,例如 SQL 定义、参数提供、结果映射。

spring-data-jdbc 将 ORM 框架做到了极致,用户可以只提供对象与数据库表的映射关系。不过 spring-data-jdbc 与 Hibernate 相比还可以灵活的提供 SQL 与参数,因此更灵活一些。

下面看下用户唯一必须要配置的映射关系。

表名与列名

类名与表名、类属性与表字段的映射关系,默认情况下使用驼峰命名到下划线命名转换关系。如果需要修改,可以使用对应的注解。

  • 表名:使用 @Table 注解自定义表名,例如 @Table("user")
  • 主键:使用 @Id 注解定义主键列,这个注解是必须的。
  • 表字段:使用 @Column 注解定义表字段,例如 @Column("username")。

支持的类型

数据库的字段类型与 Java 类的字段类型之间有一个默认的对应关系,spring-data-jdbc 默认支持的类型如下。

  • 基本类型及其包装类型。
  • 枚举类型,通过表中存入的名称转换为具体的枚举值。
  • StringDateLocalDateLocalDateTimeLocalTime
  • EntitySet<Entity>List<Entity>Map<Key,Entity>,其中 Entity 表示关联的表对应的类型。

由于 Repository 操作的是单个 Domain,spring-data-jdbc 仅支持 1-11-n 的映射关系。

1. 1-1 关系

1-1 的关系直接在 Domain 类中定义关联表对应的 Domain 类型的字段即可,不过关联表中需要有一个和主表名称相同的字段用来存储外键值。例如,user 表可能有一些扩展信息记录在 user_ext 表中。

create table user_ext
(
    id   bigint unsigned auto_increment comment '主键'
        primary key,
    name varchar(20) null comment '姓名',
    age  int         null comment '年龄',
    user bigint      null comment '外键'
)

user_ext 表对应的 Domain 类型如下,注意有一个 user 字段记录 user 表的 id 值。

@Data
public class UserExt {
    @Id
    private Long id;
    
    private Long user;
    
    private String name;
    private Integer age;
}

此时需要修改 User 类如下。

@Data
public class User {
    @Id
    private Long id;

    ... 省略其他字段

    private UserExt ext;
}

2. 1-n 关系

1-n 的关系可以在主表对应的 Domain 类上使用 SetList、或者 Map 类型的字段表示关联表。例如用户可能有多个收获地址,使用如下的表来表示。

create table address
(
    id            bigint unsigned auto_increment comment '主键'
        primary key,
    user_id       bigint unsigned null comment '用户ID',
    user_key      int unsigned    null comment '用户地址的索引,从 0 开始',
    province_name varchar(20)     null comment '省份名称',
    city_name     varchar(20)     null comment '城市名称',
    create_by     varchar(20)     null comment '创建人',
    create_time   datetime        null comment '创建时间',
    update_by     varchar(20)     null comment '修改人',
    update_time   datetime        null comment '更新时间'
)

对应的 Domain 类型如下。

@Data
public class Address {

    @Id
    private Long id;

    private Long userId;
    private Integer userKey;

    private String provinceName;
    private String cityName;

    private String createBy;
    private Date createTime;
    private String updateBy;
    private Date updateTime;
}

分别用 SetListMap 类型在 User 类中表示如下。

@Data
public class User {

    @Id
    private Long id;

    ... 省略其他字段

    @MappedCollection(idColumn = "user_id")
    private Set<Address> addressSet;

    @MappedCollection(idColumn = "user_id", keyColumn = "user_key")
    private List<Address> addressList;

    @MappedCollection(idColumn = "user_id", keyColumn = "user_key")
    private Map<Integer, Address> addressMap;
}

注意使用到了 @MappedCollection 注解,idColumn 表示外键,记录主表 ID,keyColumn 表示关联表在主表中的顺序,也就是 ListMap 中的索引位置,从 0 开始。

3. n-1n-m 关系

n-1n-m 的关系 Spring Data 不直接支持,需要转换为 1-1 表示。

乐观锁

spring-data-jdbc 支持乐观锁,在表示版本号的字段上加上 @Version 字段即可。

调用 save 方法的时候会根据版本号字段判断是否为新记录,如果是新记录执行 insert 操作,如果非新记录执行 update 操作并将版本号作为条件。

User 类型的 version 字段上加上 @Version 注解,修改代码如下。

@Data
@Accessors(chain = true)
public class User {
    @Id
    private Long id;
    
    ... 省略其他字段

    @Version
    private Integer version;
}

@Test
public void testRepository() {
    User user = new User();
    user.setId(1L).setUsername("hkp").setPassword("123").setVersion(1);

    userRepository.save(user);
}

将执行如下的 SQL。

UPDATE `USER` 
SET `USERNAME` = ?, `PASSWORD` = ?, `VERSION` = ?, `CREATE_BY` = ?, `CREATE_TIME` = ?, `UPDATE_BY` = ?, `UPDATE_TIME` = ? 
WHERE `USER`.`ID` = ? AND `USER`.`VERSION` = ?

新实体判断

save 方法兼具 insert 和 update 的功能,这取决于是否为新记录。

默认情况下先判断 id 的值,为 null 或 0 则为新记录,否则再判断 @Version 字段是否为 null 或 0 ,如果是则为新记录,否则为旧记录。

如果默认的规则不适用,可以让 Domain 类实现接口 Persistable 自定义判断逻辑。

@Data
public class User implements Persistable {

    @Id
    private Long id;

    @Override
    public boolean isNew() {
       return this.id != null;
    }
}

二、查询方法

Repository 中最重要的是查询方法,查询方法将映射为 SQL 。主要有两种方式来定义方法。

关键字

默认情况下通过方法名的特殊语法来映射 SQL,例如根据用户名查找用户可以如下定义。

public interface UserRepository extends PagingAndSortingRepository<User, Long> {

    User findByUsername(String username);
}

findby 作为关键字指定查找的主体和条件,如果使用 Idea 会有代码提示,也可以参考 官网 了解更多。

注解

方法名映射 SQL 需要学习特定的语法,如果觉得比较麻烦可以使用 @Query 注解指定 SQL,注解的优先级最高。

使用注解根据用户名查找用户的方法可以做如下修改。

public interface UserRepository extends PagingAndSortingRepository<User, Long> {

    @Query("select * from user where username = :username")
    User selectOne(String username);
}

默认情况 spring-data-jdbc 会在 META-INF/jdbc-named-queries.properties 文件中查找 key 为 ${domainClass}.${queryMethodName} 的 value 作为 SQL,以上面的 selectOne 方法为例,可以在文件中定义如下的内容指定 SQL。

com.zzuhkp.demo.entity.User.selectOne=select * from user where username = :username

此时可以把 @Query 注解中指定的 SQL 去掉。

public interface UserRepository extends PagingAndSortingRepository<User, Long> {

    @Query
    User selectOne(String username);
}

还可以使用 @Query.name 属性覆盖默认查找的 key。

public interface UserRepository extends PagingAndSortingRepository<User, Long> {

    @Query(name = "com.zzuhkp.demo.entity.User.selectOne")
    User selectOne(String username);
}

另外如果默认的映射关系不满足需求,还可以指定 @Query.rowMapperClass 或者 @Query.resultSetExtractorClass 自定义结果映射。例如。

public class UserRowMapper implements RowMapper<User> {
    @Override
    public User mapRow(ResultSet resultSet, int i) throws SQLException {
        User user=new User();
        user.setUsername(resultSet.getString("username"));
        user.setPassword(resultSet.getString("password"));
        return user;
    }
}

public interface UserRepository extends PagingAndSortingRepository<User, Long> {

    @Query(rowMapperClass = UserRowMapper.class)
    User selectOne(String username);
}

利用 RowMapperResultSetExtractor 可以做一些多表 join 操作,这两个接口是 spring-jdbc 中的概念,可以参考 《Spring JdbcTemplate 快速上手》 了解更多。

@Query 注解只能定义 select 类型的 SQL,如果想要进行 insertupdatedelete 操作,再加一个 @Modifying 注解就可以了,示例如下。

public interface UserRepository extends PagingAndSortingRepository<User, Long> {

    @Modifying
    @Query("delete from user where username = :username")
    int deleteOne(String username);
}

三、生命周期事件

Repository 操作 Domain 的时候会产生一些事件,具体如下。

事件类型 发布时间
BeforeDeleteEvent Domain 被删除前
AfterDeleteEvent Domain 被删除后
BeforeConvertEvent Domain 转换为 SQL 前,判断是否为新值后,可以在这里手动设置 ID
BeforeSaveEvent Domain 插入或更新前
AfterSaveEvent Domain 插入或更新后
AfterLoadEvent 从 ResultSet 中设置 Domain 所有属性后

这些事件可以被 Spring 的事件监听器监听,利用这个特性可以在记录保存到数据库前设置操作人和操作时间。

首先我们定义一个 BaseEntity 保存所有 Domain 共有的属性。

@Data
public class BaseEntity {

    @Id
    private Long id;

    private String createBy;

    private Date createTime;

    private String updateBy;

    private Date updateTime;
}

然后修改 User 类继承 BaseEntity

@Data
public class User extends BaseEntity {


    private String username;

    private String password;

    @Version
    private Integer version;
}

最后监听 BeforeSaveEvent 事件就可以了。

@Component
public class DomainEventListener {

    @EventListener
    public void setOperator(BeforeSaveEvent<BaseEntity> event) {
        BaseEntity entity = event.getEntity();
        if (entity.getId() == null) {
            entity.setCreateBy("hkp");
            entity.setCreateTime(new Date());
        }
        entity.setUpdateBy("hkp");
        entity.setUpdateTime(new Date());
    }

}

四、实体回调

除了生命周期中的事件,spring-data-jdbc 还支持 Domain 类实现一些回调接口,在 Repository 进行某些操作的时候也会回调这些接口方法,具体如下。

EntityCallback 发布时间
BeforeDeleteCallback Domain 被删除前
AfterDeleteCallback Domain 被删除后
BeforeConvertCallback Domain 转换为 SQL 前
BeforeSaveCallback Domain 保存前
AfterSaveCallback Domain 保存后
AfterLoadCallback ResultSet 设置 Domain 属性后

可以看到,回调与生命周期事件基本是类似的,同样可以利用回调来设置操作人。

public class BaseEntity implements BeforeSaveCallback<BaseEntity> {
    
    @Override
    public BaseEntity onBeforeSave(BaseEntity baseEntity, MutableAggregateChange<BaseEntity> mutableAggregateChange) {
        ... 省略设置操作人代码
        return baseEntity;
    }
}

五、日志、事务

spring-data-jdbc 底层依赖 JdbcTemplate,如果需要查看详细的日志,可以设置 JdbcTemplate 的日志级别。

spring-data-jdbc 支持 Spring 事务,直接在接口或方法上添加 @Transactional 注解即可。

六、审计

最后一个 spring-data-jdbc 的功能特性是审计,可以在 Domain 类上添加特定注解记录操作人。

@Data
public class BaseEntity {

    @Id
    private Long id;

    @CreatedBy
    private String createBy;

    @CreatedDate
    private Date createTime;

    @LastModifiedBy
    private String updateBy;

    @LastModifiedDate
    private Date updateTime;
}

对于日期来说采用当前时间即可,那操作人怎么办呢?需要注册一个 AuditorAware 类型的 bean 告诉框架。

@Component
public class CustomAuditorAware implements AuditorAware<String> {
    @Override
    public Optional<String> getCurrentAuditor() {
        return Optional.of("test");
    }
}

另一个可选的方式是 Domain 类实现 Auditable 接口,这个接口提供了一些设置和获取操作人、操作时间的方法,这里就不再演示了。

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐