go游戏服务器网络库antnet详解

    xiaoxiao2022-07-02  107

    全局消息设计

    1.1 原理

    以往单线程全局消息的发送通过for循环的方式遍历每个msgque,给每个msgque发送一份数据拷贝。下面看如何利用go中channel机制设计全局消息,而不是轮询。

    c chan struct{} ,通常struct{}类型channel的用法是使用同步,struct{}当做一个普通的数据类型,一般不需要往channel里面写数据。只有读等待,而读等待会在channel被关闭的时候返回。所以在close(c)的时候, 在所有等待接收c消息的groutine都会接收到消息。

    所以为每个msgque写分配一个goroutine,接收到c的信息执行一次广播,并行实现。

    在antnet设计中,利用goroutine的上述原理设计广播消息。所有msgque都会接收gmsg中的chan消息通知,读取gmsg中的msg以及执行处理msg的条件函数func()。func()可实现组播的功能或者其他限制条件。

    (图1.1 全局消息结构)

     

    gmsg初始化,当前gmsgId对应的全局消息初始化好chan

    (图1.2 全局消息初始化)

     

    新建msgque的时候把当前全局gmsgId赋值给msgque的gmsgId,用于接收gmsg消息

    (图1.3 消息队列初始化全局消息)

     

    msgque通过gmsgId获取gmsg队列中的全局消息gmsg,接收消息,计算前置条件处理全局消息。

    (图1.4 消息队列处理全局消息)

     

    发送全局消息,获得当前的全局消息,往gmsg中写入要发送的全局消息msg,以及限制条件func,close(gmsg.c)关闭chan,在所有的msgque中会接收到chan通知,达成发送全局消息的目的。

    (图1.5 发送全局消息)

     

     

    Goroutine设计

    主要介绍在系统中goroutine的设计,同步问题,错误处理,开机启动,停机处理和检查等。

    2.1  go中的异常处理

    (图2.1 异常处理)

     

    函数try用于goroutine中使用,在fun()执行异常,defer中的recover()会捕获到panic。handle函数是用户自定义的异常处理函数,如果有handle会执行handle,没有handle会把当前panic的堆栈信息打印出来。程序从宕机点退出当前正在执行的函数后继续执行。

    2.2  go执行入口

    (图2.2  goroutine入口)

             waitAll是waitGoup,是goroutine的互斥同步的解决方案设计,增加一个goroutine会加一,一个goroutine执行完会减一。为要执行的goroutine分配id并做goroutine的计数。在debug模式下,会输出go执行的起点位置和当前goroutine统计数据。go关键字开始执行一个新的goroutine,调用Try()函数执行,避免panic导致的程序宕机。函数func()执行完,waitall.Done(),goroutine的计数减一,在debug模式下,会输出go执行的起点位置和当前goroutine统计数据。

    2.3  go同步问题以及程序结束处理

     

    (图2.3  go同步)

    Stop是进程停止的标记,一旦stop标记为1,所有的goroutine不再工作,调用waitAll.Done(),当waitAll.Wait()成功,所有goroutine释放资源,做后续收尾工作,如redis数据存储,日志系统停止以及其他自定义程序停机处理工作。

    (图2.4  服务器进程收尾工作)

    stopChanForGo和stopChanForSys的设计和全局消息的设计有异曲同工之妙,当goroutine收到stopChanForGo关闭信号后结束当前的工作。在msgque中不再发送消息,会把收到的消息处理完。

            

    (图2.5  tcp消息队列接到stopChanForGo不再处理发送消息)

     

     

     

    消息队列MsgQue设计

    消息队列定义消息接口和消息队列基类如下:

    (图3.1 消息队列接口)

    (图3.2 消息队列基类)

     

    3.1 代理

    服务器自行设计代理,不采取nginx做代理。通过代理连接的客户端建立的消息队列,增加realRemoteAddr字段保存实际连接远端地址。

    (图3.3 消息队列代理)

    3.2 广播和组播

    (图3.4 消息队列分组)

    根据第一节全局消息队列的讲解,广播通过限制条件函数实现组播功能。在消息队列中通过增加分组编号给每个消息队列进行分组,发送组播消息的时候,根据是否在当前组中实现分组发送组播消息的功能。

    3.3 消息发送和接收

    每个msgque包含2个goroutine,读和写,逻辑数据处理完成后把数据丢到写通道cwite中,

    写goroutine把cwite中的数据通过连接发送出去。

    读goroutime,收到数据做数据的解析和解析后的消息处理。

    (图3.5 发送消息)

    (图3.6 消息队列读写goroutine实现)

     

    3.3.1处理读和写重点讲回调消息处理实现RPC

    发送回调消息,传入接收回调消息的通道,在消息队列接收到消息的时候根据msg生成的tag查询回调消息队列,把消息写入到接收通道c中,由外层handle处理c中的数据。

    常用于RPC中,阻塞或者非阻塞的等待回调消息,处理回调消息,如果要关闭回调,只要往c中写入nil,外层callback(nil)直接return即可。如在关闭消息队列的时候把nil写入到回调通道中。

    (图3.7 发送回调消息)

    (图3.8 设置接收回调的消息通道)

    (图3.9 消息队列接收消息尝试处理回调)

    3.4 消息队列实现

    通过组合方式antnet实现了tcp消息队列,udp消息队列和websocket消息队列。如tcp消息队列设计:

            

    (图3.9  TCP消息队列)

     

     

    消息解析器

    消息解析器作用是接收消息队列中的原始数据,通过指定的解析方式解析得到正确的数据结构。

    消息解析器接口,对外数据序列化和反序列化提供的数据接口。

    (图4.1  消息解析器接口)

    解析器接口,提供数据序列化和反序列化方法的接口。其中最重要的一步是需要注册消息解析器,根据消息ID或者类型获得对应的数据结构。

    (图4.2  解析器接口)

    (图4.3  解析器注册消息)

     

    解析器工厂,通过指定的消息类型获取对应的解析器。

    (图4.4  解析器工厂)

    Get函数实现如下,antnet支持4中常用格式解析,也支持自定义类型解析方式,如自定义二进制消息等。根据不同游戏需要可以自行设计。

    (图4.5  解析器工厂获取解析器)

     

    在TCP消息队列中,启动tcp服务器,建立tcp消息队列,监听连接,接收所有的连接会传入解析器工厂,解析器根据解析器工厂获取对应消息解析器类型,在同一个tcp服务中只处理一种消息类型。在消息队列监听到数据,解析器解析原始数据根据注册的消息类型,解析出正确的数据结构。

    (图4.6  解析器工厂生产解析器)

    消息处理器

    消息通过指定的消息解析器得到正确的数据结构就能够识别和处理了。同消息解析器,处理器一样,根据消息的ID或者类型注册消息处理函数。如图5.1所示

    (图5.1  处理器接口以及默认消息处理器数据类型)

     

    (图5.2  消息队列处理消息)

    消息队列调用r.parse.ParseC2S( msg)生成mp消息解析器(正确的数据结构)之后调用消息处理函数f(msgque,msg)处理消息。

    消息解析器和处理器分开最大的优势也是antnet的魅力所在,支持不同的消息解析和处理,使得框架更加的通用。

     

     

     

    Redis

    在antnet网络库用的go-redis,最大的特点利用了redis支持lua脚本,主要记录了对eval指令的处理,能够把预先生成的lua脚本上传到redis得到hash,以后使用evalsha命令进行调用,每一个调用都是一个事务。再者利用了redis发布订阅的处理,订阅了对应的消息,在一个redis发布信息的时候其他订阅者都能收到消息。在union服务器中,每个服务器连接了所有的redis,一个服务器执行redis脚本发布消息,让其他服务器收到通知达到同步的目的。这是在model层实现同步的。这个是union对等分布式实现的原理。

    (图6.1  reidis类型定义)

    (图6.2  reidis脚本管理器执行脚本程序使用)

    (图6.3  执行redis脚本)

     

    (图6.4  redis管理器)

     

    (图6.5  redis订阅消息)

    Redis消息订阅和发布都是针对于服务器,可以用于服务器内部通信,也是实现对等分布式的方式,如微信接口提供的API  accessToken只能由一台服务器请求获得,下发到其他的需要的服务器中。

     

     

     

    日志系统

    日志系统分为控制台日志和文件日志两个方式,以及日志分层,提供常规的日志打印功能,记录panic信息,go起始位置的堆栈日志等。

    其他

    提供常用的数学函数;时间函数;字符串函数;封装fmt打印;Zlib,Gzip加密解密;csv格式和json格式文件读取,常用文件操作;提供网络操作的函数,httpGet,httpPost,httpUpload ,获取本机内外网地址,获取本机地址,在union服务器中用到,运维不再配置服务器ip和端口号,由程序自动加载本机ip和查询可用端口启动服务器等。

     

     

    设计目的

    本着作者的设计antnet网络框架简单,易用,稳定和高性能的目的。引入作者原语。

    稳定性是压倒一切的前提,宁愿增加硬件成本也一定要稳定。

    所以在antnet里面会有比较多的try使用,在实际的运营中,我们的服务器从未崩溃过,因为antnet产生的每个goroutine都是安全的,只出现了由于程序逻辑写错了导致的死循环,因为antnet结束时要求所有的goroutine都能正常结束。所以antnet中会有AddStopCheck之类的函数来进行检查,通常只要测试力度够,上线前就能检查出这种逻辑错误。

    之前我也一味的追求单机性能,但单机性能总有极限,我们要的,是一个稳定高效的系统,而不是一个性能爆炸但不稳定的进程。

    高性能在第二并不意味antnet性能不高,antnet针对网络库特点有很多优化。

    之前设计c++的网络库时,代码量比go大一些,主要多在epoll和内存的处理,可以说这块决定了网络库的性能。

    go对这些已经封装得很好了,但也有很多需要我们关心的地方,比如组播广播之类,antnet有专门的优化,对UDP的处理也是。

    上面提到了try的使用,antnet里面的try当然是靠recover实现,但antnet并不是滥用try,基本上都是在for循环之外使用,比如收包循环之类。

    简单,简单,再简单

    go已经够简单了,网络库代码也不多,对我来说用不着分文件夹,什么codec,peer,完全用不着,整个解析模块也不过一两百行代码,为什么不统一到一个包里面。也许你会说这不合乎软件工程,这点我认可,分层确实能解决计算机系统的所有问题,想想五层网络模型,编译器的分层,但那是针对很复杂的系统,网络库实在太小了。不为规则所累灵活运用才是我们该做的,想想李云龙。

    为了追求这种简单,antnet甚至直接复制一部分常见功能在里面,是的,比如最常见的Print,antnet直接封装一下,就是无条件的调用fmt包。

    同样,我们经常会使用字符串操作,antnet直接统一复制strings包的函数,但为了更加简单,对函数名进行一些操作,比如最常见的字符串查找,忘记函数名没关系,antnet直接有FindStr和StrFind函数。

    你直接引入antnet就不用引入fmt了,避免了Print带来的麻烦。

    antnet框架由magiclvzs完成实现 https://github.com/magiclvzs/antnet

    本文目的为广大游戏行业从业者提供便利

    最新回复(0)