Netflix 实用 API 设计 (上)

简介: Netflix 实用 API 设计 (上)

gRPC 如今被很多公司应用在大规模生产环境中,很多时候我们并不需要通过 RPC 请求所有数据,而只关心响应数据中的部分字段,Protobuf FieldMask 就可以帮助我们实现这一目的。本文介绍了 Netflix 基于 FieldMask 设计更高效健壮的 API 的实践,全文分两个部分,这是第一部分。原文:Practical API Design at Netflix, Part 1: Using Protobuf FieldMask[1]


背景


在 Netflix,我们大量使用 gRPC[2]进行后端通信。处理请求的时候,如果能够知道调用者对哪些字段感兴趣、哪些字段可以忽略,会非常有用。有些响应字段的计算代价可能很高,有些字段可能需要对其他服务进行远程调用。远程调用需要付出额外的开销:额外的延迟,更高的出错概率,并且消耗了网络带宽。我们怎么样才能理解哪些字段不需要在响应中提供给调用者,从而避免进行不必要的计算以及远程调用?在 GraphQL 中,可以通过字段选择器来实现。在 JSON:API 标准中,有一个类似的被称为 Sparse Fieldsets[3]的技术。我们在设计 gRPC API 的时候,能不能实现类似的功能?在 Netflix Studio Engineering[4],我们提供的解决方案是 protobuf FieldMask[5]


image.png


《纸钞屋》(La casa de papel) / Netflix


Protobuf FieldMask


Protocol Buffers[6],简称 protobuf,是一种数据序列化机制。默认情况下,gRPC 使用 protobuf 作为它的 IDL(接口定义语言)和数据序列化协议。


FieldMask 是一个 protobuf 消息,有许多实用工具和约定用来处理 RPC 请求中包含的 FieldMask。FieldMask 消息包含一个名为paths的字段,用于指定应该由读操作返回或由更新操作修改的字段。


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


示例:Netflix 工作室内容制作


image.png


《纸钞屋》(La casa de papel) / Netflix


我们假设有一个 Production 服务,可以用来管理工作室内容的生产(Studio Content Productions,在电影和电视行业中,术语 production[7]指的是制作电影的过程,而不是运行软件的环境)。


// 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 返回一个生产消息。一个作品包含多个字段,如:标题、格式、日程日期、脚本(又称剧本)、预算、情节等,不过我们将重点放在过滤日程日期和脚本上,这样可以让例子简单一点。


读取制作细节


假设我们想要使用GetProduction API 获取特定产品(如“纸钞屋”)的制作信息。虽然产品有很多字段,但有些字段是从其他服务获取的,比如 Schedule 服务的返回的schedule,或者 Script 服务返回的scripts


image.png


即使客户端忽略响应中的schedulescripts字段,Production 服务仍然需要在每次调用GetProduction时为 Schedule 和 Script 服务生成 RPC。如上所述,远程调用是有成本的。如果服务知道调用者真正关心哪些字段,就可以做出明智的决策,决定是否进行昂贵的调用、启动资源繁重的计算和/或调用数据库。在本例中,如果调用者只需要产品标题和产品格式,那么 Production 服务就可以避免对 Schedule 和 Script 服务进行远程调用。


此外,请求大量字段会使响应负载变得很大,而这对于带宽有限的移动应用来说,可能会造成问题。在这些情况下,消费者只请求他们需要的字段是一个很好的实践。


image.png

《纸钞屋》(La casa de papel) / Netflix


一个丑陋的解决这些问题的方法是添加额外的请求参数,如includeScheduleincludeScripts

// 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字段,而不是创建一次性的“include”字段:


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



image.png

Masking fields


请注意,尽管本文代码示例是基于 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 字段名 vs 字段号


你可能已经注意到,FieldMask 中的路径是基于字段名指定的,但实际上,编码后的 protocol buffers 消息只包含字段号,而不包含字段名,因此(以及其他技术,比如用于签名类型编码的 ZigZag[8])protobuf 消息的空间效率更高。


为了理解字段号和字段名之间的区别,让我们详细了解一下 protobuf 是如何编码和解码消息的。


Production 消息的 protobuf 消息定义(.proto 文件)含有五个字段,每个字段都有一个类型、名称和数字。


// 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 对象)。


image.png


如上所述,FieldMask 列出的是字段名,而不是数字。而在 Netflix,我们使用字段号,并通过 FieldMaskUtil.fromFieldNumbers()[9]辅助程序转换为字段名。


fromFieldNumbers 方法利用已编译的消息定义将字段号转换为字段名,并创建 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,字段名就会编码在消息的有效负载中被发送出去(在paths字段值中)。


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


image.png



在这个图中,生产者(服务器端)使用了新的描述符,字段 2 名为title_name。通过网络发送的二进制消息包含字段号及其值。消费者仍然使用原始的描述符,其中字段号 2 名为title,仍然能够通过字段号解码消息。


如果消费者不使用 FieldMask 请求字段,这种方法仍然可以工作的很好。不过一旦消费者使用 FieldMask 字段中的“title”路径进行调用,生产者将无法找到该字段。生产者的描述符中没有名为 title 的字段,所以不知道消费者要求的字段号是 2。


image.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()[10]方法从响应负载中删除不必要的字段(第 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 paths 字段获得相关信息:


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字段时调用makeExpensiveCallToScheduleServicemethod(第 21 行),让我们仔细看一下这段代码。


(1)SCHEDULE_FIELD_NAME常量包含字段名。在示例代码中使用消息类型 Descriptor[11]和 FieldDescriptor[12]按字段编号查找字段名。protobuf 字段名和字段号之间的区别请参考前面的介绍。

(2)FieldMaskUtil.normalize()[13]返回按字母顺序排序并删除了重复数据的 FieldMask(又称规范形式)。

(3)生成scheduleFieldRequested的表达式(第 14 - 17 行)接受 FieldMask 路径流,将其映射为顶级字段流,如果顶级字段中包含SCHEDULE_FIELD_NAME常量的值,则返回true

(4)只有当scheduleFieldRequestedtrue时,才会检索ProductionSchedule

如果需要为不同的消息和字段应用 FieldMask,可以考虑创建可重用的 helper 方法。例如,可以基于 FieldMask 和 FieldDescriptor 返回所有顶级字段的方法,判断字段是否出现在 FieldMask 中的方法,等等。


使用预构建的 FieldMask


有些访问模式可能比其他模式更常见,如果多个消费者都对同一个字段子集感兴趣,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 使用,并让消费者可以灵活的为更具体的用例构建自己的字段掩码。


限制


  • 使用 FieldMask 限制了重命名消息字段的能力
  • 重复字段只允许出现在路径字符串的最后一个位置,这意味着不能在列表中选择(屏蔽)单个子字段。在可预见的未来,这种情况可能会改变,因为最近批准的谷歌 API 改进建议 AIP-161 Field masks[14]包含了对重复字段的通配符支持。


最后


Protobuf FieldMask 是一个简单而强大的概念,有助于使 API 更健壮,服务实现更高效。


本文介绍了 Netflix Studio Engineering 如何以及为什么使用 FieldMask 来读取数据,下一篇文章将介绍如何使用 FieldMask 进行更新和删除操作。


目录
相关文章
|
JavaScript 前端开发 Java
11 个最佳的 Python 编译器和解释器
11 个最佳的 Python 编译器和解释器
1089 1
|
前端开发 测试技术 API
DDD领域驱动设计实战-分层架构及代码目录结构(上)
DDD领域驱动设计实战-分层架构及代码目录结构
2241 0
DDD领域驱动设计实战-分层架构及代码目录结构(上)
|
存储 设计模式 缓存
DDD领域驱动设计实战-分层架构及代码目录结构(下)
DDD领域驱动设计实战-分层架构及代码目录结构
2382 0
DDD领域驱动设计实战-分层架构及代码目录结构(下)
|
3月前
|
SQL 监控 Java
分布式事务解决方案Seata之AT事务
Seata AT模式是零侵入分布式事务方案,基于改进两阶段提交(2PC),通过自动代理数据源、拦截SQL、记录undo_log实现全局事务一致性,无需修改业务代码,仅需`@GlobalTransactional`注解即可快速接入。
612 3
分布式事务解决方案Seata之AT事务
|
9月前
|
机器学习/深度学习 人工智能 搜索推荐
拔俗AI学伴智能体系统:基于大模型与智能体架构的下一代个性化学习引擎
AI学伴智能体系统融合大模型、多模态理解与自主决策,打造具备思考能力的个性化学习伙伴。通过动态推理、长期记忆、任务规划与教学逻辑优化,实现千人千面的自适应教育,助力因材施教落地,推动教育公平与效率双提升。(238字)
1146 0
|
Java 开发工具 Android开发
Android与iOS开发环境搭建全解析####
本文深入探讨了Android与iOS两大移动操作系统的开发环境搭建流程,旨在为初学者及有一定基础的开发者提供详尽指南。我们将从开发工具的选择、环境配置到第一个简单应用的创建,一步步引导读者步入移动应用开发的殿堂。无论你是Android Studio的新手还是Xcode的探索者,本文都将为你扫清开发道路上的障碍,助你快速上手并享受跨平台移动开发的乐趣。 ####
|
Android开发 数据安全/隐私保护 虚拟化
安卓手机远程连接登录Windows服务器教程
安卓手机远程连接登录Windows服务器教程
3991 5
|
安全
猿大师办公助手在线编辑微软Office/金山wps网页组件COM加载项启用说明
猿大师办公助手是一款独特的在线编辑Office插件,不同于其他厂商的弹窗模式,它真正实现了网页内嵌本机Office。其COM加载项可在Office主菜单栏增加PageHi子菜单,提供文件保存、打印等功能,并能控制文档操作权限。安装后,默认自动启动COM加载项,但需注意可能被禁用或拦截,必要时需手动启用。对于WPS和微软Office,均有详细的启用步骤。
784 5
猿大师办公助手在线编辑微软Office/金山wps网页组件COM加载项启用说明
|
机器学习/深度学习 算法 编译器
Python程序到计算图一键转化,详解清华开源深度学习编译器MagPy
【10月更文挑战第26天】MagPy是一款由清华大学研发的开源深度学习编译器,可将Python程序一键转化为计算图,简化模型构建和优化过程。它支持多种深度学习框架,具备自动化、灵活性、优化性能好和易于扩展等特点,适用于模型构建、迁移、部署及教学研究。尽管MagPy具有诸多优势,但在算子支持、优化策略等方面仍面临挑战。
906 3
|
安全 算法 编译器
【C++ 静态断言的技巧】掌握C++中static_assert的力量:深入探讨编译时检查
【C++ 静态断言的技巧】掌握C++中static_assert的力量:深入探讨编译时检查
726 1