MeteorCat / 游戏服务架构(五)

Created Mon, 23 Oct 2023 17:30:59 +0800 Modified Wed, 29 Oct 2025 23:24:54 +0800
2515 Words

游戏服务架构(五)

上一个篇章编写简单的数值游戏, 已经集合了 游戏玩法-数据保存-协议传输 所有功能, 对于这种 自己玩自己 的游戏相对来说比较简单, 直接不推送消息做 JSON 数据同步即可.

H5小程序的游戏基本集中于这种

但是可以如果想要扩展游戏本体, 可以看到目前功能缺少大量高级特性:

  • 没有主动推送机制: 服务端无法发起推送消息功能 -> 需要考虑转化为 TCP/WebSocket 长链接
  • 传递消息体过大: HTTP协议头+内容体导致消息庞大 -> 自定义传输解析
  • JSON无意义内容太多: 充斥大量 " 和 {} 符号冗余符号 -> 采用二进制流转化的序列化方案
  • 短链接请求频繁: 短链接频繁来不及释放带来 504 请求错误 -> 走长连接访问
  • 直接数据库IO拥堵: 可以看到访问数据操作是同步写入过高请求直接IO升高 -> 利用变成语言多核异步数据入库

注意: 如果没有经验最好采用 skynet 方案来搭建, 因为社区方案十分成熟还内嵌有 Lua 来热更新服务.

这里方案考虑采用 TCP + GoogleProtobuf 来做搭建, 这里需要注意的关键点:

  • skynet: 主要的网络传输框架, 业务集中于 lua 语言挂载, 但请注意只有 Linux/Unix 平台.
  • protobuf: 谷歌的序列化传输方案, 压缩的数据是常规的 JSON 的 4 倍.

这里直接导出 githubskynet 版本来手动编译:

# 先到指定编译目录
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, 即启动名为 bootstraplua 服务. 通常指的是 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(())
}

这里就是简单的推送客户端实现, 用于测试推送是否正常, 关键点只需要理解数据封包模式就行了, 目前内部没有任何业务代码所以导致数据没有什么响应功能; 后续将开始慢慢处理怎么编写业务代码, 让其能够开始构建游戏服务端.