Day 18 / 共 20 天 · 第 4 周 前端与工程收官
IDL 端到端:一个字段的三生三世
这几天反复出现"从 IDL 生成代码"这句话,今天彻底拆开看:一个 Thrift 字段是怎么同时变成后端的 Go 结构体(backend/kitex_gen/)和前端的 TypeScript 接口(frontend/packages/loop-base/api-schema/)的。这是理解"前后端如何在一个大仓库里保持契约一致"的关键一天。
📍 你在整门课的位置 · 第 4 周 前端与工程收官(共 4 周 · 20 天)
D16 前端壳与路由→
D17 前端分层→
D18 IDL 端到端→
D19 动手改功能→
D20 收官串讲
L01
换个心智模型看代码:谁是"源",谁是"生成物"
🤔 痛点:Go 后端和 TypeScript 前端,语言都不一样,怎么保证"数据结构长得一样"?
如果后端改了一个字段名,前端却没跟着改,轻则页面报错,重则数据错位却没有任何报错——这种"契约不一致"的 bug 排查起来最痛苦,因为编译器完全不知道两边约定的是同一份数据。
💡 本质:Thrift IDL 是唯一的"源",Go 和 TS 代码都是"生成物"
idl/thrift/ 目录才是这个仓库里"数据结构"和"接口"真正的定义地——不是 Go 代码,也不是 TS 代码。你永远应该先改
.thrift 文件,再跑生成脚本,让 Go 和 TS 的代码自动保持同步,而不是分别手写两份"看起来一样"的定义。生活类比:图纸和成品
Thrift 文件是"建筑图纸",Go 结构体和 TS 接口是"按图纸盖出来的两栋楼"(一栋用 Go 材料盖、一栋用 TS 材料盖)。图纸改了,两栋楼都要重新按新图纸施工;反过来,你不会直接去其中一栋楼里砸墙改布局——那样另一栋楼就跟图纸不一致了。
为什么叫 IDL:Interface Definition Language(接口定义语言)——一种独立于任何编程语言的、用来描述数据结构和服务接口的格式。Thrift 是 Apache 的一种 IDL 实现(Facebook 开源),除此之外还有 Protocol Buffers(gRPC 用的那种)等类似方案。
L02
一个真实的 thrift 字段:Prompt 结构体
idl/thrift/coze/loop/prompt/domain/prompt.thrift:3-24 的真实内容:
struct Prompt {
1: optional i64 id (api.js_conv="true", go.tag='json:"id"')
2: optional i64 workspace_id (api.js_conv="true", go.tag='json:"workspace_id"')
3: optional string prompt_key
4: optional PromptBasic prompt_basic
5: optional PromptDraft prompt_draft
6: optional PromptCommit prompt_commit
}
struct PromptBasic {
1: optional string display_name
2: optional string description
3: optional string latest_version
6: optional i64 created_at (api.js_conv="true", go.tag='json:"created_at"')
9: optional PromptType prompt_type
10: optional SecurityLevel security_level
}
💡 每个字段的几个组成部分
1: 是字段编号(Thrift 靠编号而不是名字做二进制序列化,字段编号一旦发布不能随便改/复用,这也是 ARCHITECTURE.md 强调"向前兼容"的原因之一);optional 表示可选;i64/string 是类型;括号里的 (api.js_conv="true", go.tag=...) 是注解(annotation)——专门给代码生成器看的"额外提示",不影响 Thrift 协议本身。⚠️
api.js_conv="true" 是本节最重要的一个注解,L04 会揭晓它的作用
先记住:id、workspace_id、created_at 这几个 i64 字段都带了这个注解,而 prompt_key(string)没带。这不是随便加的——继续往下看就知道为什么。L03
变成了什么样的 Go 结构体
对应的生成代码在 backend/kitex_gen/coze/loop/prompt/domain/prompt/prompt.go:125-132:
type Prompt struct {
ID *int64 `thrift:"id,1,optional" frugal:"1,optional,i64" json:"id" form:"id" query:"id"`
WorkspaceID *int64 `thrift:"workspace_id,2,optional" frugal:"2,optional,i64" json:"workspace_id" form:"workspace_id" query:"workspace_id"`
PromptKey *string `thrift:"prompt_key,3,optional" frugal:"3,optional,string" form:"prompt_key" json:"prompt_key,omitempty" query:"prompt_key"`
PromptBasic *PromptBasic `thrift:"prompt_basic,4,optional" ...`
PromptDraft *PromptDraft `thrift:"prompt_draft,5,optional" ...`
PromptCommit *PromptCommit `thrift:"prompt_commit,6,optional" ...`
}
💡 三个映射规律,一眼就能看出来
①
optional i64 id → ID *int64——optional 字段变成 Go 指针类型(用 nil 表示"没设置",这和 Day 11 学的"用指针区分零值和未设置"是同一个道理);② 字段编号 1 出现在生成的 thrift:"id,1,optional" tag 里,序列化/反序列化靠它而不是字段名;③ 字段名从 thrift 的 snake_case(workspace_id)变成了 Go 惯用的 PascalCase(WorkspaceID),但 json tag 保留了原始的 snake_case,保证和外部 JSON 交互时字段名不变。L04又变成了什么样的 TS 接口——揭晓
又变成了什么样的 TS 接口——揭晓 js_conv 之谜
对应生成代码在 frontend/packages/loop-base/api-schema/src/api/idl/prompt/domain/prompt.ts:3-21:
export interface Prompt {
id?: string, // 注意:thrift 里是 i64,这里却是 string!
workspace_id?: string, // 同样是 i64 → string
prompt_key?: string, // thrift 本来就是 string,符合预期
prompt_basic?: PromptBasic,
prompt_draft?: PromptDraft,
prompt_commit?: PromptCommit,
}
export interface PromptBasic {
display_name?: string,
created_at?: string, // 又是 i64 → string
prompt_type?: PromptType,
}
⚠️ 谜底:
api.js_conv="true" 就是为了解决 JS 的大数字精度问题
JavaScript 的 number 类型是双精度浮点数,安全整数范围只有 ±2^53,而 Thrift 的 i64 能表示到 ±2^63——一个正常的 ID(比如 7378985812345678901)如果直接转成 JS number,后几位精度会丢失,变成一个错误的数字。api.js_conv="true" 这个注解告诉代码生成器:"这个 i64 字段传到前端时序列化成字符串,前端类型也生成成 string,绕开精度问题"。回头看 L02,没标注解的 prompt_key 本来就是 string,不受影响,所以不需要这个注解。一句话记住
看到接口里 ID 字段是 string 类型不要奇怪——这几乎总是
i64 加了 js_conv 注解的产物,不是"设计成字符串",而是"绕开 JS 数字精度陷阱"的工程手段。这是从真实大厂项目里学到的一个非常值得记住的细节。L05
三份代码,一张对照表
Thrift 源(prompt.thrift) | Go 生成(kitex_gen/.../prompt.go) | TS 生成(api-schema/.../prompt.ts) |
|---|---|---|
1: optional i64 id (api.js_conv="true") | ID *int64 | id?: string |
3: optional string prompt_key | PromptKey *string | prompt_key?: string |
4: optional PromptBasic prompt_basic | PromptBasic *PromptBasic | prompt_basic?: PromptBasic |
字段编号 1 2 3 4... | thrift:"xxx,N,optional" tag | (TS 不需要编号,编译期类型检查够用) |
optional | Go 指针类型 *T | TS 可选属性 ?: |
💡
optional 在三种语言里分别用什么机制表达"可能没有"
这是个很好的"同一个概念,不同语言用最natural的方式表达"的例子:Thrift 用关键字 optional,Go 没有"可选"语法所以用指针 nil 代替,TypeScript 有原生的可选属性语法 ?:。代码生成器的职责就是把 IDL 的抽象概念,翻译成每种目标语言里最惯用(idiomatic)的写法,而不是简单粗暴地都翻译成一种通用形式。L06
真正改一个字段,要跑哪些命令
按 docs/guidance/idl-codegen-guide.md 的真实流程(只展示命令,不要求你现在真的跑):
# 1. 改 idl/thrift/ 下对应的 .thrift 文件(比如给 PromptBasic 加个新字段)
# 2. 后端代码生成(在 backend/ 目录下)
bash script/cloudwego/kitex_tool.sh # Kitex (RPC) 代码生成 → backend/kitex_gen/
bash script/cloudwego/hertz_tool.sh # Hertz (HTTP) 代码生成 → backend/api/router_gen.go
bash script/cloudwego/code_gen.sh # 通用代码生成
# 3. 前端类型生成(在 frontend/ 目录下)
rush update-api # → frontend/packages/loop-base/api-schema/
# 4. 如果新增了服务接口,重新生成依赖注入代码
cd backend/modules//application && wire
# 5. 如果涉及数据库 schema 变更,还要跑 GORM 模型生成 + 手写 SQL 迁移
⚠️ 绝对不能手改的文件
backend/kitex_gen/、backend/loop_gen/、backend/api/router_gen.go、所有 wire_gen.go、frontend/packages/loop-base/api-schema/ 下的生成文件——这些文件顶部通常都带 "Code generated ... DO NOT EDIT" 注释(Day 15 见过
lodataset 的例子)。手改它们的后果:下次任何人跑一次生成脚本,你的改动会被无声覆盖,而且这个 bug 极难定位("我明明改过这个文件,怎么又变回去了")。👶 小白问:CI 会检查这些吗?
👨🏫 老师:会。.github/workflows/idl.yaml 专门在 IDL 变更时触发检查,.github/workflows/mysql-schema-check.yaml 检查数据库 schema 变更的两处 SQL 目录(docker-compose 和 helm-chart)是否保持一致——Day 20 会完整过一遍所有 CI 工作流。
L07
今日小结 + 动手 + 预告
🧠 今天你应该能回答
- 为什么 Thrift IDL 是"源",Go/TS 代码是"生成物"?改需求应该先改哪个?
- Thrift 的
optional分别在 Go 和 TS 里怎么表达? - 为什么
id字段在 TS 里是string而不是number? - 改一个字段,后端要跑哪几个脚本,前端要跑哪个命令?
- 哪些文件/目录绝对不能手改,为什么?
🎵 记忆口诀
「Thrift 是图纸,两栋楼各自施工,optional 各显神通,大数字转字符串防精度坑,生成文件绝不手改」——理解了这条链路,你已经具备了在这个仓库里"正确修改一个接口字段"的完整认知。
✋ 动手 5 分钟(可选)
# 1. 对照读三份代码,感受同一个字段在三种语言里的样子
sed -n '3,24p' idl/thrift/coze/loop/prompt/domain/prompt.thrift
sed -n '125,132p' backend/kitex_gen/coze/loop/prompt/domain/prompt/prompt.go
sed -n '3,10p' frontend/packages/loop-base/api-schema/src/api/idl/prompt/domain/prompt.ts
# 2. 找出所有带 js_conv 注解的字段,猜猜它们在 thrift 里是什么类型
grep -rn "js_conv" idl/thrift/coze/loop/prompt/ | head -10
# 3. 完整看一遍代码生成指南
cat docs/guidance/idl-codegen-guide.md
# 4. 找到所有"生成文件禁止手改"的提示
grep -rn "DO NOT EDIT" backend/loop_gen/coze/loop/data/lodataset/*.go | head -3
明天预告 · Day 19:光看不练不算学会——明天我们真的动手设计一次"改一处小功能"的方案:以理解/扩展
ListPrompt 的筛选条件为例,走一遍"该改哪一层、不该碰哪里、怎么保证不破坏现有接口"的完整决策过程。