代码生成:protoc 与 buf,Go 工程目录约定
.proto 写得再规范,Go 代码也不会自己出现。这一步要做三件事:装好编译器与插件、把 .proto 的目录结构定下来、把生成命令固化成一条可重复执行的动作。手敲两条 protoc 命令撑一周还行,接口一多必然出错,所以工程上通常直接上 buf。
装工具:protoc 本体与两个 Go 插件
protoc 是编译器本体(C++ 写的命令行工具),Go 代码由插件生成,两者都要装:
# 1) 装 protoc 本体:下载官方预编译包解压后把 bin 加进 PATH
# 也可用 brew install protobuf / apt install -y protobuf-compiler
# 具体版本与包名以官方文档为准
# 2) 装两个 Go 插件:一个出 message,一个出 service
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
# 3) 确认插件可用:protoc 靠 PATH 找可执行文件,名字是约定死的
go env GOPATH # 插件落在 $(go env GOPATH)/bin,把该目录加进 PATH
protoc-gen-go --version
protoc-gen-go-grpc --version
命名约定是关键:看到 --go_out,protoc 就去找名为 protoc-gen-go 的可执行文件;看到 --go-grpc_out 就找 protoc-gen-go-grpc。找不到时的报错是 protoc-gen-go: program not found or is not executable。
直接用 protoc
protoc \
--proto_path=proto \
--go_out=gen --go_opt=paths=source_relative \
--go-grpc_out=gen --go-grpc_opt=paths=source_relative \
proto/account/v1/account.proto
--proto_path(可简写-I)是 import 的解析根目录;import "google/protobuf/timestamp.proto"这类标准库路径由 protoc 自带的 well-known types 提供,不用自己抄一份。paths=source_relative让输出路径跟着.proto相对--proto_path的路径走:proto/account/v1/account.proto生成gen/account/v1/account.pb.go。不加这个选项时,插件按go_package的导入路径铺目录,容易生成到意料之外的位置。- 每个
.proto产出两个文件:account.pb.go(消息与序列化)和account_grpc.pb.go(客户端与服务端骨架),两者都不要手改。
用 buf:把命令变成配置
buf 是一套 protobuf 工具链,自带 lint、破坏性变更检测与依赖管理,配置比一长串 protoc 参数耐维护得多。
buf.yaml 声明模块与检查规则:
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILE
buf.gen.yaml 声明插件与输出位置:
version: v2
plugins:
- local: protoc-gen-go
out: gen
opt: paths=source_relative
- local: protoc-gen-go-grpc
out: gen
opt: paths=source_relative
日常命令:
buf lint # 按 STANDARD 规则检查命名、目录与注释
buf format -w # 统一 .proto 排版
buf generate # 按 buf.gen.yaml 批量生成,等价于跑一遍 protoc
buf breaking --against '.git#branch=main' # 与 main 分支对比,检出破坏性改动
配置文件的 schema 会随 buf 大版本变化(v1 与 v2 差异明显),升级前以官方文档为准,别照抄旧文章的写法。
Go 工程目录约定
| 目录 | 放什么 | 约定 |
|---|---|---|
proto/<domain>/<version>/*.proto | 契约源文件,按领域加版本分目录 | 版本进路径(v1、v2),不进文件名 |
gen/<domain>/<version>/*.pb.go | 生成物,整目录提交或整目录重建二选一 | 顶部别手写代码 |
cmd/<service>/main.go | 每个服务的入口 | 只做装配:读配置、建连接、起 server |
internal/<service>/service.go | 生成接口的实现 | 一个 proto service 一个文件 |
internal/<service>/store.go | 数据访问,与 RPC 层解耦 | 便于单测替换 |
把版本写进目录的好处:im.account.v1 与 im.account.v2 可以长期共存,灰度期各服务各调各的,这正是第 15 章的兼容策略在工程结构上的落点。
把生成固化成一条命令
用 Makefile:
.PHONY: proto
proto:
buf generate
go build ./...
也可以挂到 Go 的 go:generate 上(前提是插件已安装并在 PATH 中):
package gen
//go:generate buf generate
CI 里加一行 buf lint && buf breaking --against '.git#branch=main' && buf generate && git diff --exit-code gen,就能挡住「改了 proto 忘了提交生成物」和「顺手引入不兼容改动」两类事故。
常见坑
| 坑 | 现象 | 改法 |
|---|---|---|
| 插件不在 PATH | protoc-gen-go: program not found | 把 $(go env GOPATH)/bin 加进 PATH 并重开终端 |
只跑 --go_out | 只有消息类型,没有 Client/Server | --go-grpc_out 必须一起跑,事后补不出来 |
| 生成物的 import 路径很怪 | import 一个不存在的包,编译失败 | 统一 go_package 与 paths=source_relative,导入根用模块名 |
| 手工修改生成物 | 下次生成被覆盖,改动丢失 | 要扩展行为就写包装类型,别改生成文件 |
| 生成物不提交、CI 也不生成 | 拉下代码编译不过 | 提交前统一 buf generate,或构建流水线里生成 |
| protoc 与插件版本漂移 | 生成结果 diff 满天飞 | 在 CI 与本地固定同一版本,版本号写进脚本 |
把 .proto 放在实现代码目录里 | 契约与实现耦合,复用困难 | 契约集中收在 proto/ 下 |
小结:protoc 负责编译、插件负责出 Go 代码、buf 把两者收进配置文件;契约放 proto/<domain>/<version>/、生成物放 gen/、实现在 internal/,并让 buf generate 成为 CI 上可重复执行的一步。