文章目录
- 游戏模块
- 基础功能模块
- 定时器模块
- 日志模块
- 通用模块
游戏模块
游戏从逻辑方面可以分为下面几个模块:
- 注册和登录
- 网络协议
- 数据库
- 玩法逻辑
- 其他通用模块
除了逻辑划分,还有几个重要的工具类模块:
- Excel 配置导表工具
- GM 指令
- 测试机器人
- 服务器打包部署工具
本节先来实现几个通用的基础功能模块。
基础功能模块
定时器模块
在什么场景下,我们会要使用到定时器?
- 每日任务的重置,比如游戏在每天的 0 点,需要定时进行刷新
- 登录流程的超时机制,对于长时间未通过验证的连接,需要踢客户端下线,避免占用服务端资源
- 活动结算,在定期活动结束后,需要给所有用户发放结算奖励
服务端和客户端都可以实现定时器逻辑,一般涉及到全服玩家的定时器,需要服务器来实现,针对个人玩家的定时器可以交给客户端来实现。
在 skynet
中,通过 skynet.timeout(time, func)
实现定时任务,skynet
基本的时间单位是 10ms
,即会在 0.01s
后执行一次 func
函数。
参考:https://github.com/cloudwu/skynet/wiki/LuaAPI
skynet.timeout(ti, func) 让框架在 ti 个单位时间后,调用 func 这个函数。这不是一个阻塞 API ,当前 coroutine 会继续向下运行,而 func 将来会在新的 coroutine 中执行。
skynet 的定时器实现的非常高效,所以一般不用太担心性能问题。不过,如果你的服务想大量使用定时器的话,可以考虑一个更好的方法:即在一个service里,尽量只使用一个 skynet.timeout ,用它来触发自己的定时事件模块。这样可以减少大量从框架发送到服务的消息数量。毕竟一个服务在同一个单位时间能处理的外部消息数量是有限的。
由此,考虑自己实现一个用于定时触发事件的定时器模块,并且不需要这么高的精度,采用以秒为单位实现定时器。
定时器模块实现架构: skynet.timeout
实现循环定时器,每秒循环一次,查看并执行这一秒对应的回调函数。定时器 id 采用自增唯一映射每个定时回调函数。注册回调函数时,会计算将要执行的秒数,存入对应的回调函数表。
基础变量以及模块初始化
local _M = {}
local is_init = false -- 标记模块是否初始化
local timer_inc_id = 1 -- 定时器的自增 ID
local cur_frame = 0 -- 当前帧,一帧对应一秒
local cur_timestamp = 0 -- 当前时间戳,运行到的秒数
local timer_size = 0 -- 定时器数量
local frame_size = 0 -- 帧数量
local timer2frame = {} -- 定时器ID 映射 帧
local frame2cbs = {} -- 帧 映射 多个回调任务
--[[
frame: {
timers: {
timerid: { sec, cb, args, is_repeat },
timerid: { sec, cb, args, is_repeat }
},
size: 1
}
]]
if not is_init then
is_init = true -- 初始化定时器模块
skynet.timeout(100, main_loop)
end
return _M
is_init
:用于标记模块是否初始化,即生成一次循环定时器,定时每秒执行main_loop
函数timer_inc_id
:定时器的唯一标识 ID,每个定时器创建,都会自增cur_frame
:记录当前循环是对应哪一帧,随着循环自增cur_timestamp
:当前循环时间戳timer_size
、frame_size
:维护的定时器数量和帧数量timer2frame
:定时器 ID 对应的帧frame2cbs
:帧对应的回调函数表
回调函数表的结构 frame2cbs
:
frame: {
timers: {
timerid: { sec, cb, args, is_repeat },
timerid: { sec, cb, args, is_repeat }
},
size: 1
}
每帧对应回调函数表,有 timers
和 size
两个字段,size
维护当前回调函数个数,timers
则是实际的回调函数表,以定时器 ID 映射对应的回调函数。
每个回调函数都存储 sec
、cb
、args
、is_repeat
四个字段,表示 sec
秒后执行 cb
函数,携带 args
参数, is_repeat
表示是否是一个循环任务。
下面看每帧执行的函数 main_loop
:
local function now()
return skynet.time() // 1 -- 截断小数:.0
end
-- 逐帧执行
local function main_loop()
skynet.timeout(100, main_loop)
cur_timestamp = now()
cur_frame = cur_frame + 1
-- 当前没有定时器任务
if timer_size <= 0 then return end
-- 当前帧对应的回调任务
local cbs = frame2cbs[cur_frame]
if not cbs then return end
-- 当前帧的回调任务数量为0
if cbs.size <= 0 then
frame2cbs[cur_frame] = nil
frame_size = frame_size - 1 -- 该帧执行完毕
return
end
-- task: {sec, cb, args, is_repeat}
for timerid, task in pairs(cbs.timers) do
local f = task[2]
local args = task[3]
local ok, err = xpcall(f, traceback, unpack(args, 1, args.n))
if not ok then
logger.error("timer", "crontab is run in error:", err)
end
del_timer(timerid) -- 执行成功与否都需要删掉当前这个定时器
local is_repeat = task[4]
if is_repeat then
local sec = task[1]
init_timer(timerid, sec, f, args, is_repeat)
end
end
-- 当前这一帧所有任务执行完,并且这一帧没有删(双重保障(del_timer)),删掉当前帧
if frame2cbs[cur_frame] then
frame2cbs[cur_frame] = nil
frame_size = frame_size - 1
end
end
这里在入口处,我们就立即需要执行 skynet.timeout(100, main_loop)
,实现循环定时,并且没有多余其他操作,保证一下秒定时的准确。
skynet.time()
:当前 UTC 时间(单位是秒, 精度是 ms)
主要逻辑:判断当前帧是否有任务,有则执行 frame2cbs[cur_frame].timers
回调函数表中的回调函数,执行完后进行删除和判断该回调是否是循环定时任务,是则重新创建该回调的新定时器。
再来看定时器的创建和删除逻辑
init_timer
:
local function init_timer(id, sec, f, args, is_repeat)
-- 第一步:定时器 id 映射 帧
local offset_frame = sec -- sec 帧后开始当前任务
-- 矫正帧数
if now() > cur_timestamp then
offset_frame = offset_frame + 1
end
-- 实际计算执行帧
local fix_frame = cur_frame + offset_frame
-- 第二步:该帧 映射 定时器任务
local cbs = frame2cbs[fix_frame]
if not cbs then
-- 创新当前帧的任务集
cbs = { timers = {}, size = 1 }
frame2cbs[fix_frame] = cbs
frame_size = frame_size + 1
else
cbs.size = cbs.size + 1
end
cbs.timers[id] = {sec, f, args, is_repeat}
timer2frame[id] = fix_frame
timer_size = timer_size + 1
if timer_size >= 500 then
logger.warn("timer", "timer is too many!")
end
end
创建定时器任务,对应需要修改 frame2cbs
和 timer2frame
表。回调函数加入当前帧的回调表中,回调的定时器ID映射当前帧,一并维护一下定时器和帧的数量统计。
在函数的开始,我们进行了对帧的校正。保证回调任务在未来帧中执行,而不会在当前帧中继续添加任务。
del_timer
:
-- 删除定时器
local function del_timer(id)
-- 获取定时器id 映射 帧
local frame = timer2frame[id]
if not frame then return end
-- 获取该帧对应的任务
local cbs = frame2cbs[frame]
if not cbs or not cbs.timers then return end
-- 如果这个帧中的定时器任务存在
if cbs.timers[id] then
cbs.timers[id] = nil -- 删除该定时器任务
cbs.size = cbs.size - 1 -- 当前帧的任务数 -1
end
-- 当前删掉了这一帧的最后一个定时器任务
if cbs.size == 0 then
frame2cbs[frame] = nil -- 置空
frame_size = frame_size - 1 -- 帧数 -1
end
-- 当前定时器id对应的帧置空,且定时器数量 -1
timer2frame[id] = nil
timer_size = timer_size - 1
end
删除定时器逻辑很好理解,传入定时器 ID,找到 ID 对应的帧,看该帧中是否存在这个任务,存在就删除并维护帧数和定时器数量。
接口实现:
-- 新增定时器 timer,sec 秒后执行函数 f
-- 返回定时器 ID
function _M.timeout(sec, f, ...)
assert(sec > 0)
timer_inc_id = timer_inc_id + 1
init_timer(timer_inc_id, sec, f, pack(...), false)
return timer_inc_id
end
function _M.timeout_repeat(sec, f, ...)
assert(sec > 0)
timer_inc_id = timer_inc_id + 1
init_timer(timer_inc_id, sec, f, pack(...), true)
return timer_inc_id
end
-- 取消定时器任务
function _M.cancel(id)
del_timer(id)
end
-- 检查定时器是否存在
function _M.exist(id)
if timer2frame[id] then return true end
return false
end
-- 获取定时器还有多久执行
function _M.get_remain(id)
local frame = timer2frame[id]
if frame then
return frame - cur_frame
end
return -1
end
完整代码:timer.lua
日志模块
日志系统一般分为 4 个等级:
DEBUG
:调试用的日志,线上运行时屏蔽不输出INFO
:普通日志,线上运行时输出,流程的关键步骤都需要有 INFO 日志WARN
:数据异常,但不影响正常流程的时候输出ERROR
:数据异常,且需要人工处理的时候输出
日志服务模块配置如下:
-- log conf
logger = "log"
logservice = "snlua"
logpath = "log"
logtag = "game"
-- debug | info | warn | error
log_level = "debug"
参考官方 wiki
logger
它决定了 skynet 内建的skynet_error
这个 C API 将信息输出到什么文件中。如果 logger 配置为 nil ,将输出到标准输出。你可以配置一个文件名来将信息记录在特定文件中。
logservice
默认为"logger"
,你可以配置为你定制的 log 服务(比如加上时间戳等更多信息)。可以参考 service_logger.c 来实现它。注:如果你希望用 lua 来编写这个服务,可以在这里填写 snlua ,然后在 logger 配置具体的 lua 服务的名字。在 examples 目录下,有 config.userlog 这个范例可供参考。
配置中,指定 logger
是 log.lua
这个日志服务,logservice
是 snlua
表示这个日志服务是 lua 服务。其余的三个参数作为键值对存储在配置中,用于实现服务模块时取出使用。logpath
指定为日志存放的目录路径,logtag
指定为日志进程标识,log_level
可选四种日志级别。
这里先来看日志模块:lualib/logger.lua
local skynet = require "skynet"
local loglevel = {
debug = 0,
info = 1,
warn = 2,
error = 3,
}
local logger = {
_level = nil,
_fmt = "[%s] [%s] %s", -- [info] [label] msg
_fmt2 = "[%s] [%s %s] %s", --[info] [label labeldata] msg
}
local function init_log_level()
if not logger._level then
local level = skynet.getenv "log_level"
local default_level = loglevel.debug
local val
if not level or not loglevel[level] then
val = default_level
else
val = loglevel[level]
end
logger._level = val
end
end
function logger.set_log_level(level)
local val = loglevel.debug
if level and loglevel[level] then
val = loglevel[level]
end
logger._level = val
end
local function formatmsg(loglevel, label, labeldata, args)
local args_len = #args
if args_len > 0 then
for k, v in pairs(args) do
v = tostring(v)
args[k] = v
end
args = table.concat(args, " ")
else
args = ""
end
local msg
local fmt = logger._fmt
if labeldata ~= nil then
fmt = logger._fmt2
msg = string.format(fmt, loglevel, label, labeldata, args)
else
msg = string.format(fmt, loglevel, label, args)
end
return msg
end
--[[
logger.debug("map", "user", 1024, "entered this map")
logger.debug2("map", 1, "user", 2048, "leaved this map")
]]
function logger.debug(label, ...)
if logger._level <= loglevel.debug then
local args = {...}
local msg = formatmsg("debug", label, nil, args)
skynet.error(msg)
end
end
function logger.debug2(label, labeldata, ...)
if logger._level <= loglevel.debug then
local args = {...}
local msg = formatmsg("debug", label, labeldata, args)
skynet.error(msg)
end
end
function logger.info(label, ...)
if logger._level <= loglevel.info then
local args = {...}
local msg = formatmsg("info", label, nil, args)
skynet.error(msg)
end
end
function logger.info2(label, labeldata, ...)
if logger._level <= loglevel.info then
local args = {...}
local msg = formatmsg("info", label, labeldata, args)
skynet.error(msg)
end
end
function logger.warn(label, ...)
if logger._level <= loglevel.warn then
local args = {...}
local msg = formatmsg("warn", label, nil, args)
skynet.error(msg)
end
end
function logger.warn2(label, labeldata, ...)
if logger._level <= loglevel.warn then
local args = {...}
local msg = formatmsg("warn", label, labeldata, args)
skynet.error(msg)
end
end
function logger.error(label, ...)
if logger._level <= loglevel.error then
local args = {...}
local msg = formatmsg("error", label, nil, args, debug.traceback())
skynet.error(msg)
end
end
function logger.error2(label, labeldata, ...)
if logger._level <= loglevel.error then
local args = {...}
local msg = formatmsg("error", label, labeldata, args, debug.traceback())
skynet.error(msg)
end
end
skynet.init(init_log_level)
return logger
这个日志模块主要暴露的四个接口分别对应四个日志等级,并且只有当前日志等级 log_level
低于当前 API 对应的等级才可以输出。如果程序测试阶段,那么指定 debug
级,就会获得所有日志。如果程序上线指定 error
级,那么只会关注到最高级别的错误日志。
error
等级日志额外输出了调用堆栈,方便查看错误问题所在的位置。
skynet.init
:若服务尚未初始化完成,则注册一个函数等服务初始化阶段再执行;若服务已经初始化完成,则立刻运行该函数。
下面再来看一下日志服务代码:service/log.lua
local skynet = require "skynet"
require "skynet.manager"
local time = require "utils.time"
-- 日志目录
local logpath = skynet.getenv("logpath") or "log"
-- 日志文件名
local logtag = skynet.getenv("logtag") or "game"
local logfilename = string.format("%s/%s.log", logpath, logtag)
local logfile = io.open(logfilename, "a+")
-- 写文件
local function write_log(file, str)
file:write(str, "\n")
file:flush()
print(str)
end
-- 切割日志文件,重新打开日志
local function reopen_log()
-- 下一天零点再次执行
local future = time.get_next_zero() - time.get_current_sec()
skynet.timeout(future * 100, reopen_log)
if logfile then logfile:close() end
local date_name = os.date("%Y%m%d%H%M%S", time.get_current_sec())
local newname = string.format("%s/%s-%s.log", logpath, logtag, date_name)
os.rename(logfilename, newname) -- logfilename文件内容剪切到newname文件
logfile = io.open(logfilename, "a+") -- 重新持有logfilename文件
end
-- 注册日志服务处理函数
skynet.register_protocol {
name = "text",
id = skynet.PTYPE_TEXT,
unpack = skynet.tostring,
dispatch = function(_, source, str)
local now = time.get_current_time()
str = string.format("[%08x][%s] %s", source, now, str)
write_log(logfile, str)
end
}
-- 捕捉sighup信号(kill -l) 执行安全关服逻辑
skynet.register_protocol {
name = "SYSTEM",
id = skynet.PTYPE_SYSTEM,
unpack = function(...) return ... end,
dispatch = function()
-- 执行必要服务的安全退出操作
skynet.sleep(100)
skynet.abort()
end
}
local CMD = {}
skynet.start(function()
skynet.register(".log")
skynet.dispatch("lua", function(_, _, cmd, ...)
local f = CMD[cmd]
if f then
skynet.ret(skynet.pack(f(...)))
else
skynet.error(string.format("invalid command: [%s]", cmd))
end
end)
local ok, msg = pcall(reopen_log)
if not ok then
print(msg)
end
end)
日志服务已经在配置中指定,logger = "log"、logservice = "snlua"
,不需要自行启动这个日志服务。且项目中所有 skynet.error
API 输出的内容都被定向到了日志文件中,而不是输出在控制台。便于调试,write_log
写日志函数,最后调用了 print
打印日志到了终端。
通过注册 skynet.PTYPE_TEXT
文本类型消息,那么项目中的 skynet.error
输出的日志都会经过本日志服务进行分发处理,由此在分发函数 dispatch = function(_, source, str) end
中处理日志消息,对所有的日志消息进行格式化的美观输出。
日志服务工作原理可以参考文章:https://www.jianshu.com/p/351ac2cfd98c/ ,本系列 skynet 偏原理性的东西不做深入讲解。
日志服务如果不做切割,全部放在一个文件中会导致日志文件日益增大,这里实现 reopen_log
函数,通过 skynet.timeout
定时每天零点对日志进行切割,包括在服务重启时,也会对上次的日志文件 game.log
进行分割处理。
日志服务还注册了一种消息类型,skynet.PTYPE_SYSTEM
,用来接收 kill -1
命令的信号,触发保存数据的逻辑,待后续实现了缓存模块在完善。
通用模块
同样在本章节,继续实现几个通用模块,细心的小伙伴应该注意到了,在实现日志模块、日志服务时,都有导入 utils.time
这个处理时间的一个模块。
下图是目前的模块 lualib
文件夹的结构:
time.lua
:
local skynet = require "skynet"
local _M = {}
-- 一秒只转一次时间戳
local last_sec
local current_str
-- 获取当前时间戳
function _M.get_current_sec()
return math.floor(skynet.time())
end
-- 获取下一天零点的时间戳
function _M.get_next_zero(cur_time, zero_point)
zero_point = zero_point or 0
cur_time = cur_time or _M.get_current_sec()
local t = os.date("*t", cur_time)
if t.hour >= zero_point then
t = os.date("*t", cur_time + 24 * 3600)
end
local zero_date = {
year = t.year,
month = t.month,
day = t.day,
hour = zero_point,
min = 0,
sec = 0,
}
return os.time(zero_date)
end
-- 获取当前可视化时间
function _M.get_current_time()
local cur = _M.get_current_sec()
if last_sec ~= cur then
current_str = os.date("%Y-%m-%d %H:%M:%S", cur)
last_sec = cur
end
return current_str
end
return _M
目前实现了三个接口:
get_current_sec
:获取当前时间戳get_current_time
:获取当前可视化时间get_next_zero
:获取下一天零点时间戳,可以自定义项目的刷新时间zero_point
其中有一个小优化是 last_sec,current_str
设置上一秒时间戳,与当前可视化时间变量,保证一秒只会转换一次。
os.time、os.date 的使用参考 lua 手册
table.lua
local string = require "string"
local _M = {}
function _M.dump(t)
local print_r_cache = {}
local function sub_print_table(t, indent)
if (print_r_cache[tostring(t)]) then
print(indent .. "*" .. tostring(t))
else
print_r_cache[tostring(t)] = true
if (type(t) == "table") then
for pos, val in pairs(t) do
if (type(val) == "table") then
print(indent .. "[" .. pos .. "] => " .. tostring(t) .. " {")
sub_print_table(val, indent .. string.rep(" ", string.len(pos) + 8))
print(indent .. string.rep(" ", string.len(pos) + 6) .. "}")
elseif (type(val) == "string") then
print(indent .. "[" .. pos .. '] => "' .. val .. '"')
else
print(indent .. "[" .. pos .. "] => " .. tostring(val))
end
end
else
print(indent .. tostring(t))
end
end
end
if (type(t) == "table") then
print(tostring(t) .. " {")
sub_print_table(t, " ")
print("}")
else
sub_print_table(t, " ")
end
print()
end
return _M
string.lua
local string = require "string"
local _M = {}
function _M.split(str, sep)
local arr = {}
local i = 1
for s in string.gmatch(str, "([^" .. sep .. "]+)") do
arr[i] = s
i = i + 1
end
return arr
end
return _M
目前 table
模块仅实现了 dump
接口,对表的美化输出, string
模块仅实现了对字符串的分割转表,有需求在自定义添加更多的功能。