gRPC + Go 完整实战——从 Proto 定义到服务端/客户端的完整实现
gRPC + Go 完整实战——从 Proto 定义到服务端/客户端的完整实现
适读人群:有 Java 基础的 Go 学习者、希望掌握 gRPC 实战的工程师 | 阅读时长:约18分钟 | 核心价值:系统掌握 gRPC 在 Go 中的完整开发链路,避开新手必踩的5大坑
一个让我重新认识 RPC 的下午
那是2022年秋天,我刚从一家用了8年 Java 的公司跳槽到一家 Go 技术栈的创业公司。入职第三周,老大扔给我一个任务:把原来的 HTTP 接口改成 gRPC,说是要降低内部服务调用的延迟。
我当时一脸懵。在 Java 里我用过 Dubbo,也用过 Spring Cloud 里的 Feign,但 gRPC 只是听说过,没真正写过。更要命的是,整个团队就我一个"新来的",没人能手把手带我。
我花了两个小时看官网,又花了一个下午踩坑,才总算跑通了第一个 Hello World。然后花了整整一周,才真正理解了 proto 文件的设计原则、服务端怎么处理超时、客户端怎么做连接复用。
那一周的经历让我意识到一件事:gRPC 不难,但学习资料要么太基础(就是跑个 Hello World),要么太碎(随便讲一两个点)。我当时最需要的,是一篇从头到尾的完整实战,包括为什么这样设计、踩过哪些坑、生产环境里要注意什么。
这就是我写这篇文章的原因。今天我们一起,从零把一个 gRPC 服务完整跑起来。
gRPC 是什么,Java 工程师怎么理解它
在 Java 世界里,服务间通信最常见的方案是:
- Spring Cloud + Feign:HTTP + JSON,简单,但有额外的序列化/反序列化开销
- Dubbo:自定义二进制协议,高性能,但生态相对封闭
gRPC 的定位和 Dubbo 更接近——都是高性能的 RPC 框架,基于二进制协议(Protobuf),比 JSON 序列化快 3~10 倍,适合内部服务间的高频调用。
最核心的区别在于接口定义方式。Java 里用 Dubbo,你写的是 Java 接口(interface),框架通过动态代理实现远程调用。gRPC 用的是 .proto 文件,这是一种语言无关的接口描述语言(IDL),可以同时生成 Go、Java、Python 等多种语言的代码,天然适合跨语言微服务。
用一句话说:gRPC = Protobuf(序列化协议)+ HTTP/2(传输协议)+ 自动生成的客户端/服务端代码。
环境准备
在写代码之前,先把环境装好。
# 安装 protoc 编译器(macOS)
brew install protobuf
# 安装 Go 插件
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
# 确认安装成功
protoc --version # 应输出 libprotoc 3.x.x
protoc-gen-go --version项目目录结构如下,我习惯这样组织:
grpc-demo/
├── proto/
│ └── user/
│ └── user.proto
├── server/
│ └── main.go
├── client/
│ └── main.go
├── pb/ # 自动生成的代码放这里
│ └── user/
└── go.mod初始化项目:
mkdir grpc-demo && cd grpc-demo
go mod init grpc-demo
go get google.golang.org/grpc
go get google.golang.org/protobuf第一步:定义 Proto 文件
这是整个 gRPC 开发的起点。proto 文件定义了你的服务接口和数据结构,相当于 Java 里的 interface + DTO。
新建 proto/user/user.proto:
syntax = "proto3";
// 指定 Go 包路径,这个很重要,后面生成的代码会放在这个包下
option go_package = "grpc-demo/pb/user;user";
package user;
// 用户信息请求
message GetUserRequest {
int64 user_id = 1;
}
// 用户信息响应
message UserInfo {
int64 user_id = 1;
string username = 2;
string email = 3;
int32 age = 4;
}
// 创建用户请求
message CreateUserRequest {
string username = 1;
string email = 2;
int32 age = 3;
}
// 创建用户响应
message CreateUserResponse {
int64 user_id = 1;
string message = 2;
}
// 定义 UserService 服务
service UserService {
// 获取用户信息
rpc GetUser(GetUserRequest) returns (UserInfo);
// 创建用户
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
}字段编号很关键。= 1、= 2 这些不是默认值,是字段的二进制编码编号。这个编号一旦确定,后续只能新增,不能修改,否则会破坏向后兼容性。这和 Java 的 Serializable 接口里的 serialVersionUID 有点类似,但更严格。
生成 Go 代码:
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
proto/user/user.proto执行后会在 pb/user/ 下生成两个文件:
user.pb.go:包含所有 message 的 Go 结构体user_grpc.pb.go:包含 Server 接口和 Client 的实现
第二步:实现服务端
新建 server/main.go:
package main
import (
"context"
"fmt"
"log"
"net"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "grpc-demo/pb/user"
)
// UserServiceServer 实现 pb.UserServiceServer 接口
// Java 类比:实现一个 @Service 标注的接口实现类
type UserServiceServer struct {
pb.UnimplementedUserServiceServer // 必须嵌入,提供默认实现
mu sync.RWMutex
users map[int64]*pb.UserInfo
nextID int64
}
func NewUserServiceServer() *UserServiceServer {
return &UserServiceServer{
users: make(map[int64]*pb.UserInfo),
nextID: 1,
}
}
// GetUser 获取用户信息
func (s *UserServiceServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.UserInfo, error) {
// 检查 context 是否已超时或取消(Java 里通常没有这个习惯,但在 Go 里必须检查)
select {
case <-ctx.Done():
return nil, status.Error(codes.DeadlineExceeded, "请求超时")
default:
}
s.mu.RLock()
defer s.mu.RUnlock()
user, ok := s.users[req.UserId]
if !ok {
// 使用 gRPC 状态码返回错误,不要直接返回 fmt.Errorf
return nil, status.Errorf(codes.NotFound, "用户 %d 不存在", req.UserId)
}
return user, nil
}
// CreateUser 创建用户
func (s *UserServiceServer) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.CreateUserResponse, error) {
if req.Username == "" {
return nil, status.Error(codes.InvalidArgument, "用户名不能为空")
}
if req.Age < 0 || req.Age > 150 {
return nil, status.Error(codes.InvalidArgument, "年龄不合法")
}
s.mu.Lock()
defer s.mu.Unlock()
userID := s.nextID
s.nextID++
s.users[userID] = &pb.UserInfo{
UserId: userID,
Username: req.Username,
Email: req.Email,
Age: req.Age,
}
log.Printf("创建用户成功: id=%d, name=%s", userID, req.Username)
return &pb.CreateUserResponse{
UserId: userID,
Message: "创建成功",
}, nil
}
func main() {
// 监听端口
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("监听失败: %v", err)
}
// 创建 gRPC 服务器,设置常用选项
grpcServer := grpc.NewServer(
// 设置连接超时
grpc.ConnectionTimeout(30*time.Second),
// 设置最大接收消息大小(默认 4MB,按需调整)
grpc.MaxRecvMsgSize(16*1024*1024),
)
// 注册服务
pb.RegisterUserServiceServer(grpcServer, NewUserServiceServer())
fmt.Println("gRPC 服务端启动,监听 :50051")
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("服务启动失败: %v", err)
}
}关键点:UnimplementedUserServiceServer 为什么必须嵌入?
这是 gRPC 生成代码的特性。如果未来 proto 文件新增了一个 RPC 方法,而你的服务端没有实现,嵌入这个结构体后,未实现的方法会自动返回 codes.Unimplemented 错误,而不是编译报错。这样可以做到向前兼容的渐进式更新。Java 里的类似做法是继承抽象类,而不是直接实现接口。
第三步:实现客户端
新建 client/main.go:
package main
import (
"context"
"log"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "grpc-demo/pb/user"
)
func main() {
// 建立连接
// insecure.NewCredentials() 表示不使用 TLS,仅用于开发环境
conn, err := grpc.Dial(
"localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
// 连接超时
grpc.WithBlock(),
grpc.FailOnNonTempDialError(true),
)
if err != nil {
log.Fatalf("连接失败: %v", err)
}
defer conn.Close()
// 创建客户端
// Java 类比:这相当于 @Autowired 一个 Feign Client
client := pb.NewUserServiceClient(conn)
// 创建用户
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
createResp, err := client.CreateUser(ctx, &pb.CreateUserRequest{
Username: "张三",
Email: "zhangsan@example.com",
Age: 28,
})
if err != nil {
log.Fatalf("创建用户失败: %v", err)
}
log.Printf("创建成功,用户ID: %d,消息: %s", createResp.UserId, createResp.Message)
// 获取用户
ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel2()
userInfo, err := client.GetUser(ctx2, &pb.GetUserRequest{UserId: createResp.UserId})
if err != nil {
log.Fatalf("获取用户失败: %v", err)
}
log.Printf("用户信息: id=%d, name=%s, email=%s, age=%d",
userInfo.UserId, userInfo.Username, userInfo.Email, userInfo.Age)
}运行测试(两个终端):
# 终端1:启动服务端
go run server/main.go
# 终端2:运行客户端
go run client/main.go踩坑实录
坑1:option go_package 路径写错,生成代码无法导入
现象: 执行 protoc 命令成功,但 go build 报找不到包的错误。
原因: go_package 里的路径要和你的模块路径保持一致。比如 go.mod 里 module grpc-demo,那么 go_package 就应该是 grpc-demo/pb/user;user。前面是导入路径,分号后面是包名。
解法: 把 go_package 改成 "模块名/pb/xxx;包名" 的格式,生成后检查 _grpc.pb.go 文件顶部的 package 声明是否符合预期。
坑2:没有设置 context 超时,客户端永久等待
现象: 服务端宕机后,客户端调用卡住,不报错也不返回,进程无法退出。
原因: 在 Java Spring Cloud 里,Feign 有默认的连接超时和读取超时配置。但 gRPC 客户端如果不显式传递 context.WithTimeout,默认没有超时限制。
解法: 所有 RPC 调用都必须传带超时的 context:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.GetUser(ctx, req)额外注意: cancel() 必须调用,否则会导致 context 资源泄漏。用 defer cancel() 是最安全的写法。
坑3:直接 return fmt.Errorf 导致客户端无法判断错误类型
现象: 服务端返回错误,客户端只能看到一个字符串,无法根据错误类型做不同处理(比如404时重试、400时直接返回)。
原因: gRPC 有自己的状态码体系(codes.NotFound、codes.InvalidArgument 等),客户端通过 status.FromError(err) 解析错误码。如果服务端直接 return fmt.Errorf("xxx"),这些信息会丢失。
解法: 服务端统一用 status.Errorf 返回错误,客户端用 status.FromError 解析:
// 服务端
return nil, status.Errorf(codes.NotFound, "用户 %d 不存在", req.UserId)
// 客户端
if err != nil {
st, ok := status.FromError(err)
if ok && st.Code() == codes.NotFound {
log.Println("用户不存在,不重试")
} else {
log.Println("未知错误,可以重试:", err)
}
}坑4:gRPC 连接不复用,每次请求都新建连接
现象: 高并发下延迟异常高,服务端日志显示连接数持续增长。
原因: 有同学把 grpc.Dial 写在每次请求的函数里,导致每次调用都创建新的 TCP 连接。gRPC 连接建立成本很高(需要 TLS 握手 + HTTP/2 协商),而且 HTTP/2 本身支持多路复用,一个连接可以并发处理大量请求。
解法: conn 和 client 应该在程序启动时创建一次,作为全局变量或通过依赖注入传递,整个进程共用:
// 在 main 或初始化函数中
var grpcClient pb.UserServiceClient
func init() {
conn, _ := grpc.Dial("localhost:50051", ...)
grpcClient = pb.NewUserServiceClient(conn)
}坑5:proto 文件里用了 required,升级后兼容性崩了
现象: 新版本 proto 文件添加了字段,老客户端调用新服务端后莫名报错。
原因: proto3 已经废弃了 required 关键字。在 proto2 里如果用了 required,添加新的必填字段后,老客户端没有这个字段,会触发解码错误。
解法: 始终使用 proto3,所有字段默认都是 optional。新增字段只追加,永远不删除已有字段,也不改变字段编号。
生产环境最佳实践
连接保活:在长连接场景下,NAT 网关可能会清除长时间没有数据传输的连接。设置 keepalive 参数:
grpc.NewServer(
grpc.KeepaliveParams(keepalive.ServerParameters{
MaxConnectionIdle: 15 * time.Minute,
Time: 5 * time.Second,
Timeout: 1 * time.Second,
}),
)优雅停机:服务重启时,正在处理的请求不应该被强制中断:
// 捕获系统信号,优雅停机
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
log.Println("收到停机信号,开始优雅停机...")
grpcServer.GracefulStop()
}()总结
整个 gRPC 开发流程用一句话概括:写 proto → 生成代码 → 实现接口 → 注册服务 → 客户端调用。
和 Java Dubbo 相比,gRPC 在跨语言支持和标准化程度上更胜一筹,但需要你对 proto 文件的设计更用心,因为接口定义一旦发布就很难改。
我给的建议:proto 文件要比代码设计得更谨慎,字段编号、字段名、包路径,这些都是一旦定下来就需要承担向后兼容成本的决策。
