【WebSocket 01】 入门原理剖析,手写群发消息、私聊会话功能

作者:逆境不可逃

技术永无止境

希望我的内容可以帮助到你!!!!


大家吼 ! 我是 逆境不可逃 今天给大家带来文章

《【WebSocket 01】 入门原理剖析,手写群发消息、私聊会话功能 》

本文章属于栏目 WebSocket

A. 第一部分 入门+群发

一、先搞懂:为什么要有 WebSocket?

1. 传统两种实时方案缺点

1)短轮询 setInterval 前端每隔 1~3s 发一次 HTTP 请求查数据,没人发消息也不停请求:

  • 浪费 http 握手、头部带宽、服务器 IO;消息延迟最高 3s 适用:低频非实时(普通列表刷新),不适合聊天、实时推送

2)长轮询 前端发 HTTP,服务端无数据时 hold 住连接,有数据立即返回,客户端收到立刻再发起新请求:

  • 每次消息都要新建 HTTP 连接,频繁握手,高并发性能差
2. WebSocket 核心优势
  • 一次 HTTP 握手 → 升级为 TCP 长连接,全双工:客户端、服务端随时主动发消息
  • 开销极小:ws 消息头远小于 HTTP 头
  • 协议:ws://ip:端口/路径(明文)、wss://(SSL 加密,生产)
3. 关键:HTTP1.1 协议升级握手(必背请求头)

客户端先发普通 HTTP 请求,携带三个关键 Header 申请升级协议:

复制代码
GET /ws HTTP/1.1
Host: localhost:8080
Upgrade: websocket        // 申请升级到ws协议
Connection: Upgrade       // 标识连接需要升级
Sec-WebSocket-Key: xxx    // 浏览器随机密钥,服务端加密校验
Sec-WebSocket-Version: 13

服务端返回101 Switching Protocols,握手成功,TCP 通道改成 WebSocket 长连接。

101 状态码 = 协议切换成功,此后不再遵循 HTTP,走 WS 自定义报文。

二、第一步:搭建 SpringBoot 原生 WS 环境(JSR356 @ServerEndpoint)

提醒:可以把代码直接复制给cursor它会帮你配置

1. pom.xml 依赖
复制代码
<!-- SpringBoot Web基础 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- WebSocket JSR356依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
2. 开启 WebSocket 注册配置(必须配置,否则注解不生效)
复制代码
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

@Configuration
public class WebSocketConfig {
    // 自动扫描@ServerEndpoint注解的类,注册ws端点
    @Bean
    public ServerEndpointExporter serverEndpointExporter(){
        return new ServerEndpointExporter();
    }
}
3. WebSocket 服务端点核心代码(核心)
复制代码
import jakarta.websocket.*;
import jakarta.websocket.server.ServerEndpoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.concurrent.CopyOnWriteArraySet;

// ws访问地址:ws://localhost:8080/ws/demo
@ServerEndpoint("/ws/demo")
@Component
public class DemoWebSocket {

    // 存储所有在线客户端session(线程安全集合)
    private static final CopyOnWriteArraySet<Session> SESSION_SET = new CopyOnWriteArraySet<>();
    private Session session;

    // 1. 客户端连接成功回调
    @OnOpen
    public void onOpen(Session session){
        this.session = session;
        SESSION_SET.add(session);
        System.out.println("新客户端接入,当前在线:"+SESSION_SET.size());
    }

    // 2. 收到客户端发来的消息
    @OnMessage
    public void onMessage(String msg, Session session) throws IOException {
        System.out.println("收到客户端消息:"+msg);
        // 群发:把消息推送给所有在线用户
        for(Session s : SESSION_SET){
            s.getBasicRemote().sendText("服务端已收到:" + msg);
        }
    }

    // 3. 连接关闭
    @OnClose
    public void onClose(){
        SESSION_SET.remove(this.session);
        System.out.println("客户端断开,剩余在线:"+SESSION_SET.size());
    }

    // 4. 连接异常
    @OnError
    public void onError(Session session, Throwable error){
        error.printStackTrace();
    }
}
4. 启动类
复制代码
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class WsApplication {
    public static void main(String[] args) {
        SpringApplication.run(WsApplication.class,args);
    }
}

启动项目,默认端口 8080

三、第二步:前端原生 JS WebSocket 页面(新建 ws.html,直接浏览器打开)

复制代码
<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <title>原生WebSocket测试</title>
</head>
<body>
    <div>
        <h3>消息接收区</h3>
        <div id="msgBox" style="border:1px solid #ccc;height:300px;overflow-y:auto;padding:10px;"></div>
        <br>
        <input type="text" id="inputMsg" placeholder="输入消息">
        <button onclick="sendMsg()">发送</button>
    </div>

<script>
    // 1. 创建ws实例,连接后端地址
    let ws = new WebSocket("ws://localhost:8080/ws/demo");
    let msgBox = document.getElementById("msgBox");
    let inputMsg = document.getElementById("inputMsg");

    // 连接成功回调 onopen
    ws.onopen = function(){
        appendMsg("✅ 与服务端连接成功");
    }

    // 收到服务端消息 onmessage
    ws.onmessage = function(e){
        appendMsg("📩 服务端:"+e.data);
    }

    // 连接关闭 onclose
    ws.onclose = function(){
        appendMsg("❌ 连接已关闭");
    }

    // 连接报错 onerror
    ws.onerror = function(err){
        appendMsg("⚠️ 连接异常:"+err);
    }

    // 发送消息方法
    function sendMsg(){
        let val = inputMsg.value.trim();
        if(!val) return;
        ws.send(val);
        appendMsg("👤 我:"+val);
        inputMsg.value = "";
    }

    // 页面追加消息
    function appendMsg(text){
        msgBox.innerHTML += text + "<br>";
        // 滚动到最底部
        msgBox.scrollTop = msgBox.scrollHeight;
    }
</script>
</body>
</html>

四、运行测试步骤

  1. 启动 SpringBoot 项目,控制台无报错
  2. 打开多个浏览器标签 / 多个浏览器,同时打开 ws.html
  3. 在输入框发消息:
    • A 页面发消息 → 所有打开页面都收到服务端回传消息(群发效果)
    • 关闭其中一个页面,后端控制台打印在线人数减少

打开一个标签页,服务端显示 新客户端接入 当前在线:1

又打开一个标签页,服务端显示 新客户端接入 当前在线:2

第一个标签页 发送你好 服务端接受并广播到两个标签页

关闭标签页

B. 第二部分 增加私聊

一、前端 WebSocket 四大状态 readyState

ws.readyState 数字标识当前连接状态:

常量名 含义
0 CONNECTING 正在建立连接(new WebSocket 之后、onopen 之前)
1 OPEN 连接成功,可以正常 send ()
2 CLOSING 正在关闭(调用 close () 后)
3 CLOSED 连接已彻底关闭,无法发消息

关键坑:连接关闭后不能 send ()

如果在状态≠1 时调用ws.send()会直接报错,发消息前要做状态判断。 改造前端发送函数:

复制代码
function sendMsg(){
    let val = inputMsg.value.trim();
    if(!val) return;
    // 校验连接是否可用
    if(ws.readyState !== 1){
        appendMsg("⚠️ 当前未连接,发送失败");
        return;
    }
    ws.send(val);
    appendMsg("👤 我:"+val);
    inputMsg.value = "";
}

手动关闭连接:ws.close (code, reason)

  • ws.close():正常关闭

  • 可选参数:close(1000,"用户主动退出") 前端新增一个关闭按钮:

    function closeConn(){
    if(ws.readyState === 1){
    ws.close(1000,"用户手动关闭");
    }
    }

关闭码 1000:正常退出;非 1000 代表异常断开。

二、后端改造:区分群发 / 单点私聊

需求:

  1. 普通文本 = 全员群发
  2. 约定格式:@用户编号:消息内容 = 点对点发给指定客户端

问题:原先CopyOnWriteArraySet<Session>只存连接,分不清哪个 session 对应哪个用户,改用 Map 存储:key:用户ID,value:Session

修改后端 DemoWebSocket.java

复制代码
@ServerEndpoint("/ws/demo")
@Component
public class DemoWebSocket {
    // 用户id -> 连接会话
    private static final Map<String,Session> USER_MAP = new ConcurrentHashMap<>();
    private Session session;
    // 给当前连接随机分配一个简易用户ID(实际业务从url参数/token拿)
    private String uid;

    @OnOpen
    public void onOpen(Session session){
        this.session = session;
        // 简易生成用户id,正式环境从请求参数获取:ws://localhost:8080/ws/demo?uid=1001
        uid = String.valueOf(System.currentTimeMillis() % 10000);
        USER_MAP.put(uid,session);
        System.out.println("用户【"+uid+"】上线,在线总数:"+USER_MAP.size());
        try {
            // 上线通知自己的用户编号
            session.getBasicRemote().sendText("✅ 你的用户ID:"+uid);
        } catch (IOException e) {e.printStackTrace();}
    }

    @OnMessage
    public void onMessage(String msg, Session session) throws IOException {
        System.out.println("【"+uid+"】发来:"+msg);
        // 私聊规则:@目标uid:内容
        if(msg.startsWith("@")){
            // @123:你好
            String[] arr = msg.split(":",2);
            String targetUid = arr[0].replace("@","");
            String content = arr[1];
            Session targetSession = USER_MAP.get(targetUid);
            if(targetSession != null && targetSession.isOpen()){
                targetSession.getBasicRemote().sendText("【私聊来自"+uid+"】:"+content);
            }else{
                // 对方不在线,回复发送人
                session.getBasicRemote().sendText("用户"+targetUid+"不在线");
            }
        }else{
            // 全员群发
            for(Session s : USER_MAP.values()){
                if(s.isOpen()){
                    s.getBasicRemote().sendText("【"+uid+"群发】:"+msg);
                }
            }
        }
    }

    @OnClose
    public void onClose(){
        USER_MAP.remove(uid);
        System.out.println("用户【"+uid+"】下线,剩余在线:"+USER_MAP.size());
    }

    @OnError
    public void onError(Session session, Throwable error){
        USER_MAP.remove(uid);
        error.printStackTrace();
    }
}

三、前端页面不用改动,测试规则

  1. 新开页面,每个页面弹窗收到自己用户 ID(4 位数字)
  2. 直接发文字:所有页面都收到群发消息
  3. 输入 @xxxx:晚上吃饭吗(xxxx 替换成另一个页面的用户 ID),只有目标用户收到私聊
  4. 由于后端写的比较简陋。如果不按照规则,则会异常,这个自行处理

示例: A 页面 ID:4281,B 页面 ID:1803 A 发送:@1803:内容 → 只有 B 收到私聊消息。

四、拓展:URL 参数携带 uid(正式业务用法)

上面是后端随机生成,真实项目前端传参:

复制代码
// 前端连接时带上用户id
let myUid = prompt("输入你的用户ID");
let ws = new WebSocket(`ws://localhost:8080/ws/demo?uid=${myUid}`);

后端在 onOpen 里获取 url 参数:

复制代码
@OnOpen
public void onOpen(Session session){
    // 获取url参数
    String query = session.getQueryString();
    // 解析uid=xxx,这里先记住用法,下节课详细解析握手参数、token鉴权
}

总结

本文介绍了WebSocket技术的基础知识和实践应用,主要包括以下内容:

WebSocket的优势

  • 相比传统短轮询和长轮询方案,WebSocket通过一次HTTP握手建立TCP长连接,实现全双工通信
  • 具有开销小、实时性强等特点,适合聊天、实时推送等场景

SpringBoot实现WebSocket服务端

  • 通过@ServerEndpoint注解创建端点
  • 使用ServerEndpointExporter注册端点
  • 实现onOpen、onMessage等回调方法处理连接和消息

前端WebSocket实现

  • 使用原生WebSocket API创建连接
  • 实现消息收发和状态管理
  • 增加连接状态检查和错误处理

功能扩展

  • 从简单群发到增加私聊功能
  • 使用Map存储用户会话实现点对点通信
  • 介绍了URL参数传递用户ID的方法

文章提供了完整的代码示例,包括服务端和前端实现,帮助开发者快速掌握WebSocket的基础应用。

相关推荐
天一生水water1 小时前
agent教程S01-Agent 最小循环教程整理
java·服务器·网络·agent
网络与设备以及操作系统学习使用者1 小时前
多路由设备静态路由配置详解
运维·网络·学习·华为·智能路由器
二哈赛车手1 小时前
新人笔记---继图片搜索功能后续以及AI网络搜索功能一些经验与踩坑点,吐槽一下自己在做这方面的崩溃瞬间
java·网络·人工智能·spring boot·笔记·spring
IT大白鼠1 小时前
GRE协议原理与华为设备配置实践
网络·网络协议·华为
米丘2 小时前
HTTP 传输层 TCP 三次握手 / 四次挥手
前端·网络协议·http
酿情师2 小时前
区块链网络与跨链操作03:矿池网络协议
网络·网络协议·区块链
cc4422bb2 小时前
bgp联邦
网络
草莓熊Lotso2 小时前
【Linux网络】深入理解 HTTP 协议(三):静态资源服务、状态码与重定向实战
linux·运维·服务器·网络·c++·http
y = xⁿ3 小时前
HTTP 和 HTTPS 的区别
网络协议·http·https