6.2 错误处理与调试 本节摘要:Lua 的错误模型是"错误即值":error 抛出、pcall 兜住、返回值里传递。error 的第二个参数控制报错位置归因,错误对象(表挂 )携带结构化上下文;assert 把"检查加抛错"压成一行;xpcall 在兜错的同时抓取调用栈。本节组装出"宿主安全调用脚本"的标准防护壳——四大现场共用的错误边界协议,然后过一遍 debug 库的定位工具。 谁来兜住错误 先看一场事故的两种结局。脚本里有个函数会在特定输入下抛错: 没有防护:错误一路向上炸穿所有调用者,最后到达顶层。独立解释器下打印栈回溯然后退出——无伤大雅;宿主进程下,如果宿主没在 C 边界装保护(第 9 章的 luapcall),进程直接崩掉,一次坏输入带走整个服务。
本节摘要:Lua 的错误模型是"错误即值":error 抛出、pcall 兜住、返回值里传递。error 的第二个参数控制报错位置归因,错误对象(表挂
__tostring)携带结构化上下文;assert 把"检查加抛错"压成一行;xpcall 在兜错的同时抓取调用栈。本节组装出"宿主安全调用脚本"的标准防护壳——四大现场共用的错误边界协议,然后过一遍 debug 库的定位工具。
先看一场事故的两种结局。脚本里有个函数会在特定输入下抛错:
local function parse_header(raw) local k, v = raw:match("^(%w+):%s*(.+)$") if not k then error("malformed header: " .. raw, 2) end return k:lower(), v end
没有防护:错误一路向上炸穿所有调用者,最后到达顶层。独立解释器下打印栈回溯然后退出——无伤大雅;宿主进程下,如果宿主没在 C 边界装保护(第 9 章的 lua_pcall),进程直接崩掉,一次坏输入带走整个服务。
有防护:调用处包上 pcall:
> local ok, err = pcall(parse_header, "??? no colon") > print(ok, err) false input:2: malformed header: ??? no colon
pcall 把调用装进保护壳:出错不炸栈,返回 false 与错误值;成功返回 true 加全部结果。"ok 加结果"的多返回值签名(2.1 节预告过)在这里兑现。这个模型与 Go 的显式 error、与异常栈回溯都不同——错误不自动传播,每一层显式决定:兜住、加工、还是重新抛出。

形态一:字符串。最常见,error("message")。错误值带上位置前缀(哪块哪行)。
形态二:带归因层级。error(msg, 2) 把位置归到调用者那一层——库函数报"你用错了"而不是"我内部错了"。parse_header 的例子里,2 让错误指向调用 parse_header 的那行,而非 match 失败的内部行。写库必带 2,写应用随手。
形态三:错误对象(表)。字符串只能给人看,表能携带结构化上下文:
local function parse_header(raw) local k, v = raw:match("^(%w+):%s*(.+)$") if not k then error(setmetatable({ code = "EBADHDR", raw = raw, hint = "expect name: value", }, { __tostring = function(e) return ("[%s] %s (%s)"):format(e.code, e.raw, e.hint) end}), 2) end return k:lower(), v end local ok, err = pcall(parse_header, "???") print(ok) print(type(err), err.code, err.raw) -- false -- table EBADHDR ??? print(tostring(err)) -- [EBADHDR] ??? (expect name: value)
表错误过 pcall 后字段仍在,调用方能按 code 分支处理、能补字段再抛(错误链)。注意位置前缀只在字符串错误上有——表错误的定位靠 xpcall 的栈回溯补。工程惯例:要程序分支的错用表(code 字段),要人读的错用字符串,两层混合时表里带 message 字段。
assert(v, msg):v 为真(非 nil 非 false)返回全部参数,否则抛出 msg(缺省为 "assertion failed!"):
local function load_config(raw) local t = assert(load("return " .. raw))() -- 语法错在这里抛 assert(type(t) == "table", "config must be a table") return t end
两个高频用法:检查 load/require 的返回(它们失败返回 nil 加错误串,assert 直接把错误串变异常)与入口参数校验。一个必须知道的坑:assert 会丢弃 v 之后的额外返回值语义——assert(f()) 若 f 返回 false, "reason",reason 成为错误消息,行为正确;但 assert(f()) 若 f 返回 nil, "reason" 之外还有第三返回值,就会丢失。紧凑的写法是 local ok, err = ...; assert(ok, err) 或干脆用 if。
assert 与 error 的分工:assert 表达"这里必须成立"(契约检查、不变量),error 表达"发生了无法继续的事"(业务失败、资源不可用)。心理模型不同,代码读者感受不同。
pcall 的问题:错误值只有消息(或对象),现场(调用栈)已灭。xpcall 在错误发生的瞬间先调你给的 handler,趁栈还在抓取现场:
local function safe_call(fn, ...) local ok, result = xpcall(fn, function(err) return { err = err, trace = debug.traceback("call failed:", 2), at = os.date("%Y-%m-%d %H:%M:%S"), } end, ...) return ok, result end local ok, info = safe_call(function() return parse_header("bad line") end) print(ok, info.err, info.at) print(info.trace)
错误对象里带上了完整栈文本。宿主包装层的标准形态就是这个 safe_call:每回调一段脚本,失败时记日志(带栈)、报警(带 code)、返回安全默认值。第 8 章四个现场的错误处理,剥开配置差异全是这一个骨架。
协程的 resume 也遵循同款协议(7 章展开):local ok, v = coroutine.resume(co)——错误不炸宿主,以返回值形态浮出。
⚠️ 常见坑三条。其一,pcall 里 return 的值在成功路径是 true 加原返回值——取结果要从第二个下标开始。其二,xpcall 的 handler 里别再抛错,handler 出错会让 xpcall 自己炸。其三,pcall 会吞掉尾调用优化(保护壳需要保留返回路径),超深递归包 pcall 可能提前爆栈——深递归别包。
debug 库是"语言的白盒":绕过一切常规访问直接窥视运行时。日常三件:
-- traceback:栈回溯文本(上面用过) print(debug.traceback("here")) -- getinfo:某层栈的结构化信息 local function who_called_me() local info = debug.getinfo(2, "nSl") return ("%s at %s:%d"):format(info.what, info.short_src, info.currentline) end -- locals:列某层的局部变量(排错最后手段) local function dump_locals(level) local i = 1 while true do local name, value = debug.getlocal(level, i) if not name then break end print(name, tostring(value)) i = i + 1 end end
getinfo 的第二个参数挑要哪些字段(n 名字、S 来源、l 当前行)。debug.sethook 能挂单步与调用事件钩子,是调试器与性能采样器的地基——线上 profiler 多半就是 sethook 加计数。
能力即风险:debug 库能读局部变量、能改上值(setupvalue),沙箱环境照例把它列为看管对象(6.3 节)。Embed 本体的调试建议:小问题 print 大法(1.2 节),深问题 traceback 加 getinfo,宿主侧的调试器(ZeroBrane 一类 IDE、OpenResty 的 lua-cjson 栈打印)都是这几件的包装。
"错误"在工程上其实是三种东西,处理方式应该分开:
输入错误(用户或上游传了脏数据):不抛,返回 nil 加原因,让调用方决定怎么提示。tonumber("n/a") 的做法——转不动给 nil,天塌不下来。
状态错误(当前状态不允许此操作,如余额不足):返回失败加原因(业务分支要用的错误),或者抛表错误(带 code 字段)。业务失败用异常表达是反模式——它强迫每个调用点包 pcall,而 pcall 不是流程控制(9.3 节风格手册的老话)。
程序缺陷(不该发生的事发生了:类型不对、不变量被破坏):抛,狠狠地抛,越早炸越好。assert 是它的标准写法。这类错误在开发期爆掉是福气,在生产被壳接住记日志是底线。
三分类的判据是谁有能力处理:调用方有能力判断与恢复的(输入、业务状态)用返回值;只有程序员能修的(缺陷)用异常炸出来。把这个判据写进团队约定,错误处理的争论能少一半。
把本节工具拼一个能上战场的日志器雏形(无依赖版):
local Logger = {} Logger.__index = Logger local LEVELS = { debug = 10, info = 20, warn = 30, err = 40 } function Logger.new(out, threshold) return setmetatable({ out = out or io.stderr, threshold = LEVELS[threshold or "info"] }, Logger) end function Logger:log(level, msg, ctx) if LEVELS[level] < self.threshold then return end local line = ("%s [%s] %s"):format(os.date("%H:%M:%S"), level, msg) if ctx then for k, v in pairs(ctx) do line = line .. (" %s=%s"):format(k, tostring(v)) end end self.out:write(line, "\n") end function Logger:guard(fn, name) return function(...) local ok, r = xpcall(fn, function(err) return { err = err, trace = debug.traceback("guard:", 2) } end, ...) if not ok then self:log("err", ("callback %s failed: %s"):format(name, tostring(r.err))) self:log("debug", r.trace) return nil, r.err end return r end end return Logger
guard 是 safe_call 的日志落地版:包住任意回调,失败记错误与栈、成功透传结果。宿主的每个脚本挂载点(游戏事件、网关钩子、定时任务)都套一层 guard,"脚本永不带崩宿主"就有了物理保证——这是第 8 章四大现场错误处理的统一内核,值得抄进任何项目。
把分类学落成一套可复用的错误码体系(适配层的进阶版):
local Err = {} Err.__index = Err Err.__tostring = function(e) return ("[%s] %s"):format(e.code, e.msg) end function Err.new(code, msg, extra) return setmetatable({ code = code, msg = msg, extra = extra }, Err) end -- 约定的错误码空间:调用方只认 code 做分支 Err.MISSING = "missing" Err.WRONGTYPE = "wrong_type" Err.TIMEOUT = "timeout" Err.BUSY = "busy" -- 重试包装:只对"值得重试"的错误码重试 local function with_retry(fn, opts) opts = opts or {} local times, backoff = opts.times or 3, opts.backoff or 0.1 return function(...) local last for attempt = 1, times do local ok, r = pcall(fn, ...) if ok then return r end -- 只重试网络类错误;类型错误重试一万次也一样 local code = type(r) == "table" and r.code or "unknown" if code ~= Err.TIMEOUT and code ~= Err.BUSY then error(r, 2) -- 不可重试:原样上抛 end last = r if attempt < times then os_time_sleep(backoff * (2 ^ (attempt - 1))) end end return nil, last -- 重试耗尽:返回最后的错误 end end function os_time_sleep(s) end -- 宿主注入的睡(示意) -- 使用:调用方按 code 分支,日志按 tostring 出人话 local fetch = with_retry(function(url) if url:match("timeout") then error(Err.new(Err.TIMEOUT, "upstream slow"), 2) end return "body-of-" .. url end) print(fetch("timeout-x")) -- nil [table: timeout] print(tostring(select(2, fetch("timeout-x")))) -- [timeout] upstream slow
四件套齐活:错误对象(code 可分支、__tostring 可读);码空间集中声明(调用方不出现魔数字符串);重试包装只认"值得重试"的码(指数退避);pcall 壳内消化不可重试错。backoff 指数增长(0.1、0.2、0.4)是给上游喘息的标准礼数。这套结构在第 8.2 节网关的 upstream 重试、8.3 节 EVALSHA 的 NOSCRIPT 降级里反复变装出现——错误码 + 分类重试是宿主世界的通用错误语法。
error(msg, 2)(归因调用者,写库必带)、表对象(结构化 code 上下文,配 __tostring);下一章的协程会复用"ok 加结果"的错误协议。下一节先巡礼标准库、画出沙箱边界。