别再手撸架构图了!我写了个 AI 工具,把 Spring Boot 代码一键变成 Draw.io 流程图

0 阅读10分钟

前言 

在之前的博客中,我曾介绍过一款我自主研发的AI驱动在线协作绘图平台——IntelliDraw(在线访问地址:https://www.intellidraw.top)。经过持续优化与创新,IntelliDraw现已升级为一个功能更为强大的开发辅助工具,能够:

  • 基于用户上传的数据库SQL文件自动生成实体关系图(ER Diagram)
  • 支持Java Spring Boot项目代码分析,可一键上传项目ZIP包并生成完整的项目架构图,清晰展示各层级间的调用关系
    本次更新不仅带来了功能上的扩展,还对系统性能进行了全面优化:
  • 通过改进后端提示词(Prompt)算法,将模型调用的token消耗降低了60%
  • 引入了先进的RAG(Retrieval-Augmented Generation)功能
    这些升级让IntelliDraw在技术开发和团队协作场景中展现出更大的应用价值。如果您对这个工具感兴趣或有试用体验,欢迎随时交流!

技术实现

Spring Boot 项目解析

使用了 JavaParser 库实现的 Java 类的解析,maven 依赖如下

 <!-- Source: https://mvnrepository.com/artifact/com.github.javaparser/javaparser-core -->
        <dependency>
            <groupId>com.github.javaparser</groupId>
            <artifactId>javaparser-core</artifactId>
            <version>3.25.8</version>
            <scope>compile</scope>
        </dependency>
        <dependency>
            <groupId>com.github.javaparser</groupId>
            <artifactId>javaparser-symbol-solver-core</artifactId>
            <version>3.25.8</version>
            <scope>compile</scope>
        </dependency>

通过JavaParser库解析Java代码,生成抽象语法树(AST),以便分析Spring Boot项目的结构和依赖关系。AST是计算机理解代码的逻辑结构,类似于将代码分解为语法节点。例如,int x = 10 + 10;的AST包括变量声明、赋值和加法运算节点。解析整个项目时,需识别包、类、方法、字段、注解及依赖关系,特别是Spring Bean的注解。生成项目架构图时,AST需包含模块划分、层次结构、依赖关系和调用链路,以展示项目的组织和功能流程。

抽象语法树(AST)的定义与作用

抽象语法树(AST)是编程语言源代码的抽象语法结构的树形表示。它帮助计算机理解代码的结构,使代码能够被分析和处理。AST忽略了代码中的语法细节,专注于代码的逻辑结构。
AST示例解析

  1. 加法运算的AST结构
    代码:int x = 10 + 10;
    AST分解:
    ○ 变量声明节点:int x
    ○ 赋值操作节点:=
    ○ 加法运算节点:+,包含两个子节点10和10
  2. If语句的AST结构
    代码:if (a > 10) { break; }
    AST分解:
    ○ 条件判断节点:if
    ○ 条件表达式节点:a > 10
    ○ 代码块节点:break;
    JavaParser在Spring Boot项目中的应用
    使用JavaParser库解析整个Spring Boot项目时,需要解析以下内容:
  3. 包和类结构
    ○ 包名
    ○ 类名
    ○ 类的修饰符(如public、final)
  4. 类成员
    ○ 方法:包括方法名、参数、返回类型、访问修饰符
    ○ 字段:包括字段名、类型、访问修饰符
  5. 注解
    ○ 特别是Spring相关的注解,如@Component、@Service、@Autowired等,用于识别Spring Bean及其依赖关系
  6. 依赖关系
    ○ 通过注解和依赖注入机制,解析类之间的依赖关系,如@Autowired注入的字段或方法参数
  7. 调用关系
    ○ 分析方法之间的调用,构建方法调用链路
    生成项目架构图所需的AST特征
    为了生成清晰的项目架构图,AST需要包含以下信息:
  8. 模块划分
    ○ 根据包名或注解信息,将项目划分为不同的模块或层(如Controller、Service、Repository)
  9. 层次结构
    ○ 展示模块之间的层次关系,如分层架构(表现层、业务逻辑层、数据访问层)
  10. 依赖关系
    ○ 通过注解和依赖注入信息,展示类之间的依赖关系,帮助识别高耦合模块
  11. 调用链路
    ○ 展示方法之间的调用顺序,帮助理解功能流程
  12. 接口和实现
    ○ 展示接口与其实现类之间的关系,帮助理解抽象与具体实现的对应

这里我设计了几个通用的 DTO

package com.wfh.drawio.core.model;

import lombok.Data;
import java.util.List;

/**
 * 1. 架构图中的“节点” (比如一个类、一个表、一个微服务)
 * @author fenghuanwang
 */
@Data
public class ArchNode {
    private String id;          // 唯一标识 (e.g., "com.example.UserService")
    private String name;        // 显示名称 (e.g., "UserService")
    private String type;        // 类型 (e.g., "CLASS", "INTERFACE", "TABLE", "SERVICE")
    private String stereotype;  // 构造型/注解 (e.g., "@Controller", "@Repository")
    private List<String> fields; // 关键字段
    // 新增字段:用于存放 API 路由列表、SQL 表名或其他备注
    private String description;
    // 建议新增:用于分组(比如按包名分组,画出子图)
    private String group;
    private List<String> methods; // 关键方法
}

这个就类似于架构图中的一个节点,或者说是一个服务。

package com.wfh.drawio.core.model;

import lombok.Data;

/**
 * 2. 架构图中的“连线” (比如继承、调用、外键)
 * @author fenghuanwang
 */
@Data
public class ArchRelationship {
    private String sourceId;    // 起点
    private String targetId;    // 终点
    private String type;        // 关系类型 (e.g., "DEPENDS_ON", "INHERITS", "CALLS", "FOREIGN_KEY")
    private String label;       // 连线上的文字 (e.g., "findUserById")
}

这个就是架构图中的连线,放到代码中就是服务之间的相互调用之类的。

/**
 * 3. 完整的分析结果
 * @author fenghuanwang
 */
@Data
public class ProjectAnalysisResult {
    private List<ArchNode> nodes;
    private List<ArchRelationship> relationships;
}

而这个就是完整的分析结果了,也就是要返回给前端的东西

那这个 Java 解析器具体代码逻辑是怎么样的呢

下面是完整的代码,这段代码是使用了 Java 的 SPI 机制实现的,具体什么是 SPI 机制,可以去我的上一篇文章中寻找

package com.wfh.drawio.spi.parser;

import com.github.javaparser.JavaParser;
import com.github.javaparser.ParserConfiguration;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.ImportDeclaration;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.comments.Comment;
import com.github.javaparser.ast.expr.AnnotationExpr;
import com.github.javaparser.ast.type.ClassOrInterfaceType;
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 com.wfh.drawio.core.model.ArchNode;
import com.wfh.drawio.core.model.ArchRelationship;
import com.wfh.drawio.core.model.ProjectAnalysisResult;
import com.wfh.drawio.spi.LanguageParser;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * @Title: JavaSpringParser
 * @Author wangfenghuan
 * @description: 极致优化的 Spring Boot 项目解析器 (支持 Javadoc 提取)
 */
@Slf4j
public class JavaSpringParser implements LanguageParser {

    private static final Map<String, String> STEREOTYPE_MAPPING = Map.of(
            "RestController", "API Layer",
            "Controller", "API Layer",
            "Service", "Business Layer",
            "Repository", "Data Layer",
            "Mapper", "Data Layer",
            "Component", "Infrastructure",
            "Configuration", "Infrastructure",
            "Entity", "Data Layer",
            "Table", "Data Layer"
    );

    private static final Set<String> IGNORED_SUFFIXES = Set.of(
            "Test", "Tests", "DTO", "VO", "Request", "Response", "Exception", "Constant", "Config", "Utils", "Properties"
    );

    private static final Set<String> IGNORED_PACKAGES = Set.of(
            "java.", "javax.", "jakarta.",
            "org.springframework.", "org.slf4j.", "org.apache.",
            "com.baomidou.", "lombok.", "com.fasterxml."
    );

    @Override
    public String getName() {
        return "Java-Spring-Doc-Enhanced";
    }

    @Override
    public boolean canParse(String projectDir) {
        Path rootPath = Paths.get(projectDir);
        if (!Files.exists(rootPath) || !Files.isDirectory(rootPath)) return false;
        try (Stream<Path> stream = Files.walk(rootPath, 2)) {
            return stream.filter(Files::isRegularFile).anyMatch(p -> {
                String name = p.getFileName().toString();
                return name.equals("pom.xml") || name.equals("build.gradle") || name.equals("build.gradle.kts");
            });
        } catch (IOException e) {
            return false;
        }
    }

    @Override
    public ProjectAnalysisResult parse(String projectDir) {
        log.info("Starting Analysis with Javadoc Extraction: {}", projectDir);
        JavaParser javaParser = initializeSymbolSolver(projectDir);
        List<ArchNode> rawNodes = new ArrayList<>();
        List<ArchRelationship> rawRelationships = new ArrayList<>();
        Set<String> processedClasses = new HashSet<>();

        try {
            List<Path> javaFiles = findJavaFiles(projectDir);
            for (Path javaFile : javaFiles) {
                try {
                    CompilationUnit cu = javaParser.parse(javaFile).getResult().orElse(null);
                    if (cu == null) continue;

                    String packageName = cu.getPackageDeclaration().map(pd -> pd.getNameAsString()).orElse("");

                    cu.findAll(ClassOrInterfaceDeclaration.class).forEach(clazz -> {
                        String className = clazz.getNameAsString();
                        if (isIgnoredClass(className)) return;

                        String fullClassName = getFullyQualifiedName(clazz, packageName);
                        if (processedClasses.contains(fullClassName)) return;

                        ArchNode node = analyzeClass(clazz, fullClassName);
                        if (node != null) rawNodes.add(node);

                        rawRelationships.addAll(analyzeRelationships(clazz, fullClassName, cu));
                        processedClasses.add(fullClassName);
                    });

                } catch (Exception e) {
                    log.warn("Parse error: {}", e.getMessage());
                }
            }
        } catch (IOException e) {
            log.error("IO Error", e);
        }

        return optimizeResult(rawNodes, rawRelationships);
    }

    private ArchNode analyzeClass(ClassOrInterfaceDeclaration clazz, String id) {
        ArchNode node = new ArchNode();
        node.setId(id);
        node.setName(clazz.getNameAsString());

        // 1. 提取 Javadoc 注释 (新增功能)
        String comment = extractClassComment(clazz);

        String type = "Class";
        String stereotype = "Model";

        if (clazz.isInterface()) type = "Interface";

        List<String> annotations = clazz.getAnnotations().stream().map(a -> a.getNameAsString()).collect(Collectors.toList());

        if (clazz.getNameAsString().endsWith("Mapper") || annotations.contains("Mapper")) {
            type = "Interface";
            stereotype = "Data Layer";
        }

        for (Map.Entry<String, String> entry : STEREOTYPE_MAPPING.entrySet()) {
            if (annotations.stream().anyMatch(a -> a.contains(entry.getKey()))) {
                type = entry.getKey().toUpperCase();
                stereotype = entry.getValue();
                break;
            }
        }

        node.setType(type);
        node.setStereotype(stereotype);

        // 2. 构建 Description (合并 注释 + 技术细节)
        List<String> descParts = new ArrayList<>();

        // Part A: 中文注释
        if (comment != null && !comment.isEmpty()) {
            descParts.add(comment);
        }

        // Part B: 技术细节 (API 路由 或 表名)
        if ("API Layer".equals(stereotype)) {
            List<String> routes = extractApiRoutes(clazz);
            if (!routes.isEmpty()) {
                descParts.add("APIs:\n" + String.join("\n", routes));
            }
        } else if (clazz.getNameAsString().endsWith("Entity") || annotations.contains("TableName")) {
            extractTableName(clazz).ifPresent(t -> descParts.add("Table: " + t));
        }

        if (!descParts.isEmpty()) {
            node.setDescription(String.join("\n\n", descParts));
        }

        node.setFields(Collections.emptyList());
        node.setMethods(Collections.emptyList());

        return node;
    }

    /**
     * 新增:提取并清洗 Javadoc
     */
    private String extractClassComment(ClassOrInterfaceDeclaration clazz) {
        return clazz.getComment()
                .map(Comment::getContent)
                .map(this::cleanJavadoc)
                .orElse("");
    }

    /**
     * 清洗 Javadoc:去除 * 号、@author 等标签
     */
    private String cleanJavadoc(String content) {
        if (content == null) return "";
        String[] lines = content.split("\n");
        StringBuilder sb = new StringBuilder();

        for (String line : lines) {
            // 去除开头的 * 和空格
            String cleanLine = line.trim().replaceAll("^\*+\s?", "").trim();

            // 遇到 @ 标签(如 @author, @date)停止读取,或者跳过
            // 这里策略是:只读取第一段描述,遇到 @ 就停止,通常第一段是核心描述
            if (cleanLine.startsWith("@")) {
                break;
            }
            // 忽略空行
            if (!cleanLine.isEmpty()) {
                sb.append(cleanLine).append(" "); // 将多行描述合并为一行
            }
        }
        return sb.toString().trim();
    }

    private ProjectAnalysisResult optimizeResult(List<ArchNode> nodes, List<ArchRelationship> relationships) {
        Set<String> validNodeIds = nodes.stream().map(ArchNode::getId).collect(Collectors.toSet());
        List<ArchRelationship> cleanRelationships = relationships.stream()
                .filter(r -> validNodeIds.contains(r.getSourceId()) && validNodeIds.contains(r.getTargetId()))
                .filter(r -> !r.getSourceId().equals(r.getTargetId()))
                .collect(Collectors.toList());

        Set<String> connectedNodes = new HashSet<>();
        cleanRelationships.forEach(r -> {
            connectedNodes.add(r.getSourceId());
            connectedNodes.add(r.getTargetId());
        });

        List<ArchNode> cleanNodes = nodes.stream()
                .filter(n -> {
                    if ("API Layer".equals(n.getStereotype())) return true;
                    return connectedNodes.contains(n.getId());
                })
                .collect(Collectors.toList());

        ProjectAnalysisResult result = new ProjectAnalysisResult();
        result.setNodes(cleanNodes);
        result.setRelationships(cleanRelationships);
        return result;
    }

    private List<ArchRelationship> analyzeRelationships(ClassOrInterfaceDeclaration clazz, String sourceId, CompilationUnit cu) {
        List<ArchRelationship> rels = new ArrayList<>();
        clazz.getExtendedTypes().forEach(t -> addRel(rels, sourceId, resolveType(t, cu), "EXTENDS"));
        clazz.getImplementedTypes().forEach(t -> addRel(rels, sourceId, resolveType(t, cu), "IMPLEMENTS"));
        clazz.getFields().forEach(field -> {
            if (field.isAnnotationPresent("Autowired") || field.isAnnotationPresent("Resource") || field.isAnnotationPresent("Inject")) {
                field.getVariables().forEach(v -> addRel(rels, sourceId, resolveType(field.getElementType(), cu), "DEPENDS_ON"));
            }
        });
        clazz.getConstructors().forEach(c -> c.getParameters().forEach(p -> addRel(rels, sourceId, resolveType(p.getType(), cu), "DEPENDS_ON")));
        return rels;
    }

    private boolean isIgnoredClass(String className) {
        return IGNORED_SUFFIXES.stream().anyMatch(className::endsWith);
    }

    private boolean isIgnoredPackage(String typeName) {
        if (typeName == null) return true;
        return IGNORED_PACKAGES.stream().anyMatch(typeName::startsWith);
    }

    private void addRel(List<ArchRelationship> rels, String src, String target, String type) {
        if (target == null || target.equals(src) || isIgnoredPackage(target)) return;
        ArchRelationship rel = new ArchRelationship();
        rel.setSourceId(src);
        rel.setTargetId(target);
        rel.setType(type);
        rels.add(rel);
    }

    private JavaParser initializeSymbolSolver(String projectDir) {
        CombinedTypeSolver combinedSolver = new CombinedTypeSolver();
        combinedSolver.add(new ReflectionTypeSolver());
        Path srcPath = Paths.get(projectDir, "src", "main", "java");
        if (Files.exists(srcPath)) {
            combinedSolver.add(new JavaParserTypeSolver(srcPath));
        } else {
            combinedSolver.add(new JavaParserTypeSolver(new File(projectDir)));
        }
        ParserConfiguration config = new ParserConfiguration();
        config.setSymbolResolver(new JavaSymbolSolver(combinedSolver));
        return new JavaParser(config);
    }

    private String resolveType(ClassOrInterfaceType type, CompilationUnit cu) {
        try {
            return type.resolve().asReferenceType().getQualifiedName();
        } catch (Exception e) {
            return inferFullyQualifiedNameFromImports(type.getNameAsString(), cu);
        }
    }

    private String resolveType(com.github.javaparser.ast.type.Type type, CompilationUnit cu) {
        try {
            if (type.isClassOrInterfaceType()) return type.asClassOrInterfaceType().resolve().asReferenceType().getQualifiedName();
            return null;
        } catch (Exception e) {
            return inferFullyQualifiedNameFromImports(type.asString(), cu);
        }
    }

    private String inferFullyQualifiedNameFromImports(String simpleName, CompilationUnit cu) {
        Optional<ImportDeclaration> match = cu.getImports().stream()
                .filter(i -> !i.isAsterisk() && i.getNameAsString().endsWith("." + simpleName))
                .findFirst();
        if (match.isPresent()) return match.get().getNameAsString();
        String packageName = cu.getPackageDeclaration().map(pd -> pd.getNameAsString()).orElse("");
        return packageName.isEmpty() ? simpleName : packageName + "." + simpleName;
    }

    private String getFullyQualifiedName(ClassOrInterfaceDeclaration clazz, String packageName) {
        return packageName.isEmpty() ? clazz.getNameAsString() : packageName + "." + clazz.getNameAsString();
    }

    private List<String> extractApiRoutes(ClassOrInterfaceDeclaration clazz) {
        List<String> routes = new ArrayList<>();
        String basePath = "";
        Optional<AnnotationExpr> classMapping = clazz.getAnnotationByName("RequestMapping");
        if (classMapping.isPresent()) basePath = extractPath(classMapping.get());
        String finalBasePath = basePath;
        clazz.getMethods().forEach(m -> {
            Stream.of("GetMapping", "PostMapping", "PutMapping", "DeleteMapping", "RequestMapping").forEach(methodType -> {
                m.getAnnotationByName(methodType).ifPresent(ann -> {
                    String methodPath = extractPath(ann);
                    String httpMethod = methodType.replace("Mapping", "").toUpperCase();
                    if ("REQUEST".equals(httpMethod)) httpMethod = "ALL";
                    routes.add("[" + httpMethod + "] " + (finalBasePath + methodPath).replaceAll("//", "/"));
                });
            });
        });
        return routes;
    }

    private String extractPath(AnnotationExpr ann) {
        if (ann.isSingleMemberAnnotationExpr()) {
            return ann.asSingleMemberAnnotationExpr().getMemberValue().toString().replace(""", "");
        } else if (ann.isNormalAnnotationExpr()) {
            return ann.asNormalAnnotationExpr().getPairs().stream()
                    .filter(p -> p.getNameAsString().equals("value") || p.getNameAsString().equals("path"))
                    .findFirst().map(p -> p.getValue().toString().replace(""", "")).orElse("");
        }
        return "";
    }

    private Optional<String> extractTableName(ClassOrInterfaceDeclaration clazz) {
        return clazz.getAnnotationByName("TableName").map(this::extractPath)
                .or(() -> clazz.getAnnotationByName("Table").map(this::extractPath));
    }

    private List<Path> findJavaFiles(String projectPath) throws IOException {
        try (Stream<Path> paths = Files.walk(Paths.get(projectPath))) {
            return paths.filter(Files::isRegularFile).filter(p -> p.toString().endsWith(".java")).collect(Collectors.toList());
        }
    }
}

SQL 语句解析

在实现SQL解析功能时,我选择了使用阿里巴巴开源的Druid(德鲁伊)项目中的SQLUtil模块。这一选择是基于Druid在SQL解析领域的强大功能和良好性能,同时其丰富的API和活跃的开源社区也为项目开发提供了有力支持。
在具体实现上,我采用了插件化(Service Provider Interface, SPI)的设计模式。通过定义统一的接口规范,

解析器代码如下

package com.wfh.drawio.spi.parser;

import com.alibaba.druid.sql.SQLUtils;
import com.alibaba.druid.sql.ast.SQLStatement;
import com.alibaba.druid.sql.ast.statement.*;
import com.alibaba.druid.util.JdbcConstants;
import com.wfh.drawio.core.model.ArchNode;
import com.wfh.drawio.core.model.ArchRelationship;
import com.wfh.drawio.core.model.ProjectAnalysisResult;
import com.wfh.drawio.spi.LanguageParser;
import lombok.extern.slf4j.Slf4j;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * @Title: SqlParser
 * @Author wangfenghuan
 * @description: 优化版 SQL 解析器 (已修复 Lambda 变量 Final 问题)
 */
@Slf4j
public class SqlParser implements LanguageParser {

    private static final Set<String> IGNORED_COLUMNS = Set.of(
            "create_time", "update_time", "create_at", "update_at",
            "created_time", "updated_time", "created_at", "updated_at",
            "is_delete", "is_deleted", "version", "revision"
    );

    @Override
    public String getName() {
        return "SQL-DDL-Enhanced";
    }

    @Override
    public boolean canParse(String projectDir) {
        File file = new File(projectDir);
        if (!file.exists()) {
            return false;
        }
        if (file.isFile()) {
            return file.getName().toLowerCase().endsWith(".sql");
        }
        try (Stream<Path> paths = Files.walk(Paths.get(projectDir), 3)) {
            return paths.anyMatch(p -> p.toString().toLowerCase().endsWith(".sql"));
        } catch (IOException e) {
            return false;
        }
    }

    @Override
    public ProjectAnalysisResult parse(String projectDir) {
        log.info("Starting SQL analysis: {}", projectDir);
        ProjectAnalysisResult result = new ProjectAnalysisResult();
        List<ArchNode> nodes = new ArrayList<>();
        List<ArchRelationship> relationships = new ArrayList<>();
        Set<String> tableNames = new HashSet<>();

        try {
            List<Path> sqlFiles = findSqlFiles(projectDir);
            for (Path sqlFile : sqlFiles) {
                String content = Files.readString(sqlFile);
                parseSqlContent(content, nodes, relationships, tableNames);
            }
            inferLogicalRelationships(nodes, relationships, tableNames);
        } catch (Exception e) {
            log.error("Error parsing SQL files", e);
        }

        result.setNodes(nodes);
        result.setRelationships(relationships);
        return result;
    }

    private void parseSqlContent(String content, List<ArchNode> nodes, List<ArchRelationship> relationships, Set<String> tableNames) {
        List<SQLStatement> statements = parseWithFallback(content);
        for (SQLStatement stmt : statements) {
            if (stmt instanceof SQLCreateTableStatement) {
                SQLCreateTableStatement createTable = (SQLCreateTableStatement) stmt;
                ArchNode node = processCreateTable(createTable, relationships);
                if (node != null) {
                    nodes.add(node);
                    tableNames.add(node.getId());
                }
            }
        }
    }

    private List<SQLStatement> parseWithFallback(String content) {
        try {
            return SQLUtils.parseStatements(content, JdbcConstants.MYSQL);
        } catch (Exception e1) {
            try {
                return SQLUtils.parseStatements(content, JdbcConstants.POSTGRESQL);
            } catch (Exception e2) {
                try {
                    return SQLUtils.parseStatements(content, JdbcConstants.ORACLE);
                } catch (Exception e3) {
                    return Collections.emptyList();
                }
            }
        }
    }

    private ArchNode processCreateTable(SQLCreateTableStatement createTable, List<ArchRelationship> relationships) {
        String tableName = cleanName(createTable.getTableName());
        ArchNode node = new ArchNode();
        node.setId(tableName);
        node.setName(tableName);
        node.setType("TABLE");
        node.setStereotype("Database Table");

        if (createTable.getComment() != null) {
            node.setDescription(cleanComment(createTable.getComment().toString()));
        }

        List<String> fields = new ArrayList<>();

        for (SQLTableElement element : createTable.getTableElementList()) {
            if (element instanceof SQLColumnDefinition) {
                SQLColumnDefinition column = (SQLColumnDefinition) element;
                String colName = cleanName(column.getName().getSimpleName());

                if (IGNORED_COLUMNS.contains(colName.toLowerCase())) {
                    continue;
                }

                String colType = column.getDataType().getName();
                StringBuilder fieldStr = new StringBuilder(colName).append(": ").append(colType);

                if (column.isPrimaryKey()) {
                    fieldStr.append(" (PK)");
                } else if (createTable.findColumn(colName) != null && isPrimaryKeyInConstraints(createTable, colName)) {
                    fieldStr.append(" (PK)");
                }

                if (column.getComment() != null) {
                    String comment = cleanComment(column.getComment().toString());
                    if (!comment.isEmpty()) {
                        fieldStr.append(" // ").append(comment);
                    }
                }
                fields.add(fieldStr.toString());

            } else if (element instanceof SQLForeignKeyConstraint) {
                SQLForeignKeyConstraint fk = (SQLForeignKeyConstraint) element;
                String targetTable = cleanName(fk.getReferencedTableName().getSimpleName());
                ArchRelationship rel = new ArchRelationship();
                rel.setSourceId(tableName);
                rel.setTargetId(targetTable);
                rel.setType("FOREIGN_KEY");
                String colName = fk.getReferencingColumns().stream()
                        .map(c -> cleanName(c.getSimpleName()))
                        .collect(Collectors.joining(","));
                rel.setLabel("FK: " + colName);
                relationships.add(rel);
            }
        }
        node.setFields(fields);
        node.setMethods(Collections.emptyList());
        return node;
    }

    /**
     * 修复点:inferLogicalRelationships 方法
     * 引入 finalTarget 变量,解决 Lambda 表达式报错
     */
    private void inferLogicalRelationships(List<ArchNode> nodes, List<ArchRelationship> relationships, Set<String> allTables) {
        for (ArchNode node : nodes) {
            if (node.getFields() == null) continue;

            for (String fieldStr : node.getFields()) {
                String colName = fieldStr.split(":")[0].trim();

                if (colName.toLowerCase().endsWith("_id")) {
                    String potentialTableName = colName.substring(0, colName.length() - 3);

                    String tempTarget = matchTable(potentialTableName, allTables);
                    if (tempTarget == null) {
                        tempTarget = matchTable(potentialTableName + "s", allTables);
                    }

                    // ✅ 关键修复:将可能变化的 tempTarget 赋值给一个 final 变量
                    String finalTarget = tempTarget;

                    if (finalTarget != null && !finalTarget.equals(node.getId())) {
                        // 在 Lambda 中只使用 finalTarget
                        boolean exists = relationships.stream().anyMatch(r ->
                                r.getSourceId().equals(node.getId()) && r.getTargetId().equals(finalTarget)
                        );

                        if (!exists) {
                            ArchRelationship rel = new ArchRelationship();
                            rel.setSourceId(node.getId());
                            rel.setTargetId(finalTarget);
                            rel.setType("LOGICAL_KEY");
                            rel.setLabel("Link: " + colName);
                            relationships.add(rel);
                        }
                    }
                }
            }
        }
    }

    private String matchTable(String guess, Set<String> tables) {
        for (String table : tables) {
            if (table.equalsIgnoreCase(guess)) return table;
        }
        return null;
    }

    private boolean isPrimaryKeyInConstraints(SQLCreateTableStatement table, String colName) {
        if (table.getTableElementList() == null) return false;
        for (SQLTableElement element : table.getTableElementList()) {
            if (element instanceof SQLPrimaryKey) {
                SQLPrimaryKey pk = (SQLPrimaryKey) element;
                return pk.getColumns().stream().anyMatch(c -> cleanName(c.getExpr().toString()).equals(colName));
            }
        }
        return false;
    }

    private String cleanName(String name) {
        if (name == null) return "";
        return name.replace("`", "").replace(""", "").replace("'", "").trim();
    }

    private String cleanComment(String comment) {
        if (comment == null) return "";
        return comment.replace("'", "").trim();
    }

    private List<Path> findSqlFiles(String projectPath) throws IOException {
        Path startPath = Paths.get(projectPath);
        if (Files.isRegularFile(startPath) && startPath.toString().toLowerCase().endsWith(".sql")) {
            return List.of(startPath);
        }
        try (Stream<Path> paths = Files.walk(startPath)) {
            return paths
                    .filter(Files::isRegularFile)
                    .filter(path -> path.toString().toLowerCase().endsWith(".sql"))
                    .collect(Collectors.toList());
        }
    }
}

刚才定义的一系列 DTO 是对于任何的图表都适用的,因为节点和连线就是构成一个不管是结构图还有 ER 图的主要元素。

好了,本期的分享就到这里。欢迎各位贡献代码

GitHub 仓库

前端

github.com/wangfenghua…

后端

github.com/wangfenghua…