起因
- 想处理后端向前端发送消息的情况,然后就了解到了原生
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/test",...)
才会走到上方第一个中,而返回的这个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 instll 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,暂不用 .setAllowedOrigins("*")//跨域 .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("关闭连接") }) } } } }
参考
- Spring消息之STOMP,写的挺详细的,还有源码
- Spring官方文档