1、基础原理
WebSocket
是HTML5
一种新的协议,基于TCP
协议实现了客户端和服务端全双工通信的协议。
HTTP
请求的生命周期通过Request
界定,也就是一个Request
、一个Response
,那么在HTTP1.0
中,这次HTTP
请求就结束了。
在HTTP1.1
中进行了改进,使得有一个keep-alive
,也就是说,在一个HTTP
连接中,可以发送多个Request
,接收多个Response
。
在HTTP
中永远是这样,也就是说一个Request
只能有一个Response
。而且这个Response
也是被动的,不能主动发起。
2、Websocket握手
客户端:
GET /chat HTTP/1.1
Host: server.example.com
// 告诉服务器是一个WebSocket请求
Upgrade: websocket
Connection: Upgrade
//一个Base64encode的值,这个是浏览器随机生成
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
//一个用户定义字符串,区分同URL下,不同的服务所需要的协议
Sec-WebSocket-Protocol: chat, superchat
//告诉服务器所使用的Websocket Draft(协议版本)
Sec-WebSocket-Version: 13
Origin: http://example.com
服务端:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
//这个则是经过服务器确认,并且加密过后的 Sec-WebSocket-Key。
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
//表示最终使用的协议
Sec-WebSocket-Protocol: chat
3、其它方式
Ajax
轮询:
类似定时任务,隔几秒向服务器发起请求,问有没有新的数据。
Long Poll
:
采用的是阻塞模式,就是向服务器发起一次请求,如果没有消息,就一直不返回Response
给客户端,直到有消息才返回,返回完之后客户端再次建立连接。
以上两种方法也都是客户端主动发起(服务端不能主动发起),不断建立HTTP
连接,然后等待服务器处理,体现HTTP
的被动性。
不断的建立,关闭HTTP
协议,由于HTTP
是无状态协议,每次都要重新传输identity info
(鉴别信息),来告诉服务端你是谁。
以上两种方式都是非常耗费服务器系统资源的:
Ajax
: 需要服务器有很快的处理速度和资源。
Long Poll
: 需要有很高的并发,也就是说同时处理多个请求的能力。
4、特点
- 最初的握手阶段是http协议,握手完成后就切换到websocket协议,并完全与http协议脱离了。
- 通讯一旦建立连接后,通讯就是“全双工”模式了。服务端和客户端都能在任何时间自由发送数据。
- 交互模式不再是“请求-应答”模式,完全由开发者自行设计通讯协议。
- 通信的数据是基于“帧(frame)”的,可以传输文本数据,也可以直接传输二进制数据,效率高。
- 协议标识符是ws(如果加密,则为wss,类似http与https),服务器网址就是 URL。
5、常用方法
事件 | 事件处理程序 | 描述 |
---|---|---|
open | Socket onopen | 链接建立时触发 |
message | Socket onmessage | 客户端接收服务端数据时触发 |
error | Socket onerror | 通讯发生错误时触发 |
close | Socket onclose | 链接关闭时触发 |
6、JAVA代码
1、导入Maven
依赖
<!-- websocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
2、创建配置类
@Configuration
public class WebSocketConfig {
/**
* 注入ServerEndpointExporter,
* 这个bean会自动注册使用了@ServerEndpoint注解声明的Websocket endpoint
*/
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
3、WebSocket服务
@Slf4j
@ServerEndpoint(value = "/wsServer/{token}")
@Component
public class WebSocketServer {
/**
* 记录当前在线连接数量
*/
private static AtomicInteger onlineCount = new AtomicInteger();
/**
* 存放客户端WebScoket对象 (线程安全)
*/
private static ConcurrentHashMap<String, WebSocketServer> webSocketMap = new ConcurrentHashMap<>();
/**
* 与某个客户端的连接会话,需要通过它来给客户端发送数据
*/
private Session session;
/**
* 接收userId
*/
private String userId = "";
/**
* @param session
* @param token
*/
@OnOpen
public void onOpen(Session session, @PathParam("token") String token) {
this.session = session;
try {
//通过token获取UserId 获取到了证明token有效 获取不到则无效
LoginUser loginUser = SpringUtils.getBean(TokenService.class).getLoginUserByToken(token);
this.userId = loginUser.getUserId().toString();
//刷新token
SpringUtils.getBean(TokenService.class).verifyToken(loginUser);
if (webSocketMap.containsKey(userId)) {
webSocketMap.remove(userId);
webSocketMap.put(userId, this);
} else {
webSocketMap.put(userId, this);
addOnlineCount();
}
log.info("存储用户连接:" + userId + ",当前在线人数为:" + getOnlineCount());
} catch (Exception e) {
log.error("token解析异常,连接失败!");
}
}
/**
* 连接关闭调用的方法
*/
@OnClose
public void onClose() {
if (webSocketMap.containsKey(userId)) {
webSocketMap.remove(userId);
subOnlineCount();
}
log.info("用户退出:" + userId + " 当前在线人数为:" + getOnlineCount());
}
/**
* 收到客户端消息后调用的方法
*/
@OnMessage
public void onMessage(String message, Session session) {
log.info("接收消息:" + userId + " 报文:" + message);
if (StringUtils.isNotBlank(message)) {
try {
if (message.equals("HeartBeat")) {
// 心跳
}
// 做事情
// 这个项目不会涉及前端给后段发消息操作 发了也不用处理
// 如果要用这个的话 最好每一次都做一下校验 因为尽管客户端不合法 如果地址正确也可以发送消息给我
} catch (Exception e) {
e.printStackTrace();
}
}
}
/**
* 发生错误时调用
*/
@OnError
public void onError(Session session, Throwable error) {
log.info("用户错误:" + this.userId + " 原因:" + error.getMessage());
error.printStackTrace();
}
/**
* 实现服务器主动推送
*/
public void sendMessage(String message) throws IOException {
this.session.getBasicRemote().sendText(message);
}
/**
* 发送自定义消息
*/
public static void sendInfo(String message, @PathParam("userId") String userId) throws IOException {
System.out.println("发送消息到:" + userId + ",报文:" + message);
if (StringUtils.isNotBlank(userId) && webSocketMap.containsKey(userId)) {
webSocketMap.get(userId).sendMessage(message);
} else {
System.out.println("用户" + userId + ",不在线!");
}
}
/**
* 获取连接数量
*
* @return 连接数量
*/
public static synchronized AtomicInteger getOnlineCount() {
return onlineCount;
}
/**
* 连接数加一
*/
public static synchronized void addOnlineCount() {
onlineCount.incrementAndGet();
}
/**
* 连接数减一
*/
public static synchronized void subOnlineCount() {
if (onlineCount.intValue() > 0) {
onlineCount.decrementAndGet();
}
}
}
4、调用
@GetMapping("/test")
public void test(){
try {
WebSocketServer.sendInfo(roomId.toString(), userId);
} catch (Exception e) {
if(e instanceof IOException){
//连接客户端异常
}else if(e instanceof NullPointerException){
//客户端不在线
}else{
//未知异常 联系管理员
}
}
}
7、VUE代码
<template>
<div>
</div>
</template>
<script>
export default {
data() {
return {
// 计时器对象——重连
socketReconnectTimer: null,
// WebSocket重连的锁
socketReconnectLock: false,
// 离开标记(解决 退出登录再登录 时出现的 多次相同推送 问题,出现的本质是多次建立了WebSocket连接)
socketLeaveFlag: false,
// 长连接对象
socketObj: {},
// 当前用户token
token: '',
// 长连接地址
socketUrl: 'ws://x.x.x.x:xxxx/wsServer/',
// 心跳检测
heartCheck: {
vueThis: this, // vue实例
timeout: 10000, // 超时时间
timeoutObj: null, // 计时器对象——向后端发送心跳检测
serverTimeoutObj: null, // 计时器对象——等待后端心跳检测的回复
// 心跳检测重置
reset: function() {
clearTimeout(this.timeoutObj)
clearTimeout(this.serverTimeoutObj)
return this
},
// 心跳检测启动
start: function() {
this.timeoutObj && clearTimeout(this.timeoutObj)
this.serverTimeoutObj && clearTimeout(this.serverTimeoutObj)
this.timeoutObj = setTimeout(() => {
// 这里向后端发送一个心跳检测,后端收到后,会返回一个心跳回复
this.vueThis.socketObj.send('HeartBeat')
console.log('发送心跳检测')
this.serverTimeoutObj = setTimeout(() => {
// 如果超过一定时间还没重置计时器,说明websocket与后端断开了
console.log('未收到心跳检测回复')
// 关闭WebSocket
this.vueThis.socketObj.close()
}, this.timeout)
}, this.timeout)
}
},
}
},
created() {
console.log('离开标记', this.socketLeaveFlag)
},
mounted() {
// 初始化WebSocket
this.initWebSocket()
},
destroyed() {
// 离开标记
this.socketLeaveFlag = true
// 关闭WebSocket
this.socketObj.close()
},
methods: {
// 初始化webSocket
initWebSocket() {
try {
this.socketObj = new WebSocket(this.socketUrl + this.token)
// websocket事件绑定
this.socketEventBind()
} catch (e) {
console.log('初始化WebSocket失败:' + e)
this.reconnect()
}
},
// websocket事件绑定
socketEventBind() {
this.socketObj.onopen = this.socketOpen // 连接建立触发
this.socketObj.onmessage = this.onMessage // 客户端接收服务端数据触发
this.socketObj.onsend = this.onSend
this.socketObj.onclose = this.onClose // 连接关闭触发
this.socketObj.onerror = this.onError // 通信发生异常触发
// 监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
window.onbeforeunload = () => {
this.socketObj.close()
}
},
// 连接建立触发
socketOpen() {
console.log('WebSocket:已连接')
this.heartCheck.reset().start()
},
// 客户端接收服务端数据触发
onMessage(msg) {
console.log('WebSocket:已连接')
// console.log(msg);
console.log(msg.data);
if (msg.data.indexOf("HeartBeat") > -1) {
// 心跳回复——心跳检测重置
// 收到心跳检测回复就说明连接正常
console.log("收到心跳检测回复");
// 心跳检测重置
this.heartCheck.reset().start();
} else {
// 普通推送——正常处理
console.log("收到推送消息");
let data = JSON.parse(msg.data);
// 相关处理
console.log(data);
}
},
// 向后端发送数据的回调
onSend() {
console.log('WebSocket:发送信息给后端')
},
// 连接关闭触发
onClose() {
console.log('WebSocket:已关闭')
// 心跳检测重置
this.heartCheck.reset()
if (!this.socketLeaveFlag) {
// 没有离开——重连
this.reconnect()
}
},
// 通信发生异常触发
onError() {
console.log('WebSocket:发生错误')
this.reconnect()
},
// 重连
reconnect() {
if (this.socketReconnectLock) {
return
}
this.socketReconnectLock = true
this.socketReconnectTimer && clearTimeout(this.socketReconnectTimer)
this.socketReconnectTimer = setTimeout(() => {
console.log('WebSocket:重连中...')
this.socketReconnectLock = false
// websocket启动
this.initWebSocket()
}, 4000)
}
}
}
</script>
心跳机制:心跳就是客户端定时的给服务端发送消息,证明客户端是在线的, 如果超过一定的时间没有发送则就是离线了。
如何判断在线离线?
当客户端第一次发送请求至服务端时会携带唯一标识、以及时间戳,服务端到db或者缓存去查询该请求的唯一标识,如果不存在就存入db或者缓存中。
第二次客户端定时再次发送请求依旧携带唯一标识、以及时间戳,服务端到db或者缓存去查询改请求的唯一标识,如果存在就把上次的时间戳拿取出来,使用当前时间戳减去上次的时间,
得出的毫秒秒数判断是否大于指定的时间,若小于的话就是在线,否则就是离线;
若服务端宕机了(服务器未回复心跳),客户端怎么做、服务端再次上线时怎么做?
客户端则需要断开连接,通过onclose关闭连接,服务端再次上线时则需要清除之间存的数据,若不清除则会造成只要请求到服务端的都会被视为离线。