SpringBoot 引入 WebSocket

84 阅读7分钟

1. 添加依赖

pom.xml

<!-- WebSocket支持 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

2. WebSocket 配置类

package com.yu.cloudpicturebackend.config;

import com.yu.cloudpicturebackend.websocket.PictureReviewHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;

import javax.annotation.Resource;
import java.util.Map;

@Configuration
@EnableWebSocket
@Slf4j
public class WebSocketConfig implements WebSocketConfigurer {

    @Resource
    private PictureReviewHandler pictureReviewHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        log.info("🔄 注册WebSocket处理器,路径: /ws/{userId}");
        registry.addHandler(pictureReviewHandler, "/ws/{userId}")
                .addInterceptors(new HttpSessionHandshakeInterceptor() {
                    @Override
                    public boolean beforeHandshake(ServerHttpRequest request,
                                                   ServerHttpResponse response,
                                                   WebSocketHandler wsHandler,
                                                   Map<String, Object> attributes) throws Exception {
                        log.info("🤝 WebSocket握手请求: {} {}", request.getMethod(), request.getURI());
                        String path = request.getURI().getPath();
                        String userId = path.substring(path.lastIndexOf("/") + 1);
                        attributes.put("userId", userId);
                        log.info("提取用户ID: {}", userId);
                        return super.beforeHandshake(request, response, wsHandler, attributes);
                    }
                })
                .setAllowedOrigins("*");

        log.info("✅ WebSocket处理器注册完成");

    }
}

3. Websocket 服务端实现

3.1. 消息实体类

import lombok.Data;

@Data
public class WebSocketMessage {
    private String type;       // 消息类型:heartbeat/unicast/broadcast
    private String from;      // 发送者ID
    private String to;        // 接收者ID(单播时使用)
    private String content;    // 消息内容
    private Long timestamp;   // 时间戳
}

3.2. WebSocket 处理器实现 - 核心

package com.yu.cloudpicturebackend.websocket;

import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.*;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import java.io.IOException;
import java.util.Arrays;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

@Slf4j
@Component
public class PictureReviewHandler extends TextWebSocketHandler {

    // 在类开头添加
    public PictureReviewHandler() {
        log.info("✅ PictureReviewHandler 已初始化");
    }

    // 在线连接数
    private static final AtomicInteger onlineCount = new AtomicInteger(0);

    // 存放每个客户端对应的会话
    private static final ConcurrentHashMap<String, WebSocketSession> sessionMap = new ConcurrentHashMap<>();
    private static final ConcurrentHashMap<WebSocketSession, String> userIdMap = new ConcurrentHashMap<>();
    private static final ConcurrentHashMap<WebSocketSession, Long> heartbeatMap = new ConcurrentHashMap<>();

    /**
     * 连接建立后调用的方法
     */
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        log.info("🔄 开始建立WebSocket连接,URI: {}", session.getUri());

        try {
            String userId = extractUserIdFromUri(session.getUri());
            log.info("尝试建立连接,用户ID: {}, 会话ID: {}", userId, session.getId());

            if (userId == null || userId.trim().isEmpty()) {
                log.error("用户ID为空,关闭连接");
                session.close(CloseStatus.BAD_DATA.withReason("用户ID不能为空"));
                return;
            }

            // 如果用户已存在,关闭旧连接
            if (sessionMap.containsKey(userId)) {
                WebSocketSession oldSession = sessionMap.get(userId);
                if (oldSession != null && oldSession.isOpen()) {
                    log.info("关闭用户{}的旧连接", userId);
                    oldSession.close(CloseStatus.NORMAL.withReason("新连接建立"));
                }
                sessionMap.remove(userId);
                userIdMap.remove(oldSession);
                heartbeatMap.remove(oldSession);
            }

            sessionMap.put(userId, session);
            userIdMap.put(session, userId);
            heartbeatMap.put(session, System.currentTimeMillis());

            addOnlineCount();
            log.info("✅ 用户连接成功: {},当前在线人数: {}", userId, getOnlineCount());

            // 发送连接成功消息
            String welcomeMsg = "{"type":"system","message":"连接成功"}";
            session.sendMessage(new TextMessage(welcomeMsg));

            // 启动心跳检测
            startHeartbeatCheckIfNeeded();

        } catch (Exception e) {
            log.error("建立WebSocket连接时发生异常", e);
            if (session.isOpen()) {
                session.close(CloseStatus.SERVER_ERROR.withReason("服务器异常"));
            }
        }
    }

    /**
     * 处理文本消息
     */
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String userId = userIdMap.get(session);
        if (userId == null) return;

        log.info("收到用户{}的消息:{}", userId, message.getPayload());

        WebSocketMessage msg = JSONUtil.parse(message.getPayload()).toBean(WebSocketMessage.class);

        // 心跳检测
        if ("heartbeat".equals(msg.getType())) {
            heartbeatMap.put(session, System.currentTimeMillis());
            return;
        }

        // 处理业务消息
        switch (msg.getType()) {
            case "unicast":
                sendToUser(msg.getTo(), message.getPayload());
                break;
            case "broadcast":
                broadcast(message.getPayload());
                break;
            default:
                log.warn("未知的消息类型:{}", msg.getType());
        }
    }

    /**
     * 处理传输错误
     */
    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        String userId = userIdMap.get(session);
        log.error("用户{}的连接发生错误:{}", userId, exception.getMessage());
        exception.printStackTrace();

        // 移除会话
        removeSession(session);
    }

    /**
     * 连接关闭后调用的方法
     */
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        String userId = userIdMap.get(session);
        removeSession(session);
        log.info("用户退出:{},当前在线人数:{}", userId, getOnlineCount());
    }

    /**
     * 是否支持分片消息
     */
    @Override
    public boolean supportsPartialMessages() {
        return false;
    }

    /**
     * 从URI中提取用户ID
     */
    private String extractUserIdFromUri(java.net.URI uri) {
        if (uri == null) {
            log.error("URI为空");
            return null;
        }

        String path = uri.getPath();
        log.info("解析WebSocket路径: {}", path);

        String[] pathSegments = path.split("/");
        log.info("路径分段: {}", Arrays.toString(pathSegments));

        // 路径格式: /ws/{userId}
        if (pathSegments.length >= 3) {
            String userId = pathSegments[2];
            log.info("提取到的用户ID: {}", userId);
            return userId;
        } else {
            log.error("路径格式错误,期望格式: /ws/{{userId}},实际路径: {}", path);
            return null;
        }
    }

    /**
     * 移除会话
     */
    private static void removeSession(WebSocketSession session) {
        String userId = userIdMap.get(session);
        if (userId != null) {
            sessionMap.remove(userId);
            userIdMap.remove(session);
            heartbeatMap.remove(session);
            subOnlineCount();
        }
    }

    /**
     * 单播消息
     */
    public static void sendToUser(String toUserId, String message) {
        if (sessionMap.containsKey(toUserId)) {
            WebSocketSession session = sessionMap.get(toUserId);
            if (session.isOpen()) {
                try {
                    session.sendMessage(new TextMessage(message));
                } catch (IOException e) {
                    log.error("发送消息给用户{}失败:{}", toUserId, e.getMessage());
                }
            }
        } else {
            log.warn("用户{}不在线,消息发送失败", toUserId);
        }
    }

    /**
     * 广播消息
     */
    public static void broadcast(String message) {
        sessionMap.forEach((userId, session) -> {
            if (session.isOpen()) {
                try {
                    session.sendMessage(new TextMessage(message));
                } catch (IOException e) {
                    log.error("广播消息给用户{}失败:{}", userId, e.getMessage());
                }
            }
        });
    }

    public static synchronized int getOnlineCount() {
        return onlineCount.get();
    }

    public static synchronized void addOnlineCount() {
        onlineCount.incrementAndGet();
    }

    public static synchronized void subOnlineCount() {
        onlineCount.decrementAndGet();
    }

    /**
     * 启动心跳检测(确保只启动一个线程)
     */
    private static volatile boolean heartbeatThreadStarted = false;

    private synchronized void startHeartbeatCheckIfNeeded() {
        if (!heartbeatThreadStarted) {
            heartbeatThreadStarted = true;
            new HeartbeatThread().start();
        }
    }

    /**
     * 心跳检测线程
     */
    private static class HeartbeatThread extends Thread {
        @Override
        public void run() {
            while (true) {
                try {
                    // 每30秒检测一次
                    Thread.sleep(30000);

                    long currentTime = System.currentTimeMillis();
                    heartbeatMap.entrySet().removeIf(entry -> {
                        WebSocketSession session = entry.getKey();
                        long lastHeartbeat = entry.getValue();

                        // 超过60秒未收到心跳,关闭连接
                        if (currentTime - lastHeartbeat > 60000) {
                            try {
                                if (session.isOpen()) {
                                    session.close();
                                }
                            } catch (IOException e) {
                                log.error("关闭超时会话失败:{}", e.getMessage());
                            }
                            removeSession(session);
                            return true;
                        }
                        return false;
                    });

                    // 如果没有会话了,停止线程
                    if (sessionMap.isEmpty()) {
                        heartbeatThreadStarted = false;
                        break;
                    }
                } catch (Exception e) {
                    log.error("心跳检测异常:{}", e.getMessage());
                    heartbeatThreadStarted = false;
                    break;
                }
            }
        }
    }
}

4. 前端实现

4.1. websocket 工具类

// utils/websocket.js
export const getWebSocketUrl = (userId) => {
  const { protocol } = window.location

  let backendUrl

  if (process.env.NODE_ENV === 'development') {
    // 开发环境:使用固定的后端地址
    backendUrl = 'localhost:8123'
  } else if (process.env.NODE_ENV === 'test') {
    // 测试环境
    backendUrl = 'test-backend.com:8123'
  } else {
    // 生产环境:可能使用相同的域名(通过 Nginx 代理)
    // 或者指定的后端域名
    backendUrl = process.env.VUE_APP_BACKEND_URL || window.location.host
  }

  const contextPath = '/api'

  const wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:'
  const wsUrl = `${wsProtocol}//${backendUrl}${contextPath}/ws/${userId}`

  // console.log('🔧 当前环境:', process.env.NODE_ENV)
  // console.log('🎯 后端地址:', backendUrl)
  // console.log('🌐 WebSocket URL:', wsUrl)

  return wsUrl
}

4.2. 发起连接

<template>
  <div id="global-header">
    <a-row :wrap="false">
      <a-col flex="135px">
        <router-link to="/">
          <div class="title-bar">
            <img src="../assets/logo.png" alt="logo" class="logo" />
            <div class="title">鱼籽云图库</div>
          </div>
        </router-link>
      </a-col>
      <a-col flex="1">
        <a-menu
          style="width: auto; min-width: 450px"
          v-model:selectedKeys="current"
          mode="horizontal"
          :items="items"
          @click="doMenuClick"
        />
      </a-col>
      <a-col>
        <icon-font type="icon-tongzhi" style="font-size: 20px; margin-right: 10px" />
      </a-col>
      <a-col flex="220px">
        <a-dropdown>
          <a class="ant-dropdown-link">
            <div class="user-login-status">
              <a-space v-if="loginUserStore.loginUser.id" style="color: #000">
                <a-avatar
                  :src="
                    loginUserStore.loginUser.userAvatar
                      ? loginUserStore.loginUser.userAvatar
                      : '/src/assets/avatar.png'
                  "
                ></a-avatar>
                {{ loginUserStore.loginUser.userName }}
                <DownOutlined />
              </a-space>
              <a-button type="primary" href="/user/login" v-else>登录</a-button>
            </div>
          </a>
          <template #overlay>
            <a-menu v-if="loginUserStore.loginUser.id" style="width: 120px; text-align: center">
              <a-menu-item>
                <a href="javascript:;" @click="gotoUserCenter">个人中心</a>
              </a-menu-item>
              <a-menu-item>
                <a href="javascript:;" @click="logout">退出登录</a>
              </a-menu-item>
            </a-menu>
          </template>
        </a-dropdown>
      </a-col>
    </a-row>
    <!--    <span class="iconfont icon-shangchuan fabu-btn" @click="openUploadModal"></span>-->
    <UploadSelectModal ref="uploadSelectModalRef" />
    <div ref="logRef"></div>
  </div>
</template>
<script lang="ts" setup>

import { getWebSocketUrl } from '@/utils/websocket.ts'

const loginUserStore = useLoginUserStore()

// ================== websocket =====================
var websocket = null
var heartbeatInterval = null
// 监听用户ID变化
const userId = ref(loginUserStore.loginUser?.id)

// 连接WebSocket
function connect() {
  console.log('🔗 开始连接WebSocket,用户ID:', userId.value)

  if (!userId.value) {
    console.warn('❌ 连接失败:用户ID为空')
    return false
  }

  // 检查WebSocket支持
  if (!('WebSocket' in window)) {
    console.error('❌ 浏览器不支持WebSocket')
    return false
  }

  try {
    // 构造WebSocket URL - 添加调试
    const wsUrl = getWebSocketUrl(userId.value)

    // 如果已有连接,先关闭
    if (websocket) {
      if (websocket.readyState === WebSocket.OPEN) {
        console.log('🔄 关闭现有连接')
        websocket.close()
      }
      websocket = null
    }

    // 创建新连接
    websocket = new WebSocket(wsUrl)
    console.log('🆕 创建WebSocket实例')

    websocket.onopen = function (event) {
      console.log('✅ WebSocket连接成功建立')
      console.log('📊 连接详情:', {
        url: wsUrl,
        readyState: websocket?.readyState,
        protocol: websocket?.protocol,
      })
      startHeartbeat()
    }

    websocket.onmessage = function (event) {
      console.log('📨 收到消息:', event.data)
      // 这里可以处理实际的消息逻辑
    }

    websocket.onclose = function (event) {
      console.log('🔒 WebSocket连接关闭:', {
        code: event.code,
        reason: event.reason,
        wasClean: event.wasClean,
      })
      stopHeartbeat()
    }

    websocket.onerror = function (error) {
      console.error('❌ WebSocket错误:', error)
      console.log('🔧 错误详情:', {
        readyState: websocket?.readyState,
        url: wsUrl,
      })
    }

    return true
  } catch (error) {
    console.error('💥 WebSocket连接异常:', error)
    return false
  }
}

// 断开连接
function disconnect() {
  console.log('🔌 断开WebSocket连接')
  stopHeartbeat()

  if (websocket) {
    // 移除事件监听器避免内存泄漏
    websocket.onopen = null
    websocket.onmessage = null
    websocket.onclose = null
    websocket.onerror = null

    if (websocket.readyState === WebSocket.OPEN) {
      websocket.close(1000, '正常关闭')
    }
    websocket = null
  }
}

// 发送单播消息
function sendUnicast() {
  if (websocket == null || websocket.readyState != WebSocket.OPEN) {
    alert('请先建立连接')
    return
  }

  var message = {
    type: 'unicast',
    from: 1111,
    to: userId.value,
    content: '单播消息',
    timestamp: new Date().getTime(),
  }

  websocket.send(JSON.stringify(message))
  console.log('已发送单播消息: ' + JSON.stringify(message))
}

// 发送广播消息
function sendBroadcast() {
  if (websocket == null || websocket.readyState != WebSocket.OPEN) {
    alert('请先建立连接')
    return
  }

  var message = {
    type: 'broadcast',
    from: 1111,
    content: '广播消息',
    timestamp: new Date().getTime(),
  }

  websocket.send(JSON.stringify(message))
  console.log('已发送广播消息: ' + JSON.stringify(message))
}

// 启动心跳检测
function startHeartbeat() {
  // 每20秒发送一次心跳
  heartbeatInterval = setInterval(function () {
    if (websocket.readyState == WebSocket.OPEN) {
      var heartbeat = {
        type: 'heartbeat',
        from: 1111,
        timestamp: new Date().getTime(),
      }
      websocket.send(JSON.stringify(heartbeat))
      console.log('已发送心跳')
    }
  }, 20000)
}

// 停止心跳检测
function stopHeartbeat() {
  if (heartbeatInterval != null) {
    clearInterval(heartbeatInterval)
    heartbeatInterval = null
  }
}

onMounted(() => {
  // 额外检查:如果watch没有触发,手动检查一次
  if (loginUserStore.loginUser?.id && !websocket) {
    console.log('🔄 手动触发连接(watch可能未触发)')
    setTimeout(() => {
      connect()
    }, 200)
  }
})

// 监听用户加载状态和ID变化
watch(
  () => loginUserStore.loginUser?.id,
  (newUserId, prevUserId) => {
    console.log('👀 状态变化:', {
      用户ID: newUserId,
      之前ID: prevUserId,
    })

    // 只有当有用户ID时才连接
    if (newUserId) {
      console.log('✅ 用户信息加载完成,建立WebSocket连接')
      userId.value = newUserId
      disconnect()
      setTimeout(() => connect(), 100)
    } else if (!newUserId) {
      console.log('⏸️ 用户未登录,不连接WebSocket')
      disconnect()
    }
  },
  { immediate: true },
)

onUnmounted(() => {
  console.log('🧹 组件卸载,清理WebSocket')
  disconnect()
})
</script>

<style scoped></style>

注意:如果后端设置了如下的路径上下文

server:
  port: 8123
  servlet:
    context-path: /api

那么服务端的 ws 地址就会有变化,

参考:ws://localhost:8123 /api/ws/1972918799441076226

5. 结合业务功能 - 图片审核结果通知

5.1. 后端

5.1.1. 审核结果消息VO封装类

package com.yu.cloudpicturebackend.model.vo;

import cn.hutool.json.JSONUtil;
import lombok.Data;

import java.util.Date;

@Data
public class ReviewResultMessageVO {
    private String type = "review_result";  // 消息类型
    private Long pictureId;                 // 图片ID
    private Integer reviewStatus;           // 审核状态
    private String reviewNote;              // 审核备注
    private String noticeToUser; // 通知文案
    private String name;            // 图片名称
    private Date reviewTime;                 // 审核时间

    // 转换为JSON字符串
    public String toJson() {
        return JSONUtil.toJsonStr(this);
    }
}

5.1.2. 图片审核服务层

/**
 * 管理员审核图片
 *
 * @param pictureStatusAlterRequest
 */
@Override
public void alterStatus(PictureStatusAlterRequest pictureStatusAlterRequest) {
    Long pictureId = pictureStatusAlterRequest.getPictureId();
    Integer reviewStatus = pictureStatusAlterRequest.getReviewStatus();
    String reviewNote = pictureStatusAlterRequest.getReviewNote(); // 审核备注
    Boolean notifyUser = pictureStatusAlterRequest.getNotifyUser(); // 是否通知用户
    String noticeToUser = pictureStatusAlterRequest.getNoticeToUser(); // 通知文案
    ThrowUtils.throwIf(pictureId == null || reviewStatus == null || PictureReviewStatusEnum.getEnumByValue(reviewStatus) == null, ErrorCode.PARAMS_ERROR);
    Picture picture = this.getById(pictureId);
    ThrowUtils.throwIf(picture == null, ErrorCode.NOT_FOUND_ERROR);

    // 更新图片状态
    UpdateWrapper<Picture> pictureUpdateWrapper = new UpdateWrapper<>();
    pictureUpdateWrapper.eq("id", pictureId);
    pictureUpdateWrapper.set("review_status", reviewStatus);
    pictureUpdateWrapper.set(reviewNote != null, "review_note", reviewNote);
    boolean isUpdated = this.update(pictureUpdateWrapper);
    ThrowUtils.throwIf(!isUpdated, ErrorCode.OPERATION_ERROR);

    // 如果选择通知用户,则发送WebSocket通知
    if (Boolean.TRUE.equals(notifyUser)) {
        notifyUserReviewResult(picture, reviewStatus, noticeToUser);
    }
}
/**
 * 通知用户审核结果
 */
private void notifyUserReviewResult(Picture picture, Integer reviewStatus, String noticeToUser) {
    try {
        Long userId = picture.getUserId(); // 假设Picture实体中有userId字段

        if (userId == null) {
            log.warn("图片 {} 的用户ID为空,无法发送通知", picture.getId());
            return;
        }

        // 构建审核结果消息
        ReviewResultMessageVO message = new ReviewResultMessageVO();
        message.setPictureId(picture.getId());
        message.setReviewStatus(reviewStatus);
        message.setNoticeToUser(noticeToUser);
        message.setName(picture.getName());
        message.setReviewTime(new Date());

        // 通过WebSocket发送单播消息
        PictureReviewHandler.sendToUser(userId, message.toJson());

        log.info("✅ 已向用户{}发送图片审核通知,图片ID:{},状态:{}",
                userId, picture.getId(), reviewStatus);

    } catch (Exception e) {
        log.error("发送审核结果通知失败,图片ID:{}", picture.getId(), e);
    }
}

也可以在 websocket 处理器新增方法,方便调用


/**
 * 发送图片审核结果通知给指定用户
 */
public static void notifyReviewResult(Long userId, ReviewResultMessageVO message) {
    if (userId == null || message == null) {
        log.warn("用户ID或消息为空,无法发送审核通知");
        return;
    }

    String jsonMessage = message.toJson();
    sendToUser(userId, jsonMessage);
    log.debug("发送图片审核通知给用户{}: {}", userId, jsonMessage);
}

/**
 * 批量发送审核结果通知
 */
public static void batchNotifyReviewResult(Map<Long, ReviewResultMessageVO> messages) {
    if (messages == null || messages.isEmpty()) {
        return;
    }

    messages.forEach((userId, message) -> {
        if (userId != null && message != null) {
            notifyReviewResult(userId, message);
        }
    });
}

5.2. 前端

在 onmessage 中解析收到的消息,并处理通知

// WebSocket 消息处理
websocket.onmessage = function (event) {
  console.log('📨 收到消息:', event.data)

  const message = JSON.parse(event.data)

  if (message.type === 'review_result') {
    // 处理审核结果通知
    handleReviewResult(message)
  }
}

function handleReviewResult(message) {
  const statusText = STATUS_MAP[message.reviewStatus] || '未知状态'

  // 显示通知
  notification.open({
    message: '审核结果通知',
    description: h(
      'div',
      [
        h('div', `图片名称:"${message.name}"`),
        h('div', `审核结果:${statusText}`),
        message.noticeToUser ? h('div', `备注:${message.noticeToUser}`) : null,
      ].filter(Boolean),
    ),
  })
}

6. 高级功能拓展

6.1.1. 消息持久化

// 在WebSocketServer类中添加
private void saveMessage(WebSocketMessage message) {
  // 实现消息存储逻辑,可存入数据库或Redis
  log.info("存储消息: {}", JSON.toJSONString(message));
}

6.1.2. 断线重连机制(前端)

// 修改前端connect函数
var reconnectAttempts = 0;
var maxReconnectAttempts = 5;
var reconnectInterval = 5000; // 5秒

function connect() {
    // ...原有代码...
    
    websocket.onclose = function() {
        logMessage('连接已关闭');
        $('#disconnectBtn').prop('disabled', true);
        stopHeartbeat();
        
        // 断线重连逻辑
        if (reconnectAttempts < maxReconnectAttempts) {
            reconnectAttempts++;
            logMessage('尝试重新连接(' + reconnectAttempts + '/' + maxReconnectAttempts + ')');
            setTimeout(connect, reconnectInterval);
        }
    };
    
    // 连接成功后重置重试计数
    websocket.onopen = function() {
        reconnectAttempts = 0;
        // ...原有代码...
    };
}

7. 参考文章

Spring Boot集成WebSocket项目实战详解