Ohhnews

分类导航

$ cd ..
foojay原文

基于Spring AI与MongoDB的AI代码审查助手构建指南

#人工智能#代码审查#spring ai#mongodb#向量搜索

目录

前提条件 1. 项目设置 2. 存储与管理审查模式

3. 使用 Spring AI 和 MongoDB Atlas 向量搜索嵌入模式

4. 构建代码审查引擎

5. 使用聚合管道追踪审查趋势 6. 测试完整工作流 结论

代码审查可以在代码发布前捕获错误,但非常耗时。大多数团队依赖人工审查或基础的 Linter,这些工具只能标记语法问题,却难以发现深层次的隐患,如细微的资源泄露、糟糕的异常处理或安全反模式。静态分析工具虽有帮助,但其规则过于僵化,无法泛化到各种代码变体中。例如,能够捕获 catch (Exception e) {} 的规则,却往往会漏掉 catch (Throwable t) { return null; },尽管它们本质上是同一个问题。

在本文中,你将构建一个代码审查助手 API。开发人员通过 REST 接口提交代码片段,系统利用 Spring AI 对代码进行嵌入处理,并在存储于 MongoDB Atlas 中的向量化反模式库中进行搜索。随后,系统将代码连同匹配到的模式发送给大语言模型(LLM),以获取结构化的审查反馈。每次提交及其审查结果都会存储在 MongoDB 中,并通过聚合管道随时间推移展示趋势。

技术栈包括:Java 21+、Spring Boot 3.x、Spring AI、Spring Data MongoDB 和 MongoDB Atlas。到最后,你将拥有一个功能完备的审查 API,它能接收代码、利用 Atlas 向量搜索查找相关反模式、从 LLM 获取结构化反馈,并追踪所有提交的审查结果。完整的源代码可在 GitHub 上的配套仓库 中获取。

前提条件

  • Java 21 或更高版本
  • Spring Boot 3.x(使用 Spring Initializr 添加 Spring Data MongoDBSpring Web 依赖;Spring AI 将在本文后续章节中手动添加)
  • MongoDB Atlas 集群(免费层级即可,你需要它来实现 Atlas 向量搜索)。你可以按照 MongoDB Atlas 入门指南 进行设置。
  • OpenAI API Key(用于嵌入模型和聊天模型)
  • 熟悉 Spring Boot 的基础知识(控制器、服务、依赖注入)

1. 项目设置

前往 Spring Initializr 生成一个新项目。我使用了以下设置,你可以根据需要更改 Group 名称:

  • Group: dev.farhan
  • Artifact: code-review-assistant
  • Java 版本: 21
  • 依赖: Spring Web, Spring Data MongoDB

你将在第 3 节手动添加 Spring AI 依赖。目前,项目仅需 Web 和 MongoDB 支持。

打开 application.properties 并配置 MongoDB 连接:

$ properties
spring.data.mongodb.uri=mongodb+srv://<username>:<password>@<cluster>.mongodb.net/code-review-assistant?appName=devrel-article-java-springai-foojay

将占位符替换为你的 Atlas 集群凭据。appName 查询参数有助于 MongoDB 跟踪连接的应用程序,这对监控非常有用。如果你在本地运行 MongoDB,请改用 mongodb://localhost:27017/code-review-assistant?appName=devrel-article-java-springai-foojay

配套仓库中包含了完整的项目结构。你可以克隆它并跟随文章操作,也可以在阅读过程中从零开始构建每个部分。

2. 存储与管理审查模式

审查助手的工作原理是将提交的代码与已知的反模式库进行对比。在进行任何对比之前,你需要定义反模式的结构、将其存储在 MongoDB 中,并公开用于添加和列出模式的接口。

定义模式模型

审查结果会有严重程度等级,因此首先将其定义为 Java 枚举。枚举是一种将值限制为一组固定选项的类型,这可以防止无效的严重程度字符串进入系统:

$ java
public enum Severity {
    CRITICAL, WARNING, INFO
}

CRITICAL 用于会导致 Bug 或安全漏洞的问题;WARNING 用于在特定条件下可能引发问题的情况;INFO 用于改进代码质量但不紧急的建议。

接下来,定义 ReviewPattern 类。这是表示库中单个反模式的文档。@Document 注解告诉 Spring Data MongoDB 该类映射到哪个集合,@Id 标记了 MongoDB 用作文档唯一标识符的字段:

$ java
@Document(collection = "review_patterns")
public class ReviewPattern {

    @Id
    private String id;
    private String name;
    private String description;
    private String language;
    private Severity severity;
    private String category;
    private String exampleBadCode;
    private String exampleGoodCode;
    private String explanation;

    // 为简洁起见,省略了构造函数、Getter 和 Setter
}

每个模式都有一个 name(如“空 catch 块”)、用通俗语言解释问题的 description,以及用于按编程语言筛选模式的 language 字段。category 字段将相关问题归类(例如,“安全”或“错误处理”)。exampleBadCodeexampleGoodCode 字段展示了问题及修复方案,explanation 则描述了为何坏代码是有问题的。

你将在第 3 节设置向量搜索时为该类添加 embedding 字段。目前,文本字段足以定义模式库。

每个模式的 id 是像 unclosed-resourceshardcoded-credentials 这样可读性强的别名,在创建时设定,而不是自动生成的 ObjectId。当有大量并发写入或需要按时间排序索引时,ObjectId 很有用,但对于小型管理员维护的模式库来说,这不是问题。别名使审查结果在 Shell 中更易读,并为 LLM 提供了一个有意义的标签,以便在 matchedPatternId 中回传。

以下是两个 JSON 文档示例。第一个描述了常见的错误处理问题——空 catch 块:

$ cat
{
  "_id": "empty-catch-block",
  "name": "Empty catch block",
  "description": "Catching an exception and doing nothing with it, silently swallowing errors",
  "language": "java",
  "severity": "CRITICAL",
  "category": "error-handling",
  "exampleBadCode": "try { connection.close(); } catch (SQLException e) { }",
  "exampleGoodCode": "try { connection.close(); } catch (SQLException e) { logger.warn(\"Failed to close: {}\", e.getMessage()); }",
  "explanation": "Empty catch blocks silently swallow errors. When something fails, there is no log entry and no way to diagnose the problem."
}

第二个描述了硬编码凭据,这是一种安全反模式:

$ cat
{
  "_id": "hardcoded-credentials",
  "name": "Hardcoded credentials",
  "description": "Storing passwords, API keys, or secrets as string literals in source code",
  "language": "java",
  "severity": "CRITICAL",
  "category": "security",
  "exampleBadCode": "private static final String DB_PASSWORD = \"s3cretP@ss!\";",
  "exampleGoodCode": "@Value(\"${db.password}\") private String dbPassword;",
  "explanation": "Hardcoded credentials end up in version control and build artifacts. Use environment variables or a secrets manager."
}

每个 JSON 文档直接映射到 ReviewPattern 类中的字段。当你通过 API 保存这些数据时,Spring Data MongoDB 会将 Java 对象转换为具有相同结构的文档并存储在 review_patterns 集合中。

创建存储库

要从 MongoDB 读取和写入模式,你需要一个存储库接口。在 Spring Data 中,存储库是一个无需编写实现代码即可提供数据库操作的接口。你只需按照特定的命名约定声明方法,Spring 就会在运行时生成查询逻辑:

$ java
public interface ReviewPatternRepository extends MongoRepository<ReviewPattern, String> {

    List<ReviewPattern> findByLanguage(String language);

    List<ReviewPattern> findByCategory(String category);

    List<ReviewPattern> findByLanguageAndCategory(String language, String category);

    List<ReviewPattern> findBySeverity(Severity severity);
}

通过继承 MongoRepository<ReviewPattern, String>,该接口继承了 save()findById()findAll()deleteById() 等标准操作。两个泛型参数告知 Spring 该存储库管理 ReviewPattern 文档,且 ID 字段为 String 类型。

自定义方法使用了 Spring Data 的派生查询功能。findByLanguage("java") 会转换为一个 MongoDB 查询,过滤出 language 字段等于 "java" 的文档。findByLanguageAndCategory 将两个过滤器通过 AND 条件组合。你无需在此编写任何 MongoDB 查询语法。Spring 会解析方法名称,识别字段名称和运算符(And),并为你构建查询。

构建服务层

服务类包含创建和检索模式的业务逻辑。@Service 注解将其标记为受 Spring 管理的组件,这意味着 Spring 将创建该类的一个实例,并使其可供注入到其他组件中:

$ java
@Service
public class ReviewPatternService {

    private final ReviewPatternRepository patternRepository;

    public ReviewPatternService(ReviewPatternRepository patternRepository) {
        this.patternRepository = patternRepository;
    }

    public ReviewPattern createPattern(CreatePatternRequest request) {
        ReviewPattern pattern = new ReviewPattern(
                request.id(), request.name(), request.description(), request.language(),
                request.severity(), request.category(),
                request.exampleBadCode(), request.exampleGoodCode(),
                request.explanation()
        );
        return patternRepository.save(pattern);
    }

    public List<ReviewPattern> listPatterns(String language, String category) {
        if (language != null && category != null) {
            return patternRepository.findByLanguageAndCategory(language, category);
        }
        if (language != null) {
            return patternRepository.findByLanguage(language);
        }
        if (category != null) {
            return patternRepository.findByCategory(category);
        }
        return patternRepository.findAll();
    }

    public Optional<ReviewPattern> getPattern(String id) {
        return patternRepository.findById(id);
    }
}

构造函数接收一个 ReviewPatternRepository 作为参数。Spring 会自动注入它创建的存储库实例。这种模式称为构造函数注入,是 Spring Boot 中连接依赖项的推荐方式。

createPattern 方法从请求中构建一个 ReviewPattern 并保存到 MongoDB。listPatterns 方法处理可选的过滤逻辑。getPattern 方法返回一个 Optional<ReviewPattern>,迫使调用者处理未找到模式的情况,避免空指针异常。

CreatePatternRequest 是一个 Java 记录(Record),用于映射传入的 JSON 请求体。记录是定义不可变数据载体的简洁方式:

$ java
public record CreatePatternRequest(
        String id, String name, String description, String language,
        Severity severity, String category,
        String exampleBadCode, String exampleGoodCode, String explanation
) {}

公开 REST 接口

控制器类将 HTTP 请求映射到服务方法。@RestController 注解告诉 Spring 该类处理 Web 请求,且每个方法的返回值都应直接序列化为响应体。

$ java
@RestController
@RequestMapping("/api/patterns")
public class ReviewPatternController {

    private final ReviewPatternService patternService;

    public ReviewPatternController(ReviewPatternService patternService) {
        this.patternService = patternService;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public ReviewPattern createPattern(@RequestBody CreatePatternRequest request) {
        return patternService.createPattern(request);
    }

    @GetMapping
    public List<ReviewPattern> listPatterns(
            @RequestParam(required = false) String language,
            @RequestParam(required = false) String category) {
        return patternService.listPatterns(language, category);
    }

    @GetMapping("/{id}")
    public ReviewPattern getPattern(@PathVariable String id) {
        return patternService.getPattern(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    }
}

@PostMapping 处理到 /api/patterns 的 POST 请求。@RequestBody 将 JSON 请求体反序列化为 CreatePatternRequest 记录。@ResponseStatus(HttpStatus.CREATED) 将默认响应码从 200 改为 201。

@GetMapping 处理 GET 请求。@RequestParam(required = false) 绑定 URL 查询参数。@PathVariable 从 URL 路径中提取 id 值。如果服务返回空的 OptionalorElseThrow 会将其转换为 404 响应。

你可以通过以下命令手动添加一个模式进行测试:

$ bash
curl -X POST http://localhost:8080/api/patterns \
  -H "Content-Type: application/json" \
  -d '{
    "id": "empty-catch-block",
    "name": "Empty catch block",
    "description": "Catching an exception and doing nothing with it",
    "language": "java",
    "severity": "CRITICAL",
    "category": "error-handling",
    "exampleBadCode": "try { conn.close(); } catch (SQLException e) { }",
    "exampleGoodCode": "try { conn.close(); } catch (SQLException e) { logger.warn(\"Close failed\", e); }",
    "explanation": "Empty catch blocks silently swallow errors."
  }'

这适用于逐个添加模式,但系统在加载完整库时更有用。下一节将添加数据填充器(Data Seeder)以及使模式匹配工作的嵌入和向量搜索功能。## 3. 使用 Spring AI 和 MongoDB Atlas 向量搜索嵌入模式

假设开发者编写了 InputStream is = new FileInputStream(path); 而没有使用 try-with-resources 代码块。你的模式库中描述了“try 块中未关闭的资源”,但使用的代码示例是 FileReader。虽然根本问题是一样的,但代码看起来不同。精确的字符串匹配无法将两者关联起来。这就是嵌入(Embeddings)发挥作用的地方。通过将存储的模式和提交的代码都转换为向量,无论语法上存在什么细微差别,你都可以衡量它们的语义相似度。

添加 Spring AI 依赖

Spring AI 通过物料清单(BOM)进行管理,这是一种特殊的依赖声明,用于锁定所有 Spring AI 模块的版本,以确保它们彼此兼容。将 BOM 和 OpenAI starter 添加到你的 pom.xml 中:

$ xml
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <!-- 现有依赖 -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
</dependencies>

spring-ai-starter-model-openai 依赖项不需要 <version> 标签。BOM 提供了版本,因此你只需在一个地方指定它。starter 会自动配置 EmbeddingModel Bean(用于生成向量)和 ChatClient.Builder Bean(用于调用大语言模型),你将在后续章节中使用它们。

然后将 OpenAI 配置添加到 application.properties

$ properties
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.embedding.options.model=text-embedding-3-small
spring.ai.openai.chat.options.model=gpt-4o-mini
spring.ai.openai.chat.options.temperature=0.2

${OPENAI_API_KEY} 语法会从环境变量中读取值,这样你就不会在配置文件中硬编码密钥。text-embedding-3-small 模型会生成 1536 维的向量,这意味着每段文本都被转换为包含 1536 个数字的数组,这些数字捕捉了其语义含义。较低的 temperature 设置(0.2)使代码审查输出具有确定性和一致性,这正是审查工具所需要的——对相似的代码给出相似的反馈。如果你想要更好的结果并且不介意更高的 API 成本,可以将 gpt-4o-mini 替换为其他模型。

生成嵌入

要为模式生成嵌入,你需要将其最具描述性的字段组合成一个文本块,并将其传递给嵌入模型。在 ReviewPattern 类中添加一个 embedding 字段和一个辅助方法:

$ java
@Document(collection = "review_patterns")
public class ReviewPattern {

    // ... 现有字段 ...

    private float[] embedding;

    public float[] getEmbedding() { return embedding; }
    public void setEmbedding(float[] embedding) { this.embedding = embedding; }

    public String buildEmbeddingText() {
        return description + " " + exampleBadCode + " " + explanation;
    }
}

embedding 字段存储嵌入模型生成的向量。它是一个 float[],因为每个维度都是一个浮点数。

buildEmbeddingText() 将描述、错误代码示例和解释连接成一个字符串。这为嵌入模型提供了足够的上下文来理解该模式的内容。该方法位于模型类中,因为服务层和数据种子生成器都需要构建此文本,将其放在这里意味着连接逻辑只需定义一次。如果以后决定在嵌入中包含模式名称或类别,只需更改此方法,而不必在多个地方进行更新。

现在更新 ReviewPatternService 以注入 EmbeddingModel,并在创建模式时生成嵌入:

$ java
@Service
public class ReviewPatternService {

    private final ReviewPatternRepository patternRepository;
    private final EmbeddingModel embeddingModel;

    public ReviewPatternService(ReviewPatternRepository patternRepository,
                                EmbeddingModel embeddingModel) {
        this.patternRepository = patternRepository;
        this.embeddingModel = embeddingModel;
    }

    public ReviewPattern createPattern(CreatePatternRequest request) {
        ReviewPattern pattern = new ReviewPattern(
                request.id(), request.name(), request.description(), request.language(),
                request.severity(), request.category(),
                request.exampleBadCode(), request.exampleGoodCode(),
                request.explanation()
        );

        pattern.setEmbedding(embeddingModel.embed(pattern.buildEmbeddingText()));

        return patternRepository.save(pattern);
    }

    // listPatterns 和 getPattern 保持不变
}

EmbeddingModel 是 Spring AI 的一个接口,由 OpenAI starter 自动配置。它的 embed() 方法将文本发送到 OpenAI 的嵌入 API,并返回一个包含 1536 个值的 float[]。每个值代表文本在模型向量空间中含义的一个维度。关于相似主题的两段文本将产生指向相似方向的向量,这就是语义搜索的实现原理。

为模式库播种数据

配套仓库中包含一个 DataSeeder 组件,它会在启动时加载约 20 个模式。它实现了 CommandLineRunner,这是一个具有单个 run 方法的 Spring Boot 接口。Spring Boot 会在应用程序上下文完全初始化后自动调用 run,使其成为执行加载种子数据等一次性设置任务的理想场所:

$ java
@Component
public class DataSeeder implements CommandLineRunner {

    private final ReviewPatternRepository patternRepository;
    private final EmbeddingModel embeddingModel;

    public DataSeeder(ReviewPatternRepository patternRepository,
                      EmbeddingModel embeddingModel) {
        this.patternRepository = patternRepository;
        this.embeddingModel = embeddingModel;
    }

    @Override
    public void run(String... args) {
        if (patternRepository.count() > 0) {
            return;
        }

        List<ReviewPattern> patterns = createPatterns();

        for (ReviewPattern pattern : patterns) {
            pattern.setEmbedding(embeddingModel.embed(pattern.buildEmbeddingText()));
        }

        patternRepository.saveAll(patterns);
    }

    private List<ReviewPattern> createPatterns() {
        List<ReviewPattern> patterns = new ArrayList<>();

        patterns.add(new ReviewPattern(
                "unclosed-resources",
                "Unclosed resources",
                "Opening a resource without using try-with-resources",
                "java", Severity.CRITICAL, "maintainability",
                "FileInputStream fis = new FileInputStream(\"config.properties\");\n"
                + "Properties props = new Properties();\n"
                + "props.load(fis);\nreturn props;",
                "try (FileInputStream fis = new FileInputStream(\"config.properties\")) {\n"
                + "    Properties props = new Properties();\n"
                + "    props.load(fis);\n    return props;\n}",
                "If an exception occurs between opening and closing a resource, "
                + "the close call never runs. This leaks file handles and connections."
        ));

        // ... 另外 19 个涵盖错误处理、安全性、性能和可维护性类别的模式 ...

        return patterns;
    }
}

run 方法以一个防护检查开始:patternRepository.count() > 0。如果集合中已经有数据,该方法会立即返回。这可以防止在应用程序重启时种子生成器重新生成嵌入或重新插入数据。

当集合为空时,该方法构建所有 20 个模式,然后遍历每一个以生成其嵌入。循环为每个模式调用一次 embeddingModel.embed(),将每个模式的文本发送到 OpenAI API。生成所有嵌入后,patternRepository.saveAll(patterns) 会在一次批处理操作中将所有模式写入 MongoDB,这比分次往返保存效率更高。

这 20 个模式的完整列表涵盖了错误处理(捕获通用异常、空 catch 块、吞掉 InterruptedException)、安全性(硬编码凭据、SQL 注入、记录敏感数据)、性能(循环中的字符串连接、N+1 查询、不必要的自动装箱)以及可维护性(未关闭资源、缺失空值检查、原始泛型)。完整列表可在配套仓库中找到。

创建 Atlas 向量搜索索引

在查询嵌入之前,你需要在 Atlas 中创建一个向量搜索索引。该索引告诉 MongoDB 如何高效地组织和搜索嵌入向量。

进入 Atlas UI 中的集群,选择 Atlas Search 选项卡,然后点击 Create Search Index。选择 Atlas Vector Search 作为索引类型,并选择 review_patterns 集合。在索引名称字段中输入 vector_index。你稍后编写的代码会通过此确切名称引用索引,因此不要使用自动生成的默认值。然后粘贴以下定义:

$ cat
{
  "fields": [
    {
      "type": "vector",
      "path": "embedding",
      "numDimensions": 1536,
      "similarity": "cosine"
    }
  ]
}

path 字段指向 embedding,这是你在 ReviewPattern 类中存储向量的位置。numDimensions 值必须与你的嵌入模型输出相匹配,对于 text-embedding-3-small 来说是 1536。如果这些值不匹配,搜索将会失败。

similarity 字段指定 MongoDB 如何测量向量之间的距离。余弦相似度(cosine similarity)测量两个向量之间的角度,而不考虑其大小,这使得它非常适合文本嵌入,因为向量的方向比其长度更重要。

搜索相似模式

有了索引后,你可以构建一个方法,用于查找与给定代码片段语义相似的模式。此方法接收一个查询向量(提交代码的嵌入)并针对模式集合运行 $vectorSearch 聚合。

MongoDB 中的聚合管道就像一条装配线。数据流经一系列阶段,每个阶段在将数据传递给下一个阶段之前对其进行转换。在此管道中,第一阶段查找相似向量,第二阶段为每个结果添加相似度分数,第三阶段从输出中删除大型嵌入数组:

$ java
private List<ReviewPattern> findSimilarPatterns(float[] queryVector, int limit) {
    List<Double> queryVectorList = new ArrayList<>();
    for (float f : queryVector) {
        queryVectorList.add((double) f);
    }

    Document vectorSearchStage = new Document("$vectorSearch",
            new Document("index", "vector_index")
                    .append("path", "embedding")
                    .append("queryVector", queryVectorList)
                    .append("numCandidates", 50)
                    .append("limit", limit));

    AggregationOperation vectorSearch = context -> vectorSearchStage;

    AggregationOperation addScore = context ->
            new Document("$addFields",
                    new Document("searchScore",
                            new Document("$meta", "vectorSearchScore")));

    AggregationOperation excludeEmbedding = context ->
            new Document("$project",
                    new Document("embedding", 0));

    Aggregation aggregation = Aggregation.newAggregation(vectorSearch, addScore, excludeEmbedding);

    AggregationResults<ReviewPattern> results =
            mongoTemplate.aggregate(aggregation, "review_patterns", ReviewPattern.class);

    return results.getMappedResults();
}

该方法首先将 float[] 查询向量转换为 List<Double>。此转换是必要的,因为 MongoDB Java 驱动程序期望 $vectorSearch 查询向量中使用双精度数字。

$vectorSearch 阶段是此方法的核心。它指定要使用的索引 (vector_index)、包含向量的字段 (embedding) 以及要进行比较的查询向量。numCandidates 参数控制 MongoDB 在选择最终结果之前在内部评估多少个候选文档。将其设置得高于 limit 可以让搜索算法有更多选择,从而提高准确性,代价是稍微增加处理时间。limit 参数控制返回的结果数量。

$addFields 阶段为每个结果添加了一个 searchScore 字段。$meta: "vectorSearchScore" 表达式提取了 MongoDB 在向量搜索期间计算的余弦相似度分数。此分数的范围从 0 到 1,其中 1 表示向量完全相同。你稍后会将此分数传递给大语言模型,以便它了解向量搜索对每个匹配项的置信度。

带有 "embedding": 0$project 阶段从结果中删除了嵌入数组。每个嵌入都是一个 1536 元素的数组,提示构建器并不需要它,因此如果不进行此排除,每次向量搜索都会为每个模式传输数千字节的未使用数据。

最后,mongoTemplate.aggregate() 针对 review_patterns 集合运行管道,并将每个结果文档映射回 ReviewPattern Java 对象。

为了保存 $addFields 注入的相似度分数,在 ReviewPattern 中添加一个 searchScore 字段,并用 @Transient 标记它:

$ java
@Transient
private double searchScore;

@Transient 注解告诉 Spring Data MongoDB 不要将此字段持久化到数据库中。searchScore 仅在向量搜索结果期间填充,在该上下文之外没有意义。如果没有 @Transient,保存通过向量搜索返回的模式会将陈旧的分数写入数据库。## 4. 构建代码审查引擎

ReviewService 是将各个组件连接起来的核心。它接收代码提交,通过向量搜索查找匹配的模式,将两者发送给大语言模型 (LLM),并将结构化的响应解析为审查结果。下图展示了从提交到响应的完整流程:

[LOADING...]

在构建服务之前,您还需要两个文档类:一个用于存储开发人员提交的代码,另一个用于存储审查引擎识别出的问题。

定义提交和结果模型

CodeSubmission 文档存储开发人员发送以供审查的每一段代码:

$ java
@Document(collection = "code_submissions")
public class CodeSubmission {

    @Id
    private String id;
    private String code;
    private String language;
    private String fileName;
    private String submittedBy;
    private Instant submittedAt;
    private List<String> findingIds;

    // 为简洁起见,省略了构造函数、getter 和 setter
}

code 字段保存开发人员提交的原始源代码。languagefileName 字段提供了关于代码类型的上下文。submittedAt 字段使用 Instant,它存储精确的 UTC 时间戳。findingIds 字段是对审查生成的 ReviewFinding 文档的引用列表。与其将审查结果嵌入到提交文档中,不如存储 ID,这样可以保持提交文档的小巧,并允许您独立查询审查结果。

ReviewFinding 文档存储审查引擎识别出的单个问题。每个结果都引用其父提交,并可选择性地引用其匹配的模式:

$ java
@Document(collection = "review_findings")
public class ReviewFinding {

    @Id
    private String id;
    @Indexed
    private String submissionId;
    private String matchedPatternId;
    private int startLine;
    private int endLine;
    private Severity severity;
    private String category;
    private String message;
    private String suggestion;
    private double confidence;

    // 为简洁起见,省略了构造函数、getter 和 setter
}

submissionId 上的 @Indexed 注解告诉 Spring Data MongoDB 在该字段上创建数据库索引。当您查找某个提交的所有结果时,MongoDB 会使用此索引直接跳转到匹配的文档,而不是扫描整个集合。如果没有索引,随着集合的增长,每次调用 findBySubmissionId 都会变得越来越慢。

startLineendLine 字段标记了问题在提交代码中出现的位置。matchedPatternId 字段可以为空,因为 LLM 可能会标记出无法映射到任何存储模式的问题。例如,LLM 可能会发现一个过于具体而无法归类为通用反模式的逻辑错误。confidence 字段是 LLM 分配的 0.0 到 1.0 之间的分数,用于指示其对该发现的确定程度。

审查服务

以下是审查服务针对每次提交所遵循的流程:

  1. 将代码提交保存到 MongoDB。
  2. 对提交的代码进行嵌入处理,并运行向量搜索以找到最匹配的 5 个模式。
  3. 使用代码和匹配的模式构建提示词 (Prompt),然后调用 LLM。
  4. 将 LLM 响应解析为 ReviewFinding 对象并保存。
$ java
@Service
public class ReviewService {

    private final MongoTemplate mongoTemplate;
    private final EmbeddingModel embeddingModel;
    private final ChatClient chatClient;
    private final CodeSubmissionRepository submissionRepository;
    private final ReviewFindingRepository findingRepository;

    public ReviewService(MongoTemplate mongoTemplate,
                         EmbeddingModel embeddingModel,
                         ChatClient.Builder chatClientBuilder,
                         CodeSubmissionRepository submissionRepository,
                         ReviewFindingRepository findingRepository) {
        this.mongoTemplate = mongoTemplate;
        this.embeddingModel = embeddingModel;
        this.chatClient = chatClientBuilder.build();
        this.submissionRepository = submissionRepository;
        this.findingRepository = findingRepository;
    }

    public ReviewResponse reviewCode(ReviewRequest request) {
        if (request.code() == null || request.code().isBlank()) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "代码不能为空");
        }

        CodeSubmission submission = new CodeSubmission();
        submission.setCode(request.code());
        submission.setLanguage(request.language());
        submission.setFileName(request.fileName());
        submission.setSubmittedAt(Instant.now());

        float[] codeEmbedding = embeddingModel.embed(request.code());
        List<ReviewPattern> matchedPatterns = findSimilarPatterns(codeEmbedding, 5);

        String systemPrompt = buildSystemPrompt();
        String userPrompt = buildUserPrompt(request.code(), matchedPatterns);

        List<ReviewFinding> findings = chatClient.prompt()
                .system(systemPrompt)
                .user(userPrompt)
                .call()
                .entity(new ParameterizedTypeReference<>() {});

        submission = submissionRepository.save(submission);

        for (ReviewFinding finding : findings) {
            finding.setSubmissionId(submission.getId());
        }
        List<ReviewFinding> savedFindings = findingRepository.saveAll(findings);
        List<String> findingIds = savedFindings.stream()
                .map(ReviewFinding::getId)
                .toList();

        submission.setFindingIds(findingIds);
        submissionRepository.save(submission);

        return new ReviewResponse(submission, savedFindings);
    }

    // 第3节中的 findSimilarPatterns 方法放在这里
    // buildSystemPrompt 和 buildUserPrompt 如下所示
}

构造函数接收五个依赖项。ChatClient.Builder 是一个 Spring AI 自动配置的 Bean,提供了用于创建聊天客户端的构建器。服务在构造函数中调用 .build() 来创建一个在每个审查请求中复用的 ChatClient 实例。MongoTemplate 提供了存储库接口未涵盖的底层 MongoDB 操作,这在向量搜索聚合管道中是必需的。

reviewCode 方法首先对提交的代码进行非空检查。如果没有此检查,空请求将触发昂贵的嵌入 API 调用和 LLM 调用,最终才会失败。提前返回 400 错误开销更小,并能给调用者提供清晰的错误信息。

接下来,该方法创建一个 CodeSubmission 对象并从请求中填充其字段。然后,它使用与模式相同的 embeddingModel.embed() 方法为提交的代码生成嵌入向量。该向量表示代码的语义含义,findSimilarPatterns 使用它来搜索嵌入向量方向相似的模式。

chatClient.prompt() 链用于构建和发送 LLM 请求。.system(systemPrompt) 设置定义 LLM 行为的系统级指令;.user(userPrompt) 提供实际的代码和匹配的模式;.call() 将请求发送到 OpenAI API;.entity(new ParameterizedTypeReference<>() {}) 告诉 Spring AI 将 LLM 的 JSON 响应直接解析为 List<ReviewFinding>。Spring AI 会从目标类型生成 JSON 模式并指示 LLM 以该格式返回 JSON,因此您无需编写解析代码。

LLM 返回结果后,该方法首先保存提交信息以获取生成的 id,然后在保存结果之前将该 id 分配给每个结果。使用 saveAll 进行批量保存比逐个保存更高效,因为批量保存只需一次数据库往返。最后,更新提交信息并再次保存。

该方法返回 savedFindingssaveAll 的结果列表),而不是原始的 findings 列表。保存后的列表在每个结果中都有 MongoDB 生成的 ID。返回原始列表会导致客户端获取没有 ID 的结果,从而难以在后续引用特定问题。

这两次保存存在一个潜在问题:如果应用程序在两次保存之间崩溃,结果会以有效的 submissionId 存在于数据库中,但提交文档的 findingIds 将为空。不过,数据并没有丢失。每个结果仍然引用其父级,因此 findingRepository.findBySubmissionId(submission.getId()) 依然可以查找到它们,之后您可以重建提交的 findingIds。如果您需要更严格的原子性,可以使用 Spring 的 @Transactional 将两次写入封装在 MongoDB 多文档事务中。否则,请将 findingIds 视为查找优化,并以 findBySubmissionId 作为备用方案。

提示词设计

系统提示词设置了审查者的角色并定义了确切的输出格式。明确 JSON 结构非常重要,因为聊天客户端上的 entity() 调用要求响应必须匹配 ReviewFinding 类:

$ java
private String buildSystemPrompt() {
    return """
        你是一名资深的 Java 代码审查员。请分析提交的代码并识别问题。
        你将收到一段代码片段和一组语义匹配的已知反模式。
        对于你发现的每个问题,请返回一个 JSON 数组。每个结果必须包含以下字段:
        - startLine (int): 问题开始的行号
        - endLine (int): 问题结束的行号
        - severity (string): "CRITICAL", "WARNING" 或 "INFO" 之一
        - category (string): "security", "performance", "maintainability", "error-handling" 之一
        - message (string): 问题的简要描述
        - suggestion (string): 如何修复问题
        - confidence (double): 你的置信度,范围 0.0 到 1.0
        - matchedPatternId (string 或 null): 如果匹配到提供的模式,则为模式 ID

        请关注实际问题。不要标记风格偏好或细微的格式问题。
        仅返回 JSON 数组,不要包含任何额外文本。
        """;
}

最后两行非常重要。“关注实际问题”可以防止 LLM 将每个微小的风格选择都标记为问题。“仅返回 JSON 数组”确保响应可被 Spring AI 的 entity() 方法解析。如果没有该指令,LLM 可能会将 JSON 包裹在 Markdown 代码块中或添加解释性文本,这会导致解析失败。

用户提示词提供了要审查的代码以及向量搜索匹配到的模式:

$ java
private String buildUserPrompt(String code, List<ReviewPattern> patterns) {
    StringBuilder prompt = new StringBuilder();
    prompt.append("## 要审查的代码\n\n```java\n");
    prompt.append(code);
    prompt.append("\n```\n\n");
    prompt.append("## 需要对照检查的已知反模式\n\n");

    for (int i = 0; i < patterns.size(); i++) {
        ReviewPattern pattern = patterns.get(i);
        prompt.append(String.format("%d. **%s** (ID: %s, 相似度: %.3f)\n",
                i + 1, pattern.getName(), pattern.getId(), pattern.getSearchScore()));
        prompt.append("   描述: ").append(pattern.getDescription()).append("\n");
        prompt.append("   示例: ```java\n   ").append(pattern.getExampleBadCode());
        prompt.append("\n   ```\n");
        prompt.append("   原因: ").append(pattern.getExplanation()).append("\n\n");
    }

    return prompt.toString();
}

提示词包含了每个模式的 ID,以便 LLM 可以在其发现结果中填充 matchedPatternId 字段。这为每个问题与触发它的存储模式之间建立了可追溯的链接。同时包含了向量搜索的相似度得分,这为 LLM 提供了关于匹配置信度的信号。相似度得分 0.92 的模式比 0.61 的模式权重更高,LLM 可以将其纳入置信度评估中。

如果 OpenAI 服务不可用或响应无法解析为预期结构,chatClient.prompt() 调用可能会失败。在本教程中,异常将作为 500 错误传播。在生产环境中,您应该捕获该失败并向调用者返回有意义的错误响应,而不是未处理的堆栈跟踪。

审查控制器

控制器公开了三个端点:一个用于提交审查代码,一个用于按提交 ID 检索过去的审查,一个用于列出结果:

$ java
@RestController
@RequestMapping("/api/reviews")
public class ReviewController {

    private final ReviewService reviewService;

    public ReviewController(ReviewService reviewService) {
        this.reviewService = reviewService;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public ReviewResponse submitReview(@RequestBody ReviewRequest request) {
        return reviewService.reviewCode(request);
    }

    @GetMapping("/{submissionId}")
    public ReviewResponse getReview(@PathVariable String submissionId) {
        return reviewService.getReview(submissionId);
    }

    @GetMapping("/{submissionId}/findings")
    public List<ReviewFinding> getFindings(@PathVariable String submissionId) {
        return reviewService.getFindings(submissionId);
    }
}

/api/reviews 的 POST 端点接受包含要审查代码的 JSON 主体,并返回完整的审查响应,包括提交信息和所有发现结果。GET 端点 /api/reviews/{submissionId} 用于检索之前的审查,/api/reviews/{submissionId}/findings 仅返回给定提交的结果,这在您只需要问题而不需要提交元数据时非常有用。

测试审查引擎

提交一个带有几个故意设计的问题的 Java 方法:

$ bash
curl -X POST http://localhost:8080/api/reviews \
  -H "Content-Type: application/json" \
  -d '{
    "code": "public void processFile(String path) {\n    String content = \"\";\n    try {\n        FileInputStream fis = new FileInputStream(path);\n        byte[] data = fis.readAllBytes();\n        content = new String(data);\n    } catch (Exception e) {\n        // handle later\n    }\n    String[] lines = content.split(\"\\n\");\n    String result = \"\";\n    for (String line : lines) {\n        result += line.trim() + \"\\n\";\n    }\n    System.out.println(result);\n}",
    "language": "java"
  }'

这段代码有三个问题:未关闭的 FileInputStream(未使用 try-with-resources)、空的通用 catch (Exception e) 块以及循环内的字符串拼接 +=。响应中包含了每个问题的发现结果,带有匹配的模式 ID、严重程度、代码行范围以及修复建议。置信度得分通常在 0.7 到 0.95 之间,具体取决于代码与存储模式的匹配程度。## 5. 使用聚合管道跟踪审查趋势

当积累了足够的审查记录后,您可以使用 MongoDB 聚合管道来回答诸如“哪些问题在所有提交中反复出现?”之类的问题。聚合管道通过一系列阶段处理文档,每个阶段执行过滤、分组或排序等操作。前一个阶段的输出会成为下一个阶段的输入。

创建一个包含三个管道的 AnalyticsService,用于从不同维度展示审查数据。

第一个管道按类别对发现的问题进行分组,并计算每个类别出现的次数。这能反映出团队代码中最常需要改进的地方:

$ java
public List<CategoryCount> getCategoryCounts() {
    Aggregation aggregation = Aggregation.newAggregation(
            Aggregation.group("category").count().as("count"),
            Aggregation.sort(Sort.Direction.DESC, "count")
    );
    return mongoTemplate.aggregate(aggregation, "review_findings", CategoryCount.class)
            .getMappedResults();
}

Aggregation.group("category") 是一个 $group 阶段,它将所有具有相同 category 值的发现结果归为一组。.count().as("count") 为每个组添加了一个名为 count 的字段,用于存储其中的文档数量。Aggregation.sort(Sort.Direction.DESC, "count") 对分组进行排序,使出现频率最高的类别排在最前面。mongoTemplate.aggregate()review_findings 集合上运行该管道,并将每个结果映射到 CategoryCount 对象中。

第二个管道使用相同的结构,但改为按严重程度(severity)进行分组。这展示了所有审查中严重、警告和信息类发现的比例:

$ java
public List<SeverityCount> getSeverityDistribution() {
    Aggregation aggregation = Aggregation.newAggregation(
            Aggregation.group("severity").count().as("count"),
            Aggregation.sort(Sort.Direction.DESC, "count")
    );
    return mongoTemplate.aggregate(aggregation, "review_findings", SeverityCount.class)
            .getMappedResults();
}

如果大部分发现结果为 CRITICAL(严重),团队可能需要关注基础编码实践。如果分布倾向于 INFO(信息),则说明代码库总体状况良好。

第三个管道更为复杂。它通过连接两个集合的数据,识别出哪些特定模式在审查中反复出现。下图展示了文档在各个阶段的流转过程:

[LOADING...]

聚合管道图展示了从匹配(match)到分组(group)、排序(sort)、限制(limit)、查找(lookup)、展开(unwind)和投影(project)的各个阶段。

$ java
public List<PatternFrequency> getTopPatterns() {
    Aggregation aggregation = Aggregation.newAggregation(
            Aggregation.match(Criteria.where("matchedPatternId").ne(null)),
            Aggregation.group("matchedPatternId").count().as("count"),
            Aggregation.sort(Sort.Direction.DESC, "count"),
            Aggregation.limit(10),
            Aggregation.lookup("review_patterns", "_id", "_id", "pattern"),
            Aggregation.unwind("pattern"),
            Aggregation.project()
                    .and("pattern.name").as("patternName")
                    .and("count").as("count")
    );
    return mongoTemplate.aggregate(aggregation, "review_findings", PatternFrequency.class)
            .getMappedResults();
}

该管道包含多个阶段,具体说明如下:

  • Aggregation.match(Criteria.where("matchedPatternId").ne(null)):过滤掉没有匹配到模式的发现结果。并非每个发现都能映射到存储的模式(LLM 可以独立标记问题),因此该阶段会在计数前将其剔除。
  • Aggregation.group("matchedPatternId").count().as("count"):按匹配的模式 ID 对剩余的发现结果进行分组并计数。
  • Aggregation.sort(Sort.Direction.DESC, "count"):按匹配频率对模式进行降序排列。
  • Aggregation.limit(10):仅保留前 10 个结果。
  • Aggregation.lookup("review_patterns", "_id", "_id", "pattern"):与 review_patterns 集合执行连接操作。将分组结果中的 _id(即 matchedPatternId 值)与 review_patterns 集合中的 _id 进行匹配。匹配到的文档被放入一个名为 pattern 的新数组字段中。这类似于 SQL 的 JOIN,但结果始终是数组,因为 MongoDB 不假定一对一关系。
  • Aggregation.unwind("pattern"):展平该数组。由于每个分组结果仅匹配一个模式,因此 pattern 数组中只有一个元素。unwind 将数组替换为其中的单个文档,这使得在下一个阶段更容易访问这些字段。
  • Aggregation.project():选择最终的输出字段。.and("pattern.name").as("patternName") 从连接的模式文档中提取 name 字段并重命名为 patternName.and("count").as("count") 保留分组阶段的计数值。其他所有字段均从输出中排除。

通过 AnalyticsController 公开这三个管道:

$ java
@RestController
@RequestMapping("/api/analytics")
public class AnalyticsController {

    private final AnalyticsService analyticsService;

    public AnalyticsController(AnalyticsService analyticsService) {
        this.analyticsService = analyticsService;
    }

    @GetMapping("/categories")
    public List<CategoryCount> getCategoryCounts() {
        return analyticsService.getCategoryCounts();
    }

    @GetMapping("/severity")
    public List<SeverityCount> getSeverityDistribution() {
        return analyticsService.getSeverityDistribution();
    }

    @GetMapping("/top-patterns")
    public List<PatternFrequency> getTopPatterns() {
        return analyticsService.getTopPatterns();
    }
}

在系统中运行多次审查后,类别端点可能会返回如下结果:

$ cat
[
  { "category": "error-handling", "count": 12 },
  { "category": "maintainability", "count": 8 },
  { "category": "security", "count": 5 },
  { "category": "performance", "count": 4 }
]

这表明“错误处理”是所有审查代码中最常见的问题类别。这些管道每次运行时都会扫描整个 review_findings 集合。对于只有几十次审查的教程项目,这完全没问题;但在拥有成千上万条记录的生产环境中,您需要为 categoryseveritymatchedPatternId 建立索引,以加速 $group 阶段的执行。

6. 测试完整工作流

以下是完整的流程:

启动应用程序。 DataSeeder 会在首次运行时加载约 20 个模式并生成它们的嵌入向量。您应该能在 Atlas 的 review_patterns 集合中看到这些模式。

添加自定义模式。 该库是可扩展的,您可以添加特定于您代码库的模式:

$ bash
curl -X POST http://localhost:8080/api/patterns \
  -H "Content-Type: application/json" \
  -d '{
    "id": "logging-user-passwords",
    "name": "Logging user passwords",
    "description": "Writing user passwords to log output in authentication flows",
    "language": "java",
    "severity": "CRITICAL",
    "category": "security",
    "exampleBadCode": "logger.info(\"Login: user={}, pass={}\", username, password);",
    "exampleGoodCode": "logger.info(\"Login attempt: user={}\", username);",
    "explanation": "Passwords in logs violate security policy and compliance requirements."
  }'

提交包含已知问题的代码。 发送一个带有明显反模式的片段:

$ bash
curl -X POST http://localhost:8080/api/reviews \
  -H "Content-Type: application/json" \
  -d '{
    "code": "public String readConfig() {\n    FileInputStream fis = new FileInputStream(\"app.conf\");\n    byte[] data = fis.readAllBytes();\n    return new String(data);\n}",
    "language": "java"
  }'

响应将包含一个针对未关闭 FileInputStream 的发现结果,其 matchedPatternId 指向“未关闭资源”(unclosed resources)模式。

提交包含更隐蔽问题的代码。 尝试一个与任何存储模式示例都不完全匹配的片段:

$ bash
curl -X POST http://localhost:8080/api/reviews \
  -H "Content-Type: application/json" \
  -d '{
    "code": "public void backup(Path source, Path dest) throws Exception {\n    BufferedReader reader = Files.newBufferedReader(source);\n    BufferedWriter writer = Files.newBufferedWriter(dest);\n    String line;\n    while ((line = reader.readLine()) != null) {\n        writer.write(line);\n        writer.newLine();\n    }\n}",
    "language": "java"
  }'

即使此代码使用的是 BufferedReaderBufferedWriter 而非 FileInputStream,向量搜索仍然会将“未关闭资源”模式作为最佳匹配项,因为它们的语义含义相同:即在没有使用 try-with-resources 的情况下打开了资源。检查响应中的相似度得分,看看匹配的紧密程度。

查看分析数据。 运行几次审查后,访问分析端点:

$ bash
curl http://localhost:8080/api/analytics/categories
curl http://localhost:8080/api/analytics/severity
curl http://localhost:8080/api/analytics/top-patterns

这些数据展示了您所有审查记录的累积结果。

总结

您构建了一个包含三个层的代码审查助手。Atlas 向量搜索通过语义相似度将提交的代码与模式库进行匹配,即使代码与存储的示例外观不同,也能发现问题。Spring AI 将匹配的模式和代码发送给 LLM,后者会返回包含严重程度、行范围和修复建议的结构化发现结果。MongoDB 聚合管道则将累积的发现结果转化为跨提交的趋势分析。

在此基础上,您可以利用团队代码审查中的反模式进一步扩展模式库。您可以添加对审查整个文件或 Git diff 的支持,或者尝试使用代码专用的嵌入模型以获得更好的相似度匹配。通过添加一个反馈端点(让开发人员标记发现结果是否有帮助),您可以随着时间的推移不断提高模式质量。

完整的源代码可在 GitHub 上的配套仓库中获取。