Netflix实用API设计2:Protobuf FieldMask变更操作


背景

在我们之前的文章中,我们讨论了在设计API时如何利用FieldMask作为解决方案,以便消费者可以按需通过gRPC获取请求的数据。在这篇博文中,我们将继续介绍Netflix Studio Engineering如何使用FieldMask进行更新和删除等变更操作。

案例:Netflix Studio Production

之前我们概述了什么是Production以及Production服务如何对其他微服务进行gRPC调用,例如Schedule服务和Script服务,它们是用于检索特定产品(例如 La Casa De Papel)的schedule和script。我们可以采用该模型并展示我们如何改变产品中的特定字段。

修改产品详细信息

假设我们作品添加了一些动画元素,想要将format字段从LIVE_ACTION更新为HYBRID。一个比较笨的方法是添加一个updateProductionFormatRequest方法,然后gRPC服务端来更新productionFormat:
message UpdateProductionFormatRequest {
string id = 1;
ProductionFormat format = 2;
}

service ProductionService {
rpc UpdateProductionFormat (UpdateProductionFormatRequest) 
  returns (UpdateProductionFormatResponse);


这的确可以让我们更新特定产品格式(format),但是如果我们想要更新其他字段(例如标题)或多个字段(例如 productionFormat、schedule 等),该怎么办?按照这个处理方法,我们应该为每个字段实现一个更新方法:一个用于产品格式,另一个用于标题等等:
// separate RPC for every field, not recommended
service ProductionService {
rpc UpdateProductionFormat (UpdateProductionFormatRequest) {...}

rpc UpdateProductionTitle (UpdateProductionTitleRequest) {...}

rpc UpdateProductionSchedule (UpdateProductionScheduleRequest) {...}

rpc UpdateProductionScripts (UpdateProductionScriptsRequest) {...}
}

message UpdateProductionFormatRequest {...}

message UpdateProductionTitleRequest {...}

message UpdateProductionScheduleRequest {...}

message UpdateProductionScriptsRequest {...} 

当产品中的字段数量很多时,对API的维护将会变得很难。如果我们想要更新多个字段并在单个RPC中以原子方式进行怎么办?为各种字段组合创建额外的方法将会导致API的爆炸式增长。此解决方案不可扩展。

与其尝试创建每个可能的组合,另一种解决方案是服务端提供UpdateProduction接口,该接口需要消费者提供所有字段:
message Production {
string id = 1;
string title = 2;
ProductionFormat format = 3;
repeated ProductionScript scripts = 4;
ProductionSchedule schedule = 5;
// ... more fields
}

service ProductionService {
rpc UpdateProduction (UpdateProductionRequest) returns (UpdateProductionResponse);
}

message UpdateProductionRequest {
Production production = 1;


此解决方案存在两个问题,首先消费者必须知道并提供Production中的每个必需字段,即使他们只想更新一个字段(例如format)。另一个问题是,由于Production有许多字段,因此请求负载可能会变得非常大,特别是Production有schedule或scripts信息。

如果我们只发送我们实际想要更新的字段而不是所有字段,同时让所有其他字段处于未设置状态,该怎么办?在我们的示例中,我们将只设置产品实例的format字段(和用于引用产品实例的ID ):
UpdateProduction updateProduction = UpdateProduction.newBuilder()
.setProductionFormat(PRODUCTION_FORMAT_HYBRID)
.build();

// Send the update request
UpdateProductionResponse response = client.updateProduction(LA_CASA_DE_PAPEL_PRODUCTION_ID, 
updateProductionRequest);

如果我们永远不需要删除或清空任何字段,那么这种方案倒是没有问题。 但是如果我们想删除title字段的值呢?同样,我们可以引入像RemoveProductionTitle这样的方法,但如上所述,这个解决方案不能很好地扩展。如果我们想从schedule中删除嵌套字段的值,例如计划启动日期字段,该怎么办?我们最终会为每个单独的可以为空的子字段添加相应的删除 RPC。

利用FieldMask做变更

我们可以利用FieldMask用于变更操作,而不是使用大量RPC或需要大的请求负载。FieldMask将列出我们想要明确更新的所有字段。首先,让我们更新我们的proto文件以添加到UpdateProductionRequest中,它将包含我们想要从一个产品实例中更新的数据,以及应该更新的FieldMask:
message ProductionUpdateOperation {
string production_id = 1;
string title = 2;
ProductionFormat format = 3;
ProductionSchedule schedule = 4;
repeated ProductionScript scripts = 5;
... // more fields
}

message UpdateProductionRequest {
// contains production ID and fields to be updated
ProductionUpdateOperation update = 1;
google.protobuf.FieldMask update_mask = 2;


现在,我们可以使用FieldMask来进行变更操作。我们可以通过使用FieldMaskUtil.fromStringList()方法为format字段创建一个FieldMask来更新格式,该方法为特定类型的字段路径列表构造一个FieldMask。在这种情况下,我们将有一种类型,但稍后将基于此示例进行构建:
FieldMask updateFieldMask = FieldMaskUtil.fromStringList(Production.class, 
Collections.singletonList(“format”);

// Update the production format type
ProductionUpdateOperation productionUpdateOperation = ProductionUpdateOperation
.newBuilder()
.setProductionId(LA_CASA_DE_PAPEL_PRODUCTION_ID)
.setProductionFormat(PRODUCTION_FORMAT_HYBRID)
.build();

// Build the UpdateProductionRequest including the updatefieldmask
UpdateProductionRequest updateProductionRequest = UpdateProductionRequest
.newBuilder()
.setUpdate(productionUpdateOperation)
.setUpdateMask(updateFieldMask)
.build();

// Send the update request
UpdateProductionResponse response = 
client.updateProduction(LA_CASA_DE_PAPEL_PRODUCTION_ID, updateProductionRequest);

由于我们的FieldMask仅指定format字段,即使我们在ProductionUpdateOperation中提供更多数据,该字段也将是唯一更新的字段。通过修改路径,可以更轻松地向我们的FieldMask添加或删除更多字段。 所有未添加到FieldMask路径中的数据将不会被更新并且在操作中会被忽略。另外,如果我们省略一个值同时FieldMask存在该字段,那么将会对该字段执行删除操作。让我们修改上面的示例来演示这一点:更新格式,同时删除计划启动日期,这是ProductionSchedule上的嵌套字段“schedule.planned_launch_date”:
FieldMask updateFieldMask = FieldMaskUtil.fromStringList(Production.class,
Arrays.asList("format", "schedule.planned_launch_date"));

// Update the format, in addition remove schedule.planned_launch_date by not including it in our request
ProductionUpdateOperation productionUpdateOperation = ProductionUpdateOperation
.newBuilder()
.setProductionId(LA_CASA_DE_PAPEL_PRODUCTION_ID)
.setProductionFormat(PRODUCTION_FORMAT_HYBRID)   
.build();

UpdateProductionRequest updateProductionRequest = UpdateProductionRequest
.newBuilder()
.setUpdate(productionUpdateOperation)
.setUpdateMask(updateFieldMask)
.build();

// Send the update request
UpdateProductionResponse response = 
client.updateProduction(LA_CASA_DE_PAPEL_PRODUCTION_ID, updateProductionRequest);

在这个例子中,我们向FieldMask添加了“format”和“schedule.planned_launch_date”路径,那么就会执行更新和删除操作。当我们在负载数据中提供这些字段时,这些字段将更新为新值,但在构建负载数据时,如果我们仅提供format并省略schedule.planned_launch_date,那么 这将表示我们要对该省略的字段执行删除操作:
1.jpg

空/缺失Field Mask

当FieldMask未设置或没有路径时,更新操作将作用于所有负载字段。这意味着调用者必须发送整个负载字段,或者如上所述,任何未设置的字段都将被删除。

这个约定对模式演化有影响:当一个新字段被添加到消息中时,所有消费者必须在更新操作中发送它的值,否则它将被删除。

假设我们要添加一个新字段:产品预算。我们需要扩展Production消息和ProductionUpdateOperation:
// update operation with new ‘budget’ field
message ProductionUpdateOperation {
string production_id = 1;
string title = 2;
ProductionFormat format = 3;
ProductionSchedule schedule = 4;
repeated ProductionScript scripts = 5;
ProductionBudget budget = 6;            // new field


如果有一个消费者不知道这个新字段或者还没有更新客户端存根(stubs),它可能会意外地通过在更新请求中不发送这个FieldMask将预算字段清空。

为了避免这个问题,生产者可以考虑要求所有更新操作都设置该字段掩码。另一种选择是实现版本控制协议:强制所有调用者发送他们的版本号并实现自定义逻辑来跳过旧版本中不存在的字段。

总结

在本博文系列中,我们讨论了在Netflix如何使用FieldMask,以及在设计API时如何使它成为实用且可扩展的解决方案。

API设计者应该以简单为目标,同时具有很好的扩展和演化性。保持API简单且面向未来通常并不容易。在API中使用FieldMask有助于我们实现简单性和灵活性。

原文链接:Practical API Design at Netflix, Part 2: Protobuf FieldMask for Mutation Operations(翻译:王欢)

0 个评论

要回复文章请先登录注册