写在前面
作为一名技术人,我深知学习新框架时的迷茫与焦虑。尤其是当你已经熟悉了一套技术栈,想要转向另一个生态时,那种"无从下手"的感觉尤为强烈。
最近,我开始系统学习Spring Boot生态,并尝试将Spring AI集成到实际项目中。经过一段时间的摸索,我整理出了一份7天学习路线图,目标是让有编程基础的开发者能快速上手Spring Boot + Spring AI,独立完成一个完整的AI对话应用。
这份计划不是零基础教程,而是为有一定后端开发经验(不限于Java)的朋友量身定制的"快速转型指南"。如果你对Python、Node.js或其他后端语言已有了解,这份计划将帮助你在最短时间内建立Spring Boot的核心认知,并跟上AI应用开发的浪潮。
下面,我将分享这7天的学习中的第五天内容,SSE 流式输出 + 打字机效果。
一、开启异步支持
Spring AI 的流式调用需要在异步线程中执行,否则会阻塞主线程。
修改 ChatApiApplication.java:
kotlin
package com.example.chatapi;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@MapperScan("com.example.chatapi.mapper")
@EnableAsync // 开启异步支持
public class ChatApiApplication {
public static void main(String[] args) {
SpringApplication.run(ChatApiApplication.class, args);
}
}
二、配置异步线程池
创建 config/AsyncConfig.java:
java
package com.example.chatapi.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5); // 核心线程数
executor.setMaxPoolSize(10); // 最大线程数
executor.setQueueCapacity(100); // 队列容量
executor.setThreadNamePrefix("sse-async-");
executor.initialize();
return executor;
}
}
三、创建流式对话 DTO
创建 dto/StreamChatRequest.java:
kotlin
package com.example.chatapi.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class StreamChatRequest {
@Min(value = 1, message = "会话ID无效")
private Long sessionId;
@NotBlank(message = "消息内容不能为空")
private String message;
}
四、创建流式对话 Service
创建 service/StreamChatService.java:
scss
package com.example.chatapi.service;
import com.example.chatapi.entity.Message;
import com.example.chatapi.mapper.MessageMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.concurrent.atomic.AtomicReference;
@Slf4j
@Service
@RequiredArgsConstructor
public class StreamChatService {
private final MessageMapper messageMapper;
private final ChatClient chatClient;
private static final long SSE_TIMEOUT = 120000L; // 2分钟超时
public SseEmitter streamChat(Long sessionId, String userMessage) {
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT);
// 超时处理
emitter.onTimeout(() -> {
log.warn("SSE 超时: sessionId={}", sessionId);
emitter.complete();
});
// 错误处理
emitter.onError((ex) -> {
log.error("SSE 错误: sessionId={}", sessionId, ex);
emitter.completeWithError(ex);
});
// 异步执行流式调用
doStreamChat(sessionId, userMessage, emitter);
return emitter;
}
@Async
public void doStreamChat(Long sessionId, String userMessage, SseEmitter emitter) {
try {
// 1. 先保存用户消息
Message userMsg = new Message();
userMsg.setSessionId(sessionId);
userMsg.setRole("user");
userMsg.setContent(userMessage);
userMsg.setCreatedAt(LocalDateTime.now());
messageMapper.insert(userMsg);
log.info("用户消息已保存: id={}", userMsg.getId());
// 2. 发送开始标记
emitter.send(SseEmitter.event().name("start").data("开始生成回复..."));
// 3. 流式调用 AI,逐块推送
StringBuilder fullResponse = new StringBuilder();
chatClient.prompt(userMessage).stream().content()
.doOnNext(chunk -> {
fullResponse.append(chunk);
try {
emitter.send(SseEmitter.event()
.name("chunk")
.data(chunk));
log.debug("发送块: {}", chunk);
} catch (IOException e) {
log.error("发送块失败", e);
throw new RuntimeException(e);
}
})
.doOnError(error -> {
log.error("AI 流式调用失败", error);
try {
emitter.send(SseEmitter.event()
.name("error")
.data("AI服务出错: " + error.getMessage()));
emitter.completeWithError(error);
} catch (IOException e) {
log.error("发送错误信息失败", e);
}
})
.doOnComplete(() -> {
// 4. 保存 AI 完整回复到数据库
String aiResponse = fullResponse.toString();
if (!aiResponse.isEmpty()) {
Message aiMsg = new Message();
aiMsg.setSessionId(sessionId);
aiMsg.setRole("assistant");
aiMsg.setContent(aiResponse);
aiMsg.setCreatedAt(LocalDateTime.now());
messageMapper.insert(aiMsg);
log.info("AI回复已保存: id={}, 长度={}", aiMsg.getId(), aiResponse.length());
}
// 5. 发送结束标记
try {
emitter.send(SseEmitter.event().name("done").data(""));
emitter.complete();
log.info("SSE 流式响应完成: sessionId={}", sessionId);
} catch (IOException e) {
log.error("发送完成标记失败", e);
}
})
.subscribe();
} catch (Exception e) {
log.error("流式对话处理失败", e);
try {
emitter.send(SseEmitter.event()
.name("error")
.data("处理失败: " + e.getMessage()));
emitter.completeWithError(e);
} catch (IOException ex) {
log.error("发送错误信息失败", ex);
}
}
}
}
代码解读:
- SseEmitter:Spring 提供的 SSE 推送器
- @Async:在独立线程中执行,不阻塞主线程
- .stream().content():返回 Flux,逐块推送
- doOnNext():每收到一块就推送给前端
- doOnComplete():所有块收完后保存到数据库并结束
五、创建流式 Controller
创建 controller/StreamController.java:
less
package com.example.chatapi.controller;
import com.example.chatapi.dto.StreamChatRequest;
import com.example.chatapi.service.SessionService;
import com.example.chatapi.service.StreamChatService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@Slf4j
@RestController
@RequestMapping("/api/stream")
@RequiredArgsConstructor
public class StreamController {
private final StreamChatService streamChatService;
private final SessionService sessionService;
/**
* GET 方式流式对话
* GET /api/stream/chat?sessionId=1&message=你好
*/
@GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamChat(
@RequestParam Long sessionId,
@RequestParam String message) {
log.info("接收流式对话请求(GET): sessionId={}, message={}", sessionId, message);
if (!sessionService.exists(sessionId)) {
SseEmitter emitter = new SseEmitter();
try {
emitter.send(SseEmitter.event()
.name("error")
.data("会话不存在,请先创建会话"));
emitter.complete();
} catch (Exception e) {
log.error("发送错误信息失败", e);
}
return emitter;
}
return streamChatService.streamChat(sessionId, message);
}
/**
* POST 方式流式对话(推荐)
* POST /api/stream/chat
* Body: {"sessionId": 1, "message": "你好"}
*/
@PostMapping(value = "/chat",
consumes = "application/json",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamChatPost(@Valid @RequestBody StreamChatRequest request) {
log.info("接收流式对话请求(POST): sessionId={}, message={}",
request.getSessionId(), request.getMessage());
if (!sessionService.exists(request.getSessionId())) {
SseEmitter emitter = new SseEmitter();
try {
emitter.send(SseEmitter.event()
.name("error")
.data("会话不存在,请先创建会话"));
emitter.complete();
} catch (Exception e) {
log.error("发送错误信息失败", e);
}
return emitter;
}
return streamChatService.streamChat(request.getSessionId(), request.getMessage());
}
}
六、配置全局跨域
创建 config/WebConfig.java:
kotlin
package com.example.chatapi.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000", "http://127.0.0.1:3000")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
七、测试流式接口
7.1 使用 curl 命令测试
arduino
curl -N -H "Accept: text/event-stream" \
"http://localhost:8080/api/stream/chat?sessionId=1&message=你好"
预期输出格式:
makefile
event:start
data:开始生成回复...
event:chunk
data:你
event:chunk
data:好
event:chunk
data:!
学习建议: 观察数据是逐块到达还是一次性到达。理解 SSE 和普通 HTTP 请求的本质区别。
欢迎关注我的公众号(onething365),最新的技术与你分享