Netflix實用API設(shè)計?1:Protobuf?FieldMask實踐
背景
// The set of field mask paths.
repeated string paths = 1;
}
案例:Netflix Studio Production
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;
}
讀取 Production 詳細信息
假設(shè)我們想要使用 GetProduction API 獲取特定 production 的信息,例如“La Casa De Papel”。雖然 production 有許多字段,但其中一些字段是從其他服務(wù)返回的,例如來自 Schedule 服務(wù)的 schedule 或來自 Script 服務(wù)的 scripts。
每次調(diào)用 GetProduction 時,Production 服務(wù)都會向 Schedule 和 Script 服務(wù)發(fā)出 RPC,即使客戶端忽略響應(yīng)中的 schedule 和 scripts 字段。如上所述,遠程調(diào)用是有代價的。如果服務(wù)知道哪些字段對調(diào)用者很重要,它可以在是否進行昂貴的調(diào)用、啟動資源密集型計算和/或調(diào)用數(shù)據(jù)庫這些事中做出明智的決定。在這個例子中,如果調(diào)用者只需要標題和格式兩個字段,Production 服務(wù)可以避免遠程調(diào)用 Schedule 和 Script 服務(wù)。
此外,請求大量字段會使響應(yīng)負載變得龐大。對某些應(yīng)用程序來說可能是個問題,例如,在網(wǎng)絡(luò)帶寬有限的移動設(shè)備上。在這些情況下,消費者只請求他們需要的字段是一種很好的做法。
一個比較笨的解決方法是添加額外的請求參數(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;
}
將 FieldMask 添加到請求消息中
API 設(shè)計者可以將 field_mask 字段添加到請求消息中,而不是創(chuàng)建一次性的“包含”字段:import "google/protobuf/field_mask.proto";
message GetProductionRequest {
string production_id = 1;
google.protobuf.FieldMask field_mask = 2;
}
.addPaths("title")
.addPaths("format")
.build();
GetProductionRequest request = GetProductionRequest.newBuilder()
.setProductionId(LA_CASA_DE_PAPEL_PRODUCTION_ID)
.setFieldMask(fieldMask)
.build();
請注意,即使本博文中的代碼示例是用 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();
Protobuf 字段名稱與字段編號
你可能會注意到 FieldMask 中的路徑是使用字段名稱指定的,而在傳輸中,編碼的 protocol buffers 消息僅包含字段編號,而不包含字段名稱。這(以及其他一些技術(shù),如用于簽名類型的 ZigZag[5] 編碼)會讓 protobuf 消息節(jié)省空間。
為了理解字段編號和字段名稱之間的區(qū)別,讓我們詳細了解一下 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;
}
如上所述,F(xiàn)ieldMask 列出字段名稱,而不是數(shù)字。在 Netflix,我們使用字段編號并使用 FieldMaskUtil.fromFieldNumbers()[6] 方法將它們轉(zhuǎn)換為字段名稱。此方法利用編譯的消息定義將字段編號轉(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();
假設(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。傳輸發(fā)送的二進制消息包含字段編號及其值。消費者仍然使用原始描述符,其中字段編號 2 作為標題。它仍然能夠通過字段號對消息進行解碼。
如果消費者不使用 FieldMask 來請求字段,那倒是沒問題。如果消費者使用 FieldMask 字段中的“title”路徑進行調(diào)用,生產(chǎn)者將無法找到該字段。生產(chǎn)者在其描述符中沒有名為 title 的字段,因此它不知道消費者請求的字段編號為 2。
如我們所見,如果一個字段被重命名,后端應(yīng)該能夠支持新舊字段名稱,直到所有調(diào)用者都遷移到新字段名稱(向后兼容性問題)。
有多種方法可以處理此限制:
-
使用 FieldMask 時切勿重命名字段。這是最簡單的解決方案,但并非總是可行
-
要求后端支持所有舊的字段名稱。這解決了向后兼容性問題,但需要后端額外的代碼來跟蹤所有歷史字段名稱
-
棄用舊字段并創(chuàng)建新字段而不是重命名。在我們的示例中,我們將創(chuàng)建 title_name 字段編號 6。此選項比前一個有一些優(yōu)點:它允許生產(chǎn)者繼續(xù)使用生成的描述符而不是自定義轉(zhuǎn)換器;此外,棄用一個字段在消費者端影響更大
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;
}
在生產(chǎn)者(服務(wù)器)端使用 FieldMask
在生產(chǎn)者(服務(wù)器)端,可以使用 FieldMaskUtil.merge()[7] 方法(8 和 9 行)從響應(yīng)負載中刪除不必要的字段:@Override
public void getProduction(GetProductionRequest request,
StreamObserverresponse) {
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();
}
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,
StreamObserverresponse) {
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_FIELD_NAME 常量包含字段的名稱。此代碼示例使用消息類型 Descriptor[8] 和 FieldDescriptor[9] 通過字段編號查找字段名稱。protobuf 字段名稱和字段編號之間的區(qū)別在上面的 Protobuf 字段名稱與字段編號部分進行了描述。
-
FieldMaskUtil.normalize()[10] 返回具有按字母順序排序和去重的字段路徑(又名規(guī)范形式)的 FieldMask。
-
scheduleFieldRequestedvalue 表達式(第14 - 17 行)采用 FieldMask 路徑流,將其映射到頂級(top-level)字段流,如果頂級字段包含 SCHEDULE_FIELD_NAME 常量的值,則返回 true。
-
僅當 scheduleFieldRequested 為真時才檢索 ProductionSchedule。
如果你決定將 FieldMask 用于不同的消息和字段,請考慮創(chuàng)建可重用的實用封裝方法。例如,基于 FieldMask 和 FieldDescriptor 返回所有頂級字段的方法,如果字段存在于 FieldMask 中則返回的方法等。
/**
* 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);
}
-
使用 FieldMask 會限制重命名消息字段的能力(在 Protobuf 字段名稱與字段編號部分中描述)
-
重復(fù)字段只允許出現(xiàn)在路徑字符串的最后一個位置。這意味著你不能在列表內(nèi)的消息中選擇(屏蔽)單個子字段。這在可預(yù)見的未來可能會發(fā)生變化,因為最近批準的 Google API 改進提案 AIP-161 字段掩碼[11]包括對重復(fù)字段的通配符的支持。
這篇博文介紹了 Netflix Studio Engineering 如何以及為何將其用于讀取數(shù)據(jù)的 API。第 2 部分將闡明使用 FieldMask 進行更新和刪除操作。