Websocket 实现前后端双向实时交互

以前很多网站为了实现服务器与浏览器的实时交互,一般采用前端 Ajax 定期轮询或者长连接技术请求后端接口,这种传统的模式带来很明显的缺点,浏览器与服务器之间采用 http 通信效率不高,前后端交互实时性不好,其实本质上还是前端对服务端的单向请求交互,服务端无法主动给前端发送消息。

WebSocket 是 HTML5 提供的一种在单个 TCP 连接上进行全双向通讯的协议,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输,使得客户端和服务器之间的数据交换变得更加简单。

本篇博客通过一个简单的 demo 介绍前后端通过 Websocket 进行双向交互,并在博客下方提供源代码下载。


一、前端 Websocket API

前端创建 Websocket 对象代码如下:

// websocket 的连接协议是 ws,例如 ws://localhost:8086/socket/zhangsan
var socket = new WebSocket(url);

WebSocket事件:

事件 事件处理程序 描述
open socket.onopen 连接建立时触发
message socket.onmessage 客户端接收服务端数据时触发
error socket.onerror 通信发生错误时触发
close socket.onclose 连接关闭时触发

WebSocket方法:

方法 描述
socket.send() 使用连接发送数据
socket.close() 关闭连接

本篇博客为了简化前端代码和相关引用,采用传统的 jquery 操作 dom 元素,编写一个 index.html 页面,代码如下:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>websocket</title>
    <script src="./jquery-3.7.1.min.js"></script>
    <script>
        //获取浏览器地址栏 get 请求参数
        function GetQueryString(name) {
            var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
            var r = window.location.search.substr(1).match(reg);
            if (r != null) return unescape(r[2]);
            return null;
        }

        var socket;
        if (typeof (WebSocket) == "undefined") {
            console.log("您的浏览器不支持WebSocket");
        } else {
            var host = window.location.host;
            var username = GetQueryString("username");
            //实现化 WebSocket 对象,与服务器建立连接
            socket = new WebSocket("ws://" + host + "/socket/" + username);
            //打开事件
            socket.onopen = function () {
                console.log("Socket 已连接");
            };
            //获得消息事件
            socket.onmessage = function (msg) {
                console.log(msg.data);
                $("#msg").append("接收到消息:" + msg.data + "</br>");
            };
            //关闭事件
            socket.onclose = function () {
                console.log("Socket已关闭");
            };
            //发生了错误事件
            socket.onerror = function () {
                alert("Socket发生了错误");
            }

            //关闭连接
            function closeWebSocket() {
                socket.close();
            }

            //发送消息
            function send() {
                var message = $('#text').val();
                socket.send(message);
            }
        }
    </script>
</head>
<body>
<div id="msg"></div>
<input id="text" type="text"/>
<button type="button" onclick="send()">发送消息测试</button>
</body>
</html>

二、后端 Websocket 实现

后端 Websocket 跟前端一样,也是实现 4 个事件,只不过可以通过给具体方法加注解进行实现,比较简单。

新建一个名称为 springboot_websocket 的 springboot 工程,其结构如下:

image

前端的 index.html 页面,放在 resources 下面的 static 目录下,springboot 默认将 static 目录下的文件作为可随意访问的前端文件。

首先看一下 pom 文件,springboot 已经集成了 websocket 依赖包,只需要引入 spring-boot-starter-websocket 即可。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.jobs</groupId>
    <artifactId>springboot_websocket</artifactId>
    <version>1.0</version>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.4.5</version>
    </parent>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.20</version>
        </dependency>
        <!--引入 WebSocket 依赖包-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>2.4.5</version>
            </plugin>
        </plugins>
    </build>
</project>

对于 application.yml 配置文件,只配置了后端服务启动的端口:

server:
  port: 8086

我们需要让 spring 容器管理 websocket 对象,暴露 websocket 服务,我这里将代码放在了启动类里面了,代码如下:

package com.jobs;

import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

@Slf4j
@SpringBootApplication
public class MainApp {
    public static void main(String[] args) {
        SpringApplication.run(MainApp.class, args);
        log.info("项目已经启动...");
    }

    //让 spring 容器管理 WebSocket,将服务服务暴露出去
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

自定义编写一个 WebSocketServer 类,里面编写相关方法,通过给方法加注解,实现 websocket 的相关事件:

package com.jobs.websocket;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

//配置 WebSocket 服务的连接地址,每次连接都会实例化一个对象
@ServerEndpoint(value = "/socket/{username}")
@Slf4j
@Component
public class WebSocketServer {

    //存储 username 与 Session 的对应关系,key 是 username
    private static Map<String, Session> sessionMap = new HashMap<String, Session>();

    //存储 Session 的 id 和 username 的对应关系
    private static Map<String, String> idnameMap = new HashMap<String, String>();


    //WebSocket 连接建立后调用该方法
    //注意:当前 Socket Session 属于长连接类型(有状态),因此不能持久化对象到数据库中
    @OnOpen
    public void onOpen(@PathParam("username") String username, Session session) {
        //根据 username 获取 Socket Session,如果一个用户重复连接,只保留该用户最后连接的 Socket Session
        Session userSession = sessionMap.get(username);
        if (userSession != null) {
            idnameMap.remove(userSession.getId());
            sessionMap.remove(username);
        }

        //存储 username 和 Socket Session 的对应关系
        sessionMap.put(username, session);
        //存储 Socket Session 的 ID 和 username 的对应关系
        idnameMap.put(session.getId(), username);
    }

    //关闭链接
    @OnClose
    public void onClose(Session session) {
        //根据 Scocket Session 的 ID 获取 username
        String username = idnameMap.get(session.getId());
        //移除用户信息
        sessionMap.remove(username);
        idnameMap.remove(session.getId());
    }

    //异常处理
    @OnError
    public void onError(Session session, Throwable throwable) {
        String username = idnameMap.get(session.getId());
        log.error("用户 " + username + " 的 WebSocket 通信发生了异常:" + throwable.getMessage());
    }

    //接收客户端发送过来的消息
    @OnMessage
    public void onMessage(Session session, String message) throws IOException {
        String username = idnameMap.get(session.getId());
        log.info("用户 " + username + " 接收到客户端发来的消息是:" + message);
        //同步给客户端发送消息
        session.getBasicRemote().sendText("服务端收到消息:" + message);
        //异步给客户端发送消息
        //session.getAsyncRemote().sendText("收到的消息:" + message);
    }


    //封住的消息发送方法,用于其它地方的服务端代码进行调用,给客户端发送消息
    public void sendMessage(String username, String message) throws IOException {
        //获取用户的 Socket Session 对象
        Session session = sessionMap.get(username);

        if (session != null) {
            //给指定会话发送消息
            session.getBasicRemote().sendText(message);
        }
    }
}

在上面的 WebSocketServer 类中,编写了一个 sendMessage 方法,可以在其他类中注入 WebSocketServer 对象,调用 sendMessage 方法就可以给前端页面发送消息,为了方便测试,这里编写了一个后端接口,可以通过 postman 或 apipost 工具进行调用,测试给前端发送消息:

package com.jobs.controller;

import com.jobs.dto.SendMsgDTO;
import com.jobs.websocket.WebSocketServer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.io.IOException;

@RestController
@RequestMapping("/test")
public class TestController {

    @Autowired
    private WebSocketServer webSocketServer;

    //通过后端代码,向指定用户发送信息,测试指定用户的前端页面是否可以收到信息
    @PostMapping("/sendmsg")
    public String send(@RequestBody SendMsgDTO sendMsgDTO) throws IOException {
        webSocketServer.sendMessage(sendMsgDTO.getUsername(), sendMsgDTO.getMsg());
        return "send success";
    }
}

上面的参数 SendMsgDTO 类只是为了传递参数数据,其内容如下:

package com.jobs.dto;

import lombok.Data;

@Data
public class SendMsgDTO {

    //用户名
    private String username;

    //发送的消息内容
    private String msg;
}

三、测试验证效果

启动 springboot 项目,由于我是本地启动,所以访问的域名地址是 localhost,使用 2 个不同的浏览器访问,代表 2 个不同的用户:

使用谷歌浏览器访问 http://localhost:8086?username=lisi 表示用户李四

使用 Edge 浏览器访问 http://localhost:8086?username=zhangsan表示用户张三

image

浏览器打开后,其实已经跟服务器建立了 websocket 建立了连接,此时通过文本框想服务器发送消息,能够得到服务器的实时响应:

image

我们使用 apipost 工具,通过 http://localhost:8086/test/sendmsg接口向 lisi 发送消息,lisi 的前端页面能够实时接收到数据,zhangsan 的前端页码则不会收到发给 lisi 的数据,如下图:

image

然后我们再使用 apipost 工具,通过 http://localhost:8086/test/sendmsg接口向 zhangsan 发送消息,zhangsan 的前端页面能够实时接收到数据,lisi 的前端页码则不会收到发给 zhangsan 的数据,如下图:

image


本篇博客的源代码下载地址为:https://files.cnblogs.com/files/blogs/699532/springboot_websocket.zip

posted @ 2025-04-13 10:30  乔京飞  阅读(1983)  评论(0)    收藏  举报