本期内容
使用 Websocket Stomp 协议配合@MessageMapping和@Payload 开放消息接口,和接收 JSON 请求体。
实现私有订阅,服务器将请求的结果响应给对应的用户,而不是广播给所有用户。
使用 Proxy 将请求转发给 OpenAI。
对代码进行逻辑分层,让代码更清晰。
代码实现
1. WebsocketController 接收消息
与 MVC 中的 Controller 不同,这边不能使用@RestController需要用@Controller。@AllArgsConstructor
是 lombok 提供的一个为类中的属性生成构成器的注解。这样我们可以方便的使用 Spring 推荐的构造器依赖注入。
@MessageMapping类似于@RequestMapping用来标识消息路由。它不仅可以加在方法上,也可以加载类上。加在类上那就表名类中的所有消息路由都会拼接上这个路径。
在被@MessageMapping标识的方法中(如下的 chat 方法),可以使用@Payload解析 JSON 格式的消息体和@RequestBody一样。同时也可以配合@Valid或者@Validated做参数校验。
chat()方法的第二个参数接收了Principle。它代表着在 websocket 的 handshake 阶段获取到的用户信息。可以参考io.qifan.chatgpt.assistant.infrastructure.websocket.UserHandshakeHandler#determineUser
这个方法。
2. 发送消息
发送消息的逻辑包含下面四个步骤。
1. GPT 配置校验
2. 创建 OpenAIService 用于调用 OpenAI 接口。
3. 构造请求参数,将用户发送的内容以及用户的 GPT 配置填充到请求中。
4. 发送请求并将响应的结果通过私有订阅地址推送给响应的用户。
2.1 GPT 配置校验
在正式调用 OpenAI 的 GPT 接口之前,需要做一些基础配置的校验。只有这些基础数据校验通过后才能保障后面的代码正常运行。如果不存在 API Key 则无法调用 OpenAI 的 GPT 接口。
2.2 创建 OpenAIService
配置 proxy,通过 proxy 转发给 OpenAI。先定义 Property 配置类,spring boot 会自动读取 application.yml 中的配置信息到配置类中。在代码中注入该配置类就可以获取到 yml 中的配置信息了。
引入封装好的OpenAI API。
下面开始创建 OpenAIService,用于发送请求。在创建 OpenAIService 时我们配置了它底层的代理,API Key 以及 Jackson 序列化和反序列化。
2.3 构造 ChatGPT 请求
构造的 ChatGPT 请求参数需要包含用户的历史发送消息和 GPT 的历史回复消息,这样它才能记住你们之前的对话内容。所以可以看见我开始的时候根据聊天会话查询该会话内的聊天记录,然后将最新的消息插入到历史消息的尾部。还需要填写要使用的 GPT 模型,默认是 3.5。还有随机性,话题新鲜度,最大回复数。最后我们选择了请求方式是 stream,这样可以一个个字的得到 ChatGPT 的响应,而不是长时间的等待最后得到一个结果。
还需要在 ChatMessageMapper 中添加我们的 ChatMessage 实体类和第三方包中的 ChatMessage 映射,这样我们可以方便的构造请求参数。
2.4 发送请求和推送消息
在 io.qifan.chatgpt.assistant.gpt.session.ChatSession.Statistic 新增 plusChat 和 plusToken 方法。方便统计用户调用 GPT 接口时的消耗情况。
先获取已有的统计数量,在上面累加本次用户发送消息的长度。新建一个 ChatGPT 回答消息对象`responseMessage`用于记录回答的消息。由于本次的请求是 stream 类型,所以每次响应是一个 Token(一个单词或者一个中文字)的,这边就需要阻塞一个按顺序调用`convertAndSendToUser`推送给前端。回答完毕后将用户发送的消息和 GPT 回答的消息都插入到数据库,并且更新会话消耗 Token 的统计数量。
3. 组合各个步骤发送消息
依次按照配置校验,创建 OpenAIService,ChatGPT 请求参数,发送请求的顺序调用实现消息发送逻辑。
代码测试
1. 创建会话
调用创建聊天会话接口,得到会话 id。
复制你调用后得到的 result。
2. 发送消息
安装 stompjs 和 websocket。stompjs 是在 websocket 建立的连接上用特定的协议去通信。也就是说单单安装 stompjs 无法使用,需要有 websocket 的连接才能使用。
HomeView.vue中编写如下的测试代码,先是向后端发起 websocket 连接,如果握手成功则订阅/user/queue/chatMessage/receive。
要注意,后端推送的订阅地址是 /queue/chatMessage/receive,而用户的订阅地址是/user/queue/chatMessage/receive。但是为什么依然可以推送给对应的用户呢?
可以这么理解,当用户发送订阅消息/user/queue/chatMessage/receive时,其中的/user被替换成了用户 id 如:/queue/chatMessage/receive-1234。然后在服务端推送消息时,使用的是convertAndSendToUser推送给这个订阅地址/queue/chatMessage/receive,实际上会推送给/queue/chatMessage/receive-1234。这样推送和订阅的最终地址都达到了一致,并且这个地址是用户私有的。
那为什么/user可以被替换成用户 id 呢?因为我们之前在 io.qifan.chatgpt.assistant.infrastructure.websocket.WebSocketConfig#configureMessageBroker里面配置了setUserDestinationPrefix("/user")。这行配置就是告诉 SpringWebSocket 遇到 /user开头的订阅地址要替换成用户 id,变成改用户的私有订阅地址。