Files
cs-note/hhs/gRPC/2. gRPC 核心篇/06-Service 定义与代码生成.md
T
2026-05-24 11:42:38 +08:00

479 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [gRPC, Protobuf, Go, protoc, Code Generation, proto3]
create time: 2026-05-11 16:41
---
# Service 定义与代码生成
## 概述
`.proto` 文件的最终目的是定义 Service —— 告诉 gRPC 有哪些远程可调用的 API。Protobuf 编译器会将你的 service 定义翻译成各个语言的 client stub 和 server interface。理解这个从文本到可执行代码的过程,是调试"gRPC 报错说找不到方法"的前提。
> [!question] 为什么需要代码生成?
> Protobuf 不是动态语言。所有类型、字段、方法都在编译期确定,这意味着你没法在运行时通过字符串来调用一个 RPC——必须用生成的 Stub。好处是强类型检查能在编码阶段就发现错误,坏处是你改了 proto 文件就得重新生成代码并编译。
## Proto3 语法速览
Service 由 message 组成,所以先快速过一遍 proto3 的核心语法。这是写 proto 文件时每天都要用的基础知识。
### 消息(Message)与字段类型
```protobuf
syntax = "proto3";
package user.v1;
message User {
string name = 1; // 变长字符串,UTF-8 编码
int64 id = 2; // 64 位有符号整数
bool active = 3; // 布尔值
float balance = 4; // 32 位浮点数
bytes avatar = 5; // 原始字节(如图片数据)
}
```
**常用标量类型一览:**
| 声明类型 | 对应 Go 类型 | 说明 |
|----------|-------------|------|
| `double` / `float` | `float64` / `float32` | 浮点数 |
| `int64` / `uint64` | `int64` / `uint64` | 大整数 |
| `int32` / `uint32` | `int32` / `uint32` | 普通整数(用 varint 编码,小值更紧凑) |
| `bool` | `bool` | 布尔 |
| `string` | `string` | UTF-8 字符串(必须用 string,bytes 存原始二进制) |
| `bytes` | `[]byte` | 任意字节序列 |
> [!tip] 零值语义
> proto3 没有 `optional` 标记的字段永远有零值——`string` 是 `""`,`int` 是 `0`,`bool` 是 `false`。你无法区分"字段没设置"和"字段设为零值"。如果需要检测字段是否存在,可以用 `google.protobuf.BoolValue` 包装类型,或者启用 `optional` 关键字(proto3 扩展语法)。
### 枚举(Enum)
```protobuf
enum UserRole {
ROLE_UNKNOWN = 0; // proto3 要求第一个值是 0,且必须是唯一的 zero value
ROLE_ADMIN = 1;
ROLE_USER = 2;
ROLE_GUEST = 3;
}
message CreateUserRequest {
string name = 1;
UserRole role = 2; // 用枚举替代魔法数字
}
```
> [!warning] enum 的零值陷阱
> proto3 中如果收到未知枚举值,它会被当作零值处理而非报错。这意味着服务端可以优雅地忽略客户端传来的新版本枚举值——这是 proto 向后兼容的设计之一。
### Oneof(多选一)
当多个字段互斥时使用 `oneof`,它比用单独字段节省内存,因为底层只有一个字段在存储。
```protobuf
message PaymentMethod {
oneof method {
string credit_card = 1; // Visa/MC number
string paypal_email = 2; // PayPal 账户
AlipayAccount alipay = 3; // 自定义 message
}
}
```
只能设置 oneof 中的**一个**字段。设置新字段会清除前一个的值。
### Map Fields
```protobuf
message UserMetadata {
map<string, string> tags = 1; // user -> tag mappings
map<int64, string> department_map = 2; // dept ID -> name
}
```
内部实现是一个哈希表。空 map 序列化为空,不会省略。
### Reserved 字段
当你删除或重命名某个字段时,用 `reserved` 占位防止其他人重用同一 field number:
```protobuf
message User {
reserved 3, 5 to 8; // 保留 field numbers 3, 5, 6, 7, 8
reserved "old_name", "temp"; // 也保留字段名
}
```
### Well-Known Types
Protobuf 内置了一组通用类型,import 后可直接使用:
```protobuf
import "google/protobuf/timestamp.proto";
import "google/protobuf/wrappers.proto";
import "google/protobuf/struct.proto";
message Event {
google.protobuf.Timestamp created_at = 1; // RFC 3339 时间戳
google.protobuf.StringValue display_name = 2; // *string,用于 detect missing
google.protobuf.Struct metadata = 3; // JSON-like 任意结构
}
```
常用 well-known type 速查:
| Well-Known Type | 对应 Go 类型 |
|----------------|-------------|
| `Timestamp` | `time.Time` |
| `Duration` | `time.Duration` |
| `StringValue` | `*string` |
| `Int32Value` / `Int64Value` | `*int32` / `*int64` |
| `BoolValue` | `*bool` |
| `Any` | `any` / `[]byte` |
## Service 定义语法
Service 定义使用 `service` 关键字包裹一组 `rpc` 方法:
```protobuf
syntax = "proto3";
package user.v1;
option go_package = "example.com/proto/user/v1;userpb";
message CreateUserRequest {
string name = 1;
string email = 2;
}
message CreateUserResponse {
int64 id = 1;
}
service UserService {
// Unary: 普通请求响应
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
// Server Streaming: 一次请求,多个响应
rpc ListUsers(ListUsersRequest) returns (stream ListUsersResponse);
// Client Streaming: 多次请求,一个响应
rpc UploadAvatar(stream AvatarChunk) returns (AvatarResult);
// Bidirectional Streaming: 双方都流式
rpc WatchUsers(stream WatchRequest) returns (stream WatchEvent);
}
```
**语法要点:**
- `stream` 关键字出现在参数侧表示该方向是流式
- `stream` 可以出现在 request 侧、response 侧,或两侧都有
- 每个 proto 文件可以有**多个** service 定义(但最佳实践建议一个文件一个 service,保持高内聚)
## Option 系统
### 必填项
| Option | 说明 | 示例 |
|--------|------|------|
| `syntax` | 语法版本,proto3 是当前标准 | `syntax = "proto3";` |
| `package` | 命名空间,防止跨项目命名冲突 | `package user.v1;` |
| `go_package` | Go 输出路径和包名 | `option go_package = "...";` |
### go_package 详解
这是 Go 开发者最容易踩坑的地方。它的格式是 `"模块路径/生成文件存放路径;包名"`。
```protobuf
// ✅ 正确:module path 是 github.com/myorg/services
// 生成的文件放在 gen/proto/user/v1/ 目录下
// 包名为 userpb
option go_package = "github.com/myorg/services/gen/proto/user/v1;userpb";
// ❌ 错误:go_package 中的路径与实际 import 不匹配
// 会导致 Go 编译器报 symbol undefined
option go_package = "user/v1;userpb";
```
> [!tip] go_package 拆解
> ```
> option go_package = "导入路径/子路径;包名";
> // ↑ 生成文件相对 module root 的路径 ↑ Go package name
> // 生成文件的 import path 将是 module root + 前半段
> ```
>
> 例如:`go_package = "github.com/myorg/api/user/v1;userpb"`,如果当前 `.proto` 所在目录与 `v1` 对齐,则生成的 `_pb.go` 的 import path 为 `github.com/myorg/api/user/v1`,文件中 `package userpb`。
### 其他语言路径配置
```protobuf
// 如需支持多语言输出,补全对应的 option
option java_package = "com.example.user.v1";
option java_multiple_files = true; // 每个 message 单独一个 Java 文件
option py_generic_services = false; // Python 是否生成 service 基类
option php_namespace = "Example\\User\\V1";
```
> [!note] 如果只开发 Go 服务,其他语言的 option 可以不填,减少维护负担。
### 标记已废弃字段
```protobuf
message OldUserProto {
string old_field = 1 [deprecated = true]; // 前端可用 @deprecated 注解识别
}
```
### optimize_for(性能调优选项)
对于消息体很大的场景,可以用 `optimize_for` 控制代码生成的策略:
```protobuf
option optimize_for = SPEED; // 默认:生成的序列化/反序列化代码最快
// option optimize_for = CODE_SIZE; // 优化生成代码大小(使用 lite runtime)
// option optimize_for = LITE_RUNTIME; // 生成依赖 lite protobuf runtime 的代码
```
- **`SPEED`**(默认):生成完整的序列化和反序列化代码,速度最优
- **`CODE_SIZE`**:使用反射式编解码,减小生成的代码体积,适合嵌入式环境
- **`LITE_RUNTIME`**:类似 CODE_SIZE,但保留部分直接编解码逻辑,折中方案
大多数 Web 微服务不需要改这个选项——默认的 SPEED 就是最好的选择。
### Custom Options 简介
通过 `extend google.protobuf.MessageOptions` 可以定义自定义 option,被 gRPC Gateway、Protoc Gen OpenAPI 等工具链借用。了解即可,涉及 proto 元数据反射,属于进阶话题。
## Proto 编译流程
```mermaid
flowchart LR
A[".proto 源文件"] --> B["protoc 编译器"]
B --> C["protoc-gen-go — Go struct 定义"]
B --> D["protoc-gen-go-grpc — Client Stub + Server Interface"]
C --> E["Go 代码编译"]
D --> E
E --> F["可执行程序"]
style A fill:#FFD43B
style B fill:#00B6BC,color:#fff
style F fill:#4FC08D,color:#fff
```
核心流程就是三步:写 `.proto` → `protoc` 生成 Go 代码 → 正常 `go build`。
### 典型的项目目录结构
```
proto/
├── buf.gen.yaml # buf 代码生成配置(如果用 buf)
├── user/
│ └── v1/
│ ├── user.proto # Service + Message 定义
│ └── error.proto # 错误码定义
├── google/ # third_party 依赖(来自 grpc-ecosystem/grpc-gateway)
└── Makefile # 自动化生成脚本
```
> [!tip] 推荐的两种代码生成方式
>
> 1. **Makefile + protoc**:最传统的做法,用 shell 变量管理 `-I` 路径
> 2. **buf**:现代 proto 编译工具,自动处理依赖管理和 plugin 版本,推荐新项目使用
>
> 具体用法参见 [[hhs/gRPC/6. 工程实践篇/17-protoc 工具链与 Makefile]]
## Go Stub 解析
运行 protoc 后,每个 `.proto` 文件至少生成两个文件:
| 文件 | 内容 |
|------|------|
| `xxx_pb.go` | Message 的 struct 定义(如 `CreateUserRequest{}`) |
| `xxx_grpc.pb.go` | Client interface + Server interface + Register 函数 |
### `_grpc.pb.go` 中的关键符号
```go
// --- 客户端 ---
type UserServiceClient interface {
CreateUser(ctx context.Context, in *CreateUserRequest, opts ...grpc.CallOption) (*CreateUserResponse, error)
ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (UserService_ListUsersClient, error)
// ...
}
func NewUserServiceClient(cc grpc.ClientConnInterface) UserServiceClient
// --- 服务端 ---
type UserServiceServer interface {
CreateUser(context.Context, *CreateUserRequest) (*CreateUserResponse, error)
ListUsers(*ListUsersRequest, UserService_ListUsersServer) error
// ...
}
// 你必须嵌入这个来拿到零值安全的方法实现
type UnimplementedUserServiceServer struct{}
// --- 注册 ---
func RegisterUserServiceServer(s grpc.ServiceRegistrar, srv UserServiceServer)
// --- ServiceDesc ---
var UserService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "user.v1.UserService",
MethodType: grpc.Unary,
MethodName: "CreateUser",
Handler: ...,
}
```
### `_pb.go` 中的 Message
```go
type CreateUserRequest struct {
nameState impl.MessageState
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
Email string `protobuf:"bytes,2,opt,name=email,proto3" json:"email,omitempty"`
}
// + getter methods: GetName(), GetEmail()
// + JSON marshaler/unmarshaler
```
> [!tip] Getter 方法
> Protobuf Go 生成器会为每个字段生成 `GetXxx()` getter方法。即使字段本身是 public 的(大写),你也应该优先用 getter——某些字段未来可能会改为 internal 实现,getter 能保护你的代码不受影响。
### 实战:客户端调用示例
```go
// 构建客户端并发起请求
conn, _ := grpc.Dial("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
defer conn.Close()
client := pb.NewUserServiceClient(conn)
resp, err := client.CreateUser(context.Background(), &pb.CreateUserRequest{
Name: "Alice",
Email: "alice@example.com",
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("created user with id=%d\n", resp.Id)
```
## 代码生成命令速查
### protoc 基本用法
```bash
# 最简形式(generated files 放入 output dir)
protoc \
--go_out=. \
--go-grpc_out=. \
*.proto
```
### source_relative 模式(推荐)
生成的文件与原 `.proto` 放在同目录下,更符合 Go 习惯:
```bash
protoc \
--go_opt=paths=source_relative \
--go-grpc_opt=paths=source_relative \
-I . \
*.proto
```
### 多目录 / 带 import 的场景
```bash
protoc \
--go_opt=paths=source_relative \
--go-grpc_opt=paths=source_relative \
-I proto/ \
-I third_party/googleapis/ \
proto/user/v1/*.proto
```
### 集成到 go generate
在项目的入口文件顶部添加注释指令:
```go
//go:generate protoc \
// --go_opt=paths=source_relative \
// --go-grpc_opt=paths=source_relative \
// -I . \
// ./proto/**/*.proto
```
然后在终端只需一行:
```bash
go generate ./...
```
> [!tip] 为什么推荐 go generate?
> 相比手写 protoc 命令,`go generate` 让代码生成变成 Go 工作流的一部分,不再需要额外记忆复杂的命令行参数。结合 Makefile 或 Task 更稳定。参见 [[hhs/gRPC/6. 工程实践篇/17-protoc 工具链与 Makefile]]。
## 实践要点:自动生成 vs 手写
### 自动生成(由 protoc 产出,不要手动修改)
| 文件 | 内容 |
|------|------|
| `xxx_pb.go` | Message struct 定义 + getter 方法(如 `CreateUserRequest{}`、`GetName()`) |
| `xxx_grpc.pb.go` | Client Stub interface + `NewUserServiceClient()` |
| `xxx_grpc.pb.go` | Server Interface(如 `UserServiceServer{ CreateUser(...) }`) |
| `xxx_grpc.pb.go` | Register 函数 + ServiceDesc 元数据 |
| `xxx_grpc.pb.go` | `UnimplementedUserServiceServer` 零值安全基类 |
这些文件**每次 `.proto` 变更后重新生成即可覆盖**。如果发现有 bug,修复源头 `.proto` 后重新生成,而不是直接改生成的文件。
### 开发者手写的部分
```go
// ① 服务端:实现生成的 Server Interface
type server struct {
v1.UnimplementedUserServiceServer // ← 嵌入自动生成的基类
store *UserStore // ← 自己的依赖注入
}
func (s *server) CreateUser(ctx, req *v1.CreateUserRequest) (*v1.CreateUserResponse, error) {
// ← 填充业务逻辑(数据库操作、验证、权限等)
}
// ② 客户端:使用生成的 Client Stub 发起调用
client := v1.NewUserServiceClient(conn) // ← 用生成的函数创建
resp, _ := client.CreateUser(ctx, &v1.CreateUserRequest{Name: "Alice"})
// ③ Server 启动注册
v1.RegisterUserServiceServer(s, &myServer{})
// ④ 中间件、拦截器、错误映射、健康检查等——全部自行实现
```
> [!important] 核心原则
> **所有业务代码都应假设生成的 `.pb.go` / `_grpc.pb.go` 随时会被重新生成覆盖。** 不自行定义与 proto 同名的 struct;类型统一从生成的包 import;不修改任何生成的文件。
## 常见错误排查
| 错误信息 | 原因 | 解决方法 |
|----------|------|----------|
| `symbol undefined` | `go_package` 路径不正确 | 检查 `go_package` 中的 module 路径是否匹配当前项目的 `go.mod` |
| `service not found` | 没有运行 `protoc-gen-go-grpc` | 确认 `go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest` 且 `$GOPATH/bin` 在 PATH 中 |
| `mismatched version` | `protoc` CLI 版本与 plugin 版本不匹配 | 升级 plugin 到最新版,或降级 protoc。两者不需要严格一致,但不要跨太多代 |
| `unknown syntax` | `.proto` 缺少 `syntax = "proto3"` | 添加该行或在文件开头加上 `syntax = "proto3";` |
| `import not found` | `-I` 导入路径不对 | 检查 `import "..."` 声明与 `-I` 指定的 search path 是否对齐 |
| `already defined` | 同一个 message/service 被定义了两次 | 检查是否有重复 import,或多个 proto 文件导出到了同一目标目录 |
| `go_package mismatch` | 生成文件所在的包路径不符合 Go module 约定 | 将 `go_package` 调整为相对于 module root 的正确路径 |
> [!tip] 快速诊断顺序
> 1. `protoc --version` 看版本
> 2. `which protoc-gen-go-grpc` 确认 plugin 可执行
> 3. `grep go_package *.proto` 检查模块路径
> 4. 跑 `go generate` 而非手动 protoc——Makefile 更稳定
## 关联笔记
- [[hhs/gRPC/2. gRPC 核心篇/05-RPC 调用模式总览]]
- [[hhs/gRPC/2. gRPC 核心篇/07-HTTP2 传输原理]]
- [[hhs/gRPC/6. 工程实践篇/17-protoc 工具链与 Makefile]]