SpringBoot+STOMP 实现聊天室(单聊+多聊)及群发消息详解

简介: SpringBoot+STOMP 实现聊天室

1、STOMP简介

STOMP是WebSocket的子协议,STOMP即Simple (or Streaming) Text Orientated Messaging Protocol,简单(流)文本定向消息协议,它提供了一个可互操作的连接格式,允许STOMP客户端与任意STOMP消息代理(Broker)进行交互。STOMP协议由于设计简单,易于开发客户端,因此在多种语言和多种平台上得到广泛地应用。

STOMP是一个非常简单和容易实现的协议,其设计灵感源自于HTTP的简单性。尽管STOMP协议在服务器端的实现可能有一定的难度,但客户端的实现却很容易。例如,可以使用Telnet登录到任何的STOMP代理,并与STOMP代理进行交互。

STOMP服务端

STOMP服务端被设计为客户端可以向其发送消息的一组目标地址。STOMP协议并没有规定目标地址的格式,它由使用协议的应用自己来定义。

STOMP客户端

对于STOMP协议来说, 客户端会扮演下列两种角色的任意一种:

    • 作为生产者,通过SEND帧发送消息到指定的地址
    • 作为消费者,通过发送SUBSCRIBE帧到已知地址来进行消息订阅,而当生产者发送消息到这个订阅地址后,订阅该地址的其他消费者会受到通过MESSAGE帧收到该消息

    实际上,WebSocket结合STOMP相当于构建了一个消息分发队列,客户端可以在上述两个角色间转换,订阅机制保证了一个客户端消息可以通过服务器广播到多个其他客户端,作为生产者,又可以通过服务器来发送点对点消息。

    STOMP帧结构

    COMMAND

    header1:value1

    header2:value2

    Body^@

    ^@表示行结束符

    一个STOMP帧由三部分组成:命令,Header(头信息),Body(消息体)

      • 命令使用UTF-8编码格式,命令有SEND、SUBSCRIBE、MESSAGE、CONNECT、CONNECTED等。
      • Header也使用UTF-8编码格式,它类似HTTP的Header,有content-length,content-type等。
      • Body可以是二进制也可以是文本。注意Body与Header间通过一个空行(EOL)来分隔。

      来看一个实际的帧例子:

      SEND

      destination:/broker/roomId/1

      content-length:57


      {“type":"ENTER","content":"o7jD64gNifq-wq-C13Q5CRisJx5E"}

        • 第1行:表明此帧为SEND帧,是COMMAND字段。
        • 第2行:Header字段,消息要发送的目的地址,是相对地址。
        • 第3行:Header字段,消息体字符长度。
        • 第4行:空行,间隔Header与Body。
        • 第5行:消息体,为自定义的JSON结构。

        这里就不详细讲解STOMP协议了,大家感兴趣,可以去看官网:STOMP

        2、环境介绍

        后端:springboot 2.0.8

        前端:angular7 (由于本人最近在学习angular,就选择它来做测试页面:p)

        (当然html +js 也可以,欢迎参考我的github上的WebSocketTest.html


        3、后端

        这里先讲解一下STOMP整个通讯流程:

        1、首先客户端与服务器建立 HTTP 握手连接,连接点 EndPoint 通过 WebSocketMessageBroker 设置。(这是下面操作的前提)

        2、客户端通过 subscribe 向服务器订阅消息主题(”/topic”或者“/all”),topic在本demo中是聊天室(单聊+多聊)的订阅主题,all是群发的订阅主题

        3、客户端可通过 stompClient.send 向服务器发送消息,消息通过路径 /app/chat或者/appAll达到服务端,服务端将其转发到对应的Controller(根据Controller配置的 @MessageMapping(“/chat")或者@MessageMapping(“/chaAll") 信息)

        4、服务器一旦有消息发出,将被推送到订阅了相关主题的客户端(Controller中的@SendTo(“/topic”)或者Controller中的@SendTo(“/all”)表示将方法中 return 的信息推送到 /topic 主题)。还有一种方法就是利用 messagingTemplate 发送到指定目标 (messagingTemplate.convertAndSend(destination, response);


        大家理解接下的代码,可以参考这个大致流程


        3.1、依赖

        <parent>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-parent</artifactId>
            <version>2.0.8.RELEASE</version>
            <relativePath/> <!-- lookup parent from repository -->
              </parent>
                <dependency>
              <groupId>org.springframework.boot</groupId>
              <artifactId>spring-boot-starter-web</artifactId>
            </dependency>
                <!--websocket 相关依赖-->
            <dependency>
              <groupId>org.springframework.boot</groupId>
              <artifactId>spring-boot-starter-websocket</artifactId>
            </dependency>
            <dependency>
              <groupId>org.webjars</groupId>
              <artifactId>webjars-locator-core</artifactId>
            </dependency>
            <dependency>
              <groupId>org.webjars</groupId>
              <artifactId>sockjs-client</artifactId>
              <version>1.0.2</version>
            </dependency>
            <dependency>
              <groupId>org.webjars</groupId>
              <artifactId>stomp-websocket</artifactId>
              <version>2.3.3</version>
            </dependency>
            <!--websocket 相关依赖-->

        image.gif

        3.2、application.properties

        server.port =8080

        image.gif

        3.3、WebSocketConfig

        @Configuration
        @EnableWebSocketMessageBroker
        public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
            @Override
            public void configureMessageBroker(MessageBrokerRegistry config) {
                /*
                 * 用户可以订阅来自"/topic"和"/user"的消息,
                 * 在Controller中,可通过@SendTo注解指明发送目标,这样服务器就可以将消息发送到订阅相关消息的客户端
                 *
                 * 在本Demo中,使用topic来达到聊天室效果(单聊+多聊),使用all进行群发效果
                 *
                 * 客户端只可以订阅这两个前缀的主题
                 */
                config.enableSimpleBroker("/topic","/all");
                /*
                 * 客户端发送过来的消息,需要以"/app"为前缀,再经过Broker转发给响应的Controller
                 */
                config.setApplicationDestinationPrefixes("/app");
            }
            @Override
            public void registerStompEndpoints(StompEndpointRegistry registry) {
                /*
                 * 路径"/websocket"被注册为STOMP端点,对外暴露,客户端通过该路径接入WebSocket服务
                 */
                registry.addEndpoint("/websocket").setAllowedOrigins("*").withSockJS();
            }
        }

        image.gif

        注意:

        @EnableWebSocketMessageBroker ,使用此注解来标识使能WebSocket的broker.即使用broker来处理消息.

        3.4、消息类

        RequestMessage :

        public class RequestMessage {
          private String sender;//消息发送者
          private String room;//房间号
          private String type;//消息类型
          private String content;//消息内容
            public RequestMessage() {
            }
            public RequestMessage(String sender,String room, String type, String content) {
                this.sender = sender;
                this.room = room;
                this.type = type;
                this.content = content;         
            }
            public String getSender() {
                return sender;
            }
            public String getRoom() {
                return room;
            }
            public String getType() {
                return type;
            }
            public String getContent() {
                return content;
            }
            public void setSender(String sender) {
              this.sender = sender;
            }
            public void setReceiver(String room) {
              this.room = room;
            }
            public void setType(String type) {
              this.type = type;
            }
            public void setContent(String content) {
              this.content = content;
            }

        image.gif

        ResponseMessage :

        public class ResponseMessage {
            private String sender;
          private String type;
          private String content;
            public ResponseMessage() {
            }
            public ResponseMessage(String sender, String type, String content) {
              this.sender = sender;
              this.type = type;
                this.content = content;
            }
            public String getSender() {
                return sender;
            }
            public String getType() {
                return type;
            }
            public String getContent() {
                return content;
            }
            public void setSender(String sender) {
              this.sender = sender;
            }
            public void setType(String type) {
              this.type = type;
            }
            public void setContent(String content) {
              this.content = content;
            }
        }

        image.gif

        3.5、WebSocketTestController

        @RestController
        public class WebSocketTestController {
            @Autowired
            private SimpMessagingTemplate messagingTemplate;
            /**聊天室(单聊+多聊)
             *
             * @CrossOrigin 跨域
             *
             * @MessageMapping 注解的方法可以使用下列参数:
             * * 使用@Payload方法参数用于获取消息中的payload(即消息的内容)
             * * 使用@Header 方法参数用于获取特定的头部
             * * 使用@Headers方法参数用于获取所有的头部存放到一个map中
             * * java.security.Principal 方法参数用于获取在websocket握手阶段使用的用户信息
             * @param requestMessage
             * @throws Exception
             */
            @CrossOrigin
            @MessageMapping("/chat")
            public void messageHandling(RequestMessage requestMessage) throws Exception {
                String destination = "/topic/" + HtmlUtils.htmlEscape(requestMessage.getRoom());
                String sender = HtmlUtils.htmlEscape(requestMessage.getSender());  //htmlEscape  转换为HTML转义字符表示
                String type = HtmlUtils.htmlEscape(requestMessage.getType());
                String content = HtmlUtils.htmlEscape(requestMessage.getContent());
                ResponseMessage response = new ResponseMessage(sender, type, content);
                messagingTemplate.convertAndSend(destination, response);
            }
            /**
             * 群发消息
             * @param requestMessage
             * @return
             * @throws Exception
             */
            @CrossOrigin
            @MessageMapping("/chatAll")
           public void messageHandlingAll(RequestMessage requestMessage) throws Exception {
                String destination = "/all";
                String sender = HtmlUtils.htmlEscape(requestMessage.getSender());  //htmlEscape  转换为HTML转义字符表示
                String type = HtmlUtils.htmlEscape(requestMessage.getType());
                String content = HtmlUtils.htmlEscape(requestMessage.getContent());
                ResponseMessage response = new ResponseMessage(sender, type, content);
                messagingTemplate.convertAndSend(destination, response);
            }
        }

        image.gif

        注意:

        1、使用@MessageMapping注解来标识所有发送到“/chat”这个destination的消息,都会被路由到这个方法进行处理

        2、使用@SendTo注解来标识这个方法返回的结果,都会被发送到它指定的destination,“/topic”

        3、传入的参数RequestMessage requestMessage为客户端发送过来的消息,是自动绑定的。


        如果觉得文章对你有帮助,欢迎关注微信公众号:小牛呼噜噜


        这样后端就写好了,是不是觉得很简单~

        4、前端

        新建一个angular7项目

        可以参考我之前的文章:

        angular7教程(1)——初步了解angular7及项目构建_小牛呼噜噜的博客-CSDN博客_angular7

        然后创建新组件websocket

        ng generate component websocket

        image.gif

        配置路由:

        import { NgModule } from '@angular/core';
        import { LoginComponent } from './login/login.component';
        import { WebsocketComponent } from './websocket/websocket.component';
        import { Routes, RouterModule } from '@angular/router';
        const routes: Routes = [
          { path: '',   redirectTo: '/login', pathMatch: 'full' },
          { path: 'login', component: LoginComponent },
          { path: 'websocket', component: WebsocketComponent },
        ];
        @NgModule({
          imports: [RouterModule.forRoot(routes)],
          exports: [RouterModule]
        })
        export class AppRoutingModule { }

        image.gif

        具体可以参考:

        angular7教程(2)——新建页面_小牛呼噜噜的博客-CSDN博客_angular 新建页面

        安装stomp相关的依赖:

        npm install stompjs --save
        npm install sockjs-client --save

        image.gif

        websocket.component.ts:

        import { Component, OnInit } from '@angular/core';
        import * as Stomp from 'stompjs';
        import * as SockJS from 'sockjs-client';
        @Component({
          selector: 'app-websocket',
          templateUrl: './websocket.component.html',
          styleUrls: ['./websocket.component.scss']
        })
        export class WebsocketComponent implements OnInit {
          public stompClient;
          public serverUrl = "http://localhost:8080/websocket";
          public room;//频道号
          constructor() { 
          }
          ngOnInit() {
              this.connect();
          }
          connect() {
            const ws = new SockJS(this.serverUrl);
            this.stompClient = Stomp.over(ws);
            const that = this;
            this.stompClient.connect({}, function (frame) {
              that.stompClient.subscribe('/topic/' + that.room, (message) => {
                if (message.body) {
                  const sender = JSON.parse(message.body)['sender'];
                  const language = JSON.parse(message.body)['language'];
                  const content = JSON.parse(message.body)['content'];
                  const type = JSON.parse(message.body)['type'];
                }
              });
            });
          }
        }

        image.gif

        运行项目:

        ng serve --port 4300

        image.gif

        报错:

        ERROR in ./node_modules/stompjs/lib/stomp-node.js
        Module not found: Error: Can't resolve 'net' in 'E:.....'

        解决方案:

        npm i net -S

        image.gif

        打开浏览器,打开调试模式

        发现报错:

        browser-crypto.js:3 Uncaught ReferenceError: global is not defined

        解决方案:

        polyfills.ts文件中手动填充它:

        // Add global to window, assigning the value of window itself.
        (window as any).global = window;

        image.gif

        成功

        image.gifimage.png



        现在前端已经能和后端 建立websocket连接了,那接下来我们来丰富前端页面


        我在这里采用了PrimeNG这个UI组件框架还要用到font-awesome图标库,因为PrimeNG用到了font-awesome的css(查了好久,才发现这个问题)

        下载依赖:

        npm install primeng --save
        npm install primeicons --save
        npm install font-awesome --save

        image.gif

        导入css(修改src目录下单styles.scss):

        /* You can add global styles to this file, and also import other style files */
        @import 
        "../node_modules/font-awesome/css/font-awesome.min.css", //is used by the style of ngprime
        "../node_modules/primeng/resources/primeng.min.css", // this is needed for the structural css
        "../node_modules/primeng/resources/themes/omega/theme.css",
        "../node_modules/primeicons/primeicons.css";
        // "../node_modules/primeflex/primeflex.css";

        image.gif

        接着我们修改app.module.ts,来增加我们需要的模块:

        import {ButtonModule} from 'primeng/button';
        imports: [
            ... ,
            ButtonModule
          ],

        image.gif

        新建一个前端显示类:

        image.pngimage.gif

        item.ts:

        这个用来定义聊天室的消息

        export class Item {
            type: string;
            user: String;
            content: string;
            constructor(type: string, user: String, content: string) {
                this.type = type;
                this.user = user;
                this.content = content;
            }
        }

        image.gif

        再建一个atim.ts:

        这个用来定义群发的消息

        export class Item {
            type: string;
            user: String;
            content: string;
            constructor(type: string, user: String, content: string) {
                this.type = type;
                this.user = user;
                this.content = content;
            }
        }

        image.gif

        然后我们改造websocket.html:

        <p>
          websocket works!
        </p>
        <div class="ui-g ui-fluid">
          <div class="ui-g-12 ui-md-4">
            <div class="ui-inputgroup">
                <span class="ui-inputgroup-addon"><i class="fa fa-user"></i></span>
                <input type="text" pInputText placeholder="Sender" [(ngModel)]='sender'> 
                {{sender}}     
            </div>
            <br>
            <div class="ui-inputgroup">
                <span class="ui-inputgroup-addon">$$</span>
                <input type="text" pInputText placeholder="Room"  [(ngModel)]='room'> 
                <p-button label="Connect" (click)="connect()"></p-button>
                <p-button label="Disconnect" (click)="disconnect()"></p-button>
               {{room}}
            </div>
            <br>
            <br>
            <br>
            <div class="ui-inputgroup">
              <span class="ui-inputgroup-addon">--</span>
              <input type="text" pInputText placeholder="Type"  [(ngModel)]='type'> 
             {{type}}
            </div>
            <br>
            <div class="ui-inputgroup">
              <span class="ui-inputgroup-addon"><i class="fa fa-credit-card"></i></span>  
              <input type="text" pInputText placeholder="Message"  [(ngModel)]='message'> 
              <p-button label="Send" (click)="sendMessage()"></p-button>
             {{message}}
            </div>
            <br>
            <div class="ui-inputgroup">
              <span class="ui-inputgroup-addon"><i class="fa fa fa-cc-visa"></i></span>  
              <input type="text" pInputText placeholder="MessageAll"  [(ngModel)]='messageAll'> 
              <p-button label="群发消息" (click)="sendMessageToAll()"></p-button>
            </div>
            <br>
            = = = = = =
            <p>这是来自聊天室的消息:</p>
            <br>
            <div  >
              <li *ngFor="let item of items">
                  {{item.user}} - {{item.content}} 
              </li>
            </div>
            = = = = = =
            <p>这是群发的消息:</p>
            <br>
            <div  >
              <li *ngFor="let atem of atems">
                  {{atem.user}} - {{atem.content}} 
              </li>
            </div>
          </div>
        </div>

        image.gif

        接着改造websocket.component.ts:

        import { Component, OnInit } from '@angular/core';
        import * as Stomp from 'stompjs';
        import * as SockJS from 'sockjs-client';
        import { Item } from '../entity/item';
        import { Atem } from '../entity/atem';
        @Component({
          selector: 'app-websocket',
          templateUrl: './websocket.component.html',
          styleUrls: ['./websocket.component.scss']
        })
        export class WebsocketComponent implements OnInit {
          public stompClient;
          public serverUrl = "http://localhost:8080/websocket";
          public room;//频道号
          public sender;//发送者
          public type;//消息的类型
          public message;//消息内容
          public messageAll;//群发消息的内容
          items = [];
          atems = [];
          constructor() { 
          }
          ngOnInit() {
              // this.connect();
          }
          connect() {
            if(this.sender===undefined) {
              alert("发送者不能为空")
              return
            }
            if(this.room===undefined) {
              alert("房间号不能为空")
              return
            }
            const ws = new SockJS(this.serverUrl);
            this.stompClient = Stomp.over(ws);
            const that = this;
            this.stompClient.connect({}, function (frame) {
             //获取聊天室的消息
              that.stompClient.subscribe('/topic/' + that.room, (message) => {
                if (message.body) {
                  const sender = JSON.parse(message.body)['sender'];
                  // const language = JSON.parse(message.body)['language'];
                  const content = JSON.parse(message.body)['content'];
                  const type = JSON.parse(message.body)['type'];
                  const newitem = new Item(
                   type,
                   sender,
                   content
                  );
                  that.items.push(newitem);
                }else{
                  return
                }
              });
               //获取群发消息
            that.stompClient.subscribe('/all', (message) =>{
              if (message.body) {
                const sender = JSON.parse(message.body)['sender'];
                // const language = JSON.parse(message.body)['language'];
                const content = JSON.parse(message.body)['content'];
                const type = JSON.parse(message.body)['type'];
                const newatem = new Atem(
                 type,
                 sender,
                 content
                );
                that.atems.push(newatem);
              }else{
                return
              }
            })
            });
          }
          //断开连接的方法
          disconnect() {
              if (this.stompClient !== undefined) {
                this.stompClient.disconnect();
              }else{
                alert("当前没有连接websocket")
              }
              this.stompClient = undefined;
              alert("Disconnected");
          }
          //发送消息(单聊)
          sendMessage() {
            if(this.stompClient===undefined) {
              alert("websocket还未连接")
              return
            };
            if(this.type===undefined) {
              alert("消息类型不能为空")
              return
            };
            if(this.message===undefined) {
              alert("消息内容不能为空")
              return
            };
              this.stompClient.send(
                '/app/chat',
                {},
                JSON.stringify({
                  'sender': this.sender,
                  'room': this.room,
                  'type': this.type,
                  'content': this.message })
              );
            }
            //群发消息
            sendMessageToAll() {
              if(this.stompClient===undefined) {
                alert("websocket还未连接")
                return
              };
              if(this.messageAll===undefined) {
                alert("群发消息内容不能为空")
                return
              };
                this.stompClient.send(
                  '/app/chatAll',
                  {},
                  JSON.stringify({
                    'sender': this.sender,
                    'room': "00000",
                    'type': "00000",
                    'content': this.messageAll })
                );
              }
        }

        image.gif

        测试+最终效果图

        聊天室功能测试(单聊+多聊):

        image.png

        群发功能测试:

        image.png

        这样我们就完成了STOMP完成聊天室(单聊+多聊)及群发功能的实现。

        小伙伴们是不是感觉STOMP协议比原生的websocket协议开发简单快捷多了

        ---

        本篇文章到这里就结束啦,很感谢你能看到最后,欢迎关注!

        相关文章
        |
        消息中间件 Java RocketMQ
        SpringBoot整合RocketMQ发送批量消息
        SpringBoot整合RocketMQ发送批量消息
        |
        存储 消息中间件 Java
        SpringBoot整合RocketMQ发送延时消息
        当消息写入到Broker后,在指定的时长后才可被消费处理的消息,称为延时消息
        |
        1月前
        |
        前端开发 JavaScript Java
        【十五】springboot整合WebSocket实现聊天室
        【十五】springboot整合WebSocket实现聊天室
        28 0
        |
        9月前
        |
        Java Spring
        spring boot构建Stomp客户端
        配置包括三部分,一个是基本的websocket客户端配置,另一个是Stomp客户端配置和会话处理
        104 0
        |
        6月前
        |
        前端开发 JavaScript Java
        SpringBoot整合Socket实战案例,实现单点、群发,1对1,1对多
        本篇内容: 后端 + 前端简单HTML页面 功能场景点: 群发,所有人都能收到 局部群发,部分人群都能收到 单点推送, 指定某个人的页面
        |
        9月前
        |
        前端开发 安全 Java
        SpringBoot + WebSocket+STOMP指定推送消息
        本文将简单的描述SpringBoot + WebSocket+STOMP指定推送消息场景,不包含信息安全加密等,请勿用在生产环境。
        191 0
        |
        前端开发 JavaScript Java
        Springboot 整合 Socket 实战案例 ,实现 单点发送、广播群发,1对1,1对多
        Springboot 整合 Socket 实战案例 ,实现 单点发送、广播群发,1对1,1对多
        1155 0
        Springboot 整合 Socket 实战案例 ,实现 单点发送、广播群发,1对1,1对多
        |
        消息中间件 缓存 监控
        SpringBoot与缓存、消息、检索、任务、安全与监控
        SpringBoot与缓存、消息、检索、任务、安全与监控
        |
        消息中间件 Unix Java
        SpringBoot整合RocketMQ发送事务消息
        RocketMQ提供了类似X/Open XA的分布式事务功能,通过事务消息能达到分布式事务的最终一致。XA是一种分布式事务解决方案,一种分布式事务处理模式
        957 1
        |
        消息中间件 算法 数据可视化
        SpringBoot整合RocketMQ发送顺序消息
        严格按照消息的发送顺序进行消费的消息。默认情况下生产者会把消息以Round Robin轮询方式发送到不同的Queue分区队列,而消费消息时会从多个Queue上拉取消息,这种情况下的发送和消费是不能保证顺序的,如果将消息仅发送到同一个Queue中,消费时也只从这个Queue上拉取消息,就严格保证了消息的顺序性