Spring 加强版 ORM 框架 spring-data-jdbc 入门与实践

     阅读:24

前言

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 的时候会产生一些事件,具体如下。

事件类型发布时间
BeforeDeleteEventDomain 被删除前
AfterDeleteEventDomain 被删除后
BeforeConvertEventDomain 转换为 SQL 前,判断是否为新值后,可以在这里手动设置 ID
BeforeSaveEventDomain 插入或更新前
AfterSaveEventDomain 插入或更新后
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发布时间
BeforeDeleteCallbackDomain 被删除前
AfterDeleteCallbackDomain 被删除后
BeforeConvertCallbackDomain 转换为 SQL 前
BeforeSaveCallbackDomain 保存前
AfterSaveCallbackDomain 保存后
AfterLoadCallbackResultSet 设置 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 接口,这个接口提供了一些设置和获取操作人、操作时间的方法,这里就不再演示了。