/slg-server

erlang 游戏服务器框架。

Primary LanguageErlang

0. 获取代码


首先建立你自己的git仓库,然后执行git pull origin git@42.121.105.8:slg-server.git master 获取最新的代码。

如果slg-server有改变或者bug修改,同样执行一次。

1. 初始化


获取代码: git clone git@42.121.105.8:slg-server.git

获取依赖关系: make deps

生成协议代码: make g

编译: make

初始化数据库: make db

启动: make s,默认用户名加密码为:root,密码空;如果不是,自行修改slg_server.erl

测试: 在新的终端执行make e,执行: gt:start().

2. 逻辑模块接口和规范


2.1 简介

本项目是为小服设计的slg游戏服务器框架,主要包含以下四个组件:

  • slg_proto:处理网络连接,数据的序列化,加密解密处理.
  • slg_csv:游戏中常常有gd配置文件,slg_csv提供配置文件到ets表的直接映射,便于编程访问.
  • slg_model:本框架使用ets在内存中cache玩家数据,slg_model提供ets和mysql的表映射,提供cache和自动写回等功能。
  • slg_support:提供一些系统和逻辑工具,比如spt_reloader:提供热更新,spt_notify:提供notification事件模式,等等。

(这四个组件也可以用于组合成分布式的单服).

每一个组件都对应于单独的项目,你可以从config/rebar.config看出,下面会对每一个模块的使用方法进行详细描述。

2.2 连接协议处理:slg_proto

slg_proto设计为处理游戏服务器中的tcp连接和协议打包解包,加密解密,玩家的基本进程运行在此模块.

2.2.1 协议规范

在目录proto/下定义项目需要的包和协议,需要遵守以下规范:

api.txt 定义了服务器中所有的包类型。

  • 每个协议的包名需要以:_req,_ack,_ntf,_cah结尾,如果违反make g时会报错.
  • 每个req包必须要有一对应的回复ack包,这两种包用作同步请求处理。
  • ntf包用作服务器主动向客户端push的包,比如聊天和一些消息通知,用作客户端临时使用。
  • cah包用作客户端cache,每一个cah包都会被客户端缓存,里面保存了特定模块的数据,比如:building_cah保存建筑数据,客户端会直接视cache包为其内部数据结构使用,而当某个模块数据发生变化时,服务器可以直接推送cache包,客户端直接覆盖更新.

proto.txt 定义了所有的包内容.

包内容的定义需要按找protocal.txt里的描述使用,有几点注意:

  • 普通类型全部以pt_为前缀。
  • 数据结构以db_为前缀,意为此db_数据结构与数据库中结构相对应,它的字段名字和类型都必须与服务器数据库中表定义一致,否则出错。
  • 每一个db_内容包的第一个字段都必须是id,给与其类型为pkid。

例子:登陆流程

玩家登陆时,客户端发送登陆_req,服务器验证成功后,先发送多个cache包,让客户端cache部分数据,之后发送登陆成功_ack。

error_code.txt 包含了游戏中可能出现的错误码,需要以下规范:

  • 10000以上为系统功能错误。
  • 10000以下为逻辑功能错误.
  • 对于一些简单错误,用player:code_ack回复。
  • 使用player:send给客户端发送各种包。

修改proto后执行make g && make可生成可编译新的协议处理版本。

2.1.2 回调处理

player.erl模块有以下几个回调函数:

quit/2: 进程退出,可以视为玩家退出登陆的事件点。 cast/2 处理异步消息,gen_server:cast即可。 info/2 处理info,gen_server。 call/3 处理call,gen_server。

2.3 gd配置文件处理

2.3.1 slg-csv启动

游戏中有大量的gd配置文件,gd采用ms-excel进行编辑后,服务器开发人员使用程序data/data.py脚本将其导出为csv文件,而slg_csv模块提供csv文件到ets表的直接映射。

slg_csv模块的使用大概如下:

slg_csv:root("data/") 指定了游戏中csv文件存放的路径. slg_csv:add 加入一个新的映射项。 slg_csv:load() 执行csv配置文件加载.

以上代码的执行必须在application:start(slg_csv),,在slg-server的start函数中已经集成了。

在头文件include/gd_record.hrl存放所有gd配置的映射数据结构.

data/路径下存放所有的csv文件。

本项目在slg_server:csv_config对slg-csv模块进行的初始化,你可以把它移动到任意一个你想它去的地方。

2.3.2 数据类型

本模块要求csv文件中的第一行(列名)必须和其对应的gd配置文件的record一致,比如:

gd_record.hrl: -record(gd_vip_exp, {level, exp}). data/xx.csv: INT_level;INT_exp

record里面不需要指定字段的类型,因为erlang是无类型的,但是csv文件里的第一行需要字典各列的类型,来帮助映射.

类型名与字段名通过下划线分割,比如:INT_level;INT_exp

在csv文件中不字段不区分大小写,现在支持以下类型:

  • int:数字,也是默认类型,也就是不制定类型则为int
  • str:字符串
  • term:erlang Term.

2.3.3 例子

slg_csv:add接受3个参数来添加映射:(ets表设定,record指定,文件指定):

例如: slg_csv:add({gd_vip_exp, [public, duplicate_bag, {keypos,2}]},?csv_record(gd_vip_exp), ["vip_exp.csv"])

{gd_vip_exp, [public, duplicate_bag, {keypos,2}]}:ets表名为:gd_vip_exp,属性:[public, duplicate_bag, {keypos,2}];也可以只是一个gd_vip_exp?csv_record(gd_vip_exp)其映射的record为gd_vip_exp。 ["vip_exp.csv"]对应的csv文件.

ets属性指定

默认ets属性为[public, set, named_table, {keypos, 2}], 你也可以自己制定类型属性,比如.

%% 指定`gd_vip_exp`为bag类型
add({gd_vip_exp, [bag]}, ?csv_record(gd_vip_exp), ["vip_exp.csv"]),

也就是每个ets表可以通过 atom | {atm, [attr|attr]} 指定。

多ets表指定

可以在add的第一个参数指定一个列表,已达到创建多个ets表的目的,但只有第一个ets表为csv文件的导入表,其余的用户自定义为辅助表或结果表.

add([gd_vip_exp, defined_but_not_use] , ?csv_record(gd_vip_exp), ["vip_exp.csv"]),

多csv文件

定义多csv文件将把多个csv文件导入一个相同的ets表:

add(gd_vip_exp, ?csv_record(gd_vip_exp), ["vip_exp.csv", "vip_exp2.csv"]),

注意重复的id会给出错误提示,你可以选择忽略。

inject函数

大部分时候csv表到ets是可以直接映射过去,但是不有的时候也需要手工处理一下再导入到ets,这时候可以使用inject函数.

inject函数是一个普通函数,它接受一个record为参数,返回另一个处理之后的record,只有slg_csv会把这个record导入到ets.

比如,你可以把你的inject函数定义到一个单独的erlang模块:

-module(csv_inject).
-export([compile_all]).
inject_vip_exp(Vip) ->
    io:format("vip ~p~n", [Vip]).

然后通过以下参数指定:

add(gd_vip_exp, ?csv_record(gd_vip_exp), [ {fun csv_inject/inject_vip_exp/1, "vip_exp.csv"}]),

inject函数还可以定义一个参数,但是函数原型为:

inject_vip_exp(Vip, P) ->
    io:format("vip ~p ~p~n", [Vip, P]),
    Vip.

add(gd_vip_exp, ?csv_record(gd_vip_exp), [ {fun csv_inject/inject_vip_exp/2, "vip_exp.csv", 23}]),

可以slg_csv下代码:csv_inject2.erl ok.

2.4 slg-model玩家数据处理.

程序使用ets表缓存玩家数据,查找数据时会先在ets表中进行,如果不存在则会在MySql中查询,同样,一些删除,更新,插入操作也在ets中直接执行,slg-model会实时的发送给异步持久化进程。

2.4.1 初始化方法

slg_server.erl的model_config函数对model层进行了初始化:

model:init_m(),
Dbc = #db_conf{username="root", password="", database="slg_server"},
model:add_m(users, record_info(fields, db_user), Dbc),
model:add_m(devices, record_info(fields, db_device), Dbc),
model:add_m(buildings, record_info(fields, db_building), Dbc),
model:gen_m(), %% 生成配置表

其中db_userdb_device都是在协议中定义的数据结构,而这个协议结构的字段和其类型必须和其对应的mysql表一一对应。

model:_add_m(表名,数据类型,连接配置).

2.4.2 数据操作

数据操作函数集中在data.erl模块,如下:

-export([lookup_s/2, lookup_a/2, lookup_i/2]).
-export([update_s/3, update_i/2, delete_i/3, delete_s/3, clear/1]).
-export([add_s/3, add_i/3, id/1]).

分别对应与增删查改。

约定:

_s: 为后缀的适合每个玩家只有一条的数据,比如玩家数据。 _a: 为后缀的适合每个玩家有多条的数据,比如建筑。

在player_account里有基本的例子。

2.4.3 按元素查找和更新.

data.erl模块中有lookup_s_e, lookup_i_e, update_i_e, update_s_e等四个函数,他们可以在模块被初始化之后使用,参看player_account:building_upl_req

2.5 slg-support 公共模块

本组件提供一些erlang编程或系统工具集合。

  • spt_reloader-热更新
  • spt_notify-事件注册/分发
  • spt_smerl-动态模块编程库

2.5.1 spt_reloader-热更新

热更新进程,来源于michi-web,可以对线上运行的代码进行热替换,但应该遵守以下面规则:

  • 新代码必须没有改变上下文数据结构,才能进行热部署.
  • 有一个以上模块被修改,模块被reload的顺序不可保证,因此,如果你的函数原型发生了变化,或者调用了新增的函数,热部署都很危险。
  • 对在单个模块代码bug的hotfix,非常适合直接reloader。

默认情况下spt_reloader启动,当beam代码发生变化时将会自动热更新。

2.5.2 spt_notify-事件注册/分发.

游戏中需要关注很多事件的发生,比如建筑升级事件,玩家打赢了一个boss事件,spt_notify提供事件的注册和发生接口,有以下3个api:

  • sub(Event, Fun):订阅
  • unSub(Event, Fun):取消订阅
  • post(Event, Param):事件发生.

post事件的第2个参数将会被原样传递给注册的函数,使用例子如下:

Fun1 = fun(X) ->  io:format("x1 ~p~n", [X]) end,
Fun2 = fun(X) ->  io:format("x2 ~p~n", [X]) end,
spt_notify:sub(e1,  Fun1),
spt_notify:sub(e1, Fun2),
spt_notify:post(e1, 23),
spt_notify:ubsub(e1, Fun1),
spt_notify:post(e1, 23),

2.5.3 spt_smerl-动态模块编程库

虽然erlang的动态编程能力不强(也或者是我学的很浅),但是smerl这个模块用来做动态模块扩展是比较合适的,它来源于erlyweb项目,已经稳定了很多年。

以下情况适合使用smerl动态产生模块:

  • 大量的重复编程模块:比如slg_model里的表model,基本结构都一样(select, update, delete),只有一点参数的不同而已。
  • 环境参数:有的环境参数,如果放在ets表又太慢了,放在固定的模块在每次修改时又需要编译,所以我倾向于动态产生一个模块,然后从模块函数里直接获取配置参数。

使用方法如下,来源于源码注释:

test_smerl() ->
M1 = spt_smerl:new(foo),
{ok, M2} = spt_smerl:add_func(M1, "bar() -> 1 + 1."),
spt_smerl:compile(M2),
foo:bar(),   % returns 2``
spt_smerl:has_func(M2, bar, 0). % returns true

3 slg-proto连接处理和协议设计


slg-proto提供了连接处理和协议打包,加密三个功能;

3.1 连接处理

因为erlang使用了轻量级进程,所以连接处理代码比较简单,主要分为以下三个部分:

  • conn_acceptor:监听某端口,在连接建立起时使用conn.erl开启一个新进程,并设置这个进程和新建的socket关联。
  • conn.erl:连接通信实体进程,通过handle_info({tcp, _Socket, Bin}, State)匹配处理socket发来的数据。
  • conn_super.erl:conn进程的监督者,用来启动conn而已。

本项目使用tcp长连接,主要从hotwheels移植过来,hotwheels是erlang中tcp连接处理的典范。

如果你使用过其他语言如C/C++,连接处理就不需要自己实现epoll或select之类的io多路复用,这些在erlang底层都帮你处理了,所以使用erlang编写这部分代码非常简单易懂。

有了传输层代码,下面介绍协议层,即传输数据包设计.

3.2 包设计

每一个数据包都由三部分组成: 包长度(2字节)+包类型(2字节)+包数据(剩余字节)

  • 每个正常包的前两个字节都是包长,也就是你必须先收两个字节,确定之后的包的长度,等待完整包收完后再处理。
  • 包类型为2个字节,最多有65535个不同的包,完全够用了。
  • 包的剩余部分是包内容,是被序列化为2进制的数据结构,有很多同类型的产品(google protobuff ,apache thrift),但是slg_proto足够简单,足够适合游戏。

erlang语言本身在建立socket的时候可以指定参数:

inet:setopts(Socket, [{active, once}, {packet, 2}, binary]),

3.2.1 包长度

其中{packet, 2}制定后,对于所有的gen_tcp:send操作,erlang将自动在send的内容之间加上2个字节的内容长度,也就是协议中包长度部分。

3.2.2 包类型

一个完整的包收好后,将通过包类型来决定如何处理,包类型的描述我定义在了proto/api.txt文件中,每个包有以下5个属性需要配置:

  • packet_type:包类型,必须是一个唯一的正数,10000以上为系统功能错误码,10000以下为逻辑错误码
  • name:包名字,毕竟数字在代码里不是很友好
  • payload:包内容,这说明包类型确定后,包内容也确定了
  • desc:一句注释而已.
  • module:请求处理模块,只对以_req为后缀的包名字的包有用,可以直接映射到处理模块。

3.2.3 包内容

数据包的内容我称其为payload,他们全部在proto/protocal.txt中被定义,包内容定义比较复杂,以下是基础类型,你可以通过基础类型组合成自定义类型:

  • integer:数字类型
  • float:浮点类型
  • string:字符串类型,实际上处理为erlang的binary类型
  • boolean:布尔类型,只占1个字节
  • short:端整型
  • pkid:主键类型,大整数,erlang层是数字,因为erlang支持大数,但是序列化为字符串.

有了基本类型,你可以自定义一个用户类型:

pt_user=
name string
sex boolean
===

非常简单,你可以定义一个账号类型,它嵌套了用户类型:

pt_account=
user pt_user
money integer
===

很多情况下我们需要数组,你可以这样定义一个含有数组的类型:

pt_test=
ids array integer
users array pt_user
===

3.3 错误处理

游戏中有很多错误码,什么金钱不足,等级不足之类的,建议在proto/error_code.txt中列出你所有的错误码,大概如下:

10000-ok-成功
10001-inner_error-内部错误
10002-bad_param-参数错误

在api.txt中定义了code_ack作为统一的错误接口,90%的错误提示可以使用该包完成,在代码中你使用起来像这样:

conn:code_ack(timeout)

4 slg-model数据存储设计


slg_model旨在提供这样一个模块:

游戏服务器中,用户登陆的时候会把玩家的数据从数据库总load到内存中,之后就在内存中对玩家数据进行操作,定时写回到数据库进行持久化(这里我选用了MySql数据,在游戏行业用的比mongodb稳定广泛),而这一套机制,可以作为服务框架的一部分存在,不需要框架的使用者编程,只需要他们按照相应的接口编写逻辑即可。

slg_model提供了以下三个重要的功能:

  • SQL操作:对底层的直接SQL执行封装了比较友好的上层操作,类似于active_record,但是没有那么强大,也没有表表关联,但是游戏服务器也不需要表表关联或者复杂的查询。
  • ets缓存:将用户数的数据暂存在ets表中,通过一定的机制将不活跃的内存清除掉,用户在使用时不需要知道ets和MySql的存在,比如查找数据,系统先会在ets表中查找,如果不存在则cache不命中,再从MySql中查找。
  • 自动回写和同步:在内存中插入或者修改删除了数据,系统会自动将其同步到MySql,同步时间间隔大概为1分钟,对于每个表,都开了两个erlang-mysql-driver连接池,一个读(3worker),一个写(1worker).
  • migrate:在游戏开发的过程中,常常会对表结构进行修改,比较土的方法是删除重建整个数据库,而使用migrate对表格进行修改会比较方便,完全仿照Ruby On Rails的migrate做,但是不强大,migrate函数中只能执行SQL,没有封装上层操作。

4.1 ets表

4.1.1 为什么不使用mnesia

mnesia是erlang提供的分布式数据库,功能比较强大,但它的功能对于我设计游戏服务器并不是有用,暂时没有需要使用mnesia的理由,如果设计单服的游戏服务器,并且要防止单点故障,那么可能用mnesia比较合适。

再次说明本模块主要为小服的游戏服务器设计的。

4.1.2 为什么不使用进程字典

国内有些页游开发团队使用进程字典存储玩家数据,即玩家登陆后将其数据加载到进程字典,然后就在进程字典中操作了,并且按时写回MySql,这样主要的好处是:进程字典操作比较快,纳秒级别;ets操作比较慢,微秒级别。

主要的坏处是:

  • 必须使用防御式编程:进程字典数据是随着进程的退出而消失的,所以你的进程将不能再动不动就crash了,于是你会编写大量的防御式编程代码,将erlang本身优雅的函数式编写的奇丑无比;而使用ets,玩家进程不在了ets表数据也不会丢失,可以随便崩。
  • 完全写不了单元测试:理论上函数式编程是最好写单元测试,但是你现在每个函数都跟进程上下文相关了,如果要写的话每个单元测试函数你都需要建立起一个新进程,我感觉是没法写,丑的很。

ets也不是慢,微秒级别,对于小服,拖个几千人就ok了,完全够用,所以我采用ets,也推荐你采用。

进程字典只适合存储临时数据,那种丢了就丢了,或者session相关的,不需要造成你防御式编程的,其它场景慎用

4.1.3 以表组织数据的好处

按表组织玩家数据有以下好处:

灵活访问非在线玩家数据 当采用进程字典存储玩家数据时,任何对其它玩家数据的访问都需要通过进程通信获得数据,比如好友系统,只需要获取这个好友的名字和账号信息,这时需要建立起这个玩家的进程,然后走消息通信来获得数据,而使用ets表组织,你只需要拿user_id索引其基本信息表即可,非常优雅。

手机游戏的异步性 手机游戏对非在线玩家的数据访问非常频发,因为玩手机游戏的的时间是零碎的,需要灵活的操作非在线玩家数据。

4.2 实时持久化

在ge2里,数据大概是每10触发一次同步,但是在slg_server里采用了实时同步,这样的好处是玩家数据更稳定,更不容易丢失,坏处是MySql压力更大,但是slg_server面对的是小服,一个服务器人数是非常有限的(几W),同时在线人数(6K),负载压力本身不会很大,而且持久化过程是异步的,不影响玩家体验。

设计以下几个模块:

model_sql.erl: 提供SQL拼接功能,如果更复杂的SQL操作应丰富这个模块。 model_exec.erl: 执行SQL,使用erlang-mysql-driver实现。 model.erl: 提供model操作的统一接口

对每一张表都有一个单独的进程来进行会写,data_writer_sup.erldata_writer

4.3 清除不活跃数据

slg_model中使用data_clear清除不活跃数据,使用时间超过6小时,并且当前没有人使用的数据被定义为不活跃数据,此部分数据将以玩家为单位执行清除。

每个表有单独的清除进程,代码集中在:data_clear_sup.erldata_clear.erl

4.3.1 ets的原子性

ets表不是数据库,是对其基本操作加了读写锁的内存表,所以下面的代码会有很大问题:

R = read_ets(Id)          # 1
if
	R == ok ->
		set_ets(Id, no)   # 2
		do_something      # 3
	no -> do_nothing
end

#1#2之间没有数据库的隔离性,所以同时会有多个进程可能进入代码#3,引发逻辑错误,比如清除玩家数据就是这样一个场景。

data_clear会选择不活跃且没人使用的玩家数据来清除,这其中会破坏ets表的结构,而如果同时又有用户登陆并对其进行操作,就会有逻辑错误。

4.3.2 data_guard

data_guard.erl用来解决ets表的数据访问冲突,原理是模拟读写锁,当有玩家使用时获取读锁,而清除进程获取写锁。写锁必须要没有读数或写锁的情况下才能获取成功,读数必须要没有写锁的情况下获取成功。

  • 玩家登陆时将获取其数据的读guard,直至进程退出后释放。
  • 当进程访问其他玩家数据,也会获取读数,直至进程退出后释放。
  • clear进程必须要获取到写锁才能清除数据。

4.4 SQL拼接

见代码模块model_sql.erl