springboot + netty 实现多客户端登录 超出设备 并下线

背景

为满足不同用户对多设备登录需求的差异化要求,系统需支持不同的登录策略,包括单平台登录、双平台登录、三平台登录和多平台同时在线。 (后面几个 之后在写😺)

需求描述

系统应支持以下多平台登录策略,具体限制如下:

  1. 单平台登录 (也可以说是全平台登录)
    • 限制:可有 n 种平台同时在线。(n 表示 配置的最大个数)
    • 支持平台:全平台
    • 描述:仅可有 n 种平台在线 超过n台设备 无论是web也好 还是 移动端 核心是限制登录的个数
    • 超过n台设备将通知最开始的设备下线
  • 支持多个设备同时在线:允许用户在多个设备上同时登录和在线。
  • 设置最大同时在线数量:可以配置允许用户同时在线的最大设备数量。当超过此数量时,最早连接的设备将被强制下线。

看起来很复杂 但是只要跟着我的思路 慢慢分析 其实很好实现

技术栈🙌🙌

  • Spring Boot
  • Netty
  • WebSocket

各个版本:

依赖版本
Spring Boot3.0.10-SNAPSHOT
Netty4.1.86.Final
fastjson1.2.51

技术实现方案

在这里插入图片描述

  1. 数据结构设计以及配置🚀
    • 使用 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();
        }
    }
}

  1. 新增 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());
    }
}


  1. WebSocketHandler 管理socket实例
    • handlerAdded 方法中,将新的连接添加到 channelGroup
    • channelRead 方法中,处理 HttpRequest 请求,通过调用 NettyUtils.addChannelForUser 方法添加新的连接,并根据策略限制数量。
    • channelRead0 方法中,处理读取数据 (注意这个和上面有着很大本质区别 不了解的建议去百度)。
    • handlerRemoved 方法中,处理用户下线逻辑,从 channelGroupchannelMap 中移除连接。
    • 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 那就会将最开始的设备移除并通知下线 这个时候前端收到通知 就可以对应处理逻辑😼

“感谢大家阅读本文🌹🌹,希望能对大家有所帮助,同时,也欢迎大家留下宝贵的意见和建议,如果你对本文有更好的想法 随时可以提出 我很乐意接收更好的创意!😼😼😼让我们共同进步,共同成长。祝大家学习进步,工作顺利!🙏🙏”

  • 24
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Likefr

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

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

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

打赏作者

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

抵扣说明:

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

余额充值