用工程方法解决 Spring BeanUtils 泛型不一致问题:从原理到自动化检测

Dcr 6天前 ⋅ 31 阅读

背景

在 Spring 框架升级过程中,我们发现 org.springframework.beans.BeanUtils.copyProperties 方法的行为发生了变化。升级后,一些属性复制操作出现了属性丢失问题,这导致了数据不完整和运行时异常。手动检查代码调用点成本高昂,因此我们开发了一个自动化工具,使用 JavaParser 和反射技术来检测这些问题。该工具可以扫描代码库,定位 BeanUtils.copyProperties 调用,并检查集合属性的泛型是否一致。

问题原理

在 Spring 4.3.0 版本中,BeanUtils.copyProperties 方法在复制属性时相对宽松,不会对泛型进行严格校验。但从 Spring 5.3.39 版本开始,该方法引入了更严格的类型检查机制,包括泛型兼容性校验。这意味着,如果源对象和目标对象的属性类型(尤其是集合类型如 ListSetMap)的泛型不一致,复制操作会跳过该属性,导致属性丢失。

例如:

  • 源类有 Map<String, String> memberPropertyMap,目标类有 Map<String, Object> memberPropertyMap
  • 复制时,由于泛型 String vs Object 不匹配,属性被跳过,导致数据丢失。

升级后的问题表现为:

  • 运行时可能无报错,但属性值缺失。
  • 如果强制转换,会抛出 ClassCastException
  • 在大型项目中,这种问题不易发现,需自动化检测。

解决方案原理

我们设计了一个工具,结合静态分析和动态反射:

  1. 静态分析:使用 JavaParser 扫描 .java 文件,定位 BeanUtils.copyProperties 调用,提取源和目标变量的类名。
  2. 类型解析:通过导入和符号解析器获取全限定类名。
  3. 反射检查:加载类,使用反射获取集合属性,比较泛型类型。
  4. 输出不一致:报告文件名、行号、方法名、调用表达式和不一致的集合属性。

实现示例

以下是一个简化实现,展示核心逻辑: 引入依赖

        <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>

挑战与解决

  1. 类名解析
    • 问题:变量类型可能未解析为全限定名。
    • 解决:使用 JavaParser 的符号解析器,结合导入信息解析类名。
  2. 类加载失败
    • 问题:依赖缺失导致 ClassNotFoundException
    • 解决:使用上下文类加载器,确保依赖齐全。
  3. 调用点漏检
    • 问题:静态导入或额外参数导致匹配失败。
    • 解决:仅验证方法名和声明类,兼容多种调用形式。

经验总结

  1. 静态与动态结合:JavaParser 定位调用,反射检查泛型,结合两者实现精准检测。
  2. 泛型处理:使用 ParameterizedType 提取泛型信息,需支持继承字段。
  3. 工程化思维:自动化工具大幅提升效率,符合“工程问题用工程方法解决”的理念。
  4. 日志辅助:详细记录类型解析和类加载信息,便于调试。

使用指南

  1. 环境准备:添加 JavaParser 和 Spring Beans 依赖。
  2. 运行工具:扫描项目目录,执行检测逻辑。
  3. 验证结果:检查输出,确认泛型不一致字段。
  4. 扩展:根据项目需求调整扫描范围和泛型逻辑。

未来改进

  • 支持复杂嵌套泛型检测。
  • 集成到 CI 流程,自动检查代码变更。
  • 提供修复建议,如自定义复制逻辑。

结语

Spring 从 4.3.0 升级到 5.3.39 后,BeanUtils.copyProperties 的严格泛型校验可能导致属性丢失。通过 JavaParser 和反射开发的自动化工具,我们可以高效检测泛型不一致问题,避免运行时异常。遵循“工程问题用工程方法解决”的理念,这个工具展示了如何用技术手段提升代码质量,推荐在 Spring 升级项目中应用!

全部评论: 0

    我有话说: