本节摘要:自定义插件是扩展 protoc 的正规军手段——一个读 CodeGeneratorRequest、写 CodeGeneratorResponse 的普通程序。本节以真实需求(从 proto 生成 Markdown 字段速查表)为主线,走完插件的完整生命周期:协议消息的读取、描述符树的遍历策略、响应文件的组织与写回、命令行的接入。读完你应当能独立编写并调试一个生产可用的 protoc 插件。
本章收官之节,也是全册第一个"往外造工具"而非"往里学原理"的节。它直接为第 6.1 节的自定义 option 铺路:option 的消费端,往往就是你在这里学会写的插件。
目标:写一个 protoc-gen-docmd 插件,输入任意 proto 工程的描述符,输出每条 message 一节 Markdown 速查表(字段名、类型、编号、是否 repeated),供团队 wiki 使用。选择这个例子是因为它覆盖了插件开发的全部核心动作且产物肉眼可验。
先立接口:插件的可执行文件名必须是 protoc-gen-docmd,调用形态是:
protoc --docmd_out=docs/ --plugin=protoc-gen-docmd=./protoc-gen-docmd proto/demo/v1/*.proto
用 Go 写主干(语言任选,协议只是管道里的两个 message):
func main() { // 1. 读标准输入:CodeGeneratorRequest data, err := io.ReadAll(os.Stdin) if err != nil { fatal(err) } var req pluginpb.CodeGeneratorRequest if err := proto.Unmarshal(data, &req); err != nil { fatal(err) } // 2. 遍历请求里的每个文件描述符,产出文档内容 resp := &pluginpb.CodeGeneratorResponse{} for _, fd := range req.GetProtoFile() { if !wantFile(fd.GetName(), req.GetFileToGenerate()) { continue // 依赖文件默认只做参照,不产出文档 } md := renderFile(fd) resp.File = append(resp.File, &pluginpb.CodeGeneratorResponse_File{ Name: proto.String(docName(fd.GetName())), Content: proto.String(md), }) } // 3. 写标准输出:CodeGeneratorResponse out, _ := proto.Marshal(resp) os.Stdout.Write(out) }
主干揭示的第一件事:插件拿到的输入不是源码,是完整的描述符树。req.ProtoFile 里不仅有命令行列出的文件,还有它们的全部传递依赖——所以文档插件不需要自己做 import 解析,类型信息信手拈来。req.FileToGenerate 则是命令行显式列出的文件清单,遍历产出时用它过滤(否则依赖的公共类型会被重复出文档)。
文档的核心是每条 message 的字段表。描述符里的取数路径:
func renderFile(fd *descriptorpb.FileDescriptorProto) string { var b strings.Builder for _, msg := range fd.GetMessageType() { // 顶层 message 列表 b.WriteString(renderMessage(msg, 1)) } return b.String() } func renderMessage(m *descriptorpb.DescriptorProto, depth int) string { var b strings.Builder fmt.Fprintf(&b, "## %s\n\n", m.GetName()) b.WriteString("| 字段 | 类型 | 编号 | repeated |\n|---|---|---|---|\n") for _, f := range m.GetField() { fmt.Fprintf(&b, "| %s | %s | %d | %v |\n", f.GetName(), typeName(f), f.GetNumber(), f.GetLabel() == descriptorpb.FieldDescriptorProto_LABEL_REPEATED) } // 嵌套 message 递归 for _, nested := range m.GetNestedType() { b.WriteString(renderMessage(nested, depth+1)) } return b.String() }
三个遍历要点。其一,类型名的拼接:f.GetType() 返回枚举值,f.GetTypeName() 对 message/enum 字段返回全限定名(带前导点),标量类型则要自己从枚举翻译成 "string"、"sint32"——文档质量取决于这段翻译的完备度。其二,嵌套递归:GetNestedType() 挂着嵌套 message,逐层下钻。其三,GetLabel()** 同时回答 repeated 与 oneof 归属**:oneof 字段的判断走 f.GetOneofIndex()(描述符里存的是索引,指向 m.GetOneofDecl())。
插件开发最常见的卡点与解法:
卡点一:完全没输出。 先确认管道本身通:echo '' | protoc-gen-docmd 若崩溃于 Unmarshal,说明输入不是合法的请求——检查 protoc 的调用是否真的带了 --docmd_out。再检查 FileToGenerate 过滤逻辑是否把所有文件都滤掉了(路径匹配写错是高频原因)。
卡点二:输出被 protoc 丢弃。 CodeGeneratorResponse 里有个 supported_features 字段,新版 protoc 会在响应校验里检查插件声明的特性支持(如 proto3 optional)。没声明时 protoc 可能拒绝输出。骨架里加一行 resp.SupportedFeatures = proto.Uint64(uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL)) 即可。
卡点三:想看请求里到底有什么。 把 req 原样 Marshal 后写进一个文件,再拿第 1 章的 decode 器械拆它——CodeGeneratorRequest 本身就是 protobuf message,描述符考古工具完全适用。这是"用 Protobuf 考古 Protobuf"的闭环时刻。
背景:文档插件上线三个月后,团队要求"被标 deprecated 的字段在文档里加显眼警告",否则读文档的人会继续给废弃字段提需求。操作:字段描述符里有现成的 GetOptions().GetDeprecated() 布尔——遍历时检测到就在行首加"已废弃"标记,并在表下追加该字段的迁移提示(提示文案从哪来?正好用第 6 章预告的自定义 option 携带,本节先留接口)。结果:文档迭代无需人工维护,proto 文件成为唯一事实源——在契约里标一次 deprecated,文档、代码警告、CI 检查三处同步生效。解读:这个案例展示了插件路线的复利:凡是"契约里已有、但只活在描述符里的信息",都可以用插件摊到任何消费面(文档、校验代码、监控埋点、mock 数据)。变式:把输出从 Markdown 换成 JSON,文档插件立刻变成契约元数据管道,供下游的 API 网关做字段级限流配置。
工具层到此毕业。下一章处理所有考古队最关心的问题:遗址的分期——schema 怎么演进才能让新旧版本在字节层相安无事。