Go 与 gRPC
gRPC 与 Protobuf 实战:proto3 服务定义、代码生成、四种通信模式、拦截器、超时与状态码、生产级实践。
前置知识
建议先阅读以下内容再进入本文:
概述
gRPC 是 Google 开源的高性能远程过程调用(RPC)框架,使用 Protocol Buffers 作为接口定义语言和序列化格式。与 REST/JSON 相比,gRPC 使用二进制传输,性能更高;强类型定义,开发更安全;支持双向流,通信更灵活。Go 是 gRPC 的一等公民语言,官方提供了完整的 SDK。
基础概念
在开始编码之前,需要理解 gRPC 的几个核心概念:
- Protobuf:Protocol Buffers,一种二进制序列化格式,比 JSON 更小更快。用
.proto文件定义数据结构和服务接口。 - Service:在
.proto文件中定义的一组 RPC 方法,类似于接口。 - Stub/Client:根据
.proto文件自动生成的客户端代码,调用远程方法就像调用本地函数。 - 四种通信模式:一元调用(请求-响应)、服务端流、客户端流、双向流。
- Channel:客户端与服务端之间的逻辑连接,底层是一条 HTTP/2 连接,多个 RPC 通过多路复用共享它,这也是 gRPC 高吞吐的关键。
一个容易忽略的事实:gRPC 建立在 HTTP/2 之上,因此天然获得头部压缩(HPACK)、单连接多路复用与双向流能力;而 Protobuf 的二进制编码靠”字段编号”而非字段名来标识数据,这决定了它的向后兼容规则(见下文 Protobuf 设计要点)。
快速上手
1. 安装工具
# 安装 protoc 编译器
# Windows: 从 https://github.com/protocolbuffers/protobuf/releases 下载
# Mac: brew install protobuf
# Linux: apt install protobuf-compiler
# 安装 Go 插件
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
# 安装 gRPC 库
go get google.golang.org/grpc
2. 定义 Protobuf
创建 proto/user.proto:
syntax = "proto3";
package user;
option go_package = "myapp/proto/user";
// 定义数据结构
message User {
string id = 1;
string name = 2;
string email = 3;
}
message GetUserRequest {
string id = 1;
}
message GetUserResponse {
User user = 1;
}
// 定义服务
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}
3. 生成代码
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative proto/user.proto
生成两个文件:user.pb.go(消息类型的序列化代码)与 user_grpc.pb.go(服务端接口与客户端 Stub)。
4. 实现服务端
package main
import (
"context"
"log"
"net"
pb "myapp/proto/user"
"google.golang.org/grpc"
)
// 实现服务接口
type server struct {
pb.UnimplementedUserServiceServer // 必须嵌入
}
func (s *server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
// 模拟数据库查询
return &pb.GetUserResponse{
User: &pb.User{
Id: req.Id,
Name: "小明",
Email: "ming@example.com",
},
}, nil
}
func main() {
// 创建 gRPC 服务器
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatal(err)
}
s := grpc.NewServer()
pb.RegisterUserServiceServer(s, &server{})
log.Println("gRPC 服务器启动在 :50051")
s.Serve(lis)
}
运行服务端,预期输出:
2026/09/09 10:00:00 gRPC 服务器启动在 :50051
5. 实现客户端
package main
import (
"context"
"log"
"time"
pb "myapp/proto/user"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func main() {
// grpc.NewClient(gRPC-Go 1.63+ 推荐)创建懒连接:
// 它立即返回,不立即发起 TCP 握手,首次调用时才真正连接
conn, err := grpc.NewClient("localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatal(err)
}
defer conn.Close()
client := pb.NewUserServiceClient(conn)
// 一元调用必须带超时,避免对端无响应时永久阻塞
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: "123"})
if err != nil {
log.Fatal(err)
}
log.Printf("用户: %s, 邮箱: %s", resp.User.Name, resp.User.Email)
}
先启动服务端,再运行客户端,预期输出:
2026/09/09 10:00:05 用户: 小明, 邮箱: ming@example.com
grpc.Dial 与 grpc.NewClient:旧教程里常见的
grpc.Dial会在调用时立刻建立 TCP 连接(可被grpc.WithBlock()改为阻塞等待),该 API 自 gRPC-Go 1.63(2023-12)起已被标记废弃,由grpc.NewClient取代。两者最关键的行为差异:NewClient使用名字解析器 + 负载均衡器异步建连,连接失败不会在创建时报错,而是在第一次 RPC 上体现——因此超时控制从”拨号阶段”前移到了”每次调用”,务必给每个调用都带上 deadline。
详细用法
1. 服务端流
服务端返回一个流,客户端逐条接收:
service OrderService {
rpc ListOrders(ListOrdersRequest) returns (stream Order);
}
func (s *server) ListOrders(req *pb.ListOrdersRequest, stream pb.OrderService_ListOrdersServer) error {
orders := getOrders(req.UserId)
for _, order := range orders {
// 逐条发送
if err := stream.Send(order); err != nil {
return err
}
}
return nil
}
客户端接收:
stream, _ := client.ListOrders(ctx, &pb.ListOrdersRequest{UserId: "123"})
for {
order, err := stream.Recv()
if err == io.EOF {
break // 流结束
}
if err != nil {
log.Fatal(err)
}
fmt.Printf("订单: %s\n", order.Id)
}
2. 客户端流
客户端发送一个流,服务端接收后返回一个响应:
service UploadService {
rpc UploadFile(stream FileChunk) returns (UploadResponse);
}
func (s *server) UploadFile(stream pb.UploadService_UploadFileServer) error {
var totalSize int
for {
chunk, err := stream.Recv()
if err == io.EOF {
return stream.SendAndClose(&pb.UploadResponse{
Size: int32(totalSize),
Message: "上传完成",
})
}
if err != nil {
return err
}
totalSize += len(chunk.Data)
}
}
3. 双向流
双方都可以随时发送数据:
service ChatService {
rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
func (s *server) Chat(stream pb.ChatService_ChatServer) error {
for {
msg, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
// 收到消息后回复
stream.Send(&pb.ChatMessage{
User: "服务器",
Content: "收到: " + msg.Content,
})
}
}
4. 拦截器(中间件)
gRPC 的拦截器类似 HTTP 中间件,可以在请求前后执行通用逻辑:
// 一元拦截器
func loggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
start := time.Now()
log.Printf("请求: %s", info.FullMethod)
resp, err := handler(ctx, req)
log.Printf("完成: %s, 耗时: %v", info.FullMethod, time.Since(start))
return resp, err
}
// 注册拦截器
s := grpc.NewServer(
grpc.UnaryInterceptor(loggingInterceptor),
)
5. 超时和取消
// 客户端设置超时
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.GetUser(ctx, req)
if err != nil {
// 检查是否超时
if ctx.Err() == context.DeadlineExceeded {
log.Println("请求超时")
}
}
6. 错误处理
gRPC 使用状态码表示错误:
import "google.golang.org/grpc/codes"
import "google.golang.org/grpc/status"
// 返回错误
return nil, status.Error(codes.NotFound, "用户不存在")
return nil, status.Error(codes.InvalidArgument, "参数错误")
return nil, status.Error(codes.Internal, "内部错误")
// 客户端判断错误
resp, err := client.GetUser(ctx, req)
if err != nil {
st, ok := status.FromError(err)
if ok {
switch st.Code() {
case codes.NotFound:
fmt.Println("用户不存在")
case codes.InvalidArgument:
fmt.Println("参数错误")
}
}
}
常见场景
场景一:微服务间通信
// 用户服务
type UserServer struct { pb.UnimplementedUserServiceServer }
// 订单服务调用用户服务:连接应长驻复用(进程启动时建一次),而不是每个请求新建
conn, _ := grpc.NewClient("user-service:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
userClient := pb.NewUserServiceClient(conn)
user, _ := userClient.GetUser(ctx, &pb.GetUserRequest{Id: userID})
场景二:TLS 加密通信
creds, _ := credentials.NewServerTLSFromFile("cert.pem", "key.pem")
s := grpc.NewServer(grpc.Creds(creds))
// 客户端
creds, _ := credentials.NewClientTLSFromFile("cert.pem", "example.com")
conn, _ := grpc.NewClient("localhost:50051", grpc.WithTransportCredentials(creds))
注意事项与常见错误
-
UnimplementedServer:服务端结构体必须嵌入
UnimplementedXxxServer,否则编译不通过。这是为了向前兼容。 -
protoc 路径:生成代码时注意
go_package选项和输出路径的配置,否则生成的代码 import 路径不对。 -
连接不释放:客户端
grpc.Dial返回的连接必须用conn.Close()关闭。 -
默认不加密:gRPC 默认使用不安全连接。生产环境必须使用 TLS。
-
消息大小限制:gRPC 默认最大消息大小为 4MB。传输大文件应使用流式 RPC:
grpc.MaxRecvMsgSize(10 * 1024 * 1024) // 设置为 10MB
-
阻塞调用:一元 RPC 是阻塞的,在客户端应该使用带超时的 Context。
-
status.FromError 与 errors.Is 的分工:
status.FromError解析 gRPC 状态码,errors.Is匹配应用层 sentinel 错误。跨服务传递业务错误有两种做法:要么统一用状态码 + details,要么用status.FromError包装后再在客户端errors.Is(status.Convert(err).Err(), target)。混着判断又不做转换,是最常见的”为什么 Is 判不出来”的原因。
Protobuf 兼容性设计要点
Protobuf 靠字段编号(field number)识别数据而不是字段名,这决定了演进规则:已发布的编号绝不能复用。字段删除时必须用 reserved 封锁编号与名字,防止后人不知情地重用导致新旧消息互相错位解析:
message User {
reserved 4, 5; // 曾经的 phone、fax 字段,编号永久封存
reserved "phone", "fax"; // 名字一并封存,避免误加回
string id = 1;
string name = 2;
string email = 3;
// 新增字段用新编号追加即可:旧客户端会跳过不认识的字段
string avatar_url = 6;
}
配套的三条工程约定:
- 标量选择:金额用
int64(分)或字符串表示,绝不用float(二进制浮点误差在金额上不可接受);时间统一用google.protobuf.Timestamp,不要自定义秒/纳秒字段。 - 枚举演进:
proto3枚举第一个值必须是0且通常命名为_UNSPECIFIED,作为向后兼容的默认值;枚举值同样只增不改。 - 包与路径:
option go_package决定生成代码的 import 路径,多服务共享 proto 时提前规划,后期改路径是全仓库级重构。
进阶用法
健康检查
import "google.golang.org/grpc/health"
import "google.golang.org/grpc/health/grpc_health_v1"
// 服务端注册健康检查
healthServer := health.NewServer()
healthServer.SetServingStatus("user.UserService", grpc_health_v1.HealthCheckResponse_SERVING)
grpc_health_v1.RegisterHealthServer(s, healthServer)
反射
注册反射服务后,可以使用 grpcurl 等工具调试:
import "google.golang.org/grpc/reflection"
s := grpc.NewServer()
reflection.Register(s) // 注册反射服务
# 使用 grpcurl 调试
grpcurl -plaintext localhost:50051 list
grpcurl -plaintext localhost:50051 user.UserService/GetUser -d '{"id":"123"}'
本篇小结
- gRPC = HTTP/2 传输 + Protobuf 序列化 + 代码生成:
.proto是唯一事实源,protoc 生成类型安全的服务端接口与客户端 Stub,调用远程方法如同调用本地函数。 - 四种通信模式按需选择:一元调用对应普通请求-响应;服务端流适合列表推送,客户端流适合批量上报,双向流适合实时会话。流式接口中
io.EOF表示流正常结束,其余错误直接向上传递。 - 客户端用
grpc.NewClient(grpc.Dial已废弃),连接懒建立、长驻复用;每次调用必须带 deadline,超时会沿着调用链向下游传播。 - 错误用
codes+status表达,客户端status.FromError解析;拦截器承载日志、鉴权、恢复等横切逻辑,一元与流式拦截器需要分别注册。 - Protobuf 兼容性靠纪律维持:字段编号与枚举值只增不改,删除字段必须
reserved,金额不用 float,时间用 Timestamp。生产环境必开 TLS,调试时注册 reflection 服务配合 grpcurl。
动手实践
- 把本篇的 UserService 示例从零跑通:定义 proto、生成代码、实现服务端与客户端,分别用正常、超时(服务端
time.Sleep(10*time.Second))与codes.NotFound三种情况运行客户端,记录三种输出。 - 给 GetUser 增加服务端流版本
StreamUsers,一次返回全部用户,客户端用stream.Recv()循环消费;随后在服务端 Send 循环里检查ctx.Err(),体会取消传播对流式服务端的意义。 - 写一个一元拦截器统计每个方法的耗时与错误码,注册到服务器后用 grpcurl 打 10 次请求,观察拦截器日志顺序,并思考它与 HTTP 中间件洋葱模型的对应关系。