给silly实现了一个ernro模块

去年重构 silly.net.tcp 模块时,为了识别 read 是否已经读到连接关闭,我参考了 Berkeley Sockets APIread 返回 0 的语义,并以返回空字符串来表示这一状态。

但由于设计的是 Full Read 模式,为了判断本次 read 是否获取到完整数据,我在返回 "" 的同时,将错误设置为 end of file

这样,当不关心具体错误原因时,只需判断 err 是否为 nil,即可得知当前读取的数据是否完整。

如果需要判断是否为 end of file,则可以在 err ~= nil 的前提下,检查返回的数据是否为空字符串;若为空,则表示读到了 end of file

而判断是否为 read timeout,则可以在发生错误时,通过 conn:isalive() 判断连接是否仍然存活,因为只有 read timeout 不会导致连接断开。

这里存在竞态场景,例如刚发生超时,随后连接又被关闭。此时 read 返回 nil, "read timeout",但 isalive() 却返回 false。不过连接此时本就已经断开,因此影响相对有限。

之所以没有将 end of fileread timeout 设计得更直观,是因为在现有抽象中,string 形式的 error 不具备可比较性。无法保证在其他上下文中不会返回语义不同但字面相同的 end of file。例如在 http 连接复用场景下,只要读满 content-length,也可能返回 end of file,但这并不意味着连接关闭。

最初设计用这两个返回值表达三种状态(正常、失败、end of file)时,我还自豪的认为已经将 2bit 的表达能力利用到了极致。

然而在随后几个月的实践中,即使作为作者,我也多次踩坑,经常不自觉地写出如下代码:


local conn = tcp.connect("127.0.0.1:6379")
while true do
    local dat, err = conn:read("\n")
    if not dat then
        break
    end
    ... do something with dat
end

这段代码在遇到 end of file 时会陷入死循环。

根本原因就在于,这种使用方式不直观,也不符合 Lua 的常见习惯,而且会带来额外的心智负担。

此外,由于向已关闭连接执行 write 时,返回的错误可能是 broken pipe 而非 end of file,因此该死循环并非必现,更增加了排查难度。

在之后使用 Claude 编写相关代码时,也多次遇到同样的问题,这促使我重新审视「符合直觉」的重要性。

-----------------

经过这两个月的反复权衡,我最终决定引入 silly.errno,并建立清晰的抽象边界来解决这一问题。

在当前的 silly 实现中,所有来自 C 层的错误码,在进入 Lua 层之前都会被转换为 string。这样做是因为Lua的字符串既具备良好的可读性,又不会带来额外的判断开销。

接下来需要明确一个边界:哪些 string 类型的 error 可以比较,哪些不应比较。

观察后发现,需要进行比较的错误,通常来自 C 底层,或是对其进行薄封装的模块,例如 silly.netsilly.net.tcpsilly.net.tlssilly.net.udp。

而在其之上的模块,一旦发生错误,要么是协议层自定义的错误(如 mysql/redis/http),要么属于不可恢复错误,本身不适合参与比较。

基于此,我引入了 silly.errno 类型:silly.netsilly.net.tcpsilly.net.tlssilly.net.udp 返回的 error 均为 silly.errno;其他模块则继续返回 stringsilly.errno 的值是可比较的,可以与 silly.errno.XXX 进行判断;而普通 string 类型的 error 不具备这一特性。

从类型关系上看,silly.errno 派生自 string,即它是 string 的子类型。这样设计的原因在于:当上层模块需要返回 string 类型的 error 时,可以将底层返回的 silly.errno 进行 upcast 后继续向上传递。一旦发生 upcast,即意味着该错误丧失了可比较性。

这一设计也预留了扩展空间。目前用于返回与比较的字符串均由 silly.errno 模块导出。若未来 string 难以支撑更复杂的需求,可以在不破坏兼容性的前提下,将 silly.errno 扩展为带有 __tostring 元方法的 table 类型。

ps. 由于我在标准库 errno.h 的错误码基础上扩展了一部分自定义错误码,为避免这些错误码对应的字符串与系统错误发生意外冲突,我在 Lua 层对错误字符串的格式做了轻微调整,类似这样:snprintf(buf, sizeof(buf), "%s (%d)", strerror(errno), errno);。

pps. 在设计该模块之初,一直问自己:是否有必要仅为 end of filetimeout 的可比较性,就重构整套 errno 机制。最终我的答案是:符合直觉应当高于一切,这也更符合最小知识依赖原则。

发表评论

eight × = 40
Powered by MathCaptcha