全网最强的自定义注解实现VO/DTO的Id到name的自动绑定功能

基于自定义注解的 Id → Name 自动绑定实现(支持嵌套实体)

目标:在返回给前端的 VO / DTO 中,只传一个 id 字段,在返回前自动根据 id 查询对应的 name 并赋值,而且要支持 嵌套对象 / List / 分页对象


记得点赞加收藏哦😁😁😁

1. 整体思路

  1. 在 VO / DTO 的 name 字段上打注解,描述:
    • 对应的 id 字段名是什么?
    • 要调用哪个 Service?
    • 用哪个方法批量根据 id 查询?
    • 返回对象里的「主键字段」和「名称字段」叫什么?
  2. 使用 Spring ResponseBodyAdvice 或 AOP,在 Controller 返回结果写回 HTTP 之前:
    • 递归扫描返回结果对象,收集所有需要绑定的字段及其 id 值;
    • 按注解配置做 批量查询,拿到 id -> name 映射表;
    • 再递归回去,把对应的 name 字段赋值好。
  3. 递归遍历时要支持:
    • 普通 Java Bean;
    • List / Set / 数组
    • 自定义分页对象(如 Page<T>IPage<T> 等);
    • 可以扩展 Map 等结构。

这样可以做到:Controller 里只返回纯净的 DTO,业务层完全不用管「name 的填充」,统一在一个地方自动处理,避免 N 次循环查询。


2. 定义注解

2.1 单个绑定注解

package com.example.demo.bind;

import java.lang.annotation.*;

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface IdNameBind {

    /** 对应的 id 字段名(在当前对象上) */
    String idField();

    /** 查询使用的 Service 类型 */
    Class<?> service();

    /** 批量查询的方法名,例如:listByIds / getByIds 等 */
    String method() default "listByIds";

    /** 
     * Service 查询结果中,作为 key 的字段名(通常是 id)。
     * 比如返回的是 Agent,对应字段就是 "agentId"
     */
    String keyField() default "id";

    /** Service 查询结果中,作为 name 的字段名 */
    String valueField() default "name";
}

2.2 复合注解(可选)

如果一个字段需要多种绑定方式,可以再定义一个复合注解(一般用不到可以先不搞):

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface IdNameBinds {
    IdNameBind[] value();
}

3. 定义示例 VO / DTO

3.1 业务场景假设

  • 有代理表 t_agent_info
    • agent_id
    • agent_name
  • 有商户表 t_mch_info
    • mch_id
    • agent_id(归属代理)

我们对外返回的商户列表,需要带上:

  • agentId
  • agentName(通过代理表自动填充)
  • 并且有 嵌套子对象:比如一个商户下还有 OperatorDTO,里面也有类似的 id → name。

3.2 示例 DTO

package com.example.demo.dto;

import com.example.demo.bind.IdNameBind;
import com.example.demo.service.AgentInfoService;
import lombok.Data;

import java.util.List;

@Data
public class MchInfoDTO {

    private Long mchId;

    private Long agentId;

    @IdNameBind(
            idField = "agentId",
            service = AgentInfoService.class,
            method = "listByIds",
            keyField = "agentId",
            valueField = "agentName"
    )
    private String agentName;

    /** 嵌套对象示例 */
    private OperatorDTO operator;

    /** 嵌套集合示例 */
    private List<ChildMchDTO> children;
}

子对象也一样可以用注解:

@Data
public class OperatorDTO {

    private Long operatorId;

    @IdNameBind(
            idField = "operatorId",
            service = OperatorService.class,
            method = "listByIds",
            keyField = "operatorId",
            valueField = "operatorName"
    )
    private String operatorName;
}

ChildMchDTO 同理。


4. Service 规范约定

为了让反射调用方便,我们约定:

  • Service 中提供一种 批量查询方法
public interface AgentInfoService {

    /**
     * 根据 agentId 列表批量查询代理信息
     */
    List<AgentInfo> listByIds(List<Long> agentIds);
}

返回实体示例:

@Data
public class AgentInfo {
    private Long agentId;
    private String agentName;
}

要求:

  • 方法名:和注解的 method 保持一致,例如 listByIds
  • 参数:接受 Collection<ID>(List / Set 均可);
  • 返回:List<实体> 即可,实体里必须有 keyField valueField 对应字段。

5. Binder 核心实现(递归 + 批量查询)

5.1 配置类(用来做 Map 的 key)

package com.example.demo.bind.core;

import lombok.Data;

@Data
public class BindConfig {

    private final Class<?> serviceClass;
    private final String methodName;
    private final String keyField;
    private final String valueField;

    // 用于 map key,需要重写 equals / hashCode
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof BindConfig)) return false;
        BindConfig that = (BindConfig) o;
        return serviceClass.equals(that.serviceClass)
                && methodName.equals(that.methodName)
                && keyField.equals(that.keyField)
                && valueField.equals(that.valueField);
    }

    @Override
    public int hashCode() {
        int result = serviceClass.hashCode();
        result = 31 * result + methodName.hashCode();
        result = 31 * result + keyField.hashCode();
        result = 31 * result + valueField.hashCode();
        return result;
    }
}

5.2 绑定上下文(记录所有待绑定字段)

package com.example.demo.bind.core;

import lombok.Data;

import java.lang.reflect.Field;

@Data
public class BindTarget {

    /** 持有该字段的对象实例 */
    private final Object target;

    /** 被注解的字段(name 字段) */
    private final Field field;

    /** 对应的 id 值 */
    private final Object idValue;

    /** 绑定配置 */
    private final BindConfig config;
}

5.3 Binder 主类

package com.example.demo.bind.core;

import com.example.demo.bind.IdNameBind;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
import org.springframework.util.ReflectionUtils;

import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.*;
import java.util.stream.Collectors;

@Slf4j
@Component
public class IdNameBinder implements ApplicationContextAware {

    private ApplicationContext applicationContext;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext = applicationContext;
    }

    /** 对外暴露的入口:对任意返回结果进行绑定 */
    public void bind(Object returnValue) {
        if (returnValue == null) {
            return;
        }

        // 1. 扫描阶段:收集所有绑定点
        Map<BindConfig, List<BindTarget>> configToTargets = new LinkedHashMap<>();
        scanObject(returnValue, configToTargets);

        if (configToTargets.isEmpty()) {
            return;
        }

        // 2. 对每种配置做一次批量查询
        for (Map.Entry<BindConfig, List<BindTarget>> entry : configToTargets.entrySet()) {
            BindConfig config = entry.getKey();
            List<BindTarget> targets = entry.getValue();

            // 收集所有 id
            Set<Object> ids = targets.stream()
                    .map(BindTarget::getIdValue)
                    .filter(Objects::nonNull)
                    .collect(Collectors.toSet());

            if (ids.isEmpty()) {
                continue;
            }

            Map<Object, Object> idNameMap = doBatchQuery(config, ids);

            // 3. 回填
            for (BindTarget target : targets) {
                Object idVal = target.getIdValue();
                Object nameVal = idNameMap.get(idVal);
                if (nameVal == null) {
                    continue;
                }
                Field field = target.getField();
                ReflectionUtils.makeAccessible(field);
                try {
                    field.set(target.getTarget(), nameVal);
                } catch (IllegalAccessException e) {
                    log.error("回填 name 字段失败, field={}", field.getName(), e);
                }
            }
        }
    }

    /** 通过反射做批量查询 */
    @SuppressWarnings("unchecked")
    private Map<Object, Object> doBatchQuery(BindConfig config, Set<Object> ids) {
        try {
            Object service = applicationContext.getBean(config.getServiceClass());
            Method method = findMethod(config.getServiceClass(), config.getMethodName());
            if (method == null) {
                log.error("在 {} 中未找到方法 {}", config.getServiceClass().getName(), config.getMethodName());
                return Collections.emptyMap();
            }

            ReflectionUtils.makeAccessible(method);
            Object result = method.invoke(service, ids);

            if (!(result instanceof Collection)) {
                log.error("批量查询方法返回类型不是 Collection");
                return Collections.emptyMap();
            }

            Collection<?> list = (Collection<?>) result;

            Map<Object, Object> map = new HashMap<>();
            for (Object item : list) {
                if (item == null) continue;
                Object key = getFieldValue(item, config.getKeyField());
                Object value = getFieldValue(item, config.getValueField());
                if (key != null && value != null) {
                    map.put(key, value);
                }
            }
            return map;
        } catch (Exception e) {
            log.error("doBatchQuery 异常", e);
            return Collections.emptyMap();
        }
    }

    private Method findMethod(Class<?> serviceClass, String methodName) {
        for (Method method : serviceClass.getMethods()) {
            if (method.getName().equals(methodName)
                    && method.getParameterCount() == 1
                    && Collection.class.isAssignableFrom(method.getParameterTypes()[0])) {
                return method;
            }
        }
        return null;
    }

    private Object getFieldValue(Object obj, String fieldName) {
        Field field = ReflectionUtils.findField(obj.getClass(), fieldName);
        if (field == null) return null;
        ReflectionUtils.makeAccessible(field);
        try {
            return field.get(obj);
        } catch (IllegalAccessException e) {
            return null;
        }
    }

    // ================ 递归扫描 =================

    private void scanObject(Object obj, Map<BindConfig, List<BindTarget>> configToTargets) {
        if (obj == null) return;

        // 自定义分页类型适配,可根据实际项目改造
        if (isPage(obj)) {
            List<?> records = getPageRecords(obj);
            if (records != null) {
                records.forEach(item -> scanObject(item, configToTargets));
            }
            return;
        }

        Class<?> clazz = obj.getClass();

        // 简单类型直接跳过
        if (isSimpleValueType(clazz)) {
            return;
        }

        // 数组
        if (clazz.isArray()) {
            int len = Array.getLength(obj);
            for (int i = 0; i < len; i++) {
                scanObject(Array.get(obj, i), configToTargets);
            }
            return;
        }

        // Collection
        if (obj instanceof Collection) {
            Collection<?> coll = (Collection<?>) obj;
            for (Object element : coll) {
                scanObject(element, configToTargets);
            }
            return;
        }

        // Map(这里只扫描 value,有需要可以把 key 也加上)
        if (obj instanceof Map) {
            Map<?, ?> map = (Map<?, ?>) obj;
            for (Object value : map.values()) {
                scanObject(value, configToTargets);
            }
            return;
        }

        // Java Bean:扫描字段
        List<Field> fields = getAllFields(clazz);
        for (Field field : fields) {
            ReflectionUtils.makeAccessible(field);

            // 1. 处理有注解的字段(name 字段)
            IdNameBind bind = field.getAnnotation(IdNameBind.class);
            if (bind != null) {
                Object idVal = getIdFieldValue(obj, bind.idField());
                if (idVal != null) {
                    BindConfig config = new BindConfig(
                            bind.service(),
                            bind.method(),
                            bind.keyField(),
                            bind.valueField());
                    BindTarget target = new BindTarget(obj, field, idVal, config);
                    configToTargets
                            .computeIfAbsent(config, k -> new ArrayList<>())
                            .add(target);
                }
            }

            // 2. 对于没有注解的字段,递归进去
            try {
                Object fieldVal = field.get(obj);
                if (fieldVal != null && !isSimpleValueType(field.getType())) {
                    scanObject(fieldVal, configToTargets);
                }
            } catch (IllegalAccessException e) {
                // ignore
            }
        }
    }

    private Object getIdFieldValue(Object bean, String idFieldName) {
        Field idField = ReflectionUtils.findField(bean.getClass(), idFieldName);
        if (idField == null) {
            log.warn("在 {} 中未找到 id 字段 {}", bean.getClass().getName(), idFieldName);
            return null;
        }
        ReflectionUtils.makeAccessible(idField);
        try {
            return idField.get(bean);
        } catch (IllegalAccessException e) {
            return null;
        }
    }

    private List<Field> getAllFields(Class<?> clazz) {
        List<Field> fields = new ArrayList<>();
        while (clazz != null && clazz != Object.class) {
            fields.addAll(Arrays.asList(clazz.getDeclaredFields()));
            clazz = clazz.getSuperclass();
        }
        return fields;
    }

    private boolean isSimpleValueType(Class<?> clazz) {
        return clazz.isPrimitive()
                || clazz.equals(String.class)
                || Number.class.isAssignableFrom(clazz)
                || Date.class.isAssignableFrom(clazz)
                || clazz.equals(Boolean.class)
                || clazz.isEnum();
    }

    /** 根据你项目里的分页类型自行处理 */
    private boolean isPage(Object obj) {
        // 示例:如果你用的是 MyBatis-Plus 的 IPage
        // return obj instanceof com.baomidou.mybatisplus.core.metadata.IPage;
        return false;
    }

    @SuppressWarnings("unchecked")
    private List<?> getPageRecords(Object pageObj) {
        // 示例:((IPage<?>) pageObj).getRecords();
        return null;
    }
}

6. 使用 ResponseBodyAdvice 统一处理返回值

6.1 定义开关注解(可选)

可以在 Controller 或方法上加一个开关注解,表示是否启用绑定。

package com.example.demo.bind;

import java.lang.annotation.*;

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface EnableIdNameBind {
}

6.2 实现 ResponseBodyAdvice

package com.example.demo.bind.core;

import com.example.demo.bind.EnableIdNameBind;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;

@ControllerAdvice
@RequiredArgsConstructor
public class IdNameBindResponseBodyAdvice implements ResponseBodyAdvice<Object> {

    private final IdNameBinder idNameBinder;

    @Override
    public boolean supports(MethodParameter returnType,
                            Class<? extends HttpMessageConverter<?>> converterType) {

        // 只对打了 @EnableIdNameBind 的控制器 / 方法生效
        if (returnType.getContainingClass().isAnnotationPresent(EnableIdNameBind.class)) {
            return true;
        }
        if (returnType.hasMethodAnnotation(EnableIdNameBind.class)) {
            return true;
        }
        return false;
    }

    @Override
    public Object beforeBodyWrite(Object body,
                                  MethodParameter returnType,
                                  MediaType selectedContentType,
                                  Class<? extends HttpMessageConverter<?>> selectedConverterType,
                                  org.springframework.http.server.ServerHttpRequest request,
                                  org.springframework.http.server.ServerHttpResponse response) {

        idNameBinder.bind(body);
        return body;
    }
}

6.3 Controller 使用示例

package com.example.demo.controller;

import com.example.demo.bind.EnableIdNameBind;
import com.example.demo.dto.MchInfoDTO;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Arrays;
import java.util.List;

@RestController
@EnableIdNameBind
public class MchController {

    @GetMapping("/mch/list")
    public List<MchInfoDTO> list() {
        MchInfoDTO dto = new MchInfoDTO();
        dto.setMchId(1L);
        dto.setAgentId(100L);

        // 嵌套对象
        // dto.setOperator(...);
        // 嵌套集合
        // dto.setChildren(...);

        return Arrays.asList(dto);
    }
}

访问 /mch/list 时:

  1. Controller 只设置 agentId
  2. 返回前被 IdNameBindResponseBodyAdvice 拦截;
  3. IdNameBinder 扫描 MchInfoDTO / 嵌套对象 / 列表;
  4. 收集所有 agentId
  5. 调用 AgentInfoService.listByIds(ids),得到 Map<agentId, agentName>
  6. agentName 填回所有对应字段。

7. 支持分页对象的扩展

如果你使用的是 MyBatis-Plus 的 IPage<T>,按如下方式扩展:

7.1 修改 isPage & getPageRecords

import com.baomidou.mybatisplus.core.metadata.IPage;

// ...

private boolean isPage(Object obj) {
    return obj instanceof IPage;
}

@SuppressWarnings("unchecked")
private List<?> getPageRecords(Object pageObj) {
    return ((IPage<?>) pageObj).getRecords();
}

这样,当 Controller 返回 IPage<MchInfoDTO> 时也可以正常递归绑定:

@GetMapping("/mch/page")
@EnableIdNameBind
public IPage<MchInfoDTO> page() {
    // 正常分页查询逻辑...
}

8. 常见坑 & 排查思路

  1. NoSuchMethodException

    • 检查注解里 method 名字是否和 Service 里完全一致;
    • 检查方法参数是不是 Collection 类型;
    • 如果一个 Service 里有多个同名重载,建议只保留一个 Collection 参数版本。
  2. IllegalArgumentException: argument type mismatch

    • 一般是 Method.invoke 时传入的参数类型不对;
    • 确认反射拿到的方法的参数类型是 Collection,而你传入的是 Set<Object>List<Object> 之类,正常应该没问题;
    • 注意方法如果写成了 List<Long>,而你传的是 Set<Long>,在反射层面也是 OK 的,只要是 Collection 的子类;
    • 真正的问题往往是找错方法(重载)或者方法参数个数不对。
  3. 嵌套对象未绑定

    • 检查递归是否覆盖了你的类型(比如自定义分页、Result 包装类);
    • 对自定义包装类(例如统一返回 Result<T>),需要在 scanObject 里加一段拆包装的逻辑:
      if (obj instanceof Result) {
          scanObject(((Result<?>) obj).getData(), configToTargets);
          return;
      }
      
  4. 性能问题

    • 核心就是:同一类注解配置只走一次批量查询。如果你发现数据库打了很多一样的 SQL,大概率是:
      • 你在多个地方调用了 bind
      • 或者配置对象 BindConfigequals / hashCode 写错,导致相同配置没合并。

9. 小结

  • 注解只负责「描述」:哪个 id 字段,去哪个 Service,用哪个方法,拿哪个字段当 name;
  • AOP / ResponseBodyAdvice 负责统一「执行」:递归扫描 + 批量查 + 回填;
  • 支持嵌套对象、集合、分页对象,只需要在递归里把这些结构拆开继续扫;
  • 通过 BindConfig + BindTarget 把同一种查询合并成一次批量 SQL,避免 N+1。

你可以直接把上面这些类复制到项目里,根据自己实际情况改一下包名、分页类型、返回包装类型即可落地。

<think>好的,我现在需要回答用户关于Java中PO、DTOVO的概念及区别的问题。首先,我需要回忆之前学过的相关知识,并查看提供的引用内容,确保信息准确。 根据引用[1],PO是Persistent Object,与数据库表直接对应,用于数据持久化。DTO是Data Transfer Object,用于不同层之间的数据传输,比如服务层和表现层。VO是Value Object,通常用于展示层的数据封装。 接下来,引用[2]提到DTO负责在不同层之间传输数据,减少调用次数,而PO对应数据库结构,VO则可能包含业务逻辑需要的字段。引用[3]的图示说明进一步区分了三者的用途和特点。 需要确保回答的结构清晰,先分别定义每个概念,再比较它们的区别。同时,按照用户要求,使用中文,行内公式用$...$,独立公式用$$...$$,但这里可能用不上数学公式。不过用户的问题是关于编程概念的,所以可能不需要数学表达式,但需要遵循其他格式要求,如代码块的使用。 用户还要求生成相关问题,我需要考虑常见的相关问题,比如它们的应用场景、转换工具、设计模式中的角色等。要确保问题相关且有价值,帮助用户深入理解。 最后,检查引用标记是否正确,每个定义的末尾添加对应的引用标识,例如[^1]。同时,避免使用Markdown格式,保持回答自然。需要确认是否有遗漏的概念或错误,比如是否混淆了VODTO的区别,或者PO的具体特点。 总结步骤: 1. 分别定义PO、DTOVO。 2. 对比三者的区别,包括用途、层次、数据内容。 3. 添加示例代码说明。 4. 生成相关问题。 5. 确保引用正确,结构清晰。</think>### Java中PO、DTOVO的定义与区别 #### 1. PO (Persistent Object) - **定义**:与数据库表结构直接映射的持久化对象,用于表示数据存储的实体[^1]。 - **特点**: - 字段与数据库表列一一对应 - 通常包含ORM框架(如Hibernate)的注解 - 生命周期与数据库事务绑定 - **示例代码**: ```java @Entity @Table(name = "user") public class UserPO { @Id private Long id; private String username; private String password; } ``` #### 2. DTO (Data Transfer Object) - **定义**:跨层数据传输的载体,用于优化远程调用效率或封装聚合数据[^2]。 - **特点**: - 仅包含传输所需的字段 - 可能组合多个PO的字段 - 无业务逻辑,仅作数据容器 - **示例场景**:接口返回用户信息时组合基础信息+权限列表: ```java public class UserDTO { private Long userId; private String nickname; private List<String> permissions; } ``` #### 3. VO (Value Object) - **定义**:面向展示层的值对象,用于前端交互的数据封装[^3]。 - **特点**: - 字段结构与界面需求高度匹配 - 可能包含格式化的数据(如日期字符串) - 可添加数据校验注解(如@NotBlank) - **示例代码**: ```java public class UserVO { private String userName; private String avatarUrl; @JsonFormat(pattern = "yyyy-MM-dd") private Date registerTime; } ``` #### 对比表格 | 维度 | PO | DTO | VO | |------------|-------------------|-------------------|------------------| | **作用域** | 数据访问层 | 各层之间 | 展示层 | | **数据量** | 完整表字段 | 按需选择字段 | 界面展示字段 | | **可变性** | 与数据库同步变化 | 根据接口需求变化 | 随页面需求变化 | | **注解类型**| JPA注解 | 无/序列化注解 | 校验/格式化注解 | #### 典型数据流转 $$ \text{Database} \xleftrightarrow{PO} \text{DAO层} \xrightarrow{DTO} \text{Service层} \xrightarrow{VO} \text{Controller层} $$
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值