SpringBoot - WebSocket的使用和聊天室练习

news2025/1/4 22:06:08

SpringBoot - WebSocket的使用和聊天室练习

  • 前言
  • 一. SpringBoot整合WebSocket
    • 1.1 (插曲)SpringCloud网关服务接入WebSocket启动错误
  • 二. 前端代码监听
    • 2.1 模拟进入/离开聊天室
    • 2.2 模拟聊天

前言

近期准备在我的个人云直播项目中,编写弹幕模块。前期我写的功能全都是在Egg当中完成的(整合了Socket功能),也留下了不少问题。后期准备对这块内容做一个系统性地升级。

  1. 还是准备把后端逻辑写到Java里面,拓展性和相关的APINodeJs要好一点。
  2. 每一个聊天室打开,就相当于Egg服务器和Java服务器之间建立了一条长链接WebSocket。(可能后续也有所更改)
  3. Java这里,对弹幕数据丢到MQ中,做到削峰处理。消费对应的Q,做持久化、缓存处理。并将结果进行封装,分发给对应直播间的所有用户,
  4. 前端则进行Q的监听,监听的数据就是弹幕了。

上面都是个人的一些设想,本篇文章不涉及,先做JavaNodeJs之间的一个点对点的WebSocket服务。完成一个简单的聊天室功能。

前端有现成的架构:Egg源码gitee。

一. SpringBoot整合WebSocket

1.pom依赖:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.3.12.RELEASE</version>
</parent>
<dependencies>
    <!-- WebSocket依赖 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>
    <!-- 省略get/set等方法 -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.10</version>
        <scope>provided</scope>
    </dependency>
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-lang3</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>1.2.83</version>
    </dependency>
</dependencies>

2.配置一下WebSocket

@Configuration
public class WebSocketConfig {
    @Bean
    public ServerEndpointExporter serverEndpointExporter(){
        return new ServerEndpointExporter();
    }
}

3.创建一个服务端发送给客户端的实体类对象SendMessageEntity

@Data
@NoArgsConstructor
@AllArgsConstructor
public class SendMessageEntity {
    private String userId;
    private String message;
    private Long onLineCount;
    /** 1:初始化,2:弹幕发送 */
    private int operateType;
}

4.业务类代码BulletScreenService:本文案例中,使用本地缓存来保存WebSocket信息。

import com.alibaba.fastjson.JSONObject;
import com.model.SendMessageEntity;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;

import javax.websocket.OnClose;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

/**
 * @Date 2022/12/8 15:46
 * @Created by jj.lin
 */
@Component
@ServerEndpoint("/live/{roomId}/{userId}")
@Slf4j
public class BulletScreenService {
    /**
     * 当前长连接的数量(在线人数的统计)
     * 也就是当前有多少客户端通过WebSocket连接到服务端
     */
    private static final ConcurrentHashMap<String, AtomicLong> ONLINE_COUNT = new ConcurrentHashMap<>(1000);

    /**
     * 一个客户端(SessionID) 关联 一个 BulletScreenService
     * 如果页面关闭或者刷新,SessionID都会重新创建一个,默认单调递增的数字(String)
     * BulletScreenService包含了用户ID、直播间ID
     */
    private static final ConcurrentHashMap<String, BulletScreenService> WEBSOCKET_MAP = new ConcurrentHashMap<>(1000);

    private Session session;
    private String sessionId;
    private String userId;
    private String roomId;

    /**
     * 打开连接
     *
     * @param session
     * @OnOpen 连接成功后会自动调用该方法
     * @PathParam("token") 获取 @ServerEndpoint("/imserver/{userId}") 后面的参数
     */
    @OnOpen
    public void openConnection(Session session, @PathParam("roomId") String roomId, @PathParam("userId") String userId) {
        // 如果是游客观看视频,虽然有弹幕,但是没有用户信息,所以需要用try
        this.userId = userId;
        this.roomId = roomId;
        // 保存session相关信息到本地
        this.sessionId = session.getId();
        this.session = session;

        // 判断WEBSOCKET_MAP 是否含有sessionId,有的话先删除再重新添加,一般不会重复
        if (WEBSOCKET_MAP.containsKey(sessionId)) {
            WEBSOCKET_MAP.remove(sessionId);
            WEBSOCKET_MAP.put(sessionId, this);
        } else { // 没有的话就直接新增
            WEBSOCKET_MAP.put(sessionId, this);
            // 在线人数加一
            addOnlineCount(roomId);
            log.info("*************WebSocket: {} 链接成功*************", this.sessionId);
        }
        // 发送消息,更新在线人数
        sendMessage("", 1);
    }

    public void addOnlineCount(String roomId) {
        AtomicLong count = ONLINE_COUNT.get(roomId);
        if (count == null) {
            AtomicLong atomicLong = new AtomicLong(1);
            ONLINE_COUNT.put(roomId, atomicLong);
        } else {
            count.incrementAndGet();
        }
    }

    public void decrementOnlineCount() {
        AtomicLong count = ONLINE_COUNT.get(this.roomId);
        if (count == null) {
            return;
        } else {
            count.getAndDecrement();
        }
    }

    /**
     * 客户端刷新页面,或者关闭页面,服务端断开连接等等操作,都需要关闭连接
     */
    @OnClose
    public void closeConnection() {
        if (WEBSOCKET_MAP.containsKey(sessionId)) {
            WEBSOCKET_MAP.remove(sessionId);
            // 在线人数减一
            decrementOnlineCount();
            // 发送消息,更新在线人数
            sendMessage("", 1);
            log.info("*************WebSocket: {} 关闭成功*************", this.sessionId);
        }
    }

    /**
     * 客户端发送消息给服务端
     *
     * @param message
     */
    @OnMessage
    public void onMessage(String message) {
        if (StringUtils.isBlank(message)) {
            return;
        }
        // 发送消息,更新在线人数以及弹幕
        sendMessage(message, 2);
    }

    // 后端发送信息给前端
    void sendMessage(String message, int operateType) {
        try {
            for (Map.Entry<String, BulletScreenService> entry : WEBSOCKET_MAP.entrySet()) {
                // 获取每一个和服务端连接的客户端
                BulletScreenService webSocketService = entry.getValue();
                // 过滤掉关闭状态的会话以及非同一个roomId的链接
                if (!webSocketService.session.isOpen()
                        || !StringUtils.equalsIgnoreCase(webSocketService.roomId, this.roomId)) {
                    continue;
                }
                // 给同一个room下的所有连接发送信息
                SendMessageEntity sendMessageEntity = new SendMessageEntity();
                sendMessageEntity.setMessage(message);
                sendMessageEntity.setUserId(this.userId);
                AtomicLong count = ONLINE_COUNT.get(webSocketService.roomId);
                sendMessageEntity.setOnLineCount(count == null ? 0 : count.longValue());
                sendMessageEntity.setOperateType(operateType);

                webSocketService.session.getBasicRemote().sendText(JSONObject.toJSONString(sendMessageEntity));
                log.info("给客户端: {} 发送消息成功", webSocketService.session.getId());
            }
        } catch (Exception e) {
            log.error("sendMessage", e);
        }
    }
}

其中几种重要的注解:

  • @OnMessage:监听客户端发送到服务端的消息。
  • @OnOpen:监听客户端和服务端之间建立新的链接。
  • @OnClose:监听客户端和服务端之间的链接断开。

5.配置文件application.yml

server:
  port: 8080

1.1 (插曲)SpringCloud网关服务接入WebSocket启动错误

如果在SpringCloud中的网关服务中,引用websocket,那么启动的时候可能会发生如下错误:
在这里插入图片描述
解决方案:在gateway依赖中,排除掉web以及webflux

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </exclusion>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </exclusion>
    </exclusions>
</dependency>

二. 前端代码监听

1.写一个工具类pageHelper,获取URL上参数的:
在这里插入图片描述
代码如下:

export function getValueByParam(param: string): any {
  const url = window.location.href;
  const queryParams = url.split('?');
  if (queryParams?.length < 2) {
    return '';
  }
  const queryList = queryParams[1].split('&');
  for (const key of queryList) {
    if (key.split('=')[0] === param) {
      return key.split('=')[1];
    }
  }
  return '';
}

2.修改前端页面index.tsx

import React, { useEffect, useState } from 'react';
import { Button, Row, Col, Input } from 'antd';
import { getValueByParam } from '../utils/pageHelper';

const ws = new WebSocket(`ws://localhost:8080/live/${getValueByParam('roomId')}/${getValueByParam('userId')}`);

const UserPage = () => {
  const [ message, setMessage ] = useState<string>('');
  const [ bulletList, setBulletList ] = useState<any>([]);
  const [ onlineCount, setOnlineCount ] = useState<number>(0);

  useEffect(() => {
    ws.onopen = () => {
      ws.onmessage = (msg: any) => {
        const entity: any = JSON.parse(msg.data);
        if (entity?.operateType === 2) {
          const arr :any = [ `用户[${entity.userId}]: ${entity.message}` ];
          setBulletList((pre: any[]) => [].concat(...pre, ...arr));
        }
        setOnlineCount(entity?.onLineCount ?? 0);
      };
    };
    ws.onclose = () => {
      console.log('断开连接');
    };
  }, []);

  const sendMsg = () => {
    ws?.send(message);
  };

  return <>
    <Row style={{ width: 2000, marginTop: 200 }}>
      <Col offset={6}>
        <Input onChange={event => setMessage(event.target.value)} />
      </Col>
      <Col>
        <Button
          onClick={sendMsg}
          type='primary'
        >发送弹幕</Button>
      </Col>
      <Col style={{ marginLeft: 100 }}>
        {'在线人数: ' + onlineCount}
      </Col>
      <Col style={{ marginLeft: 10 }}>
        <div style={{ border: '1px solid', width: 500, height: 500 }}>
          {bulletList.map((item: string, index: number) => {
            return <Row key={index}>
              {item}
            </Row>;
          })}
        </div>
      </Col>
    </Row>
  </>;
};

export default UserPage;

然后可以运行项目了,npm run dev,打开以下地址:

  • http://localhost:4396/zong/?userId=10086&roomId=1
  • http://localhost:4396/zong/?userId=10010&roomId=1

你会发现服务器中输出以下日志:
在这里插入图片描述

2.1 模拟进入/离开聊天室

目前有两个窗口,在线人数应该是2,如果再打开一个窗口,roomId是同一个,看看会发生什么?如果rommId不是同一个,数量还会加1吗?在这里插入图片描述

可见:

  • 当有新的用户进入相同的直播间的时候,直播在线人数会+1。
  • 用户进入不同的直播间,直播在线人数也是独立开的。

2.2 模拟聊天

在这里插入图片描述

文章到这里就结束了。案例很简单。但是有几个问题值得思考。

  1. 案例是使用本地缓存来存储WebSocket的,一个真实的直播系统,往往在线人数可能有几百万的时候,难不成在HashMap中存几百万的数据吗?而且还不考虑到其扩容带来的性能消耗。我们应该使用第三方库去存储这种信息。
  2. 弹幕流量很高的时候,就是高并发。使用WebSocket去传输信息还能顶得住吗?
  3. 案例中向同一个直播间的人发送消息,采取的是for循环发送的。如果后续还需要对消息进行持久化、过滤操作等处理,这样写就不合适了。

持续更新。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/75640.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

超详细的 pytest 教程(一)使用入门篇

前言 pytest到目前为止还没有翻译的比较好全面的使用文档&#xff0c;很多英文不太好的小伙伴&#xff0c;在学习时看英文文档还是很吃力。本来去年就计划写pytest详细的使用文档的&#xff0c;由于时间关系一直搁置&#xff0c;直到今天才开始写。本文是第一篇&#xff0c;主…

基于jsp+mysql+ssm校园在线投票系统-计算机毕业设计

项目介绍 校园在线投票系统主要包括系统用户管理模块、新闻公告管理模块、投票信息管理、班级信息管理、登录模块、和退出模块等多个模块。系统采用了jsp的mvc框架,SSM(springMvcspringMybatis)框架进行开发,本系统是独立的运行&#xff0c;不依附于其他系统&#xff0c;可移植…

Apipost下载安装和功能详解

一、ApiPost简介 ApiPost PostMan Swagger Mock Jmeter 后端、前端、测试同时在线编辑&#xff0c;同时在线协作&#xff0c;内容实时同步 1.官网地址&#xff1a;https://www.apipost.cn/ 2.官网支持客户端下载及web版在线调试&#xff0c;可根据自己的需求选择适合自己的…

@Controller和@RestController的区别?

本文为joshua317原创文章,转载请注明&#xff1a;转载自joshua317博客 Controller和RestController的区别&#xff1f; - joshua317的博客 Controller和RestController的区别&#xff1f; 在springboot开发中控制层使用注解Controller时&#xff0c;加有GetMapping(PostMappi…

网络开关量输入转4G模块钡铼技术S274

网络开关量输入转4G模块钡铼技术S274支持4路DIN4路DO1路RS485&#xff0c;本机设备自带的继电器 DO 寄存器地址属于保持线圈&#xff0c;地址 0-3&#xff0c;服务器主站发送报文格式&#xff1a; 接收设备返回报文格式&#xff1a; 示例&#xff1a;读取2 个DO状态&#xff…

FS5175AE的PCB布局设计建议

FS5175AE的PCB布局设计建议-基础篇 开关电源的一个常见问题是“不稳定”的开关波形。有时,波形抖动很明显,可以听到从磁性元件发出噪声。如果问题与印刷电路板(PCB)布局有关,则很难确定原因。 EMC也是很注重(PCB)布局,这就是为什么在开关电源设计的早期正确布局PCB至关重要的原…

React 学习笔记:事件处理

React 事件处理 React 采用 on 事件名的方式来绑定一个事件&#xff0c;注意&#xff0c;这里和原生的事件是有区别的&#xff0c;原生的事件全是小写 onclick , React 里的事件是驼峰 onClick 。并且 React 中若想阻止默认事件需要显示的调用 e.preventDefault&#xff0c;而…

智工教育:2023年安全工程师考试习题

答案在最后 1.依据中共中央国务院《关于推进安全生产领域改革发展的意见》&#xff0c;到( )年&#xff0c;实现安全生产治理体系和治理能力现代化&#xff0c;全民安全文明素质全面提升&#xff0c;安全生产保障能力显著增强&#xff0c;为实现中华民族伟大复兴的中国梦奠定稳…

Linux系统IO

文章目录Linux系统IOsysio简介sysio版本的copy示例代码代码说明函数讲解如何编译 运行系统IO与标准IO的区别示例代码函数讲解编译 运行&#xff1f;程序中的重定向代码示例代码说明函数讲解编译 运行&#xff1f;代码示例函数讲解编译 运行Linux系统IO sysio简介 所谓文件IO就…

全国青少年软件编程(Scratch)等级考试二级考试真题2022年6月——持续更新.....

电子学会202206Scratch二级真题及参考答案 1.角色初始位置如图所示&#xff0c;下面哪个选项能让角色移到舞台的左下角&#xff1f;&#xff08; &#xff09; A. B. C. D. 正确答案&#xff1a;C 答案解析&#xff1a; 舞台的左下角&#xff0c;坐标x为负数&#xff0c;…

【数据结构】七大排序

目录 一、什么是稳定性 二、七大排序 2.1基于选择的思想 2.1.1直接选择排序 2.1.2堆排序 2.2基于插入的思想 2.2.1直接插入排序 2.2.2希尔排序 2.3归并排序 2.4基于交换的思想 2.4.1冒泡排序 2.4.2快速排序 三、外部排序 排序就是将一组无序的数据经过一定的算法调…

Linux系统:root用户 登录失败

问题 在Linux系统上&#xff0c;从root用户切换到oracle用户时报错 su: cannot open session: Permission denied 如下&#xff1a; 分析 定位原因1 分析登录日志&#xff0c;可以看到时登录的时候limit中的 memlock 设置失败&#xff0c;导致用户登录失败&#xff1a; limi…

[GO] Gin入门

1. Gin基本使用 1.1 Gin入门 Gin是一个golang的微框架,封装比较优雅,API友好,源码注释比较明确,具有快速灵活,容错方便等特点对于Golang而言,web框架的依赖要远比Python,Java之类要小,自身的net/http足够简单,性能也非常不错借助框架开发,不仅可以省去很多常用的封装带来的时…

为本地web服务配置使用固定的二级子域名【内网穿透】

由于使用免费的cpolar所生成的公网地址为随机临时地址&#xff0c;该地址24小时内会发生变化&#xff0c;对于需要长期访问的用户来讲比较不方便。 不过我们可以为其配置cpolar固定的二级子域名&#xff08;该二级子域名可自定义&#xff09;&#xff0c;该地址不会随机变化&a…

芯片漫游指南(2)-- UVM结构

目录&#xff1a;1 组件家族1.1概述1.2 uvm_driver1.2.1 概述1.2.2 示例1.3 uvm_monitor1.3.1 概述1.3.2 示例1.4 uvm_sequencer1.4.1 概述1.4.2 示例1.5 uvm_agent1.5.1 概述1.5.2 示例1.6 uvm_scoreboard1.6.1 概述1.6.2 示例1.7 uvm_env1.7.1 概述1.7.2 示例1.8 uvm_test1.8…

天翎携手群晖助力电商行业文档管理

编者按&#xff1a;电商行业的文档管理怎么做&#xff1f;本文根据电商行业文档管理中存在的一些难点&#xff0c;提出天翎文档管理系统和群晖NAS结合的解决方案。 关键词&#xff1a;免安装&#xff0c;免维护&#xff0c;文件分类&#xff0c;权限设置&#xff0c;文件同步&…

【拿捏链表(Ⅱ)】—Leetcode删除排序链表中的重复元素

目录删除排序链表中的重复元素(Ⅰ)删除排序链表中的重复元素(Ⅱ)删除排序链表中的重复元素(Ⅰ) 题目&#xff1a; 给定一个已排序的链表的头 head &#xff0c;删除所有重复的元素&#xff0c;使每个元素只出现一次 。返回 已排序的链表 。 思路&#xff1a;这里的思路很简单&…

Navigation--导航算法(局部视野导航)--DWA、TAB

DWA 动态窗口法&#xff08;dynamic window approach&#xff0c;DWA&#xff09;目前与A*一样都是ROS导航包中提供的基本路径规划算法。DWA是一种贪心的算法&#xff0c;通过可选速度、可选角速度的组合&#xff0c;模拟出很多局部轨迹&#xff0c;然后选择最优的。这种方法时…

Android dex动态加载(Kotlin版)

前言 环境 语言–KotlinJDK11SDK33AndroidStudio版本 Android Studio Dolphin | 2021.3.1 Patch 1 Build #AI-213.7172.25.2113.9123335, built on September 30, 2022概述 libaray项目打包成jarjar通过dx命令行工具转为dex.jardex.jar放到assets目录下App启动读取assets中…

外贸业务12年,我想和大家分享这几点感受

如今再回看这段经历&#xff0c;很庆幸我的三观一直都很正确&#xff0c;那就是买家第一。 不管是什么原因&#xff0c;只要你想退&#xff0c;我都可以接受退&#xff0c;我不能退回上级供应商的那我就自己留着&#xff0c;只为了不想因为这一次拒绝而失去这个买家&#xff1…