基于自定义注解的 Id → Name 自动绑定实现(支持嵌套实体)
目标:在返回给前端的 VO / DTO 中,只传一个
id字段,在返回前自动根据 id 查询对应的name并赋值,而且要支持 嵌套对象 / List / 分页对象。
记得点赞加收藏哦😁😁😁
1. 整体思路
- 在 VO / DTO 的
name字段上打注解,描述:- 对应的
id字段名是什么? - 要调用哪个 Service?
- 用哪个方法批量根据 id 查询?
- 返回对象里的「主键字段」和「名称字段」叫什么?
- 对应的
- 使用 Spring ResponseBodyAdvice 或 AOP,在 Controller 返回结果写回 HTTP 之前:
- 递归扫描返回结果对象,收集所有需要绑定的字段及其 id 值;
- 按注解配置做 批量查询,拿到
id -> name映射表; - 再递归回去,把对应的
name字段赋值好。
- 递归遍历时要支持:
- 普通 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_idagent_name
- 有商户表
t_mch_info:mch_idagent_id(归属代理)
我们对外返回的商户列表,需要带上:
agentIdagentName(通过代理表自动填充)- 并且有 嵌套子对象:比如一个商户下还有
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<实体>即可,实体里必须有keyFieldvalueField对应字段。
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 时:
- Controller 只设置
agentId; - 返回前被
IdNameBindResponseBodyAdvice拦截; IdNameBinder扫描MchInfoDTO/ 嵌套对象 / 列表;- 收集所有
agentId; - 调用
AgentInfoService.listByIds(ids),得到Map<agentId, agentName>; - 把
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. 常见坑 & 排查思路
-
NoSuchMethodException- 检查注解里
method名字是否和 Service 里完全一致; - 检查方法参数是不是
Collection类型; - 如果一个 Service 里有多个同名重载,建议只保留一个
Collection参数版本。
- 检查注解里
-
IllegalArgumentException: argument type mismatch- 一般是
Method.invoke时传入的参数类型不对; - 确认反射拿到的方法的参数类型是
Collection,而你传入的是Set<Object>或List<Object>之类,正常应该没问题; - 注意方法如果写成了
List<Long>,而你传的是Set<Long>,在反射层面也是 OK 的,只要是Collection的子类; - 真正的问题往往是找错方法(重载)或者方法参数个数不对。
- 一般是
-
嵌套对象未绑定
- 检查递归是否覆盖了你的类型(比如自定义分页、Result 包装类);
- 对自定义包装类(例如统一返回
Result<T>),需要在scanObject里加一段拆包装的逻辑:if (obj instanceof Result) { scanObject(((Result<?>) obj).getData(), configToTargets); return; }
-
性能问题
- 核心就是:同一类注解配置只走一次批量查询。如果你发现数据库打了很多一样的 SQL,大概率是:
- 你在多个地方调用了
bind; - 或者配置对象
BindConfig的equals/hashCode写错,导致相同配置没合并。
- 你在多个地方调用了
- 核心就是:同一类注解配置只走一次批量查询。如果你发现数据库打了很多一样的 SQL,大概率是:
9. 小结
- 注解只负责「描述」:哪个 id 字段,去哪个 Service,用哪个方法,拿哪个字段当 name;
- AOP / ResponseBodyAdvice 负责统一「执行」:递归扫描 + 批量查 + 回填;
- 支持嵌套对象、集合、分页对象,只需要在递归里把这些结构拆开继续扫;
- 通过
BindConfig+BindTarget把同一种查询合并成一次批量 SQL,避免 N+1。
你可以直接把上面这些类复制到项目里,根据自己实际情况改一下包名、分页类型、返回包装类型即可落地。
1463

被折叠的 条评论
为什么被折叠?



