自从下决心对我的 Lua 项目 Silly 进行大规模重构以来,最近三个月几乎搭上了所有的业余时间。虽然重构还未彻底画上句号,但整体架构已然清晰。趁记忆还新鲜,先记录下这次重构的核心思考。
这次行动,更像是一次代码风格的大一统。这个仓库历经岁月,几乎是我各个时期编程风格的活化石,因此即便全是我的手笔,风格也难免打架。借助 Claude Code 的帮助,实际编码耗时不多,更多时间消耗在了各种“纠结”上:取舍、折中,甚至推翻已有的设计。
命名规则与命名空间的重新定义
首先是命名。我定下规矩:所有导出函数必须控制在两个单词以内,这个灵感源于 C 语言的简洁传统。这样做既能更好地兼容上层业务多样的命名习惯,也契合 Lua 本身的风格。如果一个模块的函数名轻易就超过了两个词,那通常意味着它的功能过于臃肿,需要拆分。
有些模块为了可读性被拆开,但内部实现却紧密耦合,需要共享一些函数。这些共享函数,我统一用 _ 开头命名。不用 __,是因为 Lua 自己保留了 __ 用作元方法。我猜 Lua 是故意把 _ 开头的命名空间留给我们用的(没有深入考究
)。
Silly 的所有库都位于 silly 这个根命名空间之下,通过 require "silly.xxx" 的方式引用。其中,属于框架核心的基础能力——比如与 IO/时间紧密相关的时间、网络、任务等——都直接放在 silly 根下,例如:
silly.time
silly.net
silly.task
其他功能则划分到二级命名空间中(并且规定,第三级一定是具体的模块名,而不是子命名空间,以保持目录结构扁平),例如:
silly.net.tcp
silly.encoding.json
silly.adt.queue
silly.store.etcd
采用这种布局,是希望二级命名空间能覆盖常用功能域,使用时能快速定位,同时又避免命名空间泛滥导致混乱。即便某些功能现在没有,未来也可以直接补充到已有的命名空间下,无需频繁新增。这也是我前面说“大局已定”的原因之一。
正是这种约束,让我在 silly.store 和 silly.security 这两个二级命名空间上反复推敲。以 silly.security 为例,它的诞生是因为我找不到 JWT 算法的合适归属:它既不属于 crypto(加密),也不属于 cipher(密码)。斟酌数日,我选择了 security 这个含义宽泛的词,这样未来所有安全相关功能(如 OAuth2.0)都能塞进来。
Silly.store 的名字来得更坎坷。原先仓库里有个很直白的名字 silly.db,下面有 silly.db.mysql 和 silly.db.redis 两个模块。然而,etcd 虽有持久化和强一致性,但严格来说并非数据库,而且几乎找不到它的同类(或许只有 Zookeeper,但也不完全一样)。如果把它硬塞进根空间又不合适,我几乎就要妥协,将 etcd 也放进 silly.db。
无奈之下,我和几个主流大模型都讨论了一番,其中一条回复点醒了我:“如果以后打算支持 AWS 的 S3 操作,也可以放入 store 命名空间”。这让我最终拍板选择了 store。store 可以泛指所有存储操作:etcd 虽不是数据库,但它是存储;mysql 和 redis 当然也属于存储。更重要的是,这符合我的设计理念:二级命名空间的数量要适中。当我需要查找某个模块时,其“查找成本”大约为 m + n/m,其中 m 是二级命名空间数量,n 是所有模块数量。虽然 store 过于泛化,查找 redis/mysql 时可能产生一点干扰,但我选择接受这个缺点——有得必有失嘛。
API 设计的范式转变
接下来是 API 设计。在最初编写 tcp/tls 模块时,我认为网络资源是有限的,必须手动管理连接。既然要手动管理,那么 __gc(垃圾回收元方法)就非必需,面向对象(OO)的调用方式也就没必要了。再加上我发现 skynet 也是类似实现,这给了我很大信心。
早期的 API 设计是这样的:
local fd = tcp.connect("127.0.0.1:553")
local line = tcp.readline(fd, 5)
tcp.close(fd)
随后在实现 http/websocket 等协议时,我不断对 tcp/tls 做封装。出于性能上的强迫症,我甚至用闭包生成不同的 read 函数来减少哈希查找次数,例如:
local function wrap_read(io)
local read = io.read
local readline = io.readline
local fn = function(fd)
local line = readline(fd, "\n")
local n = string.pack("<I4", line)
return read(fd, n)
end
return fn
end
如今我的“性能强迫症”大为好转,重新审视这种模式后,我认为这是一种过度设计:它增加了实现的复杂度,且浪费了动态语言鸭子类型(Duck Typing) 的优势。
最终,我把 tls/tcp/udp 的 API 全部重构为 OO 风格,就像下面这样:
local conn = tcp.connect("127.0.0.1:553")
local line = conn:read("\n")
conn:close()
在这种设计下,上层模块只需在入口处使用不同的连接函数,即可无缝切换 tcp/tls 的支持。相比之前的闭包方案,结构更加清晰,连接相关的细节也不会再渗透到代码的各个角落。
与此同时,我还简化了另一对 API:“读指定字节”和“读一行”。过去它们是两个独立的函数:
local line = tcp.readline(fd, "\n")
local data = tcp.read(fd, 1024)
在我重新领悟“鸭子类型”妙处的同时,我认为特意用 readline 来区分“读一行”也是一种过度设计,并不符合动态语言的惯例。当我们要读取一行时,传入的参数必然是字符串分隔符;要读取指定字节时,传入的必然是数字。完全可以通过第二个参数的类型,来动态决定行为。
于是,我去掉了 readline,只保留一个 read,用法如下:
local line = conn:read("\n") -- 传入字符串,按行读取
local data = conn:read(1024) -- 传入数字,读取指定字节数
我还简化了“读一行”时分隔符的语义。以前的实现支持任意长度的分隔符,例如 tcp.readline(fd, "\r\n")。虽然 Redis(RESP协议)和 HTTP 规范都要求使用 CRLF(即 \r\n),但在实践中仅用 \n 切分也能正常工作。于是我移除了对长度大于1的分隔符的支持——你仍然可以自定义分隔符,但只允许长度为1。这样做能大幅简化底层实现、减少复杂度。如果将来真有需要,再加回来也不迟。
这次 API 重构中最困难的部分,是为 conn:read 明确定义其语义。我将 conn:read 定义为 严格读取(Strict Read) 模式:要么读取到完整满足条件的数据量(指定的字节数或遇到行分隔符),要么就返回错误。它不会像传统的流式读取那样,只要读到一点数据就立即返回。
以前的 read 失败就返回 nil, error,但在全面考量后,发现很多场景没被覆盖,尤其是加入读取超时(read timeout) 后,问题变得更加复杂:
- 如何判断是连接出错,还是正常的文件结束符(EOF)?
- 读取超时时,如何区分是
timeout 还是连接出错?
- 在调用
conn:read(1024) 时连接断开或出错,如何把 Socket 缓冲区里剩余的数据读出来?
经过大约 3 到 4 个版本的推倒重来,最终方案如下:
- 如果
conn:read 能读到满足条件的数据,直接返回数据。
- 如果因为 EOF 导致读取失败,返回
"", "end of file"。
- 其他失败情况返回
nil, error。
这样,判断 EOF 就变得极其简单:只需检查返回值是否为空字符串即可。这里有个特殊情况:调用 conn:read(0) 时,会返回 "", nil。这是借鉴自 Berkeley Socket API 的行为:调用 read(fd, buf, 0) 会返回 0 而不是 -1。
为了区分 timeout 与其他错误,我额外增加了 conn:isalive() 接口,返回布尔值表示连接是否仍存活。发生非 timeout 错误时,conn:isalive() 总是返回 false。
第三个问题最棘手。我认为,Berkeley Socket API 之所以将阻塞式 read/recv 设计为“只要读到至少一个字节就返回”,就是为了解决这个问题。Lua Socket 则采用了更“粗暴”的三值返回:nil, error, partial,通过 partial 返回不满足读取条件的剩余数据。
我既不想像 Berkeley Socket API 那样“有数据就返回”(这会增加上层模块的解析负担,且在 Lua/C 交互时产生大量垃圾对象),也不想像 Lua Socket 那样采用三返回值的设计。
斟酌再三,我引入了 conn:unreadbytes() 接口,它返回当前连接缓冲区中剩余未读取数据的字节数。在现有设计中,只要缓冲区中有满足条件的数据,无论 conn:isalive() 返回什么,read 都不会报错。因此,我们完全可以使用 conn:read(conn:unreadbytes()) 来读取剩余的数据。
终于,通过新增 isalive 和 unreadbytes 这两个正交的 API,我覆盖了所有能想到的场景。
GC 引用链的重新权衡
将连接重构为 OO 模式后,一个随之而来的问题摆在面前:到底要不要为每个 conn 对象设置 __gc 元方法?这又引发了另一个更深层次的问题:在整个系统中,GC(垃圾回收)的引用链应该是怎样的?
在以前的设计中,由于需要手动 close,所有的 socket 对象都被一个全局的 socket_pool 强引用着。虽然为每个 socket 对象设置了 __gc 函数,但在强引用路径存在的情况下,这些 __gc 函数只会在进程结束时才会执行。
也因为如此,当时的 GC 引用链是这样的:socket_pool -> socket -> suspend coroutine(挂起的协程)。具体来说,当协程执行 tcp.read(fd, n) 时,如果条件不满足,它会把当前协程挂到对应的 socket 对象上,让 socket 成为协程的“锚点”,避免协程被 GC 回收。Silly 的调度器因此对挂起的协程仅使用弱引用。协程在被挂起前,必须先找到一个正确的“锚点”,否则就会被 GC 清理掉。
我不禁开始思考,当前设计是否正确。这种设计唯一的优点是:被挂起的协程可以被 GC 回收。但是,只有在协程之间互相等待并形成死锁时(例如两个协程使用 silly.sync.channel 互相等待),它们才会因为无人强引用而一起被 GC 回收。除此之外,大多数挂起的协程都必然会挂在某个全局引用链上(常见的就是 socket)。
缺点则很明显:每个 C 扩展模块都必须建立自己的强引用链,以保护挂起的协程不被 GC 清理。并且各个 C 模块的“锚点”并不相同,这增加了开发扩展模块的心智负担,整体设计看起来也不够简洁。
我仔细分析了操作系统线程以及 Go 语言中 goroutine 的设计。作为运行实体,线程与 goroutine 在系统中总是被区别对待,不会被当成普通的内存对象来管理。例如:pthread 通常要求显式退出,而挂起的 goroutine 如果没有被妥善管理则会造成内存泄露。
如果让调度器对所有挂起的协程进行强引用,那么 conn 对象就可以改为弱引用,它的 __gc 元方法也能在开发者忘记 close 时承担兜底清理的职责。更重要的是,开发 C 扩展模块时,不再需要费心为挂起的协程提供强引用锚点,整体设计会简洁不少。
当然,代价也很明显:挂起的协程将不再可能被 GC 自动回收。好在 silly.task 模块提供了相关的观测接口,可以展示挂起协程的调用栈,方便排查问题。权衡之后,我最终选择接受这个缺点,并将调度器中对挂起协程的引用改为了强引用。
每一次重构都是一次与过去自己的对话,充满了艰难但必要的抉择。希望这些关于命名空间、API语义和GC机制的设计思考,能给同样在打磨自己技术产品的你带来一些启发。如果你对系统设计、并发模型或Lua生态有更多兴趣,欢迎到云栈社区交流探讨。