近日,看到了国外一篇关于Protobuf 定义变量时需要注意的细节的博客,感觉甚好,特此翻译一下,供日常查看。
原博客链接:https://medium.com/@akhaku/protobuf-definition-best-practices-87f281576f31
Protocol Buffer 是一种通过被称为互联网的管道系列发送数据的机制。它们的一个常见用途是定义 gRPC 规范 - 本质上是一种远程过程调用的形式。使用 gRPC 服务定义,你创建一个带有 RPC 方法的"服务"。这些 RPC 方法接收一个请求"消息",并返回一个响应"消息"。
service FooService {
rpc GetFoo (GetFooRequest) returns (GetFooResponse);
}
message GetFooRequest {
string id = 1;
}
message GetFooResponse {
string fooName = 1;
int64 fooValue = 2;
}
Protobuf 和 gRPC 是强大的工具,可以用于多种方式。以下是在使用 protocol buffer 定义 gRPC 服务时推荐的一套最佳实践。
1. 命名一致
RPC 方法应该遵循一致的命名约定 - 这使得查找方法更容易,因为你可以推断出你正在寻找的名称。它还可以让代码具有自文档化的能力 - 如果方法明确说明它们的作用,那么理解 API 就会容易得多。
rpc GetFoo (GetFooRequest) returns (GetFooResponse);
rpc GetBar (GetBarRequest) returns (GetBarResponse);
rpc CreateOrUpdateFoo (CreateOrUpdateFooRequest) returns (CreateOrUpdateFooResponse);
rpc CreateOrUpdateBar (CreateOrUpdateBarRequest) returns (CreateOrUpdateBarResponse);
你遵循的特定约定实际上并不重要 - 更重要的是你遵循一个约定,这样服务的消费者就不会太难找到正确的方法去调用。
另一个需要注意的是,不要根据消息包含的内容来命名消息。这可能会在以后造成问题,因为 protobuf 消息会随着时间的推移而改变,你很容易陷入一种情况,名称与对象包含的内容不再匹配。
message FooIdAndName { // bad
int64 id = 1;
string name = 2;
}
message FooObject { // better - at some point Foo will have more
int64 id = 1;
string name = 2;
}
2. 在 API 层强制执行数据结构
Protobuf 消息定义了结构化对象。作为服务所有者,你可以确保收到的数据被解析为特定的结构。作为服务消费者(即作为客户端),你可以确保响应返回时会被解析为具有你所期望的字段的特定结构。
message GetFooRequest {
int64 fooId = 1;
}
message GetFooResponse {
string fooName = 1;
boolean active = 2;
repeated string tags = 3;
在上面的例子中,服务所有者始终知道一个 GetFooRequest 将带有一个名为"fooId"的字段,它是一个64位整数。客户端知道它将收到一个包含特定字段及其特定类型的响应。这种强大的规范对双方都有利。
诱人的做法是忽略结构,将一个序列化的对象塞进单个字符串或字节字段中,但这会抵消使用 protocol buffer 的许多好处。例如,protobuf 允许通过添加和删除字段来进行扩展(只要你不重用被删除字段的字段编号),但如果你实现自己的序列化,这就会成为一个你必须自己处理的问题。利用 protobuf 中不同的字段类型可以让我们交给它来处理序列化和解析,而保持我们的消息结构化则为 API 输入和输出提供了明确性。
3. 为 RPC 请求和响应使用唯一的消息
Protobuf 中的 RPC 定义包含一个请求和一个响应。你可能会有重用输入或输出消息的诱惑,这可能会节省一些初始设置时间,但重要的是要记住,API 可能会随着时间的推移而改变,你可能不希望将两个独立的 RPC 调用紧密耦合在一起。如果你最终在多个 RPC 调用中重用了相同的消息对象,你可能会陷入这样一种状态:对于一个调用,某些字段被设置了,但在另一个调用中被忽略了。这使得 RPC 客户端的生活变得更加困难,因为他们需要考虑消息对象拥有哪些字段与实际需要设置的字段。服务器端也会更加困难,因为它需要知道在某些情况下忽略某些字段,例如,如果使用消息来更新数据库中的一行。
// don't do this!
message CreateFoo (Foo) returns (Foo);
message SetFooName (Foo) returns (Foo);
message DeactivateFoo (Foo) returns (Foo);
message Foo {
int64 id = 1; // set when returned but ignored when creating
boolean fooActive = 2; // ignored in CreateFoo/DeactivateFoo
string name = 3; // ignored in DeactivateFoo
}
相反,如果你为每个 RPC 定义使用专用的消息,你就可以独立地随时间发展它们。
message CreateFoo (CreateFooRequest) returns (CreateFooResponse);
message SetFooName (SetFooNameRequest) returns (SetFooNameResponse);
message DeactivateFoo (DeactivateFooRequest) returns (DeactivateFooResponse);
message CreateFooRequest {
string name = 1;
}
message CreateFooResponse {
int64 id = 1;
string name = 2;
bool active = 3;
}
message SetFooNameRequest {
int64 id = 1;
string name = 2;
}
message SetFooNameResponse {
}
message DeactivateFooRequest {
int64 id = 1;
}
message DeactivateFooResponse {
}
在上面的例子中,如果你想为审计目的在 DeactivateFoo 调用中添加用户名,你可以毫无问题地将其添加到 DeactivateFooRequest 中,因为 DeactivateFooRequest 是专门为该用例而设计的。
需要注意的一个重要点是,保持你的消息结构简单。如果你的消息结构开始演变为有几个可选字段,你应该停下来思考是否需要通过一个单独的 RPC 调用来提供该功能,并使用自己的专用消息定义。一个有许多可选字段的结构会变得很难记录,所以大多数时候你真的需要将功能分解为不同的 RPC 方法。
4. 广泛使用结构体
Protocol Buffer 的一个优点是支持添加字段而不破坏现有客户端。这允许在现有 RPC 方法中添加功能和可选特性,而无需重新实现该方法。但是,为了利用这一特性,你必须谨慎地在可能需要添加字段的地方使用结构体。
rpc GetFooHistory (GetFooHistoryRequest) returns (GetFooHistoryResponse)
message GetFooHistoryResponse {
repeated FooHistoryResponseItem items = 1;
string cursor = 2;
}
message FooHistoryResponseItem {
int64 fooId;
}
在上面的例子中,我们为重复的项目使用结构体,而不是普通的 int64,因为这允许我们将来为响应项目列表添加更多字段。例如,如果我们想为每个项目添加 foo 名称,我们只需将其添加到 FooHistoryResponseItem 结构中,它就会与 fooId 一起可用。如果我们使用了原始的 repeated int64,这是不可能的。
这里还有一个重要的观点 - protobuf 允许你使用 google.protobuf.Empty 消息来表示一个空的请求或响应。虽然这一开始看起来很方便,但它将你困在一个角落,在未来无法扩展该 RPC 方法 - 你无法向其添加字段,所以该方法在其整个生命周期内基本上都只能保持为空状态。为了保证你的服务定义面向未来,请使用一个专用的消息,即使一开始它是空的。这给了你在未来扩展它的灵活性。
5. 不要让一个结构体中的一个字段影响另一个字段的含义
Protocol Buffer 消息通常有多个字段。这些字段应该永远彼此独立 - 你不应该让一个字段影响另一个字段的语义含义。
// don't do this!
message Foo {
int64 timestamp = 1;
bool timestampIsCreated; // true means timestamp is created time,
// false means that it is updated time
}
这会造成混乱 - 客户端现在需要特殊的逻辑来知道如何根据另一个字段来解释一个字段。相反,使用多个字段,或者使用 protobuf 的"oneof"特性。
// better, but still not ideal because the fields are mutually
// exclusive - only one will be set
message Foo {
int64 createdTimestamp;
int64 updatedTimestamp;
}
// this is ideal; one will be set, and that is enforced by protobuf
message Foo {
oneof timestamp {
int64 created;
int64 updated;
}
}
6. 枚举定义应该在0位置有一个"未知"值
Protobuf 不支持"空"枚举的概念 - 获取枚举字段的值总会返回某个值,即使该值从未被调用者/接收者设置过。
// don't do this
enum ChangeType {
CREATE = 0;
UPDATE = 1;
DELETE = 2;
}
message Foo {
ChangeType changeType = 1;
}
如果创建 Foo 实例的人没有设置 changeType,那么使用该 Foo 实例的客户端将看到一个默认值。对于枚举,protobuf 默认为序号为0的枚举常量 - 所以在上面的例子中,客户端将看到 CREATE 值。
因此,一个常见的做法是在位置0创建一个"未知"枚举类型,如下所示:
enum ChangeType {
UNKNOWN_CHANGE_TYPE = 0;
CREATE = 1;
UPDATE = 2;
DELETE = 3;
}
这意味着,如果值没有被显式设置(为非UNKNOWN_CHANGE_TYPE值),客户端将看到UNKNOWN_CHANGE_TYPE。
顺便说一下,protobuf 的作用域规则规定枚举常量值在一个 protobuf 文件中必须是唯一的,因此将其命名为 UNKNOWN_CHANGE_TYPE 而不是 UNKNOWN。