websocket通道,如何在创建连接被拒绝的时候,返回状态码给客户端

一、背景

用户允许在多个平板同时登录,比如运营人员登录教师的账号,进去做一些操作后。

这就带来了一个问题,教师的账户同时登录于两个平板,先后进入了同一个课堂的情况下,会给我们的通道消息发送带来紊乱。

如下图所示:
在这里插入图片描述

教师账户同时登录了设备A和B,经过SLB,分别和后端的节点1和2建立了长连接。

netty通道io.netty.channel.Channel是无法持久化到redis中的,只能存储与jvm集合中,且它的存储形式是<key, value>(key是userId, 不是设备ID)。

所以当同一个账户和不同的后端节点建立长连接时,就出现了问题。

本方案重在实现,限制同一个用户,在同一个房间里只允许创建一个长连接。

二、目标

1、旧版本的客户端不在本方案的实现范围内,只针对新版本的客户端。

2、兼容同时存在新旧版本。

3、当同一个用户尝试在同一个房间里,试图再次创建长连接时,发现设备ID变化的情况下,拒绝连接并给出提示信息到客户端。

三、设计思路

在这里插入图片描述

如果是已登录,则进一步解析本次设备ID和上一次的是否一致。

1、建立长连接的时候,之前只返回成功和失败,现增加一个临时状态。

public enum ResolveStatus {
    FIAL,
    TEMP,
    SUCCESS;
}
  • 何谓临时状态?

意思是先临时创建一个长连接,随机抛出一个运行时异常,在异常捕获方法中,返回提示信息给客户端;最后关闭该长连接。

public class NettyServerHandler extends SimpleChannelInboundHandler<Object> {
 @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
    
		if (cause instanceof ChannelRepeatConnException) {
		   Map<String, Object> data = new HashMap<>();
		   data.put("code", REPEAT_CONNECT);
		   data.put("msg", "repeat connect channel");
		
		   ctx.channel().writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(data)));
		}
		
		ctx.channel().close();
    }
}

2、什么样的情况下,建立的连接是临时状态的?

必须满足以下所有条件

  • 用户userId已经和通道集群服务建立了长连接
  • 用户要建立的通道房间和之前已建立的通道房间,为同一个房间
  • 头部传入字段DeviceId(设备ID)。

只有上述的条件都满足的情况下,才返回临时状态。

String deviceId = fhr.headers().get("DeviceId");
 
if(StringUtil.isNotEmptyAndNotZero(deviceId)){
  if (!deviceId.equalsIgnoreCase(userSession.getDeviceId())) {
    log.error("该教师userId={}的设备与原设备不一致, deviceId={}, oldDeviceId={}",
            user.getUserId(), deviceId, userSession.getDeviceId());
    return ResolveStatus.TEMP;
  }
}

3、保存到redis的UserSession,增加设备ID字段

在建立长连接成功后,将UserSession持久化到redis中,包括了新版本上传上来的设备ID。

当同一个用户账户再次试图建立长连接前,先从redis里查找是否有UserSession,判断先后两次的设备设备ID是否为同一个。

如果是同一个,则允许,反之,拒绝并给出提示信息。

@NoArgsConstructor
@AllArgsConstructor
@Data
@Builder
public class UserSession {
   /**
    * userId
    */
   private String userId;
   /**
    * appKey
    */
   private String appKey;
   /**
    * channelId channelId
    */
   private String channelId;
   /**
    * roomId 所连房间Id
    */
   private String roomId;
   /**
    * 所连接的服务器mac地址
    */
   private String serverMacAddress;
   /**
    * 通道创建时间
    */
   private Long createTime;

   /**
    * 设备ID
    */
   private String deviceId;
}

4、支持配置

针对用户类型和业务编号,只有在配置范围内的,才进行单点登录的限制。

/**
 * 单点登录的业务编号,多个使用逗号隔开
 */
private String ssoAppKeyList;
 
/**
 * 单点登录的用户类型,多个使用逗号隔开
 */
private String ssoUserTypeList;
  • 默认配置写在application.yml
common:
  ssoAppKeyList: xx,xxx,xxxx
  ssoUserTypeList: 1,2,3,4,5

伪代码示例

  • 建立连接的伪代码
@Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
        //传统的HTTP接入
        if (msg instanceof FullHttpRequest) {
            //获取头部参数
            FullHttpRequest fhr = (FullHttpRequest) msg;
            String userInfo = fhr.headers().get("UserInfo");
            // 新增的设备ID
            String deviceId = fhr.headers().get("DeviceId");
            ResolveStatus status;

            synchronized (this) {
                status = this.resolveUserInfo(userInfo, deviceId, ctx);
            }

            if (status.equals(ResolveStatus.FIAL)) {
                return;
            }

            // 先建立连接
            this.handleHttpRequest(ctx, (FullHttpRequest) msg);

            if (status.equals(ResolveStatus.SUCCESS)) {
                // 处理业务
            } else {
                // 如果是临时态,通过抛出异常,向客户端发送拒绝连接的原因
                throw new ChannelRepeatConnException();
            }
        } else if (msg instanceof WebSocketFrame) {
            //WebSocket接入
            this.handleWebSocketFrame(ctx, (WebSocketFrame) msg);
        }
    }
  • 只要解析用户信息返回的不是失败,就建立tcp握手。

注意!!抛自定义异常,一定要在握手成功之后,否则连接没能建立,客户端是收不到提示信息。

     private void handleHttpRequest(ChannelHandlerContext ctx, FullHttpRequest req) {
        //如果HTTP解码失败,返回HTTP异常
        if (!req.decoderResult().isSuccess()
                || (!"websocket".equals(req.headers().get("Upgrade")))) {
            this.sendHttpResponse(ctx, req, new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.BAD_REQUEST));
            return;
        }
        //构造握手响应返回,本机测试
        WebSocketServerHandshakerFactory wsFactory = new WebSocketServerHandshakerFactory(getWebSocketLocation(req), null
                , false, 65536 * 10);
        handshaker = wsFactory.newHandshaker(req);
        if (handshaker == null) {
            WebSocketServerHandshakerFactory.sendUnsupportedVersionResponse(ctx.channel());
        } else {
            //把握手消息返回给客户端
            handshaker.handshake(ctx.channel(), req);
        }
    }

     private void sendHttpResponse(ChannelHandlerContext ctx, FullHttpRequest req, FullHttpResponse res) {
        //返回应答给客户端
        if (res.status().code() != HttpResponseStatus.OK.code()) {
            ByteBuf buf = Unpooled.copiedBuffer(res.status().toString(), CharsetUtil.UTF_8);
            res.content().writeBytes(buf);
            buf.release();
            setContentLength(res, res.content().readableBytes());
        }
        //如果是非Keep-Alive,关闭连接
        ChannelFuture f = ctx.channel().writeAndFlush(res);
        if (!isKeepAlive(req) || res.status().code() != HttpResponseStatus.OK.code()) {
            f.addListener(ChannelFutureListener.CLOSE);
        }
    }
  • 解析用户信息,返回解析状态。
public ResolveStatus resolveUserInfo(UserInfo user, String deviceId, ChannelHandlerContext ctx) {
        if (user != null) {
            // 查询该用户是否已建立session
            UserSession userSession = userSessionService.find(user.getUserId());

            String userRoomId = String.format(Constants.SPLIT, user.getAppKey(), user.getRoomId());

            // 该业务是否已配置
            boolean supportAppKey = commonConfig.getSsoAppKeyList().contains(user.getAppKey());

            if (supportAppKey && null != userSession && userRoomId.equalsIgnoreCase(userSession.getRoomId())
                    && StringUtil.isNotEmptyAndNotZero(deviceId)) {
                // 该用户类型是否已配置
                boolean supportUserType = commonConfig.getSsoUserTypeList().contains(String.valueOf(user.getUserType()));

                // 如果设备ID不一样,拒绝建立连接,等待前一个通道连接释放后,才被允许。
                if (supportUserType && !deviceId.equalsIgnoreCase(userSession.getDeviceId())) {
                    log.warn("该教师userId={}的设备与原设备不一致, deviceId={}, oldDeviceId={}",
                            user.getUserId(), deviceId, userSession.getDeviceId());
                    return ResolveStatus.TEMP;
                }
            }

            String channelId = ctx.channel().id().asShortText();
            userSessionService.save(user, deviceId, ctx.channel());

            return ResolveStatus.SUCCESS;
        }
        return ResolveStatus.FIAL;
    }

四、客户端的改动

在和服务端建立连接的时候,头部header传递用户信息外,还得传入新增字段DeviceId,表示是新客户端。

1、客户端的处理

@Override
public void onMessage(String message) {
     // 判断message返回了repeat connect channel,则不再重试连接
}

2、客户端除了要对接收到的信息处理外, 上面也提到了,还需要在头部传递字段DeviceId。

Map<String, String> headers = new HashMap<>();
headers.put("DeviceId", deviceId);

headers.put("DeviceId", deviceId);

//建立ws连接

五、踩过的坑

  • 1、在没有建立握手之前,就抛出异常,导致ws消息没有发送出去。
  • 2、在对比设备ID是否变化的时候,发生npe,这是因为可能userSession不存在,也可能userSession.deviceId为null,也可以连接头部header中没有deviceId字段。

六、测试代码

		WebSocketClient client = null;
        try {
            Map<String, String> headers = new HashMap<>();
            // 测试样例允许不传入设备ID,模拟旧客户端
            if (StringUtils.isNotEmpty(deviceId) && !"0".equals(deviceId)) {
                headers.put("DeviceId", deviceId);
            }

            client = new WebSocketClient(new URI(WEB_SOCKET_ADDRESS), headers) {
                @Override
                public void onOpen(ServerHandshake handshake) {
                    // log.info("userId:{}, deviceId:{}, socket connect {} success ", userId, deviceId, WEB_SOCKET_ADDRESS);
                }

                @Override
                public void onMessage(String message) {
                    if (message.contains("repeat")) {
                        log.error("{}", message);
             // 类似spring从bean工厂找到实例化类,进行登出操作,不再发送心跳进行重连。
                        TeacherClient teacherClient = WebSocketClientFactory.deviceIdTeacherClientMap.get(userId + "_" + deviceId);
                        teacherClient.closeSendHeartBeat();
                        return;
                    }

                    handleMessage(message, userId);
                }

                @Override
                public void onClose(int code, String reason, boolean remote) {
//                    log.info("userId:{}, deviceId:{}, socket close connect {} ", userId, deviceId, WEB_SOCKET_ADDRESS);
                }

                @Override
                public void onError(Exception ex) {
                }

                @Override
                public void onMessage(ByteBuffer bytes) {
                }
            };
            client.setConnectionLostTimeout(20);
            client.connect();

//            while (!WebSocket.READYSTATE.OPEN.equals(client.getReadyState())) {
//                log.info("userId:{} 连接中···请稍后", userId);
//            }
        } catch (Exception e) {
            log.error("client start error...", e);
        }
  • 1、编写三个接口,创建连接和登录和登出。
@GetMapping("/api/v1/channel/repeat_conn/{teacherId}/{deviceId}")
    public ResponseEntity<?> repeatConnChannel(@PathVariable String teacherId, @PathVariable String deviceId) {
        channelService.repeatConn(teacherId, deviceId);
        return ResponseEntity.ok(DateUtil.now());
    }

    @GetMapping("/api/v1/channel/logout/{teacherId}/{deviceId}")
    public ResponseEntity<?> logout(@PathVariable String teacherId, @PathVariable String deviceId) {
        channelService.logout(teacherId, deviceId);
        return ResponseEntity.ok(DateUtil.now());
    }

    @GetMapping("/api/v1/channel/login/{teacherId}/{deviceId}")
    public ResponseEntity<?> login(@PathVariable String teacherId, @PathVariable String deviceId) {
        channelService.login(teacherId, deviceId);
        return ResponseEntity.ok(DateUtil.now());
    }

  • 2、代码的简单实现
public void repeatConn(String teacherId, String deviceId) {
        TeacherClient teacherClient = new TeacherClient("ONHT9WFZ", teacherId, "6L4775MW", ROLE_TEA, deviceId);
        teacherClient.init();

        // 对象放入容器里,模拟spring的bean容器
        WebSocketClientFactory.deviceIdTeacherClientMap.put(teacherId + "_" + deviceId, teacherClient);

        try {
            Thread.sleep(10 * 1000L);
        } catch (Exception e) {
            // 忽略异常
        }
    }

//开启心跳,也即登录,意味着开始和服务端进行重连。
    public void login(String teacherId, String deviceId) {
        TeacherClient teacherClient = WebSocketClientFactory.deviceIdTeacherClientMap.get(teacherId + "_" + deviceId);
        teacherClient.openSendHeartBeat();
    }

//关闭心跳,也即退出,意味着不再和服务端进行重连。
    public void logout(String teacherId, String deviceId) {
        TeacherClient teacherClient = WebSocketClientFactory.deviceIdTeacherClientMap.get(teacherId + "_" + deviceId);
        teacherClient.closeSendHeartBeat();
    }
  • 3、心跳管理
     /**
     * 发送心跳
     */
    private volatile boolean sendHeartBeat;

    public TeacherClient(String roomId, String userId, String desktopId, Integer role, String deviceId) {
        this.roomId = roomId;
        this.userId = userId;
        this.desktopId = desktopId;
        this.role = role;
        this.deviceId = deviceId;
        this.sendHeartBeat = true;
    }

    public void closeSendHeartBeat(){
        this.sendHeartBeat = false;
    }

    public void openSendHeartBeat(){
        this.sendHeartBeat = true;
    }

    public void init() {
        obtainWebSocketClient();
        scheduled.scheduleAtFixedRate(new CheckClientRunnable(), 5, 3, TimeUnit.SECONDS);
    }

    class CheckClientRunnable implements Runnable {
        @Override
        public void run() {
            if(!sendHeartBeat){
                return;
            }

            try {
                WebSocketClient client = WebSocketClientFactory.userIdClientMap.get(userId + "_" + deviceId);

                if (null == client) {
                    obtainWebSocketClient();
                    return;
                }

                // 不停地发送ping命令
                if (client.isOpen()) {
                    client.sendPing();
                } else {
                    client.close();
                    obtainWebSocketClient();
                }
            } catch (Exception e) {
                log.error("checkClientAlive error", e);
            }
        }
    }

测试一

先在设备SN00003建立ws连接,再在设备SN00002试图建立ws连接。

curl http://localhost:8085/api/v1/channel/repeat_conn/teacher999/SN00003
curl http://localhost:8085/api/v1/channel/repeat_conn/teacher999/SN00002

userId:teacher999, deviceId:SN00003, socket connect ws://192.168.8.28:8889 success 

// 设备SN00002在建立ws连接的时候,服务端返回错误码10009,错误信息"repeat connect channel"。
userId:teacher999, deviceId:SN00002, socket connect ws://192.168.8.28:8889 success 
{"msg":"repeat connect channel","code":10009}
userId:teacher999, deviceId:SN00002, socket connect ws://192.168.8.28:8889 success 
{"msg":"repeat connect channel","code":10009}
userId:teacher999, deviceId:SN00002, socket connect ws://192.168.8.28:8889 success 
{"msg":"repeat connect channel","code":10009}

测试二

设备SN00003断开已创建的ws连接。

curl http://localhost:8085/api/v1/channel/logout/teacher999/SN00003

// 设备SN00003断开前
userId:teacher999, deviceId:SN00002, socket connect ws://192.168.8.28:8889 success 
{"msg":"repeat connect channel","code":10009}

// 设备SN00003完全断开后
// 设备SN00002连接上服务端
userId:teacher999, deviceId:SN00002, socket connect ws://192.168.8.28:8889 success 

测试三

设备SN00003登录

curl http://localhost:8085/api/v1/channel/login/teacher999/SN00003

// 因为设备SN00002已连接上服务端,所以后登录的设备SN00003是无法创建ws连接的。一直收到的报错提示。
userId:teacher999, deviceId:SN00003, socket connect ws://192.168.8.28:8889 success 
{"msg":"repeat connect channel","code":10009}

测试四

设备SN00002退出

curl http://localhost:8085/api/v1/channel/logout/teacher999/SN00002

// 设备SN00002退出的话,按理就可以允许设备SN00003建立连接了,就不会报错了。
userId:teacher999, deviceId:SN00003, socket connect ws://192.168.8.28:8889 success
  • 经过上面四步测试,证明我们的限制是生效的,先登录的可以创建连接,后面的再想创建连接,就会报错。当前面登录的退出后,后面的设备就又可以创建连接了。

测试五

这个时候,我们模拟一个旧客户端登录,头部不传入设备ID字段。

curl http://localhost:8085/api/v1/channel/repeat_conn/teacher999/0

// 被允许创建ws连接
userId:teacher999, deviceId:0, socket connect ws://192.168.8.28:8889 success

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

天草二十六_简村人

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值