背景
在 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è)代碼示例。
- SCHEDULE_FIELD_NAME 常量包含字段的名稱。此代碼示例使用消息類型 Descriptor 和 FieldDescriptor 按字段編號(hào)查找字段名稱。 protobuf 字段名稱和字段編號(hào)之間的區(qū)別在上面的 Protobuf 字段名稱與字段編號(hào)部分中進(jìn)行了描述.
- FieldMaskUtil.normalize() 返回具有按字母順序排序和去重的字段路徑(又名規(guī)范形式)的 FieldMask。
- 產(chǎn)生 scheduleFieldRequestedvalue 的表達(dá)式(第 ##14 - 17 行)采用 FieldMask 路徑流,將其映射到頂級(jí)字段流,如果頂級(jí)字段包含 SCHEDULE_FIELD_NAME 常量的值,則返回 true。
- 僅當(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ù)字段通配符的支持。