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

16 KiB
Raw Blame History

tags, create time
tags create time
gRPC
Protobuf
Go
protoc
Code Generation
proto3
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)与字段类型

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)

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,它比用单独字段节省内存,因为底层只有一个字段在存储。

message PaymentMethod {
  oneof method {
    string credit_card = 1;      // Visa/MC number
    string paypal_email = 2;     // PayPal 账户
    AlipayAccount alipay = 3;    // 自定义 message
  }
}

只能设置 oneof 中的一个字段。设置新字段会清除前一个的值。

Map Fields

message UserMetadata {
  map<string, string> tags = 1;        // user -> tag mappings
  map<int64, string> department_map = 2; // dept ID -> name
}

内部实现是一个哈希表。空 map 序列化为空,不会省略。

Reserved 字段

当你删除或重命名某个字段时,用 reserved 占位防止其他人重用同一 field number:

message User {
  reserved 3, 5 to 8;         // 保留 field numbers 3, 5, 6, 7, 8
  reserved "old_name", "temp"; // 也保留字段名
}

Well-Known Types

Protobuf 内置了一组通用类型,import 后可直接使用:

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 方法:

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 开发者最容易踩坑的地方。它的格式是 "模块路径/生成文件存放路径;包名"。

// ✅ 正确: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。

其他语言路径配置

// 如需支持多语言输出,补全对应的 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 可以不填,减少维护负担。

标记已废弃字段

message OldUserProto {
  string old_field = 1 [deprecated = true]; // 前端可用 @deprecated 注解识别
}

optimize_for(性能调优选项)

对于消息体很大的场景,可以用 optimize_for 控制代码生成的策略:

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 编译流程

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 中的关键符号

// --- 客户端 ---
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

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 能保护你的代码不受影响。

实战:客户端调用示例

// 构建客户端并发起请求
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 基本用法

# 最简形式(generated files 放入 output dir)
protoc \
  --go_out=. \
  --go-grpc_out=. \
  *.proto

source_relative 模式(推荐)

生成的文件与原 .proto 放在同目录下,更符合 Go 习惯:

protoc \
  --go_opt=paths=source_relative \
  --go-grpc_opt=paths=source_relative \
  -I . \
  *.proto

多目录 / 带 import 的场景

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:generate protoc \
//    --go_opt=paths=source_relative \
//    --go-grpc_opt=paths=source_relative \
//    -I . \
//    ./proto/**/*.proto

然后在终端只需一行:

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 后重新生成,而不是直接改生成的文件。

开发者手写的部分

// ① 服务端:实现生成的 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