Day 03 / 共 20 天 · 第 1 周 环境与骨架
进程怎么启动
昨天看到了容器跑起来。今天钻进 backend/cmd/main.go,逐行看一个 Go 进程从零到能接请求,中间到底按什么顺序把 MySQL/Redis/ClickHouse 连上、把六大模块组装好。
📍 你在整门课的位置 · 第 1 周 环境与骨架(共 4 周 · 20 天)
D1 项目全景→
D2 Docker 跑起来→
D3 进程启动→
D4 HTTP 网关→
D5 Handler 桥接
L01
main.go 鸟瞰
🤔 痛点:main 函数动不动几十行,第一次看容易发懵
backend/cmd/main.go 的
main() 函数其实只做四件事,一件事一行就能说清楚——发懵往往是因为没先看"骨架"就直接抠细节。💡 本质:main 只是"编排",四步走
func main() {
ctx := context.Background()
c, err := newComponent(ctx) // ① 组装所有基础设施客户端
handler, err := api.Init(ctx, c.idgen, c.db, ...) // ② 组装六大模块 handler
initTracer(handler) // ③ 初始化链路追踪
r := registry.NewConsumerRegistryWithShutdown(...)
r.StartAll(ctx) // ④ 启动消息队列消费者
go api.Start(handler) // ⑤ 启动 HTTP 服务(协程里跑,不阻塞退出监听)
<-signalCtx.Done() // 阻塞等 SIGTERM/SIGINT
r.StopAll(stopCtx) // 优雅停止消费者
}
backend/cmd/main.go:50-80 就是这五步的真实代码——注意它只做编排,没有一行业务逻辑,完全符合 ARCHITECTURE.md 里 cmd/ 的架构不变量。🍼 类比:开餐厅前的四道准备工序
① 先把水电煤气接通(基础设施);② 把各个厨房岗位的师傅招齐(六大模块 handler);③ 装好监控摄像头(tracer);④ 打开外卖接单软件的后台任务(consumer);最后才⑤挂牌营业(HTTP Start)。顺序不能乱——没水电就开炉子是要出事的。
L02
newComponent:组装基础设施客户端
backend/cmd/main.go:167-280 的 newComponent 是"水电工",把所有基础设施的客户端连好、包成一个 component 结构体:
type component struct {
idgen idgen.IIDGenerator // 分布式 ID 生成器(基于 Redis)
db db.Provider // MySQL 连接池
redis redis.Cmdable // Redis 客户端
cfgFactory conf.IConfigLoaderFactory // 配置加载器工厂
mqFactory mq.IFactory // RocketMQ 工厂
objectStorage fileserver.ObjectStorage // MinIO/S3 客户端
ckDb ck.Provider // ClickHouse 连接
benefitSvc benefit.IBenefitService // 权益服务(开源版是 Noop 空实现)
auditClient audit.IAuditService // 审核服务(开源版是 Noop 空实现)
// ...
}
细节:Redis 连接建立后立刻用来生成 ID main.go:250 处
redis_gen.NewIDGenerator(redisCli, ...)——Coze Loop 用 Redis 实现分布式自增 ID(类似雪花算法的一种变体),而不是用数据库自增主键,这样多个应用实例并发建 Prompt 也不会 ID 冲突。⚠️ 坑:benefitSvc / auditClient 是空实现(Noop)
main.go:272-273:
benefitSvc: benefit.NewNoopBenefitService()、auditClient: audit.NewNoopAuditService()。这是开源版和商业版的一个典型分野——"权益校验"(比如额度限制)和"内容审核"在商业版接了真实服务,开源版给的是"什么都不做,直接放行"的空实现。读代码时看到调用了 auditRPCProvider.AuditPrompt(...) 却发现什么都没发生,不要慌,去看它注入的是不是 Noop。L03
infrastructure.yaml 怎么被读进来
数据库、Redis、ClickHouse 的连接参数不是硬编码在代码里,而是从 backend/conf/infrastructure.yaml 读的(注意:这份和 Day 02 的 docker-compose/conf/model_config.yaml 是两个不同的配置文件,各管各的):
# backend/conf/infrastructure.yaml(容器内路径 /coze-loop/conf/infrastructure.yaml)
infra:
redis:
host: "cozeloop-redis"
port: 6379
rds:
host: "cozeloop-mysql"
port: 3306
ck_config:
host: "cozeloop-clickhouse:9008"
idgen:
server_ids: [1] # 分布式 ID 生成器的机器号
log_level: "info"
main.go:117-129 的 getComponentConfig 就是加载这份配置:
func getComponentConfig(configFactory conf.IConfigLoaderFactory) (*ComponentConfig, error) {
componentConfigLoader, err := configFactory.NewConfigLoader("infrastructure.yaml")
componentConfig := &ComponentConfig{}
err = componentConfigLoader.UnmarshalKey(ctx, "infra", componentConfig)
return componentConfig, nil
}
💡 本质:真正的连接地址其实来自环境变量,YAML 只兜底非敏感项
仔细看
newComponent,Redis/MySQL/ClickHouse/MinIO 的 host、密码全部通过 os.Getenv("COZE_LOOP_REDIS_DOMAIN") 这类函数读取(main.go:282-367 一整串 getXxx() 辅助函数)。而这些环境变量正是 Day 02 docker-compose.yml 里 app 服务的 environment: 段注入的。infrastructure.yaml 只负责 idgen.server_ids、log_level 这类"不敏感、不随部署环境变化"的参数。这是"敏感配置走环境变量、结构化配置走 YAML"的常见工程实践。L04
api.Init:组装六大模块 handler
基础设施连好之后,backend/api/api.go 的 Init 函数把六大模块的 handler 逐个组装出来。这里能直接看到模块之间的依赖顺序:
func Init(ctx context.Context, idgen ..., db ..., ...) (*apis.APIHandler, error) {
foundationHandler, _ := apis.InitFoundationHandler(...) // ① 最先:用户/空间/鉴权
llmHandler, _ := apis.InitLLMHandler(..., loauth.NewLocalAuthService(foundationHandler.AuthService))
promptHandler, _ := apis.InitPromptHandler(...,
loruntime.NewLocalLLMRuntimeService(llmHandler.LLMRuntimeService), // 依赖 llm
loauth.NewLocalAuthService(foundationHandler.AuthService), // 依赖 foundation
lofile.NewLocalFileService(foundationHandler.FileService),
)
dataHandler, _ := apis.InitDataHandler(...)
evaluationHandler, _ := apis.InitEvaluationHandler(..., // 依赖 data + prompt + llm + foundation
lodataset.NewLocalDatasetService(dataHandler.IDatasetApplication),
lomanage.NewLocalPromptManageService(promptHandler.PromptManageService),
)
observabilityHandler, _ := apis.InitObservabilityHandler(...) // 依赖几乎所有前面模块
return &apis.APIHandler{ PromptHandler: promptHandler, ... }, nil
}
依赖顺序揭示了模块的"地基"关系:
foundation 最先初始化(几乎所有模块都要靠它鉴权/查用户),llm 第二(prompt 要调模型),observability 最后(它要观测前面所有模块产生的调用)。这条顺序和 Day 01 讲的"六大模块"角色完全吻合——今天你终于在真实代码里看到了这种依赖关系是怎么落地的。📌 注意到没有:这里传进去的不是
foundationHandler.AuthService 本身,而是包了一层 loauth.NewLocalAuthService(...)——这就是 Day 01 埋下的"模块间不直接互调"的钩子,Day 05/06 会专门揭秘这层包装是干什么的。L05
consumer 与优雅退出
HTTP 之外,Coze Loop 还有一批基于 RocketMQ 的异步消费者(比如异步处理 Trace 摄入、评测任务)。backend/cmd/main.go:69-79:
signalCtx, signalCancel := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT)
r := registry.NewConsumerRegistryWithShutdown(signalCtx, c.mqFactory).
Register(MustInitConsumerWorkers(c.cfgFactory, c.mqFactory, handler, handler, handler, handler))
r.StartAll(ctx)
go api.Start(handler) // HTTP 服务放进协程,不阻塞主流程
<-signalCtx.Done() // 主 goroutine 在这里"睡着",直到收到退出信号
stopCtx, stopCancel := context.WithTimeout(context.Background(), 30*time.Second)
r.StopAll(stopCtx) // 给消费者 30 秒把手头的消息处理完再退出
进程启动组装依赖→
consumerStartAll→
HTTP协程里 Start→
阻塞等待SIGTERM→
StopAll30s 内收尾
🍼 类比:下班关店流程
signal.NotifyContext 就像"店长盯着门口,一旦看到打烊信号(Ctrl+C 或容器要被杀)";StopAll 给 30 秒是"允许后厨把手上还在炒的最后几个菜炒完,而不是直接拔电闸"——这叫优雅停机(Graceful Shutdown),避免正在处理的消息/请求被硬生生打断导致数据不一致。L06
今日小结 + 动手
🧠 今天你应该能回答
- main 函数的五个步骤分别是什么?(组装基础设施 → 组装模块 handler → 初始化 tracer → 启动消费者 → 启动 HTTP + 阻塞等退出信号)
- 数据库连接参数从哪来?(大部分敏感项走环境变量,
infrastructure.yaml只管非敏感结构化配置) - 六大模块初始化顺序说明了什么?(foundation 最先,observability 最后,体现依赖关系)
- 什么是优雅停机?(收到退出信号后给消费者一段时间处理完手头任务再退出)
✋ 动手 5 分钟
# 1. 通读 main 函数骨架(先看骨架,别急着抠细节)
sed -n '50,80p' backend/cmd/main.go
# 2. 看 newComponent 组装了哪些基础设施
grep -n "type component struct" -A 15 backend/cmd/main.go
# 3. 看 api.Init 的六模块初始化顺序
grep -n "Init.*Handler" backend/api/api.go
# 4. 看本地容器里的 infrastructure.yaml 长什么样
cat release/deployment/docker-compose/conf/infrastructure.yaml 2>/dev/null || \
cat backend/conf/infrastructure.yaml
明天预告 · Day 04:
api.Start(handler) 只是启动了 Hertz 服务,具体一个 HTTP 请求怎么被路由到对应 handler,中间经过哪些中间件(Session/Locale)?我们会跟着一条真实路由 POST /api/prompt/v1/prompts 走一遍。