目录
一、服务端配置
整个服务端是基于ruoyi的微服务版本做的。
1、引入依赖包
首先引入websocket的maven依赖,版本号自行修改。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
<version>${spring-boot.version}</version>
</dependency>
2、编写Config类进行websocket的配置
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.web.socket.config.annotation.*;
//头部加注解EnableWebSocketMessageBroker,允许使用Stomp方式。
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketAutoConfig implements WebSocketMessageBrokerConfigurer {
@Autowired
private WebSocketInterceptor authChannelInterceptor;
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
//允许原生的websocket,如果只允许源生的websocket,用这段代码即可
//registry.addEndpoint("/ws")
// .setAllowedOrigins("*");//允许跨域
//请求地址:ws://ip:port/ws
SockJsServiceRegistration registration = registry.addEndpoint("/ws")
.setAllowedOrigins("*")//允许跨域
.withSockJS();//允许sockJS
//下面注解的代码主要用于客户端不支持websocket的情况下,SockJS降级使用xhr-stream或者pjson等等传输方式的时候使用。
//registration.setClientLibraryUrl("//cdn.jsdelivr.net/npm/sockjs-client@1.5.2/dist/sockjs.min.js");
}
/**
* 注册相关的消息频道
*
* @param config
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
//设置两个频道,topic用于广播,queue用于点对点发送
config.enableSimpleBroker("/topic/", "/queue/");
//设置应用目的地前缀
config.setApplicationDestinationPrefixes("/app");
//设置用户目的地前缀
config.setUserDestinationPrefix("/user");
}
/**
* 加入拦截器主要是为了验证权限的
*
* @param registration
*/
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(authChannelInterceptor);
}
//这个是为了解决和调度任务的冲突重写的bean
@Primary
@Bean
public TaskScheduler taskScheduler(){
ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
taskScheduler.setPoolSize(10);
taskScheduler.initialize();
return taskScheduler;
}
}
3、编写拦截器WebSocketInterceptor
拦截器主要是处理权限用的,防止没有获得权限的用户访问到服务器。
import com.ruoyi.common.core.constant.CacheConstants;
import com.ruoyi.common.core.utils.StringUtils;
import com.ruoyi.common.redis.service.TokenRedisService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.stereotype.Component;
import java.security.Principal;
import java.util.List;
import java.util.Map;
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class WebSocketInterceptor implements ChannelInterceptor {
@Autowired
private WebSocketManager webSocketManager;
@Autowired
private TokenRedisService tokenRedisService;
/**
* 连接前监听
*
* @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(CacheConstants.HEADER);
if (nativeHeader != null && !nativeHeader.isEmpty()) {
String token = nativeHeader.get(0);
if (StringUtils.isNotBlank(token)) {
Map<String,String> pass = tokenRedisService.validation(token);
if("pass".equals(pass.get("result"))){
Principal principal = new Principal() {
@Override
public String getName() {
return pass.get("username")+"_"+ accessor.getSessionId();
}
};
accessor.setUser(principal);
webSocketManager.addUser(principal.getName());
return message;
}
}
}
return null;
}
//不是首次连接,已经登陆成功
return message;
}
// 在消息发送后立刻调用,boolean值参数表示该调用的返回值
@Override
public void postSend(Message<?> message, MessageChannel messageChannel, boolean b) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
Principal principal = accessor.getUser();
// 忽略心跳消息等非STOMP消息
if(accessor.getCommand() == null)
{
return;
}
switch (accessor.getCommand())
{
// 首次连接
case CONNECT:
break;
// 连接中
case CONNECTED:
break;
// 下线
case DISCONNECT:
if(principal!=null){
webSocketManager.deleteUser(principal.getName());
}
break;
default:
break;
}
}
}
4、编写管理在线用户的WebSocketManager
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.concurrent.CopyOnWriteArraySet;
@Component
public class WebSocketManager {
private ThreadPoolTaskScheduler taskScheduler;
private Long onlineCount;
private CopyOnWriteArraySet<String> onlines;
private static final Integer POOL_MIN = 10;
@PostConstruct
public void init() {
taskScheduler = new ThreadPoolTaskScheduler();
taskScheduler.setPoolSize(POOL_MIN);
taskScheduler.initialize();
this.onlines = new CopyOnWriteArraySet<>();
this.onlineCount = 0L;
}
public boolean isOnline(String username) {
return onlines.contains(username);
}
public void addUser(String username) {
onlines.add(username);
onlineCount = Long.valueOf(onlines.size());
}
public void deleteUser(String username) {
onlines.remove(username);
onlineCount = Long.valueOf(onlines.size());
}
}
5、编写消息的接收和发送的Controller
此处是编写系统处理前端发送消息的业务代码,大家可以根据自己的项目需求进行更替,这里只编写简单例子供大家参考。
import com.ruoyi.common.core.web.domain.AjaxResult;
import com.ruoyi.zt.service.ZTRealDataService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.annotation.SendToUser;
import org.springframework.messaging.simp.annotation.SubscribeMapping;
import org.springframework.stereotype.Controller;
import java.security.Principal;
@Controller
public class RealDataWebSocketController {
@Autowired
private ZTRealDataService ztRealDataService;
@Autowired
private SimpMessagingTemplate simpMessagingTemplate;
//MessageMapping的注解是接收的目的地为app/datapoint的消息处理,只会处理客户端SEND发送的消息。
//SendToUser注解是结果以点对点方式发送到目的地user/queue/datavalue
@MessageMapping("/datapoint")
@SendToUser("/queue/datavalue")
public AjaxResult datapoint(Principal principal, @Payload String message) {
System.out.println(principal.getName());
System.out.println(message);
return AjaxResult.success(ztRealDataService.getData(message.split(",")));
}
//SubscribeMapping的注解是订阅目的地为app/news的消息处理,只会处理客户端SUBSCRIBE发送的消息。
//SendTo注解是结果发送到目的地app/topic/news
@SubscribeMapping("/news")
@SendTo("/topic/news")
public String subscribeNews(@Payload String message) {
return message;
}
//接收前端send命令,但是点对点返回
@MessageMapping("/realdata")
@SendToUser("/queue/realdata")
public String realdata(Principal principal, @Payload String message) {
System.out.println(principal.getName());
System.out.println(message);
//可以手动发送,同样有queue
simpMessagingTemplate.convertAndSendToUser(principal.getName(),"/queue/test","111");
return "111";
}
}
二、客户端配置
1、引入SockJS以及stompjs
安装SockJS客户端以及stompjs。
@stomp/stompjs是最新的版本,当然也可以使用stompjs,写法上略有不同,新版的支持断线重连的机制,所以本文采用了新版方式实现。
npm install sockjs-client
npm install @stomp/stompjs
2、编写公用的websocket.js供其他地方调用
import SockJS from 'sockjs-client';
import {Client} from '@stomp/stompjs';
import {getToken} from '@/utils/auth'
const socket = () => {
//请求的起始地址,根据开发环境变量确定
let baseUrl = process.env.VUE_APP_BASE_API;
let stompClient = new Client({
//可以不赋值,因为后面用SockJS来代替
//brokerURL: 'ws://localhost:9527/dev-api/ws/',
//获得客户端token的方法,把token放到请求头中
connectHeaders: {"Authorization": 'Bearer ' + getToken()},
debug: function (str) {
//debug日志,调试时候开启
console.log(str);
},
reconnectDelay: 10000,//重连时间
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
});
// //用SockJS代替brokenURL
stompClient.webSocketFactory = function () {
//因为服务端监听的是/ws路径下面的请求,所以跟服务端保持一致
return new SockJS(baseUrl + '/ws', null, {
timeout: 10000
});
};
return {
stompClient: stompClient,
connect(callback) {
//连接
stompClient.onConnect = (frame) => {
callback(frame);
};
//错误
stompClient.onStompError = function (frame) {
console.log('Broker reported error: ' + frame.headers['message']);
console.log('Additional details: ' + frame.body);
//这里不需要重连了,新版自带重连
};
//启动
stompClient.activate();
},
close() {
if (this.stompClient !== null) {
this.stompClient.deactivate()
}
},
//发送消息
send(addr, msg) {
//添加app的前缀,并发送消息,publish是新版的stomp/stompjs发送api,老版本更改下就可以。
this.stompClient.publish({
destination: '/app'+addr,
body: msg
})
},
//订阅消息
subscribe(addr, callback) {
this.stompClient.subscribe(addr, (res)=>{
//这里进行了JSON类型的转化,因为我的服务端返回的数据都是json,消息本身是string型的,所以进行了转化。
var result = JSON.parse(res.body);
callback(result);
});
}
}
}
export default socket
3、业务代码中引用websocket进行消息发送和接收
调用封装后的websocket.js,这样业务代码更加的简单清晰,如果有多了连接,多new 几个Websocket对象就可以了。
//在vue中直接引用
import Websocket from '@/utils/websocket'
var socket = new Websocket();
socket.connect(() => {
//发送消息到app/datapoint,app的前缀我是在websocket里面已经封装,此处不用再添加
socket.send("/datapoint", "123123123");
//订阅目的地/user/queue/datavalue的消息
socket.subscribe("/user/queue/datavalue", (res) => {
console.log(res)
});
}
三、Spring cloud gateway网关配置
在网关中配置转发websocket的服务,因为是微服务架构,所以所有的websocket请求都必须经过网关,必须对网关进行配置,服务端才能正确响应websocket请求(以ws:开头的)。
1、修改gateway网关的配置文件
在网关的配置文件中添加路由信息,/ws路径都转发到刚才配置websocket的服务(ruoyi-zt)中,
# 模块其他请求
- id: ruoyi-zt
uri: lb://ruoyi-zt
predicates:
- Path=/zt/**
filters:
- StripPrefix=1
# 模块的微服务请求
- id: ruoyi-zt-websocket
uri: lb:ws://ruoyi-zt
predicates:
- Path=/ws/**
2、在网关中添加全局过滤器
SockJS 客户端首先发送GET /info从服务器获取基本信息。之后,它必须决定使用什么传输。如果可能,使用 WebSocket。如果没有,在大多数浏览器中,至少有一个 HTTP 流选项。如果不是,则使用 HTTP(长)轮询。按照我们上述网关的配置,网关会将此请求路由成websocket请求,会导致请求的失败,所以我们必须编写一个过滤器,将第一次的这个http请求,从websocket请求还原成http请求。具体代码如下。
SockJS所有传输请求都具有以下 URL 结构:
https://host:port/myApp/myEndpoint/{server-id}/{session-id}/{transport}
在哪里:
{server-id} 用于在集群中路由请求,但不用于其他用途。
{session-id} 关联属于 SockJS 会话的 HTTP 请求。
{transport}表示传输类型(例如,websocket、xhr-streaming等)。
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.publisher.Mono;
import java.net.URI;
import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR;
/**
* @author: wang.mh
* 2019/6/19 17:05
*/
@Component
public class WebSocketFilter implements GlobalFilter, Ordered {
public final static String DEFAULT_FILTER_PATH = "/ws/info";
public final static String DEFAULT_FILTER_WEBSOCKET = "websocket";
/**
*
* @param exchange ServerWebExchange是一个HTTP请求-响应交互的契约。提供对HTTP请求和响应的访问,
* 并公开额外的 服务器 端处理相关属性和特性,如请求属性
* @param chain
* @return
*/
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String upgrade = exchange.getRequest().getHeaders().getUpgrade();
URI requestUrl = exchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR);
String scheme = requestUrl.getScheme();
//如果不是ws的请求直接通过
if (!"ws".equals(scheme) && !"wss".equals(scheme)) {
return chain.filter(exchange);
//如果是/ws/info的请求,把它还原成http请求。
} else if (DEFAULT_FILTER_PATH.equals(requestUrl.getPath())) {
String wsScheme = convertWsToHttp(scheme);
URI wsRequestUrl = UriComponentsBuilder.fromUri(requestUrl).scheme(wsScheme).build().toUri();
exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, wsRequestUrl);
//如果是sockJS降级后的http请求,把它还原成http请求,也就是地址{transport}不为websocket的所有请求
} else if (requestUrl.getPath().indexOf(DEFAULT_FILTER_WEBSOCKET)<0) {
String wsScheme = convertWsToHttp(scheme);
URI wsRequestUrl = UriComponentsBuilder.fromUri(requestUrl).scheme(wsScheme).build().toUri();
exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, wsRequestUrl);
}
return chain.filter(exchange);
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE - 2;
}
static String convertWsToHttp(String scheme) {
scheme = scheme.toLowerCase();
return "ws".equals(scheme) ? "http" : "wss".equals(scheme) ? "https" : scheme;
}
}
四、前端代理的配置
在开发环境中,一般使用前后端分离的方式进行,这样前端的请求都是通过代理的方式访问到服务端,所以我们还要进行代理的设置,本文采用的是vue2.0的开发环境,调整vue.config.js中的代理配置,加上支持ws请求。
proxy: {
[process.env.VUE_APP_BASE_API]: {
target: `http://127.0.0.1:8080`,
changeOrigin: true,
ws: true, //如果要代理 websockets,配置这个参数
pathRewrite: {
['^' + process.env.VUE_APP_BASE_API]: ''
}
},
}
这样就完成了所有的配置信息。
五、问题记录
我的开发环境,在按照上述完成整个配置后,发现sockJS客户端以websocket连接后,会迅速断开链接,然后降级成xhr-stream等其他方式进行数据请求,找了好久都没有发现原因。
最后,发现是SockJS的超时设置有问题,如果采用默认的超时参数,SockJS将计算一个合理的超时时间进行等待,如果等待超时的情况下,会降级成其他方式进行数据请求,估计是计算的超时时间不合理,还没等服务端响应,就发生超时,切换到其他方式传输了。
这种情况我们手动设置超时时间就可以了。
new SockJS(baseUrl + '/ws', null, {
timeout: 10000
});