Netflix实用API设计 1:Protobuf FieldMask实践
扫描二维码
随时随地手机看文章
背景
// The set of field mask paths.
repeated string paths = 1;
}
案例:Netflix Studio Production
message Production {
string id = 1;
string title = 2;
ProductionFormat format = 3;
repeated ProductionScript scripts = 4;
ProductionSchedule schedule = 5;
// ... more fields
}
service ProductionService {
// returns Production by ID
rpc GetProduction (GetProductionRequest) returns (GetProductionResponse);
}
message GetProductionRequest {
string production_id = 1;
}
message GetProductionResponse {
Production production = 1;
}
读取 Production 详细信息
假设我们想要使用 GetProduction API 获取特定 production 的信息,例如“La Casa De Papel”。虽然 production 有许多字段,但其中一些字段是从其他服务返回的,例如来自 Schedule 服务的 schedule 或来自 Script 服务的 scripts。
每次调用 GetProduction 时,Production 服务都会向 Schedule 和 Script 服务发出 RPC,即使客户端忽略响应中的 schedule 和 scripts 字段。如上所述,远程调用是有代价的。如果服务知道哪些字段对调用者很重要,它可以在是否进行昂贵的调用、启动资源密集型计算和/或调用数据库这些事中做出明智的决定。在这个例子中,如果调用者只需要标题和格式两个字段,Production 服务可以避免远程调用 Schedule 和 Script 服务。
此外,请求大量字段会使响应负载变得庞大。对某些应用程序来说可能是个问题,例如,在网络带宽有限的移动设备上。在这些情况下,消费者只请求他们需要的字段是一种很好的做法。
一个比较笨的解决方法是添加额外的请求参数,例如 includeSchedule 和 includeScripts:// Request with one-off "include" fields, not recommended
message GetProductionRequest {
string production_id = 1;
bool include_format = 2;
bool include_schedule = 3;
bool include_scripts = 4;
}
将 FieldMask 添加到请求消息中
API 设计者可以将 field_mask 字段添加到请求消息中,而不是创建一次性的“包含”字段:import "google/protobuf/field_mask.proto";
message GetProductionRequest {
string production_id = 1;
google.protobuf.FieldMask field_mask = 2;
}
.addPaths("title")
.addPaths("format")
.build();
GetProductionRequest request = GetProductionRequest.newBuilder()
.setProductionId(LA_CASA_DE_PAPEL_PRODUCTION_ID)
.setFieldMask(fieldMask)
.build();
请注意,即使本博文中的代码示例是用 Java 编写的,演示的概念也适用于任何支持 protocol buffers 的其他语言。
如果消费者只需要最后一个更新日程表的人的标题和电子邮件,他们可以设置不同的字段掩码:FieldMask fieldMask = FieldMask.newBuilder()
.addPaths("title")
.addPaths("schedule.last_updated_by.email")
.build();
GetProductionRequest request = GetProductionRequest.newBuilder()
.setProductionId(LA_CASA_DE_PAPEL_PRODUCTION_ID)
.setFieldMask(fieldMask)
.build();
Protobuf 字段名称与字段编号
你可能会注意到 FieldMask 中的路径是使用字段名称指定的,而在传输中,编码的 protocol buffers 消息仅包含字段编号,而不包含字段名称。这(以及其他一些技术,如用于签名类型的 ZigZag[5] 编码)会让 protobuf 消息节省空间。
为了理解字段编号和字段名称之间的区别,让我们详细了解一下 protobuf 是如何编码和解码消息的。
我们的 protobuf 消息定义(.proto 文件)包含一个具有五个字段的 Production 消息。每个字段都有一个类型、名称和编号。// Message with Production-related information
message Production {
string id = 1;
string title = 2;
ProductionFormat format = 3;
repeated ProductionScript scripts = 4;
ProductionSchedule schedule = 5;
}
如上所述,FieldMask 列出字段名称,而不是数字。在 Netflix,我们使用字段编号并使用 FieldMaskUtil.fromFieldNumbers()[6] 方法将它们转换为字段名称。此方法利用编译的消息定义将字段编号转换为字段名称并创建 FieldMask。FieldMask fieldMask = FieldMaskUtil.fromFieldNumbers(Production.class,
Production.TITLE_FIELD_NUMBER,
Production.FORMAT_FIELD_NUMBER);
GetProductionRequest request = GetProductionRequest.newBuilder()
.setProductionId(LA_CASA_DE_PAPEL_PRODUCTION_ID)
.setFieldMask(fieldMask)
.build();
假设我们要将字段 title 重命名为 title_name 并发布消息定义的 2.0 版:// version 2.0, with title field renamed to title_name
message Production {
string id = 1;
string title_name = 2; // this field used to be "title"
ProductionFormat format = 3;
repeated ProductionScript scripts = 4;
ProductionSchedule schedule = 5;
}
在此图表中,生产者(服务器)使用新的描述符,字段编号 2 名为 title_name。传输发送的二进制消息包含字段编号及其值。消费者仍然使用原始描述符,其中字段编号 2 作为标题。它仍然能够通过字段号对消息进行解码。
如果消费者不使用 FieldMask 来请求字段,那倒是没问题。如果消费者使用 FieldMask 字段中的“title”路径进行调用,生产者将无法找到该字段。生产者在其描述符中没有名为 title 的字段,因此它不知道消费者请求的字段编号为 2。
如我们所见,如果一个字段被重命名,后端应该能够支持新旧字段名称,直到所有调用者都迁移到新字段名称(向后兼容性问题)。
有多种方法可以处理此限制:
-
使用 FieldMask 时切勿重命名字段。这是最简单的解决方案,但并非总是可行
-
要求后端支持所有旧的字段名称。这解决了向后兼容性问题,但需要后端额外的代码来跟踪所有历史字段名称
-
弃用旧字段并创建新字段而不是重命名。在我们的示例中,我们将创建 title_name 字段编号 6。此选项比前一个有一些优点:它允许生产者继续使用生成的描述符而不是自定义转换器;此外,弃用一个字段在消费者端影响更大
string id = 1;
string title = 2 [deprecated = true]; // use "title_name" field instead
ProductionFormat format = 3;
repeated ProductionScript scripts = 4;
ProductionSchedule schedule = 5;
string title_name = 6;
}
在生产者(服务器)端使用 FieldMask
在生产者(服务器)端,可以使用 FieldMaskUtil.merge()[7] 方法(8 和 9 行)从响应负载中删除不必要的字段:@Override
public void getProduction(GetProductionRequest request,
StreamObserverresponse) {
Production production = fetchProduction(request.getProductionId());
FieldMask fieldMask = request.getFieldMask();
Production.Builder productionWithMaskedFields = Production.newBuilder();
FieldMaskUtil.merge(fieldMask, production, productionWithMaskedFields);
GetProductionResponse response = GetProductionResponse.newBuilder()
.setProduction(productionWithMaskedFields).build();
responseObserver.onNext(response);
responseObserver.onCompleted();
}
private static final String MAX_FIELD_NESTING = 2;
private static final String SCHEDULE_FIELD_NAME = // (1)
Production.getDescriptor()
.findFieldByNumber(Production.SCHEDULE_FIELD_NUMBER).getName();
@Override
public void getProduction(GetProductionRequest request,
StreamObserverresponse) {
FieldMask canonicalFieldMask =
FieldMaskUtil.normalize(request.getFieldMask()); // (2)
boolean scheduleFieldRequested = // (3)
canonicalFieldMask.getPathsList().stream()
.map(path -> path.split(FIELD_SEPARATOR_REGEX, MAX_FIELD_NESTING)[0])
.anyMatch(SCHEDULE_FIELD_NAME::equals);
if (scheduleFieldRequested) {
ProductionSchedule schedule =
makeExpensiveCallToScheduleService(request.getProductionId()); // (4)
...
}
...
}
-
SCHEDULE_FIELD_NAME 常量包含字段的名称。此代码示例使用消息类型 Descriptor[8] 和 FieldDescriptor[9] 通过字段编号查找字段名称。protobuf 字段名称和字段编号之间的区别在上面的 Protobuf 字段名称与字段编号部分进行了描述。
-
FieldMaskUtil.normalize()[10] 返回具有按字母顺序排序和去重的字段路径(又名规范形式)的 FieldMask。
-
scheduleFieldRequestedvalue 表达式(第14 - 17 行)采用 FieldMask 路径流,将其映射到顶级(top-level)字段流,如果顶级字段包含 SCHEDULE_FIELD_NAME 常量的值,则返回 true。
-
仅当 scheduleFieldRequested 为真时才检索 ProductionSchedule。
如果你决定将 FieldMask 用于不同的消息和字段,请考虑创建可重用的实用封装方法。例如,基于 FieldMask 和 FieldDescriptor 返回所有顶级字段的方法,如果字段存在于 FieldMask 中则返回的方法等。
/**
* Can be used in {@link GetProductionRequest} to query
* production title and format
*/
public static final FieldMask TITLE_AND_FORMAT_FIELD_MASK =
FieldMaskUtil.fromFieldNumbers(Production.class,
Production.TITLE_FIELD_NUMBER, Production.FORMAT_FIELD_NUMBER);
/**
* Can be used in {@link GetProductionRequest} to query
* production title and schedule
*/
public static final FieldMask TITLE_AND_SCHEDULE_FIELD_MASK =
FieldMaskUtil.fromFieldNumbers(Production.class,
Production.TITLE_FIELD_NUMBER,
Production.SCHEDULE_FIELD_NUMBER);
/**
* Can be used in {@link GetProductionRequest} to query
* production title and scripts
*/
public static final FieldMask TITLE_AND_SCRIPTS_FIELD_MASK =
FieldMaskUtil.fromFieldNumbers(Production.class,
Production.TITLE_FIELD_NUMBER, Production.SCRIPTS_FIELD_NUMBER);
}
-
使用 FieldMask 会限制重命名消息字段的能力(在 Protobuf 字段名称与字段编号部分中描述)
-
重复字段只允许出现在路径字符串的最后一个位置。这意味着你不能在列表内的消息中选择(屏蔽)单个子字段。这在可预见的未来可能会发生变化,因为最近批准的 Google API 改进提案 AIP-161 字段掩码[11]包括对重复字段的通配符的支持。
这篇博文介绍了 Netflix Studio Engineering 如何以及为何将其用于读取数据的 API。第 2 部分将阐明使用 FieldMask 进行更新和删除操作。