游戏服务器框架之C++使用Python脚本

  1. 为什么是C++,为什么是Python
    1. 为什么是C++
    2. 为什么是Python
  2. KBEngine是怎么玩的
  3. 简单的实现
  4. 联合调试
  5. 热更新
  6. 协议

为什么是C++,为什么是Python

好钢要用在刀刃上。项目是小怪,我们要刷怪,自然要选一个合适刷怪的武器。

为什么是C++

这个就不用多说了,作为框架,性能要紧。

当然,对于服务器这种要求性能和并发能力的场景来说,还有Golang、Erlang可以选择,C++与Golang或Erlang的对比google一下遍地都有啦。回到选择上,这就得看老板心情、项目定位、小组成员们的综合技能树情况这些限制条件来决定了。

为什么是Python

为什么有了C++还不够,还要一个脚本语言呢?

框架,我们要快,要性能,但是光有框架还不够,完整的游戏服务器还有内容、玩法逻辑。这部分的东西根据需求变化大,新功能新玩法上线下线变化幅度大,往往每周每月有个小活动,就得新加代码删代码,如果这部分也用C++来做,实现和维护成本太高,频繁的修改C++代码对整体的冲击太大,同时编译型语言做不到不停服热更新,此时脚本语言的优势就显现出来。

脚本语言有很多,Python、Lua、Ruby、JS、Perl……为什么偏偏是Python呢?

其实其他的也可以。
游戏引擎最常用的脚本语言是Lua,Lua进入游戏界的时间长,它足够小、也足够快,支持热更新,能很方便的对接到C/C++,麻雀虽小,五脏俱全。
但我还是倾向于选择Python,虽然Python的执行效率慢了点,VM的实现没有Lua的精简高效,优化不够味儿,但是当前Python的使用人数更多,业务逻辑描述能力更强(更接近于人类语言的描述),也有开源库如pybind11能方便地对接C/C++,更重要的是,Visual Studio能够支持实时的C++与Python联合调试,能够展示合并的调用堆栈,跨语言间的断点支持,这是其他脚本语言所没有的。

KBEngine是怎么玩的

KBEngine在github仓的kbe/src/lib/python下直接包含了Python的源代码(CPython,包含pythoncore和pyd/*),在编译KBEngine时编译Python为静态库.lib,并最终带到可执行程序.exe中。CPython中有用于支持C/C++与Python混合编译的函数,KBEngine直接使用了这些函数实现混合编程。KBEngine还将一些通用功能封装在了pyscript库里方便复用。

依赖关系如下图所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
graph LR
python/pyd/* --> python/pythoncore(lib:python/pythoncore)
lib:pyscript --> python/pythoncore
lib:client_lib --> python/pythoncore
server:baseapp --> python/pythoncore
server:cellapp --> python/pythoncore
server:loginapp --> python/pythoncore
server:dbmgr --> python/pythoncore
server:tools:logger --> python/pythoncore
server:tools:bots --> python/pythoncore
server:tools:guiconsole --> python/pythoncore
server:tools:interfaces --> python/pythoncore
lib:client_lib --> lib:pyscript
server:baseapp --> lib:pyscript
server:cellapp --> lib:pyscript
server:loginapp --> lib:pyscript
server:dbmgr --> lib:pyscript
server:machine --> lib:pyscript
server:tools:bots --> lib:pyscript
server:tools:guiconsole --> lib:pyscript
server:tools:kbcmd --> lib:pyscript
lib:pyscript --> python/pyd/*(lib:python/pyd/*)

简单的实现

Python脚本要配合C++写的框架完成逻辑描述的任务,这个story可以分解成两个task:

  1. C++框架中调用Python脚本里的方法
  2. Python脚本能使用C++框架中的核心组件

我们可以发现这两个task实际上成环了:从框架中来(脚本由框架拉起调用),引用框架的东西(脚本中会使用框架暴露的核心组件完成业务逻辑),又回到框架中去(脚本执行结束后又由框架继续接管程序)。任何一个框架嵌入脚本的思路都是类似的。

我们先看第一点,一般框架会在一些关键运行点插入脚本方法,类似于Hook的形式,比如初始化时、每帧开始时、每帧结束时等等。一个简单的例子类似下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int main(int argc, char* argv[])
{
// Do something others...
FrameworkInit();
while (exit) {
FrameworkEachFrame();
}
return 0;
}

void FrameworkInit()
{
// Do something init...
CallPython("fw_init");
// Do something others init...
}

void FrameworkEachFrame()
{
// Do something each frame...
CallPython("fw_frame");
// Do something others each frame...
}
1
2
3
4
5
def fw_init():
# Do something init...

def fw_frame():
# Do something each frame...

我们再来看看第二点,一般框架会向脚本暴露一些功能组件(就是类、方法等),让脚本编写者能够在脚本中使用框架的能力来实现业务逻辑。我们沿用前面的框架,一个简单的例子类似下面这样(在这个伪例子中,我们期望让C++暴露游戏的ECS系统和Scene场景组件,然后在框架初始化时,会调用脚本中的fw_init函数,而fw_init函数中使用框架的组件实现了往玩家这个Entity上挂一个PlayerHealth生命值的Component):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class ComponentFactory : public Singleton {
public:
Component* CreateComponent(ComponentType type);
// ...
};

class Scene : public Singleton {
public:
Entity* FindEntityByName(const char* name);
// ...
};

class Entity {
public:
void AddComponent(Component* component);
// ...
};
1
2
3
4
5
6
7
import Scene, ECS

def fw_init():
scene = Scene.Scene()
factory = ECS.ComponentFactory()
playerEntity = scene.FindEntityByName('Player')
playerEntity.AddComponent(factory.CreateComponent(PlayerHealth))

我们用两个有关联的例子演示了两个task的应用场景,接下来要考虑如何实现两个task了:CallPython都干了啥才能调到Python脚本中的代码?而C++又是如何暴露接口给Python脚本中去直接调用的?

这就得提到pybind11了,pybind11干的事情就是封装CPython,提供一套符合C++风格的、简单便捷的binder,方便开发者在C++中调用Python及暴露C++接口给Python使用。

pybind11提供了CMake集成方案,我们还是按顺序来,先在C++框架中调用Python脚本。

首先是CMakeLists,我们通过find_package找到pybind11,然后链接pybind11::embed即可,embed会让程序依赖Python的动态库并将解释器带上。

1
2
3
4
5
6
7
8
9
10
11
12
cmake_minimum_required(VERSION 3.10)
project(CppInvokePythonScript)

set(CMAKE_CXX_STANDARD 14)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

file(GLOB_RECURSE SRC src/*)

find_package(pybind11 REQUIRED)
add_executable(CppInvokePythonScript ${SRC})
target_link_libraries(CppInvokePythonScript PRIVATE pybind11::embed)

我们在src目录下添加一个简单的Main.cpp并循环调用Script.py中的函数pyadd。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <pybind11/embed.h>

int main(int argc, char* argv[])
{
pybind11::scoped_interpreter pyLifecycle;

auto pyModule = pybind11::module::import("Script");

for (int i = 0; i < 10000; i++) {
int result = 0;

auto returnValue = pyModule.attr("pyadd")(10, 1);
result = returnValue.cast<int>();

std::cout << "C++ Run Add:" << result << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
}

return 0;
}

pybind11::scoped_interpreter会建立一个Python的解释器环境,我们可以看pybind11的源代码,scoped_interpreter其实是一个RAII封装,封装了initialize_interpreter和finalize_interpreter,注释中也列出了interpreter的使用时序的一些问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*
{ // BAD
py::initialize_interpreter();
auto hello = py::str("Hello, World!");
py::finalize_interpreter();
} // <-- BOOM, hello's destructor is called after interpreter shutdown

{ // GOOD
py::initialize_interpreter();
{ // scoped
auto hello = py::str("Hello, World!");
} // <-- OK, hello is cleaned up properly
py::finalize_interpreter();
}

{ // BETTER
py::scoped_interpreter guard{};
auto hello = py::str("Hello, World!");
}
*/

auto pyModule = pybind11::module::import(“Script”);这一句就引入执行了在工作路径下的Script.py脚本文件。pyModule就是Script.py的句柄,通过它,我们就能执行Script.py中的方法。

1
2
3
def pyadd(a, b):
print('Python Run pyadd.')
return a + b

前面的类似的CallPython函数的内容,此时就能有真实的实现了:

1
2
auto returnValue = pyModule.attr("pyadd")(10, 1);
result = returnValue.cast<int>();

我们调用了Script.py中的pyadd,并传入参数,returnValue拿回返回值,由于Python的“万物皆对象”,我们拿到的只是一个Python Object,我们要做cast拿回pyadd的返回值数据。C++中和Python中对象的对应关系不做展开了,另外,还要注意返回值及参数的内存所有权问题,这个也不做展开了,用的时候查一查。

我们看一下这个用法:pyModule.attr(“pyadd”)(10, 1);
嗐,不就是和序列化的方法有点像嘛~import的时候把函数名对照表建好,调用的时候传入序列化的函数名,表里存的方法包装器,然后用仿函数调用起来。

接下来做暴露C++接口给Python使用,相应的CMakeLists要改改,其实更简单,pybind11已经封装好了,我们只需要使用pybind11_add_module就可以了。

1
2
3
4
5
6
7
8
9
cmake_minimum_required(VERSION 3.10)
project(CppExportInterfaceToPython)

set(CMAKE_CXX_STANDARD 14)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

find_package(pybind11 REQUIRED)
pybind11_add_module(CppExportInterfaceToPython CppExportInterfaceToPython.cpp)

像上面这样将会编译CppExportInterfaceToPython.cpp文件并生成CppExportInterfaceToPython.pyd(名称和pyd后缀中间可能会有Python的版本名称,不影响使用)。这个pyd文件本质上是一个动态链接库。动态链接库是一个PE格式的文件,有执行体和导出符,调用是通过符号表来找到执行体并执行执行体的过程,pybind11实质上是帮忙导出了符合Python调用所期望的符号,这样在Python脚本中import后就能调用C++封装的功能。

来看看CppExportInterfaceToPython.cpp中如何用pybind11来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <pybind11/pybind11.h>

class Calculator {
public:
int Add(int a, int b)
{
return a + b;
}
};

PYBIND11_MODULE(CppExportInterfaceToPython, h) {
pybind11::class_<Calculator>(h, "Calculator")
.def(pybind11::init())
.def("Add", &Calculator::Add);
}

这一处的实现,诶,有点声明式编程那个味道了,首先PYBIND11_MODULE(CppExportInterfaceToPython, …导出CppExportInterfaceToPython包,然后通过链式调用的方法,一步步声明出要导出的是一个类,类的名称是Calculator,Calculator类要导出基础默认的构造函数和Add函数。(这里要注意,PYBIND11_MODULE中给定的模块名称要和最终的动态链接库同名,否则在Python中会无法import进来)

接下来我们改造一下前面的Script.py,让它使用CppExportInterfaceToPython里的Add。

1
2
3
4
5
import CppExportInterfaceToPython
def pyadd(a, b):
print('Python Run pyadd.')
calc = CppExportInterfaceToPython.Calculator() # 创建C++中的Calculator类实例
return calc.Add(a, b)

到这里,我们只是实现了最简单最通用的一个例子和打通了功能,要完成一个复杂的框架和脚本间的对接,还需要投入不少的功夫。

联合调试

微软爸爸已经贴心地准备好了(交叉调试器可以拥有合并的堆栈显示、变量显示、混合断点、……啊,好香):

《一起调试Python和C++》

开始前可能需要简要看看:

《安装用于Python解释器的调试符号》

《CPython和pybind11》

PS: 由于KBEngine采用了直接编译Python的源码的方式,把Python直接放在自己的可执行文件内部,这样做的好处是编译出来的可执行文件不再依赖于机器环境中的Python,而是用自己内部的,环境中不需要安装或者配置Python也能直接运行,但是这也导致VS无法直接做跨语言联合调试(可以根据依赖关系修改编译属性把KBEngine所依赖Python的静态库替换成环境中的库,拷贝[环境中的Python路径]/Lib和[环境中的Python路径]/DLLs覆盖到kbe/res/scripts/common/文件夹下来使用环境中的Python,这样就能使用VS的跨语言联合调试了)。

PPS: 关于KBEngine为啥使用的CPython,而不使用更加简单高效便捷的pybind11,我看了KBEngine的历史和pybind11的历史,KBEngine在2012年就开始写了,而pybind11在2015年才发布第一个版本,大约2016~2017年才稳定和逐渐被使用,而这个时候KBEngine都已经开始发第二版本了,终究是那个时候还没有这么香的车轮能直接用。

PPPS: 事实上,VS也是在VS2017之后才很好地把C++与Python的联合调试支持起来。

热更新

下次再扯扯这个话题吧【实际上是我也没准备好:-(】

协议

本文以上内容遵循CC BY-ND 4.0协议,署名-禁止演绎。

转载请注明出处:https://tis.ac.cn/blog/kongdeyou/game_server_framework_cpp_and_python/

作者:kongdeyou(https://tis.ac.cn/blog/author/kongdeyou/)

原始链接:https://blog.kdyx.net/blog/kongdeyou/game_server_framework_cpp_and_python/

版权声明: "CC BY-NC-ND 4.0" 署名-不可商用-禁止演绎 转载请注明原文链接及作者信息,侵权必究。

×

喜欢或有帮助?赞赏下作者呗!