当前位置: 代码网 > it编程>编程语言>Java > Springboot超仔细整合websocket(附案例代码)

Springboot超仔细整合websocket(附案例代码)

2024年08月02日 Java 我要评论
添加依赖:确保在pom.xml中添加Spring WebSocket和WebSocket依赖。创建WebSocket处理器(端点):编写一个处理WebSocket消息的处理器。完成对应的生命周期如果需要传递http第一次握手时候处理信息 需要添加对应的处理配置配置WebSocket:配置WebSocket相关的Bean和端点(值得注意的是每一个端点对象对一个用户线程 所以spring的单实列bean和异步处理再这里无法生效 具体会在踩坑笔记中提及)

为什么

大家的项目一定有这种场景,系统发布公告,消息更更新,商家和客户私聊,这种场景,为了保证实时性总不能是http一直长轮询,所以就要用到今天说的websocket

为什么出现

websocket 的出现主要是为了解决 http 协议在实时通信方面的一些局限性:

  • 连接重用:http 协议在每次请求时都需要重新建立连接(http/1.1 之前),这在需要频繁通信的场景中效率很低。
  • 非实时性:传统的 http 请求-响应模型不能满足实时互动的需求,因为服务器无法主动向客户端推送信息。
  • 开销较大:每次 http 请求都会携带完整的头信息,增加了不必要的网络负载。

http的不足

  • 单工通信:http 是单工的,客户端发送请求后服务器才能响应,服务器不能主动发送消息。
  • 频繁的连接开销:每个 http 连接在传输完毕后通常都需要关闭,再次通信需要重新建立连接,这在需要频繁实时交互的应用中显得尤为低效。
  • 头部开销:http 请求和响应都包含大量的头部信息,这对于小数据包的传输非常不利。

并且由于http是单向的,必须有客户端发起请求,我们开发的服务端才会接收返回响应

常见的消息推送方式

轮询方式

在这里插入图片描述
sse
在这里插入图片描述

以及现在说的websocket
在这里插入图片描述

执行过程

因为websocket 也是从http升级而来,更改协议

先了解http的执行过程
  1. 建立连接:浏览器(客户端)通过网络向服务器发起一个 tcp 连接。其中包含3次握手

    • 第一次握手:客户端向服务器发送一个syn包,告诉服务器我要跟你建立连接。这个syn包里面包含了客户端的初始序列号。

    • 第二次握手:服务器收到syn包后,会回复一个syn+ack包给客户端。这个ack是确认客户端的syn包的,表示服务器已经收到了。同时,服务器也会发送一个自己的syn包给客户端,告诉客户端我也要跟你建立连接。

    • 第三次握手:客户端收到服务器的syn+ack包后,会再回复一个ack包给服务器。这个ack是确认服务器的syn包的,表示客户端也收到了服务器的建立连接请求。

  2. 发送 http 请求:客户端构建 http 请求,包括方法(get、post、put、delete 等)、uri、协议版本,以及必要的请求头和请求体请求发送到服务器。
    服务器处理请求:服务器接收到请求后,解析请求内容,并根据请求的资源和方法执行相应的动作(如从数据库检索数据、处理提交的表单等)。
    发送 http 响应:

  3. 服务器构建 http 响应,包括状态码(如 200 ok、404 not found)、响应头和响应体。
    响应发回到客户端。

  4. 关闭连接:在 http/1.0 中,默认情况下,服务器在发送响应后关闭 tcp 连接。http/1.1 支持持久连接(connection: keep-alive),允许多个请求和响应在同一个连接中传输,从而减少了建立和关闭连接的频率和成本。(这里包含四次挥手)

    • 第一次挥手:客户端向服务器发送一个fin包,通知将要断开连接了。

    • 第二次挥手:服务器收到fin包后,会回复一个ack回调包给客户端,表示已经收到了客户端的断开连接请求。

    • 第三次挥手:服务器在发送完所有数据后,会向客户端发送一个fin包,告诉客户端我也要断开连接了。

    • 第四次挥手:客户端收到服务器的fin包后,会回复一个ack包给服务器,表示已经收到了服务器的断开连接请求。

而websocket的过程可以通下面案列,出现请求状态101的就表示升级为web socket

比如gpt的回复页面
在这里插入图片描述

可以查看请求的消息,一般是初次响应的响应体
在这里插入图片描述

连接过程

客户端发起请求:客户端发送一个特殊的 http 请求,请求升级到 websocket。这个请求看起来像一个标准的 http 请求,但包含一些特定的头部字段来指示这是一个 websocket 升级请求:

  • upgrade: websocket:明确请求升级到 websocket。

  • connection: upgrade:指示这是一个升级请求。

  • sec-websocket-key:一个 base64 编码的随机值,服务器将用它来构造一个响应头,以确认连接的有效性。

  • sec-websocket-version:指示 websocket 协议的版本,通常是 13。
    服务器响应:如果服务器支持 websocket,并接受升级请求,则它会返回一个 http 101 switching protocols 响应,包含以下头部:

  • upgrade: websocket 和 connection: upgrade:确认升级到 websocket。

  • sec-websocket-accept:使用客户端的 sec-websocket-key 计算得出的一个值,用于验证连接。
    建立 websocket 连接:一旦握手成功,原始的 http 连接就升级到 websocket 连接。此时,客户端和服务器可以开始在这个长连接上双向发送数据。

数据传输:与 http 不同,websocket 允许服务器直接发送消息给客户端,而不需要客户端先发送请求,这对于需要实时数据更新的应用非常有用(例如在线游戏、交易平台等)。
接下来就是实现了,模拟消息聊天

代码实现

代码地址
前端页面(vue+vuetify ui) 有兴趣可以看看
在这里插入图片描述

实现步骤

先讲一下原理,之前说得websocket是双向通道,客户端连接服务端的端点,也就是没有一个连接就有一个端点实例
再java中是通过会话管理通道
建立过程可以知道,再建立连接之前会先进行握手,那么我们就可以再握手的时候对该线程用户进行验证,然后具体端点会有几个对应的生命周期 建立成功 接收到消息 连接关闭 连接异常我们就可以写对应事件,然后再建立成功时候,可以把对应建立成功的会话,再把对应的通道和用户ida进行保存,这样就可以根据id找到具体通道,进行发送消息。

代码逻辑客户端像服务端发起连接,服务端就要有一个路由对应这个连接进行处理,这个路由称为端点endpoint
所以核心就是写端点 完成端点生命周期

值得注意:我的boot 是3,jdk 17,虽然过程一样,但是springboot2关于网络编程这一块的包都是再javax ,3开始就是 jakarta包了

1.添加依赖

    <dependency>
            <groupid>org.springframework.boot</groupid>
            <artifactid>spring-boot-starter-websocket</artifactid>
        </dependency>
  1. 配置类
    作用是对端点进行扫描

@configuration
public class websocketconfig {
 
    @bean
    public serverendpointexporter serverendpointexporter(){
        return new serverendpointexporter();
    }


}

3.写端点配置类 (每个端点的作用不一样所以对端点的配置类也不一样)
集成该配置类进行重写 可以见名知意的发现:检查跨域 获取容器默认配置,获取端点实列都是一些等,而主要这里修改的就是 modifyhandshake(修改握手) 这里说的是还没有建立的时候,这里可以进行处理(比如保存用户信息,解析token,生成唯一info等等)
在这里插入图片描述

@configuration
public class gethttpsessionconfig  extends serverendpointconfig.configurator {
    /**
     * 注意:没有一个客户端发起握手,端点就有一个新的实列 那么引用的这个配置也是新的实列 所以内存地址不一样 这里sec的用胡属性也不同就不会产生冲突
     * 修改握手机制  就是第一次http发送过来的握手
     * @param sec   服务器websocket端点的配置
     * @param request
     * @param response
     */
    @override
    public void modifyhandshake(serverendpointconfig sec, handshakerequest request, handshakeresponse response) {
//        super.modifyhandshake(sec, request, response);
        httpsession httpsession =(httpsession) request.gethttpsession();
//        将从握手的请求中获取httpsession

        /**
         * 一般会在请求头中添加token 解析出来id作为键值对
         */
        map<string, object> properties = sec.getuserproperties();
        /**
         * 一个客户端和和服务器发起一次请求交互 就有一个唯一session
           *存储session 是为了能够从其中用户用户info 到时候作为wssession的key 但是session 不共享 为此redis改进 或者token也是可以的
         * 这里使用uuid作为标识
         */
//        properties.put(httpsession.class.getname(),httpsession);
        string sessionkey = uuid.randomuuid().tostring().replaceall("-", "");
        properties.put("connected",sessionkey);
    }
}


代码解析:该端点配置是再模拟新用户访问一个网站,网站主动给用户推送广告的情况,所以用户没有登录,就无法从httpsession,请求头,token,redis中获取个人信息了,所以这里使用的uuid,作为游客的websocketid
因为每有一个客户端建立websocket连接就有一个端点实列,一个端点配置,所以这里的 properties.put(“connected”,sessionkey);key值相同无妨

  1. 编写端点引用编写的端点配置类 注意每个周期的参数顺序不能错否则会报错
@slf4j
@component
@crossorigin(origins = "*")
@serverendpoint(value = "/chat",configurator = gethttpsessionconfig.class)//协议升级路由
public class chatendpoint {//只要和该路由建立连接 就new 一个新的实列 对应一个该endpoint对象
    //模拟储存当前用户的朋友全信息
    private  static final     map<string, session> friendgroup=new concurrenthashmap<string,session>();//线程安全的银蛇
   private httpsession httpsession;//存放当前用户信息
    /**
     * 定义的当前用户
     */
    private string userid;

    /**
     * 第一个参数必须是session
     * @param session
     * @param sec   不能是server
     */
    @onopen
    public void onopen(session session,endpointconfig sec){
//        1.保存当前连接用户状态
//每个端点获取该端点携带的httpsession数据
//    this.httpsession = (httpsession) sec.getuserproperties().get(httpsession.class.getname());
//    this.httpsession.getattribute("user");
        string sessionkey =(string) sec.getuserproperties().get("connected");
        this.userid=sessionkey;//用户上下文填充
//2.把成功建立升级的会话让放入会话组
        friendgroup.put(sessionkey,session);
//之所以获取http session 是为了获取获取httpsession中的数据 (用户名 /账号/信息)
        system.out.println("websocket建立成功");
//        2.广播消息(如果是好咧别表上下) 模拟放房间提示
        string content="用户id"+sessionkey+"已经上线 愉快玩耍吧";
        message message = message.builder()
                .content(content)
                .issystem(true).build();
        broadcast(message);
        system.out.println("websocket 连接建立成功: " + sessionkey);
//        3.

    }
    /**
     * 当断开连接
     * @param session
     */
    @onclose
    public void onclose(session session) {
        // 找到关闭会话对应的用户 id 并从 friendgroup 中移除
        string sessionkey = this.userid;

        if (sessionkey != null) {
            friendgroup.remove(sessionkey);

            // 广播消息给所有好友
            string content = "用户id " + sessionkey + " 已经下线";
            message message = message.builder()
                    .content(content)
                    .issystem(true)
                    .build();
            broadcast(message);
        }
    }

    /**
     * 这是接收和处理来自用户的消息的地方。我们需要在这里处理消息逻辑,可能包括广播消息给所有连接的用户。
     * @param //前端可以携带来自forname 但是我在这个实列化内部做了一个上下文
     *
     * 如果接收的消息的是对象 需要解码器,@pathparam("roomid") string roomid, 如果参数写在了第一位 那么就需要使用该注解获取路由的参数信息
     */
    @onmessage
    public void onmessage(session session,string message) throws ioexception {
        system.out.println("接收到消息"+message);
        message o = (message) json.parse(message);
        message message1 = message.builder().sender(userid)
                .toreceiver(o.gettoreceiver())
                .content(o.getcontent())
                .build();
        session session1 = friendgroup.get(userid);
        session1.getbasicremote().sendtext(json.tojsonstring(message1));
        // 你的其他逻辑
    }

    /**
     * 处理websocket中发生的任何异常。可以记录这些错误或尝试恢复。
     */
    @onerror
    public void onerror(throwable error) {
        system.out.println("onerror......"+error.getmessage());

    }


    /**
     * 将系统的公告等需要推送的消息发布给所有已经建立连接的用户
     * 用于系统更细发布公告之类的 或者用户上线通知其他
     * @param message
     */
    private  void broadcast(message message) throws runtimeexception {
        if (message.issystem()){
        friendgroup.entryset()
                .foreach(item->{
//                    遍历每一个键值对
                    session usersession = item.getvalue();
                    try {
                        usersession
                                .getbasicremote() //同步消息发送器
                                .sendtext(json.tojsonstring(message));
                    } catch (ioexception e) {
//                        记录日志 保存文件便于查看
                        throw new runtimeexception(e);
                    }
                    ;
                });
        return;
    }
        else{
            try {
                friendgroup.get(message.getsender())
                        .getbasicremote()
                        .sendtext(json.tojsonstring(message));
            } catch (ioexception e) {
                throw new runtimeexception(e);
            }
        }
    }
}

发消息的核心代码: friendgroup.get(message.getsender())
.getbasicremote()
.sendtext(json.tojsonstring(message));

端点中引用的消息对象:


/**
 * 定义的消息发送对象
 */
@data
@builder
public class message {
    //没有toname toname 是发送请求时候携带的

    //是否系统消息
    private boolean issystem;
    //私聊情况 :a->服务器->b显示      弹幕:a->服务器->广播 ->前端消息
     private string sender;//来自哪一位用户发的 如果是私聊
    private string content;  //消息内容
    //a->服务器 指定的发送私聊人 这里id
    private string toreceiver;
}

代码解读;
各个注解标识代表对应的生命周期
onopen建立成功:这里的逻辑就是读取配置类中的上下文,得到用户信息存入自身的上下文私有对象,然后用这个作为key,把管理socket的会话存入该map(线程安全:多个websocket会话 必须选这个),然后发布广公告
onclose :关闭前触发,移除会话组,然后提示
onerror:发生错误时候触发,一般记录日志
onmessage:接收到消息时候触发

很方便记忆的是前端也是这几个并且命名也一样
在这里插入图片描述
进行测试:通过alert打印出来
在这里插入图片描述
确实接收到服务器消息
在这里插入图片描述
日志也输出成功
在这里插入图片描述
当然这样就实现了上号时候服务器之前的群发,当然一对一私发也可以但是需要再请求头或者哪里携带身份凭证,握手测试demo 所以就是用的无携带凭证后端生成随机uuid作为会话id,所以为了演示点对点的私聊,这里就不做jwt了选择发送握手的时候携带参数

优化演示demo

前端演示
在这里插入图片描述

前端这里不想再做多用户了,直接修改钩子函数多创建几个窗口,让这些角色都再后台注册会话,写了俩个页面,一个明日香,一个真嗣

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

并且用字体颜色来表明俩人
修改后端端点代码

@slf4j
@component
@crossorigin(origins = "*")
@serverendpoint(value = "/chat/{username}",configurator = gethttpsessionconfig.class)//协议升级路由
public class chatendpoint {//只要和该路由建立连接 就new 一个新的实列 对应一个该endpoint对象
    //模拟储存当前用户的朋友全信息
    private  static final     map<string, session> friendgroup=new concurrenthashmap<string,session>();//线程安全的银蛇
   private httpsession httpsession;
    /**
     * 定义的当前用户
     */
    private string userid;

    /**
     * 第一个参数必须是session
     * @param session
     * @param sec   不能是server
     */
    @onopen
    public void onopen(session session,endpointconfig sec,@pathparam("username") string username){

        this.userid=username;//用户上下文填充
//2.把成功建立升级的会话让放入会话组
        string sessionkey=username;
        friendgroup.put(username,session);
//之所以获取http session 是为了获取获取httpsession中的数据 (用户名 /账号/信息)
        system.out.println("websocket建立成功");
//        2.广播消息(如果是好咧别表上下) 模拟放房间提示
        string content="用户id"+sessionkey+"已经上线 愉快玩耍吧";
        message message = message.builder()
                .content(content)
                .issystem(true).build();
        broadcast(message);
        system.out.println("websocket 连接建立成功: " + sessionkey);
//        3.

    }
    /**
     * 当断开连接
     * @param session
     */
    @onclose
    public void onclose(session session) {
        // 找到关闭会话对应的用户 id 并从 friendgroup 中移除
        string sessionkey = this.userid;

        if (sessionkey != null) {
            friendgroup.remove(sessionkey);

            // 广播消息给所有好友
            string content = "用户id " + sessionkey + " 已经下线";
            message message = message.builder()
                    .content(content)
                    .issystem(true)
                    .build();
            broadcast(message);
        }
    }

    /**
     * 这是接收和处理来自用户的消息的地方。我们需要在这里处理消息逻辑,可能包括广播消息给所有连接的用户。
     * @param //前端可以携带来自forname 但是我在这个实列化内部做了一个上下文
     *
     * 如果接收的消息的是对象 需要解码器,@pathparam("roomid") string roomid, 如果参数写在了第一位 那么就需要使用该注解获取路由的参数信息
     */
    @onmessage
    public void onmessage(session session,string message) throws ioexception {
        system.out.println("接收到消息"+message);
        jsonobject json = json.parseobject(message);
        // 从jsonobject中提取必要的字段
        string sender = json.getstring("sender");
        string content = json.getstring("content");
        string toreceiver = json.getstring("toreceiver");

        // 创建message对象
        message message1 = message.builder()
                .sender(sender)
//                .toreceiver(toreceiver) //发给谁这个信息无需填写
                .content(content)
                .build();
//调用发送方的会话 发送给他的客户端显示
        session session1 = friendgroup.get(toreceiver);
        session1.getbasicremote().sendtext(json.tojsonstring(message1));
        // 你的其他逻辑

        }

    /**
     * 处理websocket中发生的任何异常。可以记录这些错误或尝试恢复。
     */
    @onerror
    public void onerror(throwable error) {
        system.out.println("onerror......"+error.getmessage());

    }


    /**
     * 将系统的公告等需要推送的消息发布给所有已经建立连接的用户
     * 用于系统更细发布公告之类的 或者用户上线通知其他
     * @param message
     */
    private  void broadcast(message message) throws runtimeexception {
        if (message.issystem()){
        friendgroup.entryset()
                .foreach(item->{
//                    遍历每一个键值对
                    session usersession = item.getvalue();
                    try {
                        usersession
                                .getbasicremote() //同步消息发送器
                                .sendtext(json.tojsonstring(message));
                    } catch (ioexception e) {
//                        记录日志 保存文件便于查看
                        throw new runtimeexception(e);
                    }
                    ;
                });
        return;
    }
        else{
            try {
                friendgroup.get(message.getsender())
                        .getbasicremote()
                        .sendtext(json.tojsonstring(message));
            } catch (ioexception e) {
                throw new runtimeexception(e);
            }
        }
    }
}

主要修改是 请求时候直接再路由携带了用户名字,那么这里演示就不再使用uuid随机生成的key来管理会话了,使用用户名字来管理会话模拟好友,实际开发还是需要再握手的时候解析http请求数据来存储关键信息哈

端点携带参数的形式很想restful风格

/路由/{参数名}
@pathparam("username") string username

私聊逻辑
发送私聊的一对一实现原理 发送消息时候携带需要发送的对象key,通过该健获取该用户的会话然后发送信息,这里的key是用户名

  @onmessage
    public void onmessage(session session,string message) throws ioexception {
        system.out.println("接收到消息"+message);
        jsonobject json = json.parseobject(message);
        // 从jsonobject中提取必要的字段
        string sender = json.getstring("sender");
        string content = json.getstring("content");
        string toreceiver = json.getstring("toreceiver");

        // 创建message对象
        message message1 = message.builder()
                .sender(sender)
//                .toreceiver(toreceiver) //发给谁这个信息无需填写
                .content(content)
                .build();
//调用发送方的会话 发送给他的客户端显示
        session session1 = friendgroup.get(toreceiver);
        session1.getbasicremote().sendtext(json.tojsonstring(message1));
        // 你的其他逻辑

        }

前端代码:这里写了发送方数据是为了前端渲染页面,如果再其他里面做了处理,可以不用写发送方数据,减少负载,主要是发送消息对容和对方的会话存储的key
在这里插入图片描述

异步优化

当出现高并发等高性能需求时候,可以采用异步发发送器,让线程不在这里堵塞
替换为asycremote
在这里插入图片描述

测试:
当明日香发送你好时候
在这里插入图片描述

真嗣用户可以成功收到,并且回复也可以收到
在这里插入图片描述
就此完成实现,

总结步骤

添加依赖:确保在pom.xml中添加spring websocket和websocket依赖。

创建websocket处理器(端点):编写一个处理websocket消息的处理器。
完成对应的生命周期
如果需要传递http第一次握手时候处理信息 需要添加对应的处理配置

配置websocket:配置websocket相关的bean和端点(值得注意的是每一个端点对象对一个用户线程 所以spring的单实列bean和异步处理再这里无法生效 具体会在踩坑笔记中提及)整合的一些细节

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2025  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com