WebFlux的探索与实战 - r2dbc的多表查询
在一个有数据库的项目中,条件查询与多表查询总是同幽灵般如影随形。
好久不见朋友们。 本篇文章会以我的 个人经验 来介绍下如何在 Spring WebFlux 中使用 Spring Data R2DBC 进行多表查询。
这次我会以一个自己写的项目作为基础来为各位介绍。如果你想了解如何创建一个 Spring WebFlux 项目,以及如何定义实体类、Repository类等,可以看 上一篇文章,这里便不会重点介绍了。
前排免责:
对于 'r2dbc的多表查询' 这个主题,我不能保证已完全参透或已经给出非常全面的应用场景,因此本文仅供参考。如果你有更好的使用案例、解决方案,欢迎在评论区留言交流讨论😘。
既然是以一个我写的某个项目为基础进行介绍,那么我需要先交代一下这个项目的一些信息,比如涉及的表、实体类和简单的功能介绍。
可能会为了便于编撰文章而简化部分细节
这是一个简单的用户认证服务,用来登录、注册、签发token等。
数据库使用的 MySQL。
它的表包括了 账户 - 角色 - 权限 - 资源
4张表,以及连接它们的3张中间表,总共7张表。
表结构
这里是通过工具生成的DDL:
create table fa_account
(
id int auto_increment
primary key,
username varchar(200) not null,
zone_id varchar(255) not null comment '时区ID值',
email varchar(254) null,
password varchar(254) null,
status tinyint default 0 not null,
create_time datetime not null,
last_modified_time datetime not null,
version int default 0 not null,
constraint fa_account_email_uindex
unique (email)
)
comment '账户表';
create table fa_permission
(
id int auto_increment
primary key,
name varchar(100) not null,
category varchar(100) null,
enable tinyint default 1 not null,
status tinyint default 0 not null,
create_time datetime not null,
last_modified_time datetime not null,
version int default 0 not null
)
comment '权限表';
create table fa_resource
(
id int not null
primary key,
pattern varchar(500) not null,
type tinyint not null,
remark varchar(500) null,
enable tinyint default 1 not null,
status tinyint default 0 not null,
category varchar(100) null,
create_time datetime not null,
last_modified_time datetime not null,
version int default 0 not null,
constraint fa_resource_pattern_uindex
unique (pattern)
)
comment '资源表';
create table fa_permission_resource
(
permission_id int not null,
resource_id int not null,
remark varchar(500) null,
enable tinyint default 1 not null,
method int not null,
create_time datetime not null,
last_modified_time datetime not null,
version int default 0 not null,
primary key (permission_id, resource_id),
constraint fa_permission_resource_fa_permission_id_fk
foreign key (permission_id) references fa_permission (id)
on update cascade on delete cascade,
constraint fa_permission_resource_fa_resource_id_fk
foreign key (resource_id) references fa_resource (id)
on update cascade on delete cascade
)
comment '权限-资源关联表';
create table fa_role
(
id int auto_increment
primary key,
name varchar(100) not null,
is_default tinyint default 0 not null,
is_init tinyint default 0 not null,
category varchar(100) null,
enable tinyint default 1 not null,
status tinyint default 0 not null,
color int null,
create_time datetime not null,
last_modified_time datetime not null,
version int default 0 not null
)
comment '角色表';
create table fa_account_role
(
account_id int not null,
role_id int not null,
enable tinyint default 0 not null,
create_time datetime not null,
last_modified_time datetime not null,
version int default 0 not null,
primary key (account_id, role_id),
constraint fa_account_role_fa_account_id_fk
foreign key (account_id) references fa_account (id)
on update cascade on delete cascade,
constraint fa_account_role_fa_role_id_fk
foreign key (role_id) references fa_role (id)
on update cascade on delete cascade
)
comment '账户-权限表';
create table fa_role_permission
(
role_id int not null,
permission_id int not null,
enable tinyint default 0 not null,
create_time datetime not null,
last_modified_time datetime not null,
version int not null,
primary key (role_id, permission_id),
constraint fa_role_permission_fa_permission_id_fk
foreign key (permission_id) references fa_permission (id)
on update cascade on delete cascade,
constraint fa_role_permission_fa_role_id_fk
foreign key (role_id) references fa_role (id)
on update cascade on delete cascade
)
comment '角色-权限关联表';
你可以观察到一些特点:
- 每个表都会以
fa_
开头。这是它们的一个统一的表前缀。 - 每个表都包括了
create_time
、last_modified_time
、version
字段。它们通过 Spring Data R2DBC: Auditing 来实现一些审计(自动填充、更新之类的)能力。在 Spring Data JPA 里也有它们的身影。 - 每个表都有
enable
字段。这些表都被设计为可以进行"开关"的, 也包括那些中间表。
实体类
这些表都各自需要一个实体类,也包括那些中间表。
它们的实体类大概是如下的样子(会经过部分简化,并使用了 Lombok
):
// BaseAuditingEntity.java
/**
* 公共抽象类,但是没有 ID
*/
@Getter
@Setter
@ToString
public class BaseAuditingEntity {
@CreatedDate
private Instant createTime;
@LastModifiedDate
private Instant lastModifiedTime;
@Version
@JsonIgnore
private Integer version;
}
// BaseEntity.java
/**
* 公共抽象类。
*/
@Getter
@Setter
@ToString
public class BaseAuditingEntity {
public static final String TABLE_NAME_PREFIX = "fa_";
@Id
private Long id;
}
// Account.java
/**
* 账户信息
*/
@Table(Account.TABLE_NAME)
@Getter
@Setter
@ToString
public class Account extends BaseEntity {
public static final String BASE_TABLE_NAME = "account";
public static final String TABLE_NAME = BaseEntity.TABLE_NAME_PREFIX + BASE_TABLE_NAME;
private String username;
private String email;
@JsonIgnore
private String password;
private Integer status;
private ZoneId zoneId;
}
// Role.java
/**
* 角色信息
*/
@Table(Role.TABLE_NAME)
@Getter
@Setter
@ToString
public class Role extends BaseEntity {
public static final String BASE_TABLE_NAME = "role";
public static final String TABLE_NAME = BaseEntity.TABLE_NAME_PREFIX + BASE_TABLE_NAME;
private String name;
private String category;
@Column("is_default")
private Boolean defaultValue; // = false,
@Column("is_init")
private Boolean init; // = false,
private Boolean enable; // = true,
private Integer status; // = 0,
private Integer color;
}
// AccountRole.java
/**
* account - role 中间表
*/
@Table(AccountRole.TABLE_NAME)
@Getter
@Setter
@ToString
public class AccountRole extends BaseAuditingEntity {
public static final String BASE_TABLE_NAME = Account.BASE_TABLE_NAME + "_" + Role.BASE_TABLE_NAME;
public static final String TABLE_NAME = BaseEntity.TABLE_NAME_PREFIX + BASE_TABLE_NAME;
private Long accountId;
private Long roleId;
private Boolean enable;
}
// Permission.java
/**
* 权限信息
*/
@Table(Permission.TABLE_NAME)
@Getter
@Setter
@ToString
public class Permission extends BaseEntity {
public static final String BASE_TABLE_NAME = "permission";
public static final String TABLE_NAME = BaseEntity.TABLE_NAME_PREFIX + BASE_TABLE_NAME;
private String name;
private Boolean enable;
private String category;
private Integer status;
}
// RolePermission.java
/**
* role - permission 中间表
*/
@Table(RolePermission.TABLE_NAME)
@Getter
@Setter
@ToString
public class RolePermission extends BaseAuditingEntity {
public static final String BASE_TABLE_NAME = Role.BASE_TABLE_NAME + "_" + Permission.BASE_TABLE_NAME;
public static final String TABLE_NAME = BaseEntity.TABLE_NAME_PREFIX + BASE_TABLE_NAME;
private Long roleId;
private Long permissionId;
private Boolean enable;
}
// Resource.java
/**
* 资源信息
*/
@Table(Resource.TABLE_NAME)
@Getter
@Setter
@ToString
public class Resource extends BaseEntity {
public static final String BASE_TABLE_NAME = "resource";
public static final String TABLE_NAME = BaseEntity.TABLE_NAME_PREFIX + BASE_TABLE_NAME;
private String pattern;
private String remark;
private Integer type;
private Boolean enable;
private Integer status;
private String category;
}
// PermissionResource.java
/**
* permission - resource 中间表
*/
@Table(PermissionResource.TABLE_NAME)
@Getter
@Setter
@ToString
public class PermissionResource extends BaseAuditingEntity {
public static final String BASE_TABLE_NAME = Permission.BASE_TABLE_NAME + "_" + Resource.BASE_TABLE_NAME;
public static final String TABLE_NAME = BaseEntity.TABLE_NAME_PREFIX + BASE_TABLE_NAME;
private Long permissionId;
private Long resourceId;
private String remark;
private Boolean enable;
private Integer method;
}
如果你熟悉 JPA,那么你可能发现了:在 R2DBC 中,并没有什么 @ManyToOne
、@ManyToMany
之类的关系注解给你用。在实体类中,你能做的便是定义与数据库基本一致的字段,然后选择性的添加一些注解(例如 @Id
, @Version
),就这么多。
换言之,首先你要明白:R2DBC 不支持关联查询。不过有关这个问题我们稍后再说。
场景重现
接下来,让我们先根据几个查询场景来看看我是如何实现的。
1. 分步查询: 某账户的全量信息
上文我们提到,表结构中共有四级:账户 - 角色 - 权限 - 资源
,它们都是互相多对多的,因此一个 全量 的账户信息,可以大概表示为如下形式(扁平化后):
public record AccountFullView(
Account account,
List<Role> roles,
List<Permission> permissions,
List<Resource> resources
) {
}
那么接下来,准备一个 Service
, 来实现根据某个 account_id
来查询对应账户的全量信息。
首先,简单交代一下思路。由于 R2DBC 本身并不支持直接进行关联查询,那么我们只能退而求其次, 将这些数据分步查询。也就是说,我们:
- 先查询账户(
Account
)信息 - 根据账户信息,查询所有角色(
Role
)信息 - 根据这些角色信息(
Set<role_id>
),查询所有权限(Permission
)信息 - 根据这些权限信息(
Set<permission_id>
),查询所有资源(Resource
)信息
在这其中:
- 假设不会有大集合数据(比如一个用户关联的角色最多100个)
- 由于只是一种单纯的查询,不考虑严格的数据一致性,因此不加事务
那么让我们来准备好这个 Service
:
@Service
@RequiredArgsConstructor
public class AccountService {
private final AccountRepository accountRepository;
private final RoleRepository roleRepository;
private final PermissionRepository permissionRepository;
private final ResourceRepository resourceRepository;
public Mono<AccountFullView> full(Long accountId) {
// TODO 实现...
return null;
}
}
然后接下来在 full
中实现逻辑。
回顾上述的步骤,先进行最简单的一步:查询用户信息:
@Service
@RequiredArgsConstructor
public class AccountService {
private final AccountRepository accountRepository;
private final RoleRepository roleRepository;
private final PermissionRepository permissionRepository;
private final ResourceRepository resourceRepository;
private static class AccountFullViewContext {
Account account = null;
List<Role> roles = Collections.emptyList();
Set<Long> roleIds = Collections.emptySet();
List<Permission> permissions = Collections.emptyList();
Set<Long> permissionIds = Collections.emptySet();
List<Resource> resources = Collections.emptyList();
AccountFullView toView() {
return new AccountFullView(account, roles, permissions, resources);
}
}
public Mono<AccountFullView> full(Long accountId) {
// 准备一个 context
final var context = new AccountFullViewContext();
final var accountMono = accountRepository.findById(accountId)
.switchIfEmpty(Mono.error(new NoSuchElementException("Account(id=" + accountId + ")")));
// TODO 实现...
return null;
}
}
上面代码中的 AccountFullViewContext
是一个供 full
内的数据流流转使用的一个 "上下文" 类型,
它会随着流程的一步步推进而逐 步完善其内部的各属性,并在最终通过 toView
将结果转化为 AccountFullView
。
当然,你也可以选择不使用这种上下文的形式而是拆分出各个阶段的结果或者其他更好的方式,如何实现都是可以的。
不得不说,在 Java 中用响应式编程,一个简单的逻辑就可以把你的代码塞得满满当当的... 照着这股劲,将剩下的步骤继续完成!
...
...
是的,接下来便是 R2DBC 的地狱了。 首先回顾一下,我们说过,R2DBC 不支持关联查询,同时在一开始我们提到过,这几个表之间的关系都是多对多的,换言之,想查询"用户的所有角色",就需要关联它们的中间表才能做到。
为了贯彻这一小节中我们说的 "分步" 查询,我们接下来要做的是:
- 从中间表,查询对应
account_id
的所有role_id
- 根据这些
role_id
,再去查询所有角色
那么,我们继续!
要完成这个任务,我们首先得需要一个 AccountRoleRepository
, 也就是查询中间表实体 AccountRole
的仓库。我们来创建一个:
public interface AccountRoleRepository extends Repository<AccountRole, Long> {
/**
* 根据 account id 查询 AccountRole集
*/
Flux<AccountRole> findAllByAccountId(Long accountId);
}
也许你注意到了,对于一个中间表实体的持久化仓库,我直接使用了 Repository
而不是 R2dbcRepository
。这是为什么呢? R2dbcRepository
中提供的那些方法都是基于一个主键ID的,而作为一个中间表,它并没有一个具体的主键字段,所以我们也就不需要那些方法了。
如果你熟悉 JPA, 那么你可能会想要去尝试使用
@Embedded
和@Id
来实现一个组合式的复合主键类型。而在你准备尝试之前,也许你可以先去看看 spring-projects/spring-data-relational#574,来提前了解它为什么还不支持,以及大家围绕这个问题展开的讨论。
@Service
@RequiredArgsConstructor
public class AccountService {
private final AccountRepository accountRepository;
private final AccountRoleRepository accountRoleRepository;
private final RoleRepository roleRepository;
private final PermissionRepository permissionRepository;
private final ResourceRepository resourceRepository;
private static class AccountFullViewContext {
...
}
public Mono<AccountFullView> full(Long accountId) {
// 准备一个 context
final var context = new AccountFullViewContext();
final var accountMono = accountRepository.findById(accountId)
.switchIfEmpty(Mono.error(new NoSuchElementException("Account(id=" + accountId + ")")));
accountMono.flatMap(account -> {
// 初始化 account
context.account = account;
// 查询得到 roles
var contextMono = accountRoles(context, account);
// TODO permissions
return null;
});
// TODO 实现...
return null;
}
private Mono<AccountFullViewContext> accountRoles(AccountFullViewContext context, Account account) {
return accountRoleRepository.findAllByAccountId(account.getId())
.map(AccountRole::getRoleId)
// 将 AccountRole.roleId 收集为 Set.
.collect(Collectors.toSet())
.flatMap(roleIdSet -> {
// 查询所有的角色
return roleRepository.findAllById(roleIdSet)
.collectList()
.map(roles -> {
// 初始化 context 中的属性
context.roles = roles;
context.roleIds = roleIdSet;
return context;
});
});
}
}
又是一小步,这样我们便完成了对 Role
的查询。接下来如法炮制,完成剩下的、对 Permission
和 Resource
的查询吧!
最终完整的 Service
内实现大概是这个样子的:
@Service
@RequiredArgsConstructor
public class AccountService {
private final AccountRepository accountRepository;
private final AccountRoleRepository accountRoleRepository;
private final RoleRepository roleRepository;
private final RolePermissionRepository rolePermissionRepository;
private final PermissionRepository permissionRepository;
private final PermissionResourceRepository permissionResourceRepository;
private final ResourceRepository resourceRepository;
private static class AccountFullViewContext {
Account account = null;
List<Role> roles = Collections.emptyList();
Set<Long> roleIds = Collections.emptySet();
List<Permission> permissions = Collections.emptyList();
Set<Long> permissionIds = Collections.emptySet();
List<Resource> resources = Collections.emptyList();
AccountFullView toView() {
return new AccountFullView(account, roles, permissions, resources);
}
}
/**
* 查询并获取用户的全量'扁平化'信息.
*/
public Mono<AccountFullView> full(Long accountId) {
// 准备一个 context
final var context = new AccountFullViewContext();
final var accountMono = accountRepository.findById(accountId)
.switchIfEmpty(Mono.error(new NoSuchElementException("Account(id=" + accountId + ")")));
return accountMono.flatMap(account -> {
// 初始化 account
context.account = account;
// 查询各结果并合并
return accountRoles(context, account)
.flatMap(this::rolePermissions)
.flatMap(this::permissionResources)
.map(AccountFullViewContext::toView);
});
}
private Mono<AccountFullViewContext> accountRoles(AccountFullViewContext context, Account account) { // 实际上 account 也能省略
return accountRoleRepository.findAllByAccountId(account.getId())
.map(AccountRole::getRoleId)
// 将 AccountRole.roleId 收集为 Set.
.collect(Collectors.toSet())
.flatMap(roleIdSet -> {
// 查询所有的角色
return roleRepository.findAllById(roleIdSet)
.collectList()
.map(roles -> {
// 初始化 context 中的属性
context.roles = roles;
context.roleIds = roleIdSet;
return context;
});
});
}
private Mono<AccountFullViewContext> rolePermissions(AccountFullViewContext context) {
return rolePermissionRepository.findAllByRoleIdIn(context.roleIds)
.map(RolePermission::getPermissionId)
.collect(Collectors.toSet())
.flatMap(permissionIdSet -> {
// 查询所有的权限
return permissionRepository.findAllById(permissionIdSet)
.collectList()
.map(permissions -> {
context.permissionIds = permissionIdSet;
context.permissions = permissions;
return context;
});
});
}
private Mono<AccountFullViewContext> permissionResources(AccountFullViewContext context) {
var resourceIds = permissionResourceRepository.findAllByPermissionIdIn(context.permissionIds)
.map(PermissionResource::getResourceId);
// 查询所有资源
return resourceRepository.findAllById(resourceIds)
.collectList()
.map(resources -> {
context.resources = resources;
return context;
});
}
}
2. 有条件的连表查询: 某账户所有符合条件的'资源'
接下来是另一个课题。之前我们提到过,大部分实体表和关联表都有一个 enable
字段代表对应的信息是否"启用",然后如果你仔细观察便会发现,在 PermissionResource
(表 fa_permission_resource
) 中有一个字段:method
。
既然你能够坚持阅读到这里,那么为了表示感谢,我将会先来解释一下这个 method
和 Resource
的关系。
根据设计,这个系统中的'资源',也就是 Resource
是用于在 网关 中进行权限校验的 "路由" 信息。
比如:/hello/world/**
, /auth/*/login
之类的。
同时,一个资源可能会被分配给不同的权限(Permission
), 这时候便会通过中间表 PermissionResource
来控制这个权限是针对这个资源的那些 访问方式。
而这个访问方式便是 Rest API 中的 HTTP method, 它们以比特位的形式记录在 method
中。比如 权限1 允许以 GET
的形式访问 资源1,那么它的 method
便是 0x0001
,也就是 1
。
好了,接下来,我们需要这样一个接口:根据 account_id
查询它对应的全部资源,且要求:
- 这些资源以及关联链路上的其他所有(比如
Role
、Permission
或某个中间表) 的enable
都要为true
。 - 如果入参
method
不为null
,则根据位运算计算访问方式与这个参数 完全相同 的资源。也就是使用method & param.method == method
这种方式进行计算。
那么这个课题,我将会使用一个 整个SQL 来完成。但是我需要提醒你,结果可能并非如你期望的那样。
首先,准备 Service
和方法:
@Service
@RequiredArgsConstructor
public class AccountService {
private final R2dbcEntityTemplate entityTemplate;
Flux<Resource> accountResources(Long accountId, @Nullable Integer method) {
// TODO
return null;
}
}
当你看到它的第一眼,你会觉得它很简短,而后当你看到 R2dbcEntityTemplate
,我想你也许已经猜到了事情之后的发展方向。
是的,正如前述,使用 整个SQL 的方式,便是一种大家耳熟能详的方式:拼接SQL字符串。而且这里不仅需要拼接字符串,我们还可能要遇到:
- 手动绑定 (
bind
) SQL变 量 - 手动映射结果 (
Row
,RowMetadata
)
不过也好在正是因为有 R2dbcEntityTemplate
的存在,我们可以 相对 轻松的完成这些工作。
废话不多说,我们来看下一步的代码:
@Service
@RequiredArgsConstructor
public class AccountService {
private final R2dbcEntityTemplate entityTemplate;
Flux<Resource> accountResources(Long accountId, @Nullable Integer method) {
var builder = new StringBuilder();
builder.append("SELECT DISTINCT r.* FROM " + Resource.TABLE_NAME + " r \n" +
"LEFT JOIN " + PermissionResource.TABLE_NAME + " pr ON r.id = pr.resource_id AND pr.enable\n" +
"LEFT JOIN " + RolePermission.TABLE_NAME + " frp ON pr.permission_id = frp.permission_id AND frp.enable\n" +
"LEFT JOIN " + Role.TABLE_NAME + " role ON.role_id = role.id AND role.enable\n" +
"LEFT JOIN " + AccountRole.TABLE_NAME + " accr ON role.id = accr.role_id AND accr.enable\n" +
"WHERE accr.account_id = :accountId AND r.enable");
if (method != null) {
builder.append("\n AND pr.method & :method = :method");
}
// TODO 绑定参数
// TODO 查询结果并返回
return null;
}
}
我们使用一大坨 LEFT JOIN
进行这一串表关系的关联,并通过它们各自的 AND xxx.enable
完成对 enable
的筛选,
并在最后的 WHERE
处通过 accr.account_id = :accountId
来指定目标结果对应的账户ID。
而后,当 method
不为 null
时,直接使用 SQL 的位运算来计算它的条件,也同时为 SQL 中添加了一个 :method
参数。
也许到这时候,这段代码可以为你解释为什么在一开始我定义实体类的时候,要为那些实体类添加它们各自的 表名常量 TABLE_NAME
了。
接着,我们来为这段 SQL 绑定参数:
Flux<Resource> accountResources(Long accountId, @Nullable Integer method) {
var builder = new StringBuilder();
builder.append(...);
if (method != null) {
builder.append(...);
}
var sql = builder.toString();
// 绑定参数
var spec = entityTemplate.getDatabaseClient().sql(sql)
.bind("accountId", accountId);
if (method != null) {
spec = spec.bind("method", method);
}
// TODO 查询结果并返回
return null;
}
使用 R2dbcEntityTemplate
获取到一个 databaseClient 并使用 sql
创建一个执行器后,便可以轻松的为它绑定参数了。
参数绑定完成后,就是执行了,让我们继续:
Flux<Resource> accountResources(Long accountId, @Nullable Integer method) {
var builder = new StringBuilder();
builder.append(...);
if (method != null) {
builder.append(...);
}
var sql = builder.toString();
// 绑定参数
var spec = entityTemplate.getDatabaseClient().sql(sql)
.bind("accountId", accountId);
if (method != null) {
spec = spec.bind("method", method);
}
// 查询结果并返回
return spec
.map((row, meta) -> entityTemplate.getConverter().read(Resource.class, row, meta))
.all();
}
我们通过 map
来指定对查询结果的行数据(Row
, RowMetadata
)进行处理,好在 R2dbcEntityTemplate
为我们提供了转化器,它可以快速的将行数据解析为某个指定的类型, 而后便是最终得到数据的方式,all
也就是获取所有的结果。
思考总结
以上便是我遇到的两个使用 R2DBC 进行较为复杂的关系条件查询的最终应用方案了。
其实这两个场景中使用的这两种方法 (分步查询、拼接SQL) 也都是可以互相替代的,但是正如你所见,它们都并不是那么的"友好"。
这时你可能一些疑问,比如是否有能支持关系实体/关系查询的第三方库、为什么 R2DBC 官方不支持关系实体、以及有什么更好增加使用 R2DBC 的体验的方式等等。
这些问题我也思考过,也有问题至今仍在摸索和思考。