[莉莉丝研习社]简洁的Erlang 复杂的游戏
Posted 莉莉丝研习社
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[莉莉丝研习社]简洁的Erlang 复杂的游戏相关的知识,希望对你有一定的参考价值。
本期研习社我们请到了国内Erlang开发界的大牛成立涛给我们讲述《简洁的Erlang 复杂的游戏》,成立涛从Erlang语言的特点以及游戏机制两方面入手,分享了他在游戏开发中使用Erlang语言的经验和心得,并现场解答了观众的提问。
分享实录:
张昊:大家好,今天非常荣幸请到成立涛给我们做分享,我之前在千橡就是人人网工作,当时我们做了一个广告后台,这个广告后台可以支持每天20亿PV的访问量,那时候大家都在学校上人人网,我们就在想能不能搭建起一个快速并且支持大并发的服务器端,可能在座的有些人不知道我当时是做程序的。然后我们就找了一个神奇的语言,叫做Erlang,这个语言的特点就是高并发并且能够非常便捷的支持分布式,具体Erlang的其它特性会由成立涛介绍。成立涛是一个什么样的人呢?当时在09的时候,国内用Erlang语言的人还非常少,我刚进公司的时候通过买书和在网上搜索资料来学习这门语言。当时我就发现无论我怎么去搜索信息,总是会绕不开一个名字,这个名字就是“成立涛”,在各种各样的网站上都能搜索到他当时写的东西,他就是这样一个存在,当时国内用这个语言的人几乎都知道他。
可以说我当时在千橡的工作经历对现在游戏圈的Erlang语言的普适有比较大的贡献,为什么这么说呢?因为当时我学了这门语言之后,比较了解这门语言的特点,所以在莉莉丝刚开始起步的时候,就选中了这样的服务器语言,这也是今天能够和成立涛在这儿给大家做这个分享的原因,下面就有请我们的大神立涛同学给大家做今天的技术分享。
成立涛:谢谢各位!大家好,张昊说的有点夸张,当时我也刚学了Erlang,正好有机会去做一些实际的东西,然后就在千橡公司做了一段时间,那是09年好久之前了,所以如果技术有什么退步,请大家多包涵!我就分享一下我自己的经历和一些看法吧,因为其实把一件事情做好有很多方法,我们这只是其中一种,主要是大家多交流,因为准备的也很匆忙,大家都见谅一下。
其实在我心中,Erlang比较简单。因为我自己不是计算机专业出身的,但还能用Erlang来做一些事情,所以我觉得它还是比较简单的。游戏我认为比较复杂,因为自己在那么多公司工作过,感觉游戏的系统、代码要求比较高,比以前做一些互联网的网站、后台、一些服务还要复杂一些,所以我就想,这两个东西是比较好的对比。
简洁的Erlang:
下面我就说一说自己做游戏这么多年的感受,其实大家对Erlang也比较熟悉,为什么我说Erlang是简单的?主要是说它还是一门普通的也不是很复杂的语言,它的语法和基本的知识还是蛮简单的,基本上看看书就能写代码,然后再看一段时间就能写些东西出来。另外,它也是一个通用的语言,像在网络、数据库、编译、脚本等方面是通用的。我当时选择学习Erlang是一个巧合,我05年毕业,07-08年的时候在一家叫蓝汛的做CDN的公司工作过一段时间,这家公司那时候用的是smpp通讯协议,用一个叫ejabberd的开源软件来组织CDN节点之间的通信、回调和交互。我那时候比较喜欢探索,看到开源就想知道是怎么做的,然后一搜索发现是Erlang语言,因为当时主要是在做C++的客户端,有时候做C++还是比较郁闷的,一看到Erlang语言就觉得比较强大,然后就去学习。那时候书也没有出来,主要的就是看国外的一些资料,各种巧合之下,工作中也是学习,就把这门语言给掌握了。
其实我觉得很多时候接触一件事情,刚开始的时候你可能感觉不到任何东西,但当你接触之后,慢慢的会给你带来非常大的变化。所以我觉得做技术,我们平时应该保持一种探索、追求新鲜的欲望,这样能够不断接触新的东西。
我觉得我在算法等方面的功底不是特别深,所以觉得我们不用特别多的花精力在怎么样组织代码,怎么样写非常精巧的算法上面,就可以做出一些性能出众的东西来,像用Erlang创建一个高并发网络服务器是很简单的,几十行代码就可以写出来。
Erlang有很多特性,比如支持代码热更新、SMP支持、跨平台支持等。首先,做游戏的时候对热更新感受比较深,因为经常会更新各种配置数据,发现bug的时候会发现热更新非常好,特别适合我们做游戏。SMP支持也是非常好的,像以前最开始做游戏的时候,心里总是没有底,总是担心到底能不能撑住那么多人。当时总是在想各种各样的办法,比如说我们要用多少台机器来解决,因为当时硬件也没那么好,但是Erlang就有SMP支持,有的公司有很好的服务器,其实现在并发再高也没有什么问题。跨平台支持更不用说了,分布式支持也是非常好。我们有时候做跨服的安排,可能也会基于Erlang分布式来做,省的自己还要开发。
我觉得上面这些东西是它比较好的特性,这些特性如果让我们自己用最原始的方法实现可能会很花力气,要把它弄得非常稳定、稳固,可能真的要好长时间,但是Erlang都有提供这些东西,所以我感觉它是把复杂的东西的都做了,留给我们的就是简单的使用,所以我觉得它还是一个很简单、很强大的语言。说来说去,很多都像是老生常谈,但是结合我刚才说的一些事情,还是觉得如果我没有去学Erlang语言的话,可能自己还是一个苦恼的程序员,现在至少是一个稍微快乐的程序员。
我在人人网做完广告系统以后,就开始做网页游戏,那个时候的心态挺耐人寻味的。刚开始做的时候,有一个创业公司让我去做服务器主程,然后我完全没有做过游戏,玩游戏玩的也不好,所以当时也不知道怎么去做,那时候就想肯定要去网上各种搜资料,看别人是怎么做游戏的,然后更多的是看一些做端游的人分享的东西。所以那时候把游戏想的特别复杂,感觉压力比较大,因为你没做过这个东西,别人又说这个东西很难很庞大,所以你的压力就会比较大。我在网上随便找了些讲游戏服务器架构的图,然后发现图上有各种划分、好多台服务器,看着就比较壮观。当时总是琢磨这种图,思考要怎么做才能支撑那么多人在线,因为那时候端游随便一说就是多少万人在线,我那时候也不知道还有分区和分服,我就以为一个游戏全部人在线,太牛了!完全把自己吓得不轻。那时候习惯于用Erlang,觉得Erlang这方面都很强,现在轮到我来做一款游戏了,要好好想想这些东西我能用上什么,不用上什么,然后就开始拼命的想,以前应该为这种事情愁过不知道多少回,比如怎么认证,网页的请求怎么去路由等等。当时我做的第一款页游,把游戏弄得非常非常复杂,有N个Erlang节点,总之就是自寻烦恼,倾注了九牛二虎之力,部署运营极其麻烦。用了将近一年的时间,最后一测也就一两千人在线,搞来搞去你就会觉得你想多了。因为当时我们的策划很牛,所以觉得我们合作一款游戏一定牛,结果做出来比较遗憾,就这样第一款游戏10、11年做完了。
后来做了很多游戏项目,我学乖了。基本上做的时候会跟策划讨论很多事情,比如说单服最高要多少在线,大概多少人同屏,因为我做的主要是MMORPG游戏,所以这种东西和技术的关系比较大,比如玩家位置的同步,同步要求的清晰度高不高,怪物的伤害数字别人能不能看到,地图会有多少屏等等这些都会去问,当然这只是一些代表。我的意思就是说我可能会更多和项目组的其他人员去沟通交流,而不是自己总想着用技术来解决一切问题,因为可能你花很大精力做的东西,最后人家用不上,这是比较尴尬的。做游戏一定要明确需求是什么样子的,我在做游戏的过程中就是对已知的需求和未知的需求的平衡,因为有的时候,你想的太少不行,你想的太多也不行,反正就是对我们程序员很折磨的一个事情。
我觉得在做程序的时候有一些很简单的事情,也是自始至终应该坚持的一些事情,第一就是代码的风格,有很多人可能觉得这不是那么重要,虽然我不是处女座,但是我觉得这很重要。我现在写代码比较少了,眼不见心不烦。但如果让我看看到有一个地方不合规矩,心里就会很抵触。
我自己的风格是这样的:首先是注释很多,注释几乎都成了文档了,代码里面像服务器代码有很多注释。我要求注释要足够多,但不是废话。一些文档的注释,可以用三个百分号,函数注释可以用两个百分号,函数内语句注释用一个百分号,这种事情虽然是小事,但是Erlang函数很多时候是那种辅句一个一个的,当我看到有两个百分号的时候,我就知道这是对函数的注释,当我看到一个百分号的时候,我就知道这是一个函数体了。我觉得这对于划分一些界限,定位一些东西有意义,所以我会坚持这种做法。包括像空格之类,我写代码的时候一个都不会放过,该有的一定要有,不该有的一定没有。用Erlang写代码的时候没有return那些东西,嵌套会比较少,我觉得应该要避免太多的嵌套,否则的话自己看一看就会把自己绕进去。可以通过拆分函数或者通过抛一些异常出来,尽快离开这个函数,减少嵌套。函数和变量的命名也一定要有统一的风格。
第二介绍一下函数,以前学Erlang的时候,知道Erlang是希望函数要简单点,功能单一,能够被重用,这样的话副作用才会小。我写代码的时候完全是平铺直叙,就是说这个函数基本就是按照一个成年人正常逻辑往下走,该干嘛就干嘛,不会有一些高智商或低智商的行为,就是正常逻辑,因为我觉得这样子大部分人会容易理解一点。我做的时候就会要求在做一些逻辑的时候一定是先做各种检测,比如说我们做游戏的时候是从客户端进行一些输入,这些输入肯定是先做各种检测,检测过了以后,后面才是正常逻辑支撑,最外层的函数基本都是这样来做。然后像一些有状态的操作,或者进程字典,还有一些其它的对数据的修改,尽量封装成一个函数,千万不要去乱用,如果这种操作都是用原生操作满天飞的话,后面的系统维护、找bug等各方面都非常不方便。像一些原生的操作,比如说发送消息,ets操作,还有进程字典操作,我都要求要封装成函数。有的时候看到别人的代码,就是直接找到某一个服务,然后来一个感叹号发个消息过去,然后在handle_info里面处理,我觉得这个完全是欠揍的行为,太危险了。首先,“!”很常见,搜索代码的时候很难搜到,而且不是一个印象深刻直观的一个东西。其次,这种操作的出错性高,还有就是排他性太难。
还有我认为代码要对称,比如说一些即时制的服务,你肯定要在初始化的时候把文件打开,结束的时候把文件关闭,我相信没有一个正常人会在这个地方打开,在另一个地方关闭,文件关闭和打开还是要在同一个函数内比较好。
然后函数作为模块对外的接口,我们做程序都知道,接口的概念就是类似一种协议,不能暴露太多“内部实现”信息,不然就不是接口了。还有就是异常捕获一定要有处理,如果你不知道要怎么处理这个异常,那最起码要打印一条日志,而不是将错误吞没。我们出过这种bug,有人写代码直接catch一下然后没了,这样你也不知道怎么回事,找bug找半天找不着,因为他catch完了之后什么都没有打印,我们肯定要避免这种事情。
关于宏和头文件,规范一点的话,一些常量肯定是要定义成宏。比如有的时候服务器代码里面肯定要用到道具的ID,用宏会比较好。有一些人不拘小节可能会这个地方写个数,那个地方写个数,万一改的话会比较郁闷,要去改好多地方,能统一的地方还是统一一些,这样也能省事。头文件这个有点像C++的做法,会给你一个宏,防止多次include。还有就是代码里面,需要多次使用的atom,都用宏来定义,防止拼写错误,因为一个原子写错的话,其实也看不出来什么,但是一个宏写错的话,很容易看出来。比如说发一些消息,定义key,有的时候就会觉得怎么不对啊,可能就是写错一个字母而已,这个我们也会用一些宏。
还有就是代码里面会使用进程字典,我们也是建议key的定义包含模块名,不然很多时候有的人同一个进程在不同模块里处理,结果key重复了,这个也比较坑,所以尽量加一个前缀,防止进程字典被写乱了,这个要注意。还有的时候会有很多数字的key,比如说玩家身上有很多数据,这些key要划好段,这也是一种规范,如果没有划好段,各种乱插,结果数被用完了,或者被重复了,也不好。这些都是一些简单的事情,说出来大家都知道,但这是每个人都应该遵守的一些事情。
然后说模块,我建议用一些前缀划分这些模块,比如我们写代码的时候,用mod开头的代表处理逻辑,用sys开头的代表系统配置,不要使用export_all,不要使用import,一些编程规范都有说明。对于复杂的模块,有很多导出函数的,我们就要进行分组。我有个要求就是我们模块从上到下读下来要有节奏,这些事情确实比较简单,但是我觉得要记住,在实践过程中践行这些事情,会减少自己工作后期的不少烦恼。
复杂的游戏:
说一下游戏本身的事情,我们做RPG可能会感受多一点,协议功能模块太多了,像以前协议都是手写的文版,我们后来都是改成那种XML定义,然后再用样式表确定一个可以读的XML,用XML定义协议,再把它转化成二进制来生成协议,这样在规范性、可读性各方面都是蛮好的尝试。我们基本是后端把协议定好,给前端看一下,如果前端说没问题,能满足需求,然后就把协议提交,生成代码,把代码发给前端,就这样开始开发了。我们用XML来定义协议,目的就是通过工具来自动生成代码,比如说我们协议是二进制,就通过XML来生成C#代码,后端生成Erlang代码,这样客户端发协议上来,根据协议号自动解析生成一个数据结构,下发的时候根据已传的record自动编码生成二进制,这样会减少很多编码上的错误,为联调省很多事情。
数据库这边我们为了省事,也定义一个关于数据表的配置文件,通过一些脚本来生成读写这个表的一些读写查询,这都是为了减少自己的工作量,不然的话每次都要再写SQL 语句,每个字段都要转化,非常累。
我们游戏框架里面自定义了一个“behaviour”,因为游戏会有很多逻辑,这些逻辑我们认为它有一个通用的框架和流程。我们每个玩家是有一个进程的,同时会有很多功能,比如背包、角色、战斗、技能,各方面都有很多系统,有很多子模块,然后我们就会定义一个gen_mod,这个gen_mod基本上就包含最主要的一些导出函数,比如说初始化、状态信息、打印信息。状态信息就是看这个模块目前里面有什么状态,比如调试的时候发现背包的道具不对,就可能会打印一下背包模块的ID信息,其实就是要求功能模块都有这些导出函数来做一些相关的事情,这样就不会造成开发到一定程度发现有些事情忘了。角色初始化就是上线,角色功能结束就是下线,删除角色对应功能数据就是我们会删一些角色账号之类的,都会有这个导出函数。还有一个就是清理每日数据,因为这种游戏每天玩家都会有很多计数器。所以每个模块都会有这么一个函数,做一些数据的重置。
这样我们在前面定义好数据表,定义好协议,然后就开始写模块,通过模块实现相关的东西,再在每个具体处理协议的地方处理客户端的请求,这就是简单的开发流程。
玩家计数器有很多,有三种计数器,第一种是持久化的,就是存到数据库里面的,我们是用的mysql;第二种是内存数据,就是在运行时用;第三种是每日数据,就是每天12点或者2点来清理数据,这种计数器就是存储各种计数信息,如某种操作的次数,是否领取某种奖励等。比如说我们抽象出一个counter,然后通过参数化的模块定义,比如说一个叫DB,一个叫RAM,一个叫DAILY,比如我现在有个数据,我要让它持久化存储,那我就在头文件里面定义好一个key,然后就可以COUNTER_DB,然后set就可以了。这也相当于是对数据的封装,这样封装操作起来会比较简单。
我们现在游戏就是这样:游戏服务器就是一个单个的Erlang节点,然后还有跨服的节点,可能是要跨服玩法,需要一个节点操作。还有两个节点,一个是语音节点,一个是认证节点,语音节点我们用来缓存语音传输的数据,玩家通过http提交,要播放的话也是通过http下载下来,这是我们自己做的内存的缓存服务。手游和页游不一样,它有很多家平台,可能需要各种认证,我们就自己做了通用认证节点去各个平台认证,认证完之后变成我们自己加密的认证,这样游戏服务器就不用管这些事了,只要判断是我们自己的加密算法,那么就可以登录,相关的信息都在认证服务器里保存好了。最主要就是游戏服务器是单个Erlang节点,语音和认证都是后来加的边缘服务。
我们在做RPG的时候比较传统,都是用九宫格算视野和怪物,一个地图会有一个进程,同一个地图的所有怪物共享一个进程,会有一个大的循环来做这件事情,怪物的一些逻辑和AI需要优化的多一点,但游戏类型不同侧重点不一样。
我们游戏也会提供一些给我们自己用的API,比如我们会基于mochiweb实现HTTP API的一个服务,那就要一个IP的白名单保证安全性,然后像充值、查询信息、运营的一些活动,比如说开某个活动、关某个活动,配置启动时间,还有加载一些系统配置,都是用这些API,这个API开发起来也比较简单,和后台一些交互也会比较容易。
我说一下我们以前做游戏遇到的一些印象深刻的bug,有一次,有反映说某个玩家充值后账号被其他玩家登录,比较诡异,我们感觉是和充值有关,然后查看日志发现都是离线后充值,然后我们判断应该是离线充值有问题。接着排查代码发现,离线充值的时候我们有时候会load一下玩家的数据,获取一些旧的充值数据,比如说加VIP、充值总额,然后有一个地方账号回写了。因为每个平台的验证都五花八门,就有某个平台的用户,他的账号竟然为空,数据库正好也没判断就写回空账号,再有新玩家登录的时候,会用到前端提交的空账号,用空账号去查询数据库,就获取了充完值的玩家数据记录,所以这个号充完值就被别的玩家占用,出现了串号现象,最后找了一两天才知道是这么回事。
还有一个问题,这个问题断断续续困扰了很久,最后发现其实也是一件很简单的事情。这个问题就是我们的服务器偶尔不能接受新连接,除非重启,断断续续隔那么几天,就会有一台服务器出现这个问题。当时记录说服务器负载低,Erlang节点登录正常,各种端口也都有效,但就是新的连接无法建立。抓包看了一下发现TCP三次握手后,客户端发送第4个数据包后,没有收到ACK应答,连接被服务器reset。当出现这种情况以后,我们就在服务器上用trace追踪,我觉得trace在线上服务器追踪是比较方便的。因为我们的tcp server是参考了以前的一些代码改的,在一个地方有一个异步accept的匹配值有OK和错误两种,当时对错误考虑的不全,以为只有closed,但其实不是,还有其他错误信息值会偶尔出现,一旦出现这些错误信息,这里就不会再accept了,然后就不会打印任何信息,服务器也不会进入等待状态。当初这个bug折磨了我好久,每次一出问题都只有重启。
提问环节:
观众:请问你们线上的trace用的是Erlang原生的dbg,还是自己写的trace?对性能有什么影响?
成立涛:我们就是用的dbg,会对性能有一定影响,但是观察下来还好,只要trace完赶紧就stop掉就行了,我们很多时候找bug都是用trace来解决,trace一下它的函数返回,函数调用的时候一些参数,然后来解决这些问题。
观众:请问如果线上出现系统瓶颈,怎么分析?
成立涛:肯定还是要先确定一下是网络、还是CPU、还是内存各方面问题,大部分情况下是CPU,我们登入到那个节点,可以用iftop或者简单做一个profile分析一下,看看哪个函数调的最多,我们自己采集一下它的信息,看看哪个进程的队列最长,就知道谁比较忙,有的时候也会用trace,都可以。或者我们有的时候怀疑一个地方有问题,就自己写个机器人线下检测一下,线上找bug压力还是比较大的。
观众:您刚刚在PPT里鼓励多用进程字典,那还有必要用ets吗?
成立涛:有!共享数据肯定还是要用ets,要是跨进程还是用ets性能会好一点,如果是同一进程就用进程字典,类似于C++里面的私有,私有数据用进程字典比较好,没必要放ets,但是公有数据基本都放ets,比如说做怪物数据,肯定放ets好一点,有的时候边查询,边做战斗公式的计算,查ets最方便,这样就有个数据的区分。
观众:你们做游戏的时候,玩家数据都是放进程字典吗?
成立涛:我们数据也会放ets,这个地方有几种方法,进程字典算是一种,还有一种是类似于用参数传递,还有一个ets。比如玩家数据、任何功能都会用到的数据,我们肯定是放参数里面,任何地方需要,我们就会把数据传过去,这是一个基本的数据结构;一些小的数据、不会公用的数据,这个模块比方有个私有数据,别人用不到的,那就用进程字典了,每个人操作自己的数据,虽然是一个进程,但是不同模块的数据是自己的。再共有的数据我就要放ets了,这样最方便了。
观众:请问跟前端同步的九宫格大概是个什么范围,广播量大不大?
成立涛:因为九宫格也是好多年前做的,后来也没有改进过,现在一直是这个样子:玩家在某个位置,只要一移动,客户端就会提交信息告诉服务器我动了什么位置,反映出一个坐标变化。如果九宫格的大格子没有变化,我就给周围发个信息移动位置了,别人收到这个消息,把位置挪一下,就看到他动了;如果九宫格大格子动了,那我有可能告诉一部分人我离开了,同时告诉一部分人我进来了,基本都是这样移动的。这么看广播数据量不小,如果导致通讯量很大的话,这种做法就很低端,效率就会降低,重复和浪费是比较大的。可能用类似于帧同步的方法会好一点,我们现在的地图就几十人还好,但它的通讯量还是蛮大的,我们以前出现过一个bug,就是人一多就宕机,后来发现bug是客户端提交数据提交的太疯狂了,每秒钟提交十几个包,要再有广播什么的,网络很快就处理不过来了,所以我们对客户端提交的数据包都会有阀值的判断,有各种保护,主要是上线的时候有这个限制,同屏人多的时候还是要想办法改进,减少数据同步。
以上是关于[莉莉丝研习社]简洁的Erlang 复杂的游戏的主要内容,如果未能解决你的问题,请参考以下文章