Day 05 / 共 20 天 · 第 1 周 环境与骨架
Handler 桥接模式
昨天看到路由把请求分发到了 apis.CreatePrompt。今天拆开 backend/api/handler/coze/loop/apis/handler.go——这是全仓库最值得精读的一个文件,藏着 Coze Loop 一个很特别的设计:用 Kitex 客户端做"进程内本地调用"。
📍 你在整门课的位置 · 第 1 周 环境与骨架(共 4 周 · 20 天)
D1 项目全景→
D2 Docker 跑起来→
D3 进程启动→
D4 HTTP 网关→
D5 Handler 桥接
L01
APIHandler:六大模块的拼盘
🤔 痛点:全局到处传的 handler 参数到底是个什么大对象?
Day 03/04 反复出现的
*apis.APIHandler,第一次看这个类型定义的人常常一头雾水——它长这样:// backend/api/handler/coze/loop/apis/handler.go
type APIHandler struct {
*PromptHandler
*LLMHandler
*EvaluationHandler
*DataHandler
*ObservabilityHandler
*FoundationHandler
Translater i18n.ITranslater
}
💡 本质:用 Go 的匿名嵌入把六大模块的所有接口"摊平"到一个对象上
每个
XxxHandler(比如 PromptHandler)本身又是嵌入了一堆 Kitex 生成的 Service 接口:
type PromptHandler struct {
manage.PromptManageService // 增删改查 Prompt
toolmanage.ToolManageService // 工具管理
debug.PromptDebugService // 调试
execute.PromptExecuteService // 执行
openapi.PromptOpenAPIService // 开放 API
}
得益于 Go 的匿名字段嵌入机制,handler.CreatePrompt(...) 这样的方法调用可以直接"透传"到最内层的 PromptManageService.CreatePrompt——APIHandler 因此变成了一个能调用所有业务方法的巨型对象,被四处传递。🍼 类比:一张万能门卡
APIHandler 就像一张贴了六个部门授权的万能门卡——不管是去 Prompt 部门、评测部门还是基础部门办事,拿着这一张卡(一个参数)就够了,不用给每个部门单独发一张卡。L02
bindLocalCallClient:把 application 包装成 Kitex Client
🤔 痛点:为什么不直接把 application 对象存起来,非要包一层 "Client"?
这是本仓库最反直觉但也最精妙的设计之一。看 handler.go:171-190 的
NewPromptHandler:func NewPromptHandler(manageApp manage.PromptManageService, ...) *PromptHandler {
h := &PromptHandler{PromptManageService: manageApp, ...}
bindLocalCallClient(manage.PromptManageService(h), &promptManageSvc, lomanage.NewLocalPromptManageService)
// ...
return h
}
bindLocalCallClient 本身很短,handler.go:238-245:
func bindLocalCallClient[T, K any](svc T, cli any, provider func(t T, mds ...endpoint.Middleware) K) {
v := reflect.ValueOf(cli)
c := provider(svc, defaultKiteXMiddlewares()...)
v.Elem().Set(reflect.ValueOf(c)) // 用反射把生成出来的 "客户端" 塞进包级全局变量
}
💡 本质:把"直接调用 application"包一层"Kitex 客户端",为的是白嫖 Kitex 中间件体系
lomanage.NewLocalPromptManageService 是 Day 07 会讲到的 loop_gen 生成代码——它把 PromptManageService 接口包装成一个"看起来像远程调用、实际是进程内直接函数调用"的 Kitex Client。这样做的好处是:所有原本只能挂在真实 RPC 上的 Kitex 中间件(日志、鉴权、参数校验、上下文缓存)都能原样套用,而不需要为"进程内调用"这种场景重新发明一套中间件机制。包级全局变量
promptManageSvc 是干什么的? 它在 prompt_manage_service.go:15 声明为 var promptManageSvc promptmanageservice.Client——正是被 bindLocalCallClient 反射赋值的那个"本地 Kitex 客户端"。所有 apis.CreatePrompt 这样的 handler 函数,实际调用的都是这个包级变量,而不是直接调 application 对象。L03
invokeAndRender:一个函数统一所有 handler 的写法
正因为有了上一讲的"本地 Kitex 客户端",所有 handler 函数才能写得极度统一。handler.go:256-284:
func invokeAndRender[T, K any](
ctx context.Context, c *app.RequestContext,
callable func(ctx context.Context, req T, callOptions ...callopt.Option) (K, error),
) {
render := func(c *app.RequestContext, fn func() (any, error)) {
resp, err := fn()
if err == nil { c.JSON(http.StatusOK, resp); return }
_ = c.Error(err)
}
render(c, func() (r any, err error) {
defer goroutine.Recover(ctx, &err) // panic 兜底恢复
var req T
ins := reflect.New(reflect.TypeOf(req).Elem()).Interface().(T)
if err := c.BindAndValidate(ins); err != nil { // ① HTTP 请求体绑定 + 校验
return nil, kerrors.NewBizStatusError(errno.CommonBadRequestCode, ...)
}
return callable(ctx, ins) // ② 调用真正的业务逻辑
})
}
💡 本质:用 Go 泛型把"绑参数 → 调用 → 渲染响应"三步固定成一个模板函数
因为
invokeAndRender 是泛型函数,T(请求类型)和 K(响应类型)由传入的 callable 自动推导——这就是为什么几乎每个业务 handler 都只有一行代码。L04
走一遍 CreatePrompt handler
现在回头看 backend/api/handler/coze/loop/apis/prompt_manage_service.go:17-21,这一行就完全说得通了:
var promptManageSvc promptmanageservice.Client
// CreatePrompt .
// @router /api/prompt/v1/prompts [POST]
func CreatePrompt(ctx context.Context, c *app.RequestContext) {
invokeAndRender(ctx, c, promptManageSvc.CreatePrompt)
}
拆开这一行,实际发生的事情是:
| 步骤 | 发生了什么 |
|---|---|
| 1 | Hertz 把请求体交给 invokeAndRender,反射构造出一个空的 CreatePromptRequest |
| 2 | c.BindAndValidate 把 JSON 请求体解析并做参数校验(IDL 里 vt.not_nil 之类的校验规则在这里生效) |
| 3 | 调用 promptManageSvc.CreatePrompt(ctx, req)——这一步会先穿过 defaultKiteXMiddlewares(日志/校验/Session/上下文缓存),再落到真正的 PromptManageApplicationImpl.CreatePrompt(Day 09 详细拆开) |
| 4 | 拿到返回值后 c.JSON(200, resp) 写回 HTTP 响应;如果出错则 c.Error(err) 交给统一错误处理 |
⚠️ 坑:
defaultKiteXMiddlewares 和 Day 04 的 Hertz 中间件是两套不同的东西
handler.go:247-254:[]endpoint.Middleware{logmw.LogTrafficMW, validator.KiteXValidatorMW, session.NewRequestSessionMW(), cachemw.CtxCacheMW}。这是 Kitex 的 endpoint.Middleware,套在"本地调用"这一层;Day 04 的 SessionMW/LocaleMW 是 Hertz 的 app.HandlerFunc,套在 HTTP 这一层。两套中间件体系并存、各管各的阶段,初学者很容易把两者搞混。L05
为什么是"进程内"而不是真正的跨机 RPC
🤔 痛点:都叫 Kitex Client 了,是不是真的会发网络请求?
不会。这是最容易被名字误导的地方。
💡 本质:
loop_gen 生成的是"假装是 RPC 客户端、实际是函数直调"的适配层
看一眼 backend/loop_gen/coze/loop/prompt/lomanage/local_promptmanageservice.go 的实现(Day 06/07 会展开讲 loop_gen 是怎么生成的),核心就是:
type LocalPromptManageService struct {
impl manage.PromptManageService // 直接持有 application 实现,没有网络连接
mds endpoint.Middleware
}
func (l *LocalPromptManageService) CreatePrompt(ctx context.Context, request *manage.CreatePromptRequest, ...) (*manage.CreatePromptResponse, error) {
chain := l.mds(func(ctx context.Context, in, out interface{}) error {
resp, err := l.impl.CreatePrompt(ctx, arg.Request) // 就是普通的 Go 函数调用
result.SetSuccess(resp)
return nil
})
// ...
return result.GetSuccess(), nil
}
没有 socket、没有序列化传输,impl.CreatePrompt(...) 就是一次普通的内存里的函数调用,只是外层套了一层"看起来和真实 Kitex RPC 调用一模一样"的接口壳子。🍼 类比:内部快递 vs 同城送信
真正的跨机 RPC(比如以后要拆微服务)像是找快递公司送信——要打包、贴地址、路上跑一趟。而"本地调用客户端"更像是在同一栋楼里,把信直接递给隔壁工位的同事——用的是同一个"寄信"流程和模板(接口签名不变),但实际上手一递就到了,没有真正上路。这样设计的好处是:未来如果要把某个模块拆成独立微服务,只需要把这层"本地客户端"换成"真实网络客户端",上层调用代码完全不用改——这正是"面向接口编程"在微服务化路径上的价值。
为什么模块间调用要经过这层,而不是直接 import? 回顾 Day 01/03 的钩子:
prompt 模块要用 foundation 的鉴权能力,如果直接 import foundation 的 application 包,两个模块的代码就会紧密耦合、互相感知内部实现。而通过"看起来像 RPC 客户端"的接口调用,模块之间只依赖 Kitex 生成的接口类型(auth.AuthService),彼此的内部实现完全不可见——这就是 ARCHITECTURE.md 里"模块间不直接互调"这条不变量在代码层面的真正落地方式。L06
今日小结 + 动手
🧠 今天你应该能回答
- APIHandler 是什么?(六大模块 Kitex Service 接口的匿名嵌入拼盘)
- bindLocalCallClient 做了什么?(用反射把 application 包成"本地 Kitex 客户端",塞进包级变量)
- invokeAndRender 统一了什么?(绑参数校验 → 调用 → JSON 渲染,用泛型消除重复代码)
- 为什么叫"本地"调用?(没有网络传输,是普通函数调用,只是接口形态和真 RPC 一致)
- 这套设计解决了什么问题?(让模块间调用只依赖接口不依赖实现,同时白嫖 Kitex 中间件生态)
✋ 动手 5 分钟
# 1. 看 APIHandler 的拼盘结构
grep -n "type APIHandler struct" -A 8 backend/api/handler/coze/loop/apis/handler.go
# 2. 看 bindLocalCallClient 在哪些地方被调用(六大模块各自的 NewXxxHandler)
grep -n "bindLocalCallClient" backend/api/handler/coze/loop/apis/handler.go
# 3. 对比看一个 handler 函数有多短
sed -n '17,22p' backend/api/handler/coze/loop/apis/prompt_manage_service.go
# 4. 看本地 Kitex 客户端的生成代码
head -50 backend/loop_gen/coze/loop/prompt/lomanage/local_promptmanageservice.go
明天预告 · Day 06:今天看到的
PromptManageApplicationImpl 是怎么被 Wire 组装出来的?我们要打开 modules/prompt/application/wire.go,看 DDD 四层(api→application→domain←infra)在真实模块里到底长什么样。