Netflix实用API设计 1:Protobuf FieldMask实践

Netflix通过在gRPC API中应用protobuf FieldMask来优化后端通信,减少不必要的计算和远程调用。FieldMask允许客户端指定感兴趣的响应字段,避免加载不必要的数据,提高效率并降低延迟。文章介绍了如何在服务端使用FieldMask筛选响应字段,以及处理字段重命名的限制,并提供了预编译的FieldMask以简化常见场景的API使用。
摘要由CSDN通过智能技术生成

b0f85e4b3bbdbc14ed904c1e4d5325ea.png

背景

3fe3ea7b575002ca039a1bfdced9a85d.png

在 Netflix,我们大量使用 gRPC 来实现后端到后端的通信。当我们处理请求时,知道调用者对哪些字段感兴趣以及忽略哪些字段通常是有益的。某些响应字段的计算成本可能很高,某些字段可能需要远程调用其他服务。远程调用都是有代价的;它们会带来额外的延迟,增加出错的可能性,并消耗网络带宽。那么该如何知道响应中哪些字段不需要提供给调用者,从而避免进行不必要的计算以及远程调用?使用 GraphQL,这是通过使用字段选择器来实现的。在 JSON:API 标准中,类似的技术称为稀疏字段集[1]。在设计 gRPC API 时,我们如何实现类似的功能?我们在 Netflix Studio Engineering 中使用的解决方案是 protobuf FieldMask[2]。

Protobuf FieldMask

c459f5299ba5bb65e615c945444fa92b.png

Protocol Buffers[3],或简称为 protobuf,是一种数据序列化机制。默认情况下,gRPC 使用 protobuf 作为其 IDL(接口定义语言)和数据序列化协议。FieldMask 是一个 protobuf 消息。当此消息出现在 RPC 请求中时,有关如何使用此消息有许多实用工具(utilities)和约定。FieldMask 消息包含一个名为 paths 的字段,它用于指定字段,这些字段可以由读操作返回或由更新操作来修改。

message FieldMask {
  // The set of field mask paths.
  repeated string paths = 1;
}

案例:Netflix Studio Production

c6bca9b788643ea494e38acd6239c8b3.png

假设有一个 Production 服务来管理 Studio Content Productions(在电影和电视行业中,术语 production[4] 是指制作电影的过程,而不是运行软件的环境)。

// Contains Production-related information  
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;
}

GetProduction 通过唯一 ID 返回 Production 消息。一个 production 包含多个字段,例如:标题、格式、日程安排日期、脚本又名剧本、预算、剧集等,但让我们保持这个例子简单,并在请求 production时重点过滤日程安排日期和脚本。

读取 Production 详细信息

假设我们想要使用 GetProduction API 获取特定 production 的信息,例如“La Casa De Papel”。虽然 production 有许多字段,但其中一些字段是从其他服务返回的,例如来自 Schedule 服务的 schedule 或来自 Script 服务的 scripts。

66414caa5f0d2640782f6101f4e3f1c2.png

每次调用 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;
}

这种方法需要为每个昂贵的响应字段添加一个自定义的 includeXXX 字段,并且不适用于嵌套字段。它还增加了请求的复杂性,最终使维护和支持更具挑战性。

将 FieldMask 添加到请求消息中

API 设计者可以将 field_mask 字段添加到请求消息中,而不是创建一次性的“包含”字段:

import "google/protobuf/field_mask.proto";

message GetProductionRequest {
  string production_id = 1;
  google.protobuf.FieldMask field_mask = 2;
}

消费者可以为他们希望在响应中收到的字段设置路径。如果消费者只对标题和格式感兴趣,他们可以设置带有“title”和“format”路径的 FieldMask:

FieldMask fieldMask = FieldMask.newBuilder()
    .addPaths("title")
    .addPaths("format")
    .build();

GetProductionRequest request = GetProductionRequest.newBuilder()
    .setProductionId(LA_CASA_DE_PAPEL_PRODUCTION_ID)
    .setFieldMask(fieldMask)
    .build();

7aba0de968a12807d289e6d46103ca4b.png

请注意,即使本博文中的代码示例是用 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();

按照惯例,如果请求中不存在 FieldMask,则应返回所有字段。

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

当 protobuf 编译器(protoc)编译此消息定义时,它会以你选择的语言(在我们的示例中为 Java)创建代码。这个生成的代码包含定义消息的类,以及消息和字段描述符。描述符包含将消息编码和解码为其二进制格式所需的所有信息。例如,它们包含字段编号、名称、类型。消息生产者使用描述符将消息转换为传输格式。为提高效率,二进制消息仅包含字段数值对。不包括字段名称。当消费者收到消息时,它通过引用编译的消息定义将字节流解码为一个对象(例如,Java 对象)。

6f3a7ecf6dc35072d2191c015163422a.png

如上所述,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();

但是,有一个容易忽略的限制:使用 FieldMask 会限制你重命名消息字段的能力。重命名消息字段通常被认为是一种安全操作,因为如上所述,字段名称不会被传输发送的,而是使用消费者端的字段编号派生的。使用 FieldMask,字段名称会在消息负载中被发送出去(在路径字段值中)并且还是很重要的部分。

假设我们要将字段 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;
}

bfb75019f3e38752689daf71999de269.png

在此图表中,生产者(服务器)使用新的描述符,字段编号 2 名为 title_name。传输发送的二进制消息包含字段编号及其值。消费者仍然使用原始描述符,其中字段编号 2 作为标题。它仍然能够通过字段号对消息进行解码。

如果消费者不使用 FieldMask 来请求字段,那倒是没问题。如果消费者使用 FieldMask 字段中的“title”路径进行调用,生产者将无法找到该字段。生产者在其描述符中没有名为 title 的字段,因此它不知道消费者请求的字段编号为 2。

9c05cabb7b7d8450ac9eeeafad18085d.png

如我们所见,如果一个字段被重命名,后端应该能够支持新旧字段名称,直到所有调用者都迁移到新字段名称(向后兼容性问题)。

有多种方法可以处理此限制:

  • 使用 FieldMask 时切勿重命名字段。这是最简单的解决方案,但并非总是可行

  • 要求后端支持所有旧的字段名称。这解决了向后兼容性问题,但需要后端额外的代码来跟踪所有历史字段名称

  • 弃用旧字段并创建新字段而不是重命名。在我们的示例中,我们将创建 title_name 字段编号 6。此选项比前一个有一些优点:它允许生产者继续使用生成的描述符而不是自定义转换器;此外,弃用一个字段在消费者端影响更大

message Production {
  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 使字段名称成为 API 合约中不可或缺的一部分。

在生产者(服务器)端使用 FieldMask

在生产者(服务器)端,可以使用 FieldMaskUtil.merge()[7] 方法(8 和 9 行)从响应负载中删除不必要的字段:

@Override
public void getProduction(GetProductionRequest request, 
                          StreamObserver<GetProductionResponse> response) {
   
    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();
}

如果服务端代码还需要知道请求哪些字段以避免进行外部调用、数据库查询或昂贵的计算,则可以从 FieldMask 路径字段中获取此信息:

private static final String FIELD_SEPARATOR_REGEX = "\\.";
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, 
                          StreamObserver<GetProductionResponse> response) {

    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 字段被请求时调用 makeExpensiveCallToScheduleService 方法(第 21 行)。让我们更详细地探索这个代码示例。

  1. SCHEDULE_FIELD_NAME 常量包含字段的名称。此代码示例使用消息类型 Descriptor[8] 和 FieldDescriptor[9] 通过字段编号查找字段名称。protobuf 字段名称和字段编号之间的区别在上面的 Protobuf 字段名称与字段编号部分进行了描述。

  2. FieldMaskUtil.normalize()[10] 返回具有按字母顺序排序和去重的字段路径(又名规范形式)的 FieldMask。

  3. scheduleFieldRequestedvalue 表达式(第14 - 17 行)采用 FieldMask 路径流,将其映射到顶级(top-level)字段流,如果顶级字段包含 SCHEDULE_FIELD_NAME 常量的值,则返回 true。

  4. 仅当 scheduleFieldRequested 为真时才检索 ProductionSchedule。

如果你决定将 FieldMask 用于不同的消息和字段,请考虑创建可重用的实用封装方法。例如,基于 FieldMask 和 FieldDescriptor 返回所有顶级字段的方法,如果字段存在于 FieldMask 中则返回的方法等。

发布预编译的 FieldMask

be06d9c6f94762762eb97d3f4fc8d628.png

某些访问模式可能比其他访问模式更常见。如果多个消费者对同一字段子集感兴趣,API 生产者可以提供带有 FieldMask 的客户端库,用于最常用的字段组合。

public class ProductionFieldMasks {
    /**
     * 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);

}

提供预编译的字段掩码可以简化最常见场景的 API 使用,并使消费者能够灵活地为更具体的用例构建自己的字段掩码。

限制

0cc288163bd9da3463d0d5b7631219db.png

  • 使用 FieldMask 会限制重命名消息字段的能力(在 Protobuf 字段名称与字段编号部分中描述)

  • 重复字段只允许出现在路径字符串的最后一个位置。这意味着你不能在列表内的消息中选择(屏蔽)单个子字段。这在可预见的未来可能会发生变化,因为最近批准的 Google API 改进提案 AIP-161 字段掩码[11]包括对重复字段的通配符的支持。

总结

3a03ad531a0231dc69759042e195731b.png

Protobuf FieldMask 是一个简单但功能强大的概念。它可以帮助使 API 更健壮,服务实现更高效。

这篇博文介绍了 Netflix Studio Engineering 如何以及为何将其用于读取数据的 API。第 2 部分将阐明使用 FieldMask 进行更新和删除操作。

相关链接:

  1. https://jsonapi.org/format/#fetching-sparse-fieldsets

  2. https://developers.google.com/protocol-buffers/docs/reference/csharp/class/google/protobuf/well-known-types/field-mask

  3. https://developers.google.com/protocol-buffers

  4. https://en.wikipedia.org/wiki/Filmmaking

  5. https://en.wikipedia.org/wiki/Variable-length_quantity#Zigzag_encoding

  6. https://developers.google.com/protocol-buffers/docs/reference/java/com/google/protobuf/util/FieldMaskUtil.html#fromFieldNumbers-java.lang.Class-int...-

  7. https://developers.google.com/protocol-buffers/docs/reference/java/com/google/protobuf/util/FieldMaskUtil.html#merge-com.google.protobuf.FieldMask-com.google.protobuf.Message-com.google.protobuf.Message.

  8. https://developers.google.com/protocol-buffers/docs/reference/java/com/google/protobuf/Descriptors

  9. https://developers.google.com/protocol-buffers/docs/reference/java/com/google/protobuf/Descriptors.FieldDescriptor.html

  10. https://developers.google.com/protocol-buffers/docs/reference/java/com/google/protobuf/util/FieldMaskUtil.html#normalize-com.google.protobuf.

  11. https://google.aip.dev/16

原文链接:https://netflixtechblog.com/practical-api-design-at-netflix-part-1-using-protobuf-fieldmask-35cfdc606518

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值