springboot中通过stomp方式来处理websocket及token权限鉴权相关
起因
- 想处理后端向前端发送消息的情况,然后就了解到了原生
websocket
和stomp
协议方式来处理的几种方式,最终选择了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
中的参数,如果token
用jwt
等很长的,用这种方式实现并不友好。 - 实现
ChannelInterceptor
接口在preSend
方法中来处理,这种方式可以获取header
中的值,而且还可以设置用户信息等,详细见下方拦截器代码
vue端相关注意点
vue
端用websocket
的好处是单页应用,不会频繁的断开和重连,所以相关代码放到App.vue
中- 由于要鉴权,所以需要登录后再连接,这里用的方法是
watch
监听token
,如果token
从无到有,说明刚登录,触发websocket
连接。 - 前端引入包
npm install sockjs-client
和npm 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; } },
参考
- Spring消息之STOMP,写的挺详细的,还有源码
- Spring官方文档
关于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 }