Netflix API 實(shí)踐(一): 使用FieldMask進(jìn)行查詢

背景

在 Netflix,我們大量使用 gRPC 來(lái)進(jìn)行后端到后端的通信。當(dāng)我們處理請(qǐng)求時(shí),了解調(diào)用者感興趣的字段以及他們忽略的字段通常是有益的。某些響應(yīng)字段的計(jì)算成本可能很高,某些字段可能需要遠(yuǎn)程調(diào)用其他服務(wù)。遠(yuǎn)程調(diào)用從來(lái)都不是免費(fèi)的;它們會(huì)帶來(lái)額外的延遲,增加出錯(cuò)的可能性,并消耗網(wǎng)絡(luò)帶寬。我們?nèi)绾瘟私獠恍枰獮檎{(diào)用者在響應(yīng)中提供哪些字段,從而避免進(jìn)行不必要的計(jì)算并減少調(diào)用?使用GragpQL的時(shí)候,可以通過(guò)字段選擇器來(lái)完成。在 JSON:API 標(biāo)準(zhǔn)中,一種類似的技術(shù)稱為稀疏字段集。在設(shè)計(jì) gRPC API 時(shí),我們?nèi)绾螌?shí)現(xiàn)類似的功能?我們?cè)?Netflix Studio Engineering 中使用的解決方案是 protobuf FieldMask

Protobuf FieldMask

Protocol Buffers,或簡(jiǎn)稱 protobuf,是一種數(shù)據(jù)序列化機(jī)制。默認(rèn)情況下,gRPC 使用 protobuf 作為其 IDL(接口定義語(yǔ)言)和數(shù)據(jù)序列化協(xié)議。

FieldMask 是一個(gè) protobuf 消息。當(dāng)消息出現(xiàn)在 RPC 請(qǐng)求中時(shí),有許多實(shí)用程序和約定來(lái)說(shuō)明如何使用此消息。 FieldMask 消息包含一個(gè)名為 paths 的字段,用于指定應(yīng)該由讀取操作返回或由更新操作修改的字段。

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

Example: Netflix Studio Production

假設(shè)有一個(gè)制作服務(wù)管理工作室內(nèi)容制作(在電影和電視行業(yè),制作一詞是指制作電影的過(guò)程,而不是運(yùn)行軟件的環(huán)境)。

// 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 通過(guò)其唯一 ID 返回生產(chǎn)消息。一個(gè)作品包含多個(gè)字段,例如:標(biāo)題、格式、時(shí)間表日期、腳本(也稱為劇本)、預(yù)算、劇集等,我們使用這個(gè)簡(jiǎn)單的示例,并在請(qǐng)求制作時(shí)專注于過(guò)濾掉時(shí)間表日期和腳本。

獲取產(chǎn)品細(xì)節(jié)

假設(shè)我們想要使用 GetProduction API 獲取特定作品的制作信息,例如“La Casa De Papel”。雖然作品有許多字段,但其中一些字段是從其他服務(wù)返回的,例如來(lái)自 Schedule 服務(wù)的 schedule 或來(lái)自 腳本服務(wù)的腳本。

每次調(diào)用 GetProduction 時(shí),即使客戶端忽略響應(yīng)中的調(diào)度和腳本字段,生產(chǎn)服務(wù)也會(huì)對(duì)調(diào)度和腳本服務(wù)進(jìn)行 RPC。上文提到,遠(yuǎn)程調(diào)用不是免費(fèi)的。如果服務(wù)知道哪些字段對(duì)調(diào)用者很重要,它可以就進(jìn)行昂貴的調(diào)用、啟動(dòng)資源密集型計(jì)算和/或調(diào)用數(shù)據(jù)庫(kù)做出明智的決定。在這個(gè)例子中,如果調(diào)用者只需要制作標(biāo)題和制作格式,制作服務(wù)可以避免遠(yuǎn)程調(diào)用 Schedule 和 Script 服務(wù)。

此外,請(qǐng)求大量字段會(huì)使響應(yīng)負(fù)載變得龐大。這可能會(huì)成為某些應(yīng)用程序的問(wèn)題,例如,在網(wǎng)絡(luò)帶寬有限的移動(dòng)設(shè)備上。在這些情況下,消費(fèi)者最好只請(qǐng)求他們需要的字段。

解決這些問(wèn)題的一種簡(jiǎn)單方法是添加額外的請(qǐng)求參數(shù),例如 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;
}

這種方法需要為每個(gè)昂貴的響應(yīng)字段添加一個(gè)自定義 includeXXX 字段,并且不適用于嵌套字段。它還增加了請(qǐng)求的復(fù)雜性,最終使維護(hù)和支持更具挑戰(zhàn)性

在請(qǐng)求體中添加FieldMask

API 設(shè)計(jì)人員可以將 field_mask 字段添加到請(qǐng)求消息中,而不是創(chuàng)建一次性的“包含”字段:

import "google/protobuf/field_mask.proto";

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

消費(fèi)者可以為他們期望在響應(yīng)中接收的字段設(shè)置路徑。如果消費(fèi)者只對(duì)產(chǎn)品標(biāo)題和格式感興趣,他們可以使用路徑“標(biāo)題”和“格式”設(shè)置 FieldMask:

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

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

請(qǐng)注意,盡管本文中的代碼示例是用 Java 編寫的,但演示的概念適用于protobuffer支持的任何其他語(yǔ)言。
如果消費(fèi)者只需要最后一個(gè)更新時(shí)間表的人的標(biāo)題和電子郵件,他們可以設(shè)置不同的字段掩碼:

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

按照慣例,如果請(qǐng)求中不存在 FieldMask,則應(yīng)返回所有字段.

Protobuf Field Names vs Field Numbers

您可能會(huì)注意到 FieldMask 中的路徑是使用字段名稱指定的,而在網(wǎng)絡(luò)上,protocol buffers消息僅包含字段編號(hào),而不包含字段名稱。這(以及其他一些技術(shù),如用于有符號(hào)類型的 ZigZag 編碼)使 protobuf 消息能節(jié)省空間。

要了解字段編號(hào)和字段名稱之間的區(qū)別,我們來(lái)詳細(xì)了解一下 protobuf 是如何對(duì)消息進(jìn)行編碼和解碼的。

我們的 protobuf 消息定義(.proto 文件)包含具有五個(gè)字段的生產(chǎn)消息。每個(gè)字段都有類型、名稱和編號(hào)。

// Message with Production-related information  
message Production {
  string id = 1;
  string title = 2;
  ProductionFormat format = 3;
  repeated ProductionScript scripts = 4;
  ProductionSchedule schedule = 5;
}

當(dāng) protobuf 編譯器 (protoc) 編譯此消息定義時(shí),它會(huì)以您選擇的語(yǔ)言(在我們的示例中為 Java)創(chuàng)建代碼。此生成的代碼包含已定義消息的類,以及消息和字段描述符。描述符包含將消息編碼和解碼為其二進(jìn)制格式所需的所有信息。例如,它們包含字段編號(hào)、名稱、類型。消息生產(chǎn)者使用描述符將消息轉(zhuǎn)換為其有線格式。為提高效率,二進(jìn)制消息僅包含字段數(shù)字-值對(duì)。不包括字段名稱。當(dāng)消費(fèi)者接收到消息時(shí),它通過(guò)引用已編譯的消息定義將字節(jié)流解碼為一個(gè)對(duì)象(例如,Java 對(duì)象)。

如上所述,F(xiàn)ieldMask列出了字段名,而不是數(shù)字。在Netflix,我們使用字段編號(hào),并使用FieldMaskUtil.fromFieldNumbers()實(shí)用方法將其轉(zhuǎn)換為字段名。這個(gè)方法利用編譯后的消息定義,將字段號(hào)轉(zhuǎn)換為字段名,并創(chuàng)建一個(gè)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();

但是,有一個(gè)容易忽略的限制:使用 FieldMask 會(huì)限制您重命名消息字段的能力。重命名消息字段通常被認(rèn)為是一種安全操作,因?yàn)槿缟纤?,字段名稱不是在線發(fā)送的,而是使用消費(fèi)者端的字段編號(hào)派生的。使用 FieldMask,字段名稱在消息有效負(fù)載中(在路徑字段值中)發(fā)送并變得重要.

假設(shè)我們要將字段標(biāo)題重命名為 title_name 并發(fā)布我們的消息定義的 2.0 版本:

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

在此圖表中,生產(chǎn)者(服務(wù)器)使用新的描述符,字段編號(hào) 2 名為 title_name。通過(guò)線路發(fā)送的二進(jìn)制消息包含字段編號(hào)及其值。消費(fèi)者仍然使用原始描述符,其中第 2 個(gè)字段稱為title。它仍然能夠通過(guò)字段編號(hào)解碼消息。

如果消費(fèi)者不使用 FieldMask 來(lái)請(qǐng)求該字段,這將很有效。如果消費(fèi)者使用 FieldMask 字段中的“title”路徑進(jìn)行調(diào)用,生產(chǎn)者將無(wú)法找到該字段。生產(chǎn)者在其描述符中沒(méi)有名為 title 的字段,因此它不知道消費(fèi)者請(qǐng)求了第 2 個(gè)字段.

如我們所見(jiàn),如果一個(gè)字段被重命名,后端應(yīng)該能夠支持新舊字段名稱,直到所有調(diào)用者遷移到新字段名稱(向后兼容性問(wèn)題)

有多種方法可以解決此限制:

  • 使用 FieldMask 時(shí)切勿重命名字段。這是最簡(jiǎn)單的解決方案,但并非總是可行.
  • 要求后端支持所有舊的字段名稱。這解決了向后兼容性問(wèn)題,但需要在后端添加額外代碼來(lái)跟蹤所有歷史字段名稱
  • 棄用舊的并創(chuàng)建一個(gè)新字段而不是重命名。在我們的示例中,我們將創(chuàng)建第 6 個(gè) title_name 字段。與前一個(gè)選項(xiàng)相比,此選項(xiàng)具有一些優(yōu)點(diǎn):它允許生產(chǎn)者繼續(xù)使用生成的描述符而不是自定義轉(zhuǎn)換器;此外,棄用一個(gè)字段使其在消費(fèi)者方面更加突出
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;
}

無(wú)論解決方案如何,重要的是要記住 FieldMask 使字段名稱成為 API 約束的組成部分。

在服務(wù)端使用FieldMask

在生產(chǎn)者(服務(wù)器)端,可以使用 FieldMaskUtil.merge() 方法(第 ##8 和 9 行)從響應(yīng)負(fù)載中刪除不必要的字段。

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

如果服務(wù)器代碼知道客戶端不需要哪些字段以避免進(jìn)行外部調(diào)用、數(shù)據(jù)庫(kù)查詢或昂貴的計(jì)算,則可以從 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)
        ...
    }

    ...
}

僅當(dāng)請(qǐng)求調(diào)度字段時(shí),此代碼才會(huì)調(diào)用 makeExpensiveCallToScheduleService 方法(第 21 行)。讓我們更詳細(xì)地研究這個(gè)代碼示例。

  1. SCHEDULE_FIELD_NAME 常量包含字段的名稱。此代碼示例使用消息類型 Descriptor 和 FieldDescriptor 按字段編號(hào)查找字段名稱。 protobuf 字段名稱和字段編號(hào)之間的區(qū)別在上面的 Protobuf 字段名稱與字段編號(hào)部分中進(jìn)行了描述.
  2. FieldMaskUtil.normalize() 返回具有按字母順序排序和去重的字段路徑(又名規(guī)范形式)的 FieldMask。
  3. 產(chǎn)生 scheduleFieldRequestedvalue 的表達(dá)式(第 ##14 - 17 行)采用 FieldMask 路徑流,將其映射到頂級(jí)字段流,如果頂級(jí)字段包含 SCHEDULE_FIELD_NAME 常量的值,則返回 true。
  4. 僅當(dāng) scheduleFieldRequested 為 true 時(shí)才檢索 ProductionSchedule

如果您最終將 FieldMask 用于不同的消息和字段,請(qǐng)考慮創(chuàng)建可重用的實(shí)用程序助手方法。例如,基于 FieldMask 和 FieldDescriptor 返回所有頂級(jí)字段的方法,如果 FieldMask 中存在字段則返回的方法等。

預(yù)先構(gòu)建FieldMask

某些訪問(wèn)模式可能比其他訪問(wèn)模式更常見(jiàn)。如果多個(gè)消費(fèi)者對(duì)相同的字段子集感興趣,API 生產(chǎn)者可以提供帶有為最常用字段組合預(yù)構(gòu)建的 FieldMask 的客戶端庫(kù)。

提供預(yù)先構(gòu)建的字段掩碼可簡(jiǎn)化最常見(jiàn)場(chǎng)景的 API 使用,并使消費(fèi)者能夠靈活地為更具體的用例構(gòu)建自己的字段掩碼。

限制

  • 使用 FieldMask 可能會(huì)限制您重命名消息字段的能力(在 Protobuf 字段名稱與字段編號(hào)部分中描述)
  • 重復(fù)字段只允許在路徑字符串的最后位置。這意味著您不能在列表內(nèi)的消息中選擇(屏蔽)單個(gè)子字段。這在可預(yù)見(jiàn)的未來(lái)可能會(huì)發(fā)生變化,因?yàn)樽罱鷾?zhǔn)的 Google API 改進(jìn)提案 AIP-161 字段掩碼添加了對(duì)重復(fù)字段通配符的支持。
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容