背景
在 Spring 框架升级过程中,我们发现 org.springframework.beans.BeanUtils.copyProperties
方法的行为发生了变化。升级后,一些属性复制操作出现了属性丢失问题,这导致了数据不完整和运行时异常。手动检查代码调用点成本高昂,因此我们开发了一个自动化工具,使用 JavaParser 和反射技术来检测这些问题。该工具可以扫描代码库,定位 BeanUtils.copyProperties
调用,并检查集合属性的泛型是否一致。
问题原理
在 Spring 4.3.0 版本中,BeanUtils.copyProperties
方法在复制属性时相对宽松,不会对泛型进行严格校验。但从 Spring 5.3.39 版本开始,该方法引入了更严格的类型检查机制,包括泛型兼容性校验。这意味着,如果源对象和目标对象的属性类型(尤其是集合类型如 List
、Set
、Map
)的泛型不一致,复制操作会跳过该属性,导致属性丢失。
例如:
- 源类有
Map<String, String> memberPropertyMap
,目标类有Map<String, Object> memberPropertyMap
。 - 复制时,由于泛型
String
vsObject
不匹配,属性被跳过,导致数据丢失。
升级后的问题表现为:
- 运行时可能无报错,但属性值缺失。
- 如果强制转换,会抛出
ClassCastException
。 - 在大型项目中,这种问题不易发现,需自动化检测。
解决方案原理
我们设计了一个工具,结合静态分析和动态反射:
- 静态分析:使用 JavaParser 扫描
.java
文件,定位BeanUtils.copyProperties
调用,提取源和目标变量的类名。 - 类型解析:通过导入和符号解析器获取全限定类名。
- 反射检查:加载类,使用反射获取集合属性,比较泛型类型。
- 输出不一致:报告文件名、行号、方法名、调用表达式和不一致的集合属性。
实现示例
以下是一个简化实现,展示核心逻辑: 引入依赖
<dependency>
<groupId>com.github.javaparser</groupId>
<artifactId>javaparser-symbol-solver-core</artifactId>
<version>3.26.0</version>
</dependency>
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.ImportDeclaration;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.body.Parameter;
import com.github.javaparser.ast.expr.MethodCallExpr;
import com.github.javaparser.ast.expr.VariableDeclarationExpr;
import com.github.javaparser.ast.visitor.VoidVisitorAdapter;
import com.github.javaparser.resolution.types.ResolvedType;
import com.github.javaparser.symbolsolver.JavaSymbolSolver;
import com.github.javaparser.symbolsolver.resolution.typesolvers.CombinedTypeSolver;
import com.github.javaparser.symbolsolver.resolution.typesolvers.JavaParserTypeSolver;
import com.github.javaparser.symbolsolver.resolution.typesolvers.ReflectionTypeSolver;
import java.io.File;
import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
public class BeanUtilsGenericChecker {
private static final String METHOD_NAME = "copyProperties";
private static final String SPRING_BEAN_UTILS = "org.springframework.beans.BeanUtils";
private static final Set<String> COLLECTION_CLASSES = new HashSet<>();
static {
COLLECTION_CLASSES.add("java.util.List");
COLLECTION_CLASSES.add("java.util.Set");
COLLECTION_CLASSES.add("java.util.Map");
}
public static void main(String[] args) throws Exception {
// Project directories to scan
String[] projectDirs = {
""
};
List<Map<String, Object>> issues = new ArrayList<>();
int callCount = 0;
// Configure JavaParser symbol solver
CombinedTypeSolver typeSolver = new CombinedTypeSolver();
typeSolver.add(new ReflectionTypeSolver());
for (String projectDir : projectDirs) {
File projectDirFile = new File(projectDir);
if (!projectDirFile.isDirectory()) {
System.out.println("错误:指定的路径不是一个有效的目录: " + projectDir);
continue;
}
typeSolver.add(new JavaParserTypeSolver(projectDirFile));
}
JavaSymbolSolver symbolSolver = new JavaSymbolSolver(typeSolver);
StaticJavaParser.getParserConfiguration().setSymbolResolver(symbolSolver);
// Print classpath for debugging
System.out.println("调试:运行时 Classpath: " + System.getProperty("java.class.path"));
// Scan Java files
for (String projectDir : projectDirs) {
List<Path> javaFiles = Files.walk(Paths.get(projectDir))
.filter(p -> p.toString().endsWith(".java"))
.collect(Collectors.toList());
for (Path path : javaFiles) {
try {
CompilationUnit cu = StaticJavaParser.parse(path);
boolean isClass = cu.findAll(ClassOrInterfaceDeclaration.class).stream()
.anyMatch(c -> !c.isInterface());
if (!isClass) {
System.out.println("调试:跳过非类文件: " + path);
continue;
}
System.out.println("调试:处理类文件: " + path);
CopyCallVisitor visitor = new CopyCallVisitor(issues, path);
visitor.visit(cu, null);
callCount += visitor.getCallCount();
} catch (Exception e) {
System.out.println("解析文件失败: " + path + ", 错误: " + e.getMessage());
}
}
}
// Output results
System.out.println("\n找到以下需要修改的位置(集合字段泛型不一致):");
for (Map<String, Object> issue : issues) {
System.out.println("\n文件: " + issue.get("filePath") + " (行 " + issue.get("lineNum") + ")");
System.out.println(" 方法: " + issue.get("methodName"));
System.out.println(" 调用: " + issue.get("callExpression"));
System.out.println(" Source类: " + issue.get("sourceClass"));
System.out.println(" Target类: " + issue.get("targetClass"));
List<String> mismatches = (List<String>) issue.get("mismatches");
if (!mismatches.isEmpty()) {
System.out.println(" 泛型不一致的集合字段:");
for (String mismatch : mismatches) {
System.out.println(" " + mismatch);
}
} else {
System.out.println(" 无泛型不一致的集合字段");
}
}
System.out.println("\n调试:检测到 Spring BeanUtils.copyProperties 调用点总数: " + callCount);
if (issues.isEmpty()) {
System.out.println("未找到任何泛型不一致的BeanUtils.copyProperties调用。");
}
}
private static class CopyCallVisitor extends VoidVisitorAdapter<Void> {
private final List<Map<String, Object>> issues;
private final Path filePath;
private int callCount = 0;
private Map<String, String> varToType = new HashMap<>();
private Map<String, String> imports = new HashMap<>();
public CopyCallVisitor(List<Map<String, Object>> issues, Path filePath) {
this.issues = issues;
this.filePath = filePath;
}
public int getCallCount() {
return callCount;
}
@Override
public void visit(CompilationUnit n, Void arg) {
// Collect imports
n.findAll(ImportDeclaration.class).forEach(imp -> {
String fullName = imp.getNameAsString();
String simpleName = imp.getName().getIdentifier();
imports.put(simpleName, fullName);
System.out.println("调试:导入 - 简单名: " + simpleName + ", 全限定名: " + fullName);
});
super.visit(n, arg);
}
@Override
public void visit(MethodDeclaration n, Void arg) {
varToType.clear(); // Clear previous method's variables
// Collect method parameters
n.findAll(Parameter.class).forEach(p -> {
String varName = p.getNameAsString();
String typeName = p.getType().asString();
String resolvedType = resolveFullType(typeName);
varToType.put(varName, resolvedType);
System.out.println("调试:方法参数 - 变量: " + varName + ", 类型: " + typeName + ", 解析为: " + resolvedType);
});
// Collect local variables
n.findAll(VariableDeclarationExpr.class).forEach(varDecl -> {
String typeName = varDecl.getElementType().asString();
String resolvedType = resolveFullType(typeName);
varDecl.getVariables().forEach(var -> {
String varName = var.getNameAsString();
varToType.put(varName, resolvedType);
System.out.println("调试:本地变量 - 变量: " + varName + ", 类型: " + typeName + ", 解析为: " + resolvedType);
});
});
super.visit(n, arg);
}
@Override
public void visit(MethodCallExpr n, Void arg) {
super.visit(n, arg);
if (n.getNameAsString().equals(METHOD_NAME)) {
try {
String resolvedType = n.resolve().declaringType().getQualifiedName();
if (SPRING_BEAN_UTILS.equals(resolvedType)) {
processCall(n);
} else {
System.out.println("调试:跳过非 Spring BeanUtils 调用 - 文件: " + filePath +
", 调用: " + n + ", 类型: " + resolvedType);
}
} catch (Exception e) {
String scope = n.getScope().map(Object::toString).orElse("");
if (scope.isEmpty() || scope.equals("BeanUtils")) {
processCall(n);
} else {
System.out.println("调试:跳过非 Spring BeanUtils 调用 - 文件: " + filePath +
", 调用: " + n + ", Scope: " + scope);
}
}
}
}
private void processCall(MethodCallExpr call) {
if (call.getArguments().size() < 2) {
return;
}
callCount++;
String methodName = call.findAncestor(MethodDeclaration.class)
.map(md -> md.getNameAsString())
.orElse("未知方法");
String sourceName = call.getArgument(0).toString();
String targetName = call.getArgument(1).toString();
String sourceClass = varToType.get(sourceName);
String targetClass = varToType.get(targetName);
System.out.println("调试:找到 Spring BeanUtils.copyProperties 调用 - 文件: " + filePath +
", 方法: " + methodName + ", 行: " + call.getRange().map(r -> r.begin.line).orElse(0) +
", 调用: " + call.toString() +
", Source类: " + (sourceClass != null ? sourceClass : "未知") +
", Target类: " + (targetClass != null ? targetClass : "未知"));
List<String> mismatches = new ArrayList<>();
if (sourceClass != null && targetClass != null && !sourceClass.startsWith("java.lang") && !targetClass.startsWith("java.lang")) {
mismatches = checkCollectionMismatch(sourceClass, targetClass);
} else {
System.out.println("调试:跳过泛型检查 - Source或Target类无效: Source=" + sourceClass + ", Target=" + targetClass);
}
Map<String, Object> issue = new HashMap<>();
issue.put("filePath", filePath.toString());
issue.put("lineNum", call.getRange().map(r -> r.begin.line).orElse(0));
issue.put("methodName", methodName);
issue.put("callExpression", call.toString());
issue.put("sourceClass", sourceClass != null ? sourceClass : "未知");
issue.put("targetClass", targetClass != null ? targetClass : "未知");
issue.put("mismatches", mismatches);
issues.add(issue);
}
private String resolveFullType(String typeName) {
if (typeName.contains(".")) {
System.out.println("调试:类型解析 - 输入: " + typeName + ", 已为全限定名");
return typeName; // Already fully qualified
}
String fullName = imports.get(typeName);
if (fullName != null) {
System.out.println("调试:类型解析 - 输入: " + typeName + ", 从导入解析为: " + fullName);
return fullName;
}
// Try resolving with symbol solver
try {
ResolvedType resolvedType = StaticJavaParser.parseType(typeName).resolve();
String resolvedName = resolvedType.asReferenceType().getQualifiedName();
System.out.println("调试:类型解析 - 输入: " + typeName + ", 符号解析为: " + resolvedName);
return resolvedName;
} catch (Exception e) {
System.out.println("调试:符号解析失败 - 类型: " + typeName + ", 错误: " + e.getMessage());
// Search project for possible matches
String possibleMatch = searchForClass(typeName);
if (possibleMatch != null) {
System.out.println("调试:类型解析 - 输入: " + typeName + ", 项目搜索解析为: " + possibleMatch);
return possibleMatch;
}
System.out.println("调试:类型解析 - 输入: " + typeName + ", 无匹配导入,回退到原名");
return typeName; // Return original name to avoid java.lang
}
}
private String searchForClass(String className) {
String[] projectDirs = {
""
};
for (String projectDir : projectDirs) {
try {
List<Path> matches = Files.walk(Paths.get(projectDir))
.filter(p -> p.getFileName().toString().equals(className + ".java"))
.collect(Collectors.toList());
for (Path match : matches) {
CompilationUnit cu = StaticJavaParser.parse(match);
String packageName = cu.getPackageDeclaration()
.map(pd -> pd.getNameAsString())
.orElse("");
String fullName = packageName.isEmpty() ? className : packageName + "." + className;
return fullName;
}
} catch (Exception e) {
System.out.println("调试:搜索类失败 - 类: " + className + ", 目录: " + projectDir + ", 错误: " + e.getMessage());
}
}
return null;
}
private List<String> checkCollectionMismatch(String sourceClassName, String targetClassName) {
List<String> mismatches = new ArrayList<>();
try {
Class<?> sourceClass = Class.forName(sourceClassName, false, Thread.currentThread().getContextClassLoader());
Class<?> targetClass = Class.forName(targetClassName, false, Thread.currentThread().getContextClassLoader());
System.out.println("调试:成功加载类 - Source: " + sourceClassName + ", Target: " + targetClassName);
Map<String, Type> sourceFields = getCollectionFields(sourceClass);
Map<String, Type> targetFields = getCollectionFields(targetClass);
System.out.println("调试:Source类 " + sourceClassName + " 集合字段: " + sourceFields.keySet());
System.out.println("调试:Target类 " + targetClassName + " 集合字段: " + targetFields.keySet());
for (Map.Entry<String, Type> sourceEntry : sourceFields.entrySet()) {
String fieldName = sourceEntry.getKey();
Type sourceType = sourceEntry.getValue();
Type targetType = targetFields.get(fieldName);
if (targetType != null) {
String sourceTypeStr = typeToString(sourceType);
String targetTypeStr = typeToString(targetType);
if (!sourceTypeStr.equals(targetTypeStr)) {
String mismatch = "字段 '" + fieldName + "': Source类型=" + sourceTypeStr + ", Target类型=" + targetTypeStr;
mismatches.add(mismatch);
}
}
}
} catch (ClassNotFoundException e) {
System.out.println("调试:无法加载类 - Source: " + sourceClassName + ", Target: " + targetClassName + ", 错误: " + e.getMessage());
} catch (Exception e) {
System.out.println("调试:反射解析失败 - Source: " + sourceClassName + ", Target: " + targetClassName + ", 错误: " + e.getMessage());
}
return mismatches;
}
private Map<String, Type> getCollectionFields(Class<?> clazz) {
Map<String, Type> fields = new HashMap<>();
while (clazz != null) {
for (Field field : clazz.getDeclaredFields()) {
Type type = field.getGenericType();
if (isCollectionType(type)) {
fields.put(field.getName(), type);
}
}
clazz = clazz.getSuperclass();
}
return fields;
}
private boolean isCollectionType(Type type) {
if (type instanceof ParameterizedType) {
ParameterizedType pt = (ParameterizedType) type;
String rawType = pt.getRawType().getTypeName();
return COLLECTION_CLASSES.contains(rawType);
} else if (type instanceof Class) {
String className = ((Class<?>) type).getName();
return COLLECTION_CLASSES.contains(className);
}
return false;
}
private String typeToString(Type type) {
if (type instanceof ParameterizedType) {
ParameterizedType pt = (ParameterizedType) type;
StringBuilder sb = new StringBuilder(pt.getRawType().getTypeName());
Type[] typeArgs = pt.getActualTypeArguments();
if (typeArgs.length > 0) {
sb.append("<");
for (int i = 0; i < typeArgs.length; i++) {
if (i > 0) {
sb.append(", ");
}
sb.append(typeToString(typeArgs[i]));
}
sb.append(">");
}
return sb.toString();
} else if (type instanceof Class) {
return ((Class<?>) type).getName();
}
return type.getTypeName();
}
}
}
运行结果:
字段 'memberPropertyMap': Source类型=java.util.Map<java.lang.String, java.lang.String>, Target类型=java.util.Map<java.lang.String, java.lang.Object>
挑战与解决
- 类名解析:
- 问题:变量类型可能未解析为全限定名。
- 解决:使用 JavaParser 的符号解析器,结合导入信息解析类名。
- 类加载失败:
- 问题:依赖缺失导致
ClassNotFoundException
。 - 解决:使用上下文类加载器,确保依赖齐全。
- 问题:依赖缺失导致
- 调用点漏检:
- 问题:静态导入或额外参数导致匹配失败。
- 解决:仅验证方法名和声明类,兼容多种调用形式。
经验总结
- 静态与动态结合:JavaParser 定位调用,反射检查泛型,结合两者实现精准检测。
- 泛型处理:使用
ParameterizedType
提取泛型信息,需支持继承字段。 - 工程化思维:自动化工具大幅提升效率,符合“工程问题用工程方法解决”的理念。
- 日志辅助:详细记录类型解析和类加载信息,便于调试。
使用指南
- 环境准备:添加 JavaParser 和 Spring Beans 依赖。
- 运行工具:扫描项目目录,执行检测逻辑。
- 验证结果:检查输出,确认泛型不一致字段。
- 扩展:根据项目需求调整扫描范围和泛型逻辑。
未来改进
- 支持复杂嵌套泛型检测。
- 集成到 CI 流程,自动检查代码变更。
- 提供修复建议,如自定义复制逻辑。
结语
Spring 从 4.3.0 升级到 5.3.39 后,BeanUtils.copyProperties
的严格泛型校验可能导致属性丢失。通过 JavaParser 和反射开发的自动化工具,我们可以高效检测泛型不一致问题,避免运行时异常。遵循“工程问题用工程方法解决”的理念,这个工具展示了如何用技术手段提升代码质量,推荐在 Spring 升级项目中应用!