在去年重构 silly.net.tcp 模块时,为了识别 read 是否已经读到连接关闭,我参考了 Berkeley Sockets API 中 read 返回 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 file 和 read 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.net、silly.net.tcp、silly.net.tls、silly.net.udp。 而在其之上的模块,一旦发生错误,要么是协议层自定义的错误(如mysql/redis/http),要么属于不可恢复错误,本身不适合参与比较。 基于此,我引入了silly.errno类型:silly.net、silly.net.tcp、silly.net.tls、silly.net.udp返回的error均为silly.errno;其他模块则继续返回string。silly.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 file与timeout的可比较性,就重构整套errno机制。最终我的答案是:符合直觉应当高于一切,这也更符合最小知识依赖原则。