从零开始复现小智AI 05
在上一次复现中我成功实现session鉴权、请求过滤,静态资源映射。本次复现将完成webscoket链接建立,以及部分功能初始化。在与设备通信时采用WebSocket的原因是WebSocket支持全双工的长连接,并且不会自动释放链接,请求头短,是稳定链接等有的,尤其不会长链接是与Http区分的主要之处。本次代码地址荣先海/xiaozhi-esp32-ddd - Gitee.com
代码编写
想要实现WebSocket链接需要在app层书写相关配置,并且在trigger层实现自定义的WebSocket处理器
WebSocketConfig编写
@Configuration
@EnableWebSocket
@Slf4j
public class WebSocketConfig implements WebSocketConfigurer {
@Resource
private MyWebSocketHandler webSocketHandler;
// 定义为public static以便其他类可以访问
public static final String WS_PATH = "/ws/xiaozhi/v1/";
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(webSocketHandler, WS_PATH)
.setAllowedOrigins("*");
}
}
}
WebSocketConfig是是配置,类这里的关键点就在于把自定义的WebSocket处理器和相应的请求地址相对应
MyWebSocketHandler代码编写
package cn.yangfanqihang.xiaozhi.trigger.websocket.handler;
import cn.yangfanqihang.xiaozhi.domain.communication.model.entity.DeviceEntity;
import cn.yangfanqihang.xiaozhi.domain.communication.model.entity.WebSocketSessionEntity;
import cn.yangfanqihang.xiaozhi.domain.communication.service.chatSession.ICommunicationService;
import cn.yangfanqihang.xiaozhi.types.common.Constants;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.AbstractWebSocketHandler;
import java.io.IOException;
import java.net.URI;
import java.time.Instant;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
@Slf4j
public class MyWebSocketHandler extends AbstractWebSocketHandler {
@Resource
private ICommunicationService communicationService;
// 会话存储
private final ConcurrentHashMap<String, WebSocketSessionEntity> sessions = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
Map<String, String> headers = getHeadersFromSession(session);
String deviceIdAuth = headers.get("device-id");
log.info("设备:{}链接已建立",deviceIdAuth);
if (deviceIdAuth == null || deviceIdAuth.isEmpty()) {
log.error("设备ID为空");
try {
session.close(CloseStatus.BAD_DATA.withReason("设备ID为空"));
} catch (IOException e) {
log.error("关闭WebSocket连接失败", e);
}
return;
}
afterConnection(session,deviceIdAuth);
log.info("WebSocket连接建立成功 - SessionId: {}, DeviceId: {}", session.getId(), deviceIdAuth);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
WebSocketSessionEntity chatSession = sessions.get(session.getId());
if (chatSession == null || !chatSession.isOpen()) {
return;
}
DeviceEntity device = chatSession.getDevice();
if (device != null) {
Thread.startVirtualThread(() -> {
try {
communicationService.update(DeviceEntity.builder()
.deviceId(device.getDeviceId())
.state(Constants.DEVICE_STATE_OFFLINE)
.lastLogin(new Date().toString())
.build());
log.info("WebSocket连接关闭 - SessionId: {}, DeviceId: {}", session.getId(), device.getDeviceId());
} catch (Exception e) {
log.error("更新设备状态失败", e);
}
});
}
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload();
log.info("收到信息:{}",payload);
super.handleTextMessage(session, message);
}
public void afterConnection(WebSocketSession session, String deviceIdAuth) {
String deviceId = deviceIdAuth;
String sessionId = session.getId();
// 注册会话和设备
DeviceEntity device=registerSessionAndDevice(sessionId,deviceId, session);
log.info("开始查询设备信息 - DeviceId: {}", deviceId);
// 如果角色已绑定,则初始化其他内容
if (!ObjectUtils.isEmpty(device) && device.getRoleId() != null) {
//这里需要放在虚拟线程外
//TODO 初始化全局函数管理,让每个会话都绑定自己的函数
//TODO 建立会话上下文管理
//TODO 异步处理语音转文字,文字转语音预热处理,预热全局函数
}
}
public DeviceEntity registerSessionAndDevice(String sessionId,String deviceId, WebSocketSession session) {
WebSocketSessionEntity chatSession =new WebSocketSessionEntity();
chatSession.setSessionId(sessionId);
chatSession.setLastActivityTime(Instant.now());
sessions.put(sessionId, chatSession);
log.info("会话已注册 - SessionId: {} SessionType: {}", sessionId, chatSession.getClass().getSimpleName());
DeviceEntity device = communicationService.queryDeviceById(deviceId);
chatSession.setDevice(device);
log.info("设备配置已注册 - SessionId: {}, DeviceId: {}", sessionId, deviceId);
return device;
}
public void updateLastActivity(String sessionId) {
WebSocketSessionEntity session = sessions.get(sessionId);
if(session != null){
session.setLastActivityTime(Instant.now());
}
}
private Map<String, String> getHeadersFromSession(WebSocketSession session) {
// 尝试从请求头获取设备ID
String[] deviceKeys = {"device-id", "mac_address", "uuid", "Authorization"};
Map<String, String> headers = new HashMap<>();
for (String key : deviceKeys) {
String value = session.getHandshakeHeaders().getFirst(key);
if (value != null) {
headers.put(key, value);
}
}
// 尝试从URI参数中获取
URI uri = session.getUri();
if (uri != null) {
String query = uri.getQuery();
if (query != null) {
for (String key : deviceKeys) {
String paramPattern = key + "=";
int startIdx = query.indexOf(paramPattern);
if (startIdx >= 0) {
startIdx += paramPattern.length();
int endIdx = query.indexOf('&', startIdx);
headers.put(key, endIdx >= 0 ? query.substring(startIdx, endIdx) : query.substring(startIdx));
}
}
}
}
return headers;
}
}
MyWebSocketHandler与链接/ws/xiaozhi/v1/对应起来后,所有ws://127.0.0.1:8091/ws/xiaozhi/v1/开头的ws链接都会走自定义的处理器。这里面重写了的几个重要的方法,分别是afterConnectionEstablished,afterConnectionClosed,handleTextMessage,链接建立完成后会优先走afterConnectionEstablished,所有这里最适合对设备和会话进行初始化,例如根据diviceId查出设备信息初始化会话通用变量等等初始化操作。
复现05总结
本次复现内容不多,但是也是花了很长的时间,原因是之前我并没有详细了解我WebSocket的知识,同时还有更为重要的是从WebSocket开始就已经开始接触到小智的核心内容了,然而小智的源码我并没有掌握,我只是重构一点学一点,不断的去了解小智的代码。并且从这里开始有很多知识很难从网上查找了,如果不是小智这个项目我也不会知道WebSocket链接后需要进行这么多的初始化任务。
同时我发现源码中有很多的代码耦合度较高,我在后续的复现过程中可能需要对其进行深度的解耦。