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);
}

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

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

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

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