文章目錄
- 游戲模塊
- 基礎功能模塊
- 定時器模塊
- 日志模塊
- 通用模塊
游戲模塊
游戲從邏輯方面可以分為下面幾個模塊:
- 注冊和登錄
- 網絡協議
- 數據庫
- 玩法邏輯
- 其他通用模塊
除了邏輯劃分,還有幾個重要的工具類模塊:
- 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 -- 當前幀的回調任務數量為0if 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 - 1end
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 + 1end -- 實際計算執行幀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 + 1end cbs.timers[id] = {sec, f, args, is_repeat}timer2frame[id] = fix_frametimer_size = timer_size + 1if 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 -- 當前幀的任務數 -1end -- 當前刪掉了這一幀的最后一個定時器任務if cbs.size == 0 then frame2cbs[frame] = nil -- 置空frame_size = frame_size - 1 -- 幀數 -1 end -- 當前定時器id對應的幀置空,且定時器數量 -1timer2frame[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 + 1init_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 + 1init_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_frameend 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 thenlocal level = skynet.getenv "log_level"local default_level = loglevel.debuglocal valif not level or not loglevel[level] thenval = default_levelelseval = loglevel[level]endlogger._level = valend
endfunction logger.set_log_level(level)local val = loglevel.debugif level and loglevel[level] thenval = loglevel[level]endlogger._level = val
endlocal function formatmsg(loglevel, label, labeldata, args)local args_len = #argsif args_len > 0 thenfor k, v in pairs(args) dov = tostring(v)args[k] = vendargs = table.concat(args, " ")elseargs = ""endlocal msglocal fmt = logger._fmtif labeldata ~= nil thenfmt = logger._fmt2msg = string.format(fmt, loglevel, label, labeldata, args)elsemsg = string.format(fmt, loglevel, label, args)endreturn 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 thenlocal args = {...}local msg = formatmsg("debug", label, nil, args)skynet.error(msg)end
end
function logger.debug2(label, labeldata, ...)if logger._level <= loglevel.debug thenlocal args = {...}local msg = formatmsg("debug", label, labeldata, args)skynet.error(msg)end
endfunction logger.info(label, ...)if logger._level <= loglevel.info thenlocal args = {...}local msg = formatmsg("info", label, nil, args)skynet.error(msg)end
end
function logger.info2(label, labeldata, ...)if logger._level <= loglevel.info thenlocal args = {...}local msg = formatmsg("info", label, labeldata, args)skynet.error(msg)end
endfunction logger.warn(label, ...)if logger._level <= loglevel.warn thenlocal args = {...}local msg = formatmsg("warn", label, nil, args)skynet.error(msg)end
end
function logger.warn2(label, labeldata, ...)if logger._level <= loglevel.warn thenlocal args = {...}local msg = formatmsg("warn", label, labeldata, args)skynet.error(msg)end
endfunction logger.error(label, ...)if logger._level <= loglevel.error thenlocal 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 thenlocal args = {...}local msg = formatmsg("error", label, labeldata, args, debug.traceback())skynet.error(msg)end
endskynet.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))endend)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 endreturn current_str
endreturn _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)]) thenprint(indent .. "*" .. tostring(t))elseprint_r_cache[tostring(t)] = trueif (type(t) == "table") thenfor pos, val in pairs(t) doif (type(val) == "table") thenprint(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") thenprint(indent .. "[" .. pos .. '] => "' .. val .. '"')elseprint(indent .. "[" .. pos .. "] => " .. tostring(val))endendelseprint(indent .. tostring(t))endendendif (type(t) == "table") thenprint(tostring(t) .. " {")sub_print_table(t, " ")print("}")elsesub_print_table(t, " ")endprint()
endreturn _M
string.lua
local string = require "string"local _M = {}function _M.split(str, sep) local arr = {}local i = 1for s in string.gmatch(str, "([^" .. sep .. "]+)") doarr[i] = si = i + 1endreturn arr
endreturn _M
目前 table
模塊僅實現了 dump
接口,對表的美化輸出, string
模塊僅實現了對字符串的分割轉表,有需求在自定義添加更多的功能。