技术选型:
考虑到开发成本、使用场景、维护等多方面因素,我们采用WebSocket方案进行集成,由于我们开发周期紧,人员短缺,为了快速实现产品,最初我们采用单点WebSocket方案进行实现,关于双向传输,接下来将通过实例分析说明。
单机WebSocket:
所谓单机WebSocket,是指单台服务器采用WebSocket架构部署并运行,结构如下:
具备特性(直播场景):
前后端契约:
{"event":"SPEAK","data":json,"code":0}
event:事件,如交互时根据业务场景特定而出
data:事件对应的json数据,用于交互
code: 状态码,用于对错分离
{"event":"HEART","data":code,"code":0}
code: 如:客户端发出2,服务端回应3
应用集成步骤如下:
Client:
<script type="text/javascript">
var websocket = null;
//判断当前浏览器是否支持WebSocket
if ('WebSocket' in window) {
//23是一个ID ,chat是要订阅的话题
websocket = new WebSocket("ws://localhost:8091/demo/websocket/345/THE/452784/20201210162113114000eaf319eaeef4/pc");
} else {
alert('当前浏览器 Not support websocket')
}
//连接发生错误的回调方法
websocket.onerror = function () {
setMessageInnerHTML("WebSocket连接发生错误");
};
//连接成功建立的回调方法
websocket.onopen = function () {
setMessageInnerHTML("WebSocket连接成功");
}
//接收到消息的回调方法
websocket.onmessage = function (event) {
console.log(event.data)
setMessageInnerHTML(event.data );
}
//连接关闭的回调方法
websocket.onclose = function () {
setMessageInnerHTML("WebSocket连接关闭的回调方法,后台已经关闭了这个连接");
}
//监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
window.onbeforeunload = function () {
closeWebSocket();
}
//将消息显示在网页上
function setMessageInnerHTML(innerHTML) {
console.log(innerHTML)
document.getElementById('message').innerHTML += innerHTML + '
';
}
//关闭WebSocket连接
function closeWebSocket() {
websocket.close();
setMessageInnerHTML("WebSocket连接关闭");
}
//open WebSocket连接
function openWebSocket() {
if (websocket.readyState == 1 || websocket.readyState == 0) {
closeWebSocket();
console.log("如果已经存在,先给他关闭")
setMessageInnerHTML("当前连接没有断开,接下来我们会给他断开,然后重新打开一个");
}
//判断当前浏览器是否支持WebSocket
if ('WebSocket' in window) {
//不存在而且浏览器支持,重新打开连接
websocket = new WebSocket("ws://localhost:8091/demo/websocket/345/THE/452784/20201210162113114000eaf319eaeef4/pc");
setMessageInnerHTML("已经重新打开了");
} else {
alert('当前浏览器 Not support websocket')
}
}
//发送消息
function send() {
var message = document.getElementById('text').value;
websocket.send(message);
}
Server:
1.引入spring-websocket包,版本可以跟随springboot框架版本号
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
<version>2.0.6.RELEASE</version>
</dependency>
2.配置WebSocketConfig
@Configuration
public class WebSocketConfig /*extends ServerEndpointConfig.Configurator*/ {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
3.配置WebSocketServer,核心实现类
@ServerEndpoint(value = "/websocket/{userId}/{userType}/{courseContentId}/{token}/{platform}")
@Component
@Slf4j
public class WebSocketServer{
//
private static ConcurrentHashMap<String, WebSocketServer> webSocketSet = new ConcurrentHashMap<String, WebSocketServer>();
//存储用户对象,便于后续业务获取
private static ConcurrentHashMap<String, ChatBo> chatCache = new ConcurrentHashMap<String, ChatBo>();
//与某个客户端的连接会话,需要通过它来给客户端发送数据
public Session webSocketsession;
//当前发消息的人员编号
private ChatBo chatBo = null;
/**
* 连接建立成功调用的方法
*/
@OnOpen
public void onOpen(@PathParam("userId") String userId, @PathParam("userType") String userType,
@PathParam("courseContentId") Integer courseContentId, @PathParam("token") String token,
@PathParam("platform") String platform,
Session webSocketsession, EndpointConfig config) {
//TODO操作步骤
//@1设置session全局变量
this.webSocketsession = webSocketsession;
//@2针对token/userId进行权限验证
ChatBo chatBo=subcribeChat(xxx);
if(chatBo==null){//校验失败
//断开socket连接
return;
}
this.chatBo = chatBo;
//@3 存储用户订阅记录,后续统计、补偿做基础
chatAsync.subscribe(chatBo);
//@4 bizId是业务id
webSocketSet.put(demo.getBizId(), this);//加入map中
//@5 socket补偿
chatAsync.compensateForSocket(chatBo, webSocketSet.get(chatBo.getBizId()));
}
/**
* 收到客户端消息后调用的方法
*
* @param
*/
@OnMessage
public void onMessage(@PathParam("userId") String userId,
@PathParam("courseContentId") Integer courseContentId,
String message, Session session) {
log.info("[WebSocketServer] Received Message :: userId:{},planId:{},message:{}", userId, courseContentId, message);
/*
* 1.客户端约定好心跳检测的内容
* 2.处理特定的消息
* 3.message转换对象,根据Event处理特定的业务即可
*/
JSONObject obj = JSONObject.parseObject(message);
String event = (String) obj.get("event");
//处理特定的业务
}
/**
* 连接关闭调用的方法
*/
@OnClose
public void onClose(@PathParam("userId") String userId,
@PathParam("courseContentId") Integer courseContentId,
@PathParam("token") String token,
Session session) {
closeSocket(this.userSocketParam(userId, courseContentId), BaseConstans.SocketEventType.onClose.name());
}
@OnError
public void onError(@PathParam("userId") String userId,
@PathParam("courseContentId") Integer courseContentId,
@PathParam("token") String token,
Session session, Throwable error) {
if (userId != null) {
log.info("用户id:{},课次id:{},用户令牌:{},bizId:{},执行onError()", userId, courseContentId, token,chatBo.getBizId(), error);
} else {
log.info("直播间处理异常,断开连接,执行onError()", error);
}
if(error instanceof BadSqlGrammarException){
return;
}
if(error instanceof InvocationTargetException){
return;
}
closeSocket(this.userSocketParam(userId, courseContentId), BaseConstans.SocketEventType.onError.name());
}
/***
* @desc 关闭socket连接统一处理
* @param userId 当前用户id,可组装
* @param eventType onOpen,onClose,onMessage,onError;
*/
public void closeSocket(String userId, String eventType) {
if (chatBo != null && StringUtils.isNotBlank(chatBo.getBizId())) {
webSocketSet.remove(chatBo.getBizId()); //从set中删除
//chatCache.remove(chatBo.getBizId());
chatAsync.uNSubscribe(chatBo,eventType);
log.info("聊天室频道:{},用户:{}离开,正常关闭连接", chatBo.getChannelId(), chatBo.getUserId() + "/" + chatBo.getUserType());
chatBo=null;
//chatAsync.cleanChatCache(chatBo.getUserId(), chatBo.getCourseContentId());
} else {
log.info("聊天室用户参数:{},chatBo为空,可疑关闭连接", userId);
}
//关闭socket连接
//boolean hasSocket = false;
if (webSocketsession != null) {
try {
webSocketsession.close();
webSocketsession=null;
} catch (Exception e) {
log.info("关闭socket连接失败", e);
//hasSocket = true;
}
}
}
//订阅聊天室鉴权验证
public ResultCode<ChatBo> subcribeChat(String userId, String (userType), Integer courseContentId, String token, String platform) {
/**
*1.老师、学生权限验证(userType)
*2.实例化ChatBo对象(频道id+课次id+全局用户id)
*/
//实例化
ChatBo chatBo = new ChatBo();
chatBo.setBizId(chatBo.getChannelId() + "/" + chatBo.getPlanId() + "/" + CommonUtil.getUid());
return ResultCode.getSuccessReturn(null, null, chatBo);
}
@Data
public class ChatBo {
private String userId;
private String userName;
private String userType;
private String channelId;
private Integer subjectId;
private Integer planId;
private Integer classId;
private Integer courseContentId;
private String bizId;
private String platform;
}
/**
* 异步推送消息至客户端
* TODO 多请求并发情况下,会存在同个session被引用,此处可以改成同步发送,
*
* @param message
*/
public void sendMessage(String message) {
try {
if (webSocketsession != null && webSocketsession.isOpen()) {
this.webSocketsession.getBasicRemote().sendText(message);
} else {
log.info(webSocketsession == null ? ("当前session为空;message=" + message) :
("当前session已关闭,sessionId=" + webSocketsession.getId()));
}
} catch (Exception e) {
//e.printStackTrace();
log.error("推送消息失败,sessionId:{}", webSocketsession.getId());
log.error("推送消息失败", e);
}
}
}
运行遇到的问题分享
到此双向传输功能全局实现,即可满足小型的应用,部分应用可能会需要消息补偿等功能,关于Socket异常推送消息补偿如下:
关于断开及重连机制
1.触发重连流程3s后重新尝试建立socket连接,进入socket建立流程,重试次数计数+1
2.如果建立连接失败,链接最终会被关闭,并触发close事件,再次进入重连流程,3s后进行重连,重试次数计数+1
3.当进入重连流程时,重试次数计数大于3,视为已重试3次,放弃重试
4.当socket链接重新建立完成,接受到服务端的心跳报文(目前服务端暂定为数字3),且WebSocket连接status为open,判断为socket状态正常,重连成功,清空重试次数计数
关于配置:
1.重连延迟:3s
2.心跳报文间隔:30s(链接建立后会立刻发送一次)
3.最多重试次数:3-5
4.nginx和kong 超时时间:180s = 3分钟
Nginx配置ws
location /ws/ {
#通过配置端口指向部署websocker的项目
proxy_pass http://127.0.0.1:8080/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection “Upgrade”;
proxy_read_timeout 600s;
proxy_set_header X-real-ip $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
}
总结:
本章内容主要讲述了Socket单点应用场景(集成、前后端契约、心跳检测、断开重连、补偿)等。
下篇文章通过实战案例会重点分析单机Socket的问题以及分布式Socket的实现方式。
作者简介:张程 技术研究
更多文章请关注微信公众号:zachary分解狮 (frankly0423)