Skynet项目-球球作战实例
一、拓扑结构
如图3-3,其中圆圈代表服务,圈内文字代表服务类型和编号,比如:”gateway1“代表”gateway“类型的1号服务。
该拓扑结构支持横向拓展(增加物理机)
1.1 各服务功能
服务 | 说明 |
---|---|
gateway | 即网关,用于处理客户端连接的服务。客户端会知道所有网关地址(选服列表)。选择连接某个网关(gateway),如果玩家尚未登录,网关会把消息转发给节点内某个login服务,以处理账号校验等操作;如果登陆成功,则会把消息转发给客户端对应的agent服务。一个节点可以开启多个网关以分摊性能 |
login | 即登录服务,用于处理登录逻辑的服务,比如账号校验。一个节点可以开启多个登录服务以分摊性能 |
agent | 即代理服务,每个客户端会对应一个代理服务(agent),负责对应角色的数据加载、数据存储、单服逻辑的处理(比如强化装备、成就等)。出于性能考虑,agent必须与它对应的客户端连接(即客户端连接的gateway)处在同一个节点 |
agentmgr | 即管理代理(agent)的服务,它会记录每个agent所在的节点,避免不同的客户端登录同一账号 |
nodemgr | 即节点管理,每隔节点会开启一个nodemgr服务,用于管理该节点(新建agent服务)和监控性能 |
scene | 即场景服务,处理战斗逻辑的服务,每一局游戏由一个场景服务器负责 |
1.2 消息流程
登录过程:
①:客户端连接某个gateway,然后发送登录协议
②:gateway将登陆协议转发给login
③:如果login校验通过,转发给agentmgr校验
④:agentmgr发现玩家已在线,通知另一客户端agent踢下线
⑤:另一客户端的agent通知gateway和客户端断开socket连接
⑥:agentmgr通知nodemgr新建一个agent服务
⑦:新建一个agent服务
⑧:新建的agent通知login,login再通知gateway告诉玩家登陆成功
游戏过程:
⑨:客户端发消息给gateway,gateway直接转发给对应的agent
1.3 设计要点
1.gateway
这套服务端系统采用传统C++服务器架构方案。gateway只做消息转发,启用gateway服务有以下的好处:
- 隔离客户端和服务端系统。如果要更改客户端协议(比如改用json协议或者protobuf),仅需更改gateway,不会对系统内部产生影响。
- 预留了断线重连功能,如果客户端断线,仅影响gateway,不影响agent
然而引入gateway意味着客户端消息需经过一层转发,会带来一定的延迟。将同一个客户端连接的gateway、login、agent置于同一节点,有助于减少延迟。
2.agent和scene的关系
agent可以和任意一个scene通信,但跨节点通信的开销比较大。一个节点可以支撑数千名玩家,足以支撑各种段位的匹配,玩家应尽可能地进入同一节点的战斗场景服务器(scene)。
3.agentmgr
agentmgr仅记录agent的状态、处理玩家登录、登出功能,所有对它的访问都以玩家id为索引。它是个单点,但很容易拓展成分布式。
二、目录结构
2.1 项目根目录
- etc:存放服务配置的文件夹
- example:测试用例
- luaclib:存放一些C模块(.so文件)
- luaclib_src:存放C模块的源代码(.c、.h)
- lualib:存放Lua模块
- service:存放各服务的Lua代码
- skynet:skynet框架,我们不会改动skynet的任何内容。如果后续skynet有更新,直接替换该文件夹即可
- proto:存放通信协议文件(.proto)
- storage:存放数据库协议文件(.proto)
- tools:存放工具文件
- start.sh:启动服务的脚本(本质就是./skynet [配置])
2.2 service目录
- admin:类似skynet的debug_console编写的一个”管理控制台“服务,服务器管理者可以通过telnet登入控制台,然后输入指令。如”stop“妥善关闭服务器,把玩家全部踢下线。如”mail“给在线玩家发邮件等。
- agent:agent服务
- agentmgr:agentmgr服务
- gateway:gateway服务
- login:login服务
- nodemgr:nodemgr服务
- scene:scene服务
- main.lua:main服务是节点启动后第一个被加载的服务,用于启动其他各个服务
2.3 lualib目录
- protobuf.lua:protobuf用到的Lua模块代码
- service.lua:是agent、agentmgr、gateway、login、nodemgr、scene服务的父类。这些服务都继承自service。service里封装了一些skynet的API和一些自有的属性,方便子服务的创建、通信、辨别不同服务类型等,减少代码编写。
- register.lua:提供一个注册类,用于把模块方法注册进里面,然后通过register模块API取出模块里函数,实现代码简洁,不用再多处维护不同的函数列表
- extension目录:该目录下存放扩展lua标准库方法的文件。例如./extension/table.lua文件,存放扩展标准库table的函数:如PrintTable()打印表的内容、update()更新表内容,然后把自己实现的方法注册到标准库table表里即可,即table.PrintTable = PrintTable、table.update = update。
2.4 luaclib_src目录
- lua-cjson:cjson的源代码,用于json和lua之间的转换。
- pbc:pbc的源代码,用于protobuf。
lua-cjson下载与编译:
cd luaclib_src #进入luaclib_src目录
git clone https://github.com/mpx/lua-cjson #下载第三方库lua-cjson的源码
cd lua-cjson #进入lua-cjson源码目录
make #编译,成功后会多出名为cjson.so的文件
cp cjson.so ../../luaclib #将cjson.so复制到存放C模块的luaclib目录中
pbc使用protoc 2.5.0参与编译,下载安装protoc 2.5.0
cd ~
wget https://github.com/protocolbuffers/protobuf/archive/refs/tags/v2.5.0.zip
unzip v2.5.0.zip
cd protobuf-2.5.0/
./autogen.sh
./configure
make
make install
pbc下载与编译:
cd luaclib_src #进入项目工程luaclib_src目录
git clone https://github.com/cloudwu/pbc #下载第三方库pbc的源码
cd pbc #进入pbc源码目录
make #编译pbc
cd pbc/binding/lua53 #进入pbc的binding目录,它包含skynet'可用的C库源码
make #工具编译。成功后会在同目录下生成 库文件protobuf.so 和 Lua模块protobuf.lua
cp protobuf.so ../../../../luaclib/ #将protobuf.so复制到存放C模块的luaclib目录中
cp protobuf.lua ../../../../lualib/ #将protobuf.lua复制到存放Lua模块的lualib目录中
注意:编译pbc、pbc工具和cjson时,用的Lua版本和Skynet/3rd目录下的Lua版本要一致。目前新版的skynet支持了lua5.4.2,因此保证lua -v
版本要和skynet支持的lua版本一致。否则会导致protobuf.so和cjson.so使用时会报错:
报错内容:如上图所示,
error loading module ‘protobuf.c’ from file ‘./luaclib/protobuf.so’
原因:编译时未 link 5.4 的 .h 的缘故。lua_newuserdata 在 5.4 是以宏的方式提供。
2.5 luaclib目录
- cjson:cjson用到的C模块动态库
- protobuf.so:protobuf用到的C模块动态库
2.6 proto目录
- login.proto:描述文件。使用protobuf的第一步是编写描述文件。
- login.pb:根据proto描述文件生成的二进制文件。
protobuf使用过程步骤:
- 编写proto文件,如:
package login;
message Login {
required int32 id = 1;
required string pw = 2;
optional int32 result = 3;
}
- 编译proto文件:
cd proto #进入proto目录
protoc --descriptor_set_out login.pb login.proto
- 使用pbc模块API编码解码:
local skynet = require "skynet"
local pb = require "protobuf"
--protobuf编码解码
function test()
pb.register_file("../proto/login.pb") --注册编译文件(.pb文件)
--编码
local msg = {
id = 101,
pw = "123456",
}
local buff = pb.encode("login.Login", msg) --参数1:协议名,由proto描述文件的包名和协议名组成。参数2:协议对象。返回值:二进制数据
print("len:" .. string.len(buff))
--解码
local umsg = pb.decode("login.Login", buff)--参数1:协议名,由proto描述文件的包名和协议名组成。参数2:二进制数据。返回值:失败返回nil,成功返回协议对象。
if umsg then
print("id:"..umsg.id)
print("pw:"..umsg.pw)
else
print("error")
end
end
skynet.start(function ()
test()
end)
2.7 storage目录
- playerdata.proto:描述文件。
- playerdata.pb:根据proto描述文件生成的二进制文件
传统数据库:难以应付版本迭代
一个表有playerid(作为索引)、name、coin、level、last_login_time等几个栏位。
如果有策划需要增加skin栏位, 可能拓展数据库导致十几小时停服,而且,策划要求玩家上线后赠送id为1的皮肤,代码会如下:
function test()
local playerdata = {}
local res = db:query("select * from player where playerid = 105")
if res[1].skin then
playerdata.skin = res[1].skin
else
playerdata.skin = 1
end
end
经历多次版本迭代后,这些“判断历史数据的代码”会变得冗长而混乱,后期接手项目的同事,他们没有经历过前期版本迭代,很难理解这些代码的用意,很难做维护。
key-value表结构:
将玩家数据序列化,数据库仅存储序列化后的二进制数据。它类似于“Key-Value”(键值对)数据库,以玩家id为键,以序列化数据为值,其中的playerid代表玩家id,用作索引,data存储序列化后的数据。
使用Key-Value数据表,可以构造稳定的数据库结构,还能兼顾NoSQL,让服务端系统拥有无缝切换MySQL和MongoDB这两种数据库的潜力。
数据存储过程实例:
playerdata.proto
package playerdata;
message BaseInfo {
required int32 playerid = 1;
required int32 coin = 2;
required string name = 3;
required int32 level = 4;
required int32 last_login_time = 5;
required int32 skin = 6 [default = 10];
}
message Bag {}
message Task {}
message friend {}
message mail {}
message achieve {}
message title {}
storage_test.lua
local skynet = require "skynet"
local mysql = require "skynet.db.mysql"
local pb = require "protobuf"
local db = nil
--创角
function test()
--注册编译文件(.pb文件)
pb.register_file("../storage/playerdata.pb")
--创角(按照功能模块划分的玩家数据)
--playerdata表里每个key对应一个表,如baseinfo对应baseinfo表、bag对应bag表
--拆分数据表的原因是查询表需要加载,数据越大加载的时间越长,因此做数据表拆分
local playerdata = {
baseinfo = {
playerid = 109,
coin = 97,
name = "Tiny",
level = 3,
last_login_time = os.time(),
}, --基本信息
bag = {}, --背包
task = {}, --任务
friend = {}, --朋友
mail = {}, --邮件
achieve = {}, --成就
title = {}, --称号
}
--序列化
local data = pb.encode("playerdata.BaseInfo", playerdata.baseinfo)
print("data len:" .. string.len(data))
--存入数据库(这里仅示例存入baseinfo表,其他如bag、mail等表的存储略)
local sql = string.format("insert into baseinfo(playerid, data) values (%d, %s)", 109, mysql.quote_sql_str(data)) --由于变量data是二进制数据,因此,拼接成SQL语句时,需用mysql.quote_sql_str做转换。
local res = db:query(sql)
--查看存储结果
if res.err then
print("error:" .. res.err)
else
print("ok")
end
end
--读取角色数据
function test2()
pb.register_file("../storage/playerdata.pb")
--读取数据库(忽略读取失败的情况)
local sql = string.format("select * from baseinfo where playerid = 109")
local res = db:query(sql)
--反序列化
local data = res[1].data
print("data len:" .. string.len(data))
local udata = pb.decode("playerdata.BaseInfo", data)
if not udata then
print("error")
return false
end
--输出
local playerdata = {}
playerdata.baseinfo = udata
print("coin:" .. playerdata.baseinfo.coin)
print("name:" .. playerdata.baseinfo.name)
print("time:" .. playerdata.baseinfo.last_login_time)
print("skin:" .. playerdata.baseinfo.skin)
end
skynet.start(function ()
--连接数据库
db = mysql.connect({
host="192.168.184.130",
port=3306,
database="message_board",
user="root",
password="123456",
max_packet_size=1024*1024,
on_connect=nil
})
test()
test2()
end)
2.8 tools目录
- genProtold.py:python文件,给run_on_chang.sh使用。作用是生成message_define.bytes文件,这个文件是用于和客户端的通信协议id找协议名对应关系表
- run_on_change.sh:当写好新的和客户端通信的.proto文件后,使用该shell脚本,会把./proto目录下所有的.proto文件集中生成为一个.pb文件(all.bytes),和生成一个协议id和协议名对照的文件message_define.bytes,方便pbc模块的api解析协议内容。
run_on_chang.sh:
#!/bin/sh
cd ../proto #进入./proto目录
protoc -o../service/proto/all.bytes *.proto #把./proto目录下所有.proto文件生成为一个.pb文件(all.bytes)
cd .. #回到项目根目录
python tools/genProtoId.py --output=./service/proto/message_define.bytes ./proto/*.proto #执行python文件genProtoId.py,生成协议id和协议名对应的文件message_define.bytes
2.9 etc目录
- runconfig.lua:服务配置,提供服务所需的一些参数
- config.node1:节点1的启动配置,供./skynet用的配置
- config.node2:节点2的启动配置,供./skynet用的配置
三、启动流程
sh start.sh [num]
start.sh
./skynet/skynet ./etc/config.node$1
例如:在项目根目录,输入命令sh start.sh 1
。意思是:载入./etc/config.node1
的配置给./skynet/skynet
程序使用。
config.node1
--必须配置
thread = 8 --启用多少个工作线程
cpath = "./skynet/cservice/?.so" --用c编写的服务模块位置
bootstrap = "snlua bootstrap" --启动的第一个服务
--bootstrap配置
start = "main" --主服务入口
harbor = 0 --不使用主从节点模式
--lua配置项
lualoader = "./skynet/lualib/loader.lua"
luaservice = "./service/?.lua;" .. "./service/?/init.lua;" .. "./skynet/service/?.lua;"
lua_path = "./etc/?.lua;" .. "./lualib/?.lua;" .. "./skynet/lualib/?.lua;" .. "./skynet/lualib/?/init.lua"
lua_cpath = "./luaclib/?.so;" .. "./skynet/luaclib/?.so"
--后台模式
--daemon = "./skynet.pid"
--logger = "./userlog"
--节点
node = "node1"
config.node1配置解析:
- /skynet/lualib/loader.lua这个loader加载Lua服务时,会到
"luaservice= "
配置配好的地址去查找。 - 在lua文件中require时,会到
"lua_path ="
和"lua_cpath ="
配好的地址查找对应的Lua模块和C模块
- 然后skynet会启动bootstrap等一系列skynet启动的服务。最后启动如上配置(
./etc/config.node1
)start指定的的main服务(./service/main.lua)。这个main.lua是我们自己写的服务程序入口。
runconfig.lua
return {
--集群
cluster = {
node1 = "127.0.0.1:7771",
node2 = "127.0.0.1:7772",
},
--agentmgr
agentmgr = { node = "node1" },
--scene
scene = {
node1 = {1001, 1002},
--node2 = {1003},
},
--节点1
node1 = {
gateway = {
[1] = {port=8001},
[2] = {port=8002},
},
login = {
[1] = {},
[2] = {},
},
},
--节点2
node2 = {
gateway = {
[1] = {port=8011},
[2] = {port=8022},
},
login = {
[1] = {},
[2] = {},
},
},
}
main.lua
local skynet = require "skynet"
local skynet_manager = require "skynet.manager"
local cluster = require "skynet.cluster"
local runconfig = require "runconfig"
skynet.start(function ()
--初始化
local mynode = skynet.getenv("node")
local nodecfg = runconfig[mynode]
--nodemgr
local nodemgr = skynet.newservice("nodemgr", "nodemgr", 0)
skynet.name("nodemgr", nodemgr)
--集群
cluster.reload(runconfig.cluster)
cluster.open(mynode)
--gate
for i,v in pairs(nodecfg.gateway or {}) do
local srv = skynet.newservice("gateway", "gateway", i)
skynet.name("gateway"..i, srv)
end
--login
for i,v in pairs(nodecfg.login or {}) do
local srv = skynet.newservice("login", "login", i)
skynet.name("login"..i, srv)
end
--agentmgr
local anode = runconfig.agentmgr.node
if mynode == anode then
local srv = skynet.newservice("agentmgr", "agentmgr", 0)
skynet.name("agentmgr", srv)
else
local proxy = cluster.proxy(anode, "agentmgr")
skynet.name("agentmgr", proxy)
end
--scene
for _, sid in pairs(runconfig.scene[mynode] or {}) do --sid->sceneid
local srv = skynet.newservice("scene", "scene", sid)
skynet.name("scene"..sid, srv)
end
--admin
local admin = skynet.newservice("admin", "admin", 0)
skynet.name("admin", admin)
--退出自身
skynet.exit()
end)
如上代码,main服务的流程:
- reload集群,打开自己的集群接口(cluster.open(mynode))
- 创建一个nodemgr服务
- 根据runconfig配置信息,创建多个gate服务
- 根据runconfig配置信息,创建多个login服务
- 根据runconfig配置信息,创建一个agentmgr服务
- 根据runconfig配置信息,创建多个scene服务
- 创建一个admin服务
- 退出自身,即关闭main服务
最后,skynet进程除了有bootstrap等skynet启动的服务外,还有我们写的服务:多个gate服务、多个login服务、一个nodemgr服务、一个agentmgr服务、多个scene服务。客户端登陆成功后,每个客户端还会对应生成一个agent服务。
注意:
skynet创建服务(newservice)其实调用了底层C代码创建了一个服务类对象(struct),并且这个服务类对象拥有一个Lua虚拟机(luaState,其实就是一个C-union结构体)。
因此,nodemgr服务、gate服务、login服务、agentmgr服务、scene服务、agent服务等,每个服务都有自己的Lua虚拟机(luaState,其实就是一个C-union结构体),也就是说,每个服务都有自己的全局表,Upvalue表、常量表等。因此每个服务之间的Lua代码是独立的,生成的对象也是独立的。
举例:
gateway.lua
local s = require "service"
s.hehe = 1
agent.lua
local s = require "service"
如上代码,两个文件在调用require后,"service"文件的内容被装在了全局表的一个地址里,如_G[0x7f29fe7d7e00]
,返回值s是全局表的那个对应的地址(即0x7f29fe7d7e00)。
因为gateway和agent是不同的服务对象(即不同的struct),因此,他们有自己的Lua虚拟机,这两个文件内的全局表是各自维护的,互相不能访问。所以agent里的s索引不到gateway的”hehe“。
就算开启了两个gateway服务,这两个服务也是不同的服务对象(即不同的struct),因此,他们也有各自的Lua虚拟机,各自维护自己的全局表、Upvalue表、常量表等(注:全局表、Upvalue表、常量表都是Lua的概念,可以自行去看Lua书籍熟悉相关概念)。
四、项目地址
github:
https://github.com/hhhhhhh12123/BoxGameServer
gitee:
https://gitee.com/smallppppig/boxgame