分布式WebSocket-上篇

单机WebSocket落地-生产验证


众所周知,教育分线下和线上,随着疫情的发展,线上教育慢慢普及,直播作为互动及体检的重要环节,其必不可少。那么在直播过程中必然需要双向传输等核心技术体系支撑。

技术选型

  • 目前较为主流双向传输技术,如:websocket、mqtt
  • websocket开源简单,集成快,开发成本低、论坛活跃
  • Kong网关支持众多插件,方便维护

考虑到开发成本、使用场景、维护等多方面因素,我们采用WebSocket方案进行集成,由于我们开发周期紧,人员短缺,为了快速实现产品,最初我们采用单点WebSocket方案进行实现,关于双向传输,接下来将通过实例分析说明。

单机WebSocket

所谓单机WebSocket,是指单台服务器采用WebSocket架构部署并运行,结构如下:
分布式WebSocket-上篇_第1张图片
具备特性(直播场景)

  1. 多渠道支持同时观看直播,如:手机/电脑/pad等
  2. 心跳检测(客户端和服务器确认的一种方式)
  3. 异常补发 (发送失败的消息会重新发送)

前后端契约

  1. 业务数据传输格式定义
{"event":"SPEAK","data":json,"code":0}
event:事件,如交互时根据业务场景特定而出
data:事件对应的json数据,用于交互
code: 状态码,用于对错分离
  1. 心跳检测数据格式定义(用于维持socket链接不被nginx和kong关闭,同时客户端可以主动检测当前链接是否正常)
{"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);
        }
    }
}

运行遇到的问题分享

  1. 多请求并发情况下,会存在同个session被引用,建议改成同步发送,同时session也不支持序列化,无法放到缓存中。
  2. 前后端应保持socket链接和心跳检测稳定进行,非致命错误应该catch掉,不应导致链接被关闭,否则错误会进入OnError事件,根据异常捕捉特定分析,是否需要断开连接。
  3. socket传输的内容体积不建议过大,2kb内,否则会偶发出现失败
  4. socket记录表建议至少分区,后续方便维护。
  5. socket不建议携带头部信息,否则会存在CrossOrign等问题,相关参数可以通过json携带进来。
  6. jwt-token字符串存在特殊字符等,放到连接后面拼接参数存在问题,此处对于jwt-token不友好,关于鉴权验证,可以通过token对应jwt-token进行处理。

到此双向传输功能全局实现,即可满足小型的应用,部分应用可能会需要消息补偿等功能,关于Socket异常推送消息补偿如下:

  1. 消息推送全局日志表,如:live_socket_record,用于根据消息状态分析问题,并支持事后补偿
  2. 根据消息的业务场景,特定推送。如:聊天消息未收到,xxx时间内推送即处理,超过不处理等。

分布式WebSocket-上篇_第2张图片

关于断开及重连机制
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)

公众号

你可能感兴趣的:(分布式架构,websocket,双向传输,通讯,分布式,直播)