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

上篇文章:springboot+websocket构建在线聊天室(群聊+单聊)

最近发现stomp协议来实现长连接,非常简单(springboot封装的比较好)


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协议了,大家感兴趣,可以去看官网:https://stomp.github.io/

2、环境介绍

后端:springboot 2.0.8

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

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 相关依赖-->

3.2、application.properties

server.port =8080

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();
    }


}

注意:

@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;
    }

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;
    }
}

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);
    }


}

注意:

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

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

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

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

4、前端

新建一个angular7项目

可以参考我之前的文章:

https://blog.csdn.net/qq_41603102/article/details/88359648

然后创建新组件websocket

ng generate component websocket

配置路由:

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 { }

具体可以参考:

https://blog.csdn.net/qq_41603102/article/details/88547300

安装stomp相关的依赖:

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

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'];

        }
          
      });
      

    });

  }
  


}

运行项目:

ng serve --port 4300

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

解决方案:

npm i net -S

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

发现报错:

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;

成功

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

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

下载依赖:

npm install primeng --save
npm install primeicons --save

npm install font-awesome --save 

导入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";

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

import {ButtonModule} from 'primeng/button';


imports: [
    ... ,
    ButtonModule
  ],

新建一个前端显示类:

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;
    }

}

再建一个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;
    }

}

然后我们改造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>

接着改造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 })
        );

      }
    

  
}

测试+最终效果图:

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

群发功能测试:

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

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

上篇文章:springboot+websocket构建在线聊天室(群聊+单聊)

大家可以思考一下这几个简单的问题:websocket如何实现用户登录的安全验证,结合rabbitmq 如何实现消息推送

参考:

https://stomp.github.io/

https://stomp.github.io/stomp-specification-1.2.html

https://juejin.im/post/5b7071ade51d45665816f8c0

https://blog.csdn.net/elonpage/article/details/78446695

猜你喜欢

转载自blog.csdn.net/qq_41603102/article/details/88351729
今日推荐