Spring Boot 使用WebSocket、SockJS、STOMP实现消息功能(三)

一、介绍

这里主要基于上一篇介绍一下基于SocketJS+Stomp来实现的的长连接。我借鉴了其他的一些博客,只是把我用的知识总结在了一起方便我自己以后回顾。

二、WebSocket概念

概述:
WebSocket协议提供了通过一个套接字实现全双工通信的功能。除了其他的功能之外,它能够实现Web浏览器和服务器之间的异步通信。全双工意味着服务器可以发送消息给浏览器,浏览器也可以发送消息给服务器。

使用Spring的低层级WebSocketAPI
按照其最简单的形式,WebSocket只是两个应用之间通信的通道。位于WebSocket一端的应用发送消息,另一端接收消息。因为它是全双工的,所以每一端都可以发送和处理消息。

WebSocket通信可以应用于任何类型的应用中,但是WebSocket最常见的应用场景是实现服务器和基于浏览器的应用之间的通信。由于websocket协议是个低层协议, 不是应用层协议, 未对payload的格式进行规范, 导致我们需要自己定义消息体格式, 自己解析消息体, 成本高, 扩展性也不好, 所以我们引入了已被很多库和消息队列厂商实现的stomp协议, 将websocket协议与stomp协议结合。

我们再总结一下websocket与stomp的优点
websocket相对于http的优点:

全双工. 相对于http协议只能由client发送消息. 全双工的websocket协议, server与client都可以发送消息.
消息体更轻量. http的一个请求比websocket的请求大不少. 主要因为http的每次请求都要加很多的header.

stomp over websocket相对于websocket的优点:

不需要自己去规定消息的格式, 以及对消息的格式做解析.
由于stomp是一个统一的标准, 有很多库与厂商都对stomp协议进行了支持. 拿来用就可以. 成本低, 扩展好

三、代码实现

maven依赖

<?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.aisino</groupId>
    <artifactId>message-push</artifactId>
    <version>1.0-SNAPSHOT</version>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>Finchley.RC2</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

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

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-openfeign</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.16.10</version>
    </dependency>

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

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

</dependencies>

    <repositories>
        <repository>
            <id>spring-milestones</id>
            <name>Spring Milestones</name>
            <url>https://repo.spring.io/libs-milestone</url>
            <snapshots>
                <enabled>false</enabled>
            </snapshots>
        </repository>
    </repositories>

</project>

前段JS代码

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js"></script>
    <script src="https://cdn.bootcss.com/sockjs-client/1.4.0/sockjs.min.js"></script>
    <script src="https://cdn.bootcss.com/stomp.js/2.3.3/stomp.min.js"></script>
</head>
<body>
<h2>测试</h2>
<input id="start">
<input id="message">
<iframe id="my_iframe" style="display:none;"></iframe>
</body>
<script>
    var sock = new SockJS('http://127.0.0.1:8081/websocket?token=' + '0b2723a4-02d0-4f9c-8e4f-9ae959da7185');  //连接节点
    var stomp = Stomp.over(sock);
    stomp.connect({}, function(frame){
        console.log('Connected:你好' + frame);
        //连接成功后订阅消息接口
        //订阅个人消息
        stomp.subscribe('/user/queue/msg/new', function(response){
            console.log("订阅个人消息");
            var result = response.body;
            $("#message").val(result);
        });
        //消息发送结果
        stomp.subscribe('/user/queue/msg/result', function(response){
            console.log("消息发送结果");

        });

        //订阅广播消息
        stomp.subscribe('/topic/notice', function(response){
            console.log("订阅广播消息");
            var result = response.body;
            $("#message").val(result);
        });
    });

    //发送广播
    stomp.send('/app/addNotice', {}, '广播内容');
    //发送消息
    var msg = {
        toName: '接收人',
        content: '消息内容'
    };
    stomp.send('/app/msg', {}, JSON.stringify(msg));

</script>
</html>

首先要建立前段与后端服务的长连接,我这里采用的token认证,拿着token去认证授权再成功拿到用户信息后才能连接成功。

package com.wyc.messagepush.configure;

import com.wyc.messagepush.entity.MyPrincipal;
import com.wyc.messagepush.service.PrinalInterface;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.server.HandshakeInterceptor;
import org.springframework.web.socket.server.support.DefaultHandshakeHandler;
import java.util.Map;
import java.security.Principal;

@Configuration  //注册为 Spring 配置类
/*
 * 开启使用STOMP协议来传输基于代理(message broker)的消息
 * 启用后控制器支持@MessgeMapping注解
 */
@EnableWebSocketMessageBroker
//继承 AbstractWebSocketMessageBrokerConfigurer 的配置类实现 WebSocket 配置或实现WebSocketMessageBrokerConfigurer接口
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    private static final Logger logger = LoggerFactory.getLogger(WebSocketConfig.class);
   // feign实例调用用户中心获取Principal或税号等信息对象
    @Autowired
    private PrinalInterface prinalInterface;
    //注册STOMP协议节点并映射url
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/websocket") //注册一个 /websocket 的 websocket 节点
                .addInterceptors(myHandshakeInterceptor())  //添加 websocket握手拦截器
                .setHandshakeHandler(myDefaultHandshakeHandler())   //添加 websocket握手处理器
                .setAllowedOrigins("*") //设置允许可跨域的域名
                .withSockJS();  //指定使用SockJS协议
    }

    /**
     * WebSocket 握手拦截器
     * 可做一些用户认证拦截处理
     */
    private HandshakeInterceptor myHandshakeInterceptor(){
        return new HandshakeInterceptor() {
            /**
             * websocket握手连接
             * @return 返回是否同意握手
             */
            @Override
            public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
                ServletServerHttpRequest req = (ServletServerHttpRequest) request;
                //通过url的query参数获取认证参数
                String token = req.getServletRequest().getParameter("token");
                //根据token认证用户并拿到用户信息,不通过返回拒绝握手
                Principal user = authenticate();
                if(user == null){
                    logger.info("Authentication is failed!!! Connection rejection.");
                    return false;
                }
                logger.info("Authentication is Ok,Saving Authenticated Users,Username is "+user.getName());
                //保存认证用户
                attributes.put("token", token);
                attributes.put("user", user);
                return true;
            }

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

            }
        };
    }

    //WebSocket 握手处理器
    private DefaultHandshakeHandler myDefaultHandshakeHandler(){
        return new DefaultHandshakeHandler(){
            @Override
            protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {
                //设置认证通过的用户到当前会话中
                return (Principal)attributes.get("user");
            }
        };
    }

    /**
     * 定义一些消息连接规范(也可不设置)
     * @param registry
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        //设置客户端接收消息地址的前缀(可不设置)
        registry.enableSimpleBroker(
                "/topic",   //广播消息前缀
                "/queue"    //点对点消息前缀
        );
        //设置客户端接收点对点消息地址的前缀,默认为 /user
        registry.setUserDestinationPrefix("/user");
        //设置客户端向服务器发送消息的地址前缀(可不设置)
        registry.setApplicationDestinationPrefixes("/app");
        //   Use this for enabling a Full featured broker like RabbitMQ

        /*生产环境相关配置信息
        registry.enableStompBrokerRelay("/topic")
                .setRelayHost("localhost")
                .setRelayPort(61613)
                .setClientLogin("guest")
                .setClientPasscode("guest");
        */
    }

    /**
     * 根据token认证授权
     * @param token
     */
    private Principal authenticate(){
        //TODO 实现用户的认证并返回用户信息,如果认证失败返回 null
        // 一种:用户信息需继承 Principal 并实现 getName() 方法,返回全局唯一值
        // 二种:这里实现的是用token换取用户信息
        String username =  prinalInterface.member();
        MyPrincipal principal =  prinalInterface.test();
        if (principal == null)
        {
            logger.error("Failed to invoke authentication service!");
            return null;
        }
        principal.setUsername("12345678900"+username);
        System.out.println(principal.toString());
        System.out.println(principal.getName());
        logger.info("FeignClient is succeed,Username is:"+username);
        if(principal != null)
            return principal;
        return null;
    }


}

在上面握手处理完毕后建立连接,我写了一个监听类,可以监听建立成功或断开连接的状态,可以进行一些处理。比如sessionid的存储和删除。

package com.wyc.messagepush.controller;

import com.wyc.messagepush.entity.MyPrincipal;
import com.wyc.messagepush.entity.SocketSessionRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.GenericMessage;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionConnectedEvent;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;
import java.util.Map;

/**
 * Created by wyc on 25/07/19.
 */
@Component
public class WebSocketEventListener {

    private static final Logger logger = LoggerFactory.getLogger(WebSocketEventListener.class);

    /**session操作类*/
    @Autowired
    SocketSessionRegistry webAgentSessionRegistry;
    //Spring WebSocket消息发送模板
    @Autowired
    private SimpMessageSendingOperations messagingTemplate;

    @EventListener
    public void handleWebSocketConnectListener( SessionConnectedEvent event) {
        String sessionId = (String) event.getMessage().getHeaders().get("simpSessionId");
        GenericMessage genericMessage = (GenericMessage) event.getMessage().getHeaders().get("simpConnectMessage");
        Map mapUser = (Map) genericMessage.getHeaders().get("simpSessionAttributes");
        String username = ((MyPrincipal)mapUser.get("user")).getName();
        logger.info("Successful Connection Establishment,Username is:"+username);
        webAgentSessionRegistry.registerSessionId(username,sessionId);
        logger.info("Received a new web socket connection");
    }

    @EventListener
    public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
        String sessionId = (String) event.getMessage().getHeaders().get("simpSessionId");
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
     //   String username = (String) headerAccessor.getSessionAttributes().get("user");
        String username = ((MyPrincipal) headerAccessor.getSessionAttributes().get("user")).getName();
        if(username != null) {
            // 连接断开时,将session从全局HashMap移除
            webAgentSessionRegistry.unregisterSessionId(username,sessionId);
            logger.info("User Disconnected : " + username);
/*
          // 如果是聊天室项目退出时可以发消息进行广播通知
            ChatMessage chatMessage = new ChatMessage();
            chatMessage.setType(ChatMessage.MessageType.LEAVE);
            chatMessage.setSender(username);

            messagingTemplate.convertAndSend("/topic/public", chatMessage);*/
        }
    }
}

到这连接建立算是成功了。下面是controller层和实体类的主要实现:

package com.wyc.messagepush.controller;

import com.wyc.messagepush.entity.SocketSessionRegistry;
import com.wyc.messagepush.entity.WsMessage;
import com.alibaba.fastjson.JSON;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessageType;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.annotation.SendToUser;
import org.springframework.stereotype.Controller;
import org.springframework.messaging.MessageHeaders;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

import java.security.Principal;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentMap;

@Controller //注册一个Controller,WebSocket的消息处理需要放在Controller下
public class WsController {
    // 开启日志
    private static final Logger logger = LoggerFactory.getLogger(WsController.class);
    //Spring WebSocket消息发送模板
    @Autowired
    private SimpMessagingTemplate messagingTemplate;
    /**session操作类*/
    @Autowired
    private SocketSessionRegistry webAgentSessionRegistry;
    //发送广播通知

    @MessageMapping("/addNotice")   //接收客户端发来的消息,客户端发送消息地址为:/app/addNotice
    @SendTo("/topic/notice")        //向客户端发送广播消息(方式一),客户端订阅消息地址为:/topic/notice
    public WsMessage notice(String notice, Principal fromUser) {
        //TODO 业务处理
        WsMessage msg = new WsMessage();
     //   msg.setFromName(fromUser.getName());
        msg.setContent(notice);
        System.out.println("notice");
        //向客户端发送广播消息(方式二),客户端订阅消息地址为:/topic/notice
//        messagingTemplate.convertAndSend("/topic/notice", msg);
        return msg;
    }

    //发送点对点消息
    @MessageMapping("/msg")         //接收客户端发来的消息,客户端发送消息地址为:/app/msg
    @SendToUser("/queue/msg/result") //向当前发消息客户端(就是自己)发送消息的发送结果,客户端订阅消息地址为:/user/queue/msg/result
    public boolean sendMsg(WsMessage message, Principal fromUser){
        //TODO 业务处理
        message.setFromName(fromUser.getName());
        //向指定客户端发送消息,第一个参数Principal.name为前面websocket握手认证通过的用户name(全局唯一的),客户端订阅消息地址为:/user/queue/msg/new
        messagingTemplate.convertAndSendToUser(message.getToName(), "/queue/msg/new", message);
        return true;
    }

    //广播推送消息
    // @Scheduled(fixedRate = 10000)
    //@SendTo("/topic/notice")
    public void sendTopicMessage() {
        System.out.println("后台广播推送!");
        WsMessage wsMessage=new WsMessage();
        wsMessage.setToName("oyzc");
        wsMessage.setContent("一百万");
        this.messagingTemplate.convertAndSend("/topic/notice",wsMessage);
    }

    /**
     * 同样的发送消息   只不过是ws版本  http请求不能访问
     * 根据用户key发送消息
     * @param
     * @return
     * @throws Exception
     */
    @MessageMapping("/msg/hellosingle")
    public void greeting2() throws Exception {
        Map<String,String> params = new HashMap(1);
        params.put("test","test");
        System.out.println("单点推送!");
        WsMessage message=new WsMessage();
        message.setToName("2");
        message.setContent("您有新消息待查看!");
        //这里没做校验
        String sessionId=webAgentSessionRegistry.getSessionIds(message.getToName()).stream().findFirst().get();
      //  String sessionId=webAgentSessionRegistry.getSessionIds(message.getToName());
        System.out.println("sessionId:"+sessionId);
        messagingTemplate.convertAndSendToUser(sessionId,"/queue/msg/new",message,createHeaders(sessionId));
    }


    @RequestMapping(value = "/message", method = RequestMethod.POST)
    @ResponseBody
    public String messageInform(@RequestParam("Json")String messageJson) {
        Map<String,String> map = (Map) JSON.parse(messageJson);
        System.out.println("单点推送!");
        WsMessage message=new WsMessage();
       // message.setToName("1");
        message.setContent("1您有新消息待查看!");
        message.setCTaxNo(map.get("No"));
        message.setIMachineNo(Integer.valueOf(map.get("MachineNo")));
        ConcurrentMap<String, Set<String>> concurrentMap = webAgentSessionRegistry.getAllSessionIds();
        for (String key: concurrentMap.keySet()){
            if (key.toUpperCase().contains(message.getCTaxNo()))
            {
                //这里没做校验
                String sessionId=webAgentSessionRegistry.getSessionIds(key).stream().findFirst().get();
                //  String sessionId=webAgentSessionRegistry.getSessionIds(message.getToName());
                System.out.println("sessionId:"+sessionId);
                messagingTemplate.convertAndSendToUser(sessionId,"/queue/msg/new",message,createHeaders(sessionId));
            }

        }
        return "200";

    }

    private MessageHeaders createHeaders(String sessionId) {
        SimpMessageHeaderAccessor headerAccessor = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE);
        headerAccessor.setSessionId(sessionId);
        headerAccessor.setLeaveMutable(true);
        return headerAccessor.getMessageHeaders();
    }
    /*@MessageMapping("/chat.sendMessage")
    @SendTo("/topic/public")
    public ChatMessage sendMessage(@Payload ChatMessage chatMessage) {
        return chatMessage;
    }

    @MessageMapping("/chat.addUser")
    @SendTo("/topic/public")
    public ChatMessage addUser(@Payload ChatMessage chatMessage,
                               SimpMessageHeaderAccessor headerAccessor) {
        // Add username in web socket session
        headerAccessor.getSessionAttributes().put("username", chatMessage.getSender());
        return chatMessage;
    }*/

}
package com.wyc.messagepush.entity;

import org.springframework.stereotype.Component;
import org.springframework.util.Assert;

import java.util.Collections;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.CopyOnWriteArraySet;

/**
 *
 * 用户session记录类,用来保存用户名与sessionId的映射
 */
@Component
public class SocketSessionRegistry{
    //this map save every session
    //这个集合存储session
    private final ConcurrentMap<String, Set<String>> userSessionIds = new ConcurrentHashMap();
    private final Object lock = new Object();

    public SocketSessionRegistry() {
    }

    /**
     *
     * get sessionId
     * @param user
     * @return
     */
    public Set<String> getSessionIds(String user) {
        Set set = (Set)this.userSessionIds.get(user);
        return set != null?set: Collections.emptySet();
    }

    /**
     * get all session
     * @return
     */
    public ConcurrentMap<String, Set<String>> getAllSessionIds() {
        return this.userSessionIds;
    }

    /**
     * register session
     * @param user
     * @param sessionId
     */
    public void registerSessionId(String user, String sessionId) {
        Assert.notNull(user, "User must not be null");
        Assert.notNull(sessionId, "Session ID must not be null");
        Object var3 = this.lock;
        synchronized(this.lock) {
            Object set = (Set)this.userSessionIds.get(user);
            if(set == null) {
                set = new CopyOnWriteArraySet();
                this.userSessionIds.put(user, (Set<String>) set);
            }

            ((Set)set).add(sessionId);
        }
    }

    /**
     * remove session
     * @param userName
     * @param sessionId
     */
    public void unregisterSessionId(String userName, String sessionId) {
        Assert.notNull(userName, "User Name must not be null");
        Assert.notNull(sessionId, "Session ID must not be null");
        Object var3 = this.lock;
        synchronized(this.lock) {
            Set set = (Set)this.userSessionIds.get(userName);
            if(set != null && set.remove(sessionId) && set.isEmpty()) {
                this.userSessionIds.remove(userName);
            }

        }
    }
}
package com.wyc.messagepush.entity;

import java.security.Principal;
/**
 * 实现Principal接口用来接收Feign调用的OAuth2的Principal的值
 * 所有的序列化操作必须要有默认构造器,可以不写,这里做一下说明
 *
 */
public class MyPrincipal implements Principal {
    // 用户名
    private String username;

    public MyPrincipal(){
    }

    public void setUsername(String username) {
        this.username = username;
    }

    @Override
    public String getName() {
        return username;
    }
}

 

package com.wyc.messagepush.entity;

import lombok.Data;

/**
 * 消息实体类
 */
@Data
public class WsMessage {
    //消息接收人,对应认证用户Principal.name(全局唯一)
    private String toName;
    //消息发送人,对应认证用户Principal.name(全局唯一)
    private String fromName;
    //消息内容
    private Object content;
    // token
    private String token;
    // 主号码
    private String No;
    // 次号
    private int  MachineNo;

}

 启动类,我这里用到了feign声明式调用和自动调度:

package com.wyc;

import org.springframework.boot.SpringApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.cloud.openfeign.EnableFeignClients;

@SpringBootApplication
@EnableScheduling
@EnableEurekaClient
@EnableFeignClients(basePackages = "com.wyc.messagepush.service")
public class PushApplication {
    public static void main(String[] args) {
        SpringApplication.run(PushApplication.class,args);
    }
}

四、SockJS

概述
WebSocket是一个相对比较新的规范,在Web浏览器和应用服务器上没有得到一致的支持。所以我们需要一种WebSocket的备选方案。
而这恰恰是SockJS所擅长的。SockJS是WebSocket技术的一种模拟,在表面上,它尽可能对应WebSocket API,但是在底层非常智能。如果WebSocket技术不可用的话,就会选择另外的通信方式。

使用SockJS
WebSocketConfig.java

 @Override
 public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
  registry.addHandler(marcoHandler(), "/marco").withSockJS();
 }


只需加上withSockJS()方法就能声明我们想要使用SockJS功能,如果WebSocket不可用的话,SockJS的备用方案就会发挥作用。
JavaScript客户端代码
要在客户端使用SockJS,需要确保加载了SockJS客户端库。

<script src="https://cdn.bootcss.com/sockjs-client/1.1.4/sockjs.min.js"></script>

除了加载SockJS客户端库外,要使用SockJS只需要修改两行代码即可:

        var url = 'marco';
        var sock = new SockJS(url);   //SockJS所处理的URL是http://或https://,不再是ws://和wss://
                   //使用相对URL。例如,如果包含JavaScript的页面位于"http://localhost:8080/websocket"的路径下
                   // 那么给定的"marco"路径将会形成到"http://localhost:8080/websocket/marco"的连接

运行效果一样,但是客户端–服务器之间通信的方式却有了很大的变化。

五、使用STOMP消息

概述
STOMP在WebSocket之上提供了一个基于帧的线路格式层,用来定义消息的语义。STOMP帧由命令、一个或多个头信息以及负载所组成。例如如下就是发送数据的一个STOMP帧:

>>> SEND
destination:/app/marco
content-length:20

{"message":"Maeco!"}

在这个简单的样例中,STOMP命令是SEND,表明会发送一些内容。紧接着是两个头信息:一个用来表示消息要发送到哪里的目的地,另外一个则包含了负载的大小。然后,紧接着是一个空行,STOMP帧的最后是负载内容。
STOMP帧中最有意思的是destination头信息了。它表明STOMP是一个消息协议。消息会发布到某个目的地,这个目的地实际上可能真的有消息代理作为支撑。另一方面,消息处理器也可以监听这些目的地,接收所发送过来的消息。

启用STOMP消息功能
WebSocketStompConfig.java

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketStompConfig extends AbstractWebSocketMessageBrokerConfigurer{

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        
        registry.addEndpoint("/marcopolo").withSockJS();//为/marcopolo路径启用SockJS功能
    }
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry)
    {
        
        //表明在topic、queue、users这三个域上可以向客户端发消息。
        registry.enableSimpleBroker("/topic","/queue","/users");
        //客户端向服务端发起请求时,需要以/app为前缀。
        registry.setApplicationDestinationPrefixes("/app");
        //给指定用户发送一对一的消息前缀是/users/。
        registry.setUserDestinationPrefix("/users/");
    }
    
}
 @Override
 protected Class<?>[] getServletConfigClasses() {
  return new Class<?>[] {WebSocketStompConfig.class,WebConfig.class};
 }

WebSocketStompConfig 重载了registerStompEndpoints()方法,将/marcopolo注册为STOMP端点。这个路径与之前接收和发送消息的目的地路径有所不同。这是一个端点,客户端在订阅或发布消息到目的地前,要连接该端点。
WebSocketStompConfig还通过重载configureMessageBroker()方法配置了一个简单的消息代理。这个方法是可选的,如果不重载它的话,将会自动配置一个简单的内存消息代理,用它来处理以“/topic”为前缀的消息。

处理来自客户端的STOMP消息
testConroller.java

@Controller
public class testConroller {
    @MessageMapping("/marco")
    public void handleShout(Shout incoming) 
    {
    System.out.println("Received message:"+incoming.getMessage());
    }
    
    @SubscribeMapping("/subscribe")
    public Shout handleSubscribe() 
    {
    Shout  outing = new Shout();
    outing.setMessage("subscribes");
    return outing;
    }
}

@MessageMapping注解,表明handleShout()方法能够处理指定目的地上到达的消息。本例中目的地也就是“/app/marco”。(“/app”前缀是隐含 的,因为我们将其配置为应用的目的地前缀)
@SubscribeMapping注解,与@MessageMapping注解相似,当收到了STOMP订阅消息的时候,带有@SubscribeMapping注解的方法将会被触发。

Shout.java

public class Shout {
private String message;

public String getMessage() {
    return message;
}

public void setMessage(String message) {
    this.message = message;
}

}

客户端JavaScript代码

<script src="https://cdn.bootcss.com/sockjs-client/1.1.4/sockjs.min.js"></script>
<script src="https://cdn.bootcss.com/stomp.js/2.3.3/stomp.min.js"></script>
<script src="https://cdn.bootcss.com/stomp.js/2.3.3/stomp.js"></script>
<script>
var url = 'http://'+window.location.host+'/yds/marcopolo';
var sock = new SockJS(url);  //创建SockJS连接。
var stomp = Stomp.over(sock);//创建STOMP客户端实例。实际上封装了SockJS,这样就能在WebSocket连接上发送STOMP消息。
var payload = JSON.stringify({'message':'Marco!'});
stomp.connect('guest','guest',function(frame){
stomp.send("/app/marco",{},payload);
stomp.subscribe('/app/subscribe', function(message){
            
            });
});
</script> 

Received message:Marco!

这里写图片描述

这里写图片描述

这里写图片描述
发送消息到客户端
如果你想要在接收消息的时候,同时在响应中发送一条消息,那么需要做的仅仅是将内容返回就可以了。

@MessageMapping("/marco")	
public Shout handleShout(Shout incoming) {
	System.out.println("Received message:"+incoming.getMessage());
	Shout  outing = new Shout();
	outing.setMessage("Polo");
	return outing;
}

当@MessageMapping注解标示的方法有返回值的时候,返回的对象将会进行转换(通过消息转换器)并放到STOMP帧的负载中,然后发给消息代理。
默认情况下,帧所发往的目的地会与触发处理器方法的目的地相同,只不过会加上“/topic”前缀。

stomp.subscribe('/topic/marco', function(message){    订阅后将会接收到消息。
});

这里写图片描述

不过我们可以通过为方法添加@SendTo注解,重载目的地:

@MessageMapping("/marco")
@SendTo("/queue/marco")
public Shout handleShout(Shout incoming) {
    System.out.println("Received message:"+incoming.getMessage());
    Shout  outing = new Shout();
    outing.setMessage("Polo");
    return outing;
}
stomp.subscribe('/queue/marco', function(message){ 
});

这里写图片描述

在应用的任意地方发送消息
Spring的SimpMessagingTemplate能够在应用的任何地方发送消息,甚至不必以首先接收一条消息作为前提。
使用SimpMessagingTemplate的最简单方式是将它(或者其接口SimpMessageSendingOperations)自动装配到所需的对象中。

 @Autowired
 private SimpMessageSendingOperations simpMessageSendingOperations;


@RequestMapping("/test")
    public void sendMessage()
    {
        simpMessageSendingOperations.convertAndSend("/topic/test", "测试SimpMessageSendingOperations ");
    }

访问/test后:

这里写图片描述

为目标用户发送消息
使用@SendToUser注解,表明它的返回值要以消息的形式发送给某个认证用户的客户端。

    @MessageMapping("/message")
    @SendToUser("/topic/sendtouser")
    public Shout message()
    {
        Shout  outing = new Shout();
        outing.setMessage("SendToUser");
        return outing;
    }
stomp.subscribe('/users/topic/sendtouser', function(message){//给指定用户发送一对一的消息前缀是/users/。
});

这里写图片描述

这个目的地使用了/users作为前缀,以/users作为前缀的目的地将会以特殊的方式进行处理。以/users为前缀的消息将会通过UserDestinationMessageHandler进行处理。

这里写图片描述

UserDestinationMessageHandler的主要任务是将用户消息重新路由到某个用户独有的目的地上。在处理订阅的时候,它会将目标地址中的/users前缀去掉,并基于用户的会话添加一个后缀。

为指定用户发送消息
SimpMessagingTemplate还提供了convertAndSendToUser()方法。convertAndSendToUser()方法能够让我们给特定用户发送消息。

simpMessageSendingOperations.convertAndSendToUser("1", "/message", "测试convertAndSendToUser");
stomp.subscribe('/users/1/message', function(message){ 

});

这里写图片描述

客户端接收一对一消息的主题是"/users/"+usersId+"/message",这里的用户Id可以是一个普通字符串,只要每个客户端都使用自己的Id并且服务器端知道每个用户的Id就行了。

  • 3
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
好的,以下是一个简单的 Spring Boot 集成 WebSocket 的 Demo,可以实现简单的对话功能: 1. 首先,在 pom.xml 中引入 Spring BootWebSocket 的依赖: ```xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> ``` 2. 创建一个 WebSocket 配置类,用于配置 WebSocket 相关的内容: ```java @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new WebSocketHandler(), "/chat"); } } ``` 3. 创建一个 WebSocketHandler 类,用于处理 WebSocket 的连接和消息: ```java public class WebSocketHandler extends TextWebSocketHandler { private final List<WebSocketSession> sessions = new ArrayList<>(); @Override public void afterConnectionEstablished(WebSocketSession session) { sessions.add(session); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { for (WebSocketSession s : sessions) { s.sendMessage(message); } } } ``` 4. 在 Controller 中添加一个映射,用于返回聊天页面: ```java @Controller public class ChatController { @GetMapping("/") public String index() { return "chat"; } } ``` 5. 创建一个 HTML 页面,用于展示聊天界面和发送消息: ```html <!DOCTYPE html> <html> <head> <title>Chat</title> </head> <body> <div id="messages"></div> <form id="message-form"> <input type="text" id="message-input"> <button type="submit">Send</button> </form> <script src="/webjars/jquery/jquery.min.js"></script> <script src="/webjars/sockjs-client/sockjs.min.js"></script> <script src="/webjars/stomp-websocket/stomp.min.js"></script> <script> var stompClient = Stomp.over(new SockJS("/chat")); stompClient.connect({}, function() { stompClient.subscribe("/chat", function(message) { $("#messages").append("<p>" + message.body + "</p>"); }); }); $("#message-form").submit(function(event) { event.preventDefault(); var message = $("#message-input").val(); stompClient.send("/chat", {}, message); $("#message-input").val(""); }); </script> </body> </html> ``` 这个 Demo 中,前端使用SockJSSTOMP.js 这两个库来实现 WebSocket 的连接和消息发送。后端使用Spring BootWebSocket 功能来处理 WebSocket 的连接和消息。前端和后端通过 /chat 这个 URL 进行连接。 这个 Demo 可以在本地启动后,通过访问 http://localhost:8080/ 来进入聊天界面。多个浏览器窗口之间可以进行简单的对话。
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值