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-190NewPromptHandler
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)
}

拆开这一行,实际发生的事情是:

步骤发生了什么
1Hertz 把请求体交给 invokeAndRender,反射构造出一个空的 CreatePromptRequest
2c.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)在真实模块里到底长什么样。
← 上一天 · HTTP 网关与路由 下一天 · DDD 分层与 Wire →