从零开始搭建基于Spring Boot的MCP(Model Context Protocol)系统完整教程
本教程详细介绍基于Spring Boot构建MCP(Model Context Protocol)系统,实现AI模型直接调用数据库查询功能。系统采用Spring Boot+MyBatis+通义千问AI SDK技术栈,核心功能包括自然语言查询转换、动态方法调用和流式响应。教程涵盖环境准备、Maven依赖配置、后端实现(Controller/Service层)等关键步骤,特别强调DAO层必须位于com
从零开始搭建基于Spring Boot的MCP(Model Context Protocol)系统完整教程
项目介绍
本教程将详细介绍如何从零开始搭建一个基于Spring Boot的MCP(Model Context Protocol)系统,该系统能够让AI模型(如通义千问)直接调用MyBatis数据库查询方法,实现自然语言查询数据库的功能。
要求dao层必须在com.dao这个路径下面,如果不是请自行更改代码,(详见包扫描工具)
系统架构
前端(Vue) -> Controller -> Service -> AI模型 -> 工具类 -> MyBatis -> 数据库
核心功能
- 自然语言查询:用户可以用自然语言描述查询需求
- AI理解与转换:AI模型将自然语言转换为具体的数据库查询
- 动态方法调用:系统动态调用MyBatis Mapper方法执行查询
- 流式响应:通过SSE(Server-Sent Events)实现流式响应
- 智能重试:当单条查询返回多条记录时,自动重试列表查询
1. 环境准备
1.1 技术栈
- 后端:Spring Boot 2.2.2 + MyBatis Plus + 通义千问AI SDK
- 前端:Vue.js + Element UI
- 数据库:MySQL
- Java版本:JDK 1.8
1.2 创建Spring Boot项目
使用Spring Initializr创建项目,或直接使用本教程提供的pom.xml配置。
2. Maven依赖配置
以下是实现MCP系统所需的关键Maven依赖:
<!-- Spring Boot Web支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis集成 -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.1</version>
</dependency>
<!-- MyBatis增强工具 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus</artifactId>
<version>2.3</version>
</dependency>
<!-- 通义千问AI SDK -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>dashscope-sdk-java</artifactId>
<version>2.21.11</version>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- MySQL驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- Lombok简化代码 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
关键依赖说明
- dashscope-sdk-java:通义千问AI SDK,用于与AI模型交互,是MCP系统的核心组件
- mybatis-spring-boot-starter:提供MyBatis集成,用于数据库操作
- mybatis-plus:MyBatis增强工具,简化CRUD操作
- spring-boot-starter-web:提供Web MVC支持,处理HTTP请求
- mysql-connector-java:MySQL数据库驱动
- lombok:通过注解简化Java代码,减少样板代码
3. 后端实现
3.1 配置文件
在application.yml中添加以下配置:
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/your_database?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=GMT%2B8
username: your_username
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis-plus:
mapper-locations: classpath*:mapper/*.xml
type-aliases-package: com.entity
# AI配置
ai-key: your_ai_api_key
3.2 Controller层实现
创建DeepSeekController.java:
package com.controller;
import com.alibaba.dashscope.aigc.generation.Generation;
import com.alibaba.dashscope.aigc.generation.GenerationParam;
import com.alibaba.dashscope.common.Message;
import com.alibaba.dashscope.common.Role;
import com.service.DeepSeekService;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.annotation.Resource;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Author reisen7
* Date 2025/4/9 1:12
* Description
*/
@RestController
@RequestMapping("/deepSeek")
@RequiredArgsConstructor
public class DeepSeekController {
private final DeepSeekService deepSeekService;
@GetMapping(value = "/query", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter getBotContent(@RequestParam String question) {
// 直接调用Service层处理业务逻辑
return deepSeekService.processQuestion(question);
}
}
3.3 Service接口定义
创建DeepSeekService.java接口:
package com.service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import com.alibaba.dashscope.aigc.generation.Generation;
import com.alibaba.dashscope.aigc.generation.GenerationParam;
import com.alibaba.dashscope.aigc.generation.GenerationResult;
import com.alibaba.dashscope.common.Role;
import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.InputRequiredException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import io.reactivex.Flowable;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.alibaba.dashscope.common.Message;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.lang.System;
import java.util.Arrays;
/**
* Author reisen7
* Date 2025/4/9 0:04
* Description
*/
public interface DeepSeekService {
/**
* 处理AI问答请求并通过SSE流式返回结果
* @param question 用户问题
* @return SseEmitter 对象
*/
SseEmitter processQuestion(String question);
}
3.4 Service实现类
创建DeepSeekServiceImpl.java实现类,这是整个MCP系统的核心。下面我们逐步讲解其中的关键方法:
3.4.1 类结构与初始化
@Service
public class DeepSeekServiceImpl implements DeepSeekService, ApplicationContextAware {
private static final Logger logger = LoggerFactory.getLogger(DeepSeekServiceImpl.class);
private static final String MYBATIS_TOOL_NAME = "query_with_mybatis";
@Value("${ai-key}")
private String aiKey;
@Resource
private SqlSessionFactory sqlSessionFactory;
private ApplicationContext applicationContext;
private List<String> availableMethodsCache;
private final ExecutorService executor = Executors.newFixedThreadPool(5);
private final AtomicInteger eventIdGenerator = new AtomicInteger(0);
}
这个类实现了两个接口:DeepSeekService提供业务方法,ApplicationContextAware用于获取Spring上下文。关键属性包括:
aiKey:AI模型的API密钥sqlSessionFactory:MyBatis会话工厂,用于数据库操作availableMethodsCache:缓存所有可用的MyBatis方法executor:线程池,用于异步处理请求
3.4.2 Mapper方法发现
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
this.availableMethodsCache = discoverMapperMethods();
// ... 日志记录
}
@SuppressWarnings("rawtypes")
private List<String> discoverMapperMethods() {
// 1. 查找所有带@Mapper注解的Bean
Map<String, Object> mappers = new HashMap<>();
try {
mappers.putAll(applicationContext.getBeansWithAnnotation(org.apache.ibatis.annotations.Mapper.class));
} catch (BeansException e) {
logger.info("没有找到带有 @Mapper 注解的 Beans。");
}
// 2. 如果没有找到,尝试查找BaseMapper的实现类
if (mappers.isEmpty()) {
// ... 查找BaseMapper实现类的逻辑
}
// 3. 遍历所有Mapper,提取方法信息
for (Object mapperProxy : mappers.values()) {
// ... 提取方法并添加智能标记
}
// 4. 如果Spring上下文中没有找到,回退到文件系统扫描
if (methodNames.isEmpty()) {
return PackageScanner.getAvailableMapperMethods("com.dao");
}
return methodNames.stream().distinct().collect(Collectors.toList());
}
这个方法在应用启动时被调用,用于发现所有可用的MyBatis Mapper方法。它会:
- 首先尝试从Spring上下文中查找带
@Mapper注解的Bean - 如果没有找到,则查找实现了
BaseMapper接口的类 - 遍历所有找到的Mapper,提取其中的方法信息
- 为方法添加智能标记,如"✅推荐-返回列表"、"⚠️单条记录"等
- 如果Spring上下文中没有找到任何Mapper,则回退到文件系统扫描模式
3.4.3 SSE处理核心方法
@Override
public SseEmitter processQuestion(String question) {
// 1. 创建SseEmitter对象,设置超时时间
SseEmitter emitter = new SseEmitter(600_000L);
// 2. 设置回调
emitter.onCompletion(() -> logger.info("SSE连接已正常完成"));
emitter.onError(e -> { /* 错误处理 */ });
emitter.onTimeout(() -> { /* 超时处理 */ });
// 3. 异步处理请求
executor.execute(() -> {
try {
List<Message> messages = new ArrayList<>();
messages.add(buildSystemMessage());
messages.add(buildUserMessage(question));
processConversation(messages, emitter);
} catch (Exception e) {
logger.error("处理请求时发生错误", e);
sendSseResponse(emitter, "处理失败: " + e.getMessage());
} finally {
completeEmitter(emitter);
}
});
return emitter;
}
这是处理用户请求的核心方法,它:
- 创建一个
SseEmitter对象,设置10分钟的超时时间 - 设置连接完成、错误和超时的回调处理
- 使用线程池异步处理请求,避免阻塞主线程
- 构建消息列表,包括系统提示和用户问题
- 调用
processConversation方法处理对话
3.4.4 对话处理与工具调用
private void processConversation(List<Message> messages, SseEmitter emitter) throws Exception {
// 1. 调用AI模型
Generation gen = new Generation();
GenerationParam param = buildGenerationParam(messages);
GenerationResult result = gen.call(param);
Message assistantMessage = result.getOutput().getChoices().get(0).getMessage();
messages.add(assistantMessage);
// 2. 检查是否有工具调用
if (assistantMessage.getToolCalls() != null && !assistantMessage.getToolCalls().isEmpty()) {
sendSseResponse(emitter, "正在查询数据库,请稍候...");
// 3. 执行工具调用
for(com.alibaba.dashscope.tools.ToolCallBase toolCall : assistantMessage.getToolCalls()) {
if (toolCall instanceof ToolCallFunction) {
ToolCallFunction functionCall = (ToolCallFunction) toolCall;
if (MYBATIS_TOOL_NAME.equals(functionCall.getFunction().getName())) {
String toolCallResult = executeMyBatisTool(functionCall);
Message toolMessage = Message.builder()
.role(Role.TOOL.getValue())
.content(toolCallResult)
.toolCallId(functionCall.getId())
.build();
logger.info("查询方法: {}, 结果: {}", functionCall.getFunction().getName(), toolCallResult);
messages.add(toolMessage);
}
}
}
// 4. 递归处理,继续与AI对话
processConversation(messages, emitter);
} else {
// 5. 没有工具调用,直接返回AI回答
logger.info("最终回答: {}", assistantMessage.getContent());
streamAssistantResponse(assistantMessage.getContent(), emitter);
}
}
这个方法是MCP系统的核心,它实现了与AI模型的对话和工具调用:
- 调用AI模型,获取回复
- 检查AI的回复中是否包含工具调用
- 如果有工具调用,执行相应的工具(这里是MyBatis查询)
- 将工具执行结果添加到消息列表中
- 递归调用自身,继续与AI对话,直到AI不再调用工具
- 如果没有工具调用,将AI的最终回答发送给前端
3.4.5 系统提示构建
private Message buildSystemMessage() {
String systemPrompt = "你是一个全能的AI助手,尤其擅长根据用户的问题,查询数据库并给出精准的回答。你可以调用工具 " + MYBATIS_TOOL_NAME + " 来执行MyBatis的Mapper方法。\n\n"
+ "重要提示:\n"
+ "1. 优先使用返回List的方法,如selectListView、selectListVO等\n"
+ "2. 避免使用返回单条记录的方法,如selectView、selectVO、selectOne等\n"
+ "3. 如果必须使用单条记录方法,请确保查询条件足够精确(如通过主键ID查询)\n"
+ "4. 对于模糊查询或可能返回多条记录的情况,必须使用List方法\n\n"
+ "可用方法列表:\n"
+ this.availableMethodsCache.stream()
.map(method -> {
if (method.contains("selectView") && !method.contains("selectListView")) {
return method + " ⚠️单条记录";
} else if (method.contains("selectListView") || method.contains("selectListVO")) {
return method + " ✅推荐";
}
return method;
})
.collect(Collectors.joining("\n"))
+ "\n\n请根据用户的问题选择最合适的方法进行调用。";
return Message.builder().role(Role.SYSTEM.getValue()).content(systemPrompt).build();
}
这个方法构建了系统提示,指导AI如何使用MyBatis工具:
- 定义AI的角色和能力
- 提供重要提示,指导AI选择合适的查询方法
- 列出所有可用的MyBatis方法,并添加智能标记
- 要求AI根据用户问题选择最合适的查询方法
3.4.6 SSE响应发送
private void sendSseResponse(SseEmitter emitter, String data) {
try {
int eventId = eventIdGenerator.incrementAndGet();
// 将换行符替换为 <br>,确保 SSE 传输安全且前端 v-html 能正确渲染
String safeData = data.replace("\n", "<br>");
emitter.send(SseEmitter.event()
.id(String.valueOf(eventId))
.name("message")
.data(safeData, MediaType.TEXT_PLAIN));
} catch (IOException e) {
logger.debug("向客户端发送SSE事件失败,连接可能已关闭: {}", e.getMessage());
completeEmitter(emitter);
}
}
这个方法负责向客户端发送SSE响应:
- 生成唯一的事件ID
- 将换行符替换为
<br>标签,确保SSE传输安全且前端能正确渲染 - 构建SSE事件并发送给客户端
- 处理发送失败的情况,完成连接
通过这些关键方法的组合,DeepSeekServiceImpl实现了完整的MCP系统功能,包括AI对话、工具调用、数据库查询和流式响应。
3.4.7 完整代码
package com.service.impl;
import com.alibaba.dashscope.aigc.generation.Generation;
import com.alibaba.dashscope.aigc.generation.GenerationParam;
import com.alibaba.dashscope.aigc.generation.GenerationResult;
import com.alibaba.dashscope.common.Message;
import com.alibaba.dashscope.common.Role;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.alibaba.dashscope.tools.FunctionDefinition;
import com.alibaba.dashscope.tools.ToolCallFunction;
import com.alibaba.dashscope.tools.ToolFunction;
import com.baomidou.mybatisplus.mapper.BaseMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.service.DeepSeekService;
import com.utils.MyBatisToolCall;
import com.utils.PackageScanner;
import org.apache.ibatis.session.SqlSessionFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.annotation.Resource;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
@Service
public class DeepSeekServiceImpl implements DeepSeekService, ApplicationContextAware {
private static final Logger logger = LoggerFactory.getLogger(DeepSeekServiceImpl.class);
@Value("${ai-key}")
private String aiKey;
@Resource
private SqlSessionFactory sqlSessionFactory;
private ApplicationContext applicationContext;
private List<String> availableMethodsCache;
private final ExecutorService executor = Executors.newFixedThreadPool(5);
private final AtomicInteger eventIdGenerator = new AtomicInteger(0);
private static final String MYBATIS_TOOL_NAME = "query_with_mybatis";
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
this.availableMethodsCache = discoverMapperMethods();
if (availableMethodsCache != null && !availableMethodsCache.isEmpty()) {
logger.info("成功发现并缓存了 {} 个MyBatis Mapper方法。", availableMethodsCache.size());
} else {
logger.warn("未能发现任何MyBatis Mapper方法。");
}
}
@SuppressWarnings("rawtypes")
private List<String> discoverMapperMethods() {
List<String> methodNames = new ArrayList<>();
Map<String, Object> mappers = new HashMap<>();
try {
mappers.putAll(applicationContext.getBeansWithAnnotation(org.apache.ibatis.annotations.Mapper.class));
} catch (BeansException e) {
logger.info("没有找到带有 @Mapper 注解的 Beans。");
}
if (mappers.isEmpty()) {
logger.info("尝试查找所有实现 BaseMapper 的 Beans。");
try {
Map<String, BaseMapper> baseMappers = applicationContext.getBeansOfType(BaseMapper.class);
mappers.putAll(baseMappers);
} catch (Exception e) {
logger.warn("获取 BaseMapper 类型的 Beans 失败。", e);
}
}
if (!mappers.isEmpty()) {
for (Object mapperProxy : mappers.values()) {
Class<?>[] interfaces = mapperProxy.getClass().getInterfaces();
for (Class<?> mapperInterface : interfaces) {
if (mapperInterface.getName().startsWith("com.dao")) {
for (Method method : mapperInterface.getDeclaredMethods()) {
if (!method.isDefault() && method.getDeclaringClass() != Object.class) {
String fullMethodName = mapperInterface.getName() + "." + method.getName();
String displayName = fullMethodName;
// 添加智能标记
if (method.getName().contains("selectList")) {
displayName += " ✅推荐-返回列表";
} else if (method.getName().contains("selectView") || method.getName().contains("selectVO")) {
displayName += " ⚠️单条记录";
} else if (method.getName().contains("selectOne")) {
displayName += " ❗单条记录-慎用";
}
methodNames.add(displayName);
}
}
}
}
}
}
if (methodNames.isEmpty()) {
logger.warn("未能通过 Spring 上下文发现任何 Mapper 方法,将回退到文件系统扫描模式。");
return PackageScanner.getAvailableMapperMethods("com.dao");
}
return methodNames.stream().distinct().collect(Collectors.toList());
}
@Override
public SseEmitter processQuestion(String question) {
SseEmitter emitter = new SseEmitter(600_000L);
emitter.onCompletion(() -> logger.info("SSE连接已正常完成"));
emitter.onError(e -> {
if (e instanceof IOException && e.getMessage().contains("中止了一个已建立的连接")) {
logger.debug("客户端主动中断连接");
} else {
logger.error("SSE连接发生错误", e);
}
});
emitter.onTimeout(() -> {
try {
sendSseResponse(emitter, "请求处理超时,请稍后重试");
} catch (Exception ex) {
logger.debug("超时响应发送失败(连接可能已关闭)");
} finally {
completeEmitter(emitter);
}
});
executor.execute(() -> {
try {
List<Message> messages = new ArrayList<>();
messages.add(buildSystemMessage());
messages.add(buildUserMessage(question));
processConversation(messages, emitter);
} catch (Exception e) {
logger.error("处理请求时发生错误", e);
sendSseResponse(emitter, "处理失败: " + e.getMessage());
} finally {
completeEmitter(emitter);
}
});
return emitter;
}
private void processConversation(List<Message> messages, SseEmitter emitter) throws Exception {
Generation gen = new Generation();
GenerationParam param = buildGenerationParam(messages);
GenerationResult result = gen.call(param);
Message assistantMessage = result.getOutput().getChoices().get(0).getMessage();
messages.add(assistantMessage);
if (assistantMessage.getToolCalls() != null && !assistantMessage.getToolCalls().isEmpty()) {
sendSseResponse(emitter, "正在查询数据库,请稍候...");
for(com.alibaba.dashscope.tools.ToolCallBase toolCall : assistantMessage.getToolCalls()) {
if (toolCall instanceof ToolCallFunction) {
ToolCallFunction functionCall = (ToolCallFunction) toolCall;
if (MYBATIS_TOOL_NAME.equals(functionCall.getFunction().getName())) {
String toolCallResult = executeMyBatisTool(functionCall);
Message toolMessage = Message.builder()
.role(Role.TOOL.getValue())
.content(toolCallResult)
.toolCallId(functionCall.getId())
.build();
logger.info("查询方法: {}, 结果: {}", functionCall.getFunction().getName(), toolCallResult);
messages.add(toolMessage);
}
}
}
processConversation(messages, emitter);
} else {
logger.info("最终回答: {}", assistantMessage.getContent());
streamAssistantResponse(assistantMessage.getContent(), emitter);
}
}
private Message buildSystemMessage() {
String systemPrompt = "你是一个全能的AI助手,尤其擅长根据用户的问题,查询数据库并给出精准的回答。你可以调用工具 " + MYBATIS_TOOL_NAME + " 来执行MyBatis的Mapper方法。\n\n"
+ "重要提示:\n"
+ "1. 优先使用返回List的方法,如selectListView、selectListVO等\n"
+ "2. 避免使用返回单条记录的方法,如selectView、selectVO、selectOne等\n"
+ "3. 如果必须使用单条记录方法,请确保查询条件足够精确(如通过主键ID查询)\n"
+ "4. 对于模糊查询或可能返回多条记录的情况,必须使用List方法\n\n"
+ "5. 返回结果必须使用标准Markdown格式:\n"
+ " - 列表项用『- 内容』表示,每个字段占一行\n"
+ " - 需要加粗的字段名用『**字段名**』表示(如**会员账号**)\n"
+ " - 换行用\\n(不要用<br>)\n"
+ " - 不要包含任何HTML标签\n"
+ "6. 请结合用户的需求,选择最合适的方法进行调用,显示关键的数据即可,避免返回大量冗余信息。\n"
+ "7. 根据用户的需求,合理的返回所需的数据,你不是单纯的数据库查询助手,你必须体现的比较智能。\n"
+ "可用方法列表:\n"
+ this.availableMethodsCache.stream()
.map(method -> {
if (method.contains("selectView") && !method.contains("selectListView")) {
return method + " ⚠️单条记录";
} else if (method.contains("selectListView") || method.contains("selectListVO")) {
return method + " ✅推荐";
}
return method;
})
.collect(Collectors.joining("\n"))
+ "\n\n请根据用户的问题选择最合适的方法进行调用。";
return Message.builder().role(Role.SYSTEM.getValue()).content(systemPrompt).build();
}
private Message buildUserMessage(String question) {
return Message.builder().role(Role.USER.getValue()).content(question).build();
}
private GenerationParam buildGenerationParam(List<Message> messages) throws NoApiKeyException {
FunctionDefinition functionDefinition = FunctionDefinition.builder()
.name(MYBATIS_TOOL_NAME)
.description("执行一个MyBatis Mapper接口中的方法来查询数据库。")
.build();
ToolFunction myBatisTool = ToolFunction.builder()
.function(functionDefinition)
.build();
return GenerationParam.builder()
.model("qwen-flash")
.apiKey(aiKey)
.messages(messages)
.resultFormat(GenerationParam.ResultFormat.MESSAGE)
.tools(Collections.singletonList(myBatisTool))
.build();
}
private String executeMyBatisTool(ToolCallFunction functionCall) {
try {
String arguments = functionCall.getFunction().getArguments();
logger.info("AI准备调用MyBatis方法,参数: {}", arguments);
String result = MyBatisToolCall.execute(sqlSessionFactory, arguments);
logger.info("MyBatis方法执行完成,返回结果长度: {}", result != null ? result.length() : 0);
return result;
} catch (Exception e) {
logger.error("执行MyBatis工具时出错,参数: {}", functionCall.getFunction().getArguments(), e);
return "{\"error\": \"" + e.getMessage() + "\"}";
}
}
private void streamAssistantResponse(String content, SseEmitter emitter) {
if (content != null && !content.isEmpty()) {
sendSseResponse(emitter, content);
}
}
private void sendSseResponse(SseEmitter emitter, String data) {
try {
int eventId = eventIdGenerator.incrementAndGet();
// 只替换多余的空行,保留必要的换行结构
String safeData = data.replaceAll("\n{2,}", "\n").replace("\n", "[NEWLINE]");
// 对特殊字符进行转义
safeData = safeData.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'");
emitter.send(SseEmitter.event()
.id(String.valueOf(eventId))
.name("message")
.data(safeData, MediaType.TEXT_HTML)); // 改为TEXT_HTML更合适
} catch (IOException e) {
logger.debug("向客户端发送SSE事件失败,连接可能已关闭: {}", e.getMessage());
completeEmitter(emitter);
}
}
private void completeEmitter(SseEmitter emitter) {
try {
emitter.complete();
} catch (Exception e) {
logger.debug("完成SSE Emitter时出错(可能已经完成或关闭): {}", e.getMessage());
}
}
}
3.5 工具类实现
3.5.1 MyBatis工具类
MyBatisToolCall是MCP系统的核心工具类,负责动态执行MyBatis Mapper方法。下面我们分步讲解其关键方法:
3.5.1.1 类结构与核心方法
public class MyBatisToolCall {
private static final Logger logger = LoggerFactory.getLogger(MyBatisToolCall.class);
private static final ObjectMapper objectMapper = new ObjectMapper();
/**
* 执行MyBatis Mapper方法的核心方法
* @param sqlSessionFactory MyBatis会话工厂
* @param arguments AI模型提供的JSON格式参数
* @return JSON格式的查询结果
*/
public static String execute(SqlSessionFactory sqlSessionFactory, String arguments) {
// 方法实现...
}
}
这个类提供了静态方法execute,接收SqlSessionFactory和JSON格式的参数,返回JSON格式的查询结果。
3.5.1.2 参数解析与验证
public static String execute(SqlSessionFactory sqlSessionFactory, String arguments) {
String methodNameWithMapper = null;
JsonNode methodArgsNode = null;
try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
// 1. 解析JSON参数
JsonNode argsNode = objectMapper.readTree(arguments);
// 2. 支持两种参数格式
if (argsNode.has("methodName")) {
// 格式1: {"methodName": "HuiyuanDao.selectListView", "args": {...}}
methodNameWithMapper = argsNode.get("methodName").asText();
methodArgsNode = argsNode.get("args");
} else if (argsNode.has("mapper") && argsNode.has("method")) {
// 格式2: {"mapper": "HuiyuanDao", "method": "selectListView", "params": {...}}
methodNameWithMapper = argsNode.get("mapper").asText() + "." + argsNode.get("method").asText();
if (argsNode.has("params")) {
methodArgsNode = argsNode.get("params");
}
} else {
return "{\"error\": \"Invalid arguments format...\"}";
}
// 3. 验证方法名格式
int lastDotIndex = methodNameWithMapper.lastIndexOf('.');
if (lastDotIndex == -1) {
return "{\"error\": \"Invalid methodName format...\"}";
}
// 后续处理...
}
}
execute方法首先解析AI模型提供的JSON参数,支持两种格式:
methodName格式:直接指定完整的方法名mapper/method格式:分别指定Mapper名和方法名
3.5.1.3 动态查找Mapper和方法
// 解析Mapper名和方法名
String mapperIdentifier = methodNameWithMapper.substring(0, lastDotIndex);
String methodName = methodNameWithMapper.substring(lastDotIndex + 1);
// 查找目标Mapper类
Configuration configuration = sqlSessionFactory.getConfiguration();
Class<?> targetMapper = null;
for (Class<?> mapperClass : configuration.getMapperRegistry().getMappers()) {
if (mapperClass.getName().equals(mapperIdentifier) ||
mapperClass.getSimpleName().equals(mapperIdentifier)) {
targetMapper = mapperClass;
break;
}
}
if (targetMapper == null) {
return "{\"error\": \"Mapper '" + mapperIdentifier + "' not found.\"}";
}
// 查找目标方法 - 优先选择参数最少的方法
Method targetMethod = null;
Method[] methods = targetMapper.getDeclaredMethods();
int minParamCount = Integer.MAX_VALUE;
for (Method method : methods) {
if (method.getName().equals(methodName)) {
int paramCount = method.getParameterCount();
// 优先选择参数最少的方法,避免选择包含Pagination的方法
if (paramCount < minParamCount) {
minParamCount = paramCount;
targetMethod = method;
}
}
}
这段代码实现了动态查找Mapper类和方法的功能:
- 从方法名字符串中解析出Mapper名和方法名
- 在MyBatis配置中查找匹配的Mapper类
- 在Mapper类中查找匹配的方法,优先选择参数最少的方法
3.5.1.4 方法调用与结果处理
// 准备方法参数
Object[] methodArgs = prepareMethodArguments(targetMethod, methodArgsNode);
// 获取Mapper实例并调用方法
Object mapper = sqlSession.getMapper(targetMapper);
Object result = targetMethod.invoke(mapper, methodArgs);
// 序列化结果并返回
String jsonResult = objectMapper.writeValueAsString(result);
return jsonResult;
最后,准备好方法参数后,通过反射调用目标方法,并将结果序列化为JSON格式返回。
3.5.1.5 智能重试机制
private static String retryWithListMethod(SqlSessionFactory sqlSessionFactory,
String originalMethodNameWithMapper,
JsonNode originalArgsNode) throws Exception {
// 1. 智能转换方法名
String listMethodName = null;
if (originalMethodNameWithMapper.endsWith("selectView")) {
listMethodName = originalMethodNameWithMapper.replace("selectView", "selectListView");
} else if (originalMethodNameWithMapper.endsWith("selectVO")) {
listMethodName = originalMethodNameWithMapper.replace("selectVO", "selectListVO");
} else if (originalMethodNameWithMapper.endsWith("selectOne")) {
listMethodName = originalMethodNameWithMapper.replace("selectOne", "selectList");
}
// ... 其他转换逻辑
// 2. 添加查询限制,避免返回过多数据
for (int i = 0; i < methodArgs.length; i++) {
if (methodArgs[i] instanceof EntityWrapper) {
EntityWrapper<?> ew = (EntityWrapper<?>) methodArgs[i];
ew.last("LIMIT 50"); // 限制最多50条记录
methodArgs[i] = ew;
break;
}
}
// 3. 执行列表查询方法
Object mapper = sqlSession.getMapper(targetMapper);
Object result = targetMethod.invoke(mapper, methodArgs);
return objectMapper.writeValueAsString(result);
}
当单条查询方法返回多条记录时,系统会自动重试对应的列表查询方法:
- 智能转换方法名(如
selectView->selectListView) - 添加查询限制,避免返回过多数据
- 执行列表查询方法并返回结果
3.5.1.6 方法参数准备
private static Object[] prepareMethodArguments(Method method, JsonNode argsNode) {
Parameter[] parameters = method.getParameters();
Object[] preparedArgs = new Object[parameters.length];
for (int i = 0; i < parameters.length; i++) {
Parameter param = parameters[i];
Class<?> paramType = param.getType();
// 1. 跳过分页参数
if (paramType == com.baomidou.mybatisplus.plugins.pagination.Pagination.class) {
preparedArgs[i] = null;
continue;
}
// 2. 创建EntityWrapper
EntityWrapper<?> ew = new EntityWrapper<>();
// 3. 处理查询条件
if (argsNode != null && argsNode.isObject()) {
argsNode.fields().forEachRemaining(entry -> {
String key = entry.getKey();
String value = entry.getValue().asText();
ew.eq(key, value);
});
}
preparedArgs[i] = ew;
}
return preparedArgs;
}
prepareMethodArguments方法负责准备方法调用所需的参数:
- 跳过分页参数(我们不使用分页)
- 为每个参数创建
EntityWrapper对象 - 将JSON中的查询条件添加到
EntityWrapper中
3.5.1.7 完整代码
package com.utils;
import com.baomidou.mybatisplus.mapper.EntityWrapper;
import com.baomidou.mybatisplus.mapper.Wrapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.exceptions.TooManyResultsException;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
/**
* @author reisen7
* @date 2025-10-03 14:17
* @description mybatis工具类,用于执行mybatis mapper方法
*/
public class MyBatisToolCall {
private static final Logger logger = LoggerFactory.getLogger(MyBatisToolCall.class);
private static final ObjectMapper objectMapper = new ObjectMapper();
/**
* Executes a MyBatis Mapper method based on the arguments provided by the language model.
*
* @param sqlSessionFactory The SqlSessionFactory to create a SqlSession.
* @param arguments A JSON string from the model, containing "methodName" and "args".
* @return A JSON string representing the result of the method execution.
*/
public static String execute(SqlSessionFactory sqlSessionFactory, String arguments) {
String methodNameWithMapper = null;
JsonNode methodArgsNode = null;
try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
JsonNode argsNode = objectMapper.readTree(arguments);
if (argsNode.has("methodName")) {
methodNameWithMapper = argsNode.get("methodName").asText();
methodArgsNode = argsNode.get("args");
} else if (argsNode.has("mapper") && argsNode.has("method")) {
methodNameWithMapper = argsNode.get("mapper").asText() + "." + argsNode.get("method").asText();
// 对于mapper/method格式,params可能是直接的条件对象
if (argsNode.has("params")) {
methodArgsNode = argsNode.get("params");
if (methodArgsNode.isObject() && methodArgsNode.size() > 0) {
logger.info("检测到查询条件: {}", methodArgsNode);
}
} else {
methodArgsNode = null;
}
} else {
logger.error("Invalid arguments format. 'methodName' or 'mapper'/'method' is missing: {}", arguments);
return "{\"error\": \"Invalid arguments format. 'methodName' or 'mapper'/'method' is missing.\"}";
}
// methodNameWithMapper 的格式可能是 "HuiyuanDao.selectListView" 或 "com.dao.HuiyuanDao.selectListView"
int lastDotIndex = methodNameWithMapper.lastIndexOf('.');
if (lastDotIndex == -1) {
return "{\"error\": \"Invalid methodName format. Expected 'MapperName.methodName'.\"}";
}
String mapperIdentifier = methodNameWithMapper.substring(0, lastDotIndex);
String methodName = methodNameWithMapper.substring(lastDotIndex + 1);
// Find the target mapper class
Configuration configuration = sqlSessionFactory.getConfiguration();
Class<?> targetMapper = null;
// 尝试通过全限定名或简单名称匹配
for (Class<?> mapperClass : configuration.getMapperRegistry().getMappers()) {
if (mapperClass.getName().equals(mapperIdentifier) || mapperClass.getSimpleName().equals(mapperIdentifier)) {
targetMapper = mapperClass;
break;
}
}
if (targetMapper == null) {
return "{\"error\": \"Mapper '" + mapperIdentifier + "' not found.\"}";
}
// Find the target method - 优先选择参数最少的方法
Method targetMethod = null;
Method[] methods = targetMapper.getDeclaredMethods();
int minParamCount = Integer.MAX_VALUE;
for (Method method : methods) {
if (method.getName().equals(methodName)) {
int paramCount = method.getParameterCount();
// 优先选择参数最少的方法,避免选择包含Pagination的方法
if (paramCount < minParamCount) {
minParamCount = paramCount;
targetMethod = method;
}
}
}
if (targetMethod == null) {
return "{\"error\": \"Method '" + methodName + "' not found in mapper '" + targetMapper.getSimpleName() + "'.\"}";
}
// Prepare arguments for method invocation
Object[] methodArgs = prepareMethodArguments(targetMethod, methodArgsNode);
// Get the mapper instance and invoke the method
Object mapper = sqlSession.getMapper(targetMapper);
Object result = targetMethod.invoke(mapper, methodArgs);
logger.info("MyBatis方法 {} 执行成功,返回结果类型: {}", methodNameWithMapper,
result != null ? result.getClass().getSimpleName() : "null");
// Serialize and return the result
String jsonResult = objectMapper.writeValueAsString(result);
logger.debug("MyBatis方法 {} 返回JSON数据长度: {}", methodNameWithMapper, jsonResult.length());
return jsonResult;
} catch (InvocationTargetException e) {
// Check for TooManyResultsException and retry with a list method
if (e.getTargetException() instanceof TooManyResultsException) {
logger.warn("方法 {} 返回多条记录,准备重试列表查询: {}", methodNameWithMapper, e.getTargetException().getMessage());
try {
return retryWithListMethod(sqlSessionFactory, methodNameWithMapper, methodArgsNode);
} catch (Exception retryException) {
logger.error("重试列表查询失败: {}", retryException.getMessage(), retryException);
return "{\"error\": \"查询返回了多条记录,重试失败: " + retryException.getMessage() + "\"}";
}
}
logger.error("执行MyBatis方法 {} 失败: {}", methodNameWithMapper, e.getTargetException().getMessage(), e);
return "{\"error\": \"执行失败: " + e.getTargetException().getMessage() + "\"}";
} catch (Exception e) {
logger.error("执行MyBatis方法 {} 发生错误: {}", methodNameWithMapper, e.getMessage(), e);
return "{\"error\": \"执行错误: " + e.getMessage() + "\"}";
}
}
private static String retryWithListMethod(SqlSessionFactory sqlSessionFactory, String originalMethodNameWithMapper, JsonNode originalArgsNode) throws Exception {
String listMethodName = null;
// 更智能的方法名转换
if (originalMethodNameWithMapper.endsWith("selectView")) {
listMethodName = originalMethodNameWithMapper.replace("selectView", "selectListView");
} else if (originalMethodNameWithMapper.endsWith("selectVO")) {
listMethodName = originalMethodNameWithMapper.replace("selectVO", "selectListVO");
} else if (originalMethodNameWithMapper.endsWith("selectOne")) {
listMethodName = originalMethodNameWithMapper.replace("selectOne", "selectList");
} else if (originalMethodNameWithMapper.endsWith("View")) {
listMethodName = originalMethodNameWithMapper.replace("View", "ListView");
} else if (originalMethodNameWithMapper.endsWith("One")) {
listMethodName = originalMethodNameWithMapper.replace("One", "List");
} else {
return "{\"error\": \"查询返回了多条记录,但未找到对应的列表查询方法。请使用返回List的方法进行查询。\"}";
}
logger.info("重试列表查询 - 原始方法: {} -> 列表方法: {}", originalMethodNameWithMapper, listMethodName);
try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
int lastDotIndex = listMethodName.lastIndexOf('.');
String mapperIdentifier = listMethodName.substring(0, lastDotIndex);
String methodName = listMethodName.substring(lastDotIndex + 1);
Configuration configuration = sqlSessionFactory.getConfiguration();
Class<?> targetMapper = null;
for (Class<?> mapperClass : configuration.getMapperRegistry().getMappers()) {
if (mapperClass.getName().equals(mapperIdentifier) || mapperClass.getSimpleName().equals(mapperIdentifier)) {
targetMapper = mapperClass;
break;
}
}
if (targetMapper == null) {
logger.error("重试失败:未找到Mapper '{}'", mapperIdentifier);
return "{\"error\": \"重试失败:未找到Mapper '" + mapperIdentifier + "'\"}";
}
Method targetMethod = null;
for (Method method : targetMapper.getDeclaredMethods()) {
if (method.getName().equals(methodName)) {
targetMethod = method;
break;
}
}
if (targetMethod == null) {
logger.error("重试失败:在Mapper '{}' 中未找到方法 '{}'", targetMapper.getSimpleName(), methodName);
return "{\"error\": \"重试失败:在Mapper '" + targetMapper.getSimpleName() + "' 中未找到方法 '" + methodName + "'\"}";
}
// 使用原始参数,确保查询条件不丢失
Object[] methodArgs = prepareMethodArguments(targetMethod, originalArgsNode);
logger.info("重试方法参数: {}", objectMapper.writeValueAsString(methodArgs));
// 添加合理的限制,避免返回过多数据
for (int i = 0; i < methodArgs.length; i++) {
if (methodArgs[i] instanceof EntityWrapper) {
EntityWrapper<?> ew = (EntityWrapper<?>) methodArgs[i];
ew.last("LIMIT 50"); // 限制最多50条记录
methodArgs[i] = ew;
logger.info("添加查询限制: LIMIT 50");
break;
} else if (methodArgs[i] == null && i < targetMethod.getParameterCount()) {
Class<?> paramType = targetMethod.getParameters()[i].getType();
if (Wrapper.class.isAssignableFrom(paramType)) {
EntityWrapper<?> ew = new EntityWrapper<>();
ew.last("LIMIT 50");
methodArgs[i] = ew;
logger.info("创建新Wrapper并添加限制: LIMIT 50");
}
}
}
Object mapper = sqlSession.getMapper(targetMapper);
Object result = targetMethod.invoke(mapper, methodArgs);
logger.info("重试方法 {} 执行成功,返回结果类型: {}", listMethodName,
result != null ? result.getClass().getSimpleName() : "null");
return objectMapper.writeValueAsString(result);
}
}
/**
* Prepares the arguments for method invocation based on the method's parameters and the JSON from the model.
*/
private static Object[] prepareMethodArguments(Method method, JsonNode argsNode) {
Parameter[] parameters = method.getParameters();
Object[] preparedArgs = new Object[parameters.length];
logger.info("准备方法参数 - 方法: {}.{},参数数量: {}",
method.getDeclaringClass().getSimpleName(), method.getName(), parameters.length);
for (int i = 0; i < parameters.length; i++) {
Parameter param = parameters[i];
String paramName = param.getName(); // Fallback
if (param.isAnnotationPresent(Param.class)) {
paramName = param.getAnnotation(Param.class).value();
}
Class<?> paramType = param.getType();
// 检查是否为分页参数
if (paramType == com.baomidou.mybatisplus.plugins.pagination.Pagination.class) {
preparedArgs[i] = null;
logger.info("参数[{}] {}: 跳过分页参数", i, paramName);
continue;
}
EntityWrapper<?> ew = new EntityWrapper<>();
if (argsNode != null) {
// 处理直接条件对象
if (argsNode.isObject() && !argsNode.has(paramName)) {
argsNode.fields().forEachRemaining(entry -> {
String key = entry.getKey();
String value = entry.getValue().asText();
ew.eq(key, value);
logger.info("Wrapper条件: {} = {}", key, value);
});
preparedArgs[i] = ew;
} else if (argsNode.has(paramName)) {
JsonNode argValueNode = argsNode.get(paramName);
try {
logger.info("参数[{}] {}: {} -> {}", i, paramName, argValueNode, paramType.getSimpleName());
if (paramType.isAssignableFrom(EntityWrapper.class) || paramType.isAssignableFrom(Wrapper.class)) {
if (argValueNode.isObject() && argValueNode.size() > 0) {
argValueNode.fields().forEachRemaining(entry -> {
ew.eq(entry.getKey(), entry.getValue().asText());
logger.info("Wrapper条件: {} = {}", entry.getKey(), entry.getValue().asText());
});
preparedArgs[i] = ew;
} else {
preparedArgs[i] = ew;
logger.info("创建空Wrapper参数");
}
} else {
preparedArgs[i] = objectMapper.treeToValue(argValueNode, paramType);
logger.info("参数[{}]映射成功: {} -> {}", i, argValueNode, paramType.getSimpleName());
}
} catch (Exception e) {
logger.error("参数[{}]映射失败: {} -> {}", i, paramName, paramType.getSimpleName(), e);
preparedArgs[i] = null;
}
} else {
preparedArgs[i] = ew;
logger.info("参数[{}]使用默认空Wrapper", i);
}
} else {
preparedArgs[i] = ew;
logger.info("参数[{}]使用默认空Wrapper", i);
}
}
// 打印所有准备好的参数
for (int i = 0; i < preparedArgs.length; i++) {
logger.info("最终参数[{}]: {} (类型: {})", i,
preparedArgs[i] != null ? preparedArgs[i].toString() : "null",
preparedArgs[i] != null ? preparedArgs[i].getClass().getSimpleName() : "null");
}
return preparedArgs;
}
}
3.5.2 包扫描工具类
PackageScanner是一个辅助工具类,用于在Spring上下文中找不到Mapper时,通过文件系统扫描来发现Mapper方法。
3.5.2.1 核心扫描方法
public class PackageScanner {
private static final Logger logger = LoggerFactory.getLogger(PackageScanner.class);
/**
* 扫描指定包中的所有Mapper接口方法
* @param basePackage 要扫描的包名,如"com.dao"
* @return 方法名列表,如["com.dao.UserDao.selectById"]
*/
public static List<String> getAvailableMapperMethods(String basePackage) {
List<String> methodNames = new ArrayList<>();
try {
// 1. 查找包中的所有接口
List<Class<?>> mapperInterfaces = findMapperInterfaces(basePackage);
// 2. 提取每个接口的方法
for (Class<?> mapperClass : mapperInterfaces) {
for (Method method : mapperClass.getDeclaredMethods()) {
// 只添加接口本身的公共方法
if (!method.isDefault() && method.getDeclaringClass() != Object.class) {
methodNames.add(mapperClass.getName() + "." + method.getName());
}
}
}
} catch (Exception e) {
logger.error("扫描包失败: {}", basePackage, e);
}
return methodNames;
}
}
这个方法的主要功能是:
- 查找指定包中的所有接口类
- 提取每个接口中的方法
- 返回完整的方法名列表
3.5.2.2 文件系统扫描实现
private static List<Class<?>> findMapperInterfaces(String basePackage)
throws IOException, ClassNotFoundException, URISyntaxException {
List<Class<?>> classes = new ArrayList<>();
String path = basePackage.replace('.', '/');
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
Enumeration<URL> resources = classLoader.getResources(path);
while (resources.hasMoreElements()) {
URL resource = resources.nextElement();
if ("file".equals(resource.getProtocol())) {
File directory = new File(resource.toURI());
if (directory.exists() && directory.isDirectory()) {
File[] files = directory.listFiles();
if (files != null) {
for (File file : files) {
if (file.isFile() && file.getName().endsWith(".class")) {
// 将文件名转换为类名
String className = basePackage + '.' +
file.getName().substring(0, file.getName().length() - 6);
classes.add(Class.forName(className));
}
}
}
}
}
}
return classes;
}
findMapperInterfaces方法实现了文件系统扫描功能:
- 将包名转换为文件系统路径
- 获取类加载器中的所有资源
- 遍历文件系统中的.class文件
- 将文件名转换为类名并加载类
这个工具类作为备用机制,当Spring上下文中找不到Mapper时,可以通过文件系统扫描来发现Mapper方法,确保系统的健壮性。
3.5.2.3 完整代码
package com.utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Method;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
/**
* @author reisen7
* @date 2025-10-03 14:18
* @description 包扫描工具类,用于扫描指定包下的所有类
*/
public class PackageScanner {
private static final Logger logger = LoggerFactory.getLogger(PackageScanner.class);
/**
* Scans the specified package for interface classes and returns a list of their method names.
* This is a fallback mechanism.
*
* @param basePackage The package to scan, e.g., "com.dao".
* @return A list of fully qualified method names, e.g., "com.dao.UserDao.selectById".
*/
public static List<String> getAvailableMapperMethods(String basePackage) {
List<String> methodNames = new ArrayList<>();
try {
List<Class<?>> mapperInterfaces = findMapperInterfaces(basePackage);
for (Class<?> mapperClass : mapperInterfaces) {
for (Method method : mapperClass.getDeclaredMethods()) {
// Ensure we only add public methods of the interface itself
if (!method.isDefault() && method.getDeclaringClass() != Object.class) {
methodNames.add(mapperClass.getName() + "." + method.getName());
}
}
}
} catch (IOException | ClassNotFoundException | URISyntaxException e) {
logger.error("Could not scan package for mapper methods: {}", basePackage, e);
}
return methodNames;
}
/**
* Finds all classes within a given package.
*
* @param basePackage The base package to search.
* @return A list of Class objects found in the package.
*/
private static List<Class<?>> findMapperInterfaces(String basePackage) throws IOException, ClassNotFoundException, URISyntaxException {
List<Class<?>> classes = new ArrayList<>();
String path = basePackage.replace('.', '/');
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
Enumeration<URL> resources = classLoader.getResources(path);
while (resources.hasMoreElements()) {
URL resource = resources.nextElement();
if ("file".equals(resource.getProtocol())) {
File directory = new File(resource.toURI());
if (directory.exists() && directory.isDirectory()) {
File[] files = directory.listFiles();
if (files != null) {
for (File file : files) {
if (file.isFile() && file.getName().endsWith(".class")) {
String className = basePackage + '.' + file.getName().substring(0, file.getName().length() - 6);
classes.add(Class.forName(className));
}
}
}
}
}
}
return classes;
}
}
4. 前端实现
4.1 前端组件说明
前端使用Vue.js实现,主要组件为Model.vue,负责与后端SSE接口交互并展示AI回复。
4.2 完整前端代码
<template>
<div class="doubao-chat-container" v-show="show" @mousemove="modelMove" @mouseup="cancelMove">
<div class="doubao-chat-window">
<div class="doubao-header" @mousedown="setStartingPoint">
<div class="doubao-logo"></div>
<div class="doubao-title">{{ title }}</div>
<div class="doubao-close" @click.stop="cancel">×</div>
</div>
<div class="doubao-messages" ref="box">
<div v-for="(item, i) in list" :key="i"
:class="['message', item.id == 2 ? 'bot-message' : 'user-message']">
<div class="message-content">
<div class="message-text">
<!-- 机器人消息:显示逐字更新的 displayText -->
<template v-if="item.id === 2">
<span v-html="item.displayText || ''"></span>
<!-- 显示打字指示器 -->
<span
v-if="item.isTyping && item.displayText && item.displayText.length < item.content.length"
class="typing-cursor">|</span>
</template>
<!-- 用户消息:仍显示原始 content -->
<template v-else>
{{ item.content }}
</template>
</div>
</div>
</div>
<!-- 全局打字指示器 -->
<div v-if="isTyping" class="typing-indicator">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
</div>
<div class="doubao-input-area">
<input type="text" v-model="wordone" class="doubao-input" placeholder="输入消息..." @keyup.enter="sendmsg">
<button class="doubao-send-btn" :disabled="isButtonDisabled" @click="sendmsg">
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"></path>
</svg>
</button>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
show: {
type: Boolean,
default: false
},
title: {
type: String,
default: 'deepseek'
},
},
data() {
return {
x: 0,
y: 0,
node: null,
isCanMove: false,
isButtonDisabled: false,
isTyping: false, // 全局打字状态
list: [],
wordone: '',
eventSource: null,
currentBotMessage: null, // 当前正在接收的机器人消息
typingTimer: null // 逐字显示定时器
}
},
mounted() {
this.node = document.querySelector('.model-container')
},
methods: {
sendmsg() {
if (this.isButtonDisabled || !this.wordone.trim()) {
return;
}
// 重置状态
this.resetTypingState();
// 添加用户消息
this.list.push({
id: 1,
name: 'user',
content: this.wordone.trim()
});
this.scrollToBottom();
// 开始获取机器人回复
this.getBotContent();
this.isButtonDisabled = true;
this.isTyping = true; // 显示打字指示器
this.wordone = '';
},
getBotContent() {
// 清理旧的连接
this.cleanupEventSource();
// 创建新的机器人消息对象(只创建一个)
this.currentBotMessage = {
id: 2,
name: 'bot',
content: '',
displayText: '',
isTyping: true,
isCompleted: false
};
this.list.push(this.currentBotMessage);
// 初始化SSE连接
const encodedQuestion = encodeURIComponent(this.wordone);
this.eventSource = new EventSource(`http://localhost:8080/api/deepSeek/query?question=${encodedQuestion}`);
this.eventSource.onmessage = (event) => {
const sseContent = event.data.trim();
if (!sseContent) return;
// 累积内容到当前机器人消息
if (this.currentBotMessage) {
this.currentBotMessage.content += sseContent;
// 如果还没有开始逐字显示,则开始
if (!this.typingTimer) {
this.startTyping();
}
}
};
this.eventSource.onerror = (error) => {
console.error('SSE错误:', error);
//this.handleError();
};
this.eventSource.onclose = () => {
console.log('SSE连接关闭');
//this.handleMessageComplete();
};
},
startTyping() {
if (!this.currentBotMessage || this.typingTimer) {
return;
}
this.typingTimer = setInterval(() => {
if (!this.currentBotMessage) {
this.resetTypingState();
return;
}
const fullContent = this.currentBotMessage.content;
const currentDisplayLength = this.currentBotMessage.displayText.length;
if (currentDisplayLength < fullContent.length) {
// 逐字显示
this.currentBotMessage.displayText = fullContent.slice(0, currentDisplayLength + 1);
this.scrollToBottom();
} else {
// 显示完成
this.currentBotMessage.isTyping = false;
this.currentBotMessage.isCompleted = true;
this.resetTypingState();
}
}, 30); // 调整打字速度(毫秒)
},
handleMessageComplete() {
// 确保所有内容都显示完毕
if (this.currentBotMessage) {
this.currentBotMessage.displayText = this.currentBotMessage.content;
this.currentBotMessage.isTyping = false;
this.currentBotMessage.isCompleted = true;
}
this.resetTypingState();
this.scrollToBottom();
},
handleError() {
if (this.currentBotMessage) {
this.currentBotMessage.content = '抱歉,发生了错误。请稍后重试。';
this.currentBotMessage.displayText = '抱歉,发生了错误。请稍后重试。';
this.currentBotMessage.isTyping = false;
this.currentBotMessage.isCompleted = true;
}
this.resetTypingState();
this.scrollToBottom();
},
resetTypingState() {
// 清理定时器
if (this.typingTimer) {
clearInterval(this.typingTimer);
this.typingTimer = null;
}
// 重置状态
this.isButtonDisabled = false;
this.isTyping = false;
// 清理EventSource
this.cleanupEventSource();
},
cleanupEventSource() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
},
scrollToBottom() {
this.$nextTick(() => {
const div = this.$refs.box;
if (div) {
div.scrollTop = div.scrollHeight;
}
});
},
cancel() {
this.resetTypingState();
this.currentBotMessage = null;
this.$emit('cancel');
},
submit() {
this.$emit('submit');
},
setStartingPoint(e) {
this.x = e.clientX - this.node.offsetLeft;
this.y = e.clientY - this.node.offsetTop;
this.isCanMove = true;
},
modelMove(e) {
if (this.isCanMove) {
this.node.style.left = e.clientX - this.x + 'px';
this.node.style.top = e.clientY - this.y + 'px';
}
},
cancelMove() {
this.isCanMove = false;
},
},
beforeDestroy() {
// 组件销毁时清理资源
this.resetTypingState();
}
}
</script>
<style scoped>
.doubao-chat-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
justify-content: center;
align-items: center;
}
.doubao-chat-window {
width: 400px;
height: 600px;
background: #fff;
border-radius: 16px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.doubao-header {
height: 60px;
background: linear-gradient(135deg, #6e8efb, #a777e3);
color: white;
display: flex;
align-items: center;
padding: 0 20px;
cursor: move;
position: relative;
}
.doubao-logo {
width: 32px;
height: 32px;
background-color: white;
border-radius: 50%;
margin-right: 12px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
color: #6e8efb;
}
.doubao-title {
font-size: 16px;
font-weight: 600;
flex: 1;
}
.doubao-close {
font-size: 24px;
cursor: pointer;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.2s;
}
.doubao-close:hover {
background: rgba(255, 255, 255, 0.2);
}
.doubao-messages {
flex: 1;
padding: 20px;
overflow-y: auto;
background: #f5f7fb;
display: flex;
flex-direction: column;
}
.message {
max-width: 80%;
margin-bottom: 16px;
display: flex;
}
.user-message {
align-self: flex-end;
}
.bot-message {
align-self: flex-start;
}
.message-content {
padding: 12px 16px;
border-radius: 18px;
line-height: 1.4;
font-size: 14px;
position: relative;
}
.user-message .message-content {
background: #6e8efb;
color: white;
border-top-right-radius: 4px;
}
.bot-message .message-content {
background: white;
color: #333;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border-top-left-radius: 4px;
}
.typing-cursor {
animation: blink 1s infinite;
color: #6e8efb;
font-weight: bold;
}
@keyframes blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
.doubao-input-area {
padding: 16px;
border-top: 1px solid #e5e5e5;
display: flex;
align-items: center;
background: white;
}
.doubao-input {
flex: 1;
height: 40px;
border: 1px solid #e5e5e5;
border-radius: 20px;
padding: 0 16px;
font-size: 14px;
outline: none;
transition: border 0.2s;
}
.doubao-input:focus {
border-color: #6e8efb;
}
.doubao-send-btn {
width: 40px;
height: 40px;
border-radius: 50%;
background: #6e8efb;
color: white;
border: none;
margin-left: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
}
.doubao-send-btn:disabled {
background: #cccccc;
cursor: not-allowed;
}
.doubao-send-btn:not(:disabled):hover {
background: #5a7df4;
}
.typing-indicator {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
background: white;
border-radius: 18px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
align-self: flex-start;
margin-top: 8px;
}
.dot {
width: 8px;
height: 8px;
background: #a777e3;
border-radius: 50%;
margin: 0 4px;
animation: bounce 1.4s infinite ease-in-out;
}
.dot:nth-child(1) {
animation-delay: 0s;
}
.dot:nth-child(2) {
animation-delay: 0.2s;
}
.dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes bounce {
0%,
60%,
100% {
transform: translateY(0);
}
30% {
transform: translateY(-5px);
}
}
/* 滚动条样式 */
.doubao-messages::-webkit-scrollbar {
width: 6px;
}
.doubao-messages::-webkit-scrollbar-track {
background: #f1f1f1;
}
.doubao-messages::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.doubao-messages::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>
5. 系统工作流程
5.1 整体流程
- 用户输入:用户在前端输入自然语言查询
- SSE连接:前端建立与后端的SSE连接
- AI处理:后端将用户问题发送给AI模型
- 工具调用:AI模型决定调用MyBatis工具查询数据库
- 动态执行:
MyBatisToolCall动态执行Mapper方法 - 结果返回:查询结果返回给AI模型
- 格式化输出:AI模型将结果格式化为自然语言回答
- 流式响应:通过SSE将回答流式返回给前端
- 逐字显示:前端逐字显示AI回答
5.2 关键技术点
5.2.1 SSE流式响应
使用Spring Boot的SseEmitter实现服务器向客户端的实时数据推送:
private void sendSseResponse(SseEmitter emitter, String data) {
try {
int eventId = eventIdGenerator.incrementAndGet();
// 将换行符替换为 <br>,确保 SSE 传输安全且前端 v-html 能正确渲染
String safeData = data.replace("\n", "<br>");
emitter.send(SseEmitter.event()
.id(String.valueOf(eventId))
.name("message")
.data(safeData, MediaType.TEXT_PLAIN));
} catch (IOException e) {
logger.debug("向客户端发送SSE事件失败,连接可能已关闭: {}", e.getMessage());
completeEmitter(emitter);
}
}
5.2.2 动态方法调用
通过反射动态调用MyBatis Mapper方法:
// Get the mapper instance and invoke the method
Object mapper = sqlSession.getMapper(targetMapper);
Object result = targetMethod.invoke(mapper, methodArgs);
5.2.3 智能重试机制
当单条查询返回多条记录时,自动重试列表查询:
private static String retryWithListMethod(SqlSessionFactory sqlSessionFactory, String originalMethodNameWithMapper, JsonNode originalArgsNode) throws Exception {
String listMethodName = null;
// 更智能的方法名转换
if (originalMethodNameWithMapper.endsWith("selectView")) {
listMethodName = originalMethodNameWithMapper.replace("selectView", "selectListView");
} else if (originalMethodNameWithMapper.endsWith("selectVO")) {
listMethodName = originalMethodNameWithMapper.replace("selectVO", "selectListVO");
}
// ... 其他转换逻辑
}
6. 部署与测试
6.1 部署步骤
- 配置数据库:修改
application.yml中的数据库连接信息 - 配置AI密钥:在
application.yml中设置ai-key
6.2 测试用例
-
简单查询:
查询会员账号为"会员1"的会员信息 -
列表查询:
查询所有男性会员的信息 -
模糊查询:
查询姓名包含"张"的会员
7. 总结与展望
7.1 系统优势
- 自然语言交互:用户无需编写SQL,用自然语言即可查询数据库
- 智能方法选择:AI能够根据查询需求选择最合适的Mapper方法
- 自动错误处理:系统具有智能重试机制,提高查询成功率
- 实时响应:通过SSE实现流式响应,提升用户体验
7.2 扩展方向
- 多数据源支持:扩展支持多种数据库类型
- 权限控制:添加用户权限验证,确保数据安全
- 查询优化:添加查询缓存和性能优化
- 更多AI模型:支持多种AI模型,如GPT、Claude等
7.3 注意事项
- 安全性:确保AI密钥和数据库连接信息的安全
- 性能监控:监控系统性能,及时处理异常情况
- 日志管理:合理配置日志级别,便于问题排查
本教程详细介绍了从零开始搭建MCP系统的全过程,包括完整的代码实现和部署说明。通过这个系统,用户可以用自然语言轻松查询数据库,大大降低了数据查询的门槛。
实现效果



火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。
更多推荐
所有评论(0)