背景
为满足不同用户对多设备登录需求的差异化要求,系统需支持不同的登录策略,包括单平台登录、双平台登录、三平台登录和多平台同时在线。 (后面几个 之后在写😺)
需求描述
系统应支持以下多平台登录策略,具体限制如下:
- 单平台登录 (也可以说是全平台登录)
- 限制:可有 n 种平台同时在线。(n 表示 配置的最大个数)
- 支持平台:全平台
- 描述:仅可有 n 种平台在线 超过n台设备 无论是web也好 还是 移动端 核心是限制登录的个数
- 超过n台设备将通知最开始的设备下线
- 支持多个设备同时在线:允许用户在多个设备上同时登录和在线。
- 设置最大同时在线数量:可以配置允许用户同时在线的最大设备数量。当超过此数量时,最早连接的设备将被强制下线。
看起来很复杂 但是只要跟着我的思路 慢慢分析 其实很好实现
技术栈🙌🙌
- Spring Boot
- Netty
- WebSocket
各个版本:
依赖 | 版本 |
---|---|
Spring Boot | 3.0.10-SNAPSHOT |
Netty | 4.1.86.Final |
fastjson | 1.2.51 |
技术实现方案
- 数据结构设计以及配置🚀
- 使用
ConcurrentHashMap<String, List<Channel>>
存储每个用户的多个连接。 - 新增
NettyUtils
工具类方法,用于管理用户的连接列表。
但是要注意 该设计使用的是jdk map 结构 所有数据都存储在内存中 如果中途关闭服务器在线用户都会丢失
- 使用
配置🚩
- 最大同时在线设备数量等参数可以通过配置文件或其他方式动态设置。
netty:
port: 8082
maxCount: 2
path: /ws
接下来贴上netty启动代码🍉
@Component
@RequiredArgsConstructor
public class ProjectInitializer extends ChannelInitializer<SocketChannel> {
/**
* webSocket协议名
*/
static final String WEBSOCKET_PROTOCOL = "WebSocket";
private final WebSocketHandler webSocketHandler;
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
// 设置管道
ChannelPipeline pipeline = socketChannel.pipeline();
// 流水线管理通道中的处理程序(Handler),用来处理业务
// webSocket协议本身是基于http协议的,所以这边也要使用http编解码器
pipeline.addLast(new HttpServerCodec());
pipeline.addLast(new ObjectEncoder());
// 以块的方式来写的处理器
pipeline.addLast(new ChunkedWriteHandler());
pipeline.addLast(new HttpObjectAggregator(8192));
// 自定义的handler,处理消息业务逻辑
pipeline.addLast(webSocketHandler);
/*
说明:
1、对应webSocket,它的数据是以帧(frame)的形式传递
2、浏览器请求时 ws://localhost:58080/xxx 表示请求的uri
3、核心功能是将http协议升级为ws协议,保持长连接
allow Mask Mismatch
*/
pipeline.addLast(new WebSocketServerProtocolHandler(NettyProps.webSocketPath, WEBSOCKET_PROTOCOL, true, 65536 * 10, true,true));
}
}
```java
/**
* @author Likefr
*/
@Slf4j
@Component
public class NettyServer {
/**
* 本质上是个线程池
* */
EventLoopGroup bossGroup;
/**
* 工作线程
* */
EventLoopGroup workGroup;
/**
* 端口号
*/
@Value("${netty.port:8082}")
private int port;
@Autowired
ProjectInitializer nettyInitializer;
@PostConstruct
public void start() {
new Thread(() -> {
bossGroup = new NioEventLoopGroup();
workGroup = new NioEventLoopGroup();
ServerBootstrap bootstrap = new ServerBootstrap();
// bossGroup辅助客户端的tcp连接请求, workGroup负责与客户端之前的读写操作
bootstrap.group(bossGroup, workGroup);
// 设置NIO类型的channel
bootstrap.channel(NioServerSocketChannel.class);
// 设置监听端口
bootstrap.localAddress(new InetSocketAddress(port));
// 设置管道
bootstrap.childHandler(nettyInitializer);
// 配置完成,开始绑定server,通过调用sync同步方法阻塞直到绑定成功
ChannelFuture channelFuture = null;
try {
channelFuture = bootstrap.bind().sync();
log.info("Netty Server started and listen on:{}", channelFuture.channel().localAddress());
// 对关闭通道进行监听
channelFuture.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
/**
* 释放资源
*/
@PreDestroy
public void destroy() throws InterruptedException {
if (bossGroup != null) {
bossGroup.shutdownGracefully().sync();
}
if (workGroup != null) {
workGroup.shutdownGracefully().sync();
}
}
}
- 新增 NettyUtils 工具方法
addChannelForUser
:向用户的连接列表中添加新的连接,并根据策略限制数量。getOnlineCountForUser
:获取用户当前在线设备的数量。removeChannelFromUserId
:从用户的连接列表中移除指定的连接。
netty工具封装
/**
* @author Likefr
* @description socket 公共配置
* @date 2024/7/11 11:13
*/
@Slf4j
public class NettyUtils {
/**
* 存放请求ID与channel的对应关系
*/
private static volatile ConcurrentHashMap<String, List<Channel>> channelMap = null;
/**
* 定义一把锁
*/
private static final Object LOCK = new Object();
/**
* 获取所有用户实例
*
* @return Map
*/
public static ConcurrentHashMap<String, List<Channel>> getChannelMap() {
if (null == channelMap) {
synchronized (LOCK) {
if (null == channelMap) {
channelMap = new ConcurrentHashMap<>();
}
}
}
return channelMap;
}
/**
* 根据实例获取用户id
*
* @param channel
* @return String userId
*/
public static String getUserIdFromChannel(Channel channel) {
AttributeKey<String> key = AttributeKey.valueOf("userId");
return channel.attr(key).get();
}
/**
* 删除用户与channel的对应关系
*/
public static void removeChannelFromUserId(String userId, Channel channel) {
List<Channel> channels = channelMap.get(userId);
if (channels != null) {
channels.remove(channel);
// 如果一个设备都不存在 那就直接情况map
if (channels.isEmpty()) {
channelMap.remove(userId);
}
}
}
/**
* 删除对应用户所有实例
*
* @param uid
* @return
*/
public static void removeAllUserChannel(String uid) {
if (Objects.isNull(uid)) {
return;
}
NettyUtils.getChannelMap().remove(uid);
}
/**
* 获取对应在线个数
*
* @params uid
*/
public static int getOnlineCountForUser(String uid) {
List<Channel> channels = getChannelMap().get(uid);
return (channels == null) ? 0 : channels.size();
}
/**
* 删除用户与channel的对应关系
*/
public static void removeChannel( ChannelHandlerContext ctx) {
getChannelMap().remove(ctx.channel());
}
}
- WebSocketHandler 管理socket实例
- 在
handlerAdded
方法中,将新的连接添加到channelGroup
。 - 在
channelRead
方法中,处理 HttpRequest 请求,通过调用NettyUtils.addChannelForUser
方法添加新的连接,并根据策略限制数量。 - 在
channelRead0
方法中,处理读取数据 (注意这个和上面有着很大本质区别 不了解的建议去百度)。 - 在
handlerRemoved
方法中,处理用户下线逻辑,从channelGroup
和channelMap
中移除连接。 - 在
exceptionCaught
方法中,处理异常情况,移除相关的连接并关闭连接。
- 在
使用netty 的应该都知道 类似于WebSocket 中的 onMessage onOpen onClose …
贴上WebSocketHandler 部分代码 这里不处理逻辑
按照我们前面的流程图 也就是前端连接服务端之后 会向服务端 发起一条消息
{
"userId": 123123,
// or
"token": "jwtxxxxxxxx"
}
使用用户id 还是token 看个人 我这边以userId为例
服务端接收实体:
/**
* @version: java version 11
* @Author: Likefr
* @description: 接收前端参数
* @date: 2024-07-14 14:39
*/
/**/
@Data
public class UserParams {
private Long userId;
// 或
private String token;
}
/**
* 读取数据
* WebSocketHandler.java
*/
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) {
UserParams command = JSON.parseObject(msg.text(), UserParams.class);
// 使用userId 或者 使用token 看个人
if (Objects.nonNull(command.getUserId())) {
log.info("服务器收到消息:{}", command);
Long uid = command.getUserId();
AttributeKey<String> key = AttributeKey.valueOf("userId");
ctx.channel().attr(key).setIfAbsent(uid.toString());
// todo 校验用户...
// 核心代码
this.addChannelForUser(uid.toString(), ctx.channel());
ctx.channel().writeAndFlush(NettyResult.success("登录成功"));
return;
}
}
关键代码如下
public void addChannelForUser(String uid, Channel newChannel) {
// 获取当前用户的所有设备
List<Channel> existingChannels = NettyUtils.getChannelMap().get(uid);
if (Objects.isNull(existingChannels)) {
existingChannels = new ArrayList<>();
}
// 如果用户登录的设备大于最大个数就踢出最开始的设备
if (existingChannels.size() >= MAX_COUNT) {
Channel oldestChannel = existingChannels.remove(0);
oldestChannel.writeAndFlush(NettyResult.error(ResultBody.error(), "您已在其他地方登录"));
}
existingChannels.add(newChannel);
NettyUtils.getChannelMap().put(uid, existingChannels);
String userIdFromChannel = NettyUtils.getUserIdFromChannel(newChannel);
log.info("当前用户: {} 已在线个数: {}", userIdFromChannel, existingChannels.size());
}
测试
配置用例 max_count = 3
-
假设第一个客户端登录
-
同一用户第二个客户端登录
- 第三个客户端登录
此时 统一用户登录在线个数正好三个 接着 我们开启第四个客户端
此时 登录在线个数 >= maxCount (3)
我们切换第一个客户端
这个时候的 list 顺序是
- 2 3 4 (因为 1 已经被4 顶下线了)
- 如果这个时候 在登陆同一个账号 在上线
- 那么 2 将会被踢下线 !!!
回过头来看设计层面
超过 maxCount 那就会将最开始的设备移除并通知下线 这个时候前端收到通知 就可以对应处理逻辑😼
“感谢大家阅读本文🌹🌹,希望能对大家有所帮助,同时,也欢迎大家留下宝贵的意见和建议,如果你对本文有更好的想法 随时可以提出 我很乐意接收更好的创意!😼😼😼让我们共同进步,共同成长。祝大家学习进步,工作顺利!🙏🙏”