Spring MVC+STOMP实现Websocket广播订阅、权限认证、一对一通讯

示例代码

简介

WebSocket 是 Html5 新增加特性之一,目的是浏览器与服务端建立全双工的通信方式,解决 http 使用 ajax 轮询或 long-polling 请求-响应带来过多的资源消耗,同时对特殊场景应用提供了全新的实现方式,比如聊天、股票交易、游戏等对对实时性要求较高的行业领域。

原理

WebSocket是一个持久化的协议,只需要一次HTTP握手就可以进行连接。整个通讯过程是建立在一次连接/状态中,也就避免了HTTP的非状态性,服务端会一直知道你的信息,直到你关闭请求,这样服务器就不需要反复解析HTTP协议。同时,服务端就可以主动推送信息给客户端。

传统 HTTP 请求响应

WebSocket 请求响应

STOMP协议

STOMP是一个简单的互操作协议,用于服务器在客户端之间进行异步消息传递。

客户端可以使用SEND命令来发送消息以及描述消息的内容,用SUBSCRIBE命令来订阅消息以及由谁来接收消息。这样就可以建立一个发布订阅系统,消息可以从客户端发送到服务器进行操作,服务器也可以推送消息到客户端。

WebSocket 与 STOMP

WebSocket 是底层协议,STOMP 是适用于WebSocket 的上层协议。直接使用 WebSocket 就类似于使用 TCP 套接字来编写 web 应用,没有高层协议定义消息的语意,不利于开发与维护。同HTTP在TCP套接字上添加请求-响应模型层一样,STOMP在 WebSocket之上提供了一个基于帧的线路格式层,用来定义消息语义。

Spring + STOMP

当使用 Spring 实现 STOMP 时,Spring WebSocket 应用程序充当客户端的 STOMP 代理。消息被路由到 @Controller 消息处理方法,或路由到一个简单的内存代理,经过处理后,发送给订阅用户。

另外,还可以配置 Spring 使用专用的 STOMP 代理(例如RabbitMQ,ActiveMQ 等)来实际传播消息。在这种情况下,Spring 维护代理(MQ系统)的 TCP 连接,将消息转发给它,并将消息传递给连接的 WebSocket 客户端。

Spring + STOMP 实现广播订阅

通讯过程:

  1. 客户端与服务器进行 HTTP 握手连接,连接点 EndPoint 通过 WebSocketMessageBroker 设置
  2. 客户端通过 subscribe 向服务器订阅消息主题(/topic/demo1/greetings)
  3. 客户端可通过 send 向服务器发送消息,消息通过路径 /app/demo1/hello/10086 达到服务端,服务端将其转发到对应的Controller(根据Controller配置的 @MessageMapping(“/demo1/hello/{typeId}”) 信息)
  4. 服务器一旦有消息发出,将被推送到订阅了相关主题的客户端(Controller中的@SendTo(“/topic/demo1/greetings”)表示将方法中 return 的信息推送到 /topic/demo1/greetings 主题)

服务端 WebSocketMessageBroker 配置

  1. 设置对外暴露的 EndPoint ,客户端通过这个 EndPoint 进行业务接入
  2. 设置Broker,配置订阅主题、以及客户端消息的前缀等信息
//springBoot2.0版本后使用 实现WebSocketMessageBrokerConfigurer接口;
//2.0以下版本继承AbstractWebSocketMessageBrokerConfigurer 类;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        //注册一个Stomp 协议的endpoint指定URL为myWebSocket,并用.withSockJS()指定 SockJS协议。.setAllowedOrigins("*")设置跨域
        registry.addEndpoint("/socket").setAllowedOrigins("*").withSockJS();
    }


    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {

        /*
         * 配置消息代理(message broker)
         * 用户可以订阅来自"/topic"和"/user"的消息,
         * 在Controller中,可通过@SendTo注解指明发送目标,这样服务器就可以将消息发送到订阅相关消息的客户端
         *
         * 在本Demo中,使用topic来达到群发效果,使用user进行一对一发送
         *
         * 客户端只可以订阅这两个前缀的主题
         * 广播订阅(topic) 单独聊天(/user)
         */
        config.enableSimpleBroker("/topic", "/user");

        /*
         * 客户端发送过来的消息,需要以"/app"为前缀,再经过Broker转发给响应的Controller
         */
        config.setApplicationDestinationPrefixes("/app");

        /*
         * 一对一发送的前缀
         * 订阅主题:/user/{userID}/v3/greeting
         * 推送方式:1、@SendToUser("/v3/greeting")
         *          2、messagingTemplate.convertAndSendToUser(destUsername, "/demo3/greetings", greeting);
         */
        config.setUserDestinationPrefix("/user");
    }
}

服务端 Controller 配置

  1. 配置程序入口 URI @MessageMapping(“/v1/greeting/{typeId}”)
  2. 配置消息推送的目标主题 @SendTo(“/v1/greeting2/{typeId}”)
@Controller
public class GreetingController {

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

    @Autowired
    private SimpMessageSendingOperations simpMessageSendingOperations;//消息发送模板

    @Autowired
    private SimpMessagingTemplate simpMessagingTemplate;//消息发送模板


    @RequestMapping(value = {"/greeting1"}, method = RequestMethod.GET)
    public void greetingV1() {

    }

    /*
     * 使用restful风格
     */
    @MessageMapping("/v1/greeting/{typeId}")
    @SendTo("/topic/v1/greeting")
    public Greeting greetingV1$1(@DestinationVariable Integer typeId, GreetingMessage message, @Headers Map<String, Object> headers) throws Exception {
        return new Greeting(headers.get("simpSessionId").toString(), typeId + "---" + message.getMessage());
    }

    /*
     * 这里没用@SendTo注解指明消息目标接收者,消息将默认通过@SendTo("/topic/v1/greeting2/{typeId}")交给Broker进行处理
     * 不推荐不使用@SendTo注解指明目标接受者
     */
    @MessageMapping("/v1/greeting2/{typeId}")
    public Greeting greetingV1$2(GreetingMessage message) {
        return new Greeting("这是没有指明目标接受者的消息:", message.getMessage());
    }
}

客户端连接与订阅

  1. 配置 WebSocket 连接的URI:/socket
  2. 配置客户端订阅的主题:/topic/v1/greeting,/topic/v1/greeting2/10086
function connect() {
    var socket = new SockJS('https://xyzla.com/socket'); //连接SockJS的endpoint名称为"socket"
    stompClient = Stomp.over(socket);//使用STMOP子协议的WebSocket客户端
    var headers = {
        username: $("#username").val(),
        password: $("#password").val()
    };
    console.log("HEADERS:\t" + JSON.stringify(headers));
    stompClient.connect(headers, function (frame) {//连接WebSocket服务端
        console.log('Connected:' + frame);
        stompClient.subscribe('/topic/v1/greeting', function (greeting) {
            showGreeting(JSON.parse(greeting.body).username, JSON.parse(greeting.body).body);
        });
        stompClient.subscribe('/topic/v1/greeting2/10086', function (greeting) {
            showGreeting(JSON.parse(greeting.body).username, JSON.parse(greeting.body).body);
        });
    });
}

Spring + STOMP 实现用户验证

服务端设置请求拦截器

  1. 为 configureClientInboundChannel 设置拦截器
  2. WebSocket 首次请求连接的时候,获取其 Header 信息,利用Header 里面的信息进行权限认证
  3. 通过认证的用户,使用 accessor.setUser(user); 方法,将登陆信息绑定在该 StompHeaderAccessor 上,在Controller方法上可以获取 StompHeaderAccessor 的相关信息
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
    registration.setInterceptors(new ChannelInterceptorAdapter() {
        @Override
        public Message<?> preSend(Message<?> message, MessageChannel channel) {
            StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
            //1. 判断是否首次连接请求
            if (StompCommand.CONNECT.equals(accessor.getCommand())) {
                //2. 验证是否登录
                String username = accessor.getNativeHeader("username").get(0);
                String password = accessor.getNativeHeader("password").get(0);
                for (Map.Entry<String, String> entry : Users.USERS_MAP.entrySet()) {
                    System.out.println(entry.getKey() + "---" + entry.getValue());
                    if (entry.getKey().equals(username) && entry.getValue().equals(password)) {
                        //验证成功,登录
                        Authentication user = new Authentication(username); // access authentication header(s)}
                        accessor.setUser(user);
                        return message;
                    }
                }
                return null;
            }
            //不是首次连接,已经成功登陆
            return message;
        }
    });
}

服务端 Controller 可以获取在拦截器中绑定的用户登录信息

  1. 使用 StompHeaderAccessor 获得相关头信息
@RequestMapping(value = {"/greeting2"}, method = RequestMethod.GET)
public void greetingV2() {

}


@MessageMapping("/v2/greeting/{typeId}")
@SendTo("/topic/v2/greeting")
public Greeting greetingV2(@DestinationVariable Integer typeId, GreetingMessage message, StompHeaderAccessor headerAccessor) {
    Authentication user = (Authentication) headerAccessor.getUser();
    String sessionId = headerAccessor.getSessionId();
    return new Greeting(user.getName(), typeId + "---" + "sessionId: " + sessionId + ", message: " + message.getMessage());
}

客户端登陆时,带上登陆信息

  1. 利用Header,将登陆信息在首次连接时发送到服务端
function connect() {
    var socket = new SockJS('https://xyzla.com/socket'); //连接SockJS的endpoint名称为"socket"
    stompClient = Stomp.over(socket);//使用STMOP子协议的WebSocket客户端
    var headers = {
        username: $("#username").val(),
        password: $("#password").val()
    };
    console.log("HEADERS:\t" + JSON.stringify(headers));
    stompClient.connect(headers, function (frame) {//连接WebSocket服务端
        console.log('Connected:' + frame);
        stompClient.subscribe('/topic/v2/greeting', function (response) {
            showResponse(JSON.parse(response.body));
        });
    });
}

Spring + STOMP 实现指定目标发送

  1. 客户端可订阅个人专属的主题: /user/{username}/v3/greeting
  2. 在 程序 中利用 SendToUser 发送消息到指定的主题:
    1. Controller 注解,发送到自己 @SendToUser("/v3/greeting")
    2. 利用 messagingTemplate 发送到指定用户 simpMessagingTemplate.convertAndSendToUser(destUsername,"/v3/greeting", greeting);

服务端 WebSocketMessageBroker 配置

  1. 增加定向发送的配置(以下代码为configureMessageBroker中需要增加的内容)
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        ……
            
        /*
         * 一对一发送的前缀
         * 订阅主题:/user/{userID}//v3/greeting
         * 推送方式 1、@SendToUser("/v3/greeting")
         *         2、messagingTemplate.convertAndSendToUser(destUsername, "/demo3/greetings", greeting);
         */
        config.setUserDestinationPrefix("/user");
    }

服务端 Controller 配置

  1. 必须注入SimpMessagingTemplate
  2. 使用注解和 messagingTemplate 发送消息到指定的订阅主题(也就是目标客户端)
  3. 目标客户端使用 Restful 的方式在请求路径中指定
@RequestMapping(value = {"/greeting3_1"}, method = RequestMethod.GET)
public void greetingV3_1() {

}

@RequestMapping(value = {"/greeting3_2"}, method = RequestMethod.GET)
public void greetingV3_2() {

}

///user/' + $('#username').val() + '/v3/greeting

//https://xyzla.com/v3/greeting/13611212304
@MessageMapping("/v3/greeting/{destUsername}")
@SendToUser("/topic/v3/greeting")
public Greeting greetingV3(@DestinationVariable String destUsername, GreetingMessage message, StompHeaderAccessor headerAccessor) throws Exception {
    Authentication user = (Authentication) headerAccessor.getUser();
    String sessionId = headerAccessor.getSessionId();
    Greeting greeting = new Greeting(user.getName(), "sessionId: " + sessionId + ", message: " + message.getMessage());
    /*
     * 对目标进行发送信息
     */
    simpMessagingTemplate.convertAndSendToUser(destUsername, "/v3/greeting", greeting);
    return new Greeting("系统", new Date().toString() + "消息已被推送。");
}

客户端订阅

  1. 订阅用户相关消息主题:'/user/' + $("#username").val() + '/v3/greeting'
function connect() {
    var socket = new SockJS('https://xyzla.com/socket'); //连接SockJS的endpoint名称为"socket"
    stompClient = Stomp.over(socket);//使用STMOP子协议的WebSocket客户端
    var headers = {
        username: $("#username").val(),
        password: $("#password").val()
    };
    console.log("HEADERS:\t" + JSON.stringify(headers));
    stompClient.connect(headers, function (frame) {//连接WebSocket服务端
        console.log('Connected:' + frame);
        stompClient.subscribe('/user/' + $('#username').val() + '/v3/greeting', function (response) {
            showResponse(JSON.parse(response.body));
        });
    });
}

客户端发送消息

  1. 客户端发送消息,同时在请求路径中指明发送目标客户端
function sendMsg() {
    stompClient.send("/app/v3/greeting/" + $("#destUsername").val(), {}, JSON.stringify({'message': $("#message").val()}));
}

参考文章:

  1. Spring官方WebSocket文档
  2. WebSocket+SockJs+STMOP
  3. STOMP Over WebSocket(stomp.js)

Spring+STOMP实现WebSocket广播订阅、权限认证、一对一通讯(附源码)

  • qq_43638135
    妲己再美究为妃: 博主没有想过自己接一些私活干吗?我现在还没毕业,但是我也确实听说外挂市场自动化游戏脚本市场挺火热的,并且报酬也很丰厚,但是具体的我也不是很清楚,求解答。 (1个月前 #47楼) 查看回复(2) 举报 回复
    22