AOP
快来分享你的内容吧~
手写 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 的设计初衷并不是作为持久化的聊天记录存储方案**。  这种方案的设计目的是**管理模型上下文的短期记忆(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)的函数工具。**  Spring AI 的核心架构设计中,大量使用了 **Advisor(增强器)** 模式。这是一种典型的 AOP(面向切面编程)思想。通过 Advisors,我们可以拦截对大模型的每一次请求和每一次响应,对其进行增强、修改或**记录**。  上图是 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` 的示例,用于打印请求和响应日志。这正是我们需要参考的模板,因为“打印日志”和“保存日志到数据库”在逻辑上是完全一样的,只是输出目的地不同。  让我们深入解析一下官方的这个示例代码: ```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/)
AOP学习笔记
<html> <head></head> <body> <div class="content ql-editor"> <p><span style="font-family: PingFangSC-Regular; font-size: 2em;">为什么使用AOP?AOP能解决什么问题?</span></p> <p><br></p> <p>AOP:面向切面编程</p> <p>简单来说,就是为了在基础功能上再增加一部分功能。</p> <p>为了实现这个目的,我们可以简单的在原来的功能上,进行代码的增加或者删改,但是这样违背了开放封闭原则。</p> <p>所以,我们可以通过AOP来实现将核心业务和边缘业务进行拆分。</p> <p><br></p> <p><strong>应用场景:</strong>打印日志,安全控制,权限管理,性能分析等等。</p> <p>鱼皮的API项目中,就使用AOP实现了,打印日志和权限管理。</p> <p><br></p> <p><br></p> <h1>怎么样使用AOP呢?</h1> <p><br></p> <p>在SpringBoot项目中,我们只需要知道以下几个常用的注解,就可以对于AOP进行简单使用了</p> <ol> <li data-list="ordered"><span class="ql-ui"></span>引入AOP的依赖</li> <li data-list="ordered"><span class="ql-ui"></span>定义切面类(@Aspect)</li> <li data-list="ordered" class="ql-indent-1"><span class="ql-ui"></span>定义切点(@Pointcut)</li> <li data-list="ordered" class="ql-indent-1"><span class="ql-ui"></span>定义通知(@Around,@After,@Before,省略一些不常用的)</li> </ol> <p><br></p> <p><strong>切点</strong>:是定义了在“什么地方”进行切入,哪些连接点会得到通知。说人话就是,指定你<strong>需要在哪个方法</strong>前后增加功能。</p> <p><br></p> <p><strong>通知</strong>:包含了需要用于多个应用对象的横切行为。说人话就是,你给需要增强的方法,前面还是后面,<strong>增加什么功能</strong>这个就是通知干的。</p> <p><br></p> <p><strong>切面</strong>:通知和切点共同定义了切面的全部内容。说人话就是,<strong>切点+通知。</strong></p> <p><br></p> <p><br></p> <h2>依赖</h2> <p><br></p> <div class="ql-code-block-container"> <div class="ql-code-block"> <dependency> </div> <div class="ql-code-block"> <groupId>org.springframework.boot</groupId> </div> <div class="ql-code-block"> <artifactId>spring-boot-starter-aop</artifactId> </div> <div class="ql-code-block"> </dependency> </div> </div> <p><br></p> <p><br></p> <h2>切面</h2> <p><br></p> <p>这里将给出一个简单Demo进行说明</p> <p>这是一个简单的hello请求</p> <div class="ql-code-block-container"> <div class="ql-code-block"> @RestController </div> <div class="ql-code-block"> @RequestMapping("/aop") </div> <div class="ql-code-block"> public class AopController { </div> <div class="ql-code-block"><br> </div> <div class="ql-code-block"> // http://localhost:8080/aop/hello </div> <div class="ql-code-block"> @RequestMapping("/hello") </div> <div class="ql-code-block"> public UserVo sayHello(){ </div> <div class="ql-code-block"> System.out.println("hello"); </div> <div class="ql-code-block"> UserVo userVo = new UserVo(); </div> <div class="ql-code-block"> userVo.setName("tianzhao"); </div> <div class="ql-code-block"> userVo.setAge("18"); </div> <div class="ql-code-block"> //将userVo返回给网页 </div> <div class="ql-code-block"> return userVo; </div> <div class="ql-code-block"> } </div> <div class="ql-code-block"> } </div> </div> <p><br></p> <p><br></p> <p>这是定义一个切面</p> <div class="ql-code-block-container"> <div class="ql-code-block"> @Aspect </div> <div class="ql-code-block"> @Component </div> <div class="ql-code-block"> public class AopAdvice { </div> <div class="ql-code-block"> </div> <div class="ql-code-block"> @Pointcut("execution (* com.example.aopdemo.AopController.*(..))") </div> <div class="ql-code-block"> public void test() { </div> <div class="ql-code-block"><br> </div> <div class="ql-code-block"> //不执行 </div> <div class="ql-code-block"> System.out.println("test....."); </div> <div class="ql-code-block"> } </div> <div class="ql-code-block"><br> </div> <div class="ql-code-block"> @Before("test()") </div> <div class="ql-code-block"> public void beforeAdvice() { </div> <div class="ql-code-block"> System.out.println("beforeAdvice..."); </div> <div class="ql-code-block"> } </div> <div class="ql-code-block"><br> </div> <div class="ql-code-block"> @After("test()") </div> <div class="ql-code-block"> public void afterAdvice() { </div> <div class="ql-code-block"> System.out.println("afterAdvice..."); </div> <div class="ql-code-block"> } </div> <div class="ql-code-block"><br> </div> <div class="ql-code-block"> //可以直接使用Object代表返回类型,直接返回函数调用结果即可 </div> <div class="ql-code-block"> @Around("test()") </div> <div class="ql-code-block"> public Object aroundAdvice(ProceedingJoinPoint proceedingJoinPoint) { </div> <div class="ql-code-block"> System.out.println("before"); </div> <div class="ql-code-block"> try { </div> <div class="ql-code-block"> //调用通知的方法,并且还需要返回方法的值。 </div> <div class="ql-code-block"> return proceedingJoinPoint.proceed(); </div> <div class="ql-code-block"> } catch (Throwable t) { </div> <div class="ql-code-block"> t.printStackTrace(); </div> <div class="ql-code-block"> } </div> <div class="ql-code-block"> System.out.println("after"); </div> <div class="ql-code-block"> return null; </div> <div class="ql-code-block"> } </div> <div class="ql-code-block"> } </div> </div> <p><strong>切点表达式</strong>:指定你要切入到哪里</p> <p><img src="https://pic.code-nav.cn/planet_post_image/1677522185939324930/5j6tcsov.jpeg"></p> <p>还有其他的切入规则,我这里只说明最简单的一种。</p> <p><br></p> <p><strong>通知类型</strong></p> <ol> <li data-list="ordered"><span class="ql-ui"></span>前置通知(@Before):在目标方法调用之前调用通知</li> <li data-list="ordered"><span class="ql-ui"></span>后置通知(@After):在目标方法完成之后调用通知</li> <li data-list="ordered"><span class="ql-ui"></span>环绕通知(@Around):在被通知的方法调用之前和调用之后执行自定义的方法</li> <li data-list="ordered"><span class="ql-ui"></span>返回通知(@AfterReturning):在目标方法成功执行之后调用通知</li> <li data-list="ordered"><span class="ql-ui"></span>异常通知(@AfterThrowing):在目标方法抛出异常之后调用通知</li> </ol> <p><br></p> <p><br></p> <h1>推荐参考文档</h1> <p><br></p> <p><a href="https://www.cnblogs.com/kenx/p/15088701.html" target="_blank">SpringBoot AOP详解</a></p> <p><br></p> <p><br></p> <p><br></p> </div> </body> </html>
