前言
在业务开发工作中,我们经常遇到这样的需求:查询数据A,同时查询A关联的数据B。比如,查询一个员工的数据详情,同时要返回员工所属的部门名称。往往员工和部门数据是分别在两张数据表中存储的,这就导致每次查询员工数据都要在代码里额外再查一次部门表。
初步实现
使用set 方法
一般我们会在查询员工的数据后,取出员工的部门ID集合来查询部门数据,再set到员工的字段上。示例如下:
java
public class EmployeeQueryService {
private final EmployeeQueryMapper employeeQueryMapper;
private final DepartmentQueryMapper departmentQueryMapper;
public EmployeeDTO getEmployee(Long id) {
EmployeeDTO employee = employeeQueryMapper.findById(id);
if (Objects.nonNull(employee)) {
DepartmentDTO department = departmentQueryMapper.findById(employee.getDepartmentId());
employee.setDepartmentName(Optional.ofNullable(department)
.map(DepartmentDTO::getName)
.orElse(null));
}
return employee;
}
}
这种做法的问题在于:
- 随着业务的发展,会有多个地方要查询员工数据,每个查询的地方都需要手动 set 部门信息;
- 当需要返回员工的其他信息时,比如员工所在地区,按照这种实现方式,就得改动 sevice 类来查询地区数据,不符合单一职责原则;
- 最关键的是,service 类的方法应该体现业务逻辑,这些和业务逻辑无关的"填充数据"的操作,不应该存在于 service 的业务方法中,更不应该因此去改动 service 类的方法。
使用单独的 filler类
在前面方法的基础上,我们把"填充数据"这个行为,封装到专门的类中,实现"填充数据功能的内聚",比如对同一个 DTO 的所有填充操作,都由这个类来实现,我们称这个类为 xxxDTOQueryFiller,定义如下所示:
java
@Component
@RequiredArgsConstructor
public class EmployeeDTOQueryFiller {
private final DepartmentQueryMapper departmentQueryMapper;
public void fillEmployeeDTO(EmployeeDTO employee) {
if (Objects.nonNull(employee)) {
DepartmentDTO department = departmentQueryMapper.findById(employee.getDepartmentId());
employee.setDepartmentName(Optional.ofNullable(department)
.map(DepartmentDTO::getName)
.orElse(null));
}
}
public void fillEmployeeDTOs(Collection<EmployeeDTO> employees) {
if (CollectionUtils.isEmpty(employees)) {
return;
}
Set<String> departmentIds = employees.stream()
.map(EmployeeDTO::getDepartmentId)
.collect(Collectors.toSet());
List<DepartmentDTO> departmentDTOS = departmentQueryMapper.findByIds(departmentIds);
Map<String, DepartmentDTO> departmentMap = departmentDTOS.stream()
.collect(Collectors.toMap(DepartmentDTO::getId, Function.identity()));
employees.forEach(employee -> employee.setDepartmentName(
Optional.ofNullable(departmentMap.get(employee.getDepartmentId()))
.map(DepartmentDTO::getName)
.orElse(null)
));
}
}
有了 Filler 之后,EmployeeQueryService 可以使用 Filler 对象的方法来填充 EmployeeDTO 所需的额外数据,比如下面的示例中,除了单个DTO查询外,还有一个分页查询,都可以使用 EmployeeDTOQueryFiller 来填充数据:
java
@Service
@RequiredArgsConstructor
public class EmployeeQueryService {
private final EmployeeQueryMapper employeeQueryMapper;
private final EmployeeDTOQueryFiller employeeDTOQueryFiller;
public EmployeeDTO getEmployee(Long id) {
EmployeeDTO employee = employeeQueryMapper.findById(id);
employeeDTOQueryFiller.fillEmployeeDTO(employee);
return employee;
}
public PageDTO<EmployeeDTO> listEmployeeDTOs(Integer current, Integer limit) {
long total = employeeQueryMapper.count();
List<EmployeeDTO> list = employeeQueryMapper.list(current, limit);
employeeDTOQueryFiller.fillEmployeeDTOs(list);
return PageDTO.of(total, list);
}
}
和第一种方法相比,该方法中 service 类更加干净:
1、servic 方法只需要做两件事:查询数据、调用 filler 填充数据
2、别人在阅读代码时,不用关心填充逻辑的代码,可以把注意力集中在 业务的查询逻辑上
3、要修改填充数据的逻辑时,只需要去修改对应的 Filler 中的代码,不用改动 service 的代码
然而,该方法依然有如下问题:
1、我们要对每一种DTO(比如详情DTO、列表DTO等)创建一个 Filler 类,后面维护这些DTO成了一个巨大且复杂的工作,复杂性被转移到对 Filler 的维护上。
2、如果多个业务场景下要对不同DTO填充同一个字段,都要重复写填充数据的逻辑,比如另一个xxxDTO 也要填充部门名称,我们也要在对应的 filler 中重复部门填充的代码。
分析和优化
上面这个问题,实际上是N+1查询问题在service 层的表现:我们要查询数据A,同时要查询出A关联的数据B。如果是在基础设施层实现,可以通过联表的方式来查询,但是线上我们一般不建议联表,所以最好在service层使用代码的方式来解决。
注解
从上面的示例可以看出,数据A要关联数据B,必然是通过数据B的某个标识来关联(比如唯一主键),所以才能使用这个标识来查询数据B;同理,如果A还关联了C,那么也一定是包含了C的标识。
这里涉及到三个额外信息:
- 关联的目标对象类型,比如部门
- 包含的目标对象的标识,比如部门ID
- 查询出目标对象后,要设置到哪个字段上,比如部门名称
因此,在查询数据A时,我们需要用某种方式把这三个信息添加到数据A的源代码中。
这种方式是什么?
《Java编程思想》有讲到,"注解为我们在代码中添加信息提供了一种形式化的方式,使得我们可以在稍后某个时刻非常方便地使用这些数据"。
我们可以使用注解在数据A的源代码中添加需要包含的三个额外信息,我们定义下面的枚举和注解:
1、枚举:DataType
表示关联的目标对象的类型,比如 DEPARTMENT_NAME 表示要关联的是部门名称。
java
public enum DataType {
// 部门名称
DEPARTMENT_NAME;
}
2、注解:DataFillSource
添加在字段上,表示可以通过该字段查询出要目标类型的数据。
java
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface DataFillSource {
/**
* 填充类型
*/
DataType value();
}
3、注解:DataFillTarget
添加在字段上,表示查询出来的值要设置到该字段上。
java
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface DataFillTarget {
/**
* 填充类型
*/
DataType value();
}
在一个类中,DataFillSource 和 DataFillTarget 一定是成对出现的。
4、注解:DataFillNested
如果 xxxDTO 包含的其他对象,是一个聚合的对象,不是平铺的字段,就需要使用这个注解来标识。
java
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface DataFillNested {
}
使用上面的注解和枚举后,EmployeeDTO 的源码如下:
java
@Data
public class EmployeeDTO {
private Long id;
private String name;
@DataFillSource(DataType.DEPARTMENT_NAME)
private String departmentId;
@DataFillTarget(DataType.DEPARTMENT_NAME)
private String departmentName;
// 当要返回的部门信息,是以 DepartmentDTO 的方式展示时,需要使用 DataFillNested 注解
@DataFillNested
public DepartmentDTO department;
}
@Data
public class DepartmentDTO {
@DataFillSource(DataType.DEPARTMENT_NAME)
private String id;
@DataFillTarget(DataType.DEPARTMENT_NAME)
private String name;
}
上面的代码示例表示:
- EmployeeDTO 中的字段 departmentId 可以被用来查询部门名称,查询出来的结果要填充到字段 departmentName 上。
- EmployeeDTO 中的字段 department 是一个需要聚合填充的字段。
- DepartmentDTO 中的字段 id 可以被用来查询部门名称,查询出来的结果要填充到字段 name 上。
切面
前面的枚举和注解只是标注了EmployeeDTO中需要填充的内容,还需要用某种方法把内容填充进返回的对象中。填充方法有两种:
1、手动调用某个对象的方法,手动填充使用注解标记的对象
这样做又回到了前面个的两种方法,填充代码分散在项目中,增加了后期维护成本。
2、使用切面自动填充数据
在返回xxxDTO的 service 方法上添加一个注解,通过切面自动解析返回的对象以及其中的注解,实现自动填充。
很显然,第2种方法比滴1种方法更加简单,只需要在方法上添加注解即可。
1、定义注解:FillData
该注解不包含任何字段,只是用来表示使用该注解的方法需要填充数据。
java
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface FillData {
}
2、定义切面:DataFillAspect
执行数据填充操作。
java
@Component
@Aspect
@Slf4j
@Order(DataFillAspect.ORDER)
@RequiredArgsConstructor
public class DataFillAspect {
public static final int ORDER = 0;
private final DataFillManager manager;
@AfterReturning(value = "@annotation(com.example.query.annotation.annotation.FillData)", argNames = "data", returning = "data")
public Object afterReturning(Object data) {
if (data == null) {
return null;
}
// 创建一个 context,保存需要填充的上下文
DataFillManager.DataFillContext context = manager.createContext();
// 判断要填充的对象数据类型
if (data instanceof RecordsContainer) {
Collection<?> records = ((RecordsContainer<?>) data).getRecords();
context.addObject(records);
} else {
context.addObject(data);
}
// 执行填充
context.doFill();
return data;
}
}
切面使用到了两个类:
1、RecordsContainer:
RecordsContainer
是分页查询返回的PageDTO<EmployeeDTO>
的父级接口,通过判断是否是 RecordsContainer
的子类,可以填充数据到分页对象的列表中。两者的定义如下:
java
public interface RecordsContainer<T> {
Collection<T> getRecords();
}
@Data
public class PageDTO<T> implements RecordsContainer<T>{
private long total;
private Collection<T> records;
public static <T> PageDTO<T> of(long total, Collection<T> records) {
PageDTO<T> result = new PageDTO<>();
result.setTotal(total);
result.setRecords(records);
return result;
}
@JsonIgnore
public boolean isEmpty() {
return CollectionUtils.isEmpty(records);
}
}
2、DataFillManager:填充数据对象的管理器
DataType 每个枚举值都要查询不同的额外数据,比如 部门名称、所在地区等,最好按照策略模式给每个枚举值配一个对应的具体策略对象(假设叫 DataFillQuery),DataFillManager 就是所有策略对象的工厂,管理所有的策略对象。
DataFillManager 和 DataFillQuery
前面介绍了 填充数据的入口------Aspect,下面介绍管理策略对象的Manager、以及策略对象 Query。
DataFillManager
DataFillManager 作为策略模式的工厂,理应只提供两个功能:注册策略对象、返回策略对象。但是,"填充数据"场景的有着自己的特殊性:
- service 方法返回的DTO对象属于不同的类型,需要基类 Object 统一表示;
- 每个对象中有多个字段使用了注解,甚至有嵌套对象,需要先解析返回的 Object 对象,取出添加了 DataFillSource 和 DataFillTarget 注解的字段;
- 在查询出所需的目标数据后,因为返回的DTO类型不确定,不能直接通过 set 的方法设置字段值,需要使用放射的方式,把目标数据设置到 Object 对象的字段上。
为了解决上面的问题,定义两个内部类 DataFillContext 和 DataFillPair,分别用来持有 DTO 对象的上下文、DTO中需要填充的"字段对"。
因此,DataFillManager除了"注册策略对象、返回策略对象"外,还有一个功能就是"创建context":
java
@Component
@Slf4j
@SuppressWarnings("unchecked")
public class DataFillManager implements SmartInitializingSingleton {
// DTO 所属类型包含的字段缓存,提升解析速度
private static final Map<Class<?>, Field[]> CLASS_FIELD_CACHE = new ConcurrentHashMap<>();
private final ApplicationContext context;
// 每一种 DataType 对应的 DataFillQuery
private final Map<DataType, DataFillQuery<Object>> dataFillMap = new HashMap<>();
public DataFillManager(ApplicationContext context) {
this.context = context;
}
@Override
public void afterSingletonsInstantiated() {
// 注册所有 DataFillQuery
for (DataFillQuery<Object> value : context.getBeansOfType(DataFillQuery.class).values()) {
this.dataFillMap.put(value.support(), value);
}
}
// 创建 DataFillContext
public DataFillContext createContext() {
return new DataFillContext();
}
public static class DataFillContext {
// TODO
}
@Data
public static class DataFillPair {
// TODO
}
}
DataFillContext
DataFillContext 作为DTO对象的上下文对象,要提供的功能有:
- 添加 DTO 对象
- 解析 DTO 对象中添加了注解的字段
- 调用 DataFillQuery 对象查询目标数据,填充到 DTO 的对应字段上
主要代码如下:
java
public class DataFillContext {
// 指定了具体 DataType 类型的 字段对
private final Map<DataType, List<DataFillPair>> typeDataMap = new EnumMap<>(DataType.class);
// 标记了 DataFillSource 注解的字段
private final Map<DataType, Set<Object>> typeSourceMap = new EnumMap<>(DataType.class);
// 待处理 DTO 对象,这里之所以使用队列存储,是为了保存 DataFillNested 标注的对象
private final Queue<Object> pendingQueue = new LinkedList<>();
// 把要处理的 DTO 添加进 待处理队列中
void addObject(Object object) {
if (object instanceof Collection<?> collection) {
if (collection.isEmpty()) {
return;
}
pendingQueue.addAll(collection);
} else {
pendingQueue.add(object);
}
}
void doFill() {
// 1、解析待处理的 DTO 对象中字段和注解
processPendingQueue();
// 2、遍历所有 标记了 DataFillSource 注解的字段
for (Map.Entry<DataType, Set<Object>> entry : typeSourceMap.entrySet()) {
DataType dataType = entry.getKey();
Set<Object> value = entry.getValue();
// 使用 DataType 对应的 DataFillQuery 对象来查询数据
DataFillQuery<Object> queryer = dataFillMap.get(dataType);
Map<Object, Object> dataMap = (Map<Object, Object>) queryer.query(value);
List<DataFillPair> pairs = typeDataMap.get(dataType);
if (pairs == null) {
continue;
}
// 把查询到数据填充到每一个 字段对 中
for (DataFillPair pair : pairs) {
pair.fillData(dataMap);
}
}
}
@SneakyThrows
private void processPendingQueue() {
// TODO 解析待处理 DTO 中的字段和注解,相关代码在 gitee 仓库中
}
}
DataFillPair
在返回的 DTO 中,注解 DataFillSource、DataFillTarget 在字段上是成对出现的,DataFillPair 是用来保存每一个成对出现的字段,以及它们所需的 DataType、所属的 DTO 对象,代码如下:
java
@Data
public static class DataFillPair {
// 具体的 DataType 类型
private DataType dataType;
// 字段所在的 DTO 对象
private Object container;
// 添加了 DataFillSource 注解的字段
private Object source;
// 添加了 DataFillTarget 注解的字段
private Field targetField;
@Getter
private boolean emptySource;
public DataFillPair(DataType dataType) {
this.dataType = dataType;
}
public void setSource(Object source) {
if (this.source != null) {
log.error("[数据填充] 数据填充数据源已经存在,存在重复注解的情况,dataType: {}, targetField: {}", dataType, targetField);
}
if (source == null) {
emptySource = true;
}
this.source = source;
}
public void setTargetField(Field targetField) {
if (this.targetField != null) {
log.error("数据填充数据目前已经存在,存在重复注解的情况,field: {}", targetField);
}
this.targetField = targetField;
}
@SneakyThrows
public void fillData(Map<Object, Object> dataMap) {
if (source instanceof Collection<?> objects) {
Class<?> type = targetField.getType();
Collection<Object> targetContainer;
if (type.isAssignableFrom(List.class)) {
targetContainer = new ArrayList<>();
} else if (type.isAssignableFrom(Set.class)) {
targetContainer = new HashSet<>();
} else if (type.isAssignableFrom(Collection.class)) {
targetContainer = new ArrayList<>();
} else {
throw new IllegalStateException("不支持的填充类型" + type);
}
for (Object object : objects) {
Object data = dataMap.get(object);
targetContainer.add(data);
}
targetField.set(container, targetContainer);
} else {
Object data = dataMap.get(source);
targetField.set(container, data);
}
}
public boolean isNotCompleted() {
return targetField == null || source == null;
}
}
效果
相关代码推送到 gitee 仓库(gitee.com/callmekeybo...)中,本地部署执行后,调用接口能满足预期返回所需的数据。
总结
通过使用注解和切面,在service 层解决了N+1查询问题,减少了重复代码开发,保持了 service 层代码干净。