KBEngine 是国内开源的游戏服务器引擎,据说参考了 Bigworld 的架构;网上能找到的开源游戏服务器引擎很少,网易的 Pomelo 是用 Node.js 来实现的,现在还是觉得 C/C++ 更熟悉些,就先从这个开始理解游戏服务器开发吧。

有用链接

需要理清的问题

带着问题来阅读源码,比毫无目的的阅读效果更好,下面列出我想要通过源码知道的问题

  • 游戏服务器是怎么运行的?
  • 服务器与客户端之间怎么交互?
  • 多个用户之间怎么看到彼此,即 AOI 是怎么实现的?
  • 服务器怎么实现负载均衡?
  • 服务器的怎么通过心跳来维护彼此信息?

后续将分几篇文章来解释这些问题。

后续文章需要先对 KBEngine 有一定理解,大体浏览过前面提到的有用链接,至少要清楚下图中的各个组件的功用。(图片来自官网截图,具体请看《KBEngine 服务器端架构》)

官方推荐在 Windows 下编译运行,我也就不在 Linux 下折腾了。

服务器代码编译

在虚拟机里装了个 windows,再安装 VS 2013 。(2016.02.03,官方说支持 VS 2015,但我测试下来 VS 2015 会报 mysqlclient64.lib 无法连接,因为 mysqlclient64.lib 是 VS 2013 生成的)。

官方的说明,代码 git clone 到本地之后,直接 Rebuild Solution 即可;build 成功会生成一批 exe 文件。

客户端 和 assets 文件

KBEngine 是通过资产文件在配置各个不同的项目,我这里用的是 kbengine_cocos2d_js_demo,使用 js_demo 无需再另外安装一堆客户端程序,具体配置使用可以看官方的 demo 搭建文档

 

这里要注意的是,直接 git clone kbengine_cocos2d_js_demo 不能把代码一次性都捞下来,需要按说明,进入到下载下来的目录,再执行以下命令,才会把 kbengine_demos_assets 目录下载下来。因为这个目录是一个链接。

 

git submodule update --init --remote

安装 mysql

KBEngine 需要使用 mysql 来进行持久化,这个按说明安装。

运行服务器

 

把 kbengine_demos_assets 拷到服务器代码目录下,与原来的 assets 目录平行。

运行 kbengine_demos_assets 目录下的 start_server.bat,这样直接运行,不需要设置环境变量。因为 start_server.bat 会默认设置当前目录为 KBE_ROOT。

 

cd ..
set KBE_ROOT=%cd%
set KBE_RES_PATH=%KBE_ROOT%/kbe/res/;%curpath%/;%curpath%/scripts/;%curpath%/res/
set KBE_BIN_PATH=%KBE_ROOT%/kbe/bin/server/

运行客户端

客户端是 cocos2d_js 的程序,就运行 start_http_service.bat 即可(需要先安装 python)。

登录

服务器端和客户端都运行正常后,就可以通过浏览器来登录游戏了。

调试

先把 server 端的各个 exe 程序跑起来;在 VS2013 里,DEBUG -> Attach to Process...,选择具体的进程,如 loginapp.exe 或者 baseapp.exe  都可以。然后就可以设置断点进行调试了。

 

目录 kbengine_cocos2d_js_demo/cocos2d-js-client 就是一个典型的 Cocos2d-JS 项目,项目结构可以看 Cocos 官方说明文档《Cocos2d-JS项目结构介绍》,我们现在只需要知道以下事实即可。

  • 相关 js 文件通过 project.json 引入
  • main.js 是整个项目的逻辑入口

main.js

如上图,主要的逻辑有两块

  • 57~58行,即设置服务器的 ip、port
  • 67行,是 Cocos 的启动 Scene

由 project.json 里面可以看到,StartScene 所在的具体路径应该在 src/cc_scripts/StartScene.js

StartScene.js

StartScene 的展现逻辑我们不去管,直接看「登录」按钮点击下去后的处理。

这里通过 fire 一个 「login」事件,把 username 和 password 发送到 plugins/kbengine_js_plugins/kbengine.js 去处理。

kbengine.js

可以看到,2377 行注册了一个事件,即 kbengine.js 里的 login 函数,会响应 「login」 事件。 

由上可知,客户端在启动的时候,除了界面展示,最重要的就是在 installEvents 函数里,通过 KBEngine.Event.register 注册各个事件响应函数,即 kbengine.js 这个插件与客户端逻辑代码的交互是通过事件来完成的。

具体的 register/fire 代码这里不再贴出,有兴趣的可以自己去看。基本逻辑如下

  • register 的时候,在一个 list 里存放 <事件名,回调函数>的键值对。
  • fire 的时候,遍历找到这个事件名对应的回调函数,填入参数来 apply 。

「登录」前需要加载通信协议,加载过程需要在服务器与客户端之间进行函数交互调用。

通过在服务器端和客户端分别解析 res/server/messages_fixed.xml 中声明的协议来完成交互。 其实就是分别构建 <messageId,回调函数> 键值对。

那么,客户端是怎么知道如何解析这个协议的呢?

kbengine.js / login

login 函数中,3086行参数为 true 来调用 login_loginapp,所以逻辑会进入 3093~3096 行,即使用 ws 协议访问在 main.js 里配置的 ip:port;在 connect 成功后的回调函数是 kbengins.js 中的 onOpenLoginapp_login。

ws 协议介绍,这里给出部分截图,便于理解。

 

kbengine.js / onOpenLoginapp_login

opOpenLoginapp_login 主要有几个逻辑:

  • 2600 行,设置 currentserver = 'loginapp'
  • 2601 行,设置 currentstate = 'login'
  • 2605~2610 行,访问服务器端的 loginapp::importClientMessages 函数,回调函数是 kbengins.js 中的 Client_onImportClientMessages 函数

为什么 2605~2607 行能访问到 loginapp 中的 importClientMessages 函数?

因为 2066 行, KBEngine.messages.Loginapp_importClientMessages 对应的 messageId 在 服务器端 loginapp 中的处理函数是 importClientMessage 函数。这个配对需要在客户端和服务器端都进行声明。

客户端在 kbengine.js 中声明了 Loginapp_importClientMessages 对应的 messageId = 5. 如下图所示:

服务器端在 res/server/messages_fixed.xml 中声明了 messageId = 5 对应的处理函数为 Loginapp 中 importClientMessage 函数。如下图所示:

loginapp.cpp / importClientMessages

服务器端的 loginapp.cpp 中的 importClientMessages 函数,向客户端发送协议信息。

 

有几个主要逻辑

  • 5~24 行,取所有 Client 中标为 Exposed 的协议
  • 26~47 行,取所有 Loginapp 中标为 Exposed 的协议
  • 49 行,设置发送回客户端的 messageId,这个与客户端的 messageId 要一致,下文有进一步说明。
  • 54~78 行,数据塞到流里
  • 81 行,发送到客户端
  • 17 行,回调函数名称,如 Loginapp::login 改为 Loginapp_login;这个很重要,这里定义了消息名称,客户端会拿这个来回调 loginapp 的函数

kbengine.js / Client_onImportClientMessages

由于前文设置了 onmessage = Client_onImportClientMessages,根据 ws 协议,服务器返回信息时,就会调用到这个函数。

Client_onImportClientMessages 有几个主要逻辑:

  • 2972 行,先判断读取到的 messageId 是否为 KBEngine.messages.onImportClientMessages,可见服务器也是以 messageId 来标识客户端的处理函数的。这里确实在服务器端和客户端都做了配对。

    服务器端在 res/server/messages_fixed.xml 中声明了 messageId = 518 对应的处理函数为 客户端 中 importClientMessage 函数。如下图所示:

    客户端在 kbengine.js 中声明了 onImportClientMessages 对应的 messageId = 518. 如下图所示:
    服务器端是如何访问客户端的,将在讲解服务器端代码时说明。

  • 2983~3022行,代码先读取 msgid(2983行),然后看 msgname 是否带有 Client_(2996行)
    如果有,就是 Client 回调函数(即 kbengine.js 中的以 Client_ 开头的函数,loginapp 服务器往客户端发送协议时,就会把 messages_fixed.xml 中的声明 Client::xxx 改成 Client_xxx 作为函数名),就把 msgid -> Client_xxx 添加到 KBEngine.clientmessage 中;
    否则,就是服务器的函数,因为服务器分为 baseapp、loginapp 等,就要分别按 currserver 来存放,前面 (2600 行)设置了 currentserver ='loginapp',所以这里取到的,除去 client 之外,就是 loginapp 中的回调函数;
    至此,在客户端中加载 client 和 loginapp 中的协议完成。
  • 3026 行, Client_onImportClientMessages 的最后,会调用 onImportClientMessageCompleted 函数。

kbengine.js / onImportClientMessageCompleted 

onImportClientMessageCompleted 有几个逻辑:

  • 2643行,设置 socket.onmessage 为 app.onmessage
  • 2644 行,hello 函数 (握手,这个不属于协议加载,将在后面细讲)
  • 还记得前面 2600、2601 行设置的吧,此时 currentserver == 'loginapp',currstate == 'login',所以会执行到 2658 行,即此时才会真正开始发送用户名、密码进行登录。

kbengine.js / onmessage

由前面可知,协议加载完成后,在 onImportClientMessageCompleted 里的 2643 行,会把 socket 的 onmessage 置为上图的 onmessage 函数。

逻辑很简单,就是取到  msgid,然后在 KBEngine.clientmessage 中取到相应的 handler,然后执行即可。由此,服务器端就可以调用到客户端的函数。

小结

抛开前面各种回调,我们来说明一下 KBEngine 的协议机制。为简化起见,这里只说明 loginapp 与 client 进行交互的协议。

    1. 在 res/server/messages_fixed.xml 中,以 <Client::xxx></Client::xxx> 的形式声明客户端将要提供的函数,以<Loginapp:xxx></Loginapp::xxx> 的形式声明 loginapp 将要提供的函数
    2. 客户端(这里是 kbengins.js)实现具体的函数
    3. loginapp 实现具体的函数
    4. loginapp 加载 message_fixed.xml 并解析,首先建立 <messageId, 3 中实现的函数>的映射关系,即此时客户端可以通过一个 messageId 调用到 loginapp 的函数;其次知道客户端有哪些函数可以被调用,这些函数的 messageId 分别是什么。
    5. 客户端没法直接加载 message_fixed.xml,只能通过 loginapp 来加载协议;
    6. 客户端在加载协议之前,不知道 loginapp 的返回协议的函数对应的 id;所以协议加载函数的 id 必须在客户端写死,也就是前面提到的 messageId = 5,这个 messageId 传到 loginapp 服务器之后,就能调到 Loginapp::importClientMessage 函数;并返回协议信息。
    7. 客户端拿到协议信息,首先建立 <messageId,2 中实现的函数>的映射关系,即此时 loginapp 服务器可以通过一个 messageId 调用到客户端的函数;其次,知道 loginapp 服务器端有哪些函数可以被调用,这些函数的 messageId 分别是什么。
    8. 至此,客户端和 loginapp 服务器端都分别建立起 <message,回调函数> 的映射关系,即协议构建成功

                     最后补充一下,服务器与客户端交互都是通过 MemoryStream 来传递数据,先传递 messageId,然后传递参数;
                     客户端/loginapp 服务器端读取网络数据时,先读取到 messageId,就知道要调用哪个函数,然后就把后面的参数取出来,进行具体调用即可。
                     客户端的这个过程,可以参看前面的 onmessage 函数。

 

协议加载完成之后,客户端就可以向服务器端发送用户名、密码进行登录

在 kbengine.js / onImportClientMessageCompleted 中,会以参数为 false 调用 login_loginapp(false) 函数。

kbengine.js / login

 

因为参数 noconnect 为 false,所以这里逻辑会走到 3101~3107 行;

这里就是往服务器 logapp 发送用户名、密码等信息,loginapp 的响应函数是 Loginapp::login 。

KBEngine.message.Loginapp_login 是如何对应到这个函数的,请看上方的 loginapp.cpp / importClientMessages 章节。

loginapp.cpp / login

服务器端 login 响应客户端请求,先通过 python 脚本验证,然后数据库验证,然后返回负载较低的baseapp 地址。

上面的代码,精简了各种验证逻辑,可以看到行号是不连续的。这里面有几个逻辑:

  • 773 行,表示要加载的 python 脚本文件在 assets/scripts/login 下面?(这个逻辑没有跟到)
  • 895~903 行,调用 assets/login/kbengine.py  脚本里的 onRequestLogin 函数。
  • 978~984 行,在 pendingLoginMgr_ 中缓存登录信息及客户端地址,因为此次登录尚未处理完,后续需要跳转到 Dbmgr 这个组件去进行处理,在 Dbmgr 处理完成之后,还会跳转回来继续处理。
  • 995~999 行,调用 Dbmgr 中的 onAccountLogin 函数验证用户信息。

login / kbengine.py / onRequestLogin

onRequestLogin 函数就只是简单的检查一下用户名、密码的长度就返回了。当然这里只是留了一个接口,可以自行修改逻辑。

dgmgr.cpp / onAccountLogin

这个函数调用了 interface 的 loginAccount 函数来实现。

interface_handler.cpp / loginAccount

可以看到,真正的数据库查询操作,是通过 DBTaskAcountLogin 来实现的。

dbtask.cpp / DBTaskAccountLogin::presentMainThread

数据库的具体查询逻辑我们先不管;1603~1617行,数据查到之后,会反过来调用 Loginapp::onLoginAccountQueryResultFromDbMgr 函数。

 

Loginapp.cpp / onLoginAccountQueryResultFromDbmgr

通过 Dbmgr 从数据库查询到数据之后,通过这个回调函数,在 Loginapp 里继续处理登录流程。

这里有几个主要逻辑:

  • 1110~1117 行,先调用 assets/login/kbengine.py 脚本里的 onLoginCallbackFromDB 函数,给脚本一个嵌入处理流程的机会 ^^
  • 1152~1176 行,在 baseapp 上注册;为了叙述流程简单明了,我们只考虑 1165~1176 行的逻辑,也就是用户第一次登录;后面我们会看 Baseappmgr::registerPendingAccountToBaseapp 函数。

login / kbengine.py / onLoginCallbackFromDB

可以看到,现在脚本的实现,就只是简单进行 log 输出。

由上图可可以看到 log 输出结果,在 logger_loginapp.2016-02-13.log 文件中,最前面的 S_INFO 的 S 表示是 Script 的输出

 

baseappmgr.cpp / registerPendingAccountToBaseapp

这个函数,就是查找一个负载较低的 baseapp,然后在调用此 baseapp 的 registerPendingLogin 来进行注册。。

上图主要有几块逻辑: 

  • 367 行,找到最合适的 baseapp,其实就是通过 bestBaseappID_ 来查找的,这个是 Baseappmgr::updateBestBaseapp() 时就设置好了,使用时直接用就好。
    至于各个 baseapp 之间的负载均衡,应该是在 Baseappmgr::findFreeBaseapp 函数里,基本逻辑是找到包含 entity 数最少的 baseapp,这个不在这里展开分析。
  • 369~383 行,找不到 baseapp,应该是系统负载满了,那么就缓存信息,这里也不展开。
  • 389~398 行,在具体的 baseapp 中注册用户信息,实际调用函数为Baseapp::registerPendingLogin。

baseapp.cpp / registerPendingLogin

上图代码有几个主要逻辑:

  • 2510~2526 行,调用 BaseappMgr::onPendingAccountGetBaseappAddr,这里最主要的作用是带回 baseapp 的地址和端口。这里绕来绕去应该是因为 baseapp 不能直接和 loginapp 通信,需要通过 baseappMgr?
  • 2528~2536 行,在 PendingLoingMgr 中缓存当前登录用户信息,即在 baseapp 上注册一下,后续客户端直接连 baseapp 时需要靠这个注册信息来进一步验证。

 

baseappmgr.cpp / onPendingAccountGetBaseappAddr

onPendingAccountGetBaseappAddr 调用 Baseappmgr::sendAllocatedBaseappAddr 来实现;最终通过调用 Loginapp::onLoginAccountQueryBaseappAddrFromBaseappmgr,把用户名,账户名,baseapp 端口、地址返回 loginapp。

 

loginapp.cpp / onLoginAccountQueryBaseappAddrFromBaseappmgr

绕了一大圈,终于回到 Loginapp 了。

在 1207 行取到  客户端 的 channel,然后返回 baseapp 的 ip、port 等信息,1216 行指定这个消息由客户端的 onLoginSuccessfully 来响应,客户端的 Client_onLoginSuccessfully 函数将被调用(协议相关请看前面的两篇文章)。

kbengine.js / Client_onLoginSuccessfully

 

终于回到客户端了 ^^

  • 3238~3239 行,缓存 baseapp 地址
  • 3246 行,开始 baseapp 登录

小结

    • 在理清楚协议之后,再看这个登录 loginapp 的过程就比较清楚了
    • 主要逻辑:loginapp 在验证用户名、密码之后,通过 baseappmgr 获取可用的 baseapp,在此 baseapp 上注册一下,然后向客户端返回 baseapp 的地址,客户端再直接连接 baseapp(需要通过前面在 baseapp 上的注册信息来验证) 
    • python 脚本:可以看到,在这个登录过程中,主要是通过 python 脚本暴露了几个接口,以便用户可以介入这个登录流程
    • python 脚本的作用当然不限于此,后面会基于 python 脚本构造 Entity,然后基于 Mailbox 和 客户端直接通信,这个在后面会进一步讲解。