摘要:在开发“适老化”应用或社交平台时,即时通讯(IM)是绕不开的核心功能。传统的 HTTP 轮询不仅浪费资源,还无法保证消息的实时性。本文将基于 Spring Boot 和原生 WebSocket,结合 Vue 3 前端,手把手带你实现一个支持文字、图片、语音消息(带时长)的点对点即时聊天系统。

关键词:Spring Boot, WebSocket, Vue3, 即时通讯, 实战

一、 为什么选择 WebSocket?

在 Web 开发中,最常见的 HTTP 协议是“请求-响应”模式,服务器极其被动,无法主动向客户端推送消息。

如果我们要实现聊天功能,使用 HTTP 通常需要轮询(Polling),即前端每隔 1 秒问一次后端:“有新消息吗?”。这就像你每隔几秒就去敲邻居门问他在不在,效率极低且服务器压力大。

WebSocket 则是一种在单个 TCP 连接上进行全双工通信的协议。

  • 建立连接:一次握手,永久保持连接。

  • 通信方式:服务器可以主动给客户端发消息,客户端也可以随时给服务器发消息。

  • 场景:聊天室、即时通讯、股票行情推送。

二、 后端架构设计 (Spring Boot)

1. 依赖与配置

首先,我们需要在 Spring Boot 中开启 WebSocket 支持。核心配置类实现 WebSocketConfigurer 接口。

WebSocketConfig.java 解析:

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Autowired
    private ChatHandler chatHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        // 核心配置:
        // 1. 绑定处理器 chatHandler
        // 2. 定义连接端点 /chat/{userId},前端连接时需带上自己的 ID
        // 3. setAllowedOrigins("*") 允许跨域,方便前后端分离调试
        registry.addHandler(chatHandler, "/chat/{userId}")
                .setAllowedOrigins("*");
    }
}

2. 核心处理器 (The Core)

这是整个即时通讯的大脑。我们需要继承 TextWebSocketHandler。这里有三个关键点:

  1. 用户管理:使用 ConcurrentHashMap 存储所有在线用户的 Session

  2. 消息转发:接收发送者的消息 -> 存入数据库 -> 找到接收者的 Session -> 推送。

  3. 断开清理:用户下线时移除 Session,防止内存泄漏。

ChatHandler.java 核心代码:

@Component
public class ChatHandler extends TextWebSocketHandler {

    @Autowired
    private MessageMapper messageMapper;

    // 线程安全的 Map,用来存放 [用户ID -> WebSocketSession] 的映射
    private static final Map<Long, WebSocketSession> onlineUsers = new ConcurrentHashMap<>();
    private final ObjectMapper objectMapper = new ObjectMapper();

    /**
     * 连接建立成功后触发
     * 从 URL 路径 /chat/{userId} 中解析出当前登录的用户 ID
     */
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        String path = session.getUri().getPath();
        String userIdStr = path.substring(path.lastIndexOf('/') + 1);
        onlineUsers.put(Long.parseLong(userIdStr), session);
        System.out.println("【WebSocket】用户上线: " + userIdStr);
    }

    /**
     * 处理收到的文本消息
     */
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String payload = message.getPayload();
        
        try {
            // 1. 反序列化消息(包含 msgType, duration, toId 等)
            Message msg = objectMapper.readValue(payload, Message.class);

            // 2. 消息持久化入库(这一步非常重要,防止消息丢失)
            // 注意:语音消息需要保存 duration (时长)
            messageMapper.insertMessage(msg); 
            
            // 3. 实时转发给目标用户
            WebSocketSession targetSession = onlineUsers.get(msg.getToId());
            
            // 如果对方在线,直接通过 WebSocket 推送
            if (targetSession != null && targetSession.isOpen()) {
                // 重新序列化,确保 ID 和时间等数据库生成的字段被返回
                String responseJson = objectMapper.writeValueAsString(msg);
                targetSession.sendMessage(new TextMessage(responseJson));
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 连接关闭后移除用户
     */
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        onlineUsers.entrySet().removeIf(entry -> entry.getValue().equals(session));
    }
}

3. 消息模型设计

为了支持语音和多媒体,数据库模型必须具备通用性。

Message.java

@Data
public class Message {
    private Long id;
    private Long fromId;   // 发送者
    private Long toId;     // 接收者
    private String content;// 内容(文本是文字,语音/图片则是 URL 地址)
    private String msgType;// 消息类型:text, voice, image
    private Integer duration; // 【关键】语音时长,用于前端展示语音条长度
    private Date createTime;
}

三、 前端实现 (Vue 3 + WebSocket)

前端主要负责建立连接、心跳保活、以及处理多媒体输入(录音、传图)。

1. 建立连接

在 Vue 的 mounted 生命周期中初始化 WebSocket。

initWS(uid) {
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  // 连接地址对应后端的 registry.addHandler
  this.socket = new WebSocket(`${protocol}//${window.location.host}/chat/${uid}`);
  
  this.socket.onmessage = (e) => {
    const m = JSON.parse(e.data);
    // 判断消息是不是发给当前聊天窗口的
    if(m.fromId == this.currentTargetId) { 
        this.messageList.push(m); 
        this.goBottom(); // 滚动到底部
    }
  };
},

2. 核心难点:语音消息与文件上传

WebSocket 协议本身适合传输文本或小二进制数据。对于图片和语音文件,最佳实践是“HTTP 上传 + WebSocket 通知”

逻辑流程:

  1. 前端录音或选择图片。

  2. 通过 Axios (POST /api/common/upload) 将文件上传到服务器/OSS。

  3. 服务器返回文件的 URL。

  4. 前端将 URL、消息类型、语音时长 封装成 JSON,通过 WebSocket 发送给对方。

前端代码片段 (msg.html):

// 发送消息的统一方法
emitMessage(content, type, dur = 0) {
  const msg = { 
      fromId: this.currentUser.id, 
      toId: this.currentTargetId, 
      content: content, // 这里可能是文字,也可能是上传后的 URL
      msgType: type, 
      duration: dur 
  };
  // 发送 JSON 字符串
  this.socket.send(JSON.stringify(msg));
  // 自己界面上也要立刻显示出来
  this.messageList.push(msg);
  this.goBottom();
},

// 录音结束后的处理
async stopRecord() { 
    // ... 省略 MediaRecorder 停止逻辑 ...
    const dur = Math.round((Date.now() - this.recStart) / 1000); // 计算时长
    
    // 1. 上传文件
    const fd = new FormData(); 
    fd.append('file', blob, 'v.webm');
    const res = await axios.post('/api/common/upload', fd);
    
    // 2. 通过 WebSocket 发送链接和时长
    if(res.data.code === 200) {
        this.emitMessage(res.data.url, 'voice', dur);
    }
}

3. 动态渲染气泡

为了让界面更像微信,我们需要根据 msgType 渲染不同的 DOM 结构。

<div class="msg-content">
  <template v-if="msg.msgType === 'text'">
    <div class="bubble">{{ msg.content }}</div>
  </template>

  <template v-else-if="msg.msgType === 'voice'">
    <div class="bubble voice-bubble" @click="playVoice(msg.content)"
         :style="{ width: (150 + msg.duration * 10) + 'px' }">
      <i class="fa fa-rss voice-icon"></i>
      <span>{{ msg.duration }}''</span>
    </div>
  </template>

  <template v-else-if="msg.msgType === 'image'">
    <img :src="msg.content" class="media-obj">
  </template>
</div>

四、 遇到的问题与解决方案

1. 并发 Map 的选择

ChatHandler 中,必须使用 ConcurrentHashMap 而不是普通的 HashMap。因为 WebSocket 是多线程环境,多个用户同时上下线时,普通的 Map 会导致并发修改异常或数据不一致。

2. 语音时长的丢失问题

一开始实现时,发现接收方收到的语音消息时长总是显示默认值。

原因:转发时直接转发了前端发来的字符串,或者数据库读取时漏了字段。

解决:

  1. 前端发送 JSON 时必须带上 duration

  2. 后端 MessageMapper 插入时必须保存 duration

  3. 后端 MessageMapper 查询历史记录时,SQL 语句必须显式查出 duration 字段。

// MessageMapper.java
@Select("SELECT ..., duration, DATE_FORMAT(create_time, '%H:%i') as time ...")

3. 滚动条自动触底

聊天软件的体验关键在于新消息到来时自动滚动到底部。

技巧:在 Vue 中使用 $nextTick,确保 DOM 更新(消息渲染完毕)后再执行滚动逻辑。

JavaScript

goBottom(delay = 50) {
  setTimeout(() => {
    this.$nextTick(() => {
      const el = document.getElementById('chat-box');
      el.scrollTop = el.scrollHeight; // 暴力拉到底
    });
  }, delay);
}

五、 总结

通过 Spring Boot 的 spring-boot-starter-websocket 和 Vue 3,我们用极少的代码量实现了一个功能完备的即时通讯系统,未来将实现视频/语音通话,还在努力学习中。

核心流程回顾:

  1. 连接:Websocket 握手,绑定 UserID。

  2. 通信:前端 send JSON -> 后端 handleTextMessage -> 存库 -> sendMessage 给目标。

  3. 媒体:HTTP 上传文件换取 URL,WebSocket 仅传输“指令”和“元数据”。

六、 界面展示

聊天界面

添加好友

好友申请列表

未读消息提醒

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐