本篇文章从后端到前端完整的展示springboot基于注解的方式使用websocket,鉴权及前端js代码示例(心跳及断开重连)。
后端部分
服务搭建
引入websocket依赖包
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
<version>5.1.2.RELEASE</version>
</dependency>
创建Websocket类,并添加@ServerEndPoint注解,声明此类是一个ServerEndpoint服务
/**
* 每个ws请求都会生成一个websocket实例
*/
@Data
@Slf4j
@ServerEndpoint(value = "/websocket/{sid}")
@Component
public class WebSocket {
private String sid;
private Session session;
@OnOpen
public void onOpen(@PathParam("sid") String sid, Session session) {
log.info("WebSocket onOpen sid:{}", sid);
this.sid = sid;
this.session = session;
WebSocketHolder.put(userNo, this);
}
/**
* 连接关闭调用的方法
*/
@OnClose
public void onClose() {
log.info("WebSocket onClose sid:{}", sid);
WebSocketHolder.remove(sid, this);
}
/**
* 收到客户端消息后调用的方法
* @param message 客户端发送过来的消息
* @param session 可选的参数
*/
@OnMessage
public void onMessage(String message, Session session) {
log.info("WebSocket onMessage message: {}", message);
if (message.equals("ping")) {
sendMessage("pong");
}
}
/**
* 发生错误时调用
* @param session
* @param error
*/
@OnError
public void onError(Session session, Throwable error) {
log.error("WebSocket onError");
}
/**
* 发送消息
* @param message
*/
public void sendMessage(String message) {
try {
this.session.getBasicRemote().sendText(message);
} catch (IOException e) {
log.error("消息发送失败: {}", e.getMessage(), e);
}
}
public void close() {
try {
this.session.close();
} catch (IOException e) {
log.error("session 关闭失败: {}", e.getMessage(), e);
}
}
}
要想保证后端和前端进行通讯,必须要将建立的会话(Session)缓存起来,因此我们创建WebSocketHolder类
@Slf4j
public class WebSocketHolder {
// 用于缓存用户的会话,value之所以是一个集合,是为了保存同一个用户多终端登录的会话
public static Map<String, Set<WebSocket>> holder = new ConcurrentHashMap<>();
// 计数器,用于统计当前登录用户会话数
public static AtomicInteger counter = new AtomicInteger();
/**
* 存储session
* @param sid
* @param session
*/
public static void put(String sid, WebSocket session) {
Set<WebSocket> sessions = holder.getOrDefault(sid, new HashSet<>());
if (sessions.size() == 0) {
holder.put(sid, sessions);
}
sessions.add(session);
// 计数
int c = counter.incrementAndGet();
log.info("用户{}登录,当前在线会话为: {}", sid, c);
}
/**
* 获取session
* <p>
* 1. sid不为空,则获取指定用户;<br/>
* 2. sid为空,则获取所有登录用户;<br/>
* </p>
* @param sid
* @return
*/
public static Set<WebSocket> get(String sid) {
Set<WebSocket> set = new HashSet<>();
if (StringUtils.isEmpty(sid)) {
// sid标识为空
holder.values().forEach(s -> set.addAll(s));
} else {
// sid不为空
if (holder.containsKey(sid)) {
set.addAll(holder.get(sid));
}
}
return set;
}
/**
* 移除session
* @param sid
*/
public static void remove(String sid, WebSocket socket) {
Set<WebSocket> sockets = holder.get(sid);
socket.close();
sockets.remove(socket);
if (sockets.size() == 0) {
holder.remove(sid);
}
int c = counter.decrementAndGet();
log.info("用户{}退出,当前在线会话为: {}", sid, c);
}
}
同时还需要创建一个配置类,用于实例化ServerEndpointExporter
,此类会自动注册上面定义的EndPointer
@Configuration
public class WebSocketConfigurator {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
这样后端基本的websocket服务就搭建起来了。
鉴权
出于安全考虑,往往需要对请求进行鉴权,因此需要获取前端传递的token,获取的方式目前想到的有3种,仅供参考:
- 类似于sid参数,将token放到请求路径中,然后在onOpen中对token进行鉴权;
- 建立链接后前端在onOpen中立即发送消息将token传递到后端,后端获取到token消息后对其进行鉴权并记录,以后每次消息交互时都要判断一下鉴权记录是否通过。(这样做是为了保证建立成功但未鉴权的链接不能正常收发消息)
- 通过子协议传递鉴权信息,由于js的websocket不能设置header信息,但是可以通过设置子协议(
new WebSocket(url [, protocols]);
),而子协议实际上是放到header对应的Sec-WebSocket-Protocol
中的
由于基础注解的方式对握手请求封装的比较深,后端获取token的方式比较特殊,这里只演示第三种获取方式。可以使用过滤器获取请求信息,也可以使用拦截器,这里使用过滤器演示。
@Slf4j
@Order(1)
@WebFilter(filterName = "websocketFilter", urlPatterns = "/webSocket/*")
public class WebsocketFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletResponse resp = (HttpServletResponse) response;
HttpServletRequest req = (HttpServletRequest) request;
String token = req.getHeader(HandshakeRequest.SEC_WEBSOCKET_PROTOCOL);
log.info("WS:HEADER-TOKEN:{}", token);
// 权限校验
String checkResult = checkToken(token);
if (!StringUtils.isEmpty(checkResult)) {
resp.getWriter().write("WS权限校验失败:" + checkResult);
resp.getWriter().flush();
log.error("WS:鉴权失败:{}", checkResult);
return;
}
// response中也要将token信息返回,否则连接建立失败
resp.setHeader(HandshakeRequest.SEC_WEBSOCKET_PROTOCOL, token);
chain.doFilter(request, response);
}
/**
* 校验token
* @param token
* @return
*/
private String checkToken(String token) {
if (StringUtils.isEmpty(token)) {
return "缺少TOKEN信息";
}
// 在这里做token校验
}
}
由于使用了@WebFilter,需要在程序的入口类(**Application.java)中引入@ServletComponentScan注解,否则WebFilter不会生效。
至此后端websokcet服务搭建完毕。
Nginx配置
通常后端服务不会直接对外开放,而是通过nginx做代理,这时需要在nginx的location中添加如下两行配置,这样ws/wss协议才能正常访问。
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
前端部分
前端部分比较简单需要注意的地方是鉴权部分token的传递方式
var websocket = new WebSocket(url, [token]);
websocket.onopen = function() {
console.log("数据发送中...");
};
websocket.onmessage = function (evt) {
var received_msg = evt.data;
console.log("接收数据..." + received_msg);
};
websocket.onclose = function(evt) {
// 关闭
console.log("连接已关闭...");
console.log(evt);
};
websocket.onerror = function(evt) {
// 异常
console.log("异常: ");
console.log(evt);
};
心跳检测
如果后端服务是通过nginx做的代理,那么nginx配置的proxy_read_timeout(默认是1分钟)会主动断开连接,解决此问题有2种方式,一种是配置更长的超时时间,但是长时间的等待会影响服务端的性能。不建议使用此种方式。第二种是前端增加心跳请求,心跳间隔需要小于nginx的超时时间。在链接建立(onopen)后开启心跳
/**
* 心跳检测
* @param websocket
* @param timeout 心跳间隔(单位毫秒)
*/
function HeartCheck(websocket, timeout) {
this.timeout = timeout;
this.websocket = websocket;
this.intervalObj = null;
// 清除心跳
this.clear = function () {
clearInterval(this.intervalObj);
},
this.start = function() {
var self = this;
this.intervalObj = setInterval(function() {
// 这里发送一个心跳,后端收到后,返回一个心跳消息
self.websocket.send("ping");
console.log("ping")
}, this.timeout)
}
}
断线重连
某些情况下不可避免造成请求中断,如服务器重启,为了保证前端正常使用,需要引入断线重连机制,只需要在链接关闭时调用此函数即可
// 重新连接
function reconnect(url, obj) {
// 这里设置延迟时间是为了避免请求过于频繁,可以适当设置
setTimeout(function () {
initWebSocket(url, obj);
}, 2000);
}
完整的js代码
/**
* 调用websocket(带有失败重连及心跳机制)
* @param url ws/wss协议的请求链接
* @param obj 请求数据,会传递给onOpen函数
* @param onOpen 链接建立成功后调用此函数
* @param onMessage 接收到后端消息后调用此函数
* @param onError 出现异常调用此函数
* @param onClose 关闭websocket会调用次函数
*/
function initWebSocket(url, obj, onOpen, onMessage, onError, onClose) {
var data = {
user: obj.user,
token: obj.token
}
console.log("socket链接: " + url);
if('WebSocket' in window) {
// 通过子协议实现鉴权
var websocket = new WebSocket(url, [data.token]);
// 创建心跳对象
var heartCheck = new HeartCheck(websocket, 30000); // 设置间隔30秒的心跳
websocket.onopen = function() {
// 开启心跳
heartCheck.start();
websocket.send("发送数据");
console.log("数据发送中...");
if (onOpen) {
onOpen(data)
}
};
websocket.onmessage = function (evt) {
var received_msg = evt.data;
console.log("接收数据..." + received_msg);
// 不是心跳消息,则处理此消息
if(received_msg != 'pong') {
if (onMessage) {
onMessage(received_msg)
}
}
};
websocket.onclose = function(evt) {
// 关闭
console.log("连接已关闭...");
console.log(evt);
console.log("重新连接...");
if (onClose) {
onClose(evt)
}
heartCheck.clear();
reconnect(url, obj, onOpen, onMessage, onError, onClose);
};
websocket.onerror = function(evt) {
// 异常
console.log("异常: ");
console.log(evt);
if (onError) {
onError(evt);
}
websocket.close();
};
// 监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
window.onbeforeunload = function() {
websocket.close();
}
} else {
alert("浏览器不支持websocket");
}
}
// 重新连接
function reconnect(url, obj, onOpen, onMessage, onError, onClose) {
// 这里设置延迟时间是为了避免请求过于频繁,可以适当设置
setTimeout(function () {
initWebSocket(url, obj, onOpen, onMessage, onError, onClose);
}, 2000);
}
/**
* 心跳检测
* @param websocket
* @param timeout 心跳间隔(单位毫秒)
*/
function HeartCheck(websocket, timeout) {
this.timeout = timeout;
this.websocket = websocket;
this.intervalObj = null;
// 清除心跳
this.clear = function () {
clearInterval(this.intervalObj);
},
this.start = function() {
var self = this;
this.intervalObj = setInterval(function() {
// 这里发送一个心跳,后端收到后,返回一个心跳消息
self.websocket.send("ping");
console.log("ping")
}, this.timeout)
}
}
调用
// 登录成功后建立ws
function loginSuccess() {
var user = $("[name=user]").val();
var token = $("[name=token]").val();
var data = {
user: user,
token: token
}
var protocol = window.location.protocol;
if (protocol === "https:") {
protocol = "wss:";
} else {
protocol = "ws:";
}
var host = window.location.host;
var url = protocol + "//" + host + "/webSocket/" + data.user;
initWebSocket(url, data,
(data) => {
// do onOpen
}, (message)=>{
// do onMessage
});
}
网友评论