Go 开发者必备:Protocol Buffers 入门指南

363 阅读21分钟

前言

在现代软件开发中,系统之间的高效通信至关重要,尤其是在微服务架构和分布式系统中。为了高效地传输数据并保证跨语言的兼容性,Protocol Buffers(简称 Protobuf) 应运而生。ProtobufGoogle 开发的一种轻量、高效的序列化数据格式。它被广泛应用于微服务、RPC 框架以及大数据处理等领域。

与传统的 JSONXML 格式相比,Protobuf 的优势在于其更小的体积和更快的速度。它通过定义消息结构(Schema)来进行数据的序列化和反序列化,支持多种编程语言,并且能够为开发人员提供一个明确且易于管理的数据传输模型。

本文将深入探讨如何在 Go 语言中使用 Protocol Buffers (Protobuf),全面覆盖从环境配置到实际应用的各个方面。我将逐步讲解如何安装和配置 Protobuf 编译器,编写和编译 .proto 文件,理解 Protobuf 的核心概念,如何定义和生成消息类型与服务接口。接着学习如何将其与 Go 结合,实现高效的序列化与反序列化操作。最后,文章还将介绍 Protobuf 的风格指南与最佳实践,帮助开发者在实际项目中更加规范、高效地使用 Protobuf

准备好了吗?准备一杯你最喜欢的咖啡或茶,随着本文一探究竟吧。

请在此添加图片描述

Protobuf 环境配置

安装 protobuf 编译器

Windows

1、下载 Protobuf

  • 访问 Protobuf GitHub Releases 页面。
  • 选择最新版本,并下载适用于 Windowsprotoc-<version>-win64.zipprotoc-<version>-win32.zip 文件。

2、解压

  • 解压下载的 ZIP 文件到你希望存放 protoc 的目录。

3、添加环境变量

  • protoc 所在的目录添加到系统的环境变量中。这样你就可以从命令行中的任何位置运行它。
  • 在“系统属性”中找到“环境变量”,然后在“Path”变量中添加 <protoc path>\bin 的路径。

4、验证安装

  • 打开命令行,输入 protoc --version,以检查是否安装成功。
$ protoc --version
libprotoc 29.3

MacOs

MacOs 系统上,你可以使用 Homebrew 安装 protoc

brew install protobuf

验证是否安装成功

$ protoc --version
libprotoc 29.3

Linux (Ubuntu/Debian)

在基于 Debian 的系统(如 Ubuntu)上,你可以使用 apt 安装 protoc

sudo apt install protobuf-compiler

验证是否安装成功

$ protoc --version
libprotoc 3.6.1

使用 apt 安装 protoc 时,会默认安装一个较为稳定的版本,该版本可能不是最新版本。因此,如果想要安装最新版本,建议使用其他的方式下载最新版本的发布包,然后进行安装。例如:

# 下载发布包
$ wget https://github.com/protocolbuffers/protobuf/releases/download/v25.1/protoc-25.1-linux-x86_64.zip
# 解压到 /usr/local/bin 目录下
$ unzip protoc-25.1-linux-x86_64.zip -d /usr/local/bin/protoc-25.1-linux-x86_64
# 配置环境变量
$ vim ~/.bashrc
# 添加以下内容
export PATH=$PATH:/usr/local/bin/protoc-25.1-linux-x86_64/bin
# 激活配置文件
$ source ~/.bashrc
# 验证是否安装成功
$ protoc --version
libprotoc 25.1

安装 protoc-gen-go

protoc-gen-goprotoc 的一个插件,用于生成 Go 语言的代码。

通过下面的命令进行安装:

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest

验证是否安装成功:

$ protoc-gen-go --version
protoc-gen-go v1.31.0

初体验

首先在项目里面新建一个 proto 文件,假设文件名为 user.proto,然后定义消息类型

syntax = "proto3";
package tutorial;

option go_package = "github.com/chenmingyong0423/blog/tutorial-code/go/protobuf/proto/user";

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

然后执行以下命令,生成对应的 go 文件:

protoc --go_out=. --go_opt=paths=source_relative *.proto

这时我们就可以看到当前目录下多出了一个 user.pb.go 文件,该文件为 proto 代码编译后的 go 文件。

Protoc 命令常用参数

若要根据 proto 代码生成对应语言的代码(比如 Go),我们需要使用 protoc 命令,这个命令在之前已经给出安装教程。protoc 命令的常用参数如下所示:

  • -I--proto_path:指定 import 的文件查找路径,可以指定多个路径,例如 -Isrc -Iinclude。这样编译器会在这几个路径下查找 import.proto 文件。
  • --<language>_out:指定生成所指定的语言代码的输出目录,对于 Gogo_out=/directory
  • --<language>_opt:传递给指定语言插件的附加选项。作为 protoc 的插件,它们有着特定的参数选项,如果我们想指定某个参数选项,需要通过 <language>_opt 参数进行传递。例如:go_opt=paths=source_relative,传递 paths 参数选项给 protoc-gen-go 插件。

在大多数情况下,通过指定 <language>_out<language>_opt 参数,我们就可以满足代码生成的需求。值得一提的是,这些参数不限于单次使用;如果我们需要同时为多种语言生成代码,可以通过并行使用多个 <language>_opt<language>_opt 来实现这一目标。

若想了解更多的参数,可以运行 protoc --help 命令进行查看。

protoc-gen-go 插件参数

protoc-gen-go 是一个用于生成 Go 代码的插件,该插件有两个重要参数:

  • paths:控制 go 文件生成的路径
  • paths=import 时,输出文件将放置在 Go 包的导入路径命名 的目录中(导入路径.proto 文件中的 go_package 选项提供)。例如,Go 导入路径为 github.com/chenmingyong0423/blog/tutorial-code/go/protobuf/proto/user,那么输出的 .go 文件将放置在 github.com/chenmingyong0423/blog/tutorial-code/go/protobuf/proto/user/user.pb.go 。如果未指定 paths 参数,paths 的值将默认为 import
  • paths=source_relative 时,输出的 .go 文件将与 .proto 文件位于同一相对目录中。例如, .proto 文件位于 proto/user/user.proto,那么 .go 文将在 proto/user/user.pb.go 中生成。
  • module:如果指定了 module 参数,例如 module=examples,则生成的 .go 文件将位于 Go 包的导入路径 加上指定的模块目录下。例如,假设 Go 包的导入路径protobuf,并指定 module=examples,那么 .go 文件将生成在 protobuf/examples 目录中,例如:protobuf/examples/user.proto.go

protoc-gen-go 插件的参数需要通过 protoc 命令的 go_opt 参数进行传递,例如 go_opt=paths=source_relative

Protobuf 语法

定义消息类型

syntax = "proto3";

option go_package = "github.com/chenmingyong0423/blog/tutorial-code/protobuf/examples";

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

通过 message 关键字定义一个消息类型。

字段定义

消息的字段定义格式为:[关键字] 类型 字段名 = 编号;,例如 string name = 1;optional string name = 1;

要点

  • 必须为消息定义中的每个字段指定一个 1536,870,911 之间的数字,并遵守以下限制:
    • 给定的编号在该消息的所有字段中必须是唯一的。
    • 字段编号 19,00019,999 被保留给 Protocol Buffers 实现。如果你在消息中使用了这些保留的字段编号,协议缓冲区编译器会报错。
    • 不能使用任何之前已经保留的字段编号,也不能使用已经分配给扩展的字段编号。
  • proto3 中,字段默认被标记为 optional,这意味着你可以不为某个字段赋值,它会使用该字段类型的默认值,同时也可以区分该字段是否被 赋值,即使该字段的值为默认值。

字段类型

标量类型(Scalar Types)

这些类型表示常见的数据类型,如整数、浮点数、布尔值、字符串等。

类型默认值备注
double0.0
float0.0
int32032 位有符号整数,使用 变长编码(Variable-length encoding)。对于负数的编码效率较低。 如果字段值经常是负数,建议使用 sint32,因为 sint32 更有效地编码负数。
int64064 位有符号整数,使用 变长编码(Variable-length encoding)。对于负数的编码效率较低。 如果字段值经常是负数,建议使用 sint64,因为 sint64 更有效地编码负数。
uint32032 位无符号整数,使用 变长编码(Variable-length encoding)
uint64064 位无符号整数,使用 变长编码(Variable-length encoding)
sint32032 位有符号整数,使用 变长编码(Variable-length encoding)。与 int32 类似,但优化了负数的编码方式。
sint64064 位有符号整数,使用 变长编码(Variable-length encoding)。与 int64 类似,但优化了负数的编码方式。
fixed320始终使用 4 个字节进行编码。比 uint32 更有效,如果值大于 228)。
fixed640始终使用 8 个字节进行编码。比 uint64 更有效,如果值大于 256)。
sfixed320始终使用 4 个字节进行编码的有符号整数。
sfixed640始终使用 8 个字节进行编码的有符号整数。
boolfalse布尔类型,只有两个值 truefalse
string空字符串字符串必须始终包含 UTF-8 编码或 7 位 ASCII 文本,并且长度不能超过 232
bytes空字节可以包含不超过 232 的任意任意字节序列。

枚举类型(Enums)

枚举类型允许定义一组命名常量,通常用于表示状态、选项、类别等。

enum Status {
  PENDING = 0;
  IN_PROGRESS = 1;
  COMPLETED = 2;
}
  • 枚举值必须是 整数类型
  • 默认情况下,枚举值的第一个常量为 0,表示默认值。

消息类型(Message Types)

messageProtobuf 中的复合类型,用来表示一组相关的数据字段。每个字段可以是不同的类型,包括标量类型、枚举类型、其他消息类型等。

message User {
  string name = 1;
  int32 age = 2;
  string email = 3;
}
  • 嵌套消息:你还可以在一个消息中定义其他消息类型。
message AddressBook {
  message User {
    string name = 1;
    string email = 2;
  }
  repeated User user= 1;  // 这个字段是一个列表,包含多个 User
}

特殊类型

除了基本的标量、枚举以及消息类型,ProtoBuf 还提供了几种特殊的类型,用于处理更复杂的需求。

  • repeated:表示字段可以有多个值,相当于一个数组或列表。
message User {
  repeated string phones = 1; // 可以包含多个字符串
}
  • map:表示键值对集合,相当于字典或哈希表。键可以是标量类型(浮点类型和 bytes 除外),值可以是除另一个 map 之外的任何类型。。
message User {
  map <string, int32> scores = 1;
}

使用 map 类型的一些注意事项如下:

  • map 字段不能使用 repeated 关键字。
  • .proto 生成文本格式时,映射按键排序。数字键按数字排序。
  • map 的键值对在 wire 格式中的顺序以及在迭代时的顺序是未定义的,因此你不能依赖 map 中元素的顺序。
  • 在生成 .proto 的文本格式时,map 会按键进行排序。对于数值型的键,排序会按数字顺序进行。
  • 在解析 map 或进行合并时,如果出现重复的键,最后一个键值会被使用。在从文本格式解析时,如果遇到重复的键,解析可能会失败。
  • 如果你为 map 字段提供了一个键但没有提供值,则序列化时的行为取决于语言:
    • C++JavaKotlinPython 中,序列化时会使用该类型的默认值。
    • 在其他语言中,如果没有提供值,则该字段不会被序列化。
  • 在同一作用域中,不能存在一个名为 map foo 的字段和一个名为 FooEntry 的符号,因为 FooEntry 已经被用于 map 的实现。
  • Any:表示任意类型,它可以让字段存储不同类型的数据,而不需要在消息定义时提前知道这些类型。要使用 Any 类型,您需要导入 google/protobuf/any.proto
import "google/protobuf/any.proto";

message User {
  google.protobuf.Any data = 1;
}
  • oneof:一种特殊的字段类型,允许在一个消息中 定义多个字段,但在任何时候只能 设置其中一个字段。你可以添加任何类型的字段, map 字段和 repeated 字段除外。如果需要向 oneof 添加重复字段,可以使用包含重复字段的消息类型。
message MyMessage {
  oneof message_data {
    string text = 1;
    int32 number = 2;
    User user = 3;
  }
}

使用 oneof 类型的一些注意事项如下:

  • oneof 字段赋值时,它会自动清除同一 oneof 中的其他字段的值。
  • 如果解析时遇到同一个 oneof 中的多个字段,则只有最后一个字段会在解析的消息中保留其值。
    • 首先检查同一个 oneof 中的其他字段是否已经设置。如果有其他字段已设置,则清除它。
    • 然后按正常方式解析该字段,就好像它不属于 oneof 一样:
      • 基本类型 会覆盖已经设置的值。
      • 消息类型 会与已设置的值合并。
  • oneof 字段不能使用 repeated 关键字。
  • 反射 APIoneof 字段有效 你可以通过反射 API 来访问和修改 oneof 字段的值。
  • 如果你为 oneof 字段设置默认值(例如将 int32 类型的字段设置为 0),即使该字段的值是默认值,oneof 的 “case” 也会被设置,并且该值会被序列化到 wire 格式中。

定义服务

如果需要在 RPC(远程过程调用)系统中使用你的消息类型,可以在 .proto 文件中定义一个 RPC 服务接口,协议缓冲编译器会为你生成服务接口代码和存根代码,适用于你选择的编程语言。例如,如果你想定义一个 RPC 服务,包含一个方法,该方法接受 SearchRequest 并返回 SearchResponse,你可以在 .proto 文件中这样定义:

service SearchService {
  rpc Search(SearchRequest) returns (SearchResponse);
}

Go Protobuf 基础

掌握了 protobuf 基本的语法之后,接下来我们要了解 proto 代码与 go 代码之间的关系。下面将围绕着以下示例代码逐步进行讲解。

syntax = "proto3";
package tutorial;

import "google/protobuf/timestamp.proto";

option go_package = "github.com/chenmingyong0423/blog/tutorial-code/go/protobuf/protos/user";

message User {
    int32 id = 1;
    string name = 2;
    int32 age = 3;

    message PhoneNumber {
        string number = 1;
        PhoneType type = 2;
    }

    repeated PhoneNumber phones = 4;

    google.protobuf.Timestamp birth = 5;
}

enum PhoneType {
    // 个人手机
    PHONE_TYPE_MOBILE = 0;
    // 工作电话
    PHONE_TYPE_WORK = 1;
}

包声明

syntax = "proto3";
package tutorial;

option go_package = "github.com/chenmingyong0423/blog/tutorial-code/go/protobuf/protos/user";

.proto 文件以 package 声明开头,这有助于避免不同项目之间的命名冲突。然而,这里的 package 并不对应 Go 语言中的 package。协议缓冲编译器(protoc)会根据 .proto 文件中 go_package 字段的导入路径来确定 Go 代码中的包名,通常是该路径的最后一个部分。例如,基于示例代码生成的 Go 代码包名将是 user

包导入

如果在 .proto 文件中引入了标准库或第三方库,编译生成的 Go 代码中也会反映这一点。例如,若引入 google/protobuf/timestamp.proto,在 Go 代码中对应的导入路径为:

timestamppb "google.golang.org/protobuf/types/known/timestamppb"

结构体

message User {
    int32 id = 1;
    string name = 2;
    int32 age = 3;

    message PhoneNumber {
        string number = 1;
        PhoneType type = 2;
    }

    repeated PhoneNumber phones = 4;

    google.protobuf.Timestamp birth = 5;
}

enum PhoneType {
    // 个人手机
    PHONE_TYPE_MOBILE = 0;
    // 工作电话
    PHONE_TYPE_WORK = 1;
}

协议缓冲编译器(protoc)会将 protobuf 中的类型转换为 Go 语言中对应的类型。例如,message 类型会转换为 Go 中的 struct 结构体,而由于 Go 没有内建的枚举类型,enum 类型会被转换为 Go 的自定义类型。所生成的部分代码如下所示:

type User struct {
	state         protoimpl.MessageState
	sizeCache     protoimpl.SizeCache
	unknownFields protoimpl.UnknownFields

	Id     int32                  `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
	Name   string                 `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
	Age    int32                  `protobuf:"varint,3,opt,name=age,proto3" json:"age,omitempty"`
	Phones []*User_PhoneNumber    `protobuf:"bytes,4,rep,name=phones,proto3" json:"phones,omitempty"`
	Birth  *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=birth,proto3" json:"birth,omitempty"`
}

type PhoneType int32

const (
	// 个人手机
	PhoneType_PHONE_TYPE_MOBILE PhoneType = 0
	// 工作电话
	PhoneType_PHONE_TYPE_WORK PhoneType = 1
)

type User_PhoneNumber struct {
	state         protoimpl.MessageState
	sizeCache     protoimpl.SizeCache
	unknownFields protoimpl.UnknownFields

	Number string    `protobuf:"bytes,1,opt,name=number,proto3" json:"number,omitempty"`
	Type   PhoneType `protobuf:"varint,2,opt,name=type,proto3,enum=PhoneType" json:"type,omitempty"`
}

Protobuf 类型与 Go 类型之间的映射关系

Protobuf 类型与 Go 类型之间有着明确的映射关系,理解这些映射关系对于正确使用 ProtobufGo 中非常重要。以下是一些常见的映射规则:

Protobuf 类型Go 类型
doublefloat64
floatfloat32
int32int32
int64int64
uint32uint32
uint64uint64
sint32int32
sint64int64
fixed32uint32
fixed64uint64
sfixed32int32
sfixed64int64
boolbool
stringstring
bytes[]byte
messagestruct
enum自定义类型(通常是 int32)
repeatedslice
mapmap

读写消息示例

首先,我们需要创建一个名为 protobuf 的目录,并进入该目录初始化一个 Go 项目。接下来,在 proto/user 目录中创建一个名为 user.proto 的文件,文件内容使用之前提供的示例代码。项目目录结构如下所示:

.
├── go.mod
├── go.sum
└── proto
    └── user
        └── user.proto

然后在 proto 目录下,通过以下命令使用 protoc 编译 .proto 文件,生成对应的 Go 代码:

protoc --go_out=. --go_opt=paths=source_relative *.proto

接下来将基于生成的 Go 代码演示如何进行 Protobuf 消息的写入(序列化) 和 读取(反序列化) 操作。

在此之前,我们需要安装 proto 模块:

go get google.golang.org/protobuf/proto

序列化消息(写入)

	// 写入消息
	user := pb.User{
		Id:   1,
		Name: "陈明勇",
		Age:  18,
		Phones: []*pb.User_PhoneNumber{
			{
				Number: "18888888888",
				Type:   pb.PhoneType_PHONE_TYPE_MOBILE,
			},
			{
				Number: "12345678901",
				Type:   pb.PhoneType_PHONE_TYPE_WORK,
			},
		},
		Birth: timestamppb.New(time.Date(1999, 1, 1, 0, 0, 0, 0, time.UTC)),
	}
	out, err := proto.Marshal(&user)
	if err != nil {
		panic(err)
	}
	err = os.WriteFile("user.bin", out, 0644)
	if err != nil {
		panic(err)
	}

反序列化消息(读取)

	// 读取消息
	in, err := os.ReadFile("user.bin")
	if err != nil {
		panic(err)
	}
	user2 := &pb.User{}
	err = proto.Unmarshal(in, user2)
	if err != nil {
		panic(err)
	}
	// id:1 name:"陈明勇" age:18 phones:{number:"18888888888"} phones:{number:"12345678901" type:PHONE_TYPE_WORK} birth:{seconds:915148800}
	fmt.Println(user2)

完整示例:序列化与反序列化

新建 main.go 文件并写入以下内容:

package main

import (
	"fmt"
	pb "github.com/chenmingyong0423/blog/tutorial-code/go/protobuf/proto/user"
	"os"
	"time"

	"google.golang.org/protobuf/proto"
	"google.golang.org/protobuf/types/known/timestamppb"
)

func main() {
	// 写入消息
	user := pb.User{
		Id:   1,
		Name: "陈明勇",
		Age:  18,
		Phones: []*pb.User_PhoneNumber{
			{
				Number: "18888888888",
				Type:   pb.PhoneType_PHONE_TYPE_MOBILE,
			},
			{
				Number: "12345678901",
				Type:   pb.PhoneType_PHONE_TYPE_WORK,
			},
		},
		Birth: timestamppb.New(time.Date(1999, 1, 1, 0, 0, 0, 0, time.UTC)),
	}
	out, err := proto.Marshal(&user)
	if err != nil {
		panic(err)
	}
	err = os.WriteFile("user.bin", out, 0644)
	if err != nil {
		panic(err)
	}

	// 读取消息
	in, err := os.ReadFile("user.bin")
	if err != nil {
		panic(err)
	}
	user2 := &pb.User{}
	err = proto.Unmarshal(in, user2)
	if err != nil {
		panic(err)
	}
	// id:1 name:"陈明勇" age:18 phones:{number:"18888888888"} phones:{number:"12345678901" type:PHONE_TYPE_WORK} birth:{seconds:915148800}
	fmt.Println(user2)
}

通过 proto.Marshalproto.Unmarshal 函数,我们可以对 Protobuf 消息进行序列化(写入)和反序列化(读取)操作。

使用 go run main.go 命令,程序即可成功运行。

Protobuf 风格指南

为了确保 .proto 文件中协议缓冲消息定义及其对应类的结构一致且易于阅读。我们需要遵循这些规范。

需要注意的是,协议缓冲的风格在不断演进,因此我们可能会遇到采用不同风格或规范编写的 .proto 文件。在修改这些文件时,需要尽量遵循已有的风格,保持一致性是非常重要的。当然,在创建新的 .proto 文件时,建议采用当前最新的的最佳实践和风格。

标准文件格式

  • 保持每行字符长度不超过 80 个字符。
  • 使用 2 个空格作为缩进。
  • 字符串应优先使用双引号。

文件结构

文件名应采用小写蛇形命名法(lower_snake_case.proto)。

所有文件应按以下顺序组织:

  • 许可头(如果适用)
  • 文件概述
  • 语法版本
  • 包声明
  • 导入包(按字母顺序排序)
  • 文件选项
  • 其他内容

包声明

  • 包名应采用小写字母。
  • 包名应具有唯一性,通常基于项目名称,并且可以根据包含协议缓冲类型定义的文件路径进行命名。例如文件路径为 proto/user/user.proto,则包名可以是 proto.user

消息和字段命名

对于消息名称,使用 PascalCase(首字母大写)命名风格,例如 SongServerRequest。对于缩写,推荐将其为一个整体,保持首字母大写,而不是拆分字母:例如 GetDnsRequest,而不是 GetDNSRequestDns 作为一个整体,首字母大写。

对于字段名称(包括 oneof 字段和扩展名),使用 lower_snake_case(小写字母,单词间用下划线分隔):例如 song_name

Repeated 字段

Repeated 字段使用复数名称。例如 repeated string keys

Enum 字段

  • 枚举类型的命名
    • 使用 PascalCase(首字母大写)来命名枚举类型。例如:FooBar
  • 枚举值的命名
    • 使用 CAPITALS_WITH_UNDERSCORES(大写字母,并用下划线分隔)来命名枚举值。例如:FOO_BAR_UNSPECIFIEDFOO_BAR_FIRST_VALUE
  • 每个枚举值后应以分号结尾,而不是逗号。
  • 避免命名冲突:建议为每个枚举值加上枚举名称前缀或将枚举嵌套在消息内部。
  • 使用顶级枚举:如果可以,避免嵌套枚举。
  • 零值枚举:枚举的零值命名应为 UNSPECIFIED

服务(Service)

如果你的 .proto 文件中定义了 RPC 服务,应该对 服务名称RPC 方法名称 都使用 PascalCase(首字母大写)命名规则:

service FooService {
  rpc GetSomething(GetSomethingRequest) returns (GetSomethingResponse);
  rpc ListSomething(ListSomethingRequest) returns (ListSomethingResponse);
}

Protobuf 最佳实践

  • 不要重用标签号 不要重用标签号。重用标签号会导致反序列化错误。即使你认为没有人在使用该字段,也不要重用标签号,因为历史中可能已经有已序列化的 proto 数据,或者其他服务的旧代码可能会受到影响。
  • 删除字段后保留标签号 当你删除一个字段时,应该保留其标签号,以避免未来有人不小心重用该标签号。仅保留 23 等数字即可。你还可以保留已删除字段的名称,避免它们被重用:例如,reserved "foo", "bar";
  • 删除枚举值时保留标签号 同样,删除不再使用的枚举值时,应该保留它们的标签号,以免他人误用。可以像字段一样保留 23 等标签号,并保留已删除的枚举值名称:例如,reserved "FOO", "BAR";
  • 避免改变字段类型 除非是深思熟虑,否则不要改变字段的类型。这会导致反序列化失败。虽然有些类型的转换(如 int32uint32)是安全的,但改变消息类型会破坏兼容性,除非新类型是旧类型的超集。
  • 不要添加必填字段 永远不要添加必填字段,而应该通过文档注释来指定 API 合同的要求。proto3 移除了必填字段的支持,所有字段应当是可选的或重复的。这样可以避免未来需求变化时强制使用不再逻辑上需要的字段。
  • 不要创建包含大量字段的消息 尽量避免在同一消息中定义大量字段(例如:几百个字段)。过大的 proto 文件会增加内存使用,甚至可能导致生成的代码无法编译。建议将大型消息拆分为多个小的消息。
  • 为枚举添加一个未指定值 枚举应该包含一个默认的 FOO_UNSPECIFIED 值,作为枚举声明的第一个值。这样在添加新值时,旧客户端会将字段视为未设置,并返回默认值(即枚举的第一个值)。此外,枚举值应使用 tag 0 作为 UNSPECIFIED 的默认值。
  • 使用通用类型和常用类型 推荐使用一些已定义的通用类型(如 durationtimestampdatemoney 等),而不是自己定义类似的类型。这样可以减少重复定义,同时也能确保跨语言的一致性。
  • 在单独的文件中定义消息类型undefined 每个 proto 文件最好只定义一个消息、枚举、扩展、服务或循环依赖。将相关类型放在一个文件中会更容易进行重构和维护,也能确保文件不被过度膨胀。
  • 不要更改字段的默认值 永远不要更改字段的默认值,这样会导致客户端和服务端的版本不兼容。proto3 移除了为字段设置默认值的能力,因此,最好避免更改字段的默认值。
  • 避免将 repeated 类型转换为标量类型 不要将 repeated 字段改为标量类型,这样会丢失数据。对于 proto3 的数值类型字段,转换将会丢失字段数据。
  • 避免使用文本格式消息进行交换 文本格式(如 JSON 和文本格式)的序列化方法并不适合用于数据交换。它们将字段和枚举值表示为字符串,因此在字段或枚举值重命名或新增字段时,旧代码会导致反序列化失败。应尽可能使用二进制格式进行数据交换,文本格式仅限于调试和人工编辑。
  • 永远不要依赖于跨构建的序列化稳定性 Protobuf 的序列化稳定性无法保证跨不同的二进制文件或同一二进制文件的不同构建版本。不要依赖序列化稳定性来构建缓存键等。
  • 避免使用语言关键字作为字段名称 避免使用在目标语言中作为关键字的字段名称,因为这可能导致 protobuf 自动更改字段名称或提供特殊访问方式。还应避免在文件路径中使用关键字。

小结

本文介绍了如何在 Go 中使用 Protobuf,涵盖了环境配置、语法、集成步骤、风格指南和最佳实践等内容。通过本文,你可以快速上手 GoProtocol Buffers 的集成,掌握消息类型的定义、代码的生成以及消息的序列化与反序列化流程。

参考资料