为什么是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 | graph LR |
简单的实现
Python脚本要配合C++写的框架完成逻辑描述的任务,这个story可以分解成两个task:
- C++框架中调用Python脚本里的方法
- Python脚本能使用C++框架中的核心组件
我们可以发现这两个task实际上成环了:从框架中来(脚本由框架拉起调用),引用框架的东西(脚本中会使用框架暴露的核心组件完成业务逻辑),又回到框架中去(脚本执行结束后又由框架继续接管程序)。任何一个框架嵌入脚本的思路都是类似的。
我们先看第一点,一般框架会在一些关键运行点插入脚本方法,类似于Hook的形式,比如初始化时、每帧开始时、每帧结束时等等。一个简单的例子类似下面这样:
1 | int main(int argc, char* argv[]) |
1 | def fw_init(): |
我们再来看看第二点,一般框架会向脚本暴露一些功能组件(就是类、方法等),让脚本编写者能够在脚本中使用框架的能力来实现业务逻辑。我们沿用前面的框架,一个简单的例子类似下面这样(在这个伪例子中,我们期望让C++暴露游戏的ECS系统和Scene场景组件,然后在框架初始化时,会调用脚本中的fw_init函数,而fw_init函数中使用框架的组件实现了往玩家这个Entity上挂一个PlayerHealth生命值的Component):
1 | class ComponentFactory : public Singleton { |
1 | import Scene, ECS |
我们用两个有关联的例子演示了两个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 | cmake_minimum_required(VERSION 3.10) |
我们在src目录下添加一个简单的Main.cpp并循环调用Script.py中的函数pyadd。
1 |
|
pybind11::scoped_interpreter会建立一个Python的解释器环境,我们可以看pybind11的源代码,scoped_interpreter其实是一个RAII封装,封装了initialize_interpreter和finalize_interpreter,注释中也列出了interpreter的使用时序的一些问题。
1 | /* |
auto pyModule = pybind11::module::import(“Script”);这一句就引入执行了在工作路径下的Script.py脚本文件。pyModule就是Script.py的句柄,通过它,我们就能执行Script.py中的方法。
1 | def pyadd(a, b): |
前面的类似的CallPython函数的内容,此时就能有真实的实现了:
1 | auto returnValue = pyModule.attr("pyadd")(10, 1); |
我们调用了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 | cmake_minimum_required(VERSION 3.10) |
像上面这样将会编译CppExportInterfaceToPython.cpp文件并生成CppExportInterfaceToPython.pyd(名称和pyd后缀中间可能会有Python的版本名称,不影响使用)。这个pyd文件本质上是一个动态链接库。动态链接库是一个PE格式的文件,有执行体和导出符,调用是通过符号表来找到执行体并执行执行体的过程,pybind11实质上是帮忙导出了符合Python调用所期望的符号,这样在Python脚本中import后就能调用C++封装的功能。
来看看CppExportInterfaceToPython.cpp中如何用pybind11来实现。
1 |
|
这一处的实现,诶,有点声明式编程那个味道了,首先PYBIND11_MODULE(CppExportInterfaceToPython, …导出CppExportInterfaceToPython包,然后通过链式调用的方法,一步步声明出要导出的是一个类,类的名称是Calculator,Calculator类要导出基础默认的构造函数和Add函数。(这里要注意,PYBIND11_MODULE中给定的模块名称要和最终的动态链接库同名,否则在Python中会无法import进来)
接下来我们改造一下前面的Script.py,让它使用CppExportInterfaceToPython里的Add。
1 | import CppExportInterfaceToPython |
到这里,我们只是实现了最简单最通用的一个例子和打通了功能,要完成一个复杂的框架和脚本间的对接,还需要投入不少的功夫。
联合调试
微软爸爸已经贴心地准备好了(交叉调试器可以拥有合并的堆栈显示、变量显示、混合断点、……啊,好香):
开始前可能需要简要看看:
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/