一、背景
用户允许在多个平板同时登录,比如运营人员登录教师的账号,进去做一些操作后。
这就带来了一个问题,教师的账户同时登录于两个平板,先后进入了同一个课堂的情况下,会给我们的通道消息发送带来紊乱。
如下图所示:
教师账户同时登录了设备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