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.goCreatePrompt 负责"取用户 → 查权限 → 拼 DTO → 审核 → 调用 domain service";domain/service/manage.goCreatePrompt 负责"校验业务规则 → 调 repo 接口存";infra/repo/manage.go 才是真正拼 SQL、开事务写 MySQL 的地方。三层各管一段,Day 09 会把这条链路完整走一遍。
L02

依赖方向铁律:api→application→domain←infra

backend/modules/prompt/domain/repo/manage.goIManageRepo 接口是这条铁律最好的证据——它定义在 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/servicedomain/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"的完整装配链路是:

层级谁调用谁
1api.go Initapis.InitPromptHandler(...)
2InitPromptHandler 内部调 application.InitPromptManageApplication(...)(Wire 生成的函数)
3Wire 生成代码按依赖顺序 new 出 mysql.NewPromptBasicDAO → repo.NewManageRepo → service.NewPromptService → NewPromptManageApplication
4拿到 manage.PromptManageService 接口后,NewPromptHandlerbindLocalCallClient 再包一层"本地 Kitex 客户端"(Day 05)
5最终塞进 APIHandler,被路由分发到的 apis.CreatePrompt 调用
🍼 类比:宜家家具的组装顺序 Wire 就像宜家说明书——它不生产螺丝和木板(那些是 mysql.NewXxxDAO 这类"零件构造函数"),只保证"先装底座、再装侧板、最后装抽屉"这个顺序不会错。你只需要把说明书(wire.go 的依赖清单)写对,宜家(wire 工具)就能自动生成正确的组装步骤。
L06

模块间不直接互调,在这里长什么样

回头看 Day 03 埋下的钩子:api.go:77-83InitPromptHandler 接收的参数是包装过的接口:

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_genloop_gen 又分别是什么?我们会打开 idl/thrift/coze/loop/prompt/ 看契约的原始定义。
← 上一天 · Handler 桥接模式 下一天 · IDL 契约入门 →