Day 07 / 共 20 天 · 第 2 周 分层与契约

IDL 契约入门

这几天反复出现的"Thrift 接口""生成代码禁止手改"到底是怎么回事?今天打开 idl/thrift/,看这份契约文件是怎么"一处定义,前后端通吃"的。

📍 你在整门课的位置 · 第 2 周 分层与契约(共 4 周 · 20 天)
D6 DDD 与 Wire D7 IDL 契约 D8 Foundation D9 Prompt 读路径 D10 调试与 LLM
L01

为什么要"契约先行"

🤔 痛点:前端说接口字段是 promptId,后端敲成了 prompt_id,联调地狱由此开始 如果前后端各自手写接口文档、各自手写请求/响应结构体,字段名拼错、类型不一致、少传了一个必填字段,这些坑几乎每个多人协作项目都踩过。
💡 本质:一份 .thrift 文件是"唯一真相源",Go 和 TypeScript 类型都从它生成 Coze Loop 把所有接口的请求/响应结构、字段类型、路由路径,全部写在 idl/thrift/ 下的 .thrift 文件里。改接口只改这一份文件,然后跑生成脚本,前后端的类型定义会自动同步更新、不可能出现"前后端字段不一致"的问题——因为它们本来就是同一份定义生成出来的。
🍼 类比:合同的正本与副本 .thrift 文件就像一份合同的"正本",Go 的 kitex_gen 和 TypeScript 的类型定义都是从正本盖章复印出来的"副本"——副本内容永远和正本一致,你也不会去手改副本(改了也没用,下次重新复印又会被覆盖)。
L02

idl/thrift 目录地图

docs/guidance/idl-codegen-guide.md 给出的目录结构,和 Day 01 的六大模块严格对应:

idl/thrift/
├── base.thrift                    # 公共基础类型(分页、通用响应等)
├── extra.thrift                   # 扩展类型
├── trajectory.thrift               # 轨迹类型
└── coze/loop/
    ├── apis/                      # 顶层 API 接口聚合
    ├── data/                      # data 模块类型
    ├── evaluation/                # evaluation 模块类型
    ├── foundation/                # foundation 模块类型
    ├── llm/                       # llm 模块类型
    ├── observability/             # observability 模块类型
    └── prompt/                    # prompt 模块类型
        ├── coze.loop.prompt.manage.thrift    # 增删改查 Prompt 的 service 定义
        ├── coze.loop.prompt.debug.thrift     # 调试相关
        ├── coze.loop.prompt.execute.thrift   # 执行相关
        ├── coze.loop.prompt.openapi.thrift   # 开放 API
        ├── coze.loop.prompt.tool_manage.thrift
        └── domain/prompt.thrift              # Prompt 领域数据结构定义
命名规律:每个 .thrift 文件对应一个 Kitex Service(一组相关接口),domain/ 子目录下放的是纯数据结构(不含接口方法),被多个 service 文件 include 复用——这和 Go 里"domain/entity 定义数据、application 定义用例"的思路是相通的。
L03

一个 thrift service 长什么样

idl/thrift/coze/loop/prompt/coze.loop.prompt.manage.thrift 开头几行,就是 Day 04/05 一直在用的 CreatePrompt 接口的"正本":

namespace go coze.loop.prompt.manage

include "../../../base.thrift"
include "./domain/prompt.thrift"

service PromptManageService {
    // 增
    CreatePromptResponse CreatePrompt(1: CreatePromptRequest request)
        (api.post = '/api/prompt/v1/prompts')
    ClonePromptResponse ClonePrompt(1: ClonePromptRequest request)
        (api.post = '/api/prompt/v1/prompts/:prompt_id/clone')
    // 删
    DeletePromptResponse DeletePrompt(1: DeletePromptRequest request)
        (api.delete = '/api/prompt/v1/prompts/:prompt_id')
    // 查
    GetPromptResponse GetPrompt(1: GetPromptRequest request)
        (api.get = '/api/prompt/v1/prompts/:prompt_id')
}

struct CreatePromptRequest {
    1: optional i64 workspace_id (api.js_conv='true', vt.not_nil='true', vt.gt='0', go.tag='json:"workspace_id"')
    // ...
}
💡 本质:注解(annotation)里藏着路由、校验规则和跨语言细节 Day 04 看到的 /api/prompt/v1/prompts 路由,源头就是这里的 (api.post = '...') 注解——Hertz 生成器解析这些注解自动生成 router_gen.govt.not_nil='true'vt.gt='0' 是校验规则,对应 Day 05 c.BindAndValidate 时生效的那些约束。go.tag='json:"workspace_id"' 直接控制生成出来的 Go struct 字段带什么 JSON tag。api.js_conv='true' 是处理 JS 里大整数精度丢失问题的特殊转换标记。
📌 小技巧:以后遇到不确定某个接口的路由或字段校验规则,直接去对应的 .thrift 文件里搜方法名,比在生成代码的海洋里搜要快得多,而且这里才是"意图"的原始表达。
L04

kitex_gen vs loop_gen:两种生成产物

🤔 痛点:backend 下有 kitex_gen 和 loop_gen 两个生成目录,傻傻分不清 这两个目录都是"禁止手改",但职责完全不同,Day 05 已经用过它们,今天讲清楚区别。
目录内容典型文件
kitex_gen/Thrift 原生生成的类型定义 + Service 接口(标准 Kitex 产物)kitex_gen/.../manage/coze.loop.prompt.manage.goCreatePromptRequest 结构体、PromptManageService 接口)
loop_gen/kitex_gen 基础上,额外生成的"本地调用客户端"(Coze Loop 自己的代码生成器产物)loop_gen/.../lomanage/local_promptmanageservice.go(Day 05 的 LocalPromptManageService
一句话记住区别kitex_gen 是"标准答案"(Thrift 官方生成器产出的类型和接口),loop_gen 是"Coze Loop 自己加的辅助工具"(在标准答案基础上,为"模块间用本地调用而不是真 RPC"这个特殊需求量身定制的一层)。两者都不能手改,但 loop_gen 是本仓库特有的设计,其它用 Kitex 的项目未必有。
L05

thrift → Go → TS:三步生成流程

docs/guidance/idl-codegen-guide.md 定义的标准流程,改一次接口需要走完这条链:

① 改 .thrift唯一手写的一步 ② kitex_tool.sh生成 kitex_gen ③ hertz_tool.sh生成路由 ④ code_gen.sh生成 loop_gen ⑤ frontend/infra/idl生成 TS 类型
cd backend
bash script/cloudwego/kitex_tool.sh   # 生成 backend/kitex_gen/
bash script/cloudwego/hertz_tool.sh   # 生成 backend/api/router_gen.go
bash script/cloudwego/code_gen.sh     # 生成 backend/loop_gen/ 等通用代码

# 前端通过 infra/idl 工具把 thrift 转成 TS 类型,产物进 packages/loop-base/api-schema/
⚠️ 坑:新增服务接口后,还要记得重新跑 Day 06 的 wire 如果这次 IDL 变更新增了一个 Service(而不只是加字段),对应模块的 application/ 目录下可能需要重新执行 wire 生成 wire_gen.go——IDL 生成和 Wire 生成是两条独立但常常连在一起需要跑的流水线,容易漏掉后者导致编译失败。数据库 schema 变更还要额外跑 GORM 模型生成、错误码生成——idl-codegen-guide.md 第 65-79 行有完整清单。
L06

今日小结 + 动手

🧠 今天你应该能回答

  • 为什么要契约先行?(避免前后端字段/类型不一致,一份定义生成两端类型)
  • idl/thrift 目录怎么组织?(按六大模块分子目录,domain/ 放纯数据结构)
  • kitex_gen 和 loop_gen 的区别?(前者是 Thrift 标准生成物,后者是本地调用客户端的额外生成层)
  • 改一次接口要跑哪些生成脚本?(kitex_tool.sh → hertz_tool.sh → code_gen.sh → 前端 idl 转换 → 可能还要重新 wire)

✋ 动手 5 分钟

# 1. 看 prompt 模块的 IDL 文件清单
ls idl/thrift/coze/loop/prompt/

# 2. 找 CreatePrompt 的原始契约定义
grep -n "CreatePrompt" idl/thrift/coze/loop/prompt/coze.loop.prompt.manage.thrift

# 3. 对比生成出来的 Go 类型
grep -n "type CreatePromptRequest struct" -A 5 backend/kitex_gen/coze/loop/prompt/manage/*.go

# 4. 看生成脚本长什么样(不需要真的执行)
cat backend/script/cloudwego/kitex_tool.sh
明天预告 · Day 08:今天看到几乎每个 Service 请求都带 workspace_id,这背后是 foundation 模块管的"用户/空间/鉴权"体系。明天专门拆开它,看为什么所有业务模块都要先过这一层。
← 上一天 · DDD 分层与 Wire 下一天 · Foundation →