WebSocket

HTTP只能单向向服务器请求, 如果要实现服务器通知, 就必须由客户端轮询, 压力非常大. 并且HTTP会携带无效信息, 延迟很高 因此出现了WebSocket

如果需要定期给浏览器推送数据,例如股票行情,或者不定期给浏览器推送数据,例如在线聊天,基于HTTP协议实现这类需求,只能依靠浏览器的JavaScript定时轮询,效率很低且实时性不高。

2009年出现的新技术Websocket 允许建立浏览器客户端和服务端之间的双向连接, 并且发送轻量级的数据模型, 服务端可以在需要时直接向客户端推送消息

不同于传统的Http ,Websocket 允许建立通路, 可以直接发送信息而不需像HTTP一样创建连接上下文, 原理如下

在建立TCP连接后, 附带几个请求头

GET /chat HTTP/1.1
Host: www.example.com
Upgrade: websocket
Connection: Upgrade

此后连接升级为长连接

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade

收到成功响应后表示WebSocket“握手”成功,这样,代表WebSocket的这个TCP连接将不会被服务器关闭,而是一直保持,服务器可随时向浏览器推送消息,浏览器也可随时向服务器推送消息。双方推送的消息既可以是文本消息,也可以是二进制消息,一般来说,绝大部分应用程序会推送基于JSON的文本消息。

使用 Websocket

开始前阅读 架构说明

建立新模块 sample-ws

<!-- ws -->  
<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-websocket</artifactId>  
</dependency>

启动类

/**  
 * 描述:程序启动入口  
 *  
 * @author hamhuo  
 * @version 1.0.0  
 */@SpringBootApplication  
@EnableBinding(Sink.class)  
@EnableDiscoveryClient  
public class WsApplication {  
    public static void main(String[] args) {  
        SpringApplication.run(WsApplication.class, args);  
    }  
}

实现服务端和浏览器的双向通信, 我们要写一个服务器

@Component  
@ServerEndpoint("/chat")  
public class ChatServer {
/**  
 * 保存连接对象, 连接池, 放用户session和key
 */  
private static final ConcurrentHashMap<String, Session> SESSION_POOL = new ConcurrentHashMap<>();

@OnOpen  
public void onOpen(Session session) 

@OnMessage  
public String onMessage(String text, Session session) 

@OnClose  
public void onClose(Session session)

@OnError  
public void onError(Session session, Throwable throwable)

}

解释一下

  1. 首先组件注解加上, Spring 会自动装载为组件

  2. 关于 @ServerEndpoint WebSocket 是一种基于 TCP 的协议,它是持久化连接,通信过程中不会像 HTTP 请求那样每次都经过请求-响应的流程,因此不能直接通过传统的 Spring MVC 控制器方法来处理。

    @ServerEndpoint 是 WebSocket 规范中的一种标注方式,用来标识一个 WebSocket 服务器端点。当客户端连接到这个端点时,会自动调用这个类中的相应方法。

    我们只需要知道服务器与 /server 绑定即可

  3. 我们在server中定义了四个方法, 启动时调用, 消息时调用, 关闭时调用, 错误时调用

  4. 我们创建一个链接池, 存放浏览器和服务器的长连接

这个端点注解依赖一个配置类

image.png

我们在 ws.config 下新建一个配置类 ,这是写死的, 不用理解

/**  
 * @program: demo  
 * @description: 服务端点配置  
 * @author: hamhuo  
 **/  
@Configuration  
@EnableWebSocket  
public class WebSocketConfig {  
    @Bean  
    public ServerEndpointExporter serverEndpoint() {  
        return new ServerEndpointExporter();  
    }  
}

当浏览器首先试图建立与服务器的连接, 这里调用 @OnOpen 注解方法

@OnOpen  
public void onOpen(Session session) throws IOException {  
    //判断客户端对象是否存在  
    //排除重复的连接对象  
    if (SESSION_POOL.containsKey(session.getQueryString())) {  
        CloseReason reason = new CloseReason(CloseReason.CloseCodes.CANNOT_ACCEPT, "ID冲突,连接拒绝");  
        session.getUserProperties().put("reason", reason);  
        session.close();  
        return;  
    }  
    //将客户端记录到Map中  
    SESSION_POOL.put(session.getQueryString(), session);  
    System.out.println("客户端(" + session.getQueryString() + "):开启连接");  
}

解释一下, 浏览器会传入一个客户端ID, 服务器根据ID来提供服务

每个ID标识了长连接, 服务器根据ID提供服务

  1. 传入连接后判断是否重复, 从连接池拿session里的key, 如果重复就拒绝连接
  2. 没有重复, 将Session保存到连接池(Map)里
  3. 控制套打印日志, 这里可以用slf4j

注意: 这里的Session 指的是长连接, 和 HTTP Session 不是一个东西

之后长连接建立, 可以双向监听信息, 一旦服务器或者浏览器有信息了就调用 @OnMessage

@OnMessage  
public String onMessage(String text, Session session) throws IOException {  
    //解析消息 => ID::消息内容  
    String[] msgArr = text.split("::", 2);  
    //群发消息 ID=all表示群发  
    if ("all".equalsIgnoreCase(msgArr[0])) {  
        for (Session one : SESSION_POOL.values()) {  
            //排除自己  
            if (session == one) {  
                continue;  
            }  
            //发送消息  
            one.getBasicRemote().sendText(msgArr[1]);  
        }  
    }  
    //指定发送人, 不是all的情况  
    else {  
        Session target = SESSION_POOL.get(msgArr[0]);  
        if (target != null) {  
            target.getBasicRemote().sendText(msgArr[1]);  
        }  
    }  
    return session.getQueryString() + ":消息发送成功";  
}

消息发送的方式有两种, 点对点和群发

点对点就找出目标Session, 服务器向Session写信息发送就行 群发就需要遍历连接池的每一个Session, 全部写信息

当然, 前提是排除发送者的Session

关闭连接, 也就是浏览器离线需要执行线程池移除线程的操作, 调用 @OnClose 方法 这里注意, webSocket并没有强制关闭连接的能力, 这是通知远程连接自主关闭, 服务器只负责监视是否关闭

@OnClose  
public void onClose(Session session) {  
    //处理拒绝连接session关闭对象, 不从池子里移除  
    Object obj = session.getUserProperties().get("reason");  
    if (obj instanceof CloseReason) {  
        CloseReason reason = (CloseReason) obj;  
        if (reason.getCloseCode() == CloseReason.CloseCodes.CANNOT_ACCEPT) {  
            System.out.println("拒绝客户端(" + session.getQueryString() + "):关闭连接");  
            return;  
        }  
    }  
    //将session对象从map中移除,正常关闭  
    SESSION_POOL.remove(session.getQueryString());  
    System.out.println("客户端(" + session.getQueryString() + "):关闭连接");  
}

这是比较简单的应用不再考虑连接复用的问题

接下来写浏览器页面

这里我们略过了~

<!DOCTYPE html>  
<html lang="en">  
<head>  
    <meta charset="UTF-8">  
    <title>聊天室客户端</title>  
</head>  
<body>  
<h1>Chat Client</h1>  
<div>  
    <input id="clientId" placeholder="输入ID" value="1">  
    <input onclick="init()" value="连接服务器" type="button"><br><br>  
    <input id="receiverId" placeholder="输入接收人ID" value="all"><br><br>  
    <textarea id="message" style="margin: 0; height: 197px; width: 362px;"  
              placeholder="发送消息内容"></textarea><br>  
    <input onclick="send()" value="发送消息" type="button">  
    <input onclick="closeConnect()" value="关闭连接" type="button">  
</div>  
<div id="output"></div>  
<script type="text/javascript" language="JavaScript">  
    //屏幕回显输出方法  
    function writeToScreen(message) {  
        let pre = document.createElement("p");  
        pre.style.wordWrap = "break-word";  
        pre.innerHTML = message;  
        document.getElementById("output").appendChild(pre);  
    }  
  
    //初始化websocket  
    let echo_websocket;  
    function init() {  
        let clientId = document.getElementById("clientId").value;  
        /* 这里端口是写死的 */        let wsUri = "ws://localhost:10800/chat?" + clientId;  
        writeToScreen("连接到" + wsUri);  
        //1.创建WebSocket客户端对象  
        echo_websocket = new WebSocket(wsUri);  
        //2.开门握手完成回调  
        echo_websocket.onopen = function (evt) {  
            console.log(evt);  
            writeToScreen("连接打开成功 !");  
        };  
        //3.监听服务端的消息  
        echo_websocket.onmessage = function (evt) {  
            writeToScreen("接收服务端消息:<br> " + evt.data);  
        };  
        //4.如果连接中断  
        echo_websocket.onerror = function (evt) {  
            writeToScreen('<span style="color: red;">ERROR:'+evt.data+'</span>');  
            //关闭连接  
            closeConnect();  
        };  
        //5.注册close事件  
        echo_websocket.onclose = function(evt){  
            writeToScreen('<span style="color: green;">INFO:关闭连接</span> ');  
            if(evt.reason){  
                writeToScreen  
                (`<span style="color: red;">错误信息:${evt.reason}</span> `);  
            }  
        }  
    }  
  
    //6.向服务发送消息  
    function send() {  
        let message = document.getElementById("message").value;  
        let receiver = document.getElementById("receiverId").value;  
        echo_websocket.send(receiver + "::" + message);  
        writeToScreen("发送消息: " + message);  
    }  
  
    //7.如果不需要通讯,那么关闭连接  
    function closeConnect() {  
        echo_websocket.close();  
    }  
</script>  
</body>  
</html>

我们需要解释下构建/发送信息的代码

//6.向服务发送消息  
    function send() {  
        let message = document.getElementById("message").value;  
        let receiver = document.getElementById("receiverId").value;  
        echo_websocket.send(receiver + "::" + message);  
        writeToScreen("发送消息: " + message);  
    } 

这里拿到表单组件, 取值后通过 :: 拼接为消息, 链接符之前为id, 之后为消息

其中初始化的 echo_websocket 组件是dom库提供的 echo_websocket = new WebSocket(wsUri); , 除了IE其他浏览器都支持

在前端打开三个浏览器页面, 作为三个客户端

image.png

注意id要不同

后台运行服务器, 就可以开始监听了

Last modified March 3, 2025: 组件笔记 3/3 (41127dd)