游戏网络Socket长连接管理

对于网络游戏来说,网络连接的开发与维护是非常重要的,这里主要说明一下最常用的socket长连接开发与管理。服务端使用的网络框架是Netty,客户端使用的是unity,本文中的源码,可以在这里查看:https://gitee.com/wgslucky/xinyue-alone-game-server ,此文章对应的代码tag是v1.0.4

连接创建

对于服务器来说,是启动一个监听的端口,等待客户端连接即可,在源码中可以查看这个类:GameNetworkServer

public void start(GameChannelInitializer channelInitializer) {
        //从Spring的上下文bean中获取服务器的配置信息
        GameFrameworkConfig serverConfig = context.getBean(GameFrameworkConfig.class);
        boolean useEpoll = useEpoll();
        bossGroup = useEpoll ? new EpollEventLoopGroup(1) : new NioEventLoopGroup(1);// 它主要用来处理连接的管理
        //设置工作线程,工作线程负责处理Channel中的消息
        workerGroup = useEpoll ? new EpollEventLoopGroup(serverConfig.getWorkThreads()) : new NioEventLoopGroup(serverConfig.getWorkThreads());
        int port = serverConfig.getPort();
        ServerBootstrap bootstrap = new ServerBootstrap();
        try {
            //创建连接channel的初始化器
            bootstrap.group(bossGroup, workerGroup)
                    .channel(useEpoll ? EpollServerSocketChannel.class : NioServerSocketChannel.class)
                    .option(ChannelOption.SO_BACKLOG, 128).option(ChannelOption.SO_REUSEADDR, true)
                    .childOption(ChannelOption.TCP_NODELAY, true)
                    .childOption(ChannelOption.SO_SNDBUF, serverConfig.getSendBuffSize())
                    .childOption(ChannelOption.SO_RCVBUF, serverConfig.getReceiveBuffSize())
                    .childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
                    .childHandler(channelInitializer);
            logger.info("----开始启动Netty服务,port:{}", port);
            channelFuture = bootstrap.bind(port);
            channelFuture.sync();
            logger.info("----游戏服务器启动成功,port:{}---", port);
            channelFuture.channel().closeFuture().sync();
        } catch (Exception e) {
            logger.error("服务器启动失败,自动退出", e);
            System.exit(0);
        }
    }

数据加密密钥同步(连接握手)

在默认情况下,客户端与服务器的网络通信是明文的,发送的网络数据很容易被截取,然后分析出网络能信的协议格式,这样是不安全的,所以一般会考虑请求发送的消息加密处理或者将部分比较重要的通信加密处理。加密的方式采用非对称加密与对称加密相结合的方式。

  • 非对称加密:非对称加密算法需要两个密钥:公开密钥(publickey)和私有密钥(privatekey)。公开密钥与私有密钥是一对,如果用公开密钥对数据进行加密,只有用对应的私有密钥才能解密;如果用私有密钥对数据进行加密,那么只有用对应的公开密钥才能解密,常用的是Rsa,SM2算法,
  • 对称加密:需要对加密和解密使用相同密钥的加密算法。由于其速度快,对称性加密通常在消息发送方需要加密大量数据时使用,常用的是aes算法。

非对称加密算法的特点是有两个公钥,只需要公开公钥即可,公钥加密的数据只有私钥才能解密,所以在私钥不泄漏的情况下,公钥可以给任何人,但是加解密性能不高,有的算法对加密的内容大小也有限制。
对称算法是加密和解密都使用同一个密钥,如果密钥泄漏了,任何人都可以加解密,而在网络传输中,如果直接使用对称加密,密钥又不得不告诉对方,所以不太安全。
这个时候,聪明的开发者想到了一种两者结合的方法,当客户端连接成功服务端之后,来一次密钥的同步,也叫握手。参考https的握手实现方式,这里做了一下简化
一,客户端请求服务器端,获取服务器端的非对称加密的公钥。
二,客户端随机生成一个对称加密的密钥
三,客户端将对称密钥使用服务器的非对称公钥加密,封装为数字信封,发送给服务器
四,服务器收到上报的数字信封之后,使用非对称的私钥解密,得到对称加密的密钥
五,在后面的客户端与服务器的通信中,双方都使用对称加密对数据加解密。
这样做的好处就是即保证了密钥传输的安全性,又兼顾了性能。
在单服游戏服务框架中,这个也有实现,见 HandShakeHandler类
image.png

连接认证

因为在正式环境下,游戏服务器的ip地址和端口是对外开放的,通过简单的技术手段,就可以得到ip和端口,如果有恶意者利用ip和端口创建一批连接,比如一个for循环创建一个千连接,虽然没有与服务器通信,但是连接是可以创建功的,就会占用服务器的连接资源,减少正常用户的连接,所以对创建的连接需要认证,如果认证失败,服务器就主动断开连接。

我在单服游戏服务框架中是这样实现的,当netty的channel完成激活时,就启动一个延时任务,几秒钟之后执行,检测连接是否认证成功,如果没有认证成功,就断开连接,见类:GameRequestDispatcherHandler

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        String channelId = GameServerUtil.getChannelId(ctx.channel());
        logger.info("连接成功,channelId: {}", channelId);
        // 添加一个延时任务,过一段时间之后,判断是否认证成功,
        // 如果认证成功了,gameChannel一定不会为空
        ctx.channel().eventLoop().schedule(() -> {
            if (gameChannel == null) {
                gameChannel = gameChannelService.getGameChannel(ctx.channel());
                if (gameChannel == null) {
                    logger.warn("连接未认证成功,主动断开连接,channelId:{}", channelId);
                    ctx.close();
                }
            }
        }, gameFrameworkConfig.getConfirmTimeout(), TimeUnit.SECONDS);
    }

连接认证的操作是在连接握手成功之后,第一时间发送认证消息,这里的认证方式是检验token的合法性,在登陆成功之后会得到这个token,登陆是在中心服上面完成的。登陆注册服务与游戏服务分离后面再单独详细说。认证代码见RoleHandler类userConfirm方法


    @GameMapping(UserConfirmRequest.class)
    public void userConfirm(GameChannelContext<GamePlayer> ctx, UserConfirmRequest request) {
        String userId = request.getUserId();
        RequestParamCheckUtil.checkUserId(userId);
        userService.checkToken(userId, request.getToken());
        logger.debug("用户认证成功,userId: {},token:{}", userId, request.getToken());
        GameChannelService<GamePlayer> gameChannelService = gameServerSystemService.getGameChannelService();
        GameChannel<GamePlayer> gameChannel = gameChannelService.getGameChannel(userId).orElse(null);
        GamePlayer gamePlayer = null;
        if (gameChannel != null) {
            // 如果不为空,使用缓存的角色信息
            gamePlayer = gameChannel.getPlayer();
        }
        if (gamePlayer == null) {
            gamePlayer = new GamePlayer(userId);
        }
        // 认证成功之后,绑定netty的channel和角色数据
        gameChannelService.bindChannel(ctx.getCtx().channel(), gamePlayer);
        UserConfirmResponse response = new UserConfirmResponse();
        ctx.writeAndFlush(response);
    }

连接空闲检测与关闭

由于公共网络的复杂性,当客户端与服务器建立的长连接断开之后,服务器有可能不会立刻感知到连接已断开,Netty创建的连接channel还会一直存活,所以要检测当前channel是否在一定时间内没有消息接收或发送,如果是那么就说明当前channel是空闲状态了。当客户端连接数比较多的时候,空间的channel也会比较多,占用内存就会多,为了节省服务器资源 ,当channel处于空闲状态时,需要把当前连接主动断开,释放服务器资源。

netty本身提供了一个检测空闲连接的Handler,IdleStateHandler类,只需要配置一下就可以了,在GameChannelInitializer类中的initChannel中配置:
image.png
IdleStateHandler构造方法三个参数:

  • readerIdleTimeSeconds 如果不为0,在readerIdleTimeSeconds内没有收到消息,就会触发一个 IdleStateEvent事件,状态是IdleState.READER_IDLE
  • writerIdleTimeSeconds 如果不为0,在writerIdleTimeSeconds内没有给客户端发送消息,就触发一个IdleStateEvent事件,状态是IdleState.WRITER_IDLE
  • allIdleTimeSeconds 如果不为0,在allIdleTimeSeconds内,没有收到消息或没有给客户端发送消息,就触发一个IdleStateEvent事件,状态是IdleState.ALL_IDLE

在别的Handler中可以接收这个事件,并处理相关的业务,例如在GameRequestDispatcherHandler类中,如果收到空闲事件,就关闭连接:

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        //接收channel内部的一些事件
        if (evt instanceof IdleStateEvent) {
            logger.debug("channel idle and to be close,{}", ctx.channel().id().asShortText());
            ctx.channel().close();
        }
    }

连接心跳检测与断线重连

游戏玩家在进入到游戏之后,在一定时间内,并不会时刻在操作游戏,可能会思考,也可能会打开游戏去做别的事件了,所以并不会一直有消息发送给服务器,而服务器有连接空闲检测,检测到一定时间内没有消息交互就会关闭连接,这样就会导致客户端频繁的重新创建连接,就和http短连接类似了,用户体验比较差,所以需要一个连接心跳机制,即在没有业务消息发送给服务器一段时间之后,由客户端系统主动给服务器发送一条消息,表示当前连接在使用中。

心跳检测的另一个作用是,在一定时间内循环的检测当前连接是否处于可使用状态,如果不是,就断开旧的连接,创建新的连接,并处理连接认证,保持网络的畅通。所以心跳的功能主要是由客户端实现,服务器不需要对心跳请求做任何业务处理,直接返回成功即可。

如果需要客户端源码,可以在这里下载:https://download.csdn.net/download/wgslucky/79446700

保证消息顺序发送

对于同一个连接来说,一个用户的操作都是有顺序性的,所以向服务器发送的消息也应该是顺序性的,服务器处理的消息也是顺序性的,这样可以保证对用户的数据处理也是顺序性的,可以实现无锁化处理用户数据机制。要实现消息的顺序处理,需要客户端配合处理

  1. 用户操作需要向服务器发送请求时,不是用户点击之后立刻发送,而是先缓存在一个消息队列中。
  2. 客户端每帧Update的时候,检测消息队列中是否有数据发送。
  3. 如果有数据发送,则发送队列中第一个入队的数据
  4. 客户端收到上一个发送的数据返回之后,才能发送下一个数据
  5. 如果一段时间内,发送的数据没有收到服务器的响应,则重新发送
  6. 服务器在收到用户的消息之后,将消息放到一个用户id映射的单线程池中顺序处理

如果需要客户端源码,可以在这里下载:https://download.csdn.net/download/wgslucky/79446700

请求频率限制及旧消息过滤

有这样一个场景,对于一个正常的游戏玩家客户端,一秒内一般不可能操作50次,即向服务器发送50次请求,如果出现了一秒钟,一个连接发送了50次请求,要么是客户端业务写的有问题,要么是客户端被使用外挂进行了不正常的操作,所以要对连接请求的频率进行一下限制,保护服务器稳定。

另外一个场景是,同一个消息被多次发送给服务器,比如使用道具,请求的requestId是一样的,这说明此消息可能被消息转发器重复转发了。对于这样重复的消息,应该直接丢掉不做处理。因为客户端与服务器已经有约定,正常情况下,客户端不会向服务器发送已处理过的旧消息。

在单服游戏框架中,使用的是google的RateLimiter类,在RequestFilterHandler中实现的:

public class RequestFilterHandler extends ChannelInboundHandlerAdapter {
    private RateLimiter rateLimiter;
    private static Logger logger = LoggerFactory.getLogger(RequestFilterHandler.class);
    private long nowRequestId;

    public RequestFilterHandler(int qps, ApplicationContext context) {
        rateLimiter = RateLimiter.create(qps);
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        boolean flag = rateLimiter.tryAcquire();
        if (flag) {
            GameMessagePackage gameMessagePackage = (GameMessagePackage) msg;
            if (nowRequestId > 0) {
                long receiveRequestId = gameMessagePackage.getHeader().getRequestId();
                if (receiveRequestId < nowRequestId) {
                    logger.debug("收到旧消息的请求,忽略消息,nowRequestId:{},receiveRequestId:{}", nowRequestId, receiveRequestId);
                    return;
                }
                this.nowRequestId = receiveRequestId;
            }
            ctx.fireChannelRead(msg);
        } else {
            logger.error("请求太频繁,不处理,直接返回,channelId:{}", GameServerUtil.getChannelId(ctx.channel()));
        }
    }
}

连接与用户数据绑定

在服务器处理业务时,我们需要有这两个功能:

  1. 根据客户端与服务器的连接对象(netty的channel)可以找到此连接的用户数据
  2. 根据用户数据,比如用户Id,可以找到这个用户客户端与服务器的连接对象

第一条很好理解,当客户端通过连接发送用户的数据时,我们知道此连接对应的用户数据是什么,并对此请求处理相应的数据。
第二条一般是用于不同用户交互时。比如在聊天中,A用户给B用户发送消息,但是A只知道B的用户id,向B发送消息时,就需要知道B用户的连接channel,然后才可以给B发送消息。
为了实现这两个功能,在连接认证成功之后,需要将用户id与连接进行绑定,在单服服务框架中的GameChannelService中缓存了用户Id和连接GameChannel的映射关系:

 /**
     * 缓存某个用户对应的GameChannel,key是userId
     */
    private LoadingCache<String, GameChannel<P>> gameChannelMap;

    private void initGameChannelMap(GameChannelConfig gameChannelConfig) {
        gameChannelMap = CacheBuilder.newBuilder().expireAfterAccess(Duration.ofSeconds(gameChannelConfig.getPlayerIdleExpireSecond()))
                .maximumSize(gameChannelConfig.getMaxCachePlayerSize()).removalListener((RemovalListener<String, GameChannel<P>>) removalNotification -> {
                    GameChannel<P> gameChannel = removalNotification.getValue();
                    if (gameChannel != null) {
                        // 必须清理缓存,防止ByteBuf内存泄露
                        gameChannel.getGameMessageResponseManager().clearCache();
                        gameChannel.removeGameChannel();
                        if (gameChannel.getPlayer() != null) {
                            logger.warn("从缓存中移除GameChannel,userId:{}", gameChannel.getPlayer().getUserId());
                        }
                    }

                }).build(new CacheLoader<String, GameChannel<P>>() {
                    @Override
                    public GameChannel<P> load(String userId) throws Exception {
                        EventExecutor executor = selectExecutor(userId);
                        GameChannel<P> instance = new GameChannel<>(executor);
                        return instance;
                    }
                });
    }
   //  绑定连接与用户的数据
   public GameChannel<P> bindChannel(Channel channel, P player) {
        String userId = player.getUserId();
        GameChannel<P> gameChannel = getGameChannel(userId).get();
        gameChannel.registerPlayer(player, channel);
        Attribute<String> userAttr = channel.attr(GAME_CHANNEL_USER_ID_ATTRIBUTE_KEY);
        userAttr.set(userId);
        return gameChannel;
    }

当连接认证成功和角色数据加载成功之后,就会调用上面的bindChannel方法 ,将连接对象和用户数据进行绑定。
这里的缓存使用了google的** LoadingCache,**它自带了LRU算法,当缓存的数据数量达到上限或某些缓存的数据空闲时间达到最大值时,会从内存中清理掉这些数据,防止缓存堆积,导致内存泄漏。

跨设备登陆处理

这种情景一般指的是,同一个用户的账号在不同的客户端上面同时登陆,或在某种极端的情况下,同一个账号创建了两个连接。根据netty的channel机制,多个连接的channel在处理数据时,可能会在不同的线程中执行,所以为保证用户的数据安全,服务器只能允许同一个账号创建一个连接,新创建的连接需要替换掉旧的连接。
在单服游戏框架中,当向GameChannel注册netty的channel时,会检测是否有旧的连接:

    public void registerPlayer(P player, Channel channel) {
        // 放在用户自己的线程池中执行,防止创建n多个连接时,最新创建的连接被旧的连接替换掉
        this.executeTask(() -> {
            if (this.nettyChannel != null && this.nettyChannel != channel) {
                // 说明是有新的连接创建了,需要关闭旧的连接
                // TODO 发送跨设备连接的消息,通知客户端当前连接被顶掉了
                this.nettyChannel.close();
            }
            this.nettyChannel = channel;
            this.player = player;
            this.gameMessageResponseManager.bindChannel(channel);
        });
    }

常见的连接管理需要处理的问题大致有这些了,根据不同的游戏类型,可能还会有其它的连接相关需要处理的问题,这个后面可以再补充,也欢迎同行留言补充。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

wgslucky

各位都是我的衣食父母

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

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

打赏作者

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

抵扣说明:

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

余额充值