MeteorCat / 游戏服务架构(六)

Created Wed, 25 Oct 2023 15:25:45 +0800 Modified Wed, 29 Oct 2025 23:24:54 +0800
2673 Words

游戏服务架构(六)

前面已经构筑好了服务端功能, 但是目前只能知道请求确定已经到达, 但是并没有相关业务处理, 这里需要重新整合下目录:

# 回到之前目录重新初始化
cd /data/game
rm -rf /data/game/*

# 创建目录
mkdir config # 系统配置
mkdir core # 核心基础目录
mkdir game # 游戏相关业务
mkdir proto # protobuf 的协议文件
mkdir tables # 策划 Excel配表功能

# 生成初始化文件
touch config/path.lua # 路径配置
touch config/net.lua # 网络配置
touch config/main.lua # 启动配置
touch run.lua # 启动入口

现在就按照正式项目搭建来介绍如何搭建正式商业化的游戏服务端

在开始部署服务之前最好认识下 skynet 所有对应的 api 确保清楚知道怎么回事.

API 认识

  • skynet.launch: 启动一个C服务
  • skynet.kill: 强行杀死一个服务
  • skynet.abort: 退出Skynet进程
  • skynet.register: 给自身注册一个名字
  • skynet.name: 为一个服务命名
  • skynet.forward_type: 将本服务实现为消息转发器,对一类消息进行转发。
  • skynet.filter: 过滤消息再处理
  • skynet.monitor: 给当前Skynet进程设置一个全局的服务监控服务地址
  • skynet.self(): 用于获取当前服务的数字地址
  • skynet.harbor(): 用于获取服务所属的节点
  • skynet.address(address): 用于将一个数字地址转换为一个可用于阅读的字符串, 同时为了地址使用方便可以给数字地址起一个字符串的名字
  • skynet.register(name): 为服务注册一个别名, 别名必须不超过16个字符
  • skynet.name(name, address): 为地址命名, skynet.name(name, skynet.self())skynet.register(name) 的功能等价
  • skynet.exit(): 退出 skynet 进程服务
  • skyent.send(address,type,...): 推送消息到指定地址, address 可以是句柄也可以是别名
  • skyent.call(address,type,...): 带有阻塞推送消息, 阻塞调用完成会直接返回数据
  • skynet.ret(msg,sz): 响应消息返回给调用者
  • skynet.pack(msg): 将消息序列化封包, 默认返回 local msg, sz = skynet.pack("test"), 常规返回服务消息直接可以 skynet.ret(skynet.pack("PONG")) 处理
  • skynet.unpack(msg,sz): 将序列化的消息解包
  • skynet.newservice(name,...): 注册服务实例
  • skynet.uniqueservice(name,...): 注册全局唯一服务
  • skynet.queryservice(name,...): 查询服务示例, 服务不存在会阻塞到服务启动
  • skynet.sleep(time): 延迟等待后续执行

这里说明下各自调用样例:

local skynet = require "skynet" -- 引入工具

-------------------------------------------------------------------------
--- 1. 之前已经看过很多次, 在启动脚本之中声明挂载服务
skynet.start(function()
    -- 这里实际上就是挂载服务入口, 也就是相当于 C 语言当中的 `main` 功能
    -- 但是和 C 之中不同的是 skynet 允许声明多个启动入口
    print("Hello.World")
    skynet.exit() -- 这里就是直接退出入口上下文服务
end)
--- 上面就是简单的入口服务, 启动初始化 skynet 之后会直接挂载该入口服务
-------------------------------------------------------------------------


-------------------------------------------------------------------------
--- 2. 自定义设计自己系统服务, 假设创建 `echo` 服务, skynet 回去扫描 `lua_path` 路径:
--- 扫描出 `lua_path` 以分号拼接得出的 XXX/echo.lua 或者 YYY/echo.lua 等服务匹配
--- 扫描出文件会去检索出内部 dispatch 服务
skynet.start(function()
    -- 假设这里是 echo 服务出口, 首次启动初始化方法

    -- 这里用来截取内部推送数据, 初始化过程当中 dispatch 相当于声明休眠的回调方法等待调用
    -- 基本上首位 session 和 address 功能不变
    skynet.dispatch("lua", function(session, address, cmd, ...)
        -- session: 这里就是请求会话id, 为自增数字且溢出回滚1开始
        -- address: 则是自身服务地址, 也就是 echo 全局唯一服务, 同时为 skynet.self() 获取的数字地址
        -- cmd: 这是消息的命令, 这里默认都是自定义的推送指令, 不过大部分该值用于标识消息类型或者调用内部名称
        cmd = cmd:upper() -- 自定义指令大写处理
        if cmd and cmd == "PING" then
            -- 确定传递过来的指令
            -- 响应返回消息包
            skynet.error("Server By ", skynet.address(address))
            skynet.ret(skynet.pack("PONG"))
        end
    end)

    skynet.register("echo") -- 默认注册服务名, 默认服务会采用文件名, 这里不写也是默认 echo 服务
end)

-- 这里其他启动 main 脚本直接调用
skynet.start(function()
    -- 创建服务
    local echo = skynet.newservice("echo")
    print(skynet.call(echo, "lua", "PING")) -- 调用服务传递
end)
-- 这里就是注册 echo 服务来调用
-------------------------------------------------------------------------

这里就是需要了解的基础 skynet 知识, 接下来就是规划下正式服务搭建.

配置修改

path.lua 配置:

--- 核心路径, 这里需要说明, 如果可以尽量绝对路径而非相对路径
root = "/data/skynet/"
app = "/data/game/"

--- 系统关键加载
luaservice = root .. "service/?.lua;" .. app .. "core/?.lua;" .. app .. "?.lua"
lualoader = root .. "lualib/loader.lua"
lua_path = root .. "lualib/?.lua;" .. root .. "lualib/?/init.lua;" .. app .. "core/?.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"

net.lua 配置:

debug_console_port = 18080 -- 调试命令行端口

-- 游戏服务端信息
game_server_port = 18081 -- 游戏服务端监听端口
game_server_max = 64 -- 最大连接数
game_server_nodelay = true -- 是否启用 nodelay

-- 后台运营推送信息

main.lua 配置:

include "path.lua" -- 加载路径变量
include "net.lua" -- 加载服务器变量


thread = 4 -- 线程数量
logger = nil
logpath = app .. "log/"

-- 实际上后续 skynet 集群用的, 很少基本上靠其他负载均衡而不是内部集群
-- 而且官方好像集群方向已经放弃目前方案, 所以默认直接官方样例采用单个节点即可
harbor = 1
address = "127.0.0.1:2526"
master = "127.0.0.1:2013"

start = "run" -- 调用脚本入口: run.lua
bootstrap = "snlua bootstrap"    -- The service for bootstrap
standalone = "0.0.0.0:2013"

core/tick.lua 先编写定时服务测试下:

local skynet = require "skynet"

local CMD = {}

function CMD.start(cfg)
    if not cfg.second then
        return
    end

    local t = os.time()
    while true do
        skynet.sleep(cfg.second)
        t = os.time()
        skynet.error("Tick By ", t)
    end
    return t
end

skynet.start(function()
    skynet.dispatch("lua", function(session, address, cmd, ...)
        local f = CMD[cmd]
        if f then
            skynet.ret(skynet.pack(f(...)))
        end
    end)
end)

最后调用入口方法 run.lua:

local skynet = require "skynet"


-- 启动入口
skynet.start(function()

    -- 调试命令行服务, 端口这里可以外部定制传入调试
    if skynet.getenv "debug_console_port" then
        skynet.newservice("debug_console", tonumber(skynet.getenv "debug_console_port"))
    end

    -- 自己定义的服务
    local tick = skynet.newservice("tick")
    skynet.call(tick, "lua", "start", {
        second = 300 -- 3s 调用一次
    });
    skynet.exit()
end)

这种情况就是跑定时常驻场景地图事件等清空, 一般大地图生态系统可能会跑单独服务进行维护, 另外推荐动态加载服务功能( run.lua ):

local skynet = require "skynet"

-- 这里先放置在此的工具类, 后续可以提取处理
local Utils = {}
function Utils.read_files(path)
    local dirs = (io.popen("ls " .. path)):read("*all");
    local start_pos = 0
    local files = {}
    while true do
        --从文件列表里一行一行的获取文件名
        local _, end_pos, line = string.find(dirs, "([^\n\r]+)", start_pos)
        if not end_pos then
            break
        end
        table.insert(files, line)
        start_pos = end_pos + 1
    end
    return files
end

-- 启动入口
skynet.start(function()
    -- 调试命令行服务, 端口这里可以外部定制传入调试
    if skynet.getenv "debug_console_port" then
        skynet.newservice("debug_console", tonumber(skynet.getenv "debug_console_port"))
    end

    -- 挂载游戏服务
    local modules = {}
    local path = (skynet.getenv "app" or ".")
    if string.byte(path, string.len(path)) ~= 47 then
        path = path .. "/"
    end
    path = path .. "game" -- 检索 game 目录之中的 xxx/xxx.main.lua 服务并加载
    local files = Utils.read_files(path)
    for _, f in pairs(files) do
        local file = f .. ".main"
        local filename = file .. ".lua"
        local pathname = path .. f .. "/" .. filename
        local exists = io.open(pathname)
        if exists then
            skynet.error("Load Game Module:", pathname)
            modules[f] = skynet.newservice(f .. "/" .. file)
        end
    end

    skynet.exit()
end)

这里会自动加载 game 目录之中的关于 xxx/xxx.main.lua 服务, xxx 为目录名称, 这种动态加载方式在启动之后装载模块不用自己手动一个个自己加载.

网关转发

skynet 处理提供 watchdog + agent 方案之外, 还有 examples/login/ 目录之中方面登录网关方案.

这里面把登录逻辑也集成其中, 通过 uid/sid/secret 对称加密验证之后让其内部登录, 具体流程应该如下:

  1. 客户端和 Web 请求登录, 验证完成先返回客户端服务器列表, 服务器列表中含有 uid/server[sid,host,port,...]
  2. 客户端选择服务器推送 Web 服务, Web 拿到服务器ID之后按照玩家信息生成 uid/sid/secret 返回客户端
  3. 客户端获取到 uid/sid/secret 之后按照服务器列表返回 host+port 推送到指定地址
  4. 服务端获取到 uid/sid/secret 验签之后确认是否存在账号, 不存在就加载策划配置默认初始化资源创建账号, 否则就直接加载数据库的资源

但是再次之前需要清楚以下问题:

  1. 数据封包
  2. 数据解包
  3. 数据转发

这里需要知道 skynet.register_protocol {} 注册封包|解包协议预定义表:

skynet.register_protocol {
    name = "lua", -- 消息组的字符串名称
    id = skynet.PTYPE_LUA, -- 消息组的数字 id, 还有 skynet.{ PTYPE_CLIENT,PTYPE_LUA,.... } 等不同消息类型
    pack = skynet.pack, -- 打包消息
    unpack = skynet.unpack, -- 解包消息
    dispatch = function(session, source, cmd, ...)
        --- 转发方法回调处理
    end -- 消息回调/分发函数
}

这里就是注册封包|解包协议处理方式, 这里面字段作用:

  • name: 协议名称, 当调用 skynet.dispatch("lua",...) 转发消息的首个参数就是调用名称.
  • id: 协议消息组ID, 这里官方样例当中有声明消息组, 这些都是系统内置预留的消息协议id:
    • PTYPE_TEXT = 0, – 单纯文本消息, 常规用于日志
    • PTYPE_RESPONSE = 1, – 封包响应消息, 被多用于消息打包
    • PTYPE_MULTICAST = 2, – 广播推送消息, 用于多节点广播推送
    • PTYPE_CLIENT = 3, – 客户端响应消息
    • PTYPE_SYSTEM = 4,
    • PTYPE_HARBOR = 5,
    • PTYPE_SOCKET = 6,
    • PTYPE_ERROR = 7,– 系统错误消息
    • PTYPE_QUEUE = 8, – used in deprecated mqueue, use skynet.queue instead
    • PTYPE_DEBUG = 9,
    • PTYPE_LUA = 10, 常规的 Lua 消息对象
    • PTYPE_SNAX = 11,
    • PTYPE_TRACE = 12,
  • pack: 消息封包方法
  • unpack: 消息解包方法
  • dispatch: 转发给 skyent.dispatch 推送方法, 用于通知消息唤醒.

这里内置注册的协议方式:

----- register protocol
do
    local REG = skynet.register_protocol

    REG {
        name = "lua",
        id = skynet.PTYPE_LUA,
        pack = skynet.pack,
        unpack = skynet.unpack,
    }

    REG {
        name = "response",
        id = skynet.PTYPE_RESPONSE,
    }

    REG {
        name = "error",
        id = skynet.PTYPE_ERROR,
        unpack = function(...) return ... end,
        dispatch = _error_dispatch,
    }
end

那这里回归到新的问题: 自定义消息怎么进行封包解包?