第5章:程序入口与依赖装配
本章逐行解读 cmd/webserver/main.go——全书唯一的组合根(Composition Root)。240 行的 main 完成了配置加载、基础设施初始化、十余个对象的构造与注入、四组 Worker 与定时器的启动、HTTP 服务的生命周期管理。读懂它,整个系统的运行时拓扑就全部展开。
流程
main 的装配严格按依赖方向推进,任何一个环节失败立即 Fatal 退出(fail-fast):
flag解析-config路径 →config.Load得到全局配置;bootstrap.InitLogger/bootstrap.InitDatabase/storage.NewClientFromAppConfig初始化日志、连接池、对象存储;- 构造五个 repository(account / live_material / video_project / llm_prompt / task);
- 构造下载器与 URL 重写器 → 三个 LLM 客户端实例(ASR 后处理 / 切片 / 对话各一份,同配置不同用途)→ ASR 服务与音频预处理器 → ASR Worker;
- 构造 capcutmate 客户端 →
draft.Generator(组装 prepare + builder)→ 草稿 Worker → AI 切片 Worker → 一键成片 Worker(注入前两者形成编排); - 构造各业务 service 并 new 出全部 handler;
signal.NotifyContext建立 ctx → 启动四个 Worker 与 scheduler → 注册 Gin 路由 → 起 HTTP goroutine;- 阻塞等待 SIGINT/SIGTERM → 15 秒超时的
srv.Shutdown优雅退出。
注意装配顺序里藏着一个关键设计:aiSliceDraftWorker 依赖 aiSliceWorker 与 draftWorker 的接口(ProcessWithOptions 等方法),一键成片通过直接调用两者的阶段方法完成编排,而不是往任务队列里塞两个子任务——这保证了「AI 切片→草稿」在同一个 Task 行上顺序推进、进度连续(详见第 37 章)。
实现
main 中没有任何业务逻辑,只有构造与注入,这是维持「薄入口」的纪律。所有对象都是显式 new、显式传参,没有 wire/dig 之类的依赖注入框架——参数虽长(如 NewASRWorker 有 9 个参数),但依赖关系一眼可见,配合命名一致的构造函数,排查装配问题比反射注入容易得多。
值得逐个指出的装配细节:
- 三个 LLM 客户端实例:
asrLLM、sliceLLM、chatLLM使用相同配置但独立构造,为未来分化(不同模型/温度)预留了替换点,也让调用链在日志与单测中可区分。 media.NewFFmpegConverter("")/NewFFprobeProber(""):空串参数表示 PATH 查找 ffmpeg,Docker 镜像内置二进制,本地开发依赖系统安装。"temp"目录参数:音频预处理器把抽取的音频临时放在本地 temp,再上传对象存储给 ASR 远端读取。- scheduler 的两个 Job:
cleanup-staging与cleanup-asr-staging,闭包捕获 cfg 直接调用webroot.CleanupStaging,定时清理暂存目录(第 46 章)。 r.Use(gin.Recovery(), middleware.RequestLogger(logger)):中间件链只有两个,克制;/health与/swagger/*any在鉴权分组之外。- 优雅退出顺序:先
srv.Shutdown(等在途请求)再随 ctx 取消停 Worker;反过来的话 Worker 还可能写库时 HTTP 已拒绝健康检查。 _ "live-mixer/docs"匿名导入触发 swaggo 生成的 docs.go 注册 Swagger 路由元数据。
📌 设计决策
- 手工组合根而非 DI 框架:依赖图简单、编译期类型检查、新人可顺序阅读;代价是新增对象时要手动串线。
- Worker 接口先行:
AISliceWorker、DraftWorker在 service 包内定义为接口,main 注入实现,ai_slice_worker_test.go等用假实现替换,测试不需要数据库。 sync.Once保证 Start 幂等(各 Worker 内部),main 里无需防重复启动。
代码示例
一键成片 Worker 的注入体现「编排者持有被编排者」:
// cmd/webserver/main.go(节选)
aiSliceDraftWorker := service.NewAISliceDraftWorker(
taskRepo,
videoProjectRepo,
aiSliceWorker, // 复用其 ProcessWithOptions:AI 切片阶段
draftWorker, // 复用其 Process:草稿阶段
logger,
cfg.Worker.AISliceDraftConcurrencyOrDefault(),
cfg.Worker.AISliceDraftStaleTimeout(),
)
定时任务注册:Job 是纯数据 + 闭包,与业务 Worker 完全解耦:
// cmd/webserver/main.go(节选)
sched.Register(scheduler.Job{
Name: "cleanup-staging",
Interval: cfg.Web.StagingCleanupInterval(),
Run: func(context.Context) {
removed, err := webroot.CleanupStaging(cfg.Web.RootDir, cfg.Web.StagingMaxDirs())
if err != nil {
logger.Warn("清理 staging 失败", zap.Error(err), zap.Int("removed", removed))
return
}
if removed > 0 {
logger.Info("已清理过期 staging 目录", zap.Int("removed", removed))
}
},
})
小结
- main = 配置 → 基础设施 → 仓储 → 外部客户端 → Worker → service → handler → 路由 → 生命周期,方向永不回头。
- 一键成片 Worker 通过接口复用两个子 Worker 的阶段方法,编排不落队列。
- 优雅退出:HTTP 先关、Worker 随 ctx 停,15 秒兜底。
思考题
- 若把 Worker 拆成独立进程,main 需要改哪些装配段?接口边界是否已经足够?
- 三个 LLM 客户端实例共用一份配置,何时应该让 ASR 后处理与切片使用不同模型?
项目信息
- GitHub仓库:github.com/Chyona/live-mixer
- 项目案例:gogoshine.com