springboot中通过stomp方式来处理websocket及token权限鉴权相关

起因

  • 想处理后端向前端发送消息的情况,然后就了解到了原生websocketstomp协议方式来处理的几种方式,最终选择了stomp来,但很多参考资料都不全,导致费了很多时间,所以这里不说基础的内容了,只记录一些疑惑的点。

相关前缀和注解

在后台的websocket配置中,我们看到有/app/queue/topic/user这些前缀:

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/queue", "/topic");
        registry.setApplicationDestinationPrefixes("/app");//注意此处有设置
        registry.setUserDestinationPrefix("/user");
    }

同时在controller中又有@MessageMapping@SubscribeMapping@SendTo@SendToUser等注解,这些前缀和这些注解是由一定的关系的,这边理一下:

  • 首先前端stompjs有两种方式向后端交互,一种是发送消息send,一种是订阅subscribe,它们在都会带一个目的地址/app/hello
  • 如果地址前缀是/app,那么此消息会关联到@MessageMapping(send命令会到这个注解)、@SubscribeMapping(subscribe命令会到这个注解)中,如果没有/app,则不会映射到任何注解上去,例如:
      //接收前端send命令发送的
      @MessageMapping("/hello")
      //@SendTo("/topic/hello2")
      public String hello(@Payload String message) {
          return "123";
      }
      //接收前端subscribe命令发送的
      @SubscribeMapping("/subscribe")
      public String subscribe() {
          return "456";
      }
      //接收前端send命令,但是单对单返回
      @MessageMapping("/test")
      @SendToUser("/queue/test")
      public String user(Principal principal, @Payload String message) {
          log.debug(principal.getName());
          log.debug(message);
          //可以手动发送,同样有queue
          //simpMessagingTemplate.convertAndSendToUser("admin","/queue/test","111");
          return "111";
      }
    
    当前端发送:send("/app/hello",...)才会走到上方第一个中,而返回的这个123,并不是直接返回,而是默认将123转到/topic/hello这个订阅中去(自动在前面加上/topic),当然可以用@SendTo("/topic/hello2")中将123转到/topic/hello2这个订阅中;当前端发送subscribe("/app/subscribe",{接收直接返回的内容},会走到第二个中,而456就不经过转发了,直接会返回,当然也可以增加@SendTo("/topic/hello2")注解来不直接返回,而是转到其它订阅中。
  • 如果地址前缀是/topic,这个没什么说的,一般用于订阅消息,后台群发。
  • 如果地址前缀是/user,这个和一对一消息有关,而且会和queue有关联,前端必须同时增加queue,类似subscribe("/user/queue/test",...),后端的@SendToUser("/queue/test")同样要加queue才能正确的发送到前端订阅的地址。

token鉴权相关

权限相关一般是增加拦截器,网上查到的资料一般有两种方式:

  • 实现HandshakeInterceptor接口在beforeHandshake方法中来处理,这种方式缺点是无法获取header中的值,只能获取url中的参数,如果tokenjwt等很长的,用这种方式实现并不友好。
  • 实现ChannelInterceptor接口在preSend方法中来处理,这种方式可以获取header中的值,而且还可以设置用户信息等,详细见下方拦截器代码

vue端相关注意点

  • vue端用websocket的好处是单页应用,不会频繁的断开和重连,所以相关代码放到App.vue
  • 由于要鉴权,所以需要登录后再连接,这里用的方法是watch监听token,如果token从无到有,说明刚登录,触发websocket连接。
  • 前端引入包npm install sockjs-clientnpm install stompjs,具体代码见下方。

相关代码

  • 后台配置
    @Configuration
    @EnableWebSocketMessageBroker
    @Slf4j
    public class WebsocketConfig implements WebSocketMessageBrokerConfigurer {
      @Autowired
      private AuthChannelInterceptor authChannelInterceptor;
    
      @Bean
      public WebSocketInterceptor getWebSocketInterceptor() {
          return new WebSocketInterceptor();
      }
    
      @Override
      public void registerStompEndpoints(StompEndpointRegistry registry) {
          registry.addEndpoint("/ws")//请求地址:http://ip:port/ws
                  .addInterceptors(getWebSocketInterceptor())//拦截器方式1,暂不用
                  .setAllowedOriginPatterns("*")//跨域
                  .withSockJS();//开启socketJs
      }
    
      @Override
      public void configureMessageBroker(MessageBrokerRegistry registry) {
          registry.enableSimpleBroker("/queue", "/topic");
          registry.setApplicationDestinationPrefixes("/app");
          registry.setUserDestinationPrefix("/user");
      }
    
      /**
       * 拦截器方式2
       *
       * @param registration
       */
      @Override
      public void configureClientInboundChannel(ChannelRegistration registration) {
          registration.interceptors(authChannelInterceptor);
      }
    }
    
  • 拦截器
    @Component
    @Order(Ordered.HIGHEST_PRECEDENCE + 99)
    public class AuthChannelInterceptor implements ChannelInterceptor {
      /**
       * 连接前监听
       *
       * @param message
       * @param channel
       * @return
       */
      @Override
      public Message<?> preSend(Message<?> message, MessageChannel channel) {
          StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
          //1、判断是否首次连接
          if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
              //2、判断token
              List<String> nativeHeader = accessor.getNativeHeader("Authorization");
              if (nativeHeader != null && !nativeHeader.isEmpty()) {
                  String token = nativeHeader.get(0);
                  if (StringUtils.isNotBlank(token)) {
                      //todo,通过token获取用户信息,下方用loginUser来代替
                      if (loginUser != null) {
                          //如果存在用户信息,将用户名赋值,后期发送时,可以指定用户名即可发送到对应用户
                          Principal principal = new Principal() {
                              @Override
                              public String getName() {
                                  return loginUser.getUsername();
                              }
                          };
                          accessor.setUser(principal);
                          return message;
                      }
                  }
              }
              return null;
          }
          //不是首次连接,已经登陆成功
          return message;
      }
    }
    
  • 前端代码,放在App.vue中:
    import Stomp from 'stompjs'
    import SockJS from 'sockjs-client'
    import {mapGetters} from "vuex";
    
    export default {
      name: 'App',
      data() {
        return {
          stompClient: null,
        }
      },
      computed: {
        ...mapGetters(["token"])
      },
      created() {
        //只有登录后才连接
        if (this.token) {
          this.initWebsocket();
        }
      },
      destroyed() {
        this.closeWebsocket()
      },
      watch: {
        token(val, oldVal) {
          //如果一开始没有,现在有了,说明刚登录,连接websocket
          if (!oldVal && val) {
            this.initWebsocket();
          }
          //如果原先有,现在没有了,说明退出登录,断开websocket
          if (oldVal && !val) {
            this.closeWebsocket();
          }
        }
      },
      methods: {
        initWebsocket() {
          let socket = new SockJS('http://localhost:8060/ws');
          this.stompClient = Stomp.over(socket);
          this.stompClient.connect(
            {"Authorization": this.token},//传递token
            (frame) => {
              //测试topic
              this.stompClient.subscribe("/topic/subscribe", (res) => {
                console.log("订阅消息1:");
                console.log(res);
              });
              //测试 @SubscribeMapping
              this.stompClient.subscribe("/app/subscribe", (res) => {
                console.log("订阅消息2:");
                console.log(res);
              });
              //测试单对单
              this.stompClient.subscribe("/user/queue/test", (res) => {
                console.log("订阅消息3:");
                console.log(res.body);
              });
              //测试发送
              this.stompClient.send("/app/test", {}, JSON.stringify({"user": "user"}))
            },
            (err) => {
              console.log("错误:");
              console.log(err);
              //10s后重新连接一次
              setTimeout(() => {
                this.initWebsocket();
              }, 10000)
            }
          );
          this.stompClient.heartbeat.outgoing = 20000; //若使用STOMP 1.1 版本,默认开启了心跳检测机制(默认值都是10000ms)
          this.stompClient.heartbeat.incoming = 0; //客户端不从服务端接收心跳包
        },
        closeWebsocket() {
          if (this.stompClient !== null) {
            this.stompClient.disconnect(() => {
              console.log("关闭连接")
            })
          }
        }
      }
    }
    

非全局状态

  • 上方放到App.vue中主要为了在其它页面也能监听到信息,可以用来做全局的通知相关,但还存在一种情况就是除了全局的外,某个消息只想在某一个页面生效,而非全局的,目前最简单的方式就是在initWebsocket方法最后用vuex来存储stompClient变量,然后在需要页面引入此变量,打开页面时subscribe,关闭页面时unsubscribe
  • 但由于子页面是没有connect方法的,导致断线重连时子页面无法重新订阅(connect方法只在App.vue中,所以全局的订阅是没问题的,断线重连会触发重新订阅),这里提供一种方法,同样是用vuex来解决,在store中增加mutations的方法:STOMP_RECONNECT_TRIGGER: (state) => {},在connect方法中增加this.$store.commit("STOMP_RECONNECT_TRIGGER")来触发,然后在子页面中用store.subscribe来监听断线重连的触发,如下:
      mounted() {
        //本页面正常的订阅,但是断线重连后会失效,所以需要下方的方法
        this.subscription = this.stompClient.subscribe("xxx", this.noticeCallback);
        //store.subscribe方法
        this.storeUnsubscribe = this.$store.subscribe((mutation, state) => {
          //所有的mutations调用都会触发此方法,所以需要判定只取设定的
          if (mutation.type === 'STOMP_RECONNECT_TRIGGER') {
            this.subscription = this.stompClient.subscribe("xxx", this.noticeCallback);
          }
        });
      },
      destroyed() {
        //取消vuex的订阅,返回值就是一个函数,直接调用就取消了
        if (this.storeUnsubscribe) {
          this.storeUnsubscribe();
          this.storeUnsubscribe = null;
        }
        //取消stomp的订阅
        if (this.subscription) {
          this.subscription.unsubscribe();
          this.subscription = null;
        }
      },
    

参考

关于stompjs的补充

  • 如果直接用npm i stompjs,安装的是这个stomp-websocket,版本是2.3.3,七八年前的版本了,虽然还可以正常用。上方演示也是用的这个。
  • 最新的版本应该是用这个npm i @stomp/stompjs,对应的是stompjs,当前版本已经是6.x多了,一些用法有改动,类似发送不用send而是publish,官方推荐用这个。
  • 新版本的前端代码,放在App.vue中,后端没有变化,具体文档可参考Using STOMP with SockJS
    import {Client} from '@stomp/stompjs';
    import SockJS from 'sockjs-client'
    import {mapGetters} from "vuex";
    
    export default {
      name: 'App',
      data() {
        return {
          stompClient: null,
        }
      },
      computed: {
        ...mapGetters(["name", "token"])
      },
      created() {
        //只有登录后才连接
        if (this.token) {
          this.initWebsocket();
        }
      },
      destroyed() {
        this.closeWebsocket()
      },
      watch: {
        token(val, oldVal) {
          //如果一开始没有,现在有了,说明刚登录,连接websocket
          if (!oldVal && val) {
            this.initWebsocket();
          }
          //如果原先有,现在没有了,说明退出登录,断开websocket
          if (oldVal && !val) {
            this.closeWebsocket();
          }
        }
      },
      methods: {
        initWebsocket() {
          this.stompClient = new Client({
            brokerURL: '',//可以不赋值,因为后面用SockJS来代替
            connectHeaders: {"Authorization": this.token},
            debug: function (str) {
              console.log(str);
            },
            reconnectDelay: 10000,//重连时间
            heartbeatIncoming: 4000,
            heartbeatOutgoing: 4000,
          });
          //用SockJS代替brokenURL
          this.stompClient.webSocketFactory = function () {
            return new SockJS('/ws');
          };
          //连接
          this.stompClient.onConnect = (frame) => {
            this.stompClient.subscribe("/topic/hello", (res) => {
              console.log('2:');
              console.log(res);
            });
            this.stompClient.subscribe("/app/subscribe", (res) => {
              console.log('3:');
              console.log(res);
            });
            //新版不用send而是publish
            this.stompClient.publish({
              destination: '/app/hello',
              body: "123"
            })
          };
          //错误
          this.stompClient.onStompError = function (frame) {
            console.log('Broker reported error: ' + frame.headers['message']);
            console.log('Additional details: ' + frame.body);
            //这里不需要重连了,新版自带重连
          };
          //启动
          this.stompClient.activate();
        },
        closeWebsocket() {
          if (this.stompClient !== null) {
            this.stompClient.deactivate()
          }
        }
      }
    }
    
  • 新版本是不推荐用SockJS的,理由是现在大多数浏览器除了旧的IE,其它的都支持,所以如果不用的话,前端直接用brokerURL而不需要用webSocketFactory来配置了,后端配置项需要修改,参考这个回答
     @Override
      public void registerStompEndpoints(StompEndpointRegistry registry) {
          //允许原生的websocket
          registry.addEndpoint("/ws")//请求地址:ws://ip:port/ws
                  .setAllowedOriginPatterns("*");//跨域
          //允许sockJS
          registry.addEndpoint("/ws")//请求地址:http://ip:port/ws
                  .setAllowedOriginPatterns("*")//跨域
                  .withSockJS();//开启sockJs
      }
    
posted @ 2021-01-27 14:51  漫游云巅  阅读(10930)  评论(1编辑  收藏  举报