Day 04 / 共 20 天 · 第 1 周 环境与骨架

HTTP 网关与路由

昨天知道了 api.Start(handler) 会启动 HTTP 服务。今天拆开这一层:请求打进来之后,Hertz 框架怎么把它路由到具体的业务函数,中间穿过哪些中间件。

📍 你在整门课的位置 · 第 1 周 环境与骨架(共 4 周 · 20 天)
D1 项目全景 D2 Docker 跑起来 D3 进程启动 D4 HTTP 网关 D5 Handler 桥接
L01

Hertz 是什么

🤔 痛点:Go 里写 HTTP 服务不是有标准库 net/http 吗? 标准库能用,但字节内部海量服务对性能和易用性要求更高——需要更快的路由匹配、更方便的中间件机制、和内部 RPC 框架(Kitex)配套的生态。Hertz 就是 CloudWeGo 团队为此打造的高性能 HTTP 框架。
💡 本质:Hertz 是 Coze Loop 唯一的 HTTP 入口框架 backend/api/api.goStart 函数里能看到它的身影:server.Default(...) 创建一个 Hertz 实例,h.Spin() 启动监听。所有 /api/... 路由最终都通过它接收、解析、分发。

func Start(handler *apis.APIHandler) {
	render.ResetJSONMarshal(js_conv.GetMarshaler())
	bindConfig := binding.NewBindConfig()
	bindConfig.UseThirdPartyJSONUnmarshaler(js_conv.GetUnmarshaler())

	h := server.Default(server.WithBindConfig(bindConfig), server.WithMaxRequestBodySize(20*1024*1024))
	register(h, handler)
	h.Spin()   // 阻塞式启动监听
}
🍼 类比:小区大门保安亭 Hertz 就是小区门口的保安亭:所有想进小区(调用后端)的人(HTTP 请求)都要先在这里登记(路由匹配)、可能要刷门禁卡(中间件鉴权),然后保安才会告诉你"去几号楼几层"(分发给具体 handler)。
L02

手写路由 vs 生成路由

💡 本质:路由分两路汇合,一路生成一路手写 backend/api/router.goregister 函数只有三行,却揭示了整个路由体系的骨架:

func register(r *server.Hertz, handler *apis.APIHandler) {
	router.GeneratedRegister(r, handler)   // 路① 由 IDL 生成,占绝大多数
	customizedRegister(r)                  // 路② 手写,目前只有一个 /ping
}

GeneratedRegister 实际上就是 backend/api/router/coze/loop/apis/coze.loop.apis.go 里的 Register 函数——文件头写得很直白:

coze.loop.apis.go 顶部注释原话:"This file will register all the routes of the services in the master idl. And it will update automatically when you use the 'update' command for the idl. So don't modify the contents of the file, or your code will be deleted when it is updated."——这是本教程反复强调的"生成代码禁止手改"铁律的一个具体案例(Day 07 细讲整套生成流程)。

backend/api/router.gocustomizedRegister 才是给你自由发挥的地方:

func customizedRegister(r *server.Hertz) {
	r.GET("/ping", handler.Ping)
	// your code ...
}
📌 记忆点:99% 的业务路由都来自 IDL → 生成代码这条链路,手写路由只用于健康检查这类和业务契约无关的旁路端点。
L03

中间件链是怎么套起来的

backend/api/router_gen.go 里,中间件按路由分组一层套一层。最外层是所有请求都会经过的 rootMw

func rootMw(handler *apis.APIHandler) []app.HandlerFunc {
	return []app.HandlerFunc{
		middleware.CtxCacheMW(),                          // 请求级缓存上下文
		middleware.AccessLogMW(),                          // 访问日志
		middleware.LocaleMW(),                              // 解析语言 Cookie
		middleware.PacketAdapterMW(handler.GetTranslater()), // 响应包裹 + 国际化
	}
}
func _apiMw(handler *apis.APIHandler) []app.HandlerFunc {
	return []app.HandlerFunc{
		middleware.SessionMW(session.NewSessionService(), louser.NewLocalUserService(handler)),
	}
}

coze.loop.apis.go:20-24 能看到分组是怎么嵌套注册的:

root := r.Group("/", rootMw(handler)...)
{
	_api := root.Group("/api", _apiMw(handler)...)   // 所有 /api/* 都要过 SessionMW
	{
		_auth := _api.Group("/auth", _authMw(handler)...)
		// ... 每一层 Group 都可以再挂自己的中间件
	}
}
💡 本质:Hertz 的 Group 机制 = 路由前缀 + 中间件的"继承" 进了 /api 分组的请求,天生带着 rootMw + _apiMw 两层中间件;再往下每一级 Group(比如 /prompt)还可以叠加自己的中间件(_promptMw,目前是空的,留给未来扩展)。这就是为什么"鉴权只挂在 /api 这一层"就能覆盖几乎所有业务接口,而登录、注册两个接口在 SessionMW 内部被特判放行(下一讲会看到)。
L04

SessionMW 怎么鉴权 + LocaleMW 怎么处理语言

backend/api/router/coze/loop/apis/middleware/session.go 的核心逻辑:

func SessionMW(ss session.ISessionService, us userservice.Client) app.HandlerFunc {
	return func(ctx context.Context, c *app.RequestContext) {
		path := string(c.Path())
		if path == "/api/foundation/v1/users/login_by_password" ||
			path == "/api/foundation/v1/users/register" {
			c.Next(ctx)   // 登录/注册接口特判放行,不需要已有 session
			return
		}
		sess, err := ss.ValidateSession(ctx, string(c.Cookie(session.SessionKey)))  // 校验 Cookie
		if err != nil { c.Abort(); return }
		resp, err := us.GetUserInfo(ctx, &user.GetUserInfoRequest{UserID: ptr.Of(sess.UserID)}) // 查用户信息
		ctx = session.WithCtxUser(ctx, &session.User{ID: sess.UserID, Name: ..., Email: ...})   // 写回 ctx
		c.Next(ctx)
	}
}
为什么要在中间件里查一次用户信息? 因为 Cookie 里的 Session 只存了 UserID,业务 handler 常常需要用户名/邮箱等信息——与其让每个业务函数都自己查一遍,SessionMW 统一查好,用 session.WithCtxUser 塞进 context.Context,下游用 session.UserIDInCtx(ctx) 这样的辅助函数直接取。这是"横切关注点在中间件里一次性解决"的典型做法。

语言处理则简单得多,middleware/locale.go

func LocaleMW() app.HandlerFunc {
	return func(ctx context.Context, c *app.RequestContext) {
		c.Next(contexts.WithLocale(ctx, parseLocale(c)))   // 从 Cookie 取语言,塞进 ctx
	}
}
🍼 类比:门禁卡 + 语言选择器 SessionMW 是刷门禁卡(认出你是谁),LocaleMW 是进门前的语言选择器(决定后面所有提示用中文还是英文)。两者都在"进小区大门"这一层统一处理,后面每个房间(业务 handler)就不用重复问一遍。
L05

跟着一条真实路由走一遍

拿"新建 Prompt"这个动作举例,coze.loop.apis.go:406-413

_prompt := _api.Group("/prompt", _promptMw(handler)...)
_v15 := _prompt.Group("/v1", _v15Mw(handler)...)
_v15.POST("/prompts", append(_promptsMw(handler), apis.CreatePrompt)...)

拼出来的完整路径就是 POST /api/prompt/v1/prompts,一次请求要依次穿过:

rootMwCtxCache → AccessLog → Locale → PacketAdapter
_apiMwSessionMW:校验 Cookie,把当前用户写进 ctx
_promptMw / _v15Mw / _promptsMw目前均为空(留给未来扩展的挂载点)
apis.CreatePrompt真正的业务 handler(Day 05 详细拆开)
怎么自己找到某个前端按钮对应的路由? 打开 idl/thrift/coze/loop/prompt/coze.loop.prompt.manage.thrift,每个接口后面的 (api.post = '/api/prompt/v1/prompts') 注解就是路由的"源头真相"——生成代码只是把这些注解翻译成了 Hertz 的 Group/POST 调用。
L06

今日小结 + 动手

🧠 今天你应该能回答

  • Hertz 是什么?(CloudWeGo 的高性能 Go HTTP 框架,Coze Loop 唯一的 HTTP 入口)
  • 路由从哪里来?(99% 由 IDL 生成,router_gen.go/coze.loop.apis.go 禁止手改;少量手写在 router.go
  • 中间件是怎么分层套用的?(Hertz 的 Group 机制,外层中间件对所有子分组生效)
  • SessionMW 做了什么?(校验 Cookie → 查用户信息 → 写进 context,登录/注册接口特判放行)

✋ 动手 5 分钟

# 1. 看路由汇合点
cat backend/api/router.go

# 2. 数一数生成代码里到底有多少条路由
grep -c "append(.*Mw(handler)" backend/api/router/coze/loop/apis/coze.loop.apis.go

# 3. 定位 CreatePrompt 的完整路由链路
grep -n "prompt\b" backend/api/router/coze/loop/apis/coze.loop.apis.go | grep -i prompts

# 4. 看 IDL 里的路由注解源头
grep -n "api.post\|api.get" idl/thrift/coze/loop/prompt/coze.loop.prompt.manage.thrift | head
明天预告 · Day 05apis.CreatePrompt 这个 handler 函数只有一行代码却干了很多事——它怎么把 HTTP 请求"桥接"成一次 Kitex 服务调用?bindLocalCallClientinvokeAndRender 这两个函数是今天留下的悬念,明天揭晓。
← 上一天 · 进程怎么启动 下一天 · Handler 桥接模式 →