对于网络游戏来说,网络连接的开发与维护是非常重要的,这里主要说明一下最常用的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类

连接认证

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

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

    @Overridepublic 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中配置:

IdleStateHandler构造方法三个参数:

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

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

    @Overridepublic 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);}@Overridepublic 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>>() {@Overridepublic 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);});}

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

游戏网络Socket长连接管理相关推荐

  1. 网络编程懒人入门(八):手把手教你写基于TCP的Socket长连接

    转自即时通讯网:http://www.52im.net/ 本文原作者:"水晶虾饺",原文由"玉刚说"写作平台提供写作赞助,原文版权归"玉刚说" ...

  2. 【Socket】关于socket长连接的心跳包

    TCP的socket本身就是长连接的,那么为什么还要心跳包呢? 在smack里有个30s发送一个空消息的线程,同样关于心跳包(keepalive) 据网络搜索到的资料解释如下 内网机器如果不主动向外发 ...

  3. 关于socket长连接的心跳包

    出于最近对im研究的兴趣,看到smack里有个30s发送一个空消息的线程,了解了下关于心跳包,keepalive的知识. TCP的socket本身就是长连接的,那么为什么还要心跳包呢? 搜索到的资料解 ...

  4. android socket 长连接_php socket如何实现长连接

    长连接是什么? 朋友们应该都见过很多在线聊天工具和网页在线聊天的工具.学校内有一种熟悉的功能,如果有人回复你了,网站会马上出现提示,此时你并没有刷新页面:Gmail也有此功能,如果邮箱里收到了新的邮件 ...

  5. socket 获取回传信息_基于netty框架的socket长连接负载均衡解决方案 oswl

    前言 物联网如今是一个大的趋势,但是概念还比较新颖.大家对这一块的技术积累也比较匮乏,借此前段时间摩拜单车出现了大规模瘫痪的现象.我们今天来讨论一下物联网项目的开发方式. 关于tcp/ip 相关的知识 ...

  6. android端 socket长连接 架构

    看过包建强的<App研发录>之后对其中的基础Activity类封装感到惊讶,一直想找一种方式去解决关于app中使用socket长连接问题,如何实现简易的封装来达到主活动中涉及socket相 ...

  7. 基于netty框架的socket长连接负载均衡解决方案

    socket通讯的单机瓶颈 物联网的项目socket使用方式有两种: 短连接的socket请求 维持socket长连接的请求 对于socket短链接来说就好比是http请求,请求服务器,服务器返回数据 ...

  8. socket 长链接linux,手把手教你写 Socket 长连接

    原标题:手把手教你写 Socket 长连接 8点43分打卡 就是真爱 本文转载自公众号 玉刚说,由玉刚说写作平台[1]提供写作赞助 原作者:水晶虾饺[2] 版权声明:本文版权归微信公众号玉刚说所有,未 ...

  9. 安卓中socket长连接和websocket长连接的实现

    现在一款成熟的app一般都会具备长连接推送功能,那么我们要想项目具备长连接的功能现在又两种选择的方案,一种基于原生tcp协议的socket长连接,另外一种基于ws协议的websocket的长连接,今天 ...

最新文章

  1. div 自动换行_js自动打字--autotypejs
  2. [bzoj 2456]mode
  3. Java 扫描并加载包路径下class文件
  4. 利用一维数组求菲波那契数列前40项的和并输出结果。_[W2D2]斐波那契数列
  5. Java方法及构造方法
  6. uboot1.1.6 start.s分析
  7. 用Python DBUtils安全连接mssql
  8. 河南网通帐号在线转换工具
  9. uni-app与java的交互且使用小米球Ngrok连接到手机微信小程序
  10. 用了这些软件,写代码有了飞一般的速度
  11. arm板上简单运行main.cpp
  12. 我的世界刷猪人塔java版_我的世界猪人塔怎么做 5款猪人塔详解教程
  13. VUE前端应用部署页面访问404问题
  14. 2022年浙江省中职组“网络空间安全”编码信息获取
  15. MS-RTOS --- 技术特点及其检测标准
  16. DFS and BFS
  17. 每日时间管理【战隼】
  18. 礼堂椅影院椅安装步骤方法
  19. java毕业设计springboot框架 java餐厅预约管理系统毕业设计开题报告功能参考
  20. 水表读数图解_家用水表怎么看,家用水表的读数方法图解

热门文章

  1. LVS部署准备工作-DRBD的部署
  2. oracle 12c基本管理,oracle 基本管理
  3. 清除win+r的记录
  4. 无线接入控制服务器(ac),无线AP控制器是什么?无线AP与无线AC的区别
  5. 根号下的X平方加一C语言,根号下x平方加一分之一怎样积分
  6. 英伟达车载AHD高清模拟相机方案介绍
  7. 4.17 使用阴影/高光命令解决图像曝光不足问题 [原创Ps教程]
  8. Python time 模块time 函数的时间单位
  9. Python模块——os模块详解
  10. android友盟埋点,React Native 的友盟统计--打点/埋点