编程导航Spring话题讨论

Spring

118 参与
分享

快来分享你的内容吧~

点击登录,快来和大家讨论吧~
表情
图片
话题
打卡
综合
交流
文章
问答

Spring源码阅读过程中基础知识补齐

### 1. isAssignableFrom `isAssignableFrom`是`Class`类下的一个`native`方法,用来判断某个类是否和参数中的类是同一个类或者他的父类、父接口。为了更好地了解他的使用,我们来看一看一个和他用法有些相似的`instanceof`。我当时看的时候一个朴素的想法就是,为啥不用`instanceof`。下面通过表格的一个形式展示两者之间一些区别 | | instanceof | isAssignableFrom | | --- | --- | --- | | 是个啥 | 运算符 | Class类的方法 | | 谁来用 | 对象实例 | Class对象 | | 用处 | 判断对象是某个类的实例 | 该类能否接受参数那个类的赋值 | | 方向 | 子->父 | 父->子 | 可以看出,主要的区别在于,instanceof是给对象实例用的,用来保证能进行安全的类型转换。而isAssignableFrom更适合只有一些类信息的情况下使用,尤其是在Spring这种框架中,用来确认某个代理对象是否是我要的类型。 #### 举个🌰 ````Java Class<?> parentClass = List.class; Class<?> childClass = ArrayList.class; Class<?> unrelatedClass = String.class; // 判断 ArrayList 是否可以赋值给 List boolean result1 = parentClass.isAssignableFrom(childClass); // true // 判断 String 是否可以赋值给 List boolean result2 = parentClass.isAssignableFrom(unrelatedClass); // false ```` ### 2. MultiValueMap 顾名思义,是一个可以一个key映射多个value的Map。在Spring处理请求头的时候用到 位于`org.springframework.util`包下,下面是接口定义 ````Java public interface MultiValueMap<K, V> extends Map<K, List<V>> ```` 可以看出他就是一个Map,那么他是怎么做到一个key映射多个Value的呢?于是我们找来了一个实现类 ````Java public class LinkedMultiValueMap<K, V> extends MultiValueMapAdapter<K, V> // new public base class in 5.3 implements Serializable, Cloneable ```` 以下类图展示两者之间关系 ![image.png](https://pic.code-nav.cn/post_picture/1654065808541790209/ci2Hzqn8DteVNz1S.webp) 我们可以通过`MultiValueMapAdapter`的一些方法发现其实就是通过一个List将多个Value存储起来让一个key映射多个Value ````Java public class MultiValueMapAdapter<K, V> implements MultiValueMap<K, V>, Serializable { private final Map<K, List<V>> targetMap; @Override public void add(K key, @Nullable V value) { List<V> values = this.targetMap.computeIfAbsent(key, k -> new ArrayList<>(1)); values.add(value); } ```` 希望各位大佬来补充和指正

Spring AI MCP Stdio 报错信息排查

本文汇总一下 Spring AI 通过 `STDIO` 方式接入 MCP Server 时,常见报错的排查思路。这里讨论的场景是:Spring AI 客户端通过 `spring.ai.mcp.client.stdio` 启动外部 MCP Server,并通过 `stdio` 与它通信。 具体课程 MCP 链接:<https://www.codefather.cn/course/1915010091721236482/section/1923324591245287425> ## 一、先看结论 如果你遇到了 Spring AI MCP `stdio` 相关报错,建议按下面顺序排查: 1. 先单独验证 `yu-image-search-mcp-server` 本身的业务逻辑,不要一上来就从 AI 对话入口排查。 2. 只要改过 MCP Server 代码,就重新执行 `package`,因为 Spring AI 客户端最终拉起的是 `jar`,不是 IDEA 里的源码。 3. 手动运行 `jar`,确认进程能否正常拉起。对 `stdio` 场景来说,“进程没有立刻退出”往往比“终端打印了日志”更重要。 4. 核对 `application.yml` 和 `mcp-servers.json`,尤其是命令、参数、工作目录和 `jar` 路径。 5. `stdio` 模式下,MCP Server 只能把协议消息写到 `stdout`;普通日志应该写到 `stderr` 或文件,不能污染 `stdout`。 6. 调试时看到 `exitValue()` 取不到值,只能说明子进程还没退出,不能单独当作“启动成功”的充分证据。 ## 二、推荐排查顺序 ### 1. 先单独验证 `yu-image-search-mcp-server` 需要先把 `ImageSearchTool` 的 `API_KEY` 替换成你自己的真实 Key。Pexels API Key 获取地址:<https://www.pexels.com/api/key/> 配置完成后,先单独运行 `ImageSearchToolTest#searchImage`,确认图片链接能否正常返回。 如果这一步就失败了,那么问题通常还不在 MCP,而在下面这些地方: 1. `API_KEY` 不正确或未生效。 2. 工具代码本身有问题。 3. 依赖版本和源码示例不一致。 ![image.png](https://pic.code-nav.cn/post_picture/1608460212774109186/1L19p5zyHTV1LsfO.webp) ### 2. 改完代码后重新打包 `jar` 只要改了 `yu-image-search-mcp-server` 的代码,就一定要重新执行 `package`。因为 Spring AI 通过 `ProcessBuilder` 拉起的是磁盘上的 `jar`,不是你当前 IDEA 里尚未打包的代码! ![image.png](https://pic.code-nav.cn/post_picture/1608460212774109186/hQGJPxZu83enbZH3.webp) ### 3. 手动运行 `jar`,确认 `stdio` Server 能否启动 建议在父项目目录打开终端,然后手动执行和 `mcp-servers.json` 中一致的命令。 找到父项目,右键选择 `OpenIn` -> `Terminal`: ![image.png](https://pic.code-nav.cn/post_picture/1608460212774109186/7YhpGNj0sVOertLf.webp) 执行命令: ```bash java "-Dspring.ai.mcp.server.stdio=true" "-Dspring.main.web-application-type=none" -jar yu-image-search-mcp-server/target/yu-image-search-mcp-server-0.0.1-SNAPSHOT.jar --spring.profiles.active=stdio ``` 这里有几个关键点: 1. `spring.ai.mcp.server.stdio=true` 表示以 `STDIO` 模式启动 MCP Server。 2. `spring.main.web-application-type=none` 表示不要按 Web 应用启动。 正常可以发现启动成功: > 如果启动失败需要排查一下自己的 Java 版本是否是 >= 21 ![image.png](https://pic.code-nav.cn/post_picture/1608460212774109186/2QcIQhpThzX3HZ9g.webp) ### 4. 核对 `application.yml` 和 `mcp-servers.json` 先看客户端配置: ```yaml spring: ai: mcp: client: stdio: servers-configuration: classpath:mcp-servers.json ``` 这里的 `servers-configuration` 是 Spring AI 官方支持的 `stdio` 外部配置方式。 准备 `mcp-servers.json`: 排查阶段建议先只保留一个待排查的 MCP Server,不要同时把多个 MCP 都配进去(amap-maps 不要先写进入),避免互相干扰。 ```json { "mcpServers": { "yu-image-search-mcp-server": { "command": "java", "args": [ "-Dspring.ai.mcp.server.stdio=true", "-Dspring.main.web-application-type=none", "-Dspring.main.banner-mode=off", "-Dlogging.pattern.console=", "-jar", "yu-image-search-mcp-server/target/yu-image-search-mcp-server-0.0.1-SNAPSHOT.jar", "--spring.profiles.active=stdio" ], "env": {} } } } ``` 这一段最容易出错的地方主要有二个: 1. `command` 和 `args` 与你手动验证成功的命令不一致。 2. `jar` 路径写错。 ### 5. 通过 Debug 或独立测试进一步定位 如果上面的步骤都做过了,还是报错,那么可以继续往下定位。 首先运行测试类 `doChatWithMcp()`: ![image.png](https://pic.code-nav.cn/post_picture/1608460212774109186/iWfwBTR8ftqbsIEw.webp) 然后可以在 `io.modelcontextprotocol.client.transport.StdioClientTransport` 附近打断点,再 Debug 运行 `doChatWithMcp()`: ![image.png](https://pic.code-nav.cn/post_picture/1608460212774109186/yt3OhYwcu0kmMhjw.webp) 如果这里的 exitValue 是 not exited 的话,那么大概率是启动成功了。如果没有走到这个断点,那么就检查自己的配置文件是否配置正确,也就是上面一节。 可以走到断电,但是值不是 not exited 的话就需要再单独测试,具体如何测试需要看下面的代码。 下面这段代码可以放到 `src/test/java/com/yupi/yuaiagent/app` 包下: ```java import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.concurrent.TimeUnit; public class McpServerChecker { public static void main(String[] args) { List<String> command = List.of( "java", "-Dspring.ai.mcp.server.stdio=true", "-Dspring.main.web-application-type=none", "-Dspring.main.banner-mode=off", "-Dlogging.pattern.console=", "-jar", "yu-image-search-mcp-server/target/yu-image-search-mcp-server-0.0.1-SNAPSHOT.jar", "--spring.profiles.active=stdio" ); ProcessBuilder pb = new ProcessBuilder(command); pb.redirectErrorStream(true); try { Process process = pb.start(); boolean exited = process.waitFor(5, TimeUnit.SECONDS); if (!exited) { System.out.println("进程 5 秒内没有退出,说明服务大概率已经拉起,正在等待 MCP Client 通过 stdin 建连。"); process.destroy(); return; } String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); System.err.println("进程启动失败,exitCode = " + process.exitValue()); System.err.println(output); } catch (IOException e) { System.err.println("启动异常: " + e.getMessage()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); System.err.println("等待进程结果时被中断"); } } } ``` 这个测试的意义是: 1. 验证命令本身能不能成功拉起子进程。 2. 如果进程立即退出,就能直接看到退出码和错误输出。 3. 如果进程没有立即退出,至少说明“命令层面”基本没问题,下一步就要继续看 MCP 握手和工具调用链路。 4. 最后需要手动停止这个测试方法 ## 三、系统编码的问题 如果遇到的报错信息类似下面的报错信息: ``` 2025-10-20 10:10:31.298 [pool-2-thread-1] ERROR i.m.client.transport.StdioClientTransport - Error processing inbound message for line: Active code page: 65001 com.fasterxml.jackson.core.JsonParseException: Unrecognized token 'Active': was expecting (JSON String, Number, Array, Object or token 'null', 'true' or 'false') ``` 并且打开 cmd 可以发现这个信息: ![image-20260401133800231](https://pic.code-nav.cn/post_picture/1608460212774109186/pXZoQF1yJg5CjJWv.webp) 满足以上两点就证明系统编码有问题,我是不太推荐解决的(因为编码问题一旦随便修改可能会导致系统环境变量出现问题,如果要修改也建议先设置一下**系统还原点**),如果是这个问题我个人建议是使用 Linux 系统测试代码是否可以正常运行,如果正常运行就直接继续写代码即可。解决的话也可以参考一下这位大佬的文章:https://www.codefather.cn/post/1980098355279175681 ## 四、如何判断最终排查成功 最终用下面这条链路判断是否真的排查完成: 1. `ImageSearchToolTest#searchImage` 单独运行成功。 2. 重新 `package` 后,`jar` 手动运行不会立刻退出。 3. `doChatWithMcp()` 能正常拿到图片 URL。 4. 图片 URL 可以实际访问,说明结果来自工具调用,而不是模型幻觉。 正常情况下,`doChatWithMcp()` 的返回内容里会包含真实图片链接,并且链接可以正常访问: ![image.png](https://pic.code-nav.cn/post_picture/1608460212774109186/GYTFYOsQQZJkoVs9.webp)

手写 Spring AI Advisor:解耦 ChatMemory 与历史记录,实现聊天记录持久化存储

> **前言**: > 在上一篇文章 [《Spring AI + MySQL 实现会话记忆持久化》](https://www.codefather.cn/post/2011766454579478529) 中,我们成功实现了将 ChatMemory 接入 MySQL,让对话数据不再随服务重启而丢失。 > > 但随着项目测试运行,我发现了一个**致命问题**:**数据库里的聊天记录竟然变少了!** 当对话轮数超过我们在 `MessageWindowChatMemory` 中设置的 `maxMessages`(例如 50 条)时,最早的记录竟然从数据库中凭空消失了。 > > **这不仅不符合“持久化”的初衷,更导致我们无法进行后续的数据分析和历史回溯。** > > 本文将带你深入 Spring AI 源码,揭秘这个“数据消失术”的根本原因,并手把手教你通过重写核心组件,实现 **“数据库永久保存全量历史”** 与 **“大模型只传最近 N 条上下文”** 的完美平衡。 --- ## 一、 数据消失原因 在 Spring AI 的默认设计中,我们通常使用 `MessageWindowChatMemory` 来管理会话上下文。它的核心作用是**限制发送给大模型的 Token 数量**,防止上下文超长。 但是,Spring AI 默认保存的是短期记忆,是大模型上下文里的内容 让我们直接打开 `MessageWindowChatMemory.java` 的源码(位于 `org.springframework.ai.chat.memory` 包下),看看 `add` 方法到底干了什么: ### 1.1 源码分析:`add` 方法 ```java @Override public void add(String conversationId, List<Message> messages) { Assert.hasText(conversationId, "conversationId cannot be null or empty"); Assert.notNull(messages, "messages cannot be null"); Assert.noNullElements(messages, "messages cannot contain null elements"); // 1. 从数据库(Repository)取出当前会话的所有消息 List<Message> memoryMessages = this.chatMemoryRepository.findByConversationId(conversationId); // 2. 【关键点】调用 process 方法处理消息(合并新消息 + 截断) List<Message> processedMessages = process(memoryMessages, messages); // 3. 将处理后的结果“覆盖”回数据库 this.chatMemoryRepository.saveAll(conversationId, processedMessages); } ``` **深度解读:** 1. **`findByConversationId`**:首先,它从底层的 Repository(比如 MySQL)中取出了当前会话的所有历史消息。假设现在数据库里有 50 条。 2. **`process`**:接着,它调用了内部的 `process` 方法。这里是逻辑的核心,它将旧消息和新消息合并,并根据窗口大小进行处理。 3. **`saveAll`**:最后,也是最关键的一步,它将 `process` 处理后的结果**全量覆盖**回数据库。注意这里是覆盖操作,如果 `process` 方法丢弃了某些消息,那么这些消息在数据库里也会被同步删除。 ### 1.2 源码分析:`process` 方法(罪魁祸首) ```java private List<Message> process(List<Message> memoryMessages, List<Message> newMessages) { // ... 省略部分合并逻辑 ... // 合并旧消息和新消息 processedMessages.addAll(newMessages); // 如果总数没超过 maxMessages,直接返回 if (processedMessages.size() <= this.maxMessages) { return processedMessages; } // 【致命逻辑】如果超过了 maxMessages,开始移除旧消息! int messagesToRemove = processedMessages.size() - this.maxMessages; List<Message> trimmedMessages = new ArrayList<>(); int removed = 0; for (Message message : processedMessages) { // 如果是 SystemMessage 或者 已经删够了数量,才保留 if (message instanceof SystemMessage || removed >= messagesToRemove) { trimmedMessages.add(message); } else { removed++; // 计数器+1,这条消息被丢弃了,不会加入 trimmedMessages } } return trimmedMessages; // 返回的是“阉割”后的列表 } ``` **深度解读:** 假设我们将 `maxMessages` 设置为 **50**,且数据库中已经存储了 **50** 条历史消息。此时,用户发送了 **1** 条新消息。 1. **全量聚合**: * `processedMessages.addAll(newMessages)`:代码首先将新消息追加到旧消息列表中。 * **现状**:此时内存中的 `processedMessages` 列表共有 **51** 条消息(Index 0 ~ 50)。 2. **阈值检查**: * `if (processedMessages.size() <= this.maxMessages)`:这是流程的分水岭。 * **判定**:51 > 50,条件不满足。程序意识到消息超载,必须启动“裁员”流程。 3. **计算“裁员”指标**: * `int messagesToRemove = 51 - 50 = 1;` * **目标**:必须从列表中剔除 **1** 条最旧的消息,才能满足窗口限制。 4. **滑动窗口筛选**: * 这是最核心的逻辑,代码使用 `for` 循环从头(最旧的消息)开始遍历,配合 `removed` 计数器决定每条消息的命运。 * **第一轮循环(Index 0,最旧的一条消息)**: * 它是 `SystemMessage` 吗?**否**(假设是普通对话)。 * `removed` (0) >= `messagesToRemove` (1) 吗?**否**(还没删够)。 * **结局**:进入 `else` 分支,`removed` 自增变为 1,**该消息被丢弃**,没有加入 `trimmedMessages`。 * **第二轮循环(Index 1,次旧消息)**: * 它是 `SystemMessage` 吗?**否**。 * `removed` (1) >= `messagesToRemove` (1) 吗?**是**(指标已达标)。 * **结局**:进入 `if` 分支,**该消息被保留**,加入 `trimmedMessages`。 * **后续循环**:因为 `removed` 已经达标,后续所有消息都会满足 `removed >= messagesToRemove` 条件,从而被全部保留。 5. **最终审判**: * 方法返回的 `trimmedMessages` 仅包含 **后 50 条** 消息。 * **致命后果**:这个“阉割版”的列表随后会在 `add` 方法中被直接 `saveAll` 回数据库。**这意味着,数据库中存储的最早那 1 条历史记录,被物理删除了!** 这就是“数据消失术”的底层真相。 流程闭环了:`add` 方法取出了 50 条,合并了 1 条新消息变成 51 条,传给 `process` 方法。`process` 方法一看超了,把第 1 条删了,返回后 50 条。最后 `add` 方法把这后 50 条存回数据库。 ### 1.3 痛点总结 看懂了吗?流程是这样的: 1. **读出来**:把数据库里的 50 条记录读出来。 2. **加进去**:加上用户刚发的 1 条新消息,现在有 51 条。 3. **切一刀**:`process` 方法发现 51 > 50,于是把**第 1 条**旧消息删掉了,只保留后 50 条。 4. **存回去**:调用 `repository.saveAll`。对于 `JdbcChatMemoryRepository` 来说,它会把这个会话 ID 下的数据更新为这 50 条。 **结果:** 数据库里永远只有最近的 50 条,第 51 条之前的历史记录**永久丢失**。这对于需要审计、回溯、数据分析的业务系统来说,是不可接受的。 --- ## 二、 思路分析 ### 2.1 官方文档的启示 Spring AI 官方文档中其实隐晦地提到过:**ChatMemory 的设计初衷并不是作为持久化的聊天记录存储方案**。 ![请添加图片描述](https://pic.code-nav.cn/post_picture/1828322959723974658/V0LkMRW6muHC8EWi.webp) 这种方案的设计目的是**管理模型上下文的短期记忆(Short-term Memory)**,而不是**整个聊天记录(Chat History / Audit Log)**。 * **短期记忆 (Short-term Memory)**:这是**模型层面**的概念。短期记忆存在于模型上下文中的,受限于 LLM 的 Context Window(上下文窗口,如 4k, 8k, 128k Token),我们无法将所有历史对话都喂给模型。因此,必须通过 `MessageWindowChatMemory` 等机制进行“截断”,只保留最近的 N 轮对话,拼接在 Prompt 中供模型即时调用,以维持对话的连贯性。 * **聊天记录 (Chat History)**:这是**业务层面**的概念。它是项目业务需求的一部分,类似于系统日志或审计日志。它的要求是**全量保存、永久存储、不可丢失**,用于后续的用户历史查看、数据分析、模型调优等。它不应该受到模型 Token 限制的影响。 **结论**:**我们不应该试图修改 Spring AI 实现 `ChatMemory` 的源码**(如修改 `process` 方法不去删除旧数据),因为那违背了它的设计初衷(控制上下文大小)。如果强行修改,会导致发送给大模型的 Prompt 无限膨胀,最终撑爆 Token 限制或消耗巨额 Token 费用。 ### 2.2 解决思路:AOP 与 Advisor 既然我们明确了目标——**在业务层面实现聊天记录的持久化**,那么我们完全可以跳出 `ChatMemory` 的圈子,从大模型交互的本质入手。 **大模型本质上就是一个输入(Input)输出(Output)的函数工具。** ![请添加图片描述](https://pic.code-nav.cn/post_picture/1828322959723974658/a85ozlEXNe1Ok4KS.png) Spring AI 的核心架构设计中,大量使用了 **Advisor(增强器)** 模式。这是一种典型的 AOP(面向切面编程)思想。通过 Advisors,我们可以拦截对大模型的每一次请求和每一次响应,对其进行增强、修改或**记录**。 ![请添加图片描述](https://pic.code-nav.cn/post_picture/1828322959723974658/T3JncHWICdY2oTCk.webp) 上图是 Spring AI 文档中对于 Advisors 的介绍。可以看到,Advisor 处于 `ChatClient` 和底层 `ChatModel` 之间,像关卡一样层层拦截递归执行。 **核心思路**: 我们不需要动 `ChatMemory`,而是编写一个自定义的 **Advisor**。 1. **拦截请求**:在用户发送 Prompt 给大模型之前,拦截请求,提取出用户的提问,保存到数据库。 2. **拦截响应**:在大模型生成回复返回给用户之后(或流式传输过程中),拦截响应,提取出 AI 的回答,保存到数据库。 这样,**“短期记忆管理”交给 `ChatMemory`(负责截断),“长期历史记录”交给 `Advisor`(负责全量落库)**。两者职责分离,互不干扰,完美解决了我们的问题。 --- ## 三、 自定义 Advisor 实战 ### 3.1 Advisor 接口详解 Spring AI 的文档中详细介绍了如何实现一个自定义 Advisor。 > **Implementing an Advisor** > To create an advisor, implement either `CallAdvisor` or `StreamAdvisor` (or both). The key method to implement is `nextCall()` for non-streaming or `nextStream()` for streaming advisors. 我们需要实现两个核心接口(通常为了同时支持流式和非流式调用,两个都要实现): * **`CallAdvisor`**:用于拦截普通的同步调用(`call`)。核心方法是 `adviseCall`。 * **`StreamAdvisor`**:用于拦截流式调用(`stream`)。核心方法是 `adviseStream`。 ### 3.2 官方示例解读:SimpleLoggerAdvisor Spring AI 提供了一个 `SimpleLoggerAdvisor` 的示例,用于打印请求和响应日志。这正是我们需要参考的模板,因为“打印日志”和“保存日志到数据库”在逻辑上是完全一样的,只是输出目的地不同。 ![请添加图片描述](https://pic.code-nav.cn/post_picture/1828322959723974658/4lcRXFkvKWnSRWiZ.webp) 让我们深入解析一下官方的这个示例代码: ```java public class SimpleLoggerAdvisor implements CallAdvisor, StreamAdvisor { private static final Logger logger = LoggerFactory.getLogger(SimpleLoggerAdvisor.class); @Override public String getName() { return this.getClass().getSimpleName(); // Advisor 的唯一名称 } @Override public int getOrder() { return 0; // 执行顺序,数字越小越先执行 } // 1. 拦截非流式调用 @Override public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) { // 在调用大模型之前:记录请求 logRequest(chatClientRequest); // 执行链条中的下一个 Advisor 或最终的大模型调用 ChatClientResponse chatClientResponse = callAdvisorChain.nextCall(chatClientRequest); // 在大模型返回之后:记录响应 logResponse(chatClientResponse); return chatClientResponse; } // 2. 拦截流式调用 @Override public Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest, StreamAdvisorChain streamAdvisorChain) { // 在流开始之前:记录请求 logRequest(chatClientRequest); // 执行流式请求,得到响应流 (Flux) Flux<ChatClientResponse> chatClientResponses = streamAdvisorChain.nextStream(chatClientRequest); // 【关键点】聚合流式响应 // 流式响应是一段一段回来的,我们无法直接打印完整的回复。 // ChatClientMessageAggregator 工具类可以“旁路”收集所有的流片段,拼成一个完整的 Response,供我们处理。 // 注意:这里的操作是异步的,不会阻塞返回给前端的流。 return new ChatClientMessageAggregator().aggregateChatClientResponse(chatClientResponses, this::logResponse); } // ... 省略 logRequest 和 logResponse 的具体实现 ... } ``` **深度解析:** 1. **`getOrder()`**:控制 Advisor 的执行顺序。这在多个 Advisor 串联时非常重要(后面我们会利用这一点)。 2. **`adviseCall`**:这是经典的“环绕通知”。你可以在 `nextCall` 之前拿到 `chatClientRequest`(用户的输入),在 `nextCall` 之后拿到 `chatClientResponse`(AI 的输出)。 3. **`adviseStream` 与 `MessageAggregator`**:这是难点。流式调用返回的是 `Flux<ChatClientResponse>`,里面的内容是破碎的字符(Chunk)。如果我们想保存完整的 AI 回复,不能每收到一个字就存一次数据库。`ChatClientMessageAggregator` 是 Spring AI 提供的神器,它能帮我们在流传输的同时,在后台悄悄把碎片拼凑起来,等流结束时触发回调(`this::logResponse`),让我们拿到完整的文本。 --- ## 四、 核心实现:ChatHistoryRecordAdvisor 基于上述分析,我们现在来实现自己的 **`ChatHistoryRecordAdvisor`**。它的目标是:**拦截对话,将“用户提问”和“AI 回答”持久化到 MySQL 的 `spring_ai_chat_message_log` 表中**。 ### 4.1 完整代码 ```java import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.ChatClientMessageAggregator; import org.springframework.ai.chat.client.ChatClientRequest; import org.springframework.ai.chat.client.ChatClientResponse; import org.springframework.ai.chat.client.advisor.api.CallAdvisor; import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain; import org.springframework.ai.chat.client.advisor.api.StreamAdvisor; import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain; import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.MessageType; import org.springframework.core.Ordered; import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; import java.time.LocalDateTime; /** * 聊天历史记录增强器 * 用于拦截 ChatClient 的请求和响应,并将对话记录持久化到数据库中。 */ @Component @RequiredArgsConstructor @Slf4j public class ChatHistoryRecordAdvisor implements CallAdvisor, StreamAdvisor, Ordered { private final ISpringAiChatMessageLogService messageLogService; private final ISpringAiChatRecordService chatRecordService; @Override public String getName() { return this.getClass().getSimpleName(); } /** * 设置最高优先级 (HIGHEST_PRECEDENCE) * 目的:确保在其他 Advisor(如 RAG 的 QuestionAnswerAdvisor)之前执行。 * 这样我们可以获取到用户最原始的 Prompt,而不是被 RAG 修改/拼接后的 Prompt。 */ @Override public int getOrder() { return Ordered.HIGHEST_PRECEDENCE; } /** * 拦截非流式调用 */ @Override public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) { // 1. 提取原始的用户请求文本(在 RAG 修改之前) String originalUserText = extractUserText(chatClientRequest); // 2. 放行请求,继续执行后续的 Advisor 链和 AI 调用 ChatClientResponse response = callAdvisorChain.nextCall(chatClientRequest); // 3. AI 响应回来后,记录日志(包括用户问题和 AI 回答) saveLog(chatClientRequest, response, originalUserText); return response; } /** * 拦截流式调用 */ @Override public Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest, StreamAdvisorChain streamAdvisorChain) { // 1. 同样先提取原始用户文本 String originalUserText = extractUserText(chatClientRequest); // 2. 执行流式请求 Flux<ChatClientResponse> responseFlux = streamAdvisorChain.nextStream(chatClientRequest); // 3. 聚合流式响应,以便获取完整的 AI 回答内容进行存储 // 注意:这里不会阻塞流的返回,而是利用 Reactor 的副作用进行异步记录 return new ChatClientMessageAggregator().aggregateChatClientResponse(responseFlux, aggregatedResponse -> { saveLog(chatClientRequest, aggregatedResponse, originalUserText); }); } /** * 从请求中提取最后一条用户消息的内容 */ private String extractUserText(ChatClientRequest request) { try { return request.prompt().getInstructions().stream() .filter(m -> m.getMessageType() == MessageType.USER) .reduce((first, second) -> second) // 获取最后一条,通常是当前用户的提问 .map(Message::getText) .orElse(""); } catch (Exception e) { log.warn("Failed to extract user text", e); return ""; } } /** * 保存聊天日志到数据库 * * @param request 请求对象,包含上下文信息(如 sessionId) * @param response 响应对象,包含 AI 的回答 * @param userText 提取出的用户原始问题 */ private void saveLog(ChatClientRequest request, ChatClientResponse response, String userText) { try { // 1. 获取会话 ID String sessionId = (String) request.context().get(ChatMemory.CONVERSATION_ID); if (sessionId == null) { return; } // 2. 尝试获取用户 ID (从 ChatRecord 表中查找) Long userId = null; SpringAiChatRecord chatRecord = chatRecordService.getById(sessionId); if (chatRecord != null && chatRecord.getUserId() != null) { try { userId = Long.parseLong(chatRecord.getUserId()); } catch (NumberFormatException e) { log.warn("Invalid user ID format: {}", chatRecord.getUserId()); } } // 3. 保存用户提问日志 if (userText != null && !userText.isEmpty()) { SpringAiChatMessageLog userLog = new SpringAiChatMessageLog() .setSessionId(sessionId) .setUserId(userId) .setMessageType("USER") .setContent(userText) .setCreateTime(LocalDateTime.now()); messageLogService.save(userLog); } // 4. 保存 AI 回复日志 if (response.chatResponse() != null && response.chatResponse().getResult() != null && response.chatResponse().getResult().getOutput() != null) { String assistantContent = response.chatResponse().getResult().getOutput().getText(); SpringAiChatMessageLog assistantLog = new SpringAiChatMessageLog() .setSessionId(sessionId) .setUserId(userId) .setMessageType("ASSISTANT") .setContent(assistantContent) .setCreateTime(LocalDateTime.now()); messageLogService.save(assistantLog); } } catch (Exception e) { log.error("Error saving chat history log", e); } } } ``` ### 4.2 深度代码解析 这段代码虽然不长,但每一处设计都暗藏玄机。 #### 1. 为什么 `getOrder()` 要返回 `Ordered.HIGHEST_PRECEDENCE`? ```java @Override public int getOrder() { return Ordered.HIGHEST_PRECEDENCE; } ``` **这是最关键的一点!** 在 Spring AI 中,Advisor 是链式执行的。如果你使用了 RAG(检索增强生成),通常会有一个 `QuestionAnswerAdvisor`。这个 RAG Advisor 做的事情是:拿到你的问题 -> 去向量数据库搜索 -> 把搜索结果拼接成一段很长的 Prompt -> 替换掉你的原始问题 -> 传给大模型。 如果我们不设置最高优先级(让我们的 Advisor **第一个**执行),那么我们拦截到的 `ChatClientRequest` 里包含的可能就不是用户原本写的“你好”,而是一大段包含了上下文文档的 RAG Prompt。保存那样的日志对用户来说是不可读的。 设置 `HIGHEST_PRECEDENCE` 确保了我们在任何 Prompt 修改发生**之前**,就截获了用户的原始输入。 #### 2. `extractUserText` 的逻辑 ```java request.prompt().getInstructions().stream() .filter(m -> m.getMessageType() == MessageType.USER) .reduce((first, second) -> second) ``` 一个 Prompt 请求中可能包含多条消息(System Message, User Message, Assistant Message...)。我们只关心**用户当前说的这句话**。 * `.filter(...)`:只筛选用户消息。 * `.reduce(...)`:取最后一条。因为在携带历史上下文的情况下,Prompt 里可能有之前几轮的 User Message,但最后一条肯定才是当前最新的提问。 #### 3. `adviseStream` 的无感聚合 ```java return new ChatClientMessageAggregator().aggregateChatClientResponse(responseFlux, aggregatedResponse -> { saveLog(chatClientRequest, aggregatedResponse, originalUserText); }); ``` 这里使用了 Reactor 的响应式编程技巧。`aggregateChatClientResponse` 方法会返回一个新的 Flux,这个 Flux 对前端来说和原来一样,依然是流式的。但在服务器端内部,它挂载了一个“钩子”,等所有流数据跑完后,它会把拼好的完整结果传给我们的 lambda 表达式。 这样做的好处是:**日志记录完全不影响用户的首字延迟(Time to First Token)**,用户依然可以秒看流式输出,而我们的落库操作是在流结束后的异步线程中完成的。 ##### 注意:这段代码中没有实现Function Calling调用的存储逻辑,可以自行实现。 --- ## 五、 数据库设计与配置 ### 5.1 数据库表结构 为了配合上述 Advisor,我们需要一张表来存储日志。这里提供一个标准的 SQL 建表语句: ```sql -- 用户聊天记录日志表 create table spring_ai_chat_message_log ( id bigint auto_increment comment '主键ID' primary key, session_id varchar(50) not null comment '会话ID,对应 ChatMemory 中的 conversationId', user_id bigint unsigned null comment '用户ID,用于关联业务用户', message_type varchar(20) not null comment '消息类型: USER (提问), ASSISTANT (回答)', content longtext null comment '消息内容,使用 LongText 防止长文本截断', tool_calls longtext null comment '工具调用信息(JSON),预留字段', create_time timestamp default CURRENT_TIMESTAMP not null comment '创建时间' ) comment '用户聊天记录日志表'; -- 建立索引,加速查询 create index idx_session_id on spring_ai_chat_message_log (session_id); create index idx_user_id on spring_ai_chat_message_log (user_id); ``` **设计要点**: * **`session_id`**:这是关联上下文的核心,必须有索引。 * **`content`**:一定要用 `longtext`。大模型的回复(特别是写代码或写文章时)很容易超过 `varchar` 的限制。 * **`tool_calls`**:这是一个预留字段。如果你的大模型使用了 Function Calling(工具调用),你可能还想记录它调用了什么工具、传了什么参数。目前的实现暂未包含此逻辑,可根据业务自行扩展。 ### 5.2 配置 ChatClient 万事俱备,只欠东风。我们需要将写好的 `ChatHistoryRecordAdvisor` 注册到全局的 `ChatClient` 中。 ```java @Configuration public class ChatConfig { @Bean public ChatClient serviceChatClient(AlibabaOpenAiChatModel model, ChatMemory chatMemory, VectorStore vectorStore, ChatHistoryRecordAdvisor chatHistoryRecordAdvisor) { // 注入我们的 Advisor return ChatClient.builder(model) .defaultAdvisors( // 1. 日志 Advisor (官方) SimpleLoggerAdvisor.builder().build(), // 2. 【核心】我们要添加的历史记录 Advisor chatHistoryRecordAdvisor, // 3. 上下文记忆 Advisor (负责短期记忆截断) MessageChatMemoryAdvisor.builder(chatMemory).build(), // 4. RAG 检索增强 Advisor QuestionAnswerAdvisor.builder(vectorStore) .searchRequest(SearchRequest.builder().similarityThreshold(0.5d).topK(1).build()) .build() ) // 设置系统 Prompt .defaultSystem("你是一个智能助手...") .build(); } } ``` **配置详解**: * **注入顺序**:虽然我们在 `ChatHistoryRecordAdvisor` 代码里写了 `HIGHEST_PRECEDENCE`,但在 `defaultAdvisors` 方法中显式添加它依然是必要的。 * **共存关系**:请注意,`ChatHistoryRecordAdvisor` 和 `MessageChatMemoryAdvisor` 是**同时存在**的。 * `ChatHistoryRecordAdvisor`:负责把**每一句话**都完整地记入数据库,不做任何删除。 * `MessageChatMemoryAdvisor`:负责维护一个**滑动窗口**(比如最近 10 条),只把这 10 条发给大模型。 * 两者配合,既保证了大模型不会上下文溢出,又保证了数据库里有永久的查阅记录。 --- ## 六、 总结 通过本文的探索,我们纠正了一个常见的误区:**不要试图用 ChatMemory 来做持久化的业务日志**。 1. **ChatMemory (短期记忆)**:它是给**大模型**看的。为了节省 Token,它必须健忘,必须丢弃旧消息。 2. **Advisor (长期日志)**:它是给**人**看的。利用 AOP 切面思想,我们在大模型的输入输出关口设立“哨兵”,忠实地记录下每一次对话。 这种**读写分离**的架构,不仅解决了“数据消失”的 Bug,还解耦了业务逻辑与 AI 框架逻辑,是构建生产级 AI 应用的最佳实践。 现在,你的数据库里不仅有了永远不会消失的对话历史,你的大模型也依然跑得飞快。这,就是架构的艺术。 --- > **相关阅读**: > [Spring AI + MySQL 实现会话记忆持久化:彻底搞懂 ChatMemoryRepository](https://www.codefather.cn/post/2011766454579478529) > [SpringAI1.1.2官方文档](https://docs.spring.io/spring-ai/reference/)

Spring AI 工具调用回调与流式前端展示的完整落地方案

## 那些坑 用 SpringAI 重写 [零代码生成](https://www.codefather.cn/course/1948291549923344386) 时,前端展示工具调用这件事把我卡住了。 Langchain4j 写回调多舒服啊 ```java public interface StreamingChatResponseHandler { default void onPartialToolCall(PartialToolCall partialToolCall) {} default void onPartialToolCall(PartialToolCall partialToolCall, PartialToolCallContext context) {} default void onCompleteToolCall(CompleteToolCall completeToolCall) {} void onCompleteResponse(ChatResponse completeResponse); void onError(Throwable error); } ``` 再看看 `@ToolMemoryId`,直接往方法参数里一扔,conversationId 就到手了,多省心: ```java class Tools { @Tool String addCalendarEvent(CalendarEvent event, @ToolMemoryId memoryId) { // memoryId 直接能用 } } ``` SpringAI 呢?这些它都没有(也有可能是我没找到)。adviseStream 工具调用的时候也感知不到 ## 为什么一定要 conversationId? 主要有下面几点 1. 生成的代码需要区分目录,方便管理 2. 隔离每个单独 APP 生成的路径 3. 记录工具调用次数(后续分析用) ## 整体思路 用户发请求 → Ai2ChatClient 接收 → SpringAI 处理 → 切面拦截工具调用 → 事件发布 → 实时推给前端 ![41ae79fb-5207-4d5d-ae8b-2a38219acf83.png](https://pic.code-nav.cn/post_picture/1608460212774109186/RzGfxCeQoDNdXgsC.webp) 就这么几条线。核心其实就三件事:切面拦截、事件发布、流合并。 ## AOP 依赖 ```xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop </artifactId> </dependency> ``` ## 举个例子:TodoList 工具 这是我们项目里实际在用的工具类: > 具体提示词参考的是 OpenCode 的 https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/tool/todoread.txt ```java @Component public class TodolistTools extends BaseTools { /** * cache */ private static final Cache<String, String> TODOLIST_CACHE = Caffeine.newBuilder() .maximumSize(10_00) .expireAfterWrite(Duration.ofMinutes(30)) .build(); @Tool(description = "Write or update the todo list for current task. " + "Use this to track progress and plan remaining work. " + "Each todo item should be a clear, actionable task. " + "Format: numbered list with status markers like [ ], [x], [>], [-]. " + "Status meanings: [ ] pending, [x] completed, [>] in progress, [-] blocked/cancelled." ) public String todoWrite( @ToolParam(description = "The todo list content to save. Format as a structured list with status indicators.") String todoContent, ToolContext toolContext ) { String conversationId = ConversationIdUtils.getConversationId(toolContext); if (StringUtils.isBlank(todoContent)) { TODOLIST_CACHE.invalidate(conversationId); return "Todo list cleared."; } TODOLIST_CACHE.put(conversationId, todoContent); return "Todo list saved successfully.\n\nCurrent todo list:\n" + todoContent; } @Tool(description = "Read the current todo list for this conversation. " + "Use this to check progress and see what tasks remain. " + "Returns an empty message if no todo list exists yet." ) public String todoRead(ToolContext toolContext) { String conversationId = ConversationIdUtils.getConversationId(toolContext); String todoContent = TODOLIST_CACHE.getIfPresent(conversationId); if (StringUtils.isBlank(todoContent)) { return "No todo list for this conversation."; } return "Current todo list:\n" + todoContent; } @Override String getToolName() { return "Todo List Tool"; } @Override String getToolDes() { return "Read and write task todo lists to track progress"; } } ``` ## 切面是怎么工作的 SpringAI 没给我们留回调接口,那就自己造一个。切面这东西好就好在不改动原代码,加个注解就能生效。 我们用 `@Before` 抓工具调用开始的那一刻,用 `@AfterReturning` 抓调用结束的那一刻。工具类丢给 Spring 容器,切面自己就找上门来了。 ```java @Aspect @Component @Slf4j public class ToolContextAspect { private final ToolEventPublisher toolEventPublisher; public ToolContextAspect(ToolEventPublisher toolEventPublisher) { this.toolEventPublisher = toolEventPublisher; } @Pointcut("execution(* com.leikooo.codemother.ai.tools..*.*(..)) && @annotation(org.springframework.ai.tool.annotation.Tool)") public void anyToolExecution() { } @Before("anyToolExecution()") public void beforeToolCall(JoinPoint joinPoint) { ToolContext toolContext = getToolContext(joinPoint); String className = joinPoint.getTarget().getClass().getSimpleName(); String methodName = joinPoint.getSignature().getName(); if (Objects.isNull(toolContext)) { log.warn("SKIPPED: Tool method [{}.{}] was called but does not accept ToolContext as a parameter.", className, methodName); return; } handleToolContext(toolContext, className, methodName, null, true); } @AfterReturning(pointcut = "anyToolExecution()", returning = "result") public void afterToolCall(JoinPoint joinPoint, Object result) { ToolContext toolContext = getToolContext(joinPoint); String className = joinPoint.getTarget().getClass().getSimpleName(); String methodName = joinPoint.getSignature().getName(); if (toolContext != null) { handleToolContext(toolContext, className, methodName, result, false); } } private void handleToolContext(ToolContext context, String className, String methodName, Object result, boolean isBefore) { Message message = context.getToolCallHistory().getLast(); AssistantMessage.ToolCall toolCallInfo = ((AssistantMessage) message).getToolCalls().getLast(); String toolCallId = toolCallInfo.id(); String sessionId = ConversationIdUtils.getConversationId(context); if (isBefore) { toolEventPublisher.publishToolCall(sessionId, className, methodName, toolCallId); } else { toolEventPublisher.publishToolResult(sessionId, className, methodName, toolCallId, result); } } /** * toolContext * @param joinPoint joinPoint * @return ToolContext */ private ToolContext getToolContext(JoinPoint joinPoint) { Object[] args = joinPoint.getArgs(); ToolContext toolContext = null; for (Object arg : args) { if (arg instanceof ToolContext) { toolContext = (ToolContext) arg; break; } } return toolContext; } } ``` ![测试结果](https://pic.code-nav.cn/post_picture/1608460212774109186/ANNcLcuquW8gsWoG.webp) ## 事件发布:把消息送出去 这里用到了 Project Reactor 的 Sinks。每个会话一个 Sink,多线程环境下也能正常工作。 ```java @Component public class ToolEventPublisher { private final Map<String, Sinks.Many<ToolEvent>> sinks = new ConcurrentHashMap<>(); private Sinks.Many<ToolEvent> getSink(String sessionId) { return sinks.computeIfAbsent(sessionId, k -> Sinks.many().multicast().onBackpressureBuffer()); } public void publishToolCall(String sessionId, String toolName, String methodName, String toolCallId) { getSink(sessionId).tryEmitNext(new ToolEvent(sessionId, "tool_call", toolName, methodName, toolCallId, null)); } public void publishToolResult(String sessionId, String toolName, String methodName, String toolCallId, Object result) { getSink(sessionId).tryEmitNext(new ToolEvent(sessionId, "tool_result", toolName, methodName, toolCallId, result)); } public Flux<ToolEvent> events(String sessionId) { return getSink(sessionId).asFlux(); } public void complete(String sessionId) { Sinks.Many<ToolEvent> sink = sinks.remove(sessionId); if (sink != null) sink.tryEmitComplete(); } public record ToolEvent(String sessionId, String type, String toolName, String methodName, String toolCallId, Object result) {} } ``` ## 流怎么合并到主响应里 这里有两种玩法 ### 玩法一:自己动手丰衣足食 直接在业务方法里把两个流 merge 起来。好处是代码都在明面上,坏处是每个方法都得写一遍。 > **一个小细节**:`mainFlux` 结束时会触发 `doFinally`,但 `toolEventFlux` 不会。所以必须在 `doFinally` 里手动调用 `complete` 关掉事件流。否则这个流会一直挂在那儿,等不到终点。 > 前端就会一直这样: ![image.png](https://pic.code-nav.cn/post_picture/1608460212774109186/k9Jo8TKh8EilukKP.webp) ```java @Component public class Ai2ChatClient { private final ChatClient chatClient; private ToolEventPublisher toolEventPublisher; public Ai2ChatClient(ChatModel openAiChatModel, TodolistTools todolistTools, ToolAdvisor toolAdvisor, ToolEventPublisher toolEventPublisher) { this.toolEventPublisher = toolEventPublisher; this.chatClient = ChatClient .builder(openAiChatModel) .defaultTools(todolistTools) .build(); } public Flux<String> chat2Ai(String msg, String appId) { Flux<String> mainFlux = chatClient.prompt() .system(""" You are a helpful, precise, and reliable AI assistant. Respond clearly and concisely. Prioritize correctness, safety, and practicality. If information is uncertain, state the uncertainty explicitly. """) .user(msg) .advisors(advisorSpec -> advisorSpec.param(CONVERSATION_ID, appId)) .toolContext(Map.of(CONVERSATION_ID, appId)) .stream().content() .doFinally(s -> toolEventPublisher.complete(appId)); Flux<String> toolEventFlux = toolEventPublisher.events(appId) .map(event -> { Object result = Optional.ofNullable(event.result()).orElse(""); String message = switch (event.type()) { case "tool_call" -> String.format("%s: %s", "正在进行工具调用", result); case "tool_result" -> String.format("%s: %s", "工具调用完成", result); default -> ""; }; return String.format("\n\n[选择工具] %s \n\n", message); }); // 合并流 return Flux.merge(mainFlux, toolEventFlux); } } ``` ### 玩法二:把脏活累活扔给 Advisor 写个 StreamAdvisor,让它自己处理流合并。业务代码瞬间清爽了。 ```java /** * @author <a href="https://github.com/lieeew">leikooo</a> * @date 2025/12/31 * @description */ @Slf4j @Component public class ToolAdvisor implements CallAdvisor, StreamAdvisor { private final ToolEventPublisher toolEventPublisher; public ToolAdvisor(ToolEventPublisher toolEventPublisher) { this.toolEventPublisher = toolEventPublisher; } @Override public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) { return callAdvisorChain.nextCall(chatClientRequest); } @Override public Flux<ChatClientResponse> adviseStream(ChatClientRequest chatClientRequest, StreamAdvisorChain streamAdvisorChain) { String appId = ConversationIdUtils.getConversationId(chatClientRequest.context()); Flux<ChatClientResponse> toolEventFlux = getToolEventFlux(appId); Flux<ChatClientResponse> mainFlux = streamAdvisorChain.nextStream(chatClientRequest) .doFinally(signalType -> toolEventPublisher.complete(appId)); return Flux.merge(mainFlux, toolEventFlux); } @Override public String getName() { return "ToolAdvisor"; } @Override public int getOrder() { return Integer.MIN_VALUE + 100; } /** * 工具调用推送流 * @param sessionId sessionId * @return flux */ private Flux<ChatClientResponse> getToolEventFlux(String sessionId) { return toolEventPublisher.events(sessionId) .map(event -> { Object result = Optional.ofNullable(event.result()).orElse(""); final String methodName = event.methodName(); String message = switch (event.type()) { case "tool_call" -> String.format("正在进行工具调用 %s: %s", methodName, result); case "tool_result" -> String.format("工具调用完成 %s: %s", methodName, result); default -> ""; }; return String.format("\n\n[选择工具] %s \n\n", message); }).map(message -> { AssistantMessage assistantMessage = new AssistantMessage(message); Generation generation = new Generation(assistantMessage); ChatResponse chatResponse = ChatResponse.builder() .generations(List.of(generation)) .build(); return ChatClientResponse.builder().chatResponse(chatResponse).build(); }); } } ``` 注册一下,全局生效: ```java @Component public class Ai2ChatClient { private final ChatClient chatClient; public Ai2ChatClient(ChatModel openAiChatModel, FileTools fileTools, ToolAdvisor toolAdvisor) { this.chatClient = ChatClient.builder(openAiChatModel) .defaultTools(fileTools) .defaultAdvisors(toolAdvisor) .build(); } public Flux<String> chat(String msg, String appId) { return chatClient.prompt() .system(""" You are a helpful, precise, and reliable AI assistant. Respond clearly and concisely. Prioritize correctness, safety, and practicality. If information is uncertain, state the uncertainty explicitly. """) .user(msg) .advisors(spec -> spec.param(CONVERSATION_ID, appId)) .toolContext(Map.of(CONVERSATION_ID, appId)) .stream().content(); } } ``` ## 两种方案怎么选 | 看什么 | 方案一自己写 | 方案二用 Advisor | | ---- | ------ | ------------ | | 代码位置 | 业务方法里 | 单独一个类 | | 复用性 | 惨不忍睹 | 一次编写到处使用 | | 代码量 | 挺长 | 业务方法就几行 | 我的建议是使用 advisor ## 怎么拿到 conversationId Langchain4j 那个 `@ToolMemoryId` 是真方便。SpringAI 不给咱们就自己想办法。 在工具方法里加个 `ToolContext` 参数,从里面把 id 拽出来: ```java @Slf4j @Component public class FileWriteTool { @Tool("写入文件到指定路径") public String writeFile( @P("文件的相对路径") String relativeFilePath, @P("要写入文件的内容") String content, ToolContext toolContext ) { String conversationId = toolContext.getContext() .get(ChatMemory.CONVERSATION_ID).toString(); // 接下来就能使用了 } } ``` 这个 id 是在调用链上通过 `toolContext` 传进来的: ```java public Flux<String> chat2AiAdvisor(String msg, String appId) { return chatClient.prompt() .system("你是有用的小助手") .user(msg) .advisors(advisorSpec -> advisorSpec.param(CONVERSATION_ID, appId)) // 这里设置到 ToolContext .toolContext(Map.of(CONVERSATION_ID, appId)) .stream().content(); } ``` ## 跑一下看看 写个测试用例,验证整个链路通不通: ```java @SpringBootTest class Ai2ChatClientTest { @Resource private Ai2ChatClient ai2ChatClient; @Test void chat2Ai() throws InterruptedException { Flux<String> flux = ai2ChatClient.chat("帮我生成一个企业级别的后端,帮我生成 todolist", "12345"); flux.doOnNext(System.out::println).subscribe(); // 这里需要睡眠主线程,否则拿不到结果 Thread.sleep(10000); } } ``` 跑起来,控制台陆陆续续打出日志,工具调用的事件也正常推送。整个链路是通的。 ![image.png](https://pic.code-nav.cn/post_picture/1608460212774109186/TlOcMzH6nbdrEH2E.webp)

Spring AI 1.0.0 + MySQL 实现会话记忆持久化:彻底搞懂 ChatMemoryRepository

在做大模型应用时,“会话记忆”几乎是刚需: - 用户希望多轮对话是连续的; - 系统希望对话历史能存数据库,方便**会话列表、历史回看、数据分析**。 Spring AI 1.0.0 已经把这一块封装得很优雅,其中的关键角色就是这两个: - **ChatMemoryRepository**:负责「消息如何落到 MySQL、如何查出来」 - **ChatMemory(MessageWindowChatMemory)**:负责「每次调用大模型时,用哪些历史消息」 本文以我的项目为例,重点讲清楚这件事: > 我的 Controller 层只注入了一个 `ChatMemoryRepository` Bean,就能完成会话记忆的管理和 MySQL 持久化。**这个 Bean 到底做了什么?为什么不用自己写 SQL?** --- ## 一、整体架构:两层「记忆」分工 先把大的图讲清楚,避免一上来就被各种类名绕晕。 可以把 Spring AI 的会话记忆拆成两层来看: 1. **上层:ChatMemory(记忆管理器)** - 对 ChatClient 提供「添加消息」「获取历史」的统一接口。 - 决定: - 每次调用大模型时,带多少条历史消息(记忆窗口); - 用什么策略裁剪历史(比如只保留最近 50 条)。 2. **下层:ChatMemoryRepository(持久化存储引擎)** - 负责和 MySQL 打交道。 - 决定: - 一条对话消息如何序列化成表里的 `content` 字段; - 按 `conversation_id` + 时间顺序查出整段对话; - 删除指定会话的所有消息。 在我的项目里: - ChatClient 使用的是上层的 `ChatMemory`(由 `MessageWindowChatMemory` + `JdbcChatMemoryRepository` 组成),负责「让模型有记忆」; - 而对外提供「历史记录接口」的 `ChatHistoryController`,直接注入的是下层的 `ChatMemoryRepository`,负责「查历史、删历史」。 这一点非常关键: > **你在 Controller 里看到的 `ChatMemoryRepository`,就是那条连通 Spring AI 和 MySQL 的“地线”。** --- ## 二、配置内容:项目里到底配了什么? ### 1. Maven 依赖 在 `pom.xml` 中,我使用 Spring AI 官方 JDBC 记忆模块 + MySQL 驱动: ```xml <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> </dependency> ``` Spring Boot + Starter 的好处就是:只要依赖在,配好数据源,Spring AI 会自动帮你创建 `JdbcChatMemoryRepository` 这种 Bean。 --- ### 2. application 配置:启用 JDBC 记忆 + 自动建表 我开启了 Spring AI 的 JDBC 记忆功能,并指定建表脚本: ```yaml spring: ai: chat: memory: repository: jdbc: initialize-schema: always # 自动建表 schema: classpath:sql/schema-mysql.sql # 表结构脚本 datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/campusai?... username: root password: root ``` 这几行配置干了两件事: 1. 告诉 Spring AI: - 我要用 JDBC 作为 ChatMemoryRepository 的实现(其实就是项目里的 `JdbcChatMemoryRepository`)。 2. 告诉 Spring Boot: - 启动时从 `sql/schema-mysql.sql` 里加载建表 SQL,如果表不存在就建表。 --- ### 3. 数据库表结构:SPRING_AI_CHAT_MEMORY 建表脚本大致如下: ```sql CREATE TABLE IF NOT EXISTS SPRING_AI_CHAT_MEMORY ( `id` BIGINT(19) NOT NULL AUTO_INCREMENT, `conversation_id` VARCHAR(36) NOT NULL, `content` TEXT NOT NULL, `type` VARCHAR(10) NOT NULL, `timestamp` TIMESTAMP NOT NULL, PRIMARY KEY (`id`), INDEX `SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_TIMESTAMP_IDX` (`conversation_id`, `timestamp`), CONSTRAINT TYPE_CHECK CHECK (type IN ('USER', 'ASSISTANT', 'SYSTEM', 'TOOL')) ); ``` 几个字段含义: - `conversation_id`:会话唯一标识(你在业务里用的 `chatId`)。 - `type`:消息类型(用户 / 助手 / 系统 / 工具)。 - `content`:消息内容及元数据(Spring AI 负责序列化/反序列化)。 - `timestamp`:消息时间,用来按时间顺序还原整段对话。 --- ## 三、核心配置:ChatMemory Bean 到底做了什么? 看下配置类 ```java @Bean public ChatMemory chatMemory(JdbcChatMemoryRepository chatMemoryRepository) { return MessageWindowChatMemory.builder() .chatMemoryRepository(chatMemoryRepository) .maxMessages(50) .build(); } ``` 这段代码可以分三层理解: 1. **JdbcChatMemoryRepository 来源** - 来自上面提到的 Starter + application 配置; - 它已经和 `SPRING_AI_CHAT_MEMORY` 表绑定好了: - `save` 时写表; - `findByConversationId` 时按 `conversation_id + timestamp` 查表。 2. **MessageWindowChatMemory 是一个“带记忆窗口的大脑”** - 它实现了 `ChatMemory` 接口,但**不直接操作数据库**; - 内部会调用 `chatMemoryRepository` 去读写 MySQL; - 它的核心职责是: - 每次调用大模型时,从仓库里取出最近 `maxMessages` 条消息; - 把新的问答消息再写回仓库。 3. **maxMessages(50):记忆窗口大小** - 只把最近 50 条对话消息当成上下文传给大模型; - 这样可以避免无限累积上下文导致 Token 爆炸,同时又能兼顾一定的“记忆深度”。 一句话总结这段 Bean 配置: > **ChatMemory = 记忆窗口策略(MessageWindowChatMemory) + 底层存储(JdbcChatMemoryRepository)** 而这个 `ChatMemory` 会在创建 `ChatClient` 时被作为 Advisor 注入,负责「请求大模型时自动带上数据库里的对话历史」。 --- ## 四、真正的主角:ChatMemoryRepository 是怎么“自动记忆 + 持久化”的? Controller 里其实并没有用 `ChatMemory`,而是直接用了 `ChatMemoryRepository`: ```java @RestController @RequestMapping("/ai/history") @RequiredArgsConstructor public class ChatHistoryController { private final ChatMemoryRepository chatMemoryRepository; private final ISpringAiChatRecordService recordService; // 新建会话记录(只是保存会话元数据) @RequestMapping("/create") public void create(@RequestBody SpringAiChatRecord record) { recordService.save(record); } // 获取某个会话的聊天记录 @GetMapping("/info/{chatId}") public List<MessageVO> getChatHistory(@PathVariable("chatId") String chatId) { List<Message> messages = chatMemoryRepository.findByConversationId(chatId); return messages.stream().map(MessageVO::new).collect(Collectors.toList()); } // 删除某个会话 @GetMapping("/delete/{chatId}") public Result deleteChatHistory(@PathVariable("chatId") String chatId) { chatMemoryRepository.deleteByConversationId(chatId); recordService.removeById(chatId); return Result.ok(); } } ``` 这里的关键点有三个: ### 1. ChatMemoryRepository 的来源 - 它不是你自己写的接口,而是 Spring AI 在 1.0.0 里提供的**统一持久化接口**。 - Starter 会根据你配置的 JDBC 方案,自动提供一个 `JdbcChatMemoryRepository` 实例,并同时以接口类型 `ChatMemoryRepository` 注入到 Spring 容器中。 - 因此在 Controller 里,只需要: ```java private final ChatMemoryRepository chatMemoryRepository; ``` 就能拿到完整的「会话消息持久化能力」。 ### 2. findByConversationId:如何“查出一整段对话” 当你调用: ```java List<Message> messages = chatMemoryRepository.findByConversationId(chatId); ``` 底层发生的事情是: 1. 在 `SPRING_AI_CHAT_MEMORY` 表中,按 `conversation_id = chatId` 查询所有记录; 2. 按 `timestamp` 升序排序; 3. 把每一行的 `content` 字段反序列化成 Spring AI 的 `Message` 对象(其中包含角色、文本、工具调用等信息); 4. 最终返回一个 `List<Message>`。 在项目中,又通过MessageVO做了一层转换,使前端拿到的是简单的: ```java public class MessageVO { private String role; // user / assistant / system private String content; // 文本内容 public MessageVO(Message message) { this.role = switch (message.getMessageType()) { case USER -> "user"; case ASSISTANT -> "assistant"; case SYSTEM -> "system"; default -> ""; }; this.content = message.getText(); } } ``` 这样,Controller 就可以直接把 VO 列表返回给前端用于渲染历史消息。 ### 3. deleteByConversationId:如何“一键清空某个会话的记忆” 当你调用: ```java chatMemoryRepository.deleteByConversationId(chatId); ``` 底层则是: 1. 在 `SPRING_AI_CHAT_MEMORY` 表中,按 `conversation_id = chatId` 执行 `DELETE`; 2. 这个会话的所有消息(包括用户问、AI 答)都会被彻底清除。 配合业务表 `spring_ai_chat_record` 的删除: ```java recordService.removeById(chatId); ``` 就实现了「删除会话 = 删除会话元数据 + 删除会话所有记忆」。 --- ## 五、从一次真实请求看完整流程 以“智能客服”接口为例,一次请求的流程大致如下(简化版): 1. 前端调用:`/ai/service?prompt=你好&chatId=abc123` 2. Controller 调用 `ChatClient`: - 通过 `.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, chatId))` 把 `chatId` 传给 `ChatMemory`; 3. `ChatMemory(MessageWindowChatMemory)` 调用 `ChatMemoryRepository`: - `findByConversationId("abc123")`:从 MySQL 查出历史消息; - 截取最近 `maxMessages(50)` 条,作为上下文发给大模型; 4. 大模型生成回复后,`ChatMemory` 再通过 `ChatMemoryRepository`: - 把本轮 USER 消息和 ASSISTANT 消息写入 `SPRING_AI_CHAT_MEMORY`; 5. 当你访问历史接口 `/ai/history/info/abc123` 时: - `ChatHistoryController` 直接调用同一个 `ChatMemoryRepository.findByConversationId("abc123")`,把历史消息查出来给前端展示。 可以看到: > - **写入/读取记忆**:是由 `ChatMemory` 和 `ChatMemoryRepository` 配合完成的; > - **对外暴露历史接口**:只需要直接用 `ChatMemoryRepository` 的查询/删除能力即可。 --- ## 六、总结 结合这个项目,我们可以把 Spring AI + MySQL 会话记忆总结成三句话: 1. **ChatMemoryRepository = 会话消息的 MySQL 持久化引擎** - 提供 `findByConversationId`、`deleteByConversationId` 等方法; - 自动映射到 `SPRING_AI_CHAT_MEMORY` 表,无需手写 SQL。 2. **ChatMemory(MessageWindowChatMemory) = 带窗口策略的“记忆大脑”** - 上接 ChatClient,下接 ChatMemoryRepository; - 决定「每次请求带多少历史」以及「如何写回历史」。 3. **Controller 可以直接「拿仓库查历史」** - 你在 `ChatHistoryController` 中注入的 `ChatMemoryRepository` 既是 ChatClient 的底层存储,又是你实现「会话列表/详情/删除」的入口。 理解了这两层分工,再回头看你的 Controller 和配置 Bean,就会非常顺畅: - 配置里说明的是:**记忆如何落库 + 如何控制窗口**; - Controller 里用的是:**底层仓库提供的查/删接口**。

Spring 源码阅读笔记 - 浅析 bean 从读取到注册入 beanFactory 的流程

&emsp;&emsp;在 Spring 框架中管理 Bean 的注册过程,可以通过一个形象的比喻来理解:将 Bean 视作一件件“商品”。它可能由不同的创作者(作者)设计,拥有自己的产地(Class),可以是独一无二的艺术品(Singleton),也可以是工厂流水线上批量生产的复制品(Prototype)。 &emsp;&emsp;要让一个 Bean 被 Spring 容器识别,首先需要“登记”它的信息。以注解配置为例,我们可以通过 @Bean、@Service 等注解将一个普通的 POJO 声明为 Bean;或者通过 @ComponentScan 批量扫描某个包路径下的所有类,自动将其注册为 Bean。 &emsp;&emsp;随后,AnnotationConfigApplicationContext 会接手这些 Bean,并通过内置的 BeanDefinitionReader 解析它们的元数据,包括: 来源地(Class 类型) 作用域(Scope,如单例或原型) &emsp;&emsp;解析后的信息会被封装成一个 GenericBeanDefinition 对象。接下来,需要将 Bean 的名称与其定义建立映射,并存入一个“容器”中——这就是 BeanFactory 的核心作用。具体来说,是由 DefaultListableBeanFactory 来实现的。其内部维护了一个默认容量为 256 的 ConcurrentHashMap,Bean 的注册实质上就是执行一次 map.put(beanName, beanDefinition)。同时,它也提供了 getBean() 方法,用于根据名称获取对应的 Bean 实例。 &emsp;&emsp;虽然 Bean 的实际注册逻辑是在 DefaultListableBeanFactory 中完成的,但从调用链路来看,我们通常沿着这样的路径: text AnnotationConfigApplicationContext → reader.doRegisterBean() → BeanDefinitionRegistryUtils.registerBeanDefinition() → registry.registerBeanDefinition() &emsp;&emsp;表面上看,这个过程似乎并未直接涉及 BeanFactory。这是因为 Spring 在源码层做了高度的抽象:AnnotationConfigApplicationContext 和 DefaultListableBeanFactory 有一个共同的父级接口 BeanDefinitionRegistry,其中定义了 Bean 注册的方法。而 DefaultListableBeanFactory 实现了这个接口。 &emsp;&emsp;实际上,在 GenericApplicationContext 初始化时,会内部创建一个 DefaultListableBeanFactory 实例。AnnotationConfigApplicationContext 继承自 GenericApplicationContext,并实现了 BeanDefinitionRegistry 接口,从而在调用注册方法时,会将自身作为 registry 参数传入,最终委托给内部的 DefaultListableBeanFactory 完成真正的注册操作。 &emsp;&emsp;简而言之,Spring 通过多层接口与实现类的协作,将功能职责清晰分离,虽然在调用链路上显得有些“绕”,但这样的设计极大地提升了框架的灵活性与可扩展性。

Spring 源码深入讲解 - 5 创建自己的spring源码项目 笔记

BeanFactory 作为工厂,实现 Bean 的生产和获取,BeanDefinition 是 Bean 生产的原料,定义了 Bean 所属的 class、单例还是多例等信息,ApplicationContext 则像是一个管理者,负责将原料注册到工厂,以及从工厂获取 Bean 产品(getBean),三者协作,实现了 Bean 解析、注册。生产、获取、生命周期管理的全过程。

【SpringAI源码解析】RetrievalAugmentationAdvisor多轮对话历史记录解析

### RAG多轮对话历史记录解析 #### Q:Query中的history从何而来? A:history其实是基于ChatMemory的,我们在chatclient中先后配置了MessageChatMemoryAdvisor和retrievalAugmentationAdvisor。 ```java ChatClient chatClient = ChatClient.builder(chatModel) .defaultAdvisors(new MyLoggerAdvisor(), MessageChatMemoryAdvisor.builder(chatMemory).build()//添加Memory , retrievalAugmentationAdvisor//RAG ) .build(); ``` 学过Advisor的都知道,这东西可以理解成一个过滤器Filter,在MessageChatMemoryAdvisor中会**创建一个新的****ChatClientRequest**,并把有所的**历史会话记录**都放在这个**新ChatClientRequest**中,那么该Advisor放行后,**传入下一个Advisor的ChatClientRequest就带有了所以的历史记录**。 ```java @Override public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) { String conversationId = getConversationId(chatClientRequest.context(), this.defaultConversationId); // 1. Retrieve the chat memory for the current conversation. List<Message> memoryMessages = this.chatMemory.get(conversationId); // 2. Advise the request messages list. List<Message> processedMessages = new ArrayList<>(memoryMessages); processedMessages.addAll(chatClientRequest.prompt().getInstructions());//获取全部的Message信息 // 3. Create a new request with the advised messages. ChatClientRequest processedChatClientRequest = chatClientRequest.mutate() .prompt(chatClientRequest.prompt().mutate().messages(processedMessages).build()) .build(); // 4. Add the new user message to the conversation memory. UserMessage userMessage = processedChatClientRequest.prompt().getUserMessage();//逆序获取最新的UserMessage this.chatMemory.add(conversationId, userMessage); return processedChatClientRequest; } ``` #### Q:RAG的Advisor就一定会读取所有的history信息作为向量搜索的文本吗? A:答案是NO!,看看后面会发生什么。 我们在使用RAG的时候是开启了**RetrievalAugmentationAdvisor**或**QuestionAnswerAdvisor**。 而在QuestionAnswerAdvisor的源码中,其内部befor方法只使用了当前的UserMessage,也就是在向量转换检索时,并没有使用history信息融合,比如说在多轮对话中历史记录是: * UserMessage:“小米手机怎么样” * AssistantMessage:“小米手机xxxxxx很不错” * 现在用户输入(UserMessage):“它多少钱?” * 然后**QuestionAnswerAdvisor**就拿着**“它多少钱?”**向量化去数据库搜索。 * **数据库懵了:”它“是谁呢???? 检索不到任何关于“小米手机”的文档。**搜索结果为null。 * 最终导致大模型收到的Prompt信息有:上下文历史记录(开启了ChatMemeory才会有)、RAG搜到的Document(可能为null,或者乱七八糟其他的东西)、当前message“它多少钱?”。 * 最终大模型告诉你(AssistantMessage):“抱歉,我不知道小米手机的具体信息。” **这不炸了吗?**所以说**多轮对话RAG**还得是**RetrievalAugmentationAdvisor** 那么我们看看**RetrievalAugmentationAdvisor**是怎么完成基于历史对话的语义改写的。 RetrievalAugmentationAdvisor类部分源码如下: ```java public ChatClientRequest before(ChatClientRequest chatClientRequest, @Nullable AdvisorChain advisorChain) { Map<String, Object> context = new HashMap(chatClientRequest.context()); // 【关键点】这里解答了之前的疑问:Query中的History是哪里来的? // 它是从 chatClientRequest 里现场提取 UserMessage(text) 和 Instructions(history) 组装的 Query originalQuery = Query.builder() .text(chatClientRequest.prompt().getUserMessage().getText())//当前的usermessage .history(chatClientRequest.prompt().getInstructions())//获取上history信息 .context(context).build(); Query transformedQuery = originalQuery; // 遍历所有的 Transformer,对 Query 进行一轮轮的修改 // 例如:QueryRewrite 就在这里发生 for(QueryTransformer queryTransformer : this.queryTransformers) { transformedQuery = queryTransformer.apply(transformedQuery); } // 如果配置了扩展器,就把一个 Query 变成 List<Query> (比如扩充关键词) // 如果没配置,列表里就只有那一个 transformedQuery List<Query> expandedQueries = this.queryExpander != null ? this.queryExpander.expand(transformedQuery) : List.of(transformedQuery); // 针对每一个 Query,都开启一个异步任务去检索 //如果 Query 被扩展成了 3 个,这里会同时发 3 个请求给 VectorStore,而不是串行排队 Map<Query, List<List<Document>>> documentsForQuery = (Map)expandedQueries.stream() .map((query) -> CompletableFuture.supplyAsync( () -> this.getDocumentsForQuery(query), // 调用 retriever.retrieve(query) this.taskExecutor)) .toList() .stream() .map(CompletableFuture::join) .collect(Collectors.toMap(Map.Entry::getKey, (entry) -> List.of((List)entry.getValue()))); // 把刚才并发查回来的多组文档,合并成一组,默认是 ConcatenationDocumentJoiner List<Document> documents = this.documentJoiner.join(documentsForQuery); // 拿着合并后的一大堆文档,进行后处理 // 【关键】Cross-Encoder Rerank (精排) 就可以放在这里执行! for(DocumentPostProcessor documentPostProcessor : this.documentPostProcessors) { documents = documentPostProcessor.process(originalQuery, documents); } // 1. 把最终选定的文档,存入 Context (为了 after 阶段使用) context.put("rag_document_context", documents); // 2. 【核心】把文档内容拼接到用户的问题里,queryAugmenter默认使用ContextualQueryAugmenter //负责把 documents 变成 String,填入 Prompt 模板 Query augmentedQuery = this.queryAugmenter.augment(originalQuery, documents); // 3. 修改 Request,把原来的"User Message"替换成"带上下文的 User Message" return chatClientRequest.mutate().prompt(chatClientRequest.prompt().augmentUserMessage(augmentedQuery.text())).context(context).build(); } private Map.Entry<Query, List<Document>> getDocumentsForQuery(Query query) { List<Document> documents = this.documentRetriever.retrieve(query); return Map.entry(query, documents); } public ChatClientResponse after(ChatClientResponse chatClientResponse, @Nullable AdvisorChain advisorChain) { //xxxxx } ``` 可以看到第5行构建Query对象时把chatClientRequest中所有的Message都拿出来了(这归功于ChatMemory在上一层Advisor中构建了一个新的Request存放了所有的Message)。并且经过了**QueryTransformer,有了 RewriteQueryTransformer 不就可以重写Query了吗?** **开干!** ```java @Bean public ChatClient coffeeChatClient(DashScopeChatModel dashScopeChatModel, MyRedisChatMemory myRedisChatMemory, ToolCallbackProvider toolCallbackProvider, VectorStore vectorStore) { VectorStoreDocumentRetriever vectorStoreDocumentRetriever = VectorStoreDocumentRetriever.builder() .topK(3) .similarityThreshold(0.5) .vectorStore(vectorStore) .build(); // 1. 定义改写器 (需要用到 chatModel,因为要调大模型) QueryTransformer rewriteTransformer = RewriteQueryTransformer.builder() .chatClientBuilder(ChatClient.builder(dashScopeChatModel)) // 绑定模型 .build(); RetrievalAugmentationAdvisor retrievalAugmentationAdvisor = RetrievalAugmentationAdvisor.builder() .documentRetriever(vectorStoreDocumentRetriever)//检索器 .queryTransformers(rewriteTransformer) // 【关键】注入改写器! .build(); ChatClient chatClient = ChatClient.builder(dashScopeChatModel) .defaultAdvisors(new MyLoggerAdvisor(), MessageChatMemoryAdvisor.builder(myRedisChatMemory).build()//添加Memory , retrievalAugmentationAdvisor//RAG ) .defaultSystem("你是小鱼茶室的服务员,你需要回答用户的问题,可以使用知识库补充语料,并时按需使用工具") .defaultTools(new DateTimeTools()) // .defaultToolCallbacks(toolCallbackProvider.getToolCallbacks()) .build(); return chatClient; } ``` **测试** ![](https://pic.code-nav.cn/post_picture/1902726887905742849/bmfLCPk4SdeH2kkb.png) 改写后详细信息 ![](https://pic.code-nav.cn/post_picture/1902726887905742849/26ZI90MJcY1nMCO2.webp) ![](https://pic.code-nav.cn/post_picture/1902726887905742849/dHBuVor2a5xvG9N5.webp) 第二轮对话 ![](https://pic.code-nav.cn/post_picture/1902726887905742849/x5F8HfqNWkn685gG.png) 可以看出把“它”改写成了“这款饮品”说明起效果了。虽然没有把“它”直接改写为“咖啡”,但是改写成“这款饮品”已经能看出增强语义了,大家可以多试几次兴许有一次能修改的很完美。 **总结** **RewriteQueryTransformer成功改写了query**拿到新的transformedQuery去向量数据库查询知识,注意!这里并不是修改Prompt记录,只是根据Prompt和History重写Query去向量数据库查找对应的知识,增加知识的命中率,并不影响chatMemory记录Prompt,并且日志也会输出原始的Query信息,所以还是debug模式去验证是否有效。 | 特性 | QuestionAnswerAdvisor (简单版) | RetrievalAugmentationAdvisor (高级版) | | --- | --- | --- | | Prompt 拼接 | ✅ 负责拼接文档 (历史由别的 Advisor 拼) | ✅ 负责拼接文档 (历史由别的 Advisor 拼) 检索依据 | ❌ 只看当前这一句话 (容易搜不到) | ✅ 看历史 + 当前话 (通过 Transformer 重写 Query) 是否有 Query 重写 | 默认没有 (需要自己扩展) | ✅ 内置支持 (queryTransformers) 适用场景 | 单轮问答 (如搜索引擎) | 多轮对话 (如聊天机器人) |

Spring AI Tool 实现 邮箱发送

## 目的 > 开发自定义Tool,实现 邮箱 的发送功能,并在调用AI 大模型中使用 ### 技术选型 javax中包含了 `mail` 操作相关的依赖,但构建起来略显繁琐。这里我们使用 `Hutool` 包实现: ``` <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.37</version> </dependency> ``` 邮箱选择上,我们选择国内最常见的**QQ邮箱** > QQ邮箱提供 **SMTP/IMAP服务**,PC端登陆QQ邮箱 -> 设置 -> 账号与安全 -> 安全设置 -> 开启 ‘POP3/IMAP/SMTP/Exchange/CardDAV 服务’ > 注:开启服务需要先绑定手机号 开通后,系统会提供一个生成好的授权码。同时,QQ邮箱提供了多种的设置方式: ![image.png](https://pic.code-nav.cn/post_picture/1943997792107290625/HN3HJk6KWFsVyveE.webp) 不同的设置方法对应不同的服务器和端口。 ### 开发实践 我们使用 `Hutool` 中的 `MailAccount` 实现,其最主要的参数有:`host`, `port`,`from`,`pass`: | 参数名称 | 作用 | | --- | --- | | host | 访问的服务器地址 | | port | 服务器对应的端口号 | | from | 从哪个邮箱账号发送 | | pass | 通行证,这里需要放入刚刚生成的授权码 | 将所需信息放入配置文件中: ``` mail: host: smtp.qq.com port: 465 from: ${你的邮箱账号} pass: ${你的授权码} # QQ 邮箱授权码,而非登录密码 ``` 这里使用 **SMTP** 设置方法。 自定义 `MailSendTool` 方法: ``` public class MailSendTool { private final MailAccount account; public MailSendTool(String host, int port, String from, String pass) { account = new MailAccount(); account.setHost(host); account.setPort(port); account.setAuth(true); account.setFrom(from); account.setPass(pass); account.setSslEnable(true); // QQ 邮箱必须开启 SSL } /** * @param to 收件人邮箱 * @param subject 邮件标题 * @param content 邮件正文,可为 HTML * @param html 是否为html格式 */ @Tool(description = "Send email to a user with subject and content.") public String sendMail(@ToolParam(description = "Email address to send to.") String to, @ToolParam(description = "Email subject") String subject, @ToolParam(description = "Email content") String content, @ToolParam(description = "If the content is .html style") boolean html) { try { MailUtil.send(account, to, subject, content, html); return "邮件发送成功 -> " + to; } catch (Exception e) { return "邮件发送失败:" + e.getMessage(); } } } ``` Hutool包的优势体现在这里,只需一行代码即可配置并兼容。 将 `MailSendTool` 加入 项目 `ToolRegistration`中,采用依赖注入模式: ``` @Configuration public class ToolRegistration { @Value("${mail.host}") String host; @Value("${mail.port}") int port; @Value("${mail.from}") String from; @Value("${mail.pass}") String pass; @Bean public ToolCallback[] allTools() { MailSendTool mailSendTool = new MailSendTool(host, port, from, pass); return ToolCallbacks.from( mailSendTool ); } } ``` 测试功能: ``` @SpringBootTest class MailSendToolTest { @Test void sendMail() { String host = "smtp.qq.com"; int port = 465; String from = "${你的邮箱}@qq.com"; String pass = "${你的授权码}"; MailSendTool mailSendTool = new MailSendTool(host, port, from, pass); String res = mailSendTool.sendMail("xxx@gmail.com", "第一次测试", "你好,很高兴见到你", false); System.out.println(res); } } ``` #### 报错解决 当上述代码运行时,会报错: > java.lang.NoClassDefFoundError: javax/mail/Authenticator 原因是: Hutool邮件模块依赖的是旧版 javax.mail,但现在 JavaMail 已经迁移为 Jakarta Mail,但我所使用的 Hutool 版本 还没有升级,因此需要手动添加 javax.mail 依赖: ``` <dependency> <groupId>com.sun.mail</groupId> <artifactId>javax.mail</artifactId> <version>1.6.0</version> </dependency> ``` ### 扩展点 1. 附件支持 - 将图片、PDF、生成的报告等作为附件发送 2. 异步发送 + 限流 - 避免阻塞主线程,防止因网络波动邮件发送效率慢导致整个AI调用进程受阻 3. 多种邮箱支持 - 目前只有针对 QQ邮箱 的实现,后续可抽象出 `MailProvider`接口

Spring进阶 - JPA持久层

本文对 Java 的持久层历史做一个简单梳理,解释了为什么会有 **MyBatis 和 JPA 两种技术,** 并介绍 JPA 和 Spring Data JPA 基本概念和用法。 下面让我们回到什么框架都没有的 JDBC 时代,从大的时间线 **快速回顾** Java持久层历史演变。 Java 的数据库访问层技术演变 ================ 在 Java 世界里,对象(Object)生活在内存中,关系(Relation)生活在数据库里。它们就像两个语言不通的物种,而持久层框架,就是那个尽力让双方听懂彼此的翻译官 。 ![](https://pic.code-nav.cn/post_picture/1625164612255182850/dF4Hu9HHqEAI36yB.png) 核心的持久层技术有: 1. **JDBC:**最低抽象层,所有 SQL 与资源管理由开发者手工处理。 2. **MyBatis:**将 SQL 控制权交给开发者,通过映射机制减少大量模板代码。 3. **JPA(以 Hibernate 为代表):**由框架自动推断 SQL,通过 ORM 屏蔽表结构细节。 这三类技术并不是彼此替代,而是**在不同业务复杂度、团队偏好和性能要求下共存**。 #### 1、JDBC - 让Java能够访问任何第三方数据库 在 JDBC 诞生之前,不同的数据库厂商协议不一致 ,Java程序无法使用一套接口(同一套代码)统一操作各种第三方数据库,于是在1997年, Sun 公司提出 JDBC 规范,由 Java 提供一套接口,第三方数据库自己写驱动,实现接口对接 Java 的 JDBC 。 JDBC 的缺点非常多: * 连接很麻烦,样板代码多。 * 硬编码 SQL ,业务代码和 SQL 语句混合。 * 从数据库查出来的数据,需要手动转成 Java 列表。 那个年代有聪明的程序员为了减少样板代码写了多种工具类,但并没有达到理想的状态,重复工作依然存在。 #### 2、 Hibernate 自动化的持久层框架 2001 年,Gavin King 抛出了 **Hibernate, 它的核心理念是:程序员应该操作对象,而不是操作数据库表。** **Hibernate** 让开发者仅编写 Java 代码,可以根据对象自动生成 SQL ,简化了对数据库的操作,减少了开发者的重复劳动。 缺点: * 自动生成的 SQL 往往性能不可控(著名的 N+1 问题) * 由于是自动生成SQL, 对于复杂的查询显得力不从心。 #### 3、 iBATIS 崛起 后来改名 MyBatis Hibernate 让开发者尝到了甜头,掀起一场革命,但它的缺点也是致命的, 在处理复杂 SQL 时不够灵活 ,于是另一个持久层框架 iBATIS 便诞生(大约是2002年),跟 Hibernate 设计理念不同,iBATIS 并不是要在代码中消灭所有的 SQL 语句,而是认为**SQL 是数据库的灵魂,不应该被隐藏,而应该被管理。** 所以它在业界被称为 **半自动化ORM**, 它不负责帮你生成 SQL(虽然现在也可以),它只负责**把 SQL 和 Java 代码解耦。** 代码语法来到了我们熟悉的样子 Mapper接口: ```java public interface UserMapper { User selectUserById(Long id); } ``` XML 配置 : ```java <select id="selectUserById" resultType="com.example.User"> SELECT id, name, age FROM users WHERE id = #{id} </select> ``` 半自动化胜在灵活可控,SQL 语言全由开发者手动编写,并且与 Java业务分离,独立存在 XML 配置中。 ##### 防止SQL注入的原理 **#{} 可以防止SQL注入。** 原理是 XML 中写的: ```java SELECT * FROM user WHERE name = #{name} ``` MyBatis 会生成: ```java SELECT * FROM user WHERE name = ? ``` 然后立刻给数据库编译,所有主流数据库(MySQL、PostgreSQL、Oracle、SQL Server)都支持带 `?` 的预编译语句 之后 MyBatis 调用 JDBC 的 API ,发送参数给数据库: ```java preparedStatement.setString(1, name); ``` 也就是说,分为两阶段发送: * **数据库预编译**:SQL 模板先传给数据库编译 * **数据库执行阶段**:JDBC 发送参数 这就是 **JDBC PreparedStatement** 的防注入原理。 这样处理之后,随便用户传:name = "xxx' OR '1'='1", 最终SQL语句是: ```java SELECT * FROM user WHERE name = "xxx' OR '1'='1" ``` 不会把 OR 解析成关键字。 #### 4、 JPA - Java 官方的 ORM 规范 全自动框架不止 Hibernate 一个,从2002年到2006年发展了多个全自动持久层框架,包括: Hibernate、 EclipseLink、Java 官方的 EJB 框架(做的很失败),让持久层框架的使用变得混乱(但跟 Mybatis 无关,Mybatis并非 ORM 框架,自成一派)。 于是,在2006年,Java官方(Sun 公司)提出 JPA 规范。 **JPA 的本质不是一个框架,而是一套接口规范** JPA的出现是为了统一 Hibernate、EclipseLink 的使用方式,并带来了语法便利。也就是说 JPA 本身是规范,底层实现可以选用 Hibernate 或 EclipseLink 实现具体能力。 #### 5、 当下的 Java 持久层框架的两大派系 * MyBatis 发展到如今,有 MyBatis-plus , MyBatis-flex 被主流使用,**这一类属于SQL映射框架** * JPA 发展到如今, **Spring Data JPA** 被主流使用,**这一类属于 ORM 框架** 对这两个技术的定位: **Spring Data JPA** : 基于 JPA 封装的规范,兼容 Spring 生态,其底层默认由 Hibernate 实现 ORM 功能。 **JPA/Hibernate 的核心原理是:** 根据开发者定义的实体类、字段映射、关联关系以及方法命名规则,由框架自动推导并生成 SQL。 同时,JPA 还支持 JPQL、Criteria API、Native SQL 等方式,让开发者在需要时可以手动编写 SQL。 本质上,JPA/Hibernate 是一个完整的 **ORM 引擎**,以“对象模型”为中心,负责对象到关系数据库之间的自动转换。 **MyBatis-Plus** :MyBatis-Plus 通过预置的模板 SQL、Wrapper 条件构造器、CRUD 自动生成器等,一定程度上减少了 MyBatis 中手写 SQL 的工作量。 但其核心依然是 **模板化 SQL + 映射**,框架并不理解实体关系,也不会根据关联规则自动生成 SQL,复杂查询仍需开发者手写 SQL。 两者在功能上是非常像的,但 MyBatis 不是 ORM 框架。 在开发的体验上,能够明显感受到 JPA 以对象为中心,MyBatis 以 「SQL 构造」和「条件注入」为中心。 在**全球范围**,Spring Data JPA 是绝对的主流; 在**国内**,则是MyBatis-Plus 主流。 JPA === 介绍 -- JPA(Java Persistence API) 是 Java 官方发布的一套 ORM 规范 ,负责 * 要怎么把 Java 对象映射成数据库表 * 怎么维护外键关系(1对多、多对多) * 怎么查询 * 怎么管理增删改查生命周期 但只负责一套约束,底层的具体实现代码由三个主流框架提供: * **Hibernate(最主流)** * EclipseLink * OpenJPA 在 Spring Boot 环境中,**默认的 JPA 实现就是 Hibernate**。 **核心理念**: 把面向对象世界(对象、引用、继承、聚合)的模型,映射到关系数据库世界(表、行、列、外键、连接)的技术与模式集合。 也就是说你只需要写: ```java User user = new User(); user.setName("张三"); entityManager.persist(user); ``` 底层会自动生成 SQL,例如: ```sql insert into user (name) values ('张三'); ``` 映射具体包含了: * **类 <——>表** * **标识(Identity)映射** : Java 对象的唯一标识(主键)要和数据库的主键对应 (@Id) * **值类型映射** : Java 的数据类型到数据库的数据类型映射,如 String, int, LocalDateTime 映射为 varchat 、int 、DateTime。 * **事务边界与一致性** EntityManager 机制 ---------------- ### 介绍 Java 对象拥有复杂的生命周期(新建、托管、游离、删除),而关系型数据库只有简单的 CRUD 操作。两者之间缺乏自动的映射机制,这在电路中叫做 阻抗失配 ,即 两个电路连接时阻抗不匹配,会导致信号反射或能量损耗 。 简单来说,java对象有生命周期(创建,更新,销毁),而在 MySQL 等数据库的 「记录」没有生命周期的概念,对象是"活的"(动态)数据载体,数据库是"死的"(静态)数据载体。 **Hibernate 的解决方案:** 引入 `EntityManager` 作为 **ORM 的核心调度器**, 作用是充当生命周期的过渡适配,Java对象更新后,理论上Java对象和数据库记录不一致,此时 Java对象的数据处于脏状态,`EntityManager`会在合适的时机自动刷新到数据库,保证数据一致性。 在 Java 代码层面,它是一个接口 (`javax.persistence.EntityManager`)。它的职责类似“缓存”: 1. **状态管理**:它负责维护实体对象在内存中的四种状态(New, Managed, Detached, Removed)。 2. **脏检查(Dirty Checking)**:它利用内部的一级缓存机制,自动比对对象的前后状态。 3. **持久化上下文**: 持久化上下文是一块“一级缓存” , 在同一事务内重复读取不会重复查询数据库 ,它负责决定在何时、以何种顺序将内存中的变化生成 SQL 语句并发送给数据库,所以具有 自动脏检查(dirty check) 能力。 ```java @Transactional public void test() { User u1 = em.find(User.class, 1L); // SQL 1 次 User u2 = em.find(User.class, 1L); // 不触发 SQL(一级缓存) } ``` ### EntityManager 具体作用 #### 1、状态管理(对象生命周期管理) **四种状态:新建(new) 、托管(Managed)、游离(Detached)、删除态 (Removed)** 1. **新建:** Java 对象刚刚是 `new` 出来,没有 ID (主键是空),此时 EntityManager 根本不管。 2. **托管**: 有 ID , EntityManager 正在监控它 ,会触发藏检查, 只要你改了 Java 对象的属性(`setName`),就算你不喊保存,事务提交时,EntityManager 也会**自动**向数据库发 `UPDATE` 语句。 以下三种场景从 新建 变成 托管 ```java // 场景 A:通过 persist 让 New 变成 Managed em.persist(user); // 场景 B:通过 find 从数据库查出来的,天生就是 Managed User user = em.find(User.class, 1L); // 【关键】修改属性,自动同步数据库!不需要写 em.update(user) user.setName("李四"); ``` 3. **游离**:还是有ID, **但EntityManager 不再监控它了**(可能是 Session 关闭了,或者被踢出了缓存)。 此时 你改了对象的属性,EntityManager 假装看不见,数据库**不会**发生任何变化。 简单来说,就是拿到数据,连接关闭,此时对象就是纯粹的 **数据载体。** ```java User user = em.find(User.class, 1L); // 此时是 Managed em.clear(); // 清空上下文,把所有对象踢出管理 // 或者 em.close(); // 关闭连接 // 此时 user 变成了 Detached。 // 虽然你改了名字,但数据库里还是“李四”,不会变。 user.setName("王五"); ``` 4. **删除态**: 有 ID ,**EntityManager 标记它为“待删除”**。 事务提交时,物理删除数据库记录。 ```java User user = em.find(User.class, 1L); em.remove(user); // 标记删除 // 此时 user 对象在 Java 内存里其实还在!你可以打印它。 // 但事务提交后,数据库里就没了。 ``` **那逻辑删除怎么实现?** JPA 默认没有逻辑删除,可以由 Hibernate 偷梁换柱实现。代码如下: ```java import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.Where; @Entity @Table(name = "t_user") // 1. 拦截删除:告诉 Hibernate,当有人调用 remove 时,别执行 DELETE,执行这句 UPDATE @SQLDelete(sql = "UPDATE t_user SET deleted = 1 WHERE id = ?") // 2. 拦截查询:告诉 Hibernate,无论查什么,都在 WHERE 后面自动拼上这句条件 @Where(clause = "deleted = 0") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; // 逻辑删除标识:0-未删,1-已删 @Column(name = "deleted") private Integer deleted = 0; } ``` #### 2、事务控制和延迟操作 * 它可以管理事务边界,确保一系列对象操作要么全部成功,要么全部回滚。 * 支持延迟加载(Lazy Loading),比如一对多关联对象,访问时才查询数据库,提高性能。 ```java @Service public class UserServiceManual { @PersistenceContext private EntityManager entityManager; public void createUser(User user) { // 获取事务 EntityTransaction transaction = entityManager.getTransaction(); try { // 手动开启事务 transaction.begin(); // 执行数据库操作, 这个user准备被持久化到数据库 // JPA 会在事务提交时生成对应的 INSERT SQL entityManager.persist(user); // 提交事务 transaction.commit(); } catch (Exception e) { // 异常回滚 if (transaction.isActive()) { transaction.rollback(); } throw e; } } } ``` 事务可以替换为 Spring 注解事务: ```java @Service public class UserServiceTransactional { @Autowired private UserRepository userRepository; // Spring Data JPA Repository @Transactional public void createUser(User user) { // 直接操作对象即可 userRepository.save(user); // 如果这里抛出异常,事务会自动回滚 // 无需手动管理 commit 或 rollback } } ``` ### EntityManager 四大核心方法 1. persist() 把 new 的对象变成 managed(持久化态) ```java User user = new User(); user.setName("Tom"); em.persist(user); ``` 2. merge() : merge 用于将 **detached(脱管态)** 的实体重新合并回持久化上下文。 * merge 不会让传入对象变成 managed * merge 会复制一个新的对象并返回新的 managed 实体 ```java User user = em.find(User.class, 1L); transaction.commit(); user.setName("Tom2"); // u2 变成 managed User u2 = em.merge(user); ``` 3. remove() : 标记实体为 removed, 不是立即 DELETE , 在事务提交时删除记录 . ```java User user = em.find(User.class, 1L); // 不会删除 Java 内存中的对象,但一般在方法内创建的对象, // 线程离开方法后,对象是【不可达】状态,下一次GC会回收 em.remove(user); ``` remove 必须传入一个 managed 实体,否则报错。 4. refresh(): 把实体从数据库的最新数据“重置”回来 (覆盖内存中的属性) ```java User user = em.find(User.class, 1L); user.setName("Wrong"); em.refresh(user); ``` ### JPQL 对象化查询语言 JPQL(Java Persistence Query Language)是 JPA 的查询语言,用**实体类和属性名**写查询,而不是数据库表名和字段名。 * `User` → 实体类名,而不是 `user` * `u.name` → 实体字段名,而不是数据库 column 名 SQL 语句和 JPQL 语句对比 | 概念 | SQL | JPQL | | --- | --- | --- | | 查询对象 | 表 t_user | 实体 User 字段 | u_age | age(实体属性) 表别名 | u(可选) | u(必须) | **JPQL 语言规范本身要求“实体必须带别名”** 1. String 拼接的 JPQL , 最基础、最常用 ```java String jpql = "SELECT u FROM User u WHERE u.age > :age"; List<User> result = em.createQuery(jpql, User.class) .setParameter("age", 20) .getResultList(); ``` `:age`解释: * 这里 `:age` 意味着 ,这个值之后由 Java **动态赋值** * `:age` 的名字是 `"age"` * `.setParameter("age", 20)` 表示把 20 绑定进去 **2. 在实体类上定义查询** ```java @Entity @NamedQuery( name = "User.findByAge", query = "SELECT u FROM User u WHERE u.age > :age" ) public class User { ... } ``` ```java List<User> users = em.createNamedQuery("User.findByAge", User.class) .setParameter("age", 20) .getResultList(); ``` 3. **Criteria API(类型安全 Query)** 不写字符串 JPQL,**类型安全**, 多用于复杂查询、动态查询 , 用得最多的是大企业项目或动态查询场景, ```java CriteriaBuilder cb = em.getCriteriaBuilder(); // CriteriaQuery 作用和 MyBatis-Plus 的 QueryWrapper 几乎一样 // 都是查询构造器 CriteriaQuery<User> cq = cb.createQuery(User.class); Root<User> root = cq.from(User.class); cq.select(root).where(cb.gt(root.get("age"), 20)); List<User> list = em.createQuery(cq).getResultList(); ``` 等价于: ```java SELECT u FROM User u WHERE u.age > 20 ``` 4. **Native SQL(完全手写 SQL)** 完全手搓SQL, 不受 JPQL 语法限制 ,**有SQL 注入风险** ```java // 风险操作 String sql = "SELECT * FROM user WHERE name = '" + input + "'"; em.createNativeQuery(sql).getResultList(); ``` 安全操作 setPoarameter ```java String sql = "SELECT * FROM user WHERE age > ?"; List<User> users = em.createNativeQuery(sql, User.class) .setParameter(1, 20) .getResultList(); ``` 后续会讲到Spring Data JPA 的语法,这里一块汇总了,不怕赘述,就怕学不会。 5. **Spring Data JPA 的 JPQL 方式** Spring 生态绝对主流,非常常见,没有SQL注入的风险,第一种方式 JPQL 字符串有可能导致 SQL 注入风险。 ```java @Query("SELECT u FROM User u WHERE u.age > :age") List<User> findOlderThan(@Param("age") int age); ``` 6. **通过方法名自动推断查询** 不用写 JPQL,Spring 根据方法名自动生成,所以可能方法名很长。 ```java List<User> findByAgeGreaterThan(int age); ``` JPA 注解体系 -------- 在 JPA 规范中,实体类的行为是完全由注解驱动的。注解控制实体如何映射到表、字段、主键策略、枚举、二进制数据存储等。 ### 基本注解 核心注解列表 : * **@Entity** * **@Table** * **@Id** * **@GeneratedValue** * **@Column** * **@Transient** * **@Enumerated** * **@Lob** 1. `**@Entity**` (必须) , 告诉 JPA “这是一个实体,请帮我管理” , 类必须有无参构造方法。 ```java @Entity public class User { } ``` 2. `**@Table**` (可选,但推荐) , 如果类名叫 `User`,不加这个注解,默认表名也是 `User`。数据库通常用下划线命名(`t_user`),所以需要它。 ```java @Entity @Table(name = "t_user") public class User { } ``` 3. `**@Id**` (必须) , 指定谁是主键。 4. `**@GeneratedValue**` (核心) , 指定主键的生成策略,常见策略: * `GenerationType.IDENTITY`:数据库自增(MySQL 常用) * `GenerationType.SEQUENCE`:序列(PostgreSQL / Oracle 使用) * `GenerationType.AUTO`:根据数据库自动选择 * `GenerationType.UUID`(在新版 Hibernate 中已支持) ```java @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; ``` 5. `**@Column**` (最常用) , 细粒度控制列的属性。 * `name`:列名 * `nullable`:是否允许为 NULL(默认 true) * `length`:VARCHAR 长度(默认 255) * `unique`:是否创建唯一约束 ```java @Column(name = "username", nullable = false, length = 50, unique = true) private String username; ``` 6. `**@Transient**` (重要) , 指定字段不写入数据库,不参与 ORM 映射。 **使用场景:** * DTO 生成的临时字段 * 缓存某些计算结果 * 不需要被数据库持久化的业务属性(如 token) 7. `**@Enumerated**` , 指定枚举在数据库中的存储方式。 **两种模式:** * `EnumType.STRING`:把枚举值转成字符串 优点:可读、安全 缺点:占用空间稍大 * `EnumType.ORDINAL`:(禁用,全是坑)存储枚举的 ordinal(序号) 缺点:绝不能乱改枚举定义顺序!否则全表数据错位 ```java @Enumerated(EnumType.STRING) private Status status; ``` 8. @Lob ,告诉 JPA 将字段映射为大对象类型(LOB) * 字符串 → CLOB(长文本) * 字节数组 → BLOB(二进制) ```java @Lob private String largeText; @Lob private byte[] fileContent; ``` ### 进阶注解 做项目不可能只有一张表,只要有表关联,就逃不掉下面这些。这也是 JPA 比 MyBatis 难的地方。 1. **关系映射:**`**@OneToMany**` **/** `**@ManyToOne**` * **关系的“拥有方(owning side)”决定外键在哪张表上**。在一对多关系中,通常由“多”的一方(`Order`)持有外键 `user_id`,因此 `Order` 是 owning side。 * 在 JPA 的双向关系中,**只能一个 side 为 owning side**。另一个 side 使用 `mappedBy` 指向 owning side 的属性名(不是列名)。 有两个关键属性: **FetchType.LAZY** : 懒加载 查询主实体时,不查关联。只有你 **第一次访问关联字段时** 才去查。用来解决表关系太多的 SQL 操作导致性能极低问题, 不需要的数据不要查,访问才查。 **最好所有关联默认用懒加载** **FetchType. EAGER** : 立即把关联实体一起查出来 **数据库表创建了外键才需要这个注解,否则不用。** ```java @Entity @Table(name = "users") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, length = 100) private String name; // mappedBy 指向 Order 类中定义的 user 字段名 @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private List<Order> orders = new ArrayList<>(); // helper method 保持双向一致性 public void addOrder(Order o) { orders.add(o); o.setUser(this); } public void removeOrder(Order o) { orders.remove(o); o.setUser(null); } // getters/setters... } ``` ```java @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "user_id", nullable = false) // owning side, 指定外键列 private User user; private BigDecimal amount; // getters/setters... } ``` 2. **并发控制 乐观锁 @Version** 在表上维护一个 `version` 列(`int` / `long` / `timestamp`),每次更新时 JPA 在 `WHERE` 子句中带上 `version = ?`,并在更新成功后把 `version` 自增(或更新为新值) 如果更新时数据库的 `version` 与实体中持有的 `version` 不一致(说明其他事务已提交修改),则更新影响行数为 0,JPA 抛出乐观锁异常(`OptimisticLockException`,在 Spring 中常被包装为 `ObjectOptimisticLockingFailureException` / `OptimisticLockingFailureException`)。 ```java @Entity public class Order { @Id @GeneratedValue private Long id; @Version private Integer version; private BigDecimal amount; // ... } ``` Spring Data JPA =============== 介绍 -- Spring Data JPA **不是** JPA 的实现(它不是 Hibernate 的替代品)。 它的作用是**管理** JPA。它让你连 JPA 的标准代码(如 `EntityManager`)都不用写了,只需要写一个**接口**,剩下的交给它。 Spring Data JPA 的核心理念是:**约定优于配置** 如果直接用 Hibernate/JPA,你可能还需要写很多通用的 DAO(数据访问对象)代码,比如增删改查。 比如: ```java // 你需要手动管理 EntityManager,还要写 SQL 或 JPQL public User findByName(String name) { return entityManager.createQuery("SELECT u FROM User u WHERE u.name = :name", User.class) .setParameter("name", name) .getSingleResult(); } ``` Spring Data JPA 写法: ```java // 你只需要定义一个接口,不需要写实现类! public interface UserRepository extends JpaRepository<User, Long> { // 只要你的方法名符合规范,SQL 会自动生成 User findByName(String name); } ``` **你看,连实现类都不用写!** Spring Data JPA 会在程序启动时,利用**动态代理**技术,自动帮你生成这个接口的实现代码。 **核心特性** Spring Data JPA 是 JPA 的封装,对外提供了几个大杀器: * **Repository 抽象**: 它提供了 `JpaRepository` 等顶层接口,直接继承它,你就立刻拥有了标准的 CRUD(增删改查)、分页、排序功能,一行代码都不用写。 * **方法名即查询 (Query Methods)**: 这是最神奇的地方。你按照语义写方法名,它自动翻译成 SQL。 * `findByAge(int age)` -> `select * from user where age = ?` * `findByNameAndAddress(String name, String addr)` -> `select * from ... where name = ? and address = ?` * **极简的分页与排序**: 只需要在方法参数中加入 `Pageable` 或 `Sort` 参数,它自动帮你拼接 `LIMIT` 等分页语句。 Repository 接口 ------------- Spring Data JPA 最核心的 4 个 Repository: 1. **CrudRepository** 2. **PagingAndSortingRepository** 3. **JpaRepository** 4. **JpaSpecificationExecutor** 看看接口的集成关系图: ```java CrudRepository<T, ID> ↑ PagingAndSortingRepository<T, ID> ↑ JpaRepository<T, ID> ``` 而: ```plain JpaSpecificationExecutor<T> (完全独立,不在继承链上) ``` 每一个接口的职责接下来展开讲。 ### CrudRepository —— 最基础的 CRUD 提供最基本的数据操作: * `save()` * `findById()` * `existsById()` * `findAll()` * `deleteById()` * `deleteAll()` 典型用法: ```plain userRepository.save(user); userRepository.findById(1L); ``` **适合:简单项目基础 CRUD。** ### PagingAndSortingRepository —— 增加分页与排序 在 CrudRepository 基础上,扩展: * `findAll(Sort sort)` * `findAll(Pageable pageable)` 用法: ```plain Page<User> page = userRepository.findAll(PageRequest.of(0, 10)); ``` **适合:需要分页、排序,但不需要复杂 SQL 的项目。** ### JpaRepository —— 最强的默认 Repository 这是 Spring Data JPA 项目 最常用的接口,生产环境几乎都用它。 它扩展了: * 批处理增强:`saveAll()` * 刷新:`flush()` * 批量删除:`deleteInBatch()` * 去重查询:`findAll(Sort)`、`findAll(Pageable)` * 实体管理更丰富 **示例:** ```plain List<User> users = userRepository.findAll(Sort.by("age")); ``` **适合:主流企业开发,99% 项目从它开始。** ### JpaSpecificationExecutor —— 完全动态查询(Criteria API) 它让你可以构建类似 MyBatis-Plus QueryWrapper 的动态条件。 提供方法: * `findAll(Specification spec)` * `findAll(Specification spec, Sort sort)` * `findAll(Specification spec, Pageable pageable)` * `count(Specification spec)` 示例(查询 age > 20 且 name = "Tom"): ```plain userRepository.findAll((root, query, cb) -> cb.and( cb.gt(root.get("age"), 20), cb.equal(root.get("name"), "Tom") ) ); ``` 非常像 MyBatis Plus 的构造器: ```plain wrapper.gt("age", 20).eq("name", "Tom"); ``` **适合:复杂动态查询、企业级系统。** ### 一个 DEMO 学会使用 Repository 接口 1. 实体类定义 ```java @Entity @Data // Lombok 注解,生成 Getter/Setter 等 @Table(name = "t_user") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private Integer age; private String email; private Date createTime; } ``` 2. 核心接口 (Repository) 这是最重要的一步。我们要同时继承两个接口 , 实现“双剑合璧”。 ```java import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; // 继承 JpaRepository 获得基础 CRUD + 分页 // 继承 JpaSpecificationExecutor 获得动态查询能力 public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> { // 这里暂时不需要写任何代码,两个父接口已经够强大了! } ``` 3. 业务层实战使用 这里我模拟一个 Service 方法,展示如何构建复杂的动态查询。 ```java @Service public class UserService { @Autowired private UserRepository userRepository; /** * 模拟前端传入的搜索条件对象 * 如果字段为 null,说明前端没输这个条件 */ public Page<User> searchUsers(String nameKeyword, Integer minAge, String exactEmail, int page, int size) { // 1. 构建动态查询条件 (Specification) // root: 代表 User 表(也就是 SQL 中的 from t_user) // query: 顶层查询对象(主要用于组合 Order By 等,平时用得少) // cb (CriteriaBuilder): 构建 SQL 谓词的工厂(也就是 =, like, >, and, or 这些工具) Specification<User> spec = (root, query, cb) -> { // 用于存放所有的 WHERE 条件 List<Predicate> predicates = new ArrayList<>(); // --- 动态拼装条件 --- // 1. 姓名模糊查询 (对应 SQL: name LIKE %keyword%) if (nameKeyword != null && !nameKeyword.isEmpty()) { // equal 是相等,like 是模糊 predicates.add(cb.like(root.get("name"), "%" + nameKeyword + "%")); } // 2. 年龄大于某值 (对应 SQL: age > minAge) if (minAge != null) { // gt = greater than predicates.add(cb.gt(root.get("age"), minAge)); } // 3. 邮箱精确匹配 (对应 SQL: email = 'xxx') if (exactEmail != null) { predicates.add(cb.equal(root.get("email"), exactEmail)); } // --- 组合所有条件 --- // 将 List 里的条件用 AND 连接起来 return cb.and(predicates.toArray(new Predicate[0])); }; // 2. 构建分页与排序 (Pageable) // page 从 0 开始,Sort.Direction.DESC 代表倒序 Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createTime")); // 3. 发起查询 (双剑合璧调用) // findAll(Specification, Pageable) 是 JpaSpecificationExecutor 提供的 return userRepository.findAll(spec, pageable); } } ``` Spring Data 的查询机制 ----------------- ### 语义化查询 这是 Spring Data JPA 最“黑科技”的地方。它像一个翻译官,把你的 Java 方法名“翻译”成 SQL。 **翻译规则:** Spring 会剥离方法名的前缀(如 `find`, `read`, `query`, `count`),解析剩下的部分。 * **关键字映射:** * `And` / `Or` → `WHERE ... AND ...` * `Is`, `Equals` → `WHERE col = ?` * `Between` → `WHERE col BETWEEN ? AND ?` * `LessThan`, `GreaterThan` → `<` , `>` * `Like`, `Containing` → `LIKE %?%` * `OrderBy[Property][Asc/Desc]` → `ORDER BY ...` ```java List<User> findByEmailAndStatus(String email, Status status); ``` SQL: ```java SELECT * FROM user WHERE email = ? AND status = ? ``` 模糊匹配: ```java List<User> findByNameLike(String name); // 传参:“%Tom%” List<User> findByNameContaining(String name); // 自动拼接成 %name% List<User> findByNameStartingWith(String prefix); // prefix% ``` 排序: ```java List<User> findByStatusOrderByAgeDesc(Status status); ``` ```java SELECT u.id, u.name, u.age, u.email, u.status FROM user u WHERE u.status = ? ORDER BY u.age DESC; ``` 取 TopN: ```java User findTop1ByOrderByIdDesc(); ``` ```java SELECT u.id, u.name, u.age, u.email, u.status FROM user u ORDER BY u.id DESC LIMIT 1; ``` ### 手动指定查询 (`@Query` 注解) 当方法名太长,或者你需要用到很复杂的 SQL 特性(如 Join 优化、子查询)时,这层机制就派上用场了。优先级**高于**第一层。 使用 JPQL , **操作的是类和属性,不是表和字段** ```java // ?1 代表第一个参数 @Query("select u from User u where u.email = ?1 and u.age > 18") User findAdultUserByEmail(String email); ``` 也可以使用SQL 如果你必须用数据库特有的函数(比如 MySQL 的 `DATE_FORMAT` 或 Oracle 的窗口函数),就必须开启 `nativeQuery = true`。**这时写的是纯正的 SQL。** ```java // 这里的 t_user 是表名,email 是数据库字段名 @Query(value = "select * from t_user where email = ?1", nativeQuery = true) User findByEmailNative(String email); ``` 默认 `@Query` 只能查。如果你要 UPDATE 或 DELETE,必须加 `@Modifying` 并配合事务。 ```java @Modifying @Query("update User u set u.name = ?1 where u.id = ?2") int updateName(String name, Long id); ``` ### 动态查询 Specification 当查询条件是不固定的(用户可能填了名字,也可能没填),前两种方法就“死”了。 **什么是动态查询?** 动态 = **根据用户输入的参数数量不同,自动拼不同的 SQL 条件** 比如搜索用户页面一般有这些筛选项: * name(可填可不填) * minAge(可填可不填) * maxAge(可填可不填) * status(可选可空) * email(可空) 用户可能输入: | name | minAge | maxAge | status | email | | --- | --- | --- | --- | --- | | Tom | null | null | null | null null | 20 | 30 | ENABLED | null null | null | null | null | null Lily | 30 | null | DISABLED | aaa@bb.com | 每次输入不一样 → SQL 就不一样,这就是“动态查询”。 方法名的语义化查询和 JPQL 不适合动态查询,因为那两种方式都要提前写死 SQL , 不适合灵活生成。 Specification 就是用 Java 代码动态构造 WHERE 条件的工具,类似 MyBatis-Plus 的 QueryWrapper。 必要条件:**你的 Repository 必须实现 JpaSpecificationExecutor<T>** ```java public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> { } ``` 只有实现了这个接口,Repository 才能执行: ```plain findAll(Specification spec) findAll(Specification spec, Pageable pageable) findOne(Specification spec) ``` 动态查询案例: 搜索条件 ```java public List<User> search(UserQuery query) { Specification<User> spec = (root, q, cb) -> { List<Predicate> list = new ArrayList<>(); if (query.getName() != null) { list.add(cb.like(root.get("name"), "%" + query.getName() + "%")); } if (query.getMinAge() != null) { list.add(cb.ge(root.get("age"), query.getMinAge())); } if (query.getMaxAge() != null) { list.add(cb.le(root.get("age"), query.getMaxAge())); } if (query.getStatus() != null) { list.add(cb.equal(root.get("status"), query.getStatus())); } if (query.getEmail() != null) { list.add(cb.equal(root.get("email"), query.getEmail())); } return cb.and(list.toArray(new Predicate[0])); }; return userRepository.findAll(spec); } ``` 如果用户输入name , 自动生成 SQL: ```java SELECT * FROM user WHERE name LIKE '%Tom%'; ``` 用户输入: ```java minAge = 20 maxAge = 30 status = 'ENABLED' ``` 生成SQL: ```java SELECT * FROM user WHERE age >= 20 AND age <= 30 AND status = 'ENABLED'; ``` 用户全都不输入: ```java SELECT * FROM user; ``` 这叫 “全表查询”,因为没有条件。 ### 分页与排序:`Page`, `Pageable`, `Sort` Sort 是排序,不管你是否分页,可能都要排序(查多条数据)。 ```java // 写法 1:按 age 倒序 (ORDER BY age DESC) Sort sort1 = Sort.by(Sort.Direction.DESC, "age"); // 写法 2:更流式的写法 (推荐) Sort sort2 = Sort.by("age").descending(); ``` Sort 对象可以用到下面分页条件中。 Spring Data 提供统一的分页接口,Pageable 分页条件,Page 分页查询结果,包含 List + TotalCount ```plain Pageable pageable = PageRequest.of(0, 5, Sort.by(Sort.Direction.DESC, "age")); Page<User> page = userRepository.findAll(pageable); ``` 🔹 对应 SQL(第一页,5 条,按 age 倒序): ```sql SELECT * FROM user ORDER BY age DESC LIMIT 5 OFFSET 0; ``` * * * 以上是本文的全部内容,介绍了ORM框架的演进,MyBatis 与 Hibernate 等框架的设计理念不同延申出来两大派系,并介绍了 JPA 和 Spring Data JPA 的基本用法,实战中基本上使用 Spring Data JPA。

下载 APP