1-1-1 最佳实践

所有的 proto 定义都应该遵循每个文件一个顶层元素和一个构建目标的原则。

“1-1-1”最佳实践倡导每个 .proto 文件只定义一个顶层实体(消息、枚举或扩展),并对应一个 proto_library 构建规则。这种方法可以促进小型、模块化的 proto 定义。其主要优点包括:简化重构、可能改善构建时间,以及通过最小化传递性依赖来减小二进制文件的大小。


1-1-1 最佳实践是让每个 proto_library 和 .proto 文件在合理范围内尽可能小,理想状态是:

  • 一个 proto_library 构建规则
  • 一个源 .proto 文件
  • 一个顶层实体(消息、枚举或扩展)

在合理范围内,让消息、枚举、扩展和服务的数量最少,可以使重构变得更容易。当文件分离时,移动文件比从一个包含其他消息的文件中提取消息要容易得多。

遵循这种实践有助于缩短构建时间和减小二进制文件大小,因为它实际上减少了传递性依赖的规模:当某些代码只需要使用一个枚举时,在 1-1-1 设计下,它只需依赖定义该枚举的 .proto 文件,从而避免了意外引入大量可能仅由同一文件中其他消息使用的传递性依赖。

在某些情况下,1-1-1 的理想状态可能无法实现(例如存在循环依赖),或者并非最佳选择(例如概念上紧密耦合的消息放在一起可以提高可读性),又或者某些缺点并不适用(例如当一个 .proto 文件没有导入时,就不存在传递性依赖规模的技术问题)。与任何最佳实践一样,何时偏离该准则需要运用良好的判断力。

在创建 gRPC 定义时,proto 模式文件的模块化非常重要。下面这组 proto 文件展示了模块化的结构。

student_id.proto

edition = "2023";

package my.package;

message StudentId {
  string value = 1;
}

full_name.proto

edition = "2023";

package my.package;

message FullName {
  string family_name = 1;
  string given_name = 2;
}

student.proto

edition = "2023";

package my.package;

import "student_id.proto";
import "full_name.proto";

message Student {
  StudentId id = 1;
  FullName name = 2;
}

create_student_request.proto

edition = "2023";

package my.package;

import "full_name.proto";

message CreateStudentRequest {
  FullName name = 1;
}

create_student_response.proto

edition = "2023";

package my.package;

import "student.proto";

message CreateStudentResponse {
  Student student = 1;
}

get_student_request.proto

edition = "2023";

package my.package;

import "student_id.proto";

message GetStudentRequest {
  StudentId id = 1;
}

get_student_response.proto

edition = "2023";

package my.package;

import "student.proto";

message GetStudentResponse {
  Student student = 1;
}

student_service.proto

edition = "2023";

package my.package;

import "create_student_request.proto";
import "create_student_response.proto";
import "get_student_request.proto";
import "get_student_response.proto";

service StudentService {
  rpc CreateStudent(CreateStudentRequest) returns (CreateStudentResponse);
  rpc GetStudent(GetStudentRequest) returns (GetStudentResponse);
}

服务定义和每个消息定义都各自位于独立的文件中,你可以使用 include 语句来从其他模式文件中访问这些消息。

在这个例子中,StudentStudentIdFullName 是可在请求和响应中复用的领域类型。而顶层的请求和响应 proto 对于每个服务+方法都是唯一的。

如果之后需要向 FullName 消息添加一个 middle_name 字段,你无需更新每一个单独的顶层消息。同样,如果需要用更多信息来更新 Student,所有的请求和响应都会获得更新。此外,StudentId 也可能更新为一个多部分的 ID。

最后,即使是像 StudentId 这样的简单类型,将其包装成一个消息也意味着你创建了一个具有语义和统一文档的类型。对于像 FullName 这样的类型,你需要小心处理这些个人身份信息 (PII) 的记录位置;这也是不在多个顶层消息中重复这些字段的另一个优势。你可以在一个地方将这些字段标记为敏感数据,并将其从日志中排除。