别再手撸架构图了!我写了个 AI 工具,把 Spring Boot 代码一键变成 Draw.io 流程图
前言
在之前的博客中,我曾介绍过一款我自主研发的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 依赖如下
▼text复制代码<!-- 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示例解析
- 加法运算的AST结构
代码:int x = 10 + 10;
AST分解:
○ 变量声明节点:int x
○ 赋值操作节点:=
○ 加法运算节点:+,包含两个子节点10和10 - If语句的AST结构
代码:if (a > 10) { break; }
AST分解:
○ 条件判断节点:if
○ 条件表达式节点:a > 10
○ 代码块节点:break;
JavaParser在Spring Boot项目中的应用
使用JavaParser库解析整个Spring Boot项目时,需要解析以下内容: - 包和类结构
○ 包名
○ 类名
○ 类的修饰符(如public、final) - 类成员
○ 方法:包括方法名、参数、返回类型、访问修饰符
○ 字段:包括字段名、类型、访问修饰符 - 注解
○ 特别是Spring相关的注解,如@Component、@Service、@Autowired等,用于识别Spring Bean及其依赖关系 - 依赖关系
○ 通过注解和依赖注入机制,解析类之间的依赖关系,如@Autowired注入的字段或方法参数 - 调用关系
○ 分析方法之间的调用,构建方法调用链路
生成项目架构图所需的AST特征
为了生成清晰的项目架构图,AST需要包含以下信息: - 模块划分
○ 根据包名或注解信息,将项目划分为不同的模块或层(如Controller、Service、Repository) - 层次结构
○ 展示模块之间的层次关系,如分层架构(表现层、业务逻辑层、数据访问层) - 依赖关系
○ 通过注解和依赖注入信息,展示类之间的依赖关系,帮助识别高耦合模块 - 调用链路
○ 展示方法之间的调用顺序,帮助理解功能流程 - 接口和实现
○ 展示接口与其实现类之间的关系,帮助理解抽象与具体实现的对应
这里我设计了几个通用的 DTO
▼text复制代码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; // 关键方法 }
这个就类似于架构图中的一个节点,或者说是一个服务。
▼text复制代码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") }
这个就是架构图中的连线,放到代码中就是服务之间的相互调用之类的。
▼text复制代码/** * 3. 完整的分析结果 * @author fenghuanwang */ @Data public class ProjectAnalysisResult { private List<ArchNode> nodes; private List<ArchRelationship> relationships; }
而这个就是完整的分析结果了,也就是要返回给前端的东西
那这个 Java 解析器具体代码逻辑是怎么样的呢
下面是完整的代码,这段代码是使用了 Java 的 SPI 机制实现的,具体什么是 SPI 机制,可以去我的上一篇文章中寻找
▼text复制代码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)的设计模式。通过定义统一的接口规范,
解析器代码如下
▼text复制代码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 仓库
前端
https://github.com/wangfenghuan/ai-draw-io-fronted
