游戏服务架构(五)
上一个篇章编写简单的数值游戏, 已经集合了 游戏玩法-数据保存-协议传输 所有功能, 对于这种 自己玩自己 的游戏相对来说比较简单,
直接不推送消息做 JSON 数据同步即可.
H5小程序的游戏基本集中于这种
但是可以如果想要扩展游戏本体, 可以看到目前功能缺少大量高级特性:
- 没有主动推送机制: 服务端无法发起推送消息功能 -> 需要考虑转化为 TCP/WebSocket 长链接
- 传递消息体过大: HTTP协议头+内容体导致消息庞大 -> 自定义传输解析
- JSON无意义内容太多: 充斥大量
" 和 {}符号冗余符号 -> 采用二进制流转化的序列化方案 - 短链接请求频繁: 短链接频繁来不及释放带来 504 请求错误 -> 走长连接访问
- 直接数据库IO拥堵: 可以看到访问数据操作是同步写入过高请求直接IO升高 -> 利用变成语言多核异步数据入库
注意: 如果没有经验最好采用
skynet方案来搭建, 因为社区方案十分成熟还内嵌有Lua来热更新服务.
这里方案考虑采用 TCP + GoogleProtobuf 来做搭建, 这里需要注意的关键点:
skynet: 主要的网络传输框架, 业务集中于lua语言挂载, 但请注意只有Linux/Unix平台.protobuf: 谷歌的序列化传输方案, 压缩的数据是常规的JSON的 4 倍.
这里直接导出 github 的 skynet 版本来手动编译:
# 先到指定编译目录
cd /data
# 导出项目, 国内如果导出时候超时可以考虑用镜像加速
git clone https://github.com/cloudwu/skynet.git
cd skynet
# 安装系统组件
sudo apt install make gcc autoconf
# 编译二进制, 这里过程会导出 jemalloc 加入编译, 过程可能比较缓慢
make 'linux' # PLATFORM can be linux, macosx, freebsd now
# 编译完成会在目录下生成二进制文件
./skynet
注意最好先看完 skynet 的使用参考, 项目下的 examples 可以作为参考样例.
构建服务
这里首先需要处理下 echo 服务, 先保证网络服务调通:
# 这里隔离出新的项目源代码目录
mkdir /data/game
cd /data/game
# 编写首个启动配置
touch config.path.lua # 目录配置
touch config.main.lua # 启动主要配置
touch main.lua # 启动脚本入口
首先是目录加载配置( config.path.lua ):
--- 核心路径
root = "../skynet/"
app = "./"
--- 系统关键加载
luaservice = root .. "service/?.lua;" .. app .. "?.lua"
lualoader = root .. "lualib/loader.lua"
lua_path = root .. "lualib/?.lua;" .. root .. "lualib/?/init.lua;" .. app .. "lualib/?.lua;" .. app .. "lualib/?/init.lua;"
lua_cpath = root .. "luaclib/?.so"
snax = app .. "examples/?.lua;" .. app .. "test/?.lua"
-- snax_interface_g = "snax_g"
cpath = root .. "cservice/?.so"
之后就是启动文件( config.main.lua ):
include "config.path.lua" -- 加载路径变量
preload = root .. "examples/preload.lua" -- run preload.lua before every lua service run
thread = 4 -- 线程数量
logger = nil
logpath = root .. "log"
harbor = 1
address = "127.0.0.1:68081"
master = "127.0.0.1:68080" -- 主要的监听节点地址
start = "main" -- 调用脚本入口: main.lua
bootstrap = "snlua bootstrap" -- The service for bootstrap
standalone = "0.0.0.0:68080" -- 监听服务端口
-- daemon = "./skynet.pid"
这里需要说明下具体配置项, 保证配置启动成功:
luaservice(核心): Lua加载的服务功能代码, 内建skyent所需日常服务(日志/命令行/网关服务)lualoader(核心): 加载启动skynet需要加载模块和服务清单, 默认lualib/loader.lua文件lua_path(核心): 关于调用 C 底层的编写Lua模块lua_cpath(核心): 关于调用 C 底层的核心模块关联Lua动态库模块cpath(核心): 底层 C 模块动态库preload: 加载lua服务代码前会先去启动加载脚本snax: 用snax框架编写的服务的查找路径bootstrap: 启动的首个服务以及其启动参数. 默认配置为snlua bootstrap, 即启动名为bootstrap的lua服务. 通常指的是service/bootstrap.lua这段代码standalone(集群相关): 集群模式, 如果把这个skynet进程作为主进程启动, 那么需要配置standalone这项表示这个进程是主节点, 它需要开启一个控制中心, 监听一个端口让其它节点接入master(集群相关): 指定连接skynet集群控制中心的地址和端口, 如果你配置了standalone则这项通常和standalone相同address(集群相关): 当前监听skynet节点的地址和端口, 方便其它节点和它组网. (注: 即使你只使用一个节点, 也需要开启控制中心, 并额外配置这个节点的地址和端口)harbor(集群相关): 可以是1-255间的任意整数. 一个skynet网络最多支持 255 个节点. 每个节点有必须有一个唯一的编号. (注: 如果 harbor 为 0, skynet 工作在单节点模式下. 此时 master 和 address 以及 standalone 都不必设置)cluster(集群相关): 决定了集群配置文件的路径start:bootstrap最后一个环节将启动的 lua 服务, 也就是自己业务的skynet节点的主程序. 默认为main, 即启动main.lua这个脚本.thread: 指定以多少线程来启动服务.
之后编写入口文件处理( main.lua ):
local skynet = require "skynet"
skynet.start(function()
-- 编写测试
print("Hello.World")
skynet.exit()
end)
之后就跑下功能查看打印:
# 二进制调用服务
../skynet/skynet config.main.lua
确认能够打印 Hello.World 字样就代表启动成功, 这时候就可以测试挂载网关服务.
网关服务
常规游戏服务需要含有主要脚本:
main.lua: 启动服务脚本agent.lua: 请求路由脚本watchdog.lua: 看门狗服务
目前 skynet 已经集成 gate.lua, 所以直接调用服务即可.
skynet/example 的 watchdog.lua 和 agent.lua 就是主要集成服务脚本
这里直接拷贝 watchdog.lua 之后修改 agent.lua 即可:
# 拷贝 watchdog 即可
cp ../skynet/examples/watchdog.lua .
# 之后就是修改下 agent
vim agent.lua
先处理下 agent.lua 编写简单功能:
local skynet = require "skynet"
local socket = require "skynet.socket"
-- 默认请求配置
local WATCHDOG
local host
local send_request
-- 注册命令配置
local CMD = {}
local REQUEST = {}
local client_fd
-- 启动访问对象时候初始化方法
function CMD.start(conf)
local fd = conf.client
local gate = conf.gate
WATCHDOG = conf.watchdog
-- 这里的协议后续需要替换成 google protobuf
--host = sprotoloader.load(1):host "package"
-- send_request = host:attach(sprotoloader.load(2))
-- 启动另外携程不断推送心跳包
skynet.fork(function()
while true do
socket.write(client_fd, "heartbeat")
--send_package(send_request "heartbeat")
skynet.sleep(500) -- 心跳包推送
end
end)
client_fd = fd
skynet.call(gate, "lua", "forward", fd)
end
-- 客户端断开的时候调用方法
function CMD.disconnect()
-- todo: do something before exit
skynet.exit()
end
-- 注册服务
skynet.start(function()
skynet.dispatch("lua", function(_, _, command, ...)
skynet.trace()
local f = CMD[command]
skynet.ret(skynet.pack(f(...)))
end)
end)
之后修改 main.lua 启动脚本:
local skynet = require "skynet"
local max_client = 64 -- 最大在线连接
-- 启动方法
skynet.start(function()
skynet.error "OK, Game Server Startup"
-- 如果没有后台挂起, 则需要启动调试输出
if not skynet.getenv "daemon" then
local console = skynet.newservice("console")
end
-- 调试命令行服务, 端口这里可以外部定制传入调试
skynet.newservice("debug_console", 18080)
-- 看门狗服务挂起
local watchdog = skynet.newservice("watchdog")
skynet.call(watchdog, "lua", "start", {
port = 18081, -- 服务监听端口
maxclient = max_client,
nodelay = true,
})
skynet.exit()
end)
之后启动 telnet 就能调试:
# 这里是开启的 debug_console 服务
telnet 127.0.0.1 18080
# help 可以看到所有调试指令
客户端推送
可以看到这里都是服务端层面, 接下来就是需要处理客户端等方面, 请注意: 客户游戏端 和 运营后台端 都是需要预留好对应接口来推送.
skynet 消息封包也是十分简单的:
[ int32(消息长度) ] [ buffer(内息内容:最大65535字节) ]
注意: 消息序列采用大端序推送, 留意 Unity 版本当中 C# 推送变为小端序列
以此消息做基础封包, 后续如果采用序列化结构则是再 buffer 当中做处理推送.
Rust
Rust 推送这种方式可以作为编译语言的参考方式:
use std::io::Write;
use bytes::BufMut;
/// # 功能入口
///
/// 启动时候调用:
/// ./skynet_client 127.0.0.1 8080
fn main() -> Result<(), std::io::Error> {
// 获取传入参数
let args = std::env::args().collect::<Vec<String>>();
if args.len() < 3 {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Usage: {} 127.0.0.1 8080", args.get(0).unwrap()),
));
}
// 获取参数构建地址
let hostname = args.get(1).unwrap();
let port = args.get(2).unwrap();
let address = format!("{}:{}", hostname, port);
println!("{}", address);
// 链接生成 socket 推送
let mut conner = std::net::TcpStream::connect(address)?;
conner.set_nodelay(true)?;
// 获取读取内容
println!("Input Send Message:");
let mut buffer = String::new();
std::io::stdin().read_line(&mut buffer)?;
let message = buffer.trim();
let bytes = message.as_bytes();
let bytes_len = bytes.len();
// 构建消息体, 注意需要引入 bytes 包
let mut buf = bytes::BytesMut::with_capacity(bytes_len + std::mem::size_of::<i32>());
buf.put_i32(bytes_len as i32);
buf.put(bytes);
let message = buf.as_ref();
println!("Send: {:?}", message);
// 推送给 skynet
conner.write_all(message)?;
Ok(())
}
这里就是简单的推送客户端实现, 用于测试推送是否正常, 关键点只需要理解数据封包模式就行了, 目前内部没有任何业务代码所以导致数据没有什么响应功能; 后续将开始慢慢处理怎么编写业务代码, 让其能够开始构建游戏服务端.