6.2 错误处理与调试


文档摘要

6.2 错误处理与调试 本节摘要:Lua 的错误模型是"错误即值":error 抛出、pcall 兜住、返回值里传递。error 的第二个参数控制报错位置归因,错误对象(表挂 )携带结构化上下文;assert 把"检查加抛错"压成一行;xpcall 在兜错的同时抓取调用栈。本节组装出"宿主安全调用脚本"的标准防护壳——四大现场共用的错误边界协议,然后过一遍 debug 库的定位工具。 谁来兜住错误 先看一场事故的两种结局。脚本里有个函数会在特定输入下抛错: 没有防护:错误一路向上炸穿所有调用者,最后到达顶层。独立解释器下打印栈回溯然后退出——无伤大雅;宿主进程下,如果宿主没在 C 边界装保护(第 9 章的 luapcall),进程直接崩掉,一次坏输入带走整个服务。

6.2 错误处理与调试

本节摘要: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、与异常栈回溯都不同——错误不自动传播,每一层显式决定:兜住、加工、还是重新抛出

06-02-fig01

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:一行式卫语句

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。

asserterror 的分工:assert 表达"这里必须成立"(契约检查、不变量),error 表达"发生了无法继续的事"(业务失败、资源不可用)。心理模型不同,代码读者感受不同。

pcall 与 xpcall:兜错的两个层次

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 库:定位三件套

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 抛、pcall 兜、返回值传——每层显式决定兜住、加工或上抛,没有自动传播;
  • error 三形态:字符串(带位置前缀)、error(msg, 2)(归因调用者,写库必带)、表对象(结构化 code 上下文,配 __tostring);
  • assert 是一行式卫语句,表达"必须成立";与 error 的"无法继续"分工明确;
  • 错误三分类:输入错误返回 nil 加原因、状态错误返回失败或表错误、程序缺陷狠狠抛——判据是谁有能力处理;
  • xpcall 加 handler 趁栈未灭抓现场,guard 包装是宿主回调的标准防护壳,四大现场同款;
  • pcall 取结果从第二个下标起,handler 内别抛错,pcall 吞尾调用优化;
  • debug 三件套:traceback、getinfo、getlocal;sethook 是调试器与采样器的地基;能力越强越要进沙箱看管名单。

下一章的协程会复用"ok 加结果"的错误协议。下一节先巡礼标准库、画出沙箱边界。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U