[01] WebSocket
1. 案例:实时推送#
创建和部署 WebSocket 端点的过程如下:
- 创建一个端点类,实现端点的生命周期方法
- 将 ServerEndpointExporter 以 @Bean 的形式告知 Spring
1.1 @ServerEndpoint#
在一个普通的 Java 类上添加 @ServerEndpoint 注解,即指定该类作为 WebSocket 服务器端点处理客户端的连接请求。
@ServerEndpoint("/websocket")
指定该类为 WebSocket 服务器端点,客户端可以通过 /websocket
地址连接到该端点。
生命周期方法:
- onOpen 连接建立成功时触发的方法
- onClose 连接关闭时触发的方法
- onError 连接出错时触发的方法
- onMessage 收到客户端消息时触发的方法
@Slf4j
@Component
@ServerEndpoint("/websocket")
public class WebSocket {
/**
* 与某个客户端的连接会话,需要通过它来给客户端发送数据
*/
private Session session;
/**
* 客户端建立websocket连接,获取URL参数
*/
private String param;
@OnOpen
public void onOpen(@PathParam("param") String param, Session session) {
long maxIdleTimeout = session.getMaxIdleTimeout();
int maxBinaryMessageBufferSize = session.getMaxBinaryMessageBufferSize();
int maxTextMessageBufferSize = session.getMaxTextMessageBufferSize();
JSONObject jsonObject = new JSONObject();
jsonObject.put("maxIdleTimeout", maxIdleTimeout);
jsonObject.put("maxBinaryMessageBufferSize", maxBinaryMessageBufferSize);
jsonObject.put("maxTextMessageBufferSize", maxTextMessageBufferSize);
logger.info("sessionId:" + session.getId() + ",session:" + jsonObject.toJSONString());
session.setMaxIdleTimeout(1000 * 60 * 60 * 24);
this.session = session;
this.param = param;
if (session.isOpen()) {
// -------------------------------------------------------
MessagePushService.addWebSocket(session.getId(), this);
// -------------------------------------------------------
addOnlineUsers();
JSONObject sessionInfo = new JSONObject();
sessionInfo.put("type", "session");
sessionInfo.put("sessionid", session.getId());
try {
session.getBasicRemote().sendText(sessionInfo.toJSONString());
} catch (IOException e) {
logger.error("onOpen WebSocket Exception:", e.toString());
}
logger.info("[ConnCreate] sessionId is:{}, current users count is:{}", session.getId(), onlineUsers);
}
}
@OnClose
public void onClose(Session session, CloseReason reason) {
MessagePushService.removeWebSocket(session.getId());
MessagePushService.removeSubscriber(session.getId());
removeOnlineUsers();
logger.warn("[ConnClose] sessionId: {}, closeReason: {}, code: {}" + session.getId(), reason.getReasonPhrase(), reason.getCloseCode());
}
@OnMessage
public void onMessage(String message, Session session) {
logger.info("WebSocket onMessage({})", message);
Map<String, String> map = JSON.parseObject(message, Map.class);
map.put("sessionId", session.getId());
try {
RemoteEndpoint.Async async = session.getAsyncRemote();
InetSocketAddress addr = (InetSocketAddress) WebsocketUtil.getFieldInstance(async, "base#socketWrapper#socket#sc#remoteAddress");
String hostName = addr.getHostName();
map.put("clientIp", hostName);
} catch (Exception e) {
logger.error("throw Ex: ", e);
}
MessagePushService.parseSubscribe(map);
}
@OnError
public void onError(Session session, Throwable error) {
MessagePushService.removeWebSocket(session.getId());
MessagePushService.removeSubscriber(session.getId());
removeOnlineUsers();
logger.info("[ConnError] sessionId={}", session.getId());
}
public void sendMessage(String message) throws IOException {
if (session != null) {
session.getBasicRemote().sendText(message);
}
}
public void asyncSendMessage(String message) throws IOException {
if (session != null) {
session.getAsyncRemote().sendText(message);
}
}
}
WebSocket 配置类:
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
@Bean
ServletServerContainerFactoryBean createWebSocketContainer() {
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
container.setMaxTextMessageBufferSize(512000);
container.setMaxBinaryMessageBufferSize(512000);
container.setMaxSessionIdleTimeout(15 * 6000L);
return container;
}
}
1.2 MessagePushService#
自定义辅助类 MessagePushService 负责推送服务,向客户端推送订阅的消息。
@Slf4j
public final class MessagePushService {
protected static final ConcurrentHashMap<String, WebSocket> webSocketMap = new ConcurrentHashMap<>();
protected static final ConcurrentHashMap<String, Map<String, Subscriber>> subscriberMap = new ConcurrentHashMap<>();
private MessagePushService() {}
public static void addWebSocket(String sessionId, WebSocket webSocket) {
webSocketMap.put(sessionId, webSocket);
}
public static void removeWebSocket(String sessionId) {
webSocketMap.remove(sessionId);
}
public static List<String> getAllSessionId() {
return new ArrayList<>(webSocketMap.keySet());
}
public static Map<String, Map<String, Subscriber>> getSubscriberMap() {
return subscriberMap;
}
public static boolean isSubscriberPrecinct(String precinctId, String subscriberType, String sessionId) {
Map<String, Subscriber> map = subscriberMap.get(sessionId);
if (map == null) {
map = new ConcurrentHashMap<>();
}
Subscriber subscriber = map.get(subscriberType);
if (subscriber == null) {
return false;
}
List<String> precinctIds = subscriber.getPrecinctIds();
if (CollectionUtils.isEmpty(precinctIds)) {
log.info("isSubscriberPrecinct: precinctId is {}, precinctIds is empty", precinctId);
return false;
}
return precinctIds.contains("all") || precinctIds.contains(precinctId);
}
public static boolean isSubscriberDeviceId(String deviceId, String subscriberType, String sessionId) {
if (subscriberMap.containsKey(sessionId)) {
Map<String, Subscriber> map = subscriberMap.get(sessionId);
Subscriber subscriber = map.get(subscriberType);
if (Objects.isNull(subscriber)) {
return false;
}
return subscriber.isSubcriberDeviceId(deviceId);
} else {
return false;
}
}
public static void removeSubscriber(String sessionId) {
subscriberMap.remove(sessionId);
}
public static void removePart(String sessionId, String subscribeType, String deviceIds, String precinctIds) {
Map<String, Subscriber> stringSubscriberMap = subscriberMap.get(sessionId);
if (CollectionUtils.isEmpty(stringSubscriberMap)) {
log.info("sessionId:{} un subscriber", sessionId);
return;
}
// 取消某个订阅类型
if (!StringUtils.isEmpty(subscribeType)) {
String[] subscribeTypes = subscribeType.split(",");
for (String str : subscribeTypes) {
stringSubscriberMap.remove(str);
}
}
// 取消设备订阅
if (!StringUtils.isEmpty(deviceIds)) {
Collection<Subscriber> values = stringSubscriberMap.values();
log.info("subscribers: {}", JSON.toJSONString(values));
for (Subscriber subscriber : values) {
List<String> deviceIdList = subscriber.getDeviceIds();
if (!CollectionUtils.isEmpty(deviceIdList)) {
String[] deviceIdArray = deviceIds.split(",");
deviceIdList.removeAll(Arrays.asList(deviceIdArray));
}
}
}
// 取消区域订阅
if (!StringUtils.isEmpty(precinctIds)) {
Collection<Subscriber> values = stringSubscriberMap.values();
for (Subscriber subscriber : values) {
List<String> precinctIdList = subscriber.getPrecinctIds();
String[] precinctIdArray = precinctIds.split(",");
precinctIdList.removeAll(Arrays.asList(precinctIdArray));
}
}
}
public static void saveSubscriber(SubscribeParam subscribeParam) {
String sessionId = subscribeParam.getSessionId();
String subscribeType = subscribeParam.getSubscribeType();
String precinctIds = subscribeParam.getPrecinctIds();
String deviceIds = subscribeParam.getDeviceIds();
String deviceKinds = subscribeParam.getDeviceKinds();
String clientIp = subscribeParam.getClientIp();
String userId = subscribeParam.getUserId();
if (subscriberMap.containsKey(sessionId)) {
Map<String, Subscriber> map = subscriberMap.get(sessionId);
String[] subscribeTypes = subscribeType.split(",");
for (String str : subscribeTypes) {
Subscriber subscriber = map.get(str);
if (Objects.nonNull(subscriber)) {
subscriber.subscriber(str, precinctIds, deviceIds, deviceKinds);
subscriber.setUserId(userId);
} else {
subscriber = new Subscriber(sessionId, clientIp, str, precinctIds, deviceIds, deviceKinds);
subscriber.setUserId(userId);
map.put(str, subscriber);
}
}
} else {
Map<String, Subscriber> map = new ConcurrentHashMap<>();
String[] subscribeTypes = subscribeType.split(",");
for (String str : subscribeTypes) {
Subscriber subscriber = new Subscriber(sessionId, clientIp, str, precinctIds, deviceIds, deviceKinds);
subscriber.setUserId(userId);
map.put(str, subscriber);
}
subscriberMap.put(sessionId, map);
}
}
public static void parseSubscribe(SubscribeParam map) {
String isSubscribe = map.getIsSubscribe();
if (StringUtils.isEmpty(isSubscribe)) {
throw new BusinessException("sessionId and isSubscribe can not be null");
}
String sessionId = map.getSessionId();
String subscribeType = map.getSubscribeType();
String deviceIds = map.getDeviceIds();
String precinctIds = map.getPrecinctIds();
try {
// 取消订阅
if ("0".equals(isSubscribe)) {
removePart(sessionId, subscribeType, deviceIds, precinctIds);
log.info("Unsubscribe pushService, param = {}", JSON.toJSONString(map));
} else if ("1".equals(isSubscribe)) {
removePart(sessionId, subscribeType, null, null);
saveSubscriber(map);
log.info("Subscribe pushService, param = {}", JSON.toJSONString(map));
}
} catch (Exception e) {
log.error("parseSubscribe: ", e);
throw new BusinessException(e.getMessage());
}
}
public static void asyncSendMessage(String sessionId, String message) throws IOException {
log.info("Async SendMsg on SessionId: {}, Msg = {}", sessionId, message);
webSocketMap.get(sessionId).asyncSendMessage(message);
}
}
parseSubscribe()
对应一个接口:
/**
* 协议消息订阅
*/
@PostMapping(value = "/subscribe")
public Result<String> subscribe(@RequestBody SubscribeParam subscribeParam) {
String sessionId = subscribeParam.getSessionId();
String isSubscribe = subscribeParam.getIsSubscribe();
if (null == sessionId || null == isSubscribe) {
throw new BusinessException("sessionId and isSubscribe can not be null");
}
MessagePushService.parseSubscribe(subscribeParam);
return Result.ok();
}
1.3 KafkaConsumer#Push#
消费 Kafka,将过车记录通过 websocket 推送到对应的订阅用户。
@Data
@Slf4j
@Component
public class VehicleEventConsumer {
private static final String VEHICLE_EVENT_TYPE = "vehicleEvent";
@Resource
private CommonDao commonDao;
/**
* 过车记录消费
*/
@KafkaListener(topics = "${spring.kafka.vehicle.bap.vehicle.event}", containerFactory = "kafkaListenerContainerFactory")
public void vehicleTsEventListener(List<ConsumerRecord<?, ?>> records, Acknowledgment ack) {
if (!CollectionUtils.isEmpty(records)) {
try {
for (ConsumerRecord<?, ?> record : records) {
Object value = record.value();
String vehicleEventMsg = value.toString();
log.debug("VehicleEventMsg: {}", vehicleEventMsg);
if (StrUtil.isNotEmpty(vehicleEventMsg)) {
this.webSocketPush(vehicleEventMsg);
}
}
} catch (Exception e) {
log.error("VehicleEvent WebSocket Push Failed, throw Ex: ", e);
}
}
// 手动确认
ack.acknowledge();
}
/**
* 车辆事件推送
*/
private void webSocketPush(String record) {
JSONObject dataJson = JSON.parseObject(record);
String eventType = dataJson.getString("type");
if (!VEHICLE_EVENT_TYPE.equalsIgnoreCase(eventType)) {
log.info("This record isn't VehicleEvent, ignore it!");
return;
}
JSONArray dataList = dataJson.getJSONArray("data");
if (CollectionUtils.isEmpty(dataList)) {
log.info("Invalidate vehicleEvent: {}", record);
return;
}
List<String> sessionIdList = MessagePushService.getAllSessionId();
if (CollectionUtils.isEmpty(sessionIdList)) {
return;
}
List<VehicleEventDto> vehicleEventDtoList = dataList.toJavaList(VehicleEventDto.class);
for (VehicleEventDto vehicleEventDto : vehicleEventDtoList) {
this.pushData(vehicleEventDto, sessionIdList);
}
}
/**
* 推送WebSocket
*/
private void pushData(VehicleEventDto vehicleEventDto, List<String> sessionIdList) {
// 获取道闸ID
String deviceId = vehicleEventDto.getDeviceId();
if (StringUtils.isEmpty(deviceId)) {
log.info("VehicleEvent push device id is empty");
return;
}
DeviceDto deviceDto = commonDao.getDeviceInfo(deviceId);
String villageId;
if (Objects.nonNull(deviceDto)) {
villageId = deviceDto.getPrecinctId();
} else {
log.info("VehicleEvent => DeviceId={} Not Exist!", deviceId);
return;
}
JSONObject pushObj = new JSONObject();
pushObj.put("data", vehicleEventDto);
pushObj.put("eventType", EventConstants.CAR_EVENT_COMMON);
for (String sessionId : sessionIdList) {
boolean isSubscriber =
MessagePushService.isSubscriberDeviceId(deviceId, EventConstants.CAR_EVENT_COMMON, sessionId)
|| MessagePushService.isSubscriberPrecinct(villageId, EventConstants.CAR_EVENT_COMMON, sessionId);
log.info("[VehicleEvent] => sessionId:{}, isSubscriber:{}, deviceId:{}, precinctId:{}", sessionId, isSubscriber, deviceId, villageId);
if (isSubscriber) {
try {
MessagePushService.asyncSendMessage(sessionId, JSON.toJSONString(pushObj));
} catch (IOException e) {
log.error("Send VehicleEvent to Client[SessionId={}] Failed, throw Ex: ", sessionId, e);
}
}
}
}
}
2. 集成 @ServerEndpoint#
针对上面的案例,有两个疑问:
- 为什么要用
@Component
注解? - 配置
ServerEndpointExporter
有什么用?
框选住的代码很直观了。
要求被暴露的 WebSocket 服务类必须是个 bean,所以要有 @Component 注解。ServerEndpointExporter 的作用是将所有 @ServerEndpoint 的 bean 注册进容器里。
那这就又引出一个问题,加了 @Component,WebSocket 的成员变量是线程安全的吗(案例中定义了两个成员变量)?
3. 成员变量线程安全#
前端测试代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ws client</title>
</head>
<body></body>
<script>
let ws = new WebSocket("ws://localhost:8080/ws")
ws.onopen = function () {
ws.send("hello")
}
ws.onmessage = function (message) {
console.log(message)
}
</script>
</html>
后端测试代码:
以上验证了 @ServerEndpoint 类的成员变量是线程安全的。
但是存在矛盾:
上面用例 @Component 和 @ServerEndpoint 共同作用于一个类上,但 Spring 容器管理 @Component 默认是单例的。
解释上文的矛盾:
@Component 用于 Bean 相关的 API 读取;@ServerEndpoint 使用的是 Servlet 的模式,与 SpringMVC 不同,它是在 Filter 里面做了 new 操作。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?