工作实践:基于注解的N+1查询解决方案

前言

在业务开发工作中,我们经常遇到这样的需求:查询数据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 层代码干净。

相关推荐
ღ᭄ꦿ࿐Never say never꧂23 分钟前
微服务架构中的负载均衡与服务注册中心(Nacos)
java·spring boot·后端·spring cloud·微服务·架构·负载均衡
.生产的驴32 分钟前
SpringBoot 消息队列RabbitMQ 消息确认机制确保消息发送成功和失败 生产者确认
java·javascript·spring boot·后端·rabbitmq·负载均衡·java-rabbitmq
海里真的有鱼40 分钟前
Spring Boot 中整合 Kafka
后端
布瑞泽的童话1 小时前
无需切换平台?TuneFree如何搜罗所有你爱的音乐
前端·vue.js·后端·开源
写bug写bug1 小时前
6 种服务限流的实现方式
java·后端·微服务
离开地球表面_991 小时前
索引失效?查询结果不正确?原来都是隐式转换惹的祸
数据库·后端·mysql
Victor3561 小时前
Oracle(138)如何监控数据库性能?
后端
不修×蝙蝠2 小时前
eclipse使用 笔记02
前端·笔记·后端·eclipse
吃面不喝汤664 小时前
Flask + Swagger 完整指南:从安装到配置和注释
后端·python·flask