websocket + stomp + sockjs学习

学习链接

Spring & SpringBoot官方文档资料

WebSocket入门教程示例代码,代码地址已fork至本地gitee原github代码地址源老外的代码地址

其它可参考

深入使用

补充学习

后续使用rabbimtmq作为消息代理实现时,参考的文章

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

示例1

pom.xml

<?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.timeless</groupId>
    <artifactId>timeless-chat-websocket</artifactId>
    <version>1.0-SNAPSHOT</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.6.4</version>
    </parent>

    <dependencies>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.2</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>

        <!--<dependency>-->
        <!--    <groupId>com.itheima</groupId>-->
        <!--    <artifactId>pd-tools-swagger2</artifactId>-->
        <!--    <version>1.0-SNAPSHOT</version>-->
        <!--</dependency>-->

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

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

        <!-- RabbitMQ Starter Dependency -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>

        <!-- Following additional dependency is required for Full Featured STOMP Broker Relay -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-reactor-netty</artifactId>
        </dependency>



    </dependencies>

</project>

application.yml

server:
  port: 8888
spring:
  application:
    name: timeless-chat-websocket
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/timeless_chat_websocket?serverTimeZone=UTC
    username: root
    password: root
  mvc:
    pathmatch:
      # Springboot2.6以后将SpringMVC 默认路径匹配策略从AntPathMatcher 更改为PathPatternParser
      #
      matching-strategy: ANT_PATH_MATCHER
  rabbitmq:
    host: ${
    
    rabbitmq.host}
    port: ${
    
    rabbitmq.port}
    username: ${
    
    rabbitmq.username}
    password: ${
    
    rabbitmq.password}
    virtual-host: ${
    
    rabbitmq.virtual-host}
#pinda:
#  swagger:
#    enabled: true
#    title: timeless文档
#    base-package: com.timeless.controller
mybatis-plus:
  configuration:
    log-impl: com.timeless.utils.NoLog

WebSocketConfig

@Configuration
@Slf4j
@EnableWebSocketMessageBroker
@EnableConfigurationProperties(RabbitMQProperties.class)
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    
    

    private RabbitMQProperties rabbitMQProperties;

    public WebSocketConfig(RabbitMQProperties rabbitMQProperties) {
    
    
        this.rabbitMQProperties = rabbitMQProperties;
        log.info("连接rabbitmq, host: {}", rabbitMQProperties.getHost());
    }


    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
    
    
        registry
                // 这个和客户端创建连接时的url有关,后面在客户端的代码中可以看到
                .addEndpoint("/ws")
                .addInterceptors(new HandshakeInterceptor() {
    
    
                    @Override
                    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
    
    
                        log.info("客户端握手即将开始===================【开始】");
                        if (request instanceof ServletServerHttpRequest) {
    
    
                            ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
                            log.info("请求路径: {}", ((ServletServerHttpRequest) request).getServletRequest().getRequestURL());
                            log.info("校验请求头,以验证用户身份:  {}", JsonUtil.obj2Json(servletRequest.getHeaders()));
                            HttpSession session = servletRequest.getServletRequest().getSession();
                            attributes.put("sessionId", session.getId());
                            return true;
                        }
                        log.info("客户端握手结束=================== 【失败】");
                        return false;
                    }

                    @Override
                    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
    
    
                        log.info("客户端握手结束=================== 【成功】");
                    }
                })
                // .setAllowedOrigins("http://localhost:8080")
                // 当传入*时, 使用该方法, 而不要使用setAllowedOrigins("*")
                .setAllowedOriginPatterns("*")
                .withSockJS()
        ;
    }


    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
    
    

        // 1. 当客户端发送消息或订阅消息时,url路径开头如果是/app/xxx 时,会先解析stomp协议,然后路由到@controller的@MessageMapping("/xxx")的方法上执行。
        //    如果不设置,客户端所有发送消息或订阅消息时、都将去匹配@messageMapping。所以最好还是配置上。
        // 2. 这句表示客户端向服务端发送时的主题上面需要加"/app"作为前缀
        registry.setApplicationDestinationPrefixes("/app");

        // 1. 基于内存的消息代理
        // 2. 声明消息中间件Broker的主题名称,当向这个主题下发送消息时(js: stompclient.send("/topic/target1",{},"hello")),订阅当前主题的客户端都可以收到消息。
        //    注意:js 客户端如果发送时、直接是/topic/xxx,spring收到消息会直接发送给broker中。
        //    点对点发送时:enableSimpleBroker 中要配置 /user才可以用: template.convertAndSendToUser("zhangsan","/aaa/hello","111"),否则收不到消息
        // 3. 这句表示在topic和user这两个域上可以向客户端发消息
        registry.enableSimpleBroker("/topic", "/user");

        // 1. 点对点发送前缀
        // 2. 这句表示给指定用户发送(一对一)的主题前缀是 /user
        registry.setUserDestinationPrefix("/user");

        // Use this for enabling a Full featured broker like RabbitMQ
        /*
        // 基于mq的消息代理
        registry.enableStompBrokerRelay("/topic")
                .setVirtualHost(rabbitMQProperties.getVirtualHost())
                .setRelayHost(rabbitMQProperties.getHost())
                .setRelayPort(61613)
                .setClientLogin(rabbitMQProperties.getUsername())
                .setClientPasscode(rabbitMQProperties.getPassword())
                .setSystemLogin(rabbitMQProperties.getUsername())
                .setSystemPasscode(rabbitMQProperties.getPassword())
                .setSystemHeartbeatSendInterval(5000)
                .setSystemHeartbeatReceiveInterval(5000);
        */

    }
}

PrivateController

@Slf4j
@RestController
public class PrivateController {
    
    

    @Autowired
    private WebSocketService ws;

    // 1. 这个注解其实就是用来定义接受客户端发送消息的url(不能是topic开头,如果是topic直接发送给broker了,要用/app/privateChat)
    //    如果有返回值,则会将返回的内容转换成stomp协议格式发送给broker(主题名:/topic/privateChat)。如果要换主题名可使用@sendTo
    //    @SubscribeMapping注解和@messageMapping差不多,但不会再把内容发给broker,而是直接将内容响应给客户端,
    @MessageMapping("/privateChat")
    public void privateChat(PrivateMessage message) {
    
    
        // 使用发布订阅的方式变相的实现私聊(并不是真正意义上的点对点)
        ws.sendChatMessage(message);
    }

	/**
     * 问候信息处理
     * <p>{@link MessageMapping}方法的返回值会被转发到Broker对应的主题中</p>
     * <p>比如向/app/greetings发送的消息,其响应会被转发到/topic/greetings主题中</p>
     */
    @MessageMapping("/greetings")
    public String greetings(String content) {
    
    
        return String.format("Server response: %s", content);
    }


    // 客户端向 /app/broadcastMsg 发送消息, 将会使用该方法处理,
    // 并且因为此方法有返回值, 所以将结果又发送到/topic/broadcastMsg, 因此订阅了/topic/broadcastMsg的客户端将会收到此消息
    @MessageMapping("/broadcastMsg")
    @SendTo("/topic/broadcastMsg")
    public BroadcastMessage broadcastMsg(@Payload BroadcastMessage message,
                                         SimpMessageHeaderAccessor headerAccessor) {
    
    
        // 理解为会话添加属性标识
        headerAccessor.getSessionAttributes().put("extraInfo", message.getFromUsername());
        message.setContent("广播消息>>> " + message.getContent());
        return message;
    }

    @Autowired
    private SimpMessagingTemplate template;

    // 广播推送消息
    // (向此接口发送请求, 将会向所有的订阅了 /topic/broadcastMsg的客户端发送消息)
    @RequestMapping("/sendTopicMessage")
    public void sendTopicMessage(String content) {
    
    
        template.convertAndSend("/topic/broadcastMsg", content);
    }

    // 点对点消息
    @RequestMapping("/sendPointMessage")
    // (向此接口发送请求, 将会向所有的订阅了 /user/{targetUsername}/singleUserMsg 的客户端发送消息。
    //  这种方式调用的前提是需要registry.enableSimpleBroker("/topic", "/user"); 里面指定/user的前缀时才能使用的
    // (但觉得这并不是真正意义上的点对点,因为只要有客户端订阅了这个/user/{targetUsername}/singleUserMsg主题, 就能收到这个主题下的消息,
    public void sendQueueMessage(String targetUsername, String content) {
    
    
        this.template.convertAndSendToUser(targetUsername, "/singleUserMsg", content);
    }

}

WebSocketService

@Service
public class WebSocketService {
    
    


    @Autowired
    private SimpMessagingTemplate template;

    @Autowired
    private PrivateMessageService privateMessageService;

    /**
     * 简单点对点聊天室(使用发布订阅的方式变相的实现私聊(并不是真正意义上的点对点))
     */
    public void sendChatMessage(PrivateMessage message) {
    
    
        message.setMessage(message.getFromUsername() + " 发送:" + message.getMessage());
        // 消息存储到数据库
        boolean save = privateMessageService.save(message);
        //可以看出template最大的灵活就是我们可以获取前端传来的参数来指定订阅地址, 前面参数是订阅地址,后面参数是消息信息
        template.convertAndSend("/topic/ServerToClient.private." + message.getToUsername(), message);
        if(!save){
    
    
            throw new SystemException(AppHttpCodeEnum.SYSTEM_ERROR);
        }
    }

}

WebSocketEventListener

也可以实现ApplicationListener<T>接口,泛型T,即为感兴趣的事件类型。支持如下事件监听:

  • SessionConnectedEvent
  • SessionConnectEvent
  • SessionDisconnectEvent
  • SessionSubscribeEvent
  • SessionUnsubscribeEvent
@Component
public class WebSocketEventListener {
    
    

    @Autowired
    private SimpMessageSendingOperations messagingTemplate;

    public static AtomicInteger userNumber = new AtomicInteger(0);

    @EventListener
    public void handleWebSocketConnectListener(SessionConnectedEvent event) {
    
    
        userNumber.incrementAndGet();
        messagingTemplate.convertAndSend("/topic/ServerToClient.showUserNumber", userNumber);
        System.out.println("我来了哦~");
    }

    @EventListener
    public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
    
    
        userNumber.decrementAndGet();
        messagingTemplate.convertAndSend("/topic/ServerToClient.showUserNumber", userNumber);
        System.out.println("我走了哦~");
    }
}

CorsFilter

@WebFilter
public class CorsFilter implements Filter {
    
    
 
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
    
    
        HttpServletResponse response = (HttpServletResponse) res;
        response.setHeader("Access-Control-Allow-Origin", "*");  
        response.setHeader("Access-Control-Allow-Methods", "*");  
        response.setHeader("Access-Control-Max-Age", "3600");  
        response.setHeader("Access-Control-Allow-Headers", "*");
        response.setHeader("Access-Control-Allow-Credentials", "true");
        chain.doFilter(req, res);  
    }  
}

package.json

{
    
    
  "name": "timeless-chat-websocket-front",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    
    
    "serve": "vue-cli-service serve --open",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },
  "dependencies": {
    
    
    "axios": "^0.17.1",
    "core-js": "^3.8.3",
    "element-ui": "^2.15.3",
    "net": "^1.0.2",
    "nprogress": "^0.2.0",
    "sockjs-client": "^1.6.1",
    "stompjs": "^2.3.3",
    "vue": "^2.6.14",
    "vue-router": "^3.5.3"
  },
  "devDependencies": {
    
    
    "@babel/core": "^7.12.16",
    "@babel/eslint-parser": "^7.12.16",
    "@vue/cli-plugin-babel": "~5.0.0",
    "@vue/cli-plugin-eslint": "~5.0.0",
    "@vue/cli-service": "~5.0.0",
    "eslint": "^7.32.0",
    "eslint-plugin-vue": "^8.0.3",
    "vue-template-compiler": "^2.6.14"
  },
  "eslintConfig": {
    
    
    "root": true,
    "env": {
    
    
      "node": true
    },
    "extends": [
      "plugin:vue/essential",
      "eslint:recommended"
    ],
    "parserOptions": {
    
    
      "parser": "@babel/eslint-parser"
    },
    "rules": {
    
    }
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not dead"
  ]
}

Room.vue

<template>
  <div>
    <h3 style="text-align: center">当前用户:{
   
   { this.username }}</h3>
    <h3 style="text-align: center">在线人数:{
   
   { this.userNumber }}</h3>
    <!--    <h3 style="text-align: center">在线用户:-->
    <!--      <div v-for="user in usernameOnlineList" :key="user">{
    
    { user }}</div>-->
    <!--    </h3>-->
    <div class="container">
      <div class="left">
        <h2 style="text-align: center">用户列表</h2>
        <ul>
          <li v-for="user in userList" :key="user.id" :class="{ selected: user.selected }" title="点击选择用户聊天">
            <div class="user-info">
              <span @click="selectUser(user)">
                {
   
   { user.toUsername }}
              </span>
              <!-- <div class="button-container">
                <el-button
                  v-if="user.isFriend === 0"
                  type="primary"
                  size="mini"
                  @click="sendFriendRequest(user)"
                >
                  申请加好友
                </el-button>
                <el-button
                  v-if="user.isFriend === 1"
                  type="success"
                  @click="sendMessage(user)"
                >
                  好友
                </el-button>
                <el-button v-if="user.isFriend === 2" type="danger" disabled>
                  申请中
                </el-button>
              </div> -->
            </div>
          </li>
        </ul>
      </div>
      <div class="right">
        <div v-if="selectedUser">
          <h2 style="text-align: center">
            正在与{
   
   { selectedUser.toUsername }}聊天
          </h2>
        </div>
        <div v-if="selectedUser">
          <ul>
            <li v-for="message in messageList[username + selectedUser.toUsername]" :key="message.id">
              {
   
   { message }}
            </li>
          </ul>
        </div>
        <div v-if="selectedUser">
          <div class="message-input">
            <el-input v-model="selectedUserMessage.message" placeholder="请输入内容" @keyup.enter.native="sendMsg"></el-input>
            <div class="button-container">
              <el-button type="primary" @click="sendMsg">发送消息</el-button>
              <el-button type="danger" @click="deleteAllMsgs">删除所有消息</el-button>
            </div>
          </div>
          <div class="message-input">
            <el-input v-model="broadcastMsgContent" placeholder="请输入广播消息内容" @keyup.enter.native="sendMsg"></el-input>
            <div class="button-container">
              <el-button type="primary" @click="sendBroadcastMsg">发送广播消息</el-button>
            </div>
          </div>
          
          <div class="message-input">
            <el-input v-model="toTopicMsgContent" placeholder="请输入ToTopicMsg" @keyup.enter.native="sendMsg"></el-input>
            <div class="button-container">
              <el-button type="primary" @click="sendToTopicMsg">发送ToTopicMsg</el-button>
            </div>
          </div>
          <div class="message-input">
            <el-input v-model="greetingsMsgContent" placeholder="请输入greetings消息" @keyup.enter.native="sendMsg"></el-input>
            <div class="button-container">
              <el-button type="primary" @click="sendGreetingsMsg">发送GreetingsMsg</el-button>
            </div>
          </div>
        </div>
      </div>
    </div>
    <div>
      <h1 class="bottom" style="text-align: center">好友申请</h1>

      <h2 style="text-align: center; color: rgb(57, 29, 216)">
        功能开发中......
      </h2>
    </div>
  </div>
</template>

<script>
import {
      
       getAllUsers, listPrivateMessages, deleteAllMsg } from "@/api";
import SockJS from "sockjs-client";
import Stomp from "stompjs";
import {
      
       Message } from "element-ui";

export default {
      
      
  name: "Room",
  data() {
      
      
    return {
      
      
      userList: [],
      groupList: [],
      selectedUser: null,
      message: "",
      stompClient: null,
      messageList: {
      
      }, // 使用对象来存储每个用户的聊天记录
      username: "",
      usernameOnlineList: [],
      userNumber: 1,
      selectedUserMessage: {
      
      
        user: null,
        message: "",
      },
      broadcastMsgContent: '',
      userMsgContent: '',
      toTopicMsgContent: '',
      greetingsMsgContent:'',
    };
  },
  methods: {
      
      

    listAllUsers() {
      
      
      getAllUsers(this.username).then((response) => {
      
      
        this.userNumber = ++response.data.userNumber;
        this.userList = response.data.friends.filter(
          (user) => user.toUsername !== this.username
        );
      });
    },

    selectUser(user) {
      
      

      if (!this.messageList[this.username + user.toUsername]) {
      
      
        console.log(2222222)
        this.$set(this.messageList, this.username + user.toUsername, []);
      }

      // TODO 展示数据库中存在的信息,也就是聊天记录
      listPrivateMessages(this.username, user.toUsername).then((response) => {
      
      
        this.$set(this.messageList, this.username + user.toUsername, response.data);
      });

      this.selectedUser = user;
      this.selectedUserMessage.user = user;
      this.selectedUserMessage.message = ""; // 清空输入框内容
      this.userList.forEach((u) => {
      
      
        u.selected = false;
      });
      user.selected = true;
    },

    sendMsg() {
      
      
      if (this.stompClient !== null && this.selectedUserMessage.message !== "") {
      
      

        // 发送私聊消息给服务端
        this.stompClient.send(
          "/app/privateChat",
          {
      
      },
          JSON.stringify({
      
      
            fromUsername: this.username,
            message: this.selectedUserMessage.message,
            toUsername: this.selectedUserMessage.user.toUsername,
          })
        );

        this.messageList[this.username + this.selectedUserMessage.user.toUsername].push(
          this.username + " 发送:" + this.selectedUserMessage.message
        );

        this.selectedUserMessage.message = ""; // 清空输入框内容
      } else {
      
      
        Message.info("请输入消息");
      }
    },

    sendBroadcastMsg() {
      
      
      if (this.stompClient !== null) {
      
      
        // 发送私聊消息给服务端
        this.stompClient.send(
          "/app/broadcastMsg",
          {
      
      },
          JSON.stringify({
      
      
            fromUsername: this.username,
            content: this.broadcastMsgContent
          })
        );
      }
    },
    
    // 客户端也可以发送 /topic/xx, 这样订阅了 /topic/xx的客户端也会收到消息
    sendToTopicMsg() {
      
      
      if (this.stompClient !== null) {
      
      
        // 发送私聊消息给服务端
        this.stompClient.send(
          "/topic/broadcastMsg",
          {
      
      },
          JSON.stringify({
      
      
            fromUsername: this.username,
            content: this.toTopicMsgContent
          })
        );
      }
    },
    // 客户端发送/app/greetings消息, 将会被@MessageMapping处理, 验证方法的返回值将会被发送到/topic/greetings主题
    sendGreetingsMsg() {
      
      
      if (this.stompClient !== null) {
      
      
        // 发送私聊消息给服务端
        this.stompClient.send(
          "/app/greetings",
          {
      
      },
          JSON.stringify({
      
      
            fromUsername: this.username,
            content: this.greetingsMsgContent,
          })
        );
      }
    },
    deleteAllMsgs() {
      
      
      if (this.messageList[this.username + this.selectedUserMessage.user.toUsername] == "") {
      
      
        Message.error("当前没有聊天记录");
        return;
      }
      deleteAllMsg(this.username, this.selectedUser.toUsername).then(
        (response) => {
      
      
          this.messageList[this.username + this.selectedUserMessage.user.toUsername] = [];
          Message.success("删除成功");
        }
      );
    },
    connect() {
      
      

      //建立连接对象(还未发起连接)
      const socket = new SockJS("/api/ws");

      // 获取 STOMP 子协议的客户端对象 
      this.stompClient = Stomp.over(socket);
      window.stompClient = this.stompClient

      // 向服务器发起websocket连接并发送CONNECT帧 
      this.stompClient.connect(
        {
      
      },
        (frame) => {
      
       // 连接成功时(服务器响应 CONNECTED 帧)的回调方法

          console.log("建立连接: " + frame);

          // 订阅当前个人用户消息
          this.stompClient.subscribe(`/user/${ 
        this.username}/singleUserMsg`, (response) => {
      
      
            console.log('收到当前点对点用户消息: ', response.body);
          })

          // 订阅当前个人用户消息2
          this.stompClient.subscribe(`/user/singleUserMsg`, (response) => {
      
      
            console.log('收到当前点对点用户消息2: ', response.body);
          })

          // 订阅广播消息
          this.stompClient.subscribe(`/topic/broadcastMsg`, (response) => {
      
      
            console.log('收到广播消息: ', response.body);
          })

          // 订阅/topic/greetings消息
          this.stompClient.subscribe(`/topic/greetings`, (response) => {
      
      
            console.log('收到greetings消息: ', response.body);
          })

          // 订阅 服务端发送给客户端 的私聊消息
          //(疑问: 订阅的范围如何限制?当前用户应该不能订阅别的用户吧?
          //  尝试: 每进来一个用户动态生成这个用户对应的一个标识, 然后, 这个用户订阅当前这个标识, 
          //        其它没用如果想发消息给这个用户, 后台先查询这个用户标识, 然后发消息给这个用户,
          //        这样这个订阅路径就是动态的, 其它不是好友的用户就无法获取到这个动态生成的用户标识。)
          this.stompClient.subscribe(
            "/topic/ServerToClient.private." + this.username,
            (result) => {
      
      
              this.showContent(
                JSON.parse(result.body).message,
                JSON.parse(result.body).fromUsername,
                JSON.parse(result.body).toUsername,
              )
            });

          // 订阅 服务端发送给客户端删除所有聊天内容 的消息
          this.stompClient.subscribe("/topic/ServerToClient.deleteMsg", (result) => {
      
      
            const res = JSON.parse(result.body);
            this.messageList[res.toUsername + res.fromUsername] = [];
          });

          // 订阅 服务端发送给客户端在线用户数量 的消息
          this.stompClient.subscribe("/topic/ServerToClient.showUserNumber", (result) => {
      
      
            this.userNumber = result.body;
          });

        });
    },

    disconnect() {
      
      
      if (this.stompClient !== null) {
      
      
        // 断开连接
        this.stompClient.disconnect();
      }
      console.log("断开连接...");
    },

    showContent(body, from, to) {
      
      
      // 处理接收到的消息
      // 示例代码,根据实际需求进行修改
      if (!this.messageList[to + from]) {
      
      
        this.$set(this.messageList, to + from, []); // 初始化选定用户的聊天记录数组
      }
      this.messageList[to + from].push(body); // 将接收到的消息添加到选定用户的聊天记录数组
    },
  },
  created() {
      
      
  },
  mounted() {
      
      
    // 从sessionStorage中获取用户名
    this.username = sessionStorage.getItem("username");
    console.log('username', this.username);
    if (!this.username) {
      
      
      this.$router.push('/login')
      return
    }
    this.connect();
    this.listAllUsers();
    // console.log(this.username);
  },
  beforeDestroy() {
      
      
    this.disconnect();
  },
};
</script>

<style scoped>
.container {
      
      
  display: flex;
  justify-content: space-between;
  margin: 10px;
}

.left,
.middle,
.right {
      
      
  flex: 0.5;
  margin: 5px;
  padding: 10px;
  background-color: lightgray;
}

.right {
      
      
  flex: 2;
}

.bottom {
      
      
  margin-top: 20px;
  text-align: center;
}

li {
      
      
  cursor: pointer;
  transition: color 0.3s ease;
}

li:hover {
      
      
  color: blue;
}

li.selected {
      
      
  color: blue;
  font-weight: bold;
}

.send-button {
      
      
  display: flex;
  justify-content: flex-end;
}

.message-input {
      
      
  display: flex;
  align-items: center;
}

.button-container {
      
      
  margin-left: 10px;
  /* 调整间距大小 */
}

.message-container {
      
      
  display: flex;
  justify-content: flex-end;
}

.button-container {
      
      
  display: flex;
  justify-content: flex-end;
}

.user-info {
      
      
  display: flex;
  align-items: center;
}

.button-container {
      
      
  margin-left: auto;
}
</style>

示例2

引入依赖

<?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>

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

    <groupId>com.zzhua</groupId>
    <artifactId>ws-demo2</artifactId>
    <version>1.0-SNAPSHOT</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>

    <dependencies>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>

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

        <!-- RabbitMQ Starter Dependency -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>

        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.80</version>
        </dependency>

    </dependencies>

</project>

WebsocketMessageBrokerConfig

@Configuration
@EnableWebSocketMessageBroker
public class WebsocketMessageBrokerConfig implements WebSocketMessageBrokerConfigurer {
    
    

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
    
    
        registry
                .addEndpoint("/websocket") // WebSocket握手端口
                .addInterceptors(new HttpSessionHandshakeInterceptor())
                .addInterceptors(new HandshakeInterceptor() {
    
     
                    @Override
                    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
    
    
                    	// 此处可作认证, 因为只有握手成功之后, 才会建立websocket连接
                        return true;
                    }

                    @Override
                    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
    
    

                    }
                })
                
                .setHandshakeHandler(new DefaultHandshakeHandler(){
    
     // 设置默认的握手处理器(可重写其中的方法,比如determineUser)
                    @Override
                    protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {
    
    
                    	// 此处可作认证, 因为只有握手成功之后, 才会建立websocket连接
                    	// 这里可以返回Principal对象, Principal#
                        return super.determineUser(request, wsHandler, attributes);
                    }
                })
                .setAllowedOriginPatterns("*") // 设置跨域
                .withSockJS(); // 开启SockJS回退机制
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
    
    
        // 拦截器配置
        registration
                .interceptors(new UserAuthenticationChannelInterceptor());
    }

    @Override
    public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
    
    
        // 这里我们设置入站消息最大为8K
        registry
                .setMessageSizeLimit(8 * 1024);
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
    
    
        registry
                .setApplicationDestinationPrefixes("/app") // 发送到服务端目的地前缀
                .enableSimpleBroker("/topic");// 开启简单消息代理,指定消息订阅前缀
    }

}

StompAuthenticatedUser

@Data
@AllArgsConstructor
@NoArgsConstructor
public class StompAuthenticatedUser implements Principal {
    
    

	/**
	 * 用户唯一ID
	 */
	private String userId;

	/**
	 * 用户昵称
	 */
	private String nickName;

	/**
	 * 用于指定用户消息推送的标识
	 * @return
	 */
	@Override
	public String getName() {
    
    
		return this.userId;
	}

}

UserAuthenticationChannelInterceptor

@Slf4j
public class UserAuthenticationChannelInterceptor implements ChannelInterceptor {
    
    

	private static final String USER_ID = "User-ID";
	private static final String USER_NAME = "User-Name";

	@Override
	public Message<?> preSend(Message<?> message, MessageChannel channel) {
    
    
	
		StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
		
		// 如果是连接请求,记录userId
		if (StompCommand.CONNECT.equals(accessor.getCommand())) {
    
    
		
			// 此处可以拿到stomp客户端请求连接时,传入的请求头
			String userID = accessor.getFirstNativeHeader(USER_ID);
			
			String username = accessor.getFirstNativeHeader(USER_NAME);

			log.info("Stomp User-Related headers found, userID: {}, username:{}", userID, username);
			
			/*
			  为什么保存认证信息使用 setUser 方法?
			  
				- 该方法表示会话的拥有者,即存储该会话拥有者信息。
				  每次建立连接都会创建一个 WebSocketSession 会话信息类,在该会话进行消息传递每次都会把 SessionId ,SessionAttributes 和 
				  Principal(即我们setUser()保存的信息) 赋值到 Message 中,而 Principal 就是专门存储身份认证信息的。
			
				- SessionId: 初始随机分配的,用于确定唯一的会话
				  SessionAttributes: 用于给 WebSocketSession 设置一些额外记录属性,结构是 Map
				  Principal: 用于设置 WebSocketSession 的身份认证信息
			*/

			accessor.setUser(new StompAuthenticatedUser(userID, username));
			
			// 如果没有权限, 则直接在此处抛出异常即可
		}

		// 此处也可以根据stomp客户端不同的命令作权限控制, 比如是否有权限订阅某个路径

		

		return message;
	}

	    @Override
    public void postSend(Message<?> message, MessageChannel channel, boolean sent) {
    
    
        log.info("ChannelInterceptor#postSend: {}", message);
    }

    @Override
    public void afterSendCompletion(Message<?> message, MessageChannel channel, boolean sent, Exception ex) {
    
    
        log.info("ChannelInterceptor#afterSendCompletion: {}", message);
    }

    @Override
    public boolean preReceive(MessageChannel channel) {
    
    
        log.info("ChannelInterceptor#preReceive: {}", channel);
        return true;
    }

    @Override
    public Message<?> postReceive(Message<?> message, MessageChannel channel) {
    
    
        log.info("ChannelInterceptor#preReceive: {}", message);
        return message;
    }

    @Override
    public void afterReceiveCompletion(Message<?> message, MessageChannel channel, Exception ex) {
    
    
        log.info("ChannelInterceptor#afterReceiveCompletion: {}", message);
    }

}

StompSessionEventListener

@Slf4j
@Component
public class StompSessionEventListener implements ApplicationListener<AbstractSubProtocolEvent> {
    
    

    @Autowired
    private SimpMessagingTemplate simpMessagingTemplate;

    @Override
    public void onApplicationEvent(AbstractSubProtocolEvent event) {
    
    

        log.debug("监听到事件: {}", event);

        if (event instanceof SessionConnectEvent) {
    
    
            handleSessionConnect((SessionConnectEvent)event);
        } else if (event instanceof SessionConnectedEvent) {
    
    
            handleSessionConnected((SessionConnectedEvent)event);
        } else if (event instanceof SessionDisconnectEvent) {
    
    
            handleSessionDisconnect((SessionDisconnectEvent)event);
        } else if (event instanceof SessionSubscribeEvent) {
    
    
            handleSessionSubscribe((SessionSubscribeEvent)event);
        } else if (event instanceof SessionUnsubscribeEvent) {
    
    
            handleSessionUnSubscribe((SessionUnsubscribeEvent)event);
        }
    }

    private void handleSessionUnSubscribe(SessionUnsubscribeEvent event) {
    
    
        log.info("{}取消订阅{}", event.getUser(),StompHeaderAccessor.wrap(event.getMessage()).getDestination());
    }

    private void handleSessionSubscribe(SessionSubscribeEvent event) {
    
    
        log.info("{}订阅了{}", event.getUser(), StompHeaderAccessor.wrap(event.getMessage()).getDestination());
    }

    private void handleSessionDisconnect(SessionDisconnectEvent event) {
    
    
        log.info("{}下线了", event.getUser());
        simpMessagingTemplate.convertAndSend(
                "/topic/chat/group",
                new WebSocketMsgVO(event.getUser() + "下线了")
        );
    }

    private void handleSessionConnected(SessionConnectedEvent event) {
    
    
        log.info("{}上线了", event.getUser());
        simpMessagingTemplate.convertAndSend(
                "/topic/chat/group",
                new WebSocketMsgVO(event.getUser() + "上线了")
        );
    }

    private void handleSessionConnect(SessionConnectEvent event) {
    
    
        log.info("{}请求建立stomp连接", event.getUser());
    }

}

ChatController

@Slf4j
@Controller
@EnableScheduling
@RequiredArgsConstructor
public class ChatController {
    
    

	private final SimpUserRegistry simpUserRegistry;
	
	private final SimpMessagingTemplate simpMessagingTemplate;

	@GetMapping("/page/chat")
	public ModelAndView turnToChatPage() {
    
    
		return new ModelAndView("chat");
	}

	/**
	 * 群聊消息处理
	 * 这里我们通过@SendTo注解指定消息目的地为"/topic/chat/group",如果不加该注解则会自动发送到"/topic" + "/chat/group"
	 * @param webSocketMsgDTO 请求参数,消息处理器会自动将JSON字符串转换为对象
	 * @return 消息内容,方法返回值将会广播给所有订阅"/topic/chat/group"的客户端
	 */
	@MessageMapping("/chat/group")
	@SendTo("/topic/chat/group")
	public WebSocketMsgVO groupChat(WebSocketMsgDTO webSocketMsgDTO) {
    
    
	
		log.info("Group chat message received: {}", JSONObject.toJSONString(webSocketMsgDTO));
		
		String content = String.format("来自[%s]的群聊消息: %s", webSocketMsgDTO.getName(), webSocketMsgDTO.getContent());
		
		return WebSocketMsgVO.builder().content(content).build();
	}

	/**
	 * 私聊消息处理(客户端向/app/chat/private发送消息, 会触发该方法, 该方法的返回值将只会发送给当前用户自己)
	 * 这里我们通过@SendToUser注解指定消息目的地为"/topic/chat/private",发送目的地默认会拼接上"/user/"前缀
	 * 实际发送目的地为"/user/topic/chat/private"
	 * @param webSocketMsgDTO 请求参数,消息处理器会自动将JSON字符串转换为对象
	 * @return 消息内容,方法返回值将会基于SessionID单播给指定用户
	 */
	@MessageMapping("/chat/private")
	@SendToUser("/topic/chat/private")
	public WebSocketMsgVO privateChat(WebSocketMsgDTO webSocketMsgDTO,
                                      Principal principal,
                                      Message message,
                                      MessageHeaderAccessor messageHeaderAccessor) {
    
    
		log.info("Private chat message received: {}, ", JSONObject.toJSONString(webSocketMsgDTO), principal);
		log.info("Private chat message  principal:{}", principal);
		log.info("Private chat message  message:{}", message);
		log.info("Private chat message  messageHeaderAccessor:{}", messageHeaderAccessor);
        StompHeaderAccessor stompHeaderAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
        if (stompHeaderAccessor != null) {
    
    
            log.info("Private chat message  sessionId:{}", stompHeaderAccessor.getSessionId());           
        }
        log.info("Private chat message  sessionId:{}", StompHeaderAccessor.wrap(message).getSessionId());
        String content = "私聊消息回复:" + webSocketMsgDTO.getContent();
		return WebSocketMsgVO.builder().content(content).build();
	}

	/**
	 * 后台发送消息给指定的用户
	 * 条件: 1. 客户端订阅stompClient.subscribe(`/user/topic/chat/toPrivate`,message=>{...})
	 *      2. 请求http://localhost:9090/toPrivateChat?userId=1693137128824&content=halo111接口即可
	 *         (其中, userId为UserAuthenticationChannelInterceptor 在连接时记录的userId)
	 */
	@GetMapping("/toPrivateChat")
    @ResponseBody
    public String toPrivateChat(String userId,String content) {
    
    
        messagingTemplate.convertAndSendToUser(userId, "/topic/chat/toPrivate", content);
        return "ok";
    }

	/**
     * 发送(广播)消息到/topic/chat/group
     */
    @GetMapping("/toPublicChat")
    @ResponseBody
    public String toPublicChat(String content) {
    
    
	    messagingTemplate.convertAndSend("/topic/chat/group",new WebSocketMsgVO(content));
        return "ok";
    }

	/**
	 * 定时消息推送,这里我们会列举所有在线的用户,然后单播给指定用户。
	 * 通过SimpMessagingTemplate实例可以在任何地方推送消息。
	 */
	@Scheduled(fixedRate = 10 * 1000)
	public void pushMessageAtFixedRate() {
    
    
	
		log.info("当前在线人数: {}", simpUserRegistry.getUserCount());
		
		if (simpUserRegistry.getUserCount() <= 0) {
    
    
			return;
		}

		// 这里的Principal为StompAuthenticatedUser实例
		Set<StompAuthenticatedUser> users = simpUserRegistry.getUsers().stream()
			.map(simpUser -> StompAuthenticatedUser.class.cast(simpUser.getPrincipal()))
			.collect(Collectors.toSet());

		users.forEach(authenticatedUser -> {
    
    
		
			String userId = authenticatedUser.getUserId();
			
			String nickName = authenticatedUser.getNickName();
			
			WebSocketMsgVO webSocketMsgVO = new WebSocketMsgVO();
			
			webSocketMsgVO.setContent(String.format("定时推送的私聊消息, 接收人: %s, 时间: %s", nickName, LocalDateTime.now()));

			log.info("开始推送消息给指定用户, userId: {}, 消息内容:{}", userId, JSONObject.toJSONString(webSocketMsgVO));
			
			simpMessagingTemplate.convertAndSendToUser(userId, "/topic/chat/push", webSocketMsgVO);
			
		});
	}

}

WebSocketMsgDTO

@Data
public class WebSocketMsgDTO {
    
    

	private String name;

	private String content;
}

WebSocketMsgVO

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class WebSocketMsgVO {
    
    

	private String content;
}

chat.html

引入下面3个js文件(可以使用cdn,此处我把它们下载到了本地,放在了resources/static/js下)

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>greeting</title>
    <!--<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.6.1/sockjs.min.js"></script>-->
    <!--<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>-->
    <!--<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.4/jquery.min.js"></script>-->
    <script src="/js/sockjs.min.js"></script>
    <script src="/js/stomp.min.js"></script>
    <script src="/js/jquery.min.js"></script>
    <style>
        #mainWrapper {
      
      
            width: 600px;
            margin: auto;
        }
    </style>
</head>
<body>
<div id="mainWrapper">
    <div>
        <label for="username" style="margin-right: 5px">姓名:</label><input id="username" type="text"/>
    </div>
    <div id="msgWrapper">
        <p style="vertical-align: top">发送的消息:</p>
        <textarea id="msgSent" style="width: 600px;height: 100px"></textarea>
        <p style="vertical-align: top">收到的群聊消息:</p>
        <textarea id="groupMsgReceived" style="width: 600px;height: 100px"></textarea>
        <p style="vertical-align: top">收到的私聊消息:</p>
        <textarea id="privateMsgReceived" style="width: 600px;height: 200px"></textarea>
    </div>
    <div style="margin-top: 5px;">
        <button onclick="connect()">连接</button>
        <button onclick="sendGroupMessage()">发送群聊消息</button>
        <button onclick="sendPrivateMessage()">发送私聊消息</button>
        <button onclick="disconnect()">断开连接</button>
    </div>
</div>
<script type="text/javascript">
    $(() => {
      
      
        $('#msgSent').val('');
        $("#groupMsgReceived").val('');
        $("#privateMsgReceived").val('');
    });

    let stompClient = null;


    // 连接服务器
    const connect = () => {
      
      
        const header = {
      
      "User-ID": new Date().getTime().toString(), "User-Name": $('#username').val()};
        const ws = new SockJS('http://localhost:8080/websocket');
        stompClient = Stomp.over(ws);
        // 连接时, 传入请求头
        stompClient.connect(header, () => subscribeTopic());
    }

    // 订阅主题
    const subscribeTopic = () => {
      
      
        alert("连接成功!");

        // 订阅广播消息
        stompClient.subscribe('/topic/chat/group', function (message) {
      
      
                console.log(`Group message received : ${ 
        message.body}`);
                const resp = JSON.parse(message.body);
                const previousMsg = $("#groupMsgReceived").val();
                $("#groupMsgReceived").val(`${ 
        previousMsg}${ 
        resp.content}\n`);
            }
        );
        // 订阅单播消息
        stompClient.subscribe('/user/topic/chat/private', message => {
      
      
                console.log(`Private message received : ${ 
        message.body}`);
                const resp = JSON.parse(message.body);
                const previousMsg = $("#privateMsgReceived").val();
                $("#privateMsgReceived").val(`${ 
        previousMsg}${ 
        resp.content}\n`);
            }
        );
        // 订阅定时推送的单播消息
        stompClient.subscribe(`/user/topic/chat/push`, message => {
      
      
                console.log(`Private message received : ${ 
        message.body}`);
                const resp = JSON.parse(message.body);
                const previousMsg = $("#privateMsgReceived").val();
                $("#privateMsgReceived").val(`${ 
        previousMsg}${ 
        resp.content}\n`);
            }
        );

		// 订阅发送到当前用户的消息
        stompClient.subscribe(`/user/topic/chat/toPrivate`, message => {
      
      
                console.log(`ToPrivate message received : ${ 
        message.body}`);
                console.log(message.body)
            }
        );
    };

    // 断连
    const disconnect = () => {
      
      
        stompClient.disconnect(() => {
      
      
            $("#msgReceived").val('Disconnected from WebSocket server');
        });
    }

    // 发送群聊消息
    const sendGroupMessage = () => {
      
      
        const msg = {
      
      name: $('#username').val(), content: $('#msgSent').val()};
        stompClient.send('/app/chat/group', {
      
      }, JSON.stringify(msg));
    }

    // 发送私聊消息
    const sendPrivateMessage = () => {
      
      
        const msg = {
      
      name: $('#username').val(), content: $('#msgSent').val()};
        stompClient.send('/app/chat/private', {
      
      }, JSON.stringify(msg));
    }
</script>
</body>
</html>

StompApp 启动类

@SpringBootApplication
public class StompApp {
    
    

    public static void main(String[] args) {
    
    
        SpringApplication.run(StompApp.class, args);
    }

}

application.yml配置文件

server:
  port: 8080
spring:
  thymeleaf:
    enabled: true
    prefix: classpath:/templates/
    cache: false

猜你喜欢

转载自blog.csdn.net/qq_16992475/article/details/132393064