Go 在游戏行业中的工程实践 | ECUG Con 精粹系列
陈明达
真有趣技术总监。从事游戏行业,开发过《神仙道》《仙侠道》等项目。
先从工程说起
软件工程三要素
我创业后,总结了过往工作和创业过程中参与的项目,发现不论做什么,软件工程都回到三个问题上:
- 需求。首先,我们会有一个方向。
- 时间。大概多长时间做完,需求和时间是关联的。
- 资源。我们打算投入的人才类型、人才数量、资金数量、资金能够支撑团队运转多久。
这三者互相影响。所以,我们在做软件工程时,所有的决策都逃不开这三个因素。区别可能仅仅是当我想做一个小程序,不指望长期地更新它时,代码可能很简单。但如果今天要做一个需要长期更新的游戏,比如当预计更新时间需要一两年时,就不可能随便地定义很简单、很初级的规范,因为这样的东西没有办法长久维持。但是从公司经营的角度看,往往会考虑资金预算,因为时间资源都是有限的。所以,我所有的决策都会落到这三个因素上。
游戏行业的软件工程
为了大家更好的理解我的决定,下面和大家分享一下游戏行业的软件工程存在的一些问题:
1. 需求不确定性很大
产品设计人员和开发人员的最大矛盾在于需求不确定。如果今天产品设计人员说:“我要做这个东西,需求不会改变。”那么,我相信每一个开发人员都会非常高兴。
2. 通常会有时间节点的要求
游戏往往会有时间节点的安排,比如临近一些长假,大家回老家后会减少玩游戏的时间,此时,你需要通过运营活动增加用户留存。它有时间节点性,譬如运营商今天已经约好广告,几点几分开始导量,那么这个行为会影响游戏的增长,所以服务器、版本等都要做好准备,因为不能出现问题。另外,国内游戏普遍存在山寨的问题,但其实山寨也要讲究战术,假如大家都山寨,那么你为了抢占市场先机,必须快速上线。如果是做创意型的游戏,可能需求变动比较大,如果是做创意较低的游戏,时间的要求则会很高。
3. 稳定性和实时性要求双高
导量等都是流量变现的生意,稍微有一点折损,回报率就不同。就像刚才的分享,它很精细地去抠用户的到达率,中间下载到底卡在哪个点。其实游戏的稳定性和实时性要求都非常高,大家都玩过游戏,特别是联网型游戏很卡,会造成用户体验很差。网站中,单个页面展示 0.5 s 和 2 s 没有太大差异,但是一款游戏里的操作差到秒级时,用户就会开骂。另外,游戏中玩家的数据很宝贵,因为这些数据是玩家花钱花时间去获得的,所以数据安全性要求特别高。
4. 人员普遍缺乏工程经验
参与的人员普遍缺乏工程经验,比较常听到的是“我原来做 xx 领域,现在有兴趣做游戏,就加入游戏行业了。”另外,大部分产品项目都是默默无闻的,上线后没有大量的用户就会消失,大家平时能看到的是极少数,这就是行业现状。我们作为一个公司,没有办法把自己放在一个“这个游戏肯定赚钱,我们的人员素质都很高,我们的需求都非常确定”的角度,我们只能做最坏、最保险的打算。因此,我们会比较现实、客观地剖析所处的环境。
保持简单
在这种环境下,我们所能做的只有一件事情:保持简单。保持简单有很多好处:
- 门槛低、参与人员要求不高;
- 做起来快;
- 验证快,当需求不确定的时候,尽早去验证,就可以尽早调整它;
- 迭代快,可以快速地调整;
- 简单的事情容易说清楚,不容易在沟通中出现误差,比如今天想要做一个东西或者制定一个项目的规范,如果列出手册让程序员按照手册进行编码,相信很多人其实根本不会去看;
- 不容易出错;
- 出错了也比较容易找到问题的原因。
把事情做复杂了容易,做简单却比较难。我在团队中一直鼓励大家尽量采用简单的方式实现。
我采取的策略
1. 自动影射数据库的缓存系统游戏的稳定性和实时性要求双高。当我扔一个 Redis 给大家,业务就往 Redis 访问,数据落地,写到 MySQL。如此无法保证所有人产出一样质量的设计方式,在小团队中的风险很大。所以,我做了数据库的自动映射,开发的时候只需要与 MySQL 打交道,剩下的事情都交给框架去做,开发的难度就大幅下降,只要你懂得用这套系统,基本上开发出来的程序不会有效率问题,大家的精力就可以放在业务的开发上,不用再去纠结缓存中具体要用什么数据结构去组织、数据要用什么样的方式同步等。
2. 贯彻速错理念让程序员以简洁的方式开发业务逻辑,开发的时候只管做正确的事情,错误的东西我不需要去增加代码的复杂度。速错让我们比较早地暴露问题。 3. 简单的业务模块管理用一句话就能说清楚的管理方式,稍后也会跟大家分享。错误和异常处理
Go 和其他编程语言不太一样的是,它把错误和异常分开。Go 提供了 error 类型用来表达错误,语法上允许函数有多个返回值,因此,返回 error 的设计在 runtime 中随处可见。另外,由于 Go 的普及率不高,所以我列一下代码(如图 1,2 所示),与大家分享一下 Go 的错误写法。图 1 这个函数是两个偶数相加,如果参数不是偶数的话,就会返回一个 error,error 这个包是 Go 内置的,可以让你快速返回一个错误,不需要重新手动构造错误类型。 图 2 调用它的时候可以通过第二个参数是否 nil 来判断它是否出错,然后把对应的错误信息打印出来。
错误处理
有时候只是判断 nil 是不够的,因为有时会有不同的错误类型,我们要针对不同的错误类型来判断,有时候错误中需要附带额外的错误信息。
有两种常见的做法:
1. 设置一个全局变量,业务上直接返回全局变量,外部调用时可以判断返回的 error 是不是这个全局变量,因为 error 是引用类型,所以可以通过判断引用来判断是不是某个错误。这种方式在平时用的比较多。
2. 自己去实现 error 接口,这样可以附带更详细的数据和信息,外部就能取到具体的上下文相关信息。这个做法相对更复杂。
异常
- 运行时抛出:数组越界、空对象、类型断言失败。
- panic() 手动抛出。它的参数是可以任何类型的,函数就不用显示去返回一个 error。
- defer() + recover()。
- 不捕获进程就会退出。
速错实践
- 只处理业务上定义的错误,不做多余的事。
如图 4 所示,如果是速错的写法,在我们项目中会定义一个 fatal.when。如果判断这句话是错误,则会出现 panic。数组访问时不会去额外判断数组长度,因为正常的客户端知道数组长度,如果返回越界,则定义为客户端 BUG 或者非法客户端。另外,数据是否存在也不用管,因为如果访问的时候发现是空对象,它就会抛出来。所以有两个前提,只有错误的客户端逻辑才会触发 fatal 和 panic,我们不必为外挂开发者提供友好的错误提示,像这种程序错误,我们内部会计入日志,没有必要反馈给客户端。
2. 分清会话级别的异常和业务级别的异常。
游戏其实有两种可能的异常情况:
a. 会话级别的异常。单个玩家操作某个东西,比如刚才的强化,它可能存在客户端 Bug。玩家点进去时,链接就会断开,我们把这种常见的错误异常定义为会话级别的异常,它只影响自己、不影响别人。
b. 业务级别的异常。假设我们有一个玩家在线 PK 匹配系统,假设这个匹配系统的进程因为某一段代码写错挂掉,速度越界,这时业务是没有办法响应所有人请求。此时,我们会直接让进程挂掉,因为业务已经无法响应。因此,业务进程的异常会影响所有人。
3. 所有 goroutine 都应有明确的入口。
4. 所有入口都应有 recover 和日志记录。
5. 绘画入口只记录日志,不抛出异常。
6. 业务入口记录日志后,抛出异常让进程崩溃。
如果不及时让进程挂掉,它可能会产生更严重的问题。早期用 PHP 做页游的时候就遇到这种情况,其实某一段业务逻辑 PHP 执行已经出错,同时把缓存污染。但是,我们的做法并没有直接把这个问题暴露出来,而玩家在已经污染的缓存数据下面跑,数据会越坏越严重,等到玩家向我们反馈他的数据坏掉时,我们已经无法还原,这种问题很让人头痛,因此,我们倾向于在出现问题后,第一时间暴露出来,而不是把它掩盖掉。
实践总结
1. 需要让用户做出响应的错误才需要管。假设我有一个系统,系统告诉玩家他目前余额不足,需要进行充值。余额不足这件事就属于业务上已经定义好需要对应去处理的一个情况,这种就属于业务上面的错误,不属于程序的错误或者异常。
2. 模块调用者是模块开发者的用户。
我们平时做的时候不只是把东西交付给最终用户。其实,我们平时在开发的时候,两个模块互相也是用户关系。那么,我需不需要让调用者知道这个事情,然后由他做出对应处理?如果不需要,则不需要有多余的错误判断和错误反馈。
3. 客户端开发者是服务端开发者的用户。
服务端的信息是不是需要客户端处理?比如,玩家的网络连接故障,那么客户端需要赶快弹出一个界面“我现在正在重连”。此时,错误需要暴露给客户端,并且去判断处理。否则,有一些游戏没有断线重连机制,断掉就是断掉,那么此时应该暴露,而不是掩盖。
4. 服务端开发者是自己的用户。
如果错误没有处理好,最后坑的是自己。如果服务端的错误处理没有写好、客户信息处理不全,可能最后会增加自己查问题的难度。 所以,我的总结关键是第 1 条,需要让用户做出响应的才需要管,不需要响应的不需要做多余判断。这样程序不需要写很多错误判断,其实大部分的错误是直接当异常处理掉。因为,我们在定义业务时,通常不会定义数据库故障时怎么样、网络连接故障时怎么样、通信协议故障时怎么样,通常需求不会到这种级别。因此,这种情况我们当异常处理。
interface 的应用
interface 是 Go 的核心
大家可能更多的是听到 Go 在并发方面的特性,其实 Go 在面向对象方面,跟其他的语言也有很大差异。我认为 interface 在 Go 里面非常重要,如果没有正确理解 interface,那么 Go 就没有被正确理解。
interface 有两个很重要的特性: 1. 它的实现是隐式的; 2. interface可以组合出来。 图 5 如图 5 所示,interface 的实现是隐式的特点,可能在工程实践中会引发一些问题。如看到这个类型时,不知道这个类型具体实现了哪些接口。有一个小技巧是通过匿名的全局变量,做一个类型转换,这样在编译时期就能暴露问题,如果 MyImplement 没有实现 MyInterface,那么编译就无法实现。比较常见的误解是,MyImplement 前面有个星号,这是它的指针类型,这个方法是实现在这个结构体的指针类型上的。调用时,有些地方可能不小心会写成结构体类型,然后就会出现编译失败。因为,结构体和结构体指针是不一样的。io 包是最好的示例
io 包定义了实现 io 所需的基本接口(譬如读、写、关闭)。通过基本接口组装出不同的复合型接口 (譬如,同时支持读写的 io,和同时支持读写关闭的 io)。举例,Reader Writer Closer 分别是 3 个接口。如果某个 io 是只读,就只需要实现 Reader,如果某个 io 是只写,就只需要实现 Writer ,有一些 io 是不可关闭的,不一定可以实现 Closer,所以这三个是分开的。这三个最基本 interface 又可以重新组装,这是 Go 很重要的一个特性。另外,在 os 包中就实现了文件 io,net 包实现了网络 io,bytes 包实现了内存 io,bufio 包实现了带缓存的 io,bufio 包利用接口解耦了底层 io 实现。
依赖注入
业务模块不像 runtime,交叉引用很常见。但 Go 不允许 package 之间交叉引用。所以我们需要换一种思路,业务模块只要声明它想要什么,要的东西从哪里来不用业务模块管。这是一个依赖注入的过程,依赖注入实现起来非常简单,不需要很重的框架去实现。
图 6如图 6 所示的例子。module 包负责管理所有的业务模块,通过 IPlayer、IEquip 声明具体的业务模块需要提供什么样的方法,然后再通过对应的全局变量,去存放这两个口的具体实现,这是很简单的依赖注入的方式。譬如,图 6 中的 Player 模块可以引入 module,然后把自己注入到 module 的 player 实现当中。譬如玩家注册送装备,就可以调用装备模块送装备,而不会直接地调装备模块,会变成间接的。只要依赖 module,然后把自己注册进去就可以。
装备模块也相同,启动的时候把自己注册进去,可能装备升级的时候要扣钱,可能对应的调用玩家模块,只需要调 module.player 即可,无需注意它是在哪里实现的。最关键的一点,这个 module 作为总的模块管理的一个包,我们可以在一份文件里面很清楚的看到,业务对外提供哪些方法,哪些接口。平时,我在 review 代码时,基本只看这一份。
实践总结
接口在 Go 语言中是非常重要的,要重点掌握。接口的运用是很灵活的,脑筋要会急转弯。解耦并不需要很厚重的封装。可以恶心自己,但不要恶心别人。
去 DSL 的尝试
我们在通信协议上有一套自己的 DSL,但在实际应用中存在一个问题,即它的功能隐藏在语法中,甚至有一些功能隐藏在工具参数背后,同样的文档通过不同的命令行参数生成的长度也不同,因此,这里有一个学习成本。
开发人员在设计 DSL 时,脑海中需要联想原生的语言最终生成出来会是什么,比如客户端对应生成什么、服务端对应生成什么。其实,大脑有一定的工作负担。当大家在写开源项目,譬如 markdown 文件时,如果没有可视化工具辅助,你要在脑海里面联想,写出来的东西最后别人看到是什么样的,而所有的 DSL 都会有这样一个问题。另外,开发的过程中,我需要反复地在工具间切来切去,可能需要改一下 DSL 的描述,然后切到命令行生成代码,再切回来看对应的生成产物,再继续编写代码。大家在这一块的工作其实并不流畅。而 Go 的代码很清晰简洁,没有多余的符号,因此,一份代码本身就是一份很好的文档。
图 7
如图 7 所示,大家一看就知道每个字段的用处。那么,如何有效利用代码本身呢?我想了一个办法,即让大家去写 Go 的代码,只要把 Go 的代码写好,针对这份代码去生成对应的客户端的代码和序列化的东西,就不用在 DSL 上来回切换,这就是我们的一个 RPC 参数返回值,直接写到代码即可,不需要到 DSL 里面去切,写出来的样子即最后的样子。我们对通信协议中用到的数据类型做了简单的归纳,其中用到的类型很简单。简单类型:整形、浮点型,复合类型:自负串、数组、slice、map、指针、结构体。只要这几个实现了,就可以进行组合,已经完全满足平时使用。
基于AST的实现
Go 的 runtime 中提供了解析 Go 代码所需的库,一开始在小规模用的时候我们觉得很舒服,go/passer 一分析,就可以拿到里面的结构体字段、类型。go/doc 可以分析注释中的特殊符号,比如通过注释的约定,去约定要不要生成结构体对应的代码。Go 的 template 包做代码生成,把代码生成逻辑和代码分析逻辑做解耦。Go generate 用于代码生成,原理比较简单,它负责把代码读进来,你的工具就可以取到代码做分析,把它转成新的产物。但是用 AST 的一个问题是,往后继续加工会发现,包引用关系不好分析。单份文件读的时候很方便,但是分析整个项目时,包引用关系的分析比较难。另外,类型转义不太好分析,持续增加功能的时候代码变得异常复杂。types 包也许可以帮忙,它起到一定的简化作用,但是毕竟我们项目做的比较匆忙。因此,这个东西就没有花太多时间继续深入。
基于反射的实现
后来,因为需要继续往上叠功能,所以我们改成用基于反射去实现,这样相对简单,因为类型信息提供得很详尽,代价是要注册类型,并且需要编译两次代码,这是反射的代价。反射虽然让工具变得更简单,但是需要付出这样的成本。关闭编译优化可以缓解一下。
优化 RPC
另外,我们实现了反序列化格式后,顺便又优化了 RPC。Go 原生的 RPC 序列化有一些限制性。Gob 支持 BinaryMarshaler 和 BinaryUnmarshaler 接口。生成对应方法,类型的序列化和反序列化就被接管了。基于原生 RPC 包,稍微封装一下就得到了符合项目需要的 RPC。一个 RPC 接口在一个代码中就可以看得一清二楚,不用再到别的地方看,这是我们目前做起来最舒服的一点。
实践总结
适合服务端只用 Go 以及客户端语言单一的项目。但是这个东西是有限制的,这就是格局问题。当你在做一个东西的时候,你的定位需要考虑很多人的需求,可能相应地在要在开发效益和使用体验、调用效率上做一些牺牲,来换取一些通用性。我们这种做法,由于项目的周期是比较短,不会与很多客户端配合做很长时间,所以适合服务端只用 Go 以及客户端语言单一的项目。但是如果要做多语言的协作,还是建议用行业标准的做法。多语言协作的项目推荐 Protobuf、JSON。
另外可视化也是一种选择。 DSL 的优化不只是把 DSL 干掉这一条路,譬如 markdown 虽然书写时,最终产物不直观,但是可以通过工具辅助达到比较好的编辑体验。
最后,基于我刚才所分享的内容,我想讲一些自己的感想。在做项目时,不同的公司愿景不一样,人员组成不一样,它所产生的架构差异会非常大。像刘奇老师的做法,它选择去和更多人配合,部署一个更大的格局,就需要去选择一些行业通用的标准,尽量避免去做一些特殊化的东西。
反过来,一个愿景很小、时间很短的项目,并且没有外部合作的预期,它产生出来的东西就是大量的专属化定制。因为人员问题、资金问题、周期要求问题,产生出来的东西开发效率会不一样。
ECUG 全称为 Effective Cloud User Group(实效云计算用户组),由七牛云 CEO 许式伟发起,集结了一批具有高端视角并仍醉心于技术本身的同仁,共同关注云计算前沿技术的最新成果和分布式开发、运维的最佳实践。
点击 「阅读原文」,了解 ECUG。