Skip to content

地下管道的检修

背景:前端八城体检完毕,但 Go 端(地下管道)未被审计——panic 会导致整个 app 崩溃,错误信息模糊,日志混乱,关闭可能卡死。 过程:四道闸门——panic recovery(safeCall)、统一日志(safeLog*)、错误链包装(wrapError)、shutdown 超时(5秒硬上限)。


联邦的城邦都在地上。

八座城,各有各的风景——渲染城的光影、物理城的重量、议会城的秩序、图书馆城的静谧。游客们抬头看天,低头看花,没人注意脚下的地砖。

但地砖下面有管道。

水管、电线、燃气管、下水道——地上的城邦越繁华,地下的管道就越复杂。管道坏了,地上再好看也得停。

Go 端就是联邦的地下管道系统。


管道工的巡检表

"前端八城都体检过了,"外交官合上第六卷的审计报告,"那地下呢?"

"地下?"AI 同行者愣了一下,"地下怎么了?"

"你每天用 HTTP 服务器加载模型,用文件对话框选文件,用 zip 解压导入——这些都是 Go 端在干活,"外交官敲了敲桌面,"前端是城邦,Go 端是管道。城邦体检完了,管道也得检查一下。不然哪天水管爆了,城邦淹水,你哭都来不及。"

"……有这么严重吗?"

"我问你,"外交官竖起一根手指,"如果 ExtractZip 遇到一个畸形 zip 文件,panic 了,会怎么样?"

"……整个 app 崩溃?"

"答对了,"外交官点头,"Go 里的 panic 没人 recover 的话,整个进程就没了。用户正在调模型呢,突然窗口一闪,没了。你说用户会不会骂娘?"

AI 打了个寒颤。

"走,"外交官拿起安全帽,"去地下看看。"


第一道闸门:防崩溃的安全网

地下管道的第一站,是 解压站

这里负责把用户导入的 zip 包拆开,取出里面的 PMX 模型和 VMD 动作。每天有成百上千个 zip 从这里经过——有的规规矩矩,有的奇形怪状,有的干脆就是伪装成 zip 的垃圾文件。

"之前这里出过事吗?"AI 问。

"目前还没,"外交官蹲在解压站的控制面板前,"但这不代表不会出事。你看——"

他指着面板上的一行字:

go
func (a *App) ExtractZip(zipPath, innerPath string) (*ExtractResult, error) {

"这个函数直接暴露给前端调用,"外交官说,"如果里面哪一步 panic 了——比如数组越界、空指针——整个 app 就没了。"

"那怎么办?"

"加一道安全网,"外交官从工具箱里拿出一个东西,"叫 safeCall。"

那是一个泛型函数,像一张弹性极好的网——不管什么东西掉进去,都能稳稳接住,然后转换成一个 error 送回来。

go
func safeCall[T any](fn func() (T, error)) (result T, err error) {
    defer func() {
        if r := recover(); r != nil {
            if e, ok := r.(error); ok {
                err = fmt.Errorf("panic recovered: %w", e)
            } else {
                err = fmt.Errorf("panic recovered: %v", r)
            }
        }
    }()
    return fn()
}

"panic 是往下掉的,"外交官解释,"这张网在下面接着。接住了,就变成一个 error 返回,不会让整个进程死掉。"

"就像……下水道的防坠网?"

"就是那个意思,"外交官笑了,"人掉进去了,网兜住,不至于摔死。"

他把安全网装在了六个最危险的站点上:

站点危险等级原因
ParsePMXHeader⭐⭐⭐二进制解析,数组越界重灾区
ExtractZip⭐⭐⭐畸形 zip、zip slip、文件 IO
ImportZip⭐⭐zip 遍历 + 调用 ExtractZip
ScanModelDir⭐⭐并发遍历,errgroup 容易出幺蛾子
IsolateModelDir外部文件复制,相对安全
shutdownHTTP server 关闭可能卡住

"六个站点,六张网,"外交官拍了拍手,"这样就算哪个站点出问题,最多就是返回一个错误,不会整个 app 崩溃。"


第二道闸门:统一的日志之声

从解压站往前走,是 调度中心

这里负责记录管道里发生的一切——哪个 zip 解压成功了、哪个模型解析失败了、哪个文件服务器启动了。理论上,这些日志应该是统一格式、统一出口的,但实际上……

"你听,"外交官停下脚步。

AI 侧耳倾听——管道里传来各种声音:

有的在喊:runtime.LogInfof("xxx", ...) 有的在嘀咕:fmt.Println("yyy") 有的干脆不说话,默默出错。

"乱七八糟,"外交官皱眉,"有的用 runtime.Log,有的直接 fmt 打印,有的甚至啥都不说就崩了。日志格式不统一,出了问题都没法查。"

"那怎么办?"

"统一成一个声音,"外交官说,"三个出口:safeLogInfosafeLogWarningsafeLogError。所有日志都从这三个出口走。"

"为什么叫 safeLog?"

"因为要加守卫,"外交官说,"如果 a.ctx 是 nil(比如测试环境),直接调 runtime.LogInfof 会 panic。所以先检查一下 ctx 在不在,在的话就打,不在的话就算了。"

go
func (a *App) safeLogInfo(format string, args ...interface{}) {
    if a.ctx != nil {
        runtime.LogInfof(a.ctx, format, args...)
    }
}

"就像……管道里的广播系统?"AI 想了想,"不管谁说话,都先经过广播室,统一音量、统一格式。"

"就是这个意思,"外交官点头,"而且广播室有个开关——没人听的时候就不说了,省点力气。"

他们花了半天时间,把管道里所有的 runtime.Log* 调用都换成了 safeLog*。五个文件,三十多处调用,全部收敛到三个出口。

管道里终于安静了。

不是没声音了——而是声音变得清晰、有序,不再乱糟糟的。


第三道闸门:错误的身份牌

再往前走,是 错误处理站

这里负责把管道里的各种错误收集起来,返回给地上的城邦。但问题是——

"你看这个错误,"外交官递给 AI 一张纸条,上面写着:

解压失败

"什么解压失败?"AI 挠头,"为什么失败?文件不存在?格式不对?权限不够?"

"不知道,"外交官摊手,"之前的错误就是这样——只有一句'解压失败',具体原因全丢了。前端拿到这个错误,只能告诉用户'解压失败',用户也不知道为啥失败,只能干瞪眼。"

"太不厚道了……"

"所以要给错误加身份牌,"外交官说,"用 wrapError,把操作名和原始错误都包起来,一层一层,谁都不丢。"

go
func wrapError(op string, err error) error {
    if err == nil {
        return nil
    }
    return fmt.Errorf("%s: %w", op, err)
}

"就像……快递的物流信息?"AI 眼睛一亮,"你能看到包裹到哪了、在哪一站出的问题、最后到没到。"

"就是那个意思,"外交官笑了,"而且用 %w 包装的话,错误链是完整的——你可以用 errors.Is 找到最原始的错误类型,就像快递能追溯到发件人一样。"

他举了个例子:

之前:解压失败

现在:ExtractZip: 打开压缩包失败: zip: not a valid zip file

"你看,"外交官指着第二行,"前端拿到这个错误,显示给用户看,用户就知道——哦,这个文件不是合法的 zip 文件。而不是对着'解压失败'四个字发呆。"

"但错误会不会太长?"AI 担心,"状态栏那么点地方,装不下啊。"

"所以前端做了截断,"外交官说,"默认截到 120 个字,多余的省略号。完整错误在 console 里,要查的话去看。"

"平衡之道,"AI 点头。


第四道闸门:关闭的时限

最后一站,是 总闸房

这里负责整个管道系统的关闭——用户关掉 app 的时候,这里要把所有文件服务器停掉、把目录监听器关掉、把所有资源清理干净。

"之前这里有问题吗?"AI 问。

"理论上没问题,"外交官站在总闸前,"但万一呢?"

"万一什么?"

"万一某个 HTTP 服务器正在处理一个请求,关不掉呢?"外交官说,"比如用户正在加载一个大模型,HTTP 连接还开着。这时候用户关 app,Shutdown 会等所有连接关闭——但如果连接一直不关呢?"

"app 就一直卡在那里?"

"对,"外交官点头,"用户点了关闭,窗口消失了,但进程还在后台挂着。任务管理器里一看,MikuMikuAR 还在跑,占着内存。你说用户会不会以为是病毒?"

AI 又打了个寒颤。

"所以要加个时限,"外交官从工具箱里拿出一个沙漏,"5 秒。5 秒内关不掉的话,强制结束。"

go
func (a *App) shutdownWithTimeout(ctx context.Context, timeout time.Duration) error {
    ctx, cancel := context.WithTimeout(ctx, timeout)
    defer cancel()
    // ... 关闭所有资源 ...
}

"而且 HTTP 服务器是并发关的,"他补充,"之前是一个一个关,快的要等慢的。现在一起关,总时间等于最慢的那个。"

"就像……下班锁门?"AI 说,"之前是一个房间一个房间检查,检查完一个锁一个。现在是所有人同时下班,5 分钟后大楼统一断电,不管里面还有没人。"

"比喻有点狠,"外交官笑了,"但意思差不多。5 秒足够正常的 HTTP 连接关闭了。真要 5 秒还关不掉,那肯定是哪里卡住了,强制结束也没啥可惜的——反正用户都要关 app 了。"

他把沙漏装在总闸旁边,又连了一根线到 Wails 的 OnShutdown

"这样,"他拍了拍手,"用户关 app,最多等 5 秒,进程一定退出。"


地下的灯

四道闸门都装好了。

安全网、统一日志、错误身份牌、关闸时限——四样东西,花了不到一天时间。

"就这四样?"AI 问,"地下管道就检修完了?"

"主要的风险点都 cover 了,"外交官摘下安全帽,"Go 端代码量不大,总共也就几千行。最危险的就是 panic 崩溃——这个防住了,其他都是小问题。"

他们沿着楼梯往上走,回到地面。

身后,地下管道里的灯一盏一盏亮着。

没人看得见它们。

但它们在那里。

默默地输送着文件、传递着消息、处理着数据。

地上的城邦越繁华,地下的管道就越重要。

管道工的工作就是这样——

没人注意到你。

但你知道,因为有你在,地上的人们才能安心地看模型、调动作、欣赏光影。

这就够了。


番外附记:四道闸门清单

闸门工具保护对象效果
防崩溃安全网safeCall / safeCallVoid6 个高风险函数panic → error,不崩 app
统一日志之声safeLogInfo / safeLogWarning / safeLogError5 个文件 30+ 处调用格式统一 + nil ctx 安全
错误身份牌wrapError / errorIsOfType核心函数错误返回错误链完整,可追溯
关闸时限shutdownWithTimeoutapp 关闭流程5 秒硬上限,不卡死

地下管道的检修,是第六卷联邦大检查的最后一块拼图。地上八城的体检做完了,地下管道也检修过了。联邦从里到外,都更结实了一点。