核心内容摘要
探秘厨房私密时刻
在分布式系统中不同服务间的高效通信是核心需求之一。
RPC、gRPC与Protobuf作为一套协同工作的技术组合广泛应用于微服务、跨语言通信等场景。
本文将逐一拆解三者的核心概念、工作原理并重点分析RPC与HTTP的差异帮助大家理清技术选型逻辑。
RPC远程过程调用的核心范式什么是RPCRPC 是一种编程思想是一种跨进程通信协议把在不同服务之间的通信变得高效和简单全称是远程过程调用Remote Procedure Call 其核心思想是“像调用本地方法一样调用远程服务的方法”调用另一个远程项目服务提供者的接口而不需要了解数据的传输处理过程和底层网络的通信细节让程序员专注于业务逻辑快速开发分布式、微服务系统。
RPC的核心原理与流程一次完整的RPC调用流程包含以下步骤涉及客户端调用方和服务端提供方的协同工作客户端调用本地代理Stub客户端将远程方法调用参数传递给本地Stub类似“代理类”Stub负责后续的通信封装。
参数序列化Stub将调用参数如对象、基本类型转换为可在网络中传输的二进制数据序列化避免因数据格式不兼容导致的通信问题。
网络传输通过底层网络协议如TCP、UDP将序列化后的二进制数据发送到服务端。
服务端Stub反序列化服务端Stub接收数据后将二进制数据还原为原始参数反序列化。
调用服务端本地方法服务端Stub调用对应的本地业务方法执行核心逻辑并获取返回结果。
结果序列化与回传服务端Stub将方法返回结果序列化通过网络传输回客户端。
客户端处理结果客户端Stub反序列化返回数据将结果传递给客户端业务代码完成一次远程调用。
有了Http为啥还有RPCHttp的本质是基于请求-响应模式的应用层网络通信协议而Rpc是远程调用本地化的一种思想主流的RPC大多采用基于Tcp的二进制格式传送的数据更紧凑传送的效率也更高效。
一般来说常用于浏览器与服务器、跨服务接口调用如RESTful API。
而Rpc更适合服务间的内部通信RPC与HTTP虽都可用于跨服务通信但设计目标、特性差异显著。
还有一点补充的是RPC并非与HTTP对立部分RPC框架如gRPC底层基于HTTP/2实现本质是“用HTTP/2作为传输载体的RPC协议”兼顾了RPC的易用性和HTTP/2的高效特性。
远程调用过程面临的问题?
Call ID映射。
我们怎么告诉远程机器我们要调用的函数ID呢?再本地调用中函数体是直接通过指针来指定的我们调用function编译器就自动帮我们调用它相应的函数指针。
但是在远程调用中函数指针是不行的因为两个进程的地址空间是完全不一样的。
所以再RPC中所有的函数都必须有自己的一个ID。
这个ID在所有的进程中都是唯一确定的。
客户端在做远程调用时必须附上这个ID。
然后还需要再客户端和服务端分别维护一个{函数 -- Call ID}的对应表。
两者的表不一定需要完全相同但相同函数对应的Call ID必须相同。
当客户端需要进行远程调用时它就查一下这个表找出相应的Call ID,然后把它传给服务端服务端也通过查表来确定客户端需要调用的函数然后执行相应的函数的代码。
序列化和反序列化客户端怎么把参数值传给远程函数呢?在本地调用中我们只需要把参数压到栈里然后让函数自己去栈里读就行。
但是在远程过程调用时客户端跟服务端是不同的进程不能通过内存来传递参数。
甚至有时候客户端和服务端使用的不是同一种编程语言这时候就需要客户端和服务端把参数先转成一个字节流传给服务端后再把字节流转成自己能读懂的格式这个过程叫做序列化和反序列化。
网络传输远程调用往往用在网络上客户端和服务端是通过网络连接的。
所有的数据都需要通过网络传输因此就需要一个网络传输层。
网络传输层需要把Call ID和序列化后的参数字节流传给服务端然后在把序列化后的调用结果返回客户端只要能完成这两者的都可以作为传输层使用。
因此它所使用的协议其实是不限的能完成传输就行。
尽管大部分Rpc框架都使用Tcp协议但其实Udp也可以gRPC干脆使用了Http2。
Protobuf高效的序列化协议ProtobufProtocol Buffers是Google开源的一种语言无关、平台无关、可扩展的二进制序列化协议用于将结构化数据序列化为紧凑的二进制格式适用于数据存储、通信协议等场景。
它是gRPC的默认序列化协议也是目前高性能RPC通信的首选序列化方案之一。
Protobuf的核心特性高效紧凑采用二进制编码序列化后的数据体积远小于JSON/XML解析速度更快比JSON快
倍甚至更高减少网络传输开销和解析耗时。
跨语言/跨平台支持Java、Go、Python、C等多种语言可在不同平台Windows、Linux、Mac间无缝传输数据适配跨语言RPC场景。
可扩展性强支持字段的新增和废弃且向后兼容旧版本程序可解析新版本数据新版本程序可兼容旧版本数据无需担心接口升级导致的兼容性问题。
强类型约束通过IDL定义数据结构编译后生成对应语言的代码强制数据类型校验避免因类型不匹配导致的异常提升代码健壮性。
我们为什么需要 protoc和protoc-gen-goproto文件本质上是纯文本的协议定义比如定义了消息结构、字段类型、服务接口等它只是一份 “设计图纸”—— 计算机无法直接识别和使用这份图纸必须把它转换成对应编程语言比如 Go的可执行代码才能在程序中实现序列化、反序列化、网络通信等功能。
而 protoc 和 protoc-gen-go 就是完成 “图纸转代码” 这个核心任务的工具二者分工协作缺一不可。
protoc 要执行的就是把Protobuf文件生成对应语言的源码 而protoc-gen-go 是生成Go语言的源码由于你的protoc版本是
3.
1
5直接安装最新版插件会出现版本不兼容问题因此需要指定适配的版本# 安装 protoc-gen-go适配
3.
1
5 的版本 go install google.golang.org/protobuf/cmd/protoc-gen-gov
1.
2
1 # 安装 protoc-gen-go-grpc适配
3.
1
5 的版本 go install google.golang.org/grpc/cmd/protoc-gen-go-grpcv
1.
0 # 查看 protoc-gen-go 版本 protoc-gen-go --version # 查看 protoc-gen-go-grpc 版本 protoc-gen-go-grpc --versionprotoc的基础命令格式参数解释-I .指定 proto 文件的搜索目录当前目录。
--go_out.指定基础 Protobuf 代码生成到当前目录。
--go_optpathssource_relative按 proto 文件的相对路径生成 Go 文件避免生成多层嵌套目录新手友好。
--go-grpc_out.指定 gRPC 代码生成到当前目录。
--go-grpc_optpathssource_relative同上保证 gRPC 代码和 proto 文件路径对应。
# 第一步生成基础的 Protobuf Go 代码 protoc -I . helloworld.proto --go_out. --go_optpathssource_relative # 第二步生成 gRPC 相关的 Go 代码 protoc -I . helloworld.proto --go-grpc_out. --go-grpc_optpathssource_relativeProtobuf 文件的编写protobuf 标量类型是最基础的数据类型以下是 proto3 核心标量类型与 Go/Python/PHP 的精准映射重点标注易踩坑的差异Protobuf 类型Go 类型Python 类型PHP 类型备注说明doublefloat64floatfloat64 位浮点数floatfloat32floatfloat32 位浮点数int32int32intint有符号 32 位整型可变长编码int64int64intstringPHP 64 位整型易溢出映射为字符串避免问题uint32uint32intint无符号 32 位整型可变长编码uint64uint64intstring同上PHP 映射为字符串sint32int32intint有符号可变长编码负数更高效sint64int64intstring有符号可变长编码负数更高效fixed32uint32intint固定 4 字节编码大数更高效fixed64uint64intstring固定 8 字节编码sfixed32int32intint有符号固定 4 字节编码sfixed64int64intstring有符号固定 8 字节编码boolboolboolbool布尔值stringstringstrstringUTF-8 编码长度 ≤ 2^
bytes[]bytebytesstringPHP 无原生 bytes 类型用 string 存二进制
proto 简单示例syntax proto3; // 必须放在第一行声明使用proto3语法 // 展示基本类型和默认值的message message BasicTypeDemo { int32 age 1; // 默认值0 string name 2; // 默认值空字符串 bool is_student 3; // 默认值false double score 4; // 默认值
0 bytes avatar 5; // 默认值空字节数组 }
option go_package 的作用:option go_package是 Go 语言专属的编译选项核心作用是指定生成的 Go 代码的包路径和包名解决 Go 模块模式下的代码导入冲突问题。
语法格式option go_package 模块路径/子目录;包名;分号前生成的 Go 文件存放的模块相对路径如github.com/yourname/proto-demo/pb分号后生成的 Go 代码的包名省略则用目录名。
编译时protoc会根据该选项自动将生成的.go文件放到指定路径并设置正确的包名。
syntax proto3; // 假设你的Go模块名是 github.com/yourname/proto-demo // 生成的Go代码会放到 ./pb 目录下包名是 pb option go_package github.com/yourname/proto-demo/pb;pb; message User { string username 1; }
proto 文件的 import 导入import用于复用其他 proto 文件中定义的 message、枚举等结构是 Protobuf 实现代码复用的核心方式普通导入import 路径/文件名.proto;仅当前文件可使用导入的结构公共导入import public 路径/文件名.proto;当前文件和导入当前文件的其他文件都能使用导入路径支持相对路径如./common.proto或绝对路径需配合protoc的-I参数指定根目录。
被导入文件common.protosyntax proto3; option go_package github.com/yourname/proto-demo/pb;pb; // 供其他文件复用的枚举 enum Gender { GENDER_UNSPECIFIED 0; GENDER_MALE 1; GENDER_FEMALE 2; }主文件import_demo.protosyntax proto3; option go_package github.com/yourname/proto-demo/pb;pb; // 导入common.proto复用Gender枚举 import ./common.proto; message User { string name 1; Gender gender 2; // 使用导入的枚举 }
message 对象和 message 对象的嵌套message是 Protobuf 定义结构化数据的核心单元类似 Go 的struct支持两种嵌套方式内部嵌套在一个 message 内定义子 message仅能通过外部 message 的作用域访问如Outer.Inner外部嵌套在一个 message 中直接引用其他已定义的 message 作为字段类型同文件 / 导入的均可repeated关键字表示字段是数组 / 切片类型默认空数组。
syntax proto3; option go_package github.com/yourname/proto-demo/pb;pb; // 外部message供嵌套引用 message Phone { string number 1; string type 2; // 如mobile、home } // 主message包含内部嵌套和外部引用 message UserProfile { string username 1; // 内部嵌套message仅UserProfile可用 message ExtraInfo { string hobby 1; string signature 2; } repeated Phone phones 2; // 外部嵌套引用Phonerepeated表示数组 ExtraInfo extra_info 3; // 内部嵌套使用ExtraInfo }
枚举类型enum用于定义命名常量proto3 有严格规范第一个枚举值必须为 0作为默认值通常命名为XXX_UNSPECIFIED可通过option allow_alias true;开启别名多个常量映射同一数值枚举可定义在 message 内部局部可用或外部全局可用枚举字段的默认值是第一个值值为 0 的常量。
syntax proto3; option go_package github.com/yourname/proto-demo/pb;pb; // 开启别名功能 option allow_alias true; // 外部枚举全局可用 enum OrderStatus { ORDER_STATUS_UNSPECIFIED 0; // 默认值 ORDER_STATUS_PENDING 1; ORDER_STATUS_PAID 2; ORDER_STATUS_CONFIRMED 2; // 别名PAID和CONFIRMED都对应2 } // 内部枚举仅Order内可用 message Order { enum PaymentMethod { PAYMENT_METHOD_UNSPECIFIED 0; PAYMENT_METHOD_ALIPAY 1; PAYMENT_METHOD_WECHAT 2; } PaymentMethod payment_method 1; // 内部枚举 OrderStatus status 2; // 外部枚举 }
gRPC基于HTTP/2和Protobuf的RPC框架gRPC是Google开源的高性能跨语言RPC框架基于HTTP/2协议传输默认使用Protobuf作为序列化协议完美结合了HTTP/2的高效传输能力和Protobuf的紧凑序列化优势同时支持多种调用模式和跨语言部署。
gRPC 流模式GRPC 基于 HTTP/2 实现了四种通信模式其中流模式是区别于传统 “一次请求一次响应” 的核心特性分为三类服务器流 RPC客户端发送 1 个请求 → 服务器流式返回多个响应如实时日志推送、数据分页返回。
客户端流 RPC客户端流式发送多个请求 → 服务器返回 1 个响应如批量数据上传后返回汇
总结果。
双向流 RPC客户端和服务器双向流式通信双方可独立发送 / 接收数据如即时聊天、实时音视频指令交互。
syntax proto3; option go_package ./pb;pb; // 生成的Go代码路径 // 流模式演示服务 service StreamService { //
服务器流RPC客户端发请求服务端返回流 rpc ServerStream(StreamRequest) returns (stream StreamResponse); //
客户端流RPC客户端发流服务端返回单个响应 rpc ClientStream(stream StreamRequest) returns (StreamResponse); //
双向流RPC双向流式通信 rpc BidirectionalStream(stream StreamRequest) returns (stream StreamResponse); } // 请求结构 message StreamRequest { string message 1; int32 id 2; } // 响应结构 message StreamResponse { string reply 1; int32 code 2; }服务端实现server.gopackage main import ( context fmt log net time google.golang.org/grpc pb your-project-path/pb // 替换为实际pb包路径 ) // 实现StreamService服务 type streamServer struct { pb.UnimplementedStreamServiceServer } //
服务器流RPC实现 func (s *streamServer) ServerStream(req *pb.StreamRequest, stream pb.StreamService_ServerStreamServer) error { log.Printf(收到客户端请求%s (id:%d), req.Message, req.Id) // 模拟流式返回3个响应 for i : 0; i 3; i { reply : fmt.Sprintf(服务器流响应 %d: %s, i, req.Message) if err : stream.Send(pb.StreamResponse{Reply: reply, Code: int32(i)}); err ! nil { return err } time.Sleep(1 * time.Second) // 模拟延迟 } return nil } //
客户端流RPC实现 func (s *streamServer) ClientStream(stream pb.StreamService_ClientStreamServer) error { var total int32 0 var messages []string // 循环接收客户端流式请求 for { req, err : stream.Recv() if err ! nil { // 客户端流结束EOF if err.Error() EOF { reply : fmt.Sprintf(客户端共发送%d条消息%v, total, messages) return stream.SendAndClose(pb.StreamResponse{Reply: reply, Code: total}) } return err } total messages append(messages, req.Message) log.Printf(收到客户端流请求 %d: %s, req.Id, req.Message) } } //
双向流RPC实现 func (s *streamServer) BidirectionalStream(stream pb.StreamService_BidirectionalStreamServer) error { // 协程接收客户端消息 go func() { for { req, err : stream.Recv() if err ! nil { log.Printf(双向流接收结束: %v, err) return } log.Printf(收到双向流请求%s (id:%d), req.Message, req.Id) } }() // 主线程发送流式响应 for i : 0; i 5; i { reply : fmt.Sprintf(双向流服务器响应 %d, i) if err : stream.Send(pb.StreamResponse{Reply: reply, Code: int32(i)}); err ! nil { return err } time.Sleep(1 * time.Second) } return nil } func main() { // 监听端口 lis, err : net.Listen(tcp, :
if err ! nil { log.Fatalf(监听失败: %v, err) } // 创建GRPC服务器 s : grpc.NewServer() pb.RegisterStreamServiceServer(s, streamServer{}) log.Println(GRPC服务器启动端口
if err : s.Serve(lis); err ! nil { log.Fatalf(服务启动失败: %v, err) } }客户端实现client.gopackage main import ( context fmt log time google.golang.org/grpc google.golang.org/grpc/credentials/insecure pb your-project-path/pb // 替换为实际pb包路径 ) func main() { // 连接GRPC服务器 conn, err : grpc.Dial(:50051, grpc.WithTransportCredentials(insecure.NewCredentials())) if err ! nil { log.Fatalf(连接失败: %v, err) } defer conn.Close() client : pb.NewStreamServiceClient(conn) //
调用服务器流RPC fmt.Println( 测试服务器流RPC ) serverStream, err : client.ServerStream(context.Background(), pb.StreamRequest{Message: hello server stream, Id: 1}) if err ! nil { log.Fatalf(调用服务器流失败: %v, err) } for { resp, err : serverStream.Recv() if err ! nil { if err.Error() EOF { break } log.Fatalf(接收服务器流响应失败: %v, err) } fmt.Printf(收到服务器流响应%s (code:%d)\n, resp.Reply, resp.Code) } //
调用客户端流RPC fmt.Println(\n 测试客户端流RPC ) clientStream, err : client.ClientStream(context.Background()) if err ! nil { log.Fatalf(调用客户端流失败: %v, err) } // 发送3个流式请求 for i : 0; i 3; i { req : pb.StreamRequest{Message: fmt.Sprintf(client stream msg %d, i), Id: int32(i)} if err : clientStream.Send(req); err ! nil { log.Fatalf(发送客户端流请求失败: %v, err) } time.Sleep(500 * time.Millisecond) } // 关闭客户端流接收响应 resp, err : clientStream.CloseAndRecv() if err ! nil { log.Fatalf(接收客户端流响应失败: %v, err) } fmt.Printf(客户端流最终响应%s (code:%d)\n, resp.Reply, resp.Code) //
调用双向流RPC fmt.Println(\n 测试双向流RPC ) biStream, err : client.BidirectionalStream(context.Background()) if err ! nil { log.Fatalf(调用双向流失败: %v, err) } // 协程发送客户端消息 go func() { for i : 0; i 3; i { req : pb.StreamRequest{Message: fmt.Sprintf(bi stream msg %d, i), Id: int32(i)} if err : biStream.Send(req); err ! nil { log.Printf(发送双向流请求失败: %v, err) return } time.Sleep(800 * time.Millisecond) } // 关闭客户端发送流 if err : biStream.CloseSend(); err ! nil { log.Printf(关闭双向流发送失败: %v, err) } }() // 接收服务器流式响应 for { resp, err : biStream.Recv() if err ! nil { if err.Error() EOF { break } log.Fatalf(接收双向流响应失败: %v, err) } fmt.Printf(收到双向流响应%s (code:%d)\n, resp.Reply, resp.Code) } }GRPC Metadata 机制Metadata 是 GRPC 的元数据传递机制类比 HTTP 的 Header用于在客户端和服务端之间传递非业务的附加信息如认证 Token、请求 ID、超时时间、版本号等结构键值对key-valuekey 推荐小写以grpc-开头的 key 为 GRPC 保留字段。
类型支持字符串metadata.Pairs和二进制值需转字符串传递。
传递方向客户端 → 服务端通过上下文Context注入 Metadata。
服务端 → 客户端分为Header Metadata响应头和Trailing Metadata响应尾可主动返回。
服务端添加 Metadata 处理server.go// 新增导入 import google.golang.org/grpc/metadata // 重写ServerStream方法添加Metadata解析和返回 func (s *streamServer) ServerStream(req *pb.StreamRequest, stream pb.StreamService_ServerStreamServer) error { //
提取客户端传递的Metadata md, ok : metadata.FromIncomingContext(stream.Context()) if !ok { log.Println(未获取到客户端Metadata) } else { if tokens : md.Get(token); len(tokens) 0 { log.Printf(客户端Token: %s, tokens[0]) } if reqIDs : md.Get(request-id); len(reqIDs) 0 { log.Printf(请求ID: %s, reqIDs[0]) } } //
向客户端返回Header Metadata headerMd : metadata.Pairs( server-version, v
1.
0, timestamp, fmt.Sprintf(%d, time.Now().Unix()), ) if err : stream.SendHeader(headerMd); err ! nil { return err } //
向客户端返回Trailing Metadata trailingMd : metadata.Pairs( request-status, success, response-count, 3, ) stream.SetTrailer(trailingMd) // 原有流式响应逻辑略 log.Printf(收到客户端请求%s (id:%d), req.Message, req.Id) for i : 0; i 3; i { reply : fmt.Sprintf(服务器流响应 %d: %s, i, req.Message) if err : stream.Send(pb.StreamResponse{Reply: reply, Code: int32(i)}); err ! nil { return err } time.Sleep(1 * time.Second) } return nil }客户端添加 Metadata 发送和接收client.go// 新增导入 import google.golang.org/grpc/metadata // 修改服务器流RPC调用逻辑 fmt.Println( 测试服务器流RPC带Metadata ) //
创建Metadata md : metadata.Pairs( token, user_123456_token, request-id, req_987654, ) //
注入到上下文 ctx : metadata.NewOutgoingContext(context.Background(), md) //
调用RPC serverStream, err : client.ServerStream(ctx, pb.StreamRequest{Message: hello server stream, Id: 1}) if err ! nil { log.Fatalf(调用服务器流失败: %v, err) } //
接收服务端Header Metadata headerMd, err : serverStream.Header() if err ! nil { log.Fatalf(获取Header Metadata失败: %v, err) } fmt.Printf(服务端Header: server-version%s, timestamp%s\n, headerMd.Get(server-version)[0], headerMd.Get(timestamp)[0]) //
接收流式响应原有逻辑略 for { resp, err : serverStream.Recv() if err ! nil { if err.Error() EOF { break } log.Fatalf(接收响应失败: %v, err) } fmt.Printf(收到响应%s (code:%d)\n, resp.Reply, resp.Code) } //
接收服务端Trailing Metadata trailingMd : serverStream.Trailer() fmt.Printf(服务端Trailer: request-status%s, response-count%s\n, trailingMd.Get(request-status)[0], trailingMd.Get(response-count)[0])GRPC 验证器GRPC 验证器用于请求参数的前置校验避免非法请求进入业务逻辑核心依赖protoc-gen-validate插件原理在 Proto 文件中通过注解定义字段验证规则如非空、长度、数值范围、正则匹配生成对应的验证代码服务端调用Validate()方法即可校验。
优势校验逻辑与业务解耦规则集中在 Proto 中跨语言通用。
常用规则required非空、min_len/max_len字符串长度、gt/lt数值范围、pattern正则等。
go install github.com/bufbuild/protoc-gen-validatelatestproto文件syntax proto3; option go_package ./pb;pb; // 导入验证器注解 import validate/validate.proto; message StreamRequest { // 验证规则非空长度
string message 1 [(validate.rules).string {min_len: 1, max_len: 20}]; // 验证规则大于0小于100 int32 id 2 [(validate.rules).int32 {gt: 0, lt: 100}]; } // 服务定义不变 service StreamService { rpc ServerStream(StreamRequest) returns (stream StreamResponse); // ... 其他方法 } message StreamResponse { string reply 1; int32 code 2; }服务端添加验证逻辑// 新增导入 import ( google.golang.org/grpc/codes google.golang.org/grpc/status ) // 修改ServerStream方法添加参数验证 func (s *streamServer) ServerStream(req *pb.StreamRequest, stream pb.StreamService_ServerStreamServer) error { //
参数验证核心 if err : req.Validate(); err ! nil { log.Printf(参数验证失败: %v, err) // 返回INVALID_ARGUMENT状态码 return status.Error(codes.InvalidArgument, fmt.Sprintf(参数非法: %v, err)) } // 原有Metadata解析、响应逻辑略 return nil }GRPC 状态码错误机制GRPC 定义了标准化状态码StatusCode用于统一表示请求处理结果替代 HTTP 状态码核心特点核心状态码共 16 种标准码常用的有OK(
成功、INVALID_ARGUMENT(
参数非法、UNAUTHENTICATED(
认证失败、FAILED_PRECONDITION(
业务规则失败等。
错误结构包含状态码、错误消息、详细信息可通过WithDetails附加。
使用方式服务端通过status.Error()返回错误客户端通过status.FromError()解析状态码和消息。
服务端返回不同状态码错误func (s *streamServer) ServerStream(req *pb.StreamRequest, stream pb.StreamService_ServerStreamServer) error { //