MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

gRPC 中的 Protocol Buffers 详解

2023-11-033.3k 阅读

什么是 Protocol Buffers

Protocol Buffers(简称 Protobuf)是一种轻便高效的结构化数据存储格式,由 Google 开发并开源。它最初是为了在内部解决不同系统之间数据交换和存储的问题,由于其诸多优点,后来广泛应用于各种领域,尤其是在 gRPC 这种高性能、低延迟的远程过程调用框架中,Protobuf 成为了数据序列化和反序列化的标准工具。

Protobuf 的设计目标是提供一种比 XML 和 JSON 更紧凑、更高效的数据表示方式,同时保持良好的跨语言兼容性。它通过定义一种描述数据结构的语言,然后根据这个描述生成特定语言的代码,这些代码负责将结构化数据序列化为二进制格式,以及从二进制格式反序列化回结构化数据。

Protobuf 的优势

  1. 高效性:相比 XML 和 JSON,Protobuf 生成的二进制数据体积更小,序列化和反序列化速度更快。这使得在网络传输和数据存储方面都能显著减少开销,尤其适用于对性能要求极高的场景,如移动应用、物联网设备和大规模分布式系统。例如,在一个实时监控系统中,大量传感器数据需要频繁传输,如果使用 JSON 格式,数据量较大可能会导致网络拥堵;而使用 Protobuf,其紧凑的二进制格式能有效减少数据传输量,提高系统响应速度。
  2. 跨语言支持:Protobuf 支持多种编程语言,包括 C++、Java、Python、Go 等。这意味着不同语言编写的系统之间可以方便地进行数据交换,开发者可以根据项目需求选择最合适的语言进行开发,而不用担心数据格式兼容性问题。例如,后端服务使用 Go 语言开发,前端应用使用 JavaScript 开发,中间通过 gRPC 进行通信,就可以借助 Protobuf 来确保数据在不同语言环境下的顺利传输和解析。
  3. 强类型定义:通过 Protobuf 的描述语言,开发者需要明确指定数据的结构和类型,这有助于在编译阶段发现错误,提高代码的稳定性和可靠性。相比之下,JSON 是一种弱类型的数据格式,在解析时可能会因为数据类型不匹配而导致运行时错误。例如,在定义一个用户信息结构体时,使用 Protobuf 可以明确指定用户名是字符串类型,年龄是整数类型,这样在生成代码时就能对数据进行严格验证。
  4. 向后兼容性:Protobuf 设计得非常灵活,允许在不破坏已有代码的情况下对数据结构进行扩展。新的字段可以添加到消息定义中,并且旧版本的代码仍然可以正确解析包含新字段的消息(只要它忽略这些新字段)。这对于长期演进的系统来说非常重要,能够保证系统在不断升级过程中的兼容性。比如,一个已经上线的金融交易系统,随着业务发展需要添加新的交易属性字段,使用 Protobuf 就可以轻松实现这一需求,而不会影响到现有的交易处理逻辑。

Protobuf 基础语法

  1. 定义消息(Message) 消息是 Protobuf 中最基本的数据结构,用于表示一组相关的数据字段。定义消息的语法如下:
syntax = "proto3";

message Person {
  string name = 1;
  int32 age = 2;
  string email = 3;
}

在这个例子中:

  • syntax = "proto3"; 声明使用的是 Protobuf 3 语法版本。目前 Protobuf 有 proto2 和 proto3 两个主要版本,proto3 在语法和功能上有一些改进,使用更为广泛。
  • message Person 定义了一个名为 Person 的消息类型。
  • 每个字段由字段类型、字段名和字段编号组成,如 string name = 1;,这里 string 是字段类型,name 是字段名,1 是字段编号。字段编号在消息定义中必须是唯一的,并且在序列化和反序列化过程中用于标识字段。
  1. 字段类型 Protobuf 支持多种基本字段类型,包括:
  • 数值类型int32int64uint32uint64sint32sint64fixed32fixed64sfixed32sfixed64floatdouble。例如,int32 用于表示 32 位有符号整数,float 用于表示单精度浮点数。不同的数值类型适用于不同范围和精度要求的数据,在选择时需要根据实际情况考虑。
  • 布尔类型bool,用于表示真或假的布尔值。
  • 字符串类型string,用于表示 UTF - 8 编码的字符串。
  • 字节类型bytes,用于表示任意字节序列。例如,可以用来存储图片、音频等二进制数据。

此外,还可以定义复合类型,如枚举(enum)和嵌套消息(nested message)。

  1. 枚举(Enum) 枚举类型用于定义一组命名的常量值。例如:
message Order {
  enum OrderStatus {
    PENDING = 0;
    PROCESSING = 1;
    COMPLETED = 2;
    CANCELED = 3;
  }
  OrderStatus status = 1;
}

在这个例子中,定义了一个 OrderStatus 枚举类型,它包含了 PENDINGPROCESSINGCOMPLETEDCANCELED 四个常量值,并且在 Order 消息中使用了这个枚举类型的字段 status。需要注意的是,枚举值必须从 0 开始,且每个值必须唯一。

  1. 嵌套消息(Nested Message) 可以在一个消息内部定义另一个消息类型,形成嵌套结构。例如:
message Address {
  string street = 1;
  string city = 2;
  string country = 3;
}

message Customer {
  string name = 1;
  Address shipping_address = 2;
  Address billing_address = 3;
}

这里 Address 消息被定义在 Customer 消息的外部,也可以将 Address 消息定义在 Customer 消息内部,形成嵌套结构。这种方式有助于构建复杂的数据结构,使得相关的数据紧密关联在一起。

  1. 重复字段(Repeated Fields) 如果一个字段可能包含多个值,可以使用 repeated 关键字来定义重复字段。例如:
message Book {
  string title = 1;
  repeated string authors = 2;
}

在这个 Book 消息中,authors 字段是一个重复字段,可以包含多个作者的名字。重复字段在序列化后会按照顺序排列,反序列化时也会保持相同的顺序。

  1. 默认值 对于基本类型的字段,如果在序列化时没有设置值,Protobuf 会使用默认值。例如,int32 类型的默认值是 0,string 类型的默认值是一个空字符串,bool 类型的默认值是 false。开发者也可以为字段指定自定义的默认值,例如:
message Settings {
  bool debug_mode = 1 [default = false];
  int32 timeout = 2 [default = 30];
}

这里为 debug_mode 字段指定了默认值 false,为 timeout 字段指定了默认值 30

使用 Protobuf 生成代码

  1. 安装 Protobuf 编译器 要使用 Protobuf 生成代码,首先需要安装 Protobuf 编译器。安装方法因操作系统而异:
  • 在 Linux 上:可以通过包管理器安装,例如在 Ubuntu 上,可以使用以下命令安装:
sudo apt - get install protobuf - compiler
  • 在 macOS 上:可以使用 Homebrew 安装:
brew install protobuf
  • 在 Windows 上:可以从 Protobuf 的官方 GitHub 仓库下载预编译的二进制文件,并将其添加到系统路径中。
  1. 生成代码 假设我们有一个名为 example.proto 的文件,内容如下:
syntax = "proto3";

message Person {
  string name = 1;
  int32 age = 2;
  string email = 3;
}

要为不同的编程语言生成代码,可以使用以下命令:

  • 生成 C++ 代码
protoc - I. --cpp_out=. example.proto

这里 -I. 表示在当前目录查找导入的 .proto 文件,--cpp_out=. 表示将生成的 C++ 代码输出到当前目录。

  • 生成 Java 代码
protoc - I. --java_out=. example.proto

生成的 Java 代码会以 Java 类的形式存在,包含用于序列化、反序列化和访问字段的方法。

  • 生成 Python 代码
protoc - I. --python_out=. example.proto

生成的 Python 代码是一个 Python 模块,其中包含与消息对应的类和相关方法。

  • 生成 Go 代码
protoc - I. --go_out=. example.proto

Go 语言的生成代码会使用结构体和方法来表示 Protobuf 消息,并且支持 gRPC 相关的功能。

在 gRPC 中使用 Protobuf

  1. 定义服务(Service) gRPC 使用 Protobuf 来定义服务接口。服务接口由一组方法组成,每个方法定义了请求和响应的消息类型。例如:
syntax = "proto3";

message HelloRequest {
  string name = 1;
}

message HelloResponse {
  string message = 1;
}

service Greeter {
  rpc SayHello(HelloRequest) returns (HelloResponse);
}

在这个例子中,定义了一个 Greeter 服务,其中包含一个 SayHello 方法。该方法接受一个 HelloRequest 类型的请求,并返回一个 HelloResponse 类型的响应。

  1. 实现服务 以 Go 语言为例,生成代码后,可以按照以下方式实现 Greeter 服务:
package main

import (
  "context"
  "fmt"
  "log"

  pb "github.com/your - path/your - project/proto"
  "google.golang.org/grpc"
)

type server struct{}

func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloResponse, error) {
  return &pb.HelloResponse{Message: "Hello, " + in.Name}, nil
}

func main() {
  lis, err := net.Listen("tcp", fmt.Sprintf(":%d", 50051))
  if err!= nil {
    log.Fatalf("failed to listen: %v", err)
  }
  s := grpc.NewServer()
  pb.RegisterGreeterServer(s, &server{})
  if err := s.Serve(lis); err!= nil {
    log.Fatalf("failed to serve: %v", err)
  }
}

这里定义了一个 server 结构体,并实现了 SayHello 方法。然后在 main 函数中启动 gRPC 服务器,注册 Greeter 服务。

  1. 调用服务 客户端调用 Greeter 服务的代码如下:
package main

import (
  "context"
  "fmt"
  "log"

  pb "github.com/your - path/your - project/proto"
  "google.golang.org/grpc"
)

func main() {
  conn, err := grpc.Dial("localhost:50051", grpc.WithInsecure())
  if err!= nil {
    log.Fatalf("did not connect: %v", err)
  }
  defer conn.Close()
  c := pb.NewGreeterClient(conn)
  ctx, cancel := context.WithTimeout(context.Background(), time.Second)
  defer cancel()
  r, err := c.SayHello(ctx, &pb.HelloRequest{Name: "world"})
  if err!= nil {
    log.Fatalf("could not greet: %v", err)
  }
  fmt.Printf("Greeting: %s\n", r.Message)
}

在客户端代码中,首先建立与 gRPC 服务器的连接,然后创建 GreeterClient,并调用 SayHello 方法,将请求发送到服务器并获取响应。

Protobuf 高级特性

  1. Oneof oneof 是 Protobuf 中的一种特殊类型,它允许在一组字段中最多选择一个字段被设置。这在节省内存和确保数据一致性方面非常有用。例如:
message Payment {
  oneof payment_method {
    string credit_card = 1;
    string paypal_account = 2;
    string bank_transfer = 3;
  }
}

在这个 Payment 消息中,payment_method 是一个 oneof 类型,它可以是 credit_cardpaypal_accountbank_transfer 中的一个,但不能同时设置多个。在代码中访问 oneof 字段时,需要注意检查当前设置的是哪个字段。

  1. Maps Protobuf 支持定义映射类型,用于存储键值对。例如:
message Product {
  string name = 1;
  map<string, int32> attributes = 2;
}

这里 attributes 是一个映射类型,键是字符串类型,值是 int32 类型。在生成的代码中,可以像操作普通的映射数据结构一样操作这个字段。

  1. Any Any 类型允许在 Protobuf 消息中嵌入任意类型的消息。这在需要灵活处理不同类型数据的场景中很有用。例如:
import "google/protobuf/any.proto";

message GenericMessage {
  string type = 1;
  google.protobuf.Any data = 2;
}

message User {
  string name = 1;
  int32 age = 2;
}

message Order {
  string order_id = 1;
  repeated string items = 2;
}

GenericMessage 中,type 字段表示嵌入数据的类型,data 字段是 Any 类型,可以嵌入 UserOrder 等其他消息。使用时需要进行类型检查和转换。

  1. Timestamp google.protobuf.Timestamp 类型用于表示时间戳,它以 UTC 时间格式存储秒数和纳秒数。例如:
import "google/protobuf/timestamp.proto";

message Event {
  string event_name = 1;
  google.protobuf.Timestamp occurrence_time = 2;
}

在生成的代码中,可以方便地对 Timestamp 类型进行操作,如获取当前时间、比较时间等。

Protobuf 的性能优化

  1. 字段编号优化 合理选择字段编号可以提高 Protobuf 的性能。字段编号越小,序列化后的二进制数据占用空间越小。因此,对于经常出现的字段,应该分配较小的编号。例如,在一个用户信息消息中,如果 name 字段和 age 字段经常使用,而 email 字段使用频率较低,可以将 name 字段编号设为 1,age 字段编号设为 2,email 字段编号设为 3。

  2. 使用合适的字段类型 根据数据的实际范围和精度选择合适的字段类型。例如,如果数据范围在 0 到 255 之间,使用 uint32 就有些浪费空间,可以使用 uint8。对于浮点数,如果精度要求不高,使用 floatdouble 更节省空间。

  3. 避免不必要的嵌套 虽然嵌套消息可以构建复杂的数据结构,但过多的嵌套会增加序列化和反序列化的复杂度。在设计数据结构时,应尽量保持结构简洁,避免不必要的嵌套。例如,如果可以通过平级字段表示数据关系,就不要使用多层嵌套。

  4. 缓存序列化结果 在某些场景下,如果数据不会频繁变化,可以缓存序列化后的结果,避免重复序列化。例如,在一个配置文件读取的场景中,配置信息在应用启动后很少改变,那么可以在首次读取并序列化后将结果缓存起来,后续直接使用缓存数据,提高性能。

Protobuf 与其他数据格式的比较

  1. 与 JSON 的比较
  • 可读性:JSON 以文本形式表示数据,具有良好的可读性,易于人类编写和调试。而 Protobuf 序列化后的二进制数据不易直接阅读,需要借助工具进行解析。
  • 性能:Protobuf 在序列化和反序列化速度以及数据体积上都优于 JSON。JSON 由于是文本格式,在传输和存储时需要更多的空间,并且解析过程相对较慢。
  • 类型支持:JSON 是弱类型的,在解析时可能会出现类型不匹配问题。Protobuf 是强类型的,在编译阶段就能发现类型错误,提高代码稳定性。
  • 灵活性:JSON 更灵活,不需要预先定义严格的结构,适合一些快速开发和结构不太固定的场景。Protobuf 需要预先定义数据结构,在结构稳定的项目中更能发挥优势。
  1. 与 XML 的比较
  • 可读性:XML 也是文本格式,可读性较好,但 XML 标签较多,数据冗余较大。Protobuf 二进制格式不易读,但数据紧凑。
  • 性能:Protobuf 在性能上明显优于 XML,无论是序列化速度还是数据体积。XML 的解析和生成过程相对复杂,占用资源较多。
  • 扩展性:XML 有丰富的扩展机制,如 XSD 用于验证结构。Protobuf 也支持扩展,但方式相对简洁,主要通过 oneof 等特性实现类似功能。
  • 应用场景:XML 常用于配置文件、数据交换等对可读性和扩展性要求较高的场景。Protobuf 更适合对性能要求极高、对数据结构有严格定义的场景,如 gRPC 通信。

总结

Protocol Buffers 在 gRPC 微服务架构中扮演着至关重要的角色,它通过高效的数据序列化和反序列化,为 gRPC 提供了强大的数据处理能力。其清晰的语法定义、丰富的特性以及良好的跨语言支持,使得开发者能够轻松构建高性能、可靠的分布式系统。通过合理运用 Protobuf 的各种特性,如字段编号优化、选择合适的字段类型等,可以进一步提升系统的性能。在与其他数据格式的比较中,Protobuf 在性能和强类型定义方面展现出独特的优势,尤其适合在对性能和数据结构稳定性要求较高的微服务场景中使用。无论是构建新的分布式系统,还是对现有系统进行优化,深入理解和掌握 Protobuf 都是后端开发者的必备技能。