Day 06 / 共 20 天 · 第 2 周 分层与契约
DDD 分层与 Wire
第一周走完了"一次请求怎么进来"的骨架。今天开始第二周,钻进 modules/prompt 内部,看真实的 DDD 四层是怎么划分的,以及这些层与层之间的依赖,是怎么被 Wire 这个工具自动组装出来的。
📍 你在整门课的位置 · 第 2 周 分层与契约(共 4 周 · 20 天)
D6 DDD 与 Wire→
D7 IDL 契约→
D8 Foundation→
D9 Prompt 读路径→
D10 调试与 LLM
L01
modules/<domain> 四层目录
🤔 痛点:Day 01 已经画过四层示意图了,今天有什么不一样?
Day 01 是"看图",今天是"进真实目录"。打开 backend/modules/prompt/ 数一数文件,你会发现四层不是空谈:
backend/modules/prompt/
├── application/ # manage.go debug.go execute.go openapi.go tool_manage.go + wire.go/wire_gen.go
├── domain/
│ ├── entity/ # prompt.go prompt_detail.go label.go debug_context.go ...
│ ├── service/ # manage.go execute.go formatter.go snippet_parser.go ...
│ └── repo/ # manage.go label.go debug_log.go debug_context.go tool.go(全是接口)
├── infra/
│ ├── repo/mysql/ # DAO 实现
│ ├── repo/redis/ # 缓存 DAO 实现
│ ├── rpc/ # 调 foundation/llm 等其他模块(本地客户端)
│ └── conf/ collector/ metrics/
└── pkg/ # errno consts template(模块内部小工具)
💡 本质:application 是"编排",domain 是"规则",infra 是"脏活"
application/manage.go 的 CreatePrompt 负责"取用户 → 查权限 → 拼 DTO → 审核 → 调用 domain service";domain/service/manage.go 的 CreatePrompt 负责"校验业务规则 → 调 repo 接口存";infra/repo/manage.go 才是真正拼 SQL、开事务写 MySQL 的地方。三层各管一段,Day 09 会把这条链路完整走一遍。L02
依赖方向铁律:api→application→domain←infra
backend/modules/prompt/domain/repo/manage.go 的 IManageRepo 接口是这条铁律最好的证据——它定义在 domain 层,却描述的是"存储"这件"infra 的事":
// domain/repo/manage.go —— 接口定义在 domain 层
type IManageRepo interface {
CreatePrompt(ctx context.Context, promptDO *entity.Prompt) (promptID int64, err error)
GetPrompt(ctx context.Context, param GetPromptParam) (promptDO *entity.Prompt, err error)
// ...
}
// infra/repo/manage.go —— 实现放在 infra 层,反向依赖 domain
type ManageRepoImpl struct { db db.Provider; idgen idgen.IIDGenerator; ... }
func (d *ManageRepoImpl) CreatePrompt(ctx context.Context, promptDO *entity.Prompt) (promptID int64, err error) { ... }
这叫"依赖倒置原则"(DIP):domain 层不 import infra 包,反而是 infra 层 import domain 包(引用
entity.Prompt 和实现 repo.IManageRepo 接口)。好处是:domain 里的业务规则测试可以完全脱离真实数据库,用 mock 实现 IManageRepo 就能跑单测(domain/repo/mocks/manage_repo.go 就是自动生成的 mock)。⚠️ 坑:新手常把校验逻辑写反位置
比如"promptKey 不能为空"这种业务规则应该写在
domain/service(domain/service/manage.go:344-349 就是这么做的),而不是写在 infra/repo(那里应该只管怎么存)或者散落在 application(那里应该只管编排,不该重复业务判断)。层次一乱,规则就会散落各处难以维护。L03
wire.go:手写"要装配哪些零件"
🤔 痛点:一个 application 对象往往依赖十几个东西,手动 new 太痛苦
看 modules/prompt/application/wire.go 就知道
PromptManageApplicationImpl 到底依赖多少东西——光是 domain 层的依赖集合 promptDomainSet 就有 20 多项://go:build wireinject
package application
var (
promptDomainSet = wire.NewSet(
service.NewPromptFormatter, service.NewToolConfigProvider, service.NewPromptService,
repo.NewManageRepo, repo.NewLabelRepo, repo.NewDebugLogRepo,
mysql.NewPromptBasicDAO, mysql.NewPromptCommitDAO, mysql.NewPromptUserDraftDAO,
rediscache.NewPromptBasicDAO, rediscache.NewPromptDAO,
rpc.NewLLMRPCProvider, rpc.NewAuthRPCProvider, rpc.NewFileRPCProvider,
// ... 共 20 余项
)
manageSet = wire.NewSet(NewPromptManageApplication, promptDomainSet)
)
func InitPromptManageApplication(idgen idgen.IIDGenerator, db db.Provider, redisCli redis.Cmdable, ...) (manage.PromptManageService, error) {
wire.Build(manageSet) // 只声明"用这些零件",具体怎么拼装交给 wire 工具生成
return nil, nil
}
💡 本质:wire.go 只是"零件清单",不是"组装说明书"
注意函数体只有一行
wire.Build(manageSet) 加一行占位 return nil, nil——这份文件永远不会被真正编译执行(顶部 //go:build wireinject 就是排除它参与正常编译的 build tag)。它存在的唯一目的,是给 wire 命令行工具读,告诉它"要造出一个 manage.PromptManageService,需要用到这些构造函数"。L04
wire_gen.go:真正跑起来的那份
执行 wire 命令后,真正参与编译的是同目录下的 wire_gen.go——它会分析每个构造函数需要什么参数、自动按正确顺序把它们一个个 new 出来:
// wire_gen.go(自动生成,勿手改)
func InitPromptManageApplication(idgen idgen2.IIDGenerator, db2 db.Provider, redisCli redis2.Cmdable, ...) (manage.PromptManageService, error) {
promptBasicDAO := mysql.NewPromptBasicDAO(db2)
promptCommitDAO := mysql.NewPromptCommitDAO(db2)
manageRepo := repo.NewManageRepo(idgen, promptBasicDAO, promptCommitDAO, ...)
authRPCProvider := rpc.NewAuthRPCProvider(authClient)
promptService := service.NewPromptService(manageRepo, ...)
promptManageApplicationImpl := NewPromptManageApplication(promptService, authRPCProvider, ...)
return promptManageApplicationImpl, nil
}
为什么不直接手写这一大串 new? 因为依赖关系一旦有十几层,手写装配代码极易漏参数、顺序错乱,而且每加一个新依赖就要改遍全部调用点。Wire 的思路是"你只管声明零件清单,装配顺序让工具去算"——本质上和 Day 07 的 IDL 生成代码是同一种工程哲学:把容易出错的重复劳动交给代码生成,人只维护"意图"。
⚠️ 坑:改了 wire.go 忘记重新生成
如果你在
promptDomainSet 里新增了一个构造函数,必须在 modules/prompt/application/ 目录下执行 wire 命令重新生成 wire_gen.go,否则新依赖不会真正被装配进去——这一步和 IDL 改完要重新跑生成脚本(Day 07)是完全一样的心智模型。L05
组装全过程实例:从 api.go 到最底层 DAO
把 Day 03 的 api.Init、Day 05 的 bindLocalCallClient、今天的 Wire 串起来,一次"新建 Prompt handler"的完整装配链路是:
| 层级 | 谁调用谁 |
|---|---|
| 1 | api.go Init 调 apis.InitPromptHandler(...) |
| 2 | InitPromptHandler 内部调 application.InitPromptManageApplication(...)(Wire 生成的函数) |
| 3 | Wire 生成代码按依赖顺序 new 出 mysql.NewPromptBasicDAO → repo.NewManageRepo → service.NewPromptService → NewPromptManageApplication |
| 4 | 拿到 manage.PromptManageService 接口后,NewPromptHandler 用 bindLocalCallClient 再包一层"本地 Kitex 客户端"(Day 05) |
| 5 | 最终塞进 APIHandler,被路由分发到的 apis.CreatePrompt 调用 |
🍼 类比:宜家家具的组装顺序
Wire 就像宜家说明书——它不生产螺丝和木板(那些是
mysql.NewXxxDAO 这类"零件构造函数"),只保证"先装底座、再装侧板、最后装抽屉"这个顺序不会错。你只需要把说明书(wire.go 的依赖清单)写对,宜家(wire 工具)就能自动生成正确的组装步骤。L06
模块间不直接互调,在这里长什么样
回头看 Day 03 埋下的钩子:api.go:77-83 里 InitPromptHandler 接收的参数是包装过的接口:
promptHandler, err := apis.InitPromptHandler(ctx, ...,
loruntime.NewLocalLLMRuntimeService(llmHandler.LLMRuntimeService), // 不是直接传 llm 模块的 application 对象
loauth.NewLocalAuthService(foundationHandler.AuthService), // 而是先包一层 "本地 Kitex 客户端"
lofile.NewLocalFileService(foundationHandler.FileService),
louser.NewLocalUserService(foundationHandler.UserService),
auditClient,
)
这些包装函数最终会流入 modules/prompt/infra/rpc/ 下的 NewAuthRPCProvider 等 Provider——prompt 模块拿到手的,永远只是 authservice.Client 这样一个 Kitex 生成的接口类型,完全看不到 foundation 模块内部 AuthApplicationImpl 的任何实现细节。
💡 本质:每个模块只对外暴露 Thrift 定义的接口,模块内部对彼此是黑盒
这意味着
foundation 模块的维护者可以随意重写 AuthApplicationImpl 内部实现(比如换一种鉴权算法),只要 auth.AuthService 这个 Thrift 接口签名不变,prompt 模块完全不需要动。这正是 Day 01 说的"模块间不直接互调"在代码里的完整实现路径:IDL 定义接口 → Kitex 生成接口类型 → loop_gen 包一层本地客户端 → 各模块只依赖接口类型。L07
今日小结 + 动手
🧠 今天你应该能回答
- modules/<domain> 四层分别叫什么、各自放什么?(application 编排、domain/entity+service+repo 规则、infra 实现)
- 依赖倒置怎么体现?(repo 接口定义在 domain,实现在 infra,infra 反向依赖 domain)
- wire.go 和 wire_gen.go 的关系?(前者是手写的零件清单,后者是自动生成的真实装配代码)
- 模块间调用为什么要包一层"本地客户端"?(让模块只依赖 Thrift 接口类型,彼此实现互为黑盒)
✋ 动手 5 分钟
# 1. 数一数 prompt 模块四层各有多少文件
for d in application domain/entity domain/service domain/repo infra; do
echo "$(find backend/modules/prompt/$d -maxdepth 1 -name '*.go' 2>/dev/null | grep -v _test | wc -l) $d"
done
# 2. 看 wire.go 的零件清单
grep -n "wire.NewSet\|wire.Build" backend/modules/prompt/application/wire.go
# 3. 对比 wire_gen.go 是怎么真正装配的
grep -n "func InitPromptManageApplication" -A 15 backend/modules/prompt/application/wire_gen.go
# 4. 找到 IManageRepo 接口定义与实现分别在哪
grep -rn "type IManageRepo interface" backend/modules/prompt/domain/repo/
grep -rn "type ManageRepoImpl struct" backend/modules/prompt/infra/repo/
明天预告 · Day 07:今天反复出现的"Thrift 接口"到底是从哪个
.thrift 文件生成的?kitex_gen 和 loop_gen 又分别是什么?我们会打开 idl/thrift/coze/loop/prompt/ 看契约的原始定义。