目录

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 文件里有两个容易混淆的参数:packagego_packagexx_packagexx 指目标语言)。

package 针对的是 protobuf 本身,是 proto 文件的命名空间。假设 A.protoB.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.ErrUnexpectedEOFtransport.ConnectionError
  • 默认 MaxReceiveMessageSize1024*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())
}

常用码:OKInvalidArgumentNotFoundAlreadyExistsPermissionDeniedUnauthenticatedUnavailableDeadlineExceededInternal

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 保留删除字段、packagego_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.Dialgrpc.NewClient,服务端需内嵌 UnimplementedXxxServer 以向前兼容。
  • 生产实践:拦截器统一处理横切逻辑,status/codes 传递语义化错误,善用 context 超时与连接复用。