Netflix實用API設(shè)計(上)

gRPC如今被很多公司應(yīng)用在大規(guī)模生產(chǎn)環(huán)境中,很多時候我們并不需要通過RPC請求所有數(shù)據(jù),而只關(guān)心響應(yīng)數(shù)據(jù)中的部分字段,Protobuf FieldMask就可以幫助我們實現(xiàn)這一目的。本文介紹了Netflix基于FieldMask設(shè)計更高效健壯的API的實踐,全文分兩個部分,這是第一部分。原文:Practical API Design at Netflix, Part 1: Using Protobuf FieldMask[1]

背景

在Netflix,我們大量使用gRPC[2]進行后端通信。處理請求的時候,如果能夠知道調(diào)用者對哪些字段感興趣、哪些字段可以忽略,會非常有用。有些響應(yīng)字段的計算代價可能很高,有些字段可能需要對其他服務(wù)進行遠程調(diào)用。遠程調(diào)用需要付出額外的開銷:額外的延遲,更高的出錯概率,并且消耗了網(wǎng)絡(luò)帶寬。我們怎么樣才能理解哪些字段不需要在響應(yīng)中提供給調(diào)用者,從而避免進行不必要的計算以及遠程調(diào)用?在GraphQL中,可以通過字段選擇器來實現(xiàn)。在JSON:API標準中,有一個類似的被稱為Sparse Fieldsets[3]的技術(shù)。我們在設(shè)計gRPC API的時候,能不能實現(xiàn)類似的功能?在Netflix Studio Engineering[4],我們提供的解決方案是protobuf FieldMask[5]

《紙鈔屋》(La casa de papel) / Netflix

Protobuf FieldMask

Protocol Buffers[6],簡稱protobuf,是一種數(shù)據(jù)序列化機制。默認情況下,gRPC使用protobuf作為它的IDL(接口定義語言)和數(shù)據(jù)序列化協(xié)議。

FieldMask是一個protobuf消息,有許多實用工具和約定用來處理RPC請求中包含的FieldMask。FieldMask消息包含一個名為paths的字段,用于指定應(yīng)該由讀操作返回或由更新操作修改的字段。

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

示例:Netflix工作室內(nèi)容制作

《紙鈔屋》(La casa de papel) / Netflix

我們假設(shè)有一個Production服務(wù),可以用來管理工作室內(nèi)容的生產(chǎn)(Studio Content Productions,在電影和電視行業(yè)中,術(shù)語production[7]指的是制作電影的過程,而不是運行軟件的環(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通過其唯一ID返回一個生產(chǎn)消息。一個作品包含多個字段,如:標題、格式、日程日期、腳本(又稱劇本)、預(yù)算、情節(jié)等,不過我們將重點放在過濾日程日期和腳本上,這樣可以讓例子簡單一點。

讀取制作細節(jié)

假設(shè)我們想要使用GetProduction API獲取特定產(chǎn)品(如“紙鈔屋”)的制作信息。雖然產(chǎn)品有很多字段,但有些字段是從其他服務(wù)獲取的,比如Schedule服務(wù)的返回的schedule,或者Script服務(wù)返回的scripts。

即使客戶端忽略響應(yīng)中的schedulescripts字段,Production服務(wù)仍然需要在每次調(diào)用GetProduction時為Schedule和Script服務(wù)生成RPC。如上所述,遠程調(diào)用是有成本的。如果服務(wù)知道調(diào)用者真正關(guān)心哪些字段,就可以做出明智的決策,決定是否進行昂貴的調(diào)用、啟動資源繁重的計算和/或調(diào)用數(shù)據(jù)庫。在本例中,如果調(diào)用者只需要產(chǎn)品標題和產(chǎn)品格式,那么Production服務(wù)就可以避免對Schedule和Script服務(wù)進行遠程調(diào)用。

此外,請求大量字段會使響應(yīng)負載變得很大,而這對于帶寬有限的移動應(yīng)用來說,可能會造成問題。在這些情況下,消費者只請求他們需要的字段是一個很好的實踐。

《紙鈔屋》(La casa de papel) / Netflix

一個丑陋的解決這些問題的方法是添加額外的請求參數(shù),如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;
}

不過這種方法需要為每個開銷較大的響應(yīng)字段添加自定義的includeXXX字段,對于嵌套字段就不太適用,而且還增加了請求的復(fù)雜性,最終使得維護和支持更困難。

在請求消息里添加FieldMask

API設(shè)計者可以在請求消息中添加field_mask字段,而不是創(chuàng)建一次性的“include”字段:

import "google/protobuf/field_mask.proto";

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

消費者可以為希望在響應(yīng)中接收的字段設(shè)置路徑,如果消費者只對產(chǎn)品標題和格式感興趣,他們可以設(shè)置路徑為“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();
Masking fields

請注意,盡管本文代碼示例是基于Java的,但所演示的概念適用于protocol buffers支持的任何語言。

如果消費者只需要上一個更新日程的人的標題和電子郵件,他們可以設(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();

按照慣例,如果請求中沒有包含F(xiàn)ieldMask,則應(yīng)該返回所有字段。

Protobuf字段名 vs 字段號

你可能已經(jīng)注意到,F(xiàn)ieldMask中的路徑是基于字段名指定的,但實際上,編碼后的protocol buffers消息只包含字段號,而不包含字段名,因此(以及其他技術(shù),比如用于簽名類型編碼的ZigZag[8])protobuf消息的空間效率更高。

為了理解字段號和字段名之間的區(qū)別,讓我們詳細了解一下protobuf是如何編碼和解碼消息的。

Production消息的protobuf消息定義(.proto文件)含有五個字段,每個字段都有一個類型、名稱和數(shù)字。

// 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)編譯這個消息定義時,會根據(jù)我們選擇的語言(示例中是Java)創(chuàng)建代碼。生成的代碼包含用于定義消息的類,以及消息和字段描述符。描述符包含將消息編碼和解碼成二進制格式所需的所有信息。例如,它們包含字段編號、名稱和類型。消息生成程序使用描述符將消息編碼為傳輸格式。為了提高效率,二進制消息里只包含字段號和對應(yīng)的值,不包括字段名。當(dāng)使用者接收到消息時,它通過引用已編譯的消息定義將字節(jié)流解碼為對象(例如,Java對象)。

如上所述,F(xiàn)ieldMask列出的是字段名,而不是數(shù)字。而在Netflix,我們使用字段號,并通過FieldMaskUtil.fromFieldNumbers()[9]輔助程序轉(zhuǎn)換為字段名。fromFieldNumbers方法利用已編譯的消息定義將字段號轉(zhuǎn)換為字段名,并創(chuàng)建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會限制我們重命名消息字段的能力。重命名消息字段通常被認為是安全的操作,因為如上所述,字段名并沒有被編碼發(fā)送,而是基于消費者端的消息定義產(chǎn)生的。要是使用FieldMask,字段名就會編碼在消息的有效負載中被發(fā)送出去(在paths字段值中)。

假設(shè)我們想將字段title重命名為title_name,并發(fā)布消息定義的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;
}

在這個圖中,生產(chǎn)者(服務(wù)器端)使用了新的描述符,字段2名為title_name。通過網(wǎng)絡(luò)發(fā)送的二進制消息包含字段號及其值。消費者仍然使用原始的描述符,其中字段號2名為title,仍然能夠通過字段號解碼消息。

如果消費者不使用FieldMask請求字段,這種方法仍然可以工作的很好。不過一旦消費者使用FieldMask字段中的“title”路徑進行調(diào)用,生產(chǎn)者將無法找到該字段。生產(chǎn)者的描述符中沒有名為title的字段,所以不知道消費者要求的字段號是2。

如上所見,如果一個字段被重命名,后臺應(yīng)該能夠支持新的和舊的字段名,直到所有調(diào)用者都遷移到新的字段名(向后兼容)。

有多種解決方案可以處理這個問題:

  • 使用FieldMask時,永遠不要重命名字段。這是最簡單的解決方案,但并不總是可行。
  • 要求后端支持所有舊字段名。這解決了向后兼容性問題,但需要在后端添加額外的代碼來跟蹤所有歷史字段名。
  • 棄用舊字段并創(chuàng)建一個新字段而不是重命名。在我們的示例中,我們將創(chuàng)建新的title_name,并設(shè)置字段號為6。與前一個選項相比,這個選項的優(yōu)點在于:允許生產(chǎn)者繼續(xù)使用生成的描述符而不是自定義轉(zhuǎn)換器;另外,可以讓消費者很快察覺到某個字段已經(jīng)被棄用了。
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;
}

不管用哪個解決方案,最重要的是要記住,F(xiàn)ieldMask使字段名稱成為API合約的一個組成部分。

在生產(chǎn)者(服務(wù)器)端使用FieldMask

在生產(chǎn)者(服務(wù)器)端,可以使用FieldMaskUtil.merge()[10]方法從響應(yīng)負載中刪除不必要的字段(第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();
}

如果服務(wù)器代碼需要知道哪些字段被請求,以避免進行外部調(diào)用、數(shù)據(jù)庫查詢或昂貴的計算,可以從FieldMask paths字段獲得相關(guān)信息:

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字段時調(diào)用makeExpensiveCallToScheduleServicemethod(第21行),讓我們仔細看一下這段代碼。

(1)SCHEDULE_FIELD_NAME常量包含字段名。在示例代碼中使用消息類型Descriptor[11]和FieldDescriptor[12]按字段編號查找字段名。protobuf字段名和字段號之間的區(qū)別請參考前面的介紹。

(2)FieldMaskUtil.normalize()[13]返回按字母順序排序并刪除了重復(fù)數(shù)據(jù)的FieldMask(又稱規(guī)范形式)。

(3)生成scheduleFieldRequested的表達式(第14 - 17行)接受FieldMask路徑流,將其映射為頂級字段流,如果頂級字段中包含SCHEDULE_FIELD_NAME常量的值,則返回true。

(4)只有當(dāng)scheduleFieldRequestedtrue時,才會檢索ProductionSchedule

如果需要為不同的消息和字段應(yīng)用FieldMask,可以考慮創(chuàng)建可重用的helper方法。例如,可以基于FieldMask和FieldDescriptor返回所有頂級字段的方法,判斷字段是否出現(xiàn)在FieldMask中的方法,等等。

使用預(yù)構(gòu)建的FieldMask

有些訪問模式可能比其他模式更常見,如果多個消費者都對同一個字段子集感興趣,API生產(chǎn)商可以為最常用的字段組合提供預(yù)構(gòu)建的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);

}

提供預(yù)構(gòu)建字段掩碼簡化了大多數(shù)常見場景的API使用,并讓消費者可以靈活的為更具體的用例構(gòu)建自己的字段掩碼。

限制

  • 使用FieldMask限制了重命名消息字段的能力
  • 重復(fù)字段只允許出現(xiàn)在路徑字符串的最后一個位置,這意味著不能在列表中選擇(屏蔽)單個子字段。在可預(yù)見的未來,這種情況可能會改變,因為最近批準的谷歌API改進建議AIP-161 Field masks[14]包含了對重復(fù)字段的通配符支持。

最后

Protobuf FieldMask是一個簡單而強大的概念,有助于使API更健壯,服務(wù)實現(xiàn)更高效。

本文介紹了Netflix Studio Engineering如何以及為什么使用FieldMask來讀取數(shù)據(jù),下一篇文章將介紹如何使用FieldMask進行更新和刪除操作。

References:
[1] https://netflixtechblog.com/practical-api-design-at-netflix-part-1-using-protobuf-fieldmask-35cfdc606518
[2] https://grpc.io/
[3] https://jsonapi.org/format/#fetching-sparse-fieldsets
[4] https://netflixtechblog.com/netflix-studio-engineering-overview-ed60afcfa0ce
[5] https://developers.google.com/protocol-buffers/docs/reference/csharp/class/google/protobuf/well-known-types/field-mask
[6] https://developers.google.com/protocol-buffers
[7] https://en.wikipedia.org/wiki/Filmmaking
[8] https://en.wikipedia.org/wiki/Variable-length_quantity#Zigzag_encoding
[9] https://developers.google.com/protocol-buffers/docs/reference/java/com/google/protobuf/util/FieldMaskUtil.html#fromFieldNumbers-java.lang.Class-int...-
[10] 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.Builder-
[11] https://developers.google.com/protocol-buffers/docs/reference/java/com/google/protobuf/Descriptors
[12] https://developers.google.com/protocol-buffers/docs/reference/java/com/google/protobuf/Descriptors.FieldDescriptor.html
[13] https://developers.google.com/protocol-buffers/docs/reference/java/com/google/protobuf/util/FieldMaskUtil.html#normalize-com.google.protobuf.FieldMask-
[14] https://google.aip.dev/161

你好,我是俞凡,在Motorola做過研發(fā),現(xiàn)在在Mavenir做技術(shù)工作,對通信、網(wǎng)絡(luò)、后端架構(gòu)、云原生、DevOps、CICD、區(qū)塊鏈、AI等技術(shù)始終保持著濃厚的興趣,平時喜歡閱讀、思考,相信持續(xù)學(xué)習(xí)、終身成長,歡迎一起交流學(xué)習(xí)。
微信公眾號:DeepNoMind

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

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

  • gRPC如今被很多公司應(yīng)用在大規(guī)模生產(chǎn)環(huán)境中,很多時候我們并不需要通過RPC請求所有數(shù)據(jù),而只關(guān)心響應(yīng)數(shù)據(jù)中的部分...
    DeepNoMind閱讀 499評論 0 0
  • 本文主要針對Protobuf進行介紹,主要針對版本proto2,給出demo來講解proto語法,并對其中部分編解...
    赤子心_d709閱讀 946評論 0 0
  • 翻譯查閱外網(wǎng)資料過程中遇到的比較優(yōu)秀的文章和資料,一是作為技術(shù)參考以便日后查閱,二是訓(xùn)練英文能力。此文翻譯自 Pr...
    401閱讀 69,133評論 1 39
  • 英文原文:Language Guide (proto3)[https://developers.google.co...
    岑吾閱讀 486評論 0 6
  • 我是黑夜里大雨紛飛的人啊 1 “又到一年六月,有人笑有人哭,有人歡樂有人憂愁,有人驚喜有人失落,有的覺得收獲滿滿有...
    陌忘宇閱讀 8,843評論 28 54

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