自从下决心对Silly进行大规模重构以来,最近三个月,我几乎把所有业余时间都投入到了这次重构中。
虽然重构还未完全结束,不过总算大局已定。趁现在记忆还算清晰,先总结一下。
这次重构,更像是一次代码风格的统一。
这个仓库存在时间太久了,几乎囊括了我所有时期的代码风格,因此即便都是我写的,也存在风格不一致的情况。
在重构过程中,我重新明确了许多规范和原则。
借助Claude Code,实际编码的时间并不算多,更多的时间花在了取舍、折中和推翻已有设计上。
先是命名规则和命名空间
所有导出函数必须控制在两个单词以内,这一设计借鉴自 C 语言。
这样既能更好地兼容业务层的多样命名风格,也符合 Lua 的习惯。
如果超过两个单词,通常意味着模块功能过于复杂,需要拆分。
有些模块仅为了可读性而拆分,但内部实现高度耦合,因此需要共享一些函数。这些共享函数统一以 _ 开头。
之所以不用 __,是因为 Lua 保留了 __ 用作元方法,我猜测 Lua 是故意将 _ 开头的命名空间留给我们使用(没有深入考究 :D)。
Silly 的所有库都位于 silly 命名空间下,通过 require "silly.xxx" 引用。
其中,属于框架核心的基础能力——如时间、网络、任务等与 IO/time 紧密相关的部分——都放在 silly 根命名空间,例如:
silly.timesilly.netsilly.task
其他功能则拆分到二级命名空间中(第三级一定是模块名称,而不是子命名空间,以保持目录结构扁平),例如:
silly.net.tcpsilly.encoding.jsonsilly.adt.queuesilly.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 设计。
在刚写 tcp/tls 模块时,我认为网络资源是有限的,因此必须手动管理连接。
既然要手动管理,__gc 就变得不那么必要,面向对象的调用方式也就没必要了。
再加上我发现 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)
在我意识到DuckTyping的同时,我认为使用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 模式,即:要么读取到完整满足条件的数据量(例如指定的字节数或行分隔符),要么就返回错误。它不会像传统的流式读取(Stream Read)那样,只要读到一点数据就返回(Short Read)。
以前 read 失败就返回 nil, error,但在全面考虑后发现很多场景没被覆盖,尤其是加入读取超时(read timeout)后,问题变得更加复杂,例如:
-
如何判断是连接出错还是正常的 EOF?
-
读取超时时,如何区分是
timeout还是连接出错? -
在调用
conn:read(1024)时连接断开或出错,如何把剩余数据读出?
经过大约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 那样采取3返回值的设计。
斟酌再三,我引入了 conn:unreadbytes() 接口,他返回当前连接剩余未读取数据的字节数。
在现有的设计中,不管 conn:isalive()返回什么,只要缓冲区中有满足条件的数据都不会返回错误。
因此,我们完全可以使用conn:read(conn:unreadbytes())来读取剩余的数据。
终于,通过新增2个正交的 API,我终于覆盖了我能想到的所有场景。
将链接重构为 OO 模式之后,一个随之而来的问题摆在面前:到底要不要为每个 conn 对象设置 __gc 函数。
这又引发另一个更深层次的问题,在整个系统中,我们的 GC 引用链应该是怎么样的。
在以前的设计中,由于需要手动 close, 所有的 socket 对象都被全局的 socket_pool 引用着。
虽然为每个 socket 对象设置了 __gc 函数,但在强引用路径存在的情况下,这些 __gc 函数只会在进程结束时执行。
也因为此,当时的 GC 引用链是这样的:socket_pool -> socket -> suspend coroutine。
其中,当coroutine执行tcp.read(fd, n)时,如果条件不满足,它会把当前coroutine挂到对应的socket对象上, 让 socket 成为协程的“锚点”,避免coroutine被 GC 回收。
Silly的调度器因此对挂起的coroutine仅使用弱引用,coroutine在被挂起前,必须先找到一个正确的“锚点”,否则就会被 GC 清理掉。
我不禁在思考,当前设计是否正确。
这种设计唯一的优点就是:被挂起的 coroutine 可以被 GC 回收。
但是,只有在 coroutine 之间互相等待并形成死锁(例如两个协程使用 silly.sync.channel 相互等待)时,它们才会因为无人强引用而一起被 GC 回收。
除此之外,大多数挂起的 coroutine 都必然会挂在某个全局引用链上(常见是 socket)。
缺点看起来就很显眼: 每个 C 扩展模块必须建立自己的强引用链,以保护挂起 coroutine 不被 GC 清理。
并且各个C模块的锚点并不相同,这增加了扩展模块的心智负担。并且这种设计看起来不那么简洁。
我仔细分析了线程以及 goroutine 的设计。
作为运行实体,线程与 goroutine 在系统中总是被区别对待,不会被当成普通内存对象来管理。
比如:pthread 通常要求显式退出,而挂起的 goroutine 如果没有妥善管理则会造成泄露。
如果让调度器对所有挂起的 coroutine 进行强引用,那么 conn 就可以改为弱引用,它的 __gc 也能在忘记 close 时承担兜底的清理职责。
更重要的是,扩展 C 模块时,不再需要费心为挂起的 coroutine 提供强引用锚点,整体设计会简洁不少。
当然,代价也摆在这里:挂起的 coroutine 将不再可能被 GC 回收。
好在 silly.task 模块提供了相关的观测接口,可以展示挂起 coroutine 的调用栈,方便排查问题。
权衡之后,我最终选择接受这个缺点,并将调度器中对挂起 coroutine 的引用改为强引用。