注解式 WebSocket - 构建 群聊、单聊 系统

目录

前言

注解式 WebSocket 构建聊天系统

群聊系统(基本框架)

群聊系统(添加昵称)

单聊系统

WebSocket 作用域下无法注入 Spring Bean 对象?

考虑离线消息


前言


很久之前,咱们聊过 WebSocket 编程式的写法,但是有些过于繁琐,这次来看看更接近现代的注解式,构建 群聊、单聊 有多么便利.

注解式 WebSocket 构建聊天系统


群聊系统(基本框架)

a)定义 WebSocket 配置类.

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.socket.server.standard.ServerEndpointExporter

/**
 * 注入对象 ServerEndpointExporter
 * 这个 bean 会自动注册使用了 @ServerEndpoint 注解声明的 WebSocket endpoint
 */

@Configuration
class WebSocketConfig {

    @Bean
    fun serverEndpointExporter() = ServerEndpointExporter()

}

b)WebSocket 实现类

import org.springframework.stereotype.Component
import java.util.concurrent.CopyOnWriteArraySet
import javax.websocket.OnClose
import javax.websocket.OnError
import javax.websocket.OnMessage
import javax.websocket.OnOpen
import javax.websocket.Session
import javax.websocket.server.ServerEndpoint

/**
 * 虽然此处 @Component 默认是单例的,但是 SpringBoot 还是会为每个 WebSocket 初始化一个 bean,
 * 因此可以使用一个静态的 Set 保存起来(CopyOnWriteArraySet 相比于 HashSet 是线程安全的)
 */
@ServerEndpoint(value = "/websocket")
@Component
class MyWebSocket {

    companion object {
        //用来存放每个客户端对应的 MyWebSocket 对象
        private val webSocketSet = CopyOnWriteArraySet<MyWebSocket>()
    }

    //与某个客户都安连接的会话,需要通过他来给客户都安发送数据
    private lateinit var session: Session

    /**
     * 连接成功调用的方法
     */
    @OnOpen
    fun onOpen(session: Session) {
        //获取当前连接客户端 session
        this.session = session
        //加入到 set 中
        webSocketSet.add(this)
        println("当前在线人数为: ${webSocketSet.size}")
        this.session.asyncRemote.sendText("恭喜您成功连接上 WebSocket,当前在线人数为: ${webSocketSet.size}")
    }

    /**
     * 收到客户端消息时调用的方法
     */
    @OnMessage
    fun onMessage(message: String, session: Session) {
        println("收到客户端的消息: $message")
        //群发消息
        allSend(message)
    }

    @OnError
    fun onError(session: Session, error: Throwable) {
        println("连接异常")
        error.printStackTrace()
    }

    @OnClose
    fun onClose() {
        webSocketSet.remove(this)
        println("有人下线!当前在线人数: ${webSocketSet.size}")
    }

    /**
     * 自定义群发消息
     * basicRemote: 阻塞式
     * asyncRemote: 非阻塞式
     * 大部分情况下更推荐使用 asyncRemote, 详情: https://blog.csdn.net/who_is_xiaoming/article/details/53287691
     */
    private fun allSend(message: String) {
        webSocketSet.forEach {
            //it.session.basicRemote.sendText(message)
            it.session.asyncRemote.sendText(message)
        }
    }

}

c)客户端开发

<!DOCTYPE HTML>
<html>

<head>
  <meta charset="UTF-8">
  <title>My WebSocket</title>
  <style>
    #message {
      margin-top: 40px;
      border: 1px solid gray;
      padding: 20px;
    }
  </style>
</head>

<body>
  <button onclick="conectWebSocket()">连接WebSocket</button>
  <button onclick="closeWebSocket()">断开连接</button>
  <hr />
  <br />
  消息:<input id="text" type="text" />
  <button onclick="send()">发送消息</button>
  <div id="message"></div>
</body>
<script type="text/javascript">

  var websocket = null;

  function conectWebSocket() {
    //判断当前浏览器是否支持WebSocket
    if ('WebSocket' in window) {
      websocket = new WebSocket("ws://localhost:9000/websocket");
    } else {
      alert('Not support websocket')
    }

    //连接发生错误的回调方法
    websocket.onerror = function () {
      setMessageInnerHTML("error");
    };

    //连接成功建立的回调方法
    websocket.onopen = function (event) {
      setMessageInnerHTML("tips: 连接成功!");
    }

    //接收到消息的回调方法
    websocket.onmessage = function (event) {
      setMessageInnerHTML(event.data);
    }

    //连接关闭的回调方法
    websocket.onclose = function () {
      setMessageInnerHTML("tips: 关闭连接");
    }

    //监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
    window.onbeforeunload = function () {
      websocket.close();
    }

  }

  //将消息显示在网页上
  function setMessageInnerHTML(innerHTML) {
    document.getElementById('message').innerHTML += innerHTML + '<br/>';
  }


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

  //发送消息
  function send() {
    var message = document.getElementById('text').value;
    websocket.send(message);
  }

</script>

</html>

d)效果如下:

打开两个浏览器,依次点击建立连接

左边的浏览器中输入:"你好,我是 cyk",效果如下

群聊系统(添加昵称)

上述聊天系统中可以看到,并不知道当前消息是哪一个用户发出的,因此这里我们改造一下,让每个消息前携带用户名.

a)客户端改造:在用户点击 "连接 WebSocket" 之前输入昵称,以此作为消息的身份标识.

b)服务端改造:

之后在 WebSocket 注解标记的每一个方法中,都可以通过 @PathParam("nickname") nickname: String 获取到 nickname.

尽管如此,再上图中我还是使用成员变量 nickname 在 WebSocket 第一次建立连接的时候通过 @onOpen 标记的方法进行保存. 如下:

    /**
     * 连接成功调用的方法
     */
    @OnOpen
    fun onOpen(
        session: Session,
        @PathParam("nickname") nickname: String
    ) {
        //获取当前连接客户端 session
        this.session = session
        this.nickname = nickname
        //加入到 set 中
        webSocketSet.add(this)
        println("$nickname 上线,当前在线人数为: ${webSocketSet.size}")
        allSend("系统消息: $nickname 上线!")
    }

发送的消息携带上昵称

    @OnMessage
    fun onMessage(message: String, session: Session) {
        println("收到客户端的消息: $message")
        //群发消息
        allSend("$nickname: $message")
    }

c)效果如下:

单聊系统

a)服务器开发:需要通过一个 map 来记录用户的 session 信息(key:用户唯一标识,value: session)

ChatMsg:用来接收客户端传入的 JSON 消息(通过 ObjectMapper 反序列化).

onOpen:记录用户信息到 map 中.

opMessage:将消息转发给目标人物.

import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.stereotype.Component
import java.util.concurrent.ConcurrentHashMap
import javax.websocket.OnClose
import javax.websocket.OnError
import javax.websocket.OnMessage
import javax.websocket.OnOpen
import javax.websocket.Session
import javax.websocket.server.PathParam
import javax.websocket.server.ServerEndpoint

data class ChatMsg (
    val targetName: String = "", //目标
    val msg: String = "",        //消息
)

/**
 * 虽然此处 @Component 默认是单例的,但是 SpringBoot 还是会为每个 WebSocket 初始化一个 bean,
 * 因此可以使用一个静态的 Set 保存起来(CopyOnWriteArraySet 相比于 HashSet 是线程安全的)
 */
@ServerEndpoint(value = "/websocket/{nickname}")
@Component
class MyWebSocket {

    companion object {
        //用来存放每个客户端对应的 MyWebSocket 对象
        private val webSocketMap = ConcurrentHashMap<String, Session>()
    }

    //与某个客户都安连接的会话,需要通过他来给客户都安发送数据
    private lateinit var session: Session //用来记录当前连接者会话
    private lateinit var nickname: String

    /**
     * 连接成功调用的方法
     */
    @OnOpen
    fun onOpen(
        session: Session,
        @PathParam("nickname") nickname: String
    ) {
        //获取当前连接客户端 session
        this.session = session
        this.nickname = nickname
        //加入到 set 中
        webSocketMap[nickname] = session
        println("$nickname 上线,当前在线人数为: ${webSocketMap.size}")
        allSend("系统消息: $nickname 上线!")
    }

    /**
     * 收到客户端消息时调用的方法
     */
    @OnMessage
    fun onMessage(messageJson: String, session: Session) {
        println("收到客户端的消息: $messageJson")
        //单独发送消息
        val mapper = ObjectMapper()
        val message = mapper.readValue(messageJson, ChatMsg::class.java)
        val targetSession = webSocketMap[message.targetName]
        val postSession = this.session
        if(targetSession == null) {
            postSession.asyncRemote.sendText("当前用户不存在或者不在线!")
        } else {
            postSession.asyncRemote.sendText("${nickname}: ${message.msg}") //发送者获取自己的消息
            targetSession.asyncRemote.sendText("${nickname}: ${message.msg}") //接收者获取发送者的消息
        }
    }

    @OnError
    fun onError(session: Session, error: Throwable) {
        println("连接异常")
        error.printStackTrace()
    }

    @OnClose
    fun onClose() {
        webSocketMap.remove(nickname)
        println("${nickname} 下线!当前在线人数: ${webSocketMap.size}")
        allSend("系统消息: $nickname 下线!")
    }


    /**
     * 自定义群发消息
     * basicRemote: 阻塞式
     * asyncRemote: 非阻塞式
     * 大部分情况下更推荐使用 asyncRemote, 详情: https://blog.csdn.net/who_is_xiaoming/article/details/53287691
     */
    private fun allSend(message: String) {
        webSocketMap.forEach {
            it.value.asyncRemote.sendText(message)
        }
    }

}

b)客户端开发

<!DOCTYPE HTML>
<html>

<head>
  <meta charset="UTF-8">
  <title>My WebSocket</title>
  <style>
    #message {
      margin-top: 40px;
      border: 1px solid gray;
      padding: 20px;
    }
  </style>
</head>

<body>
  <div>
    <span>昵称: </span>
    <input type="text" id="nickname">
  </div>
  <button onclick="conectWebSocket()">连接WebSocket</button>
  <button onclick="closeWebSocket()">断开连接</button>
  <hr />
  <br />

  <div>
    <span>targetName: </span>
    <input type="text" id="targetName">
  </div>

  <div>
    <span>消息: </span>
    <input id="text" type="text" />
  </div>
  <button onclick="send()">发送消息</button>
  <div id="message"></div>
</body>
<script type="text/javascript">

  var websocket = null;

  function conectWebSocket() {
    //判断当前浏览器是否支持WebSocket
    if ('WebSocket' in window) {
      let nickname = document.getElementById("nickname").value
      if (nickname == null || nickname == "") {
        alert("请先输入昵称!")
        return
      }
      websocket = new WebSocket("ws://localhost:9000/websocket/" + nickname);
    } else {
      alert('Not support websocket')
    }

    //连接发生错误的回调方法
    websocket.onerror = function () {
      setMessageInnerHTML("error");
    };

    //连接成功建立的回调方法
    websocket.onopen = function (event) {
      setMessageInnerHTML("tips: 连接成功!");
    }

    //接收到消息的回调方法
    websocket.onmessage = function (event) {
      setMessageInnerHTML(event.data);
    }

    //连接关闭的回调方法
    websocket.onclose = function () {
      setMessageInnerHTML("tips: 关闭连接");
    }

    //监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
    window.onbeforeunload = function () {
      websocket.close();
    }

  }

  //将消息显示在网页上
  function setMessageInnerHTML(innerHTML) {
    document.getElementById('message').innerHTML += innerHTML + '<br/>';
  }


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

  //发送消息
  function send() {
    var message = document.getElementById('text').value;
    var targetName = document.getElementById('targetName').value;
    var chatMsg = {
      "targetName": targetName,
      "msg": message
    }
    websocket.send(JSON.stringify(chatMsg));
  }

</script>

</html>

 

WebSocket 作用域下无法注入 Spring Bean 对象?

这是因为 Spring 管理 Bean 对象默认都是单例的,而 WebSocket 却是多例的,因此注入 Spring 中的 Bean 对象会冲突. 

解决办法:通过 set 方法注入一个静态的 Bean 即可.

@ServerEndpoint("/websocket/{id}")
@Component
class ChatRoom {

    companion object {
        private lateinit var userInfoRepo: UserInfoRepo
    }

    @Resource
    fun setUserInfoRepo(userInfoRepo: UserInfoRepo) {
        Companion.userInfoRepo = userInfoRepo
    }


}

考虑离线消息

只需要再添加一个 ConcurrentHashMap 来记录用户和离线消息~ 

考虑到消息可能过大,放在内存中不太合适,也可以通过专门设计一个张数据库表来存放用户的离线消息.

当用户再次上线,触发 onOpen 方法时,就可以恢复离线消息啦~

Ps:想要源码可以联系我......

相关推荐

  1. python的WebSocket编程详解,案例系统实现

    2024-04-07 08:52:01       63 阅读
  2. UDP<span style='color:red;'>群</span><span style='color:red;'>聊</span>

    UDP

    2024-04-07 08:52:01      50 阅读
  3. UDP<span style='color:red;'>群</span><span style='color:red;'>聊</span>

    UDP

    2024-04-07 08:52:01      57 阅读
  4. UDP<span style='color:red;'>群</span><span style='color:red;'>聊</span>

    UDP

    2024-04-07 08:52:01      52 阅读
  5. UDP实现

    2024-04-07 08:52:01       63 阅读

最近更新

  1. docker php8.1+nginx base 镜像 dockerfile 配置

    2024-04-07 08:52:01       94 阅读
  2. Could not load dynamic library ‘cudart64_100.dll‘

    2024-04-07 08:52:01       100 阅读
  3. 在Django里面运行非项目文件

    2024-04-07 08:52:01       82 阅读
  4. Python语言-面向对象

    2024-04-07 08:52:01       91 阅读

热门阅读

  1. notepad++绿色版添加右键菜单

    2024-04-07 08:52:01       34 阅读
  2. leetcode148. 排序链表

    2024-04-07 08:52:01       37 阅读
  3. TypeScript快速入门

    2024-04-07 08:52:01       29 阅读
  4. c++中c风格的字符串

    2024-04-07 08:52:01       41 阅读
  5. 《状态模式(极简c++)》

    2024-04-07 08:52:01       152 阅读