Go-24 RPC与gRPC实践
1. RPC 是什么
RPC 是远程过程调用(Remote Procedure Call)的缩写,是分布式系统中不同节点间流行的通信方式。它的核心目标是:让调用远程服务像调用本地函数一样自然——开发者只管调用一个方法,网络传输、序列化、寻址等细节全部被框架屏蔽。
1.1 四大核心组件
一个完整的 RPC 架构包含四个核心组件:Client、Server、Client Stub 和 Server Stub。这里的 Stub 可以理解为「存根」:
| 组件 | 职责 |
|---|---|
| 客户端(Client) | 服务的调用方 |
| 服务端(Server) | 真正的服务提供者 |
| 客户端存根(Client Stub) | 存放服务端地址,将请求参数打包成网络消息,通过网络发送给服务方 |
| 服务端存根(Server Stub) | 接收客户端消息并解包,调用本地方法,再把结果打包返回 |
Client ──▶ Client Stub ──▶ 网络 ──▶ Server Stub ──▶ Server
(调用方) 序列化+发送 接收+反序列化 执行方法
▲ │
└────────────────── 结果原路返回 ◀────────────────────┘
一次完整调用的链路是:Client → Client Stub(序列化 + 发送)→ 网络 → Server Stub(接收 + 反序列化)→ Server(执行)→ 原路返回。
2. net/rpc:标准库的 RPC
Go 语言标准库自带 RPC 支持,包路径为 net/rpc,从名字可以猜到它建立在 net 包之上。下面基于它实现一个最简单的打印服务。
2.1 服务端
先构造一个 HelloService 类型,其中 Hello 方法用于实现打印功能:
package main
import (
"log"
"net"
"net/rpc"
)
// HelloService 是 RPC 服务对象
type HelloService struct{}
func (p *HelloService) Hello(request string, reply *string) error {
*reply = "hello:" + request
return nil
}
func main() {
// 将 HelloService 对象注册为一个 RPC 服务
rpc.RegisterName("HelloService", new(HelloService))
listener, err := net.Listen("tcp", ":1234")
if err != nil {
log.Fatal("ListenTCP error:", err)
}
conn, err := listener.Accept()
if err != nil {
log.Fatal("Accept error:", err)
}
rpc.ServeConn(conn)
}
rpc.RegisterName 会将对象类型中所有满足 RPC 规则的方法注册为 RPC 函数,注册的方法都放在 "HelloService" 服务空间之下。随后建立一个 TCP 链接,并通过 rpc.ServeConn 在该链接上为对方提供 RPC 服务。
2.2 客户端
package main
import (
"fmt"
"log"
"net/rpc"
)
func main() {
client, err := rpc.Dial("tcp", "localhost:1234")
if err != nil {
log.Fatal("dialing err:", err)
}
var reply string
err = client.Call("HelloService.Hello", "test_rpc", &reply)
if err != nil {
log.Fatal(err)
}
fmt.Println(reply) // hello:test_rpc
}
通过 rpc.Dial 拨号 RPC 服务,再通过 client.Call 调用具体方法。client.Call 的第一个参数是用点号连接的「服务名.方法名」,第二、三个参数分别对应 RPC 方法的两个参数。
2.3 RPC 方法必须遵守的规则
net/rpc 对可注册的方法有严格约束,Hello 方法正是满足了这些规则:
- 方法必须是导出的(首字母大写);
- 方法有且只有两个可序列化的参数;
- 第二个参数必须是指针类型(用于回写返回值);
- 方法返回一个
error类型。
net/rpc默认使用 Go 自有的encoding/gob编码,只能 Go 调 Go。要跨语言,就得换一套与语言无关的序列化协议——这正是 Protobuf 登场的地方。
3. Protobuf:跨语言的序列化基石
Protobuf 是 Protocol Buffers 的简称,是一种与语言、平台无关、可扩展的结构化数据序列化格式。作为接口规范的描述语言,它是设计跨语言 RPC 接口的基础工具,也是 gRPC 的默认序列化方案。
3.1 proto3 基础语法
一个最简单的 hello.proto:
syntax = "proto3";
package greeter;
option go_package = "example.com/rpc/proto;proto";
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
syntax = "proto3";:声明使用 proto3 语法,必须是文件第一行非空非注释内容。不写则默认 proto2,目前主流一律用 proto3。package:protobuf 层面的命名空间,用于避免 message、service 定义冲突。option go_package:生成 Go 代码时的包路径(见 3.2)。message:定义一个消息体,类似 Go 的结构体,生成后对应一个 Go struct。message 中可以嵌套 message 或其它基础类型。每个字段包含三部分:类型、字段名、字段编号。
3.2 package 与 go_package 的区别
proto 文件里有两个容易混淆的参数:package 和 go_package(xx_package 中 xx 指目标语言)。
package 针对的是 protobuf 本身,是 proto 文件的命名空间。假设 A.proto 和 B.proto 里都定义了 UserInfo,当 A 需要引用 B 时,没有 package 就无法区分调用的是哪一个:
// A.proto
message UserInfo {
uint32 uid = 1;
string name = 2;
}
go_package 声明生成的 .pb.go 文件的存放位置与包名(proto 编译后是 Go 文件,自然有包名问题)。现代写法推荐带上分号指定包名:
// 分号前是导入路径,分号后是生成文件的包名
option go_package = "example.com/rpc/proto;proto";
⚠️ 演进提示:在新版 protoc-gen-go(APIv2)中,
go_package选项几乎是必填的——要么在 proto 文件里写option go_package,要么在命令行用--go_opt=M<file>=<import_path>指定,否则会直接报错。这与旧文里「可有可无」的时代已经不同。
3.3 关于字段编号(标识号)
message 中每个字段都有唯一的数字编号,它们用于在二进制格式中识别各个字段,一旦使用就不能再改变:
- 编号
[1,15]编码时只占 1 个字节,应留给频繁出现的字段;[16,2047]占 2 个字节。 - 最小编号从 1 开始,最大可到
2^29 - 1(536,870,911)。 [19000, 19999]是 Protobuf 协议预留区间,不可使用。
3.4 保留字段 reserved
删除字段后,应当用 reserved 保留其编号(和字段名),防止将来复用导致数据错乱:
message Stock {
reserved 3, 4; // 保留编号
reserved "foo", "bar"; // 保留字段名
// ...
}
message Info {
reserved 2, 9 to 11, 15; // to 关键字占位连续编号
// ...
}
3.5 基本数据类型映射
Protobuf 生成的 Go 类型并非与 proto 类型完全同名,常见映射如下:
| Protobuf 类型 | Go 类型 | 说明 |
|---|---|---|
double / float |
float64 / float32 |
浮点 |
int32 / int64 |
int32 / int64 |
变长编码,负数效率低 |
uint32 / uint64 |
uint32 / uint64 |
无符号变长 |
sint32 / sint64 |
int32 / int64 |
ZigZag 编码,适合负数 |
fixed32 / fixed64 |
uint32 / uint64 |
固定长度 |
bool |
bool |
布尔 |
string |
string |
UTF-8 字符串 |
bytes |
[]byte |
任意字节序列 |
3.6 注释
.proto 文件支持 C/C++ 风格注释:// 单行 和 /* … */ 多行。
4. protoc 工具链(现代版)
protoc 是 Protobuf 官方的编译器,负责把 .proto 文件翻译成各语言代码。但 protoc 本身不认识 Go——它通过插件机制生成 Go 代码,而这套插件在最近几年发生了重大变化,旧教程里的命令大多已经失效。
4.1 ⭐ 最重要的演进:protoc-gen-go 拆分
这是本文要重点校正的过时内容,务必看清:
旧世界(2020 年以前):Go 代码由 github.com/golang/protobuf/protoc-gen-go 生成,gRPC 服务代码通过它内置的 grpc 插件生成,命令是:
# ❌ 已废弃,不要再用
protoc --go_out=plugins=grpc:. ./proto/*.proto
新世界(protoc-gen-go v1.20 起):官方把插件拆成了两个独立的二进制,--go_out=plugins=grpc 这种写法被彻底移除:
| 插件 | 来源 | 职责 | 生成文件 |
|---|---|---|---|
protoc-gen-go |
google.golang.org/protobuf |
生成 message 结构体、序列化代码 | xxx.pb.go |
protoc-gen-go-grpc |
google.golang.org/grpc/cmd/protoc-gen-go-grpc |
生成 gRPC 的 Server/Client 接口 | xxx_grpc.pb.go |
也就是说,一次生成会产出两个文件:.pb.go(消息)和 _grpc.pb.go(服务)。
4.2 关于两个 protobuf 库
旧文用的 github.com/golang/protobuf(俗称 APIv1)已被官方标记为弃用。现在应统一使用重写后的 google.golang.org/protobuf(APIv2):
google.golang.org/protobuf:新的官方运行时与 protoc-gen-go 插件来源,新项目一律用它。github.com/golang/protobuf:仅作为 APIv2 的兼容垫片保留,其新版本内部实际转发到 APIv2,不应在新代码中直接依赖。
4.3 安装
# 1. 安装 protoc 编译器本体
brew install protobuf # macOS
# apt install -y protobuf-compiler # Debian/Ubuntu
protoc --version # 确认安装,如 libprotoc 25.x
# 2. 安装两个 Go 插件(go install 会放到 $GOBIN / $GOPATH/bin)
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
记得把
$GOPATH/bin(或$GOBIN)加入PATH,否则 protoc 找不到protoc-gen-go插件。
4.4 生成命令
现代的标准生成命令如下(一条命令同时驱动两个插件):
protoc \
--go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
./proto/hello.proto
各参数含义:
| 参数 | 作用 |
|---|---|
--proto_path / -I |
proto 文件搜索路径,可指定多次,默认当前目录 |
--go_out |
protoc-gen-go 输出目录(生成 .pb.go) |
--go-grpc_out |
protoc-gen-go-grpc 输出目录(生成 _grpc.pb.go) |
--go_opt=paths=source_relative |
按 proto 源文件目录结构输出,最常用 |
paths 有两个取值:import(默认,按 go_package 全路径建目录,容易层级冗余)和 source_relative(按源文件目录,实践中更省心,推荐)。
如果只是纯序列化、不需要 gRPC 服务,去掉
--go-grpc_out两行即可,只生成.pb.go。
5. gRPC 与四种通信模式
前面我们用 net/rpc 实现了服务,也可以把参数换成 Protobuf 定义的类型手动组合。但真正工程化的做法是直接用 gRPC——它由 Google 开源,基于 HTTP/2 传输,用 Protobuf 做序列化,原生支持流式通信、拦截器、多语言,是当下 RPC 的事实标准。
安装 gRPC 运行库:
go get google.golang.org/grpc
以带 service 的 hello.proto 为例:
syntax = "proto3";
package proto;
option go_package = "example.com/rpc/proto;proto";
message String {
string value = 1;
}
service HelloService {
rpc Hello (String) returns (String);
}
用 4.4 的命令生成后,hello_grpc.pb.go 中会为服务端和客户端各生成一套接口:
// 服务端需要实现的接口
type HelloServiceServer interface {
Hello(context.Context, *String) (*String, error)
mustEmbedUnimplementedHelloServiceServer()
}
// 客户端调用的接口
type HelloServiceClient interface {
Hello(ctx context.Context, in *String, opts ...grpc.CallOption) (*String, error)
}
gRPC 通过 context.Context 参数为每个方法调用提供上下文(超时、取消、元数据)支持。
演进提示:新版 protoc-gen-go-grpc 生成的
HelloServiceServer接口内含一个mustEmbedUnimplementedHelloServiceServer(),要求实现体内嵌UnimplementedHelloServiceServer。这样将来 proto 新增方法时,老服务端不会因为缺方法而编译失败——是一种向前兼容设计。
5.1 一元 RPC(Unary RPC)
最常见的模式:客户端发一个请求,服务端返回一个响应,一问一答。
服务端:
package main
import (
"context"
"log"
"net"
"google.golang.org/grpc"
pb "example.com/rpc/proto"
)
type HelloServiceImpl struct {
pb.UnimplementedHelloServiceServer // 内嵌以获得向前兼容
}
func (p *HelloServiceImpl) Hello(ctx context.Context, args *pb.String) (*pb.String, error) {
return &pb.String{Value: "hello:" + args.GetValue()}, nil
}
func main() {
lis, err := net.Listen("tcp", ":1234")
if err != nil {
log.Fatal(err)
}
grpcServer := grpc.NewServer()
pb.RegisterHelloServiceServer(grpcServer, new(HelloServiceImpl))
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("grpcServer.Serve err: %v", err)
}
}
客户端:
package main
import (
"context"
"fmt"
"log"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "example.com/rpc/proto"
)
func main() {
// 旧写法 grpc.Dial + grpc.WithInsecure() 均已弃用,
// 改用 grpc.NewClient + insecure.NewCredentials()
conn, err := grpc.NewClient("localhost:1234",
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatal("dialing err:", err)
}
defer conn.Close()
client := pb.NewHelloServiceClient(conn)
reply, err := client.Hello(context.Background(), &pb.String{Value: "wekenw"})
if err != nil {
log.Fatal(err)
}
fmt.Println(reply.GetValue()) // hello:wekenw
}
演进提示:
grpc.WithInsecure()已弃用,改用grpc.WithTransportCredentials(insecure.NewCredentials());grpc.Dial在新版中也被grpc.NewClient取代(NewClient惰性连接、语义更清晰)。
gRPC 借助 HTTP/2 的多路复用能力,在一元 RPC 之外还支持三种流式模式。流式的关键在 proto 里用 stream 关键字标注请求或响应。
5.2 服务端流式 RPC(Server-side streaming)
单向流:Client 发起一次普通请求,Server 通过流式多次返回数据,Client 循环 Recv 接收,直到 io.EOF。
service HelloService {
rpc Hello (String) returns (stream String) {};
}
服务端——留意 stream.Send:
func (p *HelloServiceImpl) Hello(req *pb.String, stream pb.HelloService_HelloServer) error {
for n := 0; n < 5; n++ {
// 向流中发送消息,默认单次消息最大长度为 math.MaxInt32 字节
err := stream.Send(&pb.String{
Value: req.Value + strconv.Itoa(n),
})
if err != nil {
return err
}
}
return nil // 函数返回即表示服务端流关闭
}
stream.Send 内部最终调用 SendMsg,过程包括:消息体序列化 → 压缩 → 增加 5 字节 header(标志位)→ 校验总长度是否超过 maxSendMessageSize(默认 math.MaxInt32)→ 写入流。
客户端——留意 stream.Recv:
func SayHello(client pb.HelloServiceClient, r *pb.String) error {
stream, err := client.Hello(context.Background(), r)
if err != nil {
return err
}
for {
resp, err := stream.Recv()
if err == io.EOF {
break // 流正常结束
}
if err != nil {
return err
}
log.Printf("resp: %v", resp)
}
return nil
}
关于 stream.Recv()(封装了 ClientStream.RecvMsg)需要知道:
- 它是阻塞等待的;
- 流正常结束(对端 Close)时返回
io.EOF; - 流出错时会被中止,错误里包含 RPC 错误码(见
google.golang.org/grpc/codes),常见如io.ErrUnexpectedEOF、transport.ConnectionError; - 默认
MaxReceiveMessageSize为1024*1024*4(4MB),有需要可调整。
5.3 客户端流式 RPC(Client-side streaming)
单向流:客户端通过流多次发送请求,服务端在收完后返回一次响应。
service HelloService {
rpc Hello (stream String) returns (String) {};
}
服务端——用 stream.SendAndClose 收完后一次性返回:
func (p *HelloServiceImpl) Hello(stream pb.HelloService_HelloServer) error {
for {
resp, err := stream.Recv()
if err == io.EOF {
// 收完客户端的流,返回最终响应并关闭
return stream.SendAndClose(&pb.String{Value: "say.hello"})
}
if err != nil {
return err
}
log.Printf("resp: %v", resp)
}
}
客户端——发完后用 stream.CloseAndRecv 关闭并接收结果:
func SayHello(client pb.HelloServiceClient, r *pb.String) error {
stream, err := client.Hello(context.Background())
if err != nil {
return err
}
for n := 0; n < 6; n++ {
if err := stream.Send(r); err != nil {
return err
}
}
resp, err := stream.CloseAndRecv()
if err != nil {
return err
}
log.Printf("resp: %v", resp)
return nil
}
客户端的 stream.CloseAndRecv 与服务端的 stream.SendAndClose 是配套使用的一对方法。
5.4 双向流式 RPC(Bidirectional streaming)
由客户端以流的方式发起请求,服务端也以流的方式响应。首个请求一定由 Client 发起,但之后谁先谁后、发多少、何时关闭,完全由程序自己组织(常结合 goroutine)。
service HelloService {
rpc Hello (stream String) returns (stream String) {};
}
服务端:
func (p *HelloServiceImpl) Hello(stream pb.HelloService_HelloServer) error {
for {
if err := stream.Send(&pb.String{Value: "say.hello"}); err != nil {
return err
}
resp, err := stream.Recv()
if err == io.EOF {
return nil // 客户端流关闭
}
if err != nil {
return err
}
log.Printf("resp: %v", resp)
}
}
客户端:
func SayHello(client pb.HelloServiceClient, r *pb.String) error {
stream, err := client.Hello(context.Background())
if err != nil {
return err
}
for n := 0; n <= 3; n++ {
if err := stream.Send(r); err != nil {
return err
}
resp, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return err
}
log.Printf("resp: %v", resp)
}
return stream.CloseSend() // 关闭客户端发送方向
}
服务端在循环中接收客户端数据,遇到 io.EOF 表示客户端流关闭,函数退出表示服务端流关闭。双向流的收发是完全独立的,不要求一一对应,可根据真实场景自由组织。
演进提示:protoc-gen-go-grpc v1.5+ 开始用泛型别名生成流类型,例如
grpc.BidiStreamingServer[String, String],上文的HelloService_HelloServer会作为其别名保留,方法名(Send/Recv/CloseSend)保持不变,业务代码基本无感。
5.5 四种模式小结
| 模式 | 请求 | 响应 | 典型场景 |
|---|---|---|---|
| 一元 RPC | 单个 | 单个 | 常规增删改查 |
| 服务端流 | 单个 | stream |
大结果集下发、订阅推送 |
| 客户端流 | stream |
单个 | 批量上传、指标聚合 |
| 双向流 | stream |
stream |
实时聊天、长连接交互 |
6. 工程实践要点
6.1 拦截器(Interceptor)
拦截器是 gRPC 的「中间件」,可统一处理日志、认证、限流、监控、panic 恢复等横切逻辑,分一元和流式两种。服务端一元拦截器示例:
func LoggingInterceptor(ctx context.Context, req interface{},
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
start := time.Now()
resp, err := handler(ctx, req) // 调用真正的业务处理
log.Printf("method=%s cost=%s err=%v", info.FullMethod, time.Since(start), err)
return resp, err
}
// 注册(多个拦截器用 ChainUnaryInterceptor 串联)
grpcServer := grpc.NewServer(
grpc.UnaryInterceptor(LoggingInterceptor),
)
客户端对应 grpc.WithUnaryInterceptor,流式则是 grpc.StreamInterceptor / grpc.WithStreamInterceptor。
6.2 错误处理:status 与 codes
gRPC 不用普通 error 传递语义化错误,而是用 google.golang.org/grpc/status 携带错误码 + 消息,错误码定义在 google.golang.org/grpc/codes:
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// 服务端:返回带错误码的错误
func (p *HelloServiceImpl) Hello(ctx context.Context, args *pb.String) (*pb.String, error) {
if args.GetValue() == "" {
return nil, status.Errorf(codes.InvalidArgument, "value 不能为空")
}
return &pb.String{Value: "hello:" + args.GetValue()}, nil
}
// 客户端:解析错误码
reply, err := client.Hello(ctx, &pb.String{})
if err != nil {
st, _ := status.FromError(err)
log.Printf("code=%s msg=%s", st.Code(), st.Message())
}
常用码:OK、InvalidArgument、NotFound、AlreadyExists、PermissionDenied、Unauthenticated、Unavailable、DeadlineExceeded、Internal。
6.3 超时、取消与消息大小
- 超时/取消:用
context.WithTimeout创建带截止时间的 ctx 传给调用,超时后服务端 ctx 也会被取消,避免请求堆积。 - 消息大小:默认发送上限
math.MaxInt32、接收上限 4MB,大消息用grpc.MaxRecvMsgSize/grpc.MaxSendMsgSize调整,或改用流式分块。 - 连接复用:一个
ClientConn基于 HTTP/2 多路复用,应长期持有、并发复用,不要每次调用都新建连接。
7. 小结
- RPC 本质:让远程调用像本地调用,核心是 Client/Server + 两端 Stub 负责序列化与网络传输。
- net/rpc:标准库开箱即用,但基于 gob 编码,只能 Go 调 Go,方法需满足「导出 + 两参 + 第二参为指针 + 返回 error」的规则。
- Protobuf:跨语言序列化基石,proto3 语法、字段编号不可变、
reserved保留删除字段、package与go_package各司其职。 - 工具链已变天:
protoc-gen-go在 v1.20 后拆分,gRPC 代码改由独立的protoc-gen-go-grpc生成;--go_out=plugins=grpc写法废弃,改用--go_out+--go-grpc_out;库从github.com/golang/protobuf迁移到google.golang.org/protobuf。 - gRPC 四模式:一元、服务端流、客户端流、双向流,靠 proto 里的
stream关键字区分,收发通过Send/Recv/CloseAndRecv/SendAndClose/CloseSend组织。 - API 演进:
grpc.WithInsecure()→insecure.NewCredentials(),grpc.Dial→grpc.NewClient,服务端需内嵌UnimplementedXxxServer以向前兼容。 - 生产实践:拦截器统一处理横切逻辑,
status/codes传递语义化错误,善用 context 超时与连接复用。
xingliuhua