- 原文地址:Courier: Dropbox migration to gRPC
- 原文作者:blogs.dropbox.com
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:kasheemlew
- 校對者:shixi-li
Dropbox 執行著幾百個服務,它們由不同的語言編寫,每秒會交換幾百萬個請求。在我們面向服務架構的中心就是 Courier,它是我們基於 gRPC 的遠過程呼叫(RPC)框架。在開發 Courier 的過程中,我們學到了很多擴充套件 RPC 並優化效能和銜接原有 RPC 系統的東西。
註釋:本文只展示了 Python 和 Go 生成程式碼的例子。我們也支援 Rust 和 Java。
The road to gRPC
Courier 不是 Dropbox 的第一個 RPC 框架。在我們正式開始將龐大的的 Python 程式拆成多個服務之前,我們就認識到服務之間的通訊需要有牢固的基礎,所以選擇一個高可靠性的 RPC 框架就顯得尤其關鍵。
開始之前,Dropbox 調研了多個 RPC 框架。首先,我們從傳統的手動序列化和反序列化的協議著手,比如我們用 Apache Thrift 搭建的基於 Scribe 的日誌管道之類的服務。但我們主要的 RPC 框架(傳統的 RPC)是基於 HTTP/1.1 協議並使用 protobuf 編碼訊息。
我們的新框架有幾個候選項。我們可以升級遺留的 RPC 框架使其相容 Swagger(現在叫 OpenAPI),或者建立新標準,也可以考慮在 Thrift 和 gRPC 的基礎上開發。
我們最終選擇 gRPC 主要是因為它允許我們沿用 protobuf。對於我們的情況,多路 HTTP/2 傳輸和雙向流也很有吸引力。
如果那時候有 fbthrift 的話,我們也許會仔細瞧瞧基於 Thrift 的解決方案。
Courier 給 gRPC 帶來了什麼
Courier 不是一個新的 RPC 協議 —— 它只是 Dropbox 用來相容 gRPC 和原有基礎設施的解決方案。例如,只有使用指定版本的驗證、授權和服務發現時它才能工作。它還必須相容我們的統計、事件日誌和追蹤工具。滿足所有這些條件才是我們所說的 Courier。
儘管我們支援在一些特殊情況下使用 Bandaid 作為 gRPC 代理,但為了減小 RPC 的延遲,大多數服務間的通訊並不使用代理。
我們想減少需要編寫的樣板檔案的數量。作為我們服務開發的通用框架,Courier 擁有所有服務需要的特性。大多數特性都是預設開啟的,並且可以通過命令列引數進行控制。有些還可以使用特性標識動態開啟。
安全性:服務身份和 TLS 相互認證
Courier 實現了我們的標準服務身份機制。我們的伺服器和客戶端都有各自的 TLS 證照,這些證照由我們內部的權威機構頒發。每個伺服器和客戶端還有一個使用這個證照加密的身份,用於他們之間的雙向驗證。
我們在 TLS 側控制通訊的兩端,並強制進行一些預設的限制。內部的 RPC 通訊都強制使用 PFS 加密。TLS 的版本固定為 1.2+。我們還限制使用對稱/非對稱演算法的安全的子集進行加密,這裡比較傾向於使用
ECDHE-ECDSA-AES128-GCM-SHA256
。
完成身份認證和請求的解碼之後,伺服器會對客戶端進行許可權驗證。在服務層和獨立的方法中都可以設定訪問控制表(ACL) 和限制速率,也可以使用我們的分散式配置系統(AFS)進行更新。這樣就算服務管理者不重啟程式,也能在幾秒之內完成分流。訂閱通知和更新配置由 Courier 框架完成。
服務 “身份” 是用於 ACL、速率限制、統計等的全域性識別符號。另外,它也是加密安全的。
我們的光學字元識別(OCR)服務中有這樣一個 Courier ACL/速率限制配置定義的例子:
limits:
dropbox_engine_ocr:
# 所有的 RPC 方法。
default:
max_concurrency: 32
queue_timeout_ms: 1000
rate_acls:
# OCR 客戶端無限制。
ocr: -1
# 沒有其他人與我們通訊。
authenticated: 0
unauthenticated: 0
複製程式碼
我們在考慮使用每個人都該用的安全生產標識框架 (SPIFFE)中的 SPIFFE 可驗證標識證件。這將使我們的 RPC 框架與眾多開源專案相容。
可觀察性:統計和追蹤
有了標識,我們很容易就能定位到對應 Courier 服務的標準日誌、統計、記錄等有用的資訊。
我們的程式碼生成給客戶端和服務端的每個服務和方法都新增了統計。服務端的統計資料按客戶端的識別符號分類。每個 Courier 服務的負載、錯誤和延遲都進行了細粒度的歸因,由此實現了開箱即用。
Courier 的統計包括客戶端的可用性、延遲和服務端請求率和佇列大小。還有各請求延遲直方圖、各客戶端 TLS 握手等各種分類。
擁有自己的程式碼生成的一個好處是我們可以靜態地初始化這些資料結構,包括直方圖和追蹤範圍。這減小了效能的影響。
我們傳統的 RPC 在 API 邊界只傳送 request_id
,因此可以從不同的服務中加入日誌。在 Courier 中,我們採用了基於 OpenTracing 規範的一個子集的 API。在客戶端,我們編寫了自己的庫;在服務端,我們基於 Cassandra 和 Jaeger 進行開發。關於如何優化這個追蹤系統的效能,我們有必要用一片專門的文章來講解。
追蹤讓我們可以生成一個執行時服務的依賴圖,用於幫助工程師理解一個服務所有的傳遞依賴,也可以在完成部署後用於檢查和避免不必要的依賴。
可靠性:截止期限和斷路限制
Courier 集中管理所有的客戶端的基於特定語言實現的功能,例如超時。隨著時間的推移,我們還在這一層加入了像檢視的任務項之類的功能。
截止期限
每個 gRPC 請求都包含一個 截止期限,用來表示客戶端等待回覆的時長。由於 Courier 自動傳送全部已知的後設資料,截止期限會一隻存在於請求中,甚至跨越 API 邊界。在程式中,截止期限被轉換成了特定的表示。例如在 Go 中會使用 WithDeadline
方法的返回結構 context.Context
進行表示。
在實踐過程中,我們要求工程師們在服務的定義中制定截止期限,從而使所有的類都是可靠的。
這個上下文甚至可以被傳送到 RPC 層之外!例如,我們傳統的 MySQL ORM 將 RPC 的上下文和截止期限序列化,放入 SQL 查詢的註釋中,我們的 SQLProxy 就可以解析這些評論,並在超過截止期限後
殺死
這些查詢 。附帶的好處是我們在除錯資料庫查詢的時候能夠找到每個請求的原因。
斷路限制
另一個常見的問題是傳統的 RPC 客戶端需要在重試時實現自定義指數補償和抖動。
在 Courier 中,我們希望用一種更通用的方法解決斷路限制的問題,於是在監聽器和工作池之間採用了一個 LIFO 佇列。
在服務過載的時候,這個 LIFO 佇列就會像一個自動斷路器一樣工作。這個佇列不僅有大小的限制,還有更嚴格的時間限制。一個請求只能在該佇列中存在指定的時間。
LIFO 在對請求排序時有缺陷。如果想維持順序,你可以試試 CoDel。它也有斷路限制的功能,且不會打亂請求的順序
自省:除錯端點
除錯端點儘管不是 Courier 本身的一部分,但在 Dropbox 中得到了廣泛的使用。它們太有用了,我不能不提!這裡有些有用的自省的例子。
為了安全考慮,你可能想將這些暴露到一個單獨的埠(也許只是一個迴環介面)甚至是一個 Unix 套接字(可以用 Unix 檔案系統進行控制。)你也一定要考慮使用雙向 TLS 驗證,要求開發者在訪問除錯端點時提供他們的證照(特別是非只讀的那些。)
執行時
能在看到執行時的狀態是非常有用的。例如 堆和 CPU 檔案可以暴露為 HTTP 或 gRPC 端點。
我們打算在灰度驗證的階段用這個方法自動化新舊版本程式碼間的對比。
這些除錯端點允許在修改執行時的狀態,例如,一個用 golang 開發的服務可以動態設定 GCPercent。
庫
動態匯出某些特定庫的資料作為 RPC 端點對於庫的作者來說很有用。malloc 庫轉儲內部狀態就是個很好的例子。
RPC
考慮到對加密的和二進位制編碼的協議進行故障診斷有點複雜,因此應該在效能允許的情況下向 RPC 層加入儘可能多的工具。最近有個這樣的自省 API 的例子,就是 gRPC 的 channelz 提案。
應用
檢視 API 級別的引數也很有用。將構建/原地址雜湊、命令列等用於通用應用資訊端點就是很好的例子。編排系統可以通過這些資訊驗證服務部署的一致性。
效能優化
在擴充套件 Dropbox 的 gRPC 規模的時候,我們發現了很多效能瓶頸。
TLS 握手開銷
由於服務要處理大量的連線,累積起來的 TLS 握手開銷是不可忽視的。在大規模服務重啟時這一點尤其突出。
為了提升簽約操作的效能,我們將 RSA 2048 金鑰對換成了 ECDSA P-256。下面是 BoringSSL 效能的例子(儘管 RSA 比簽名驗證還是要快一些):
RSA:
? ~/c0d3/boringssl bazel run -- //:bssl speed -filter 'RSA 2048'
Did ... RSA 2048 signing operations in .............. (1527.9 ops/sec)
Did ... RSA 2048 verify (same key) operations in .... (37066.4 ops/sec)
Did ... RSA 2048 verify (fresh key) operations in ... (25887.6 ops/sec)
複製程式碼
ECDSA:
? ~/c0d3/boringssl bazel run -- //:bssl speed -filter 'ECDSA P-256'
Did ... ECDSA P-256 signing operations in ... (40410.9 ops/sec)
Did ... ECDSA P-256 verify operations in .... (17037.5 ops/sec)
複製程式碼
從效能上說,RSA 2048 驗證比 ECDSA P-256 大約快了 3 倍,因此你可以考慮用 RSA 作為根/葉的證照。但是從安全方面考慮,切換安全原語可能有些困難,況且這樣會帶來最小的安全屬性。 同樣考慮效能因素,你在使用 RSA 4096(或更高)證照之前應該三思。
我們還發現 TLS 庫(以及編譯標識)在效能和安全方面有很大的影響。例如,下面比較了相同硬體環境下 MacOS X Mojave 的 LibreSSL 構建和 homebrewed OpenSSL:
LibreSSL 2.6.4:
? ~ openssl speed rsa2048
LibreSSL 2.6.4
...
sign verify sign/s verify/s
rsa 2048 bits 0.032491s 0.001505s 30.8 664.3
複製程式碼
OpenSSL 1.1.1a:
? ~ openssl speed rsa2048
OpenSSL 1.1.1a 20 Nov 2018
...
sign verify sign/s verify/s
rsa 2048 bits 0.000992s 0.000029s 1208.0 34454.8
複製程式碼
但是最快的方法就是不使用 TLS 握手!為了支援會話恢復,我們修改了 gRPC-core 和 gRPC-python,降低了服務啟動時的 CPU 佔用。
加密開銷並不高
人們有個普遍的誤解,認為加密開銷很高。事實上,對稱加密在現代硬體上相當快。桌面級的處理器使用單核就能以 40Gbps 的速率進行加密和驗證。
? ~/c0d3/boringssl bazel run -- //:bssl speed -filter 'AES'
Did ... AES-128-GCM (8192 bytes) seal operations in ... 4534.4 MB/s
複製程式碼
儘管如此,我們最終還是要使 gRPC 適配我們的 50Gb/s 儲存箱。我們瞭解到,當加密速度可以和記憶體拷貝速度相提並論的時候,降低 memcpy
操作的次數至關重要。此外,我們對 gRPC 本身也做了修改
驗證和加密協議有一些很棘手的問題。例如,處理器、DMA 和 網路資料損壞。即便你不用 gRPC,使用 TLS 進行內部通訊也是個好主意。
高時延頻寬積連結
Dropbox 擁有 大量通過骨幹網路連線的資料中心。有時候不同區域的節點可能需要使用 RPC 進行通訊,例如為了複製。使用 TCP 的核心是為了限制指定連線(限制在 /proc/sys/net/ipv4/tcp_{r,w}mem
)的傳輸中資料的數量。由於 gRPC 是基於 HTTP/2 的,在 TCP 之上還有其特有的流控制。BDP 的上限硬編碼於 grpc-go 為 16Mb,這可能會成為單一的高 BDP 連線的瓶頸。
Golang 的 net.Server 和 grpc.Server 對比
在我們的 Go 程式碼中,我們起初支援 HTTP/1.1 和 gRPC 使用相同的 net.Server。這從邏輯上講得通,但是在效能上表現不佳。將 HTTP/1.1 和 gRPC 拆分到不同的路徑、用不同的伺服器管理並且將 gRPC 換成 grpc.Server 大大改進了 Courier 服務的吞吐量和記憶體佔用。
golang/protobuf 和 gogo/protobuf 對比
如果你使用 gRPC 的話,編組和解組開銷會很大。對於我們的 Go 程式碼,我們使用了 gogo/protobuf,它顯著降低了對我們最忙碌的 Courier 伺服器的 CPU 使用。
同樣的,使用 gogo/protobuf 也有一些注意事項,但堅持使用一個正常的功能子集的話應該沒問題。
實現細節
從這裡開始,我們將會深挖 Courier 的內部,看看不同語言下的 protobuf 模式和存根的例子。下面所有的例子都會用我們的 Test
服務(我們在 Courier 中用這個進行整合測試)
服務描述
service Test {
option (rpc_core.service_default_deadline_ms) = 1000;
rpc UnaryUnary(TestRequest) returns (TestResponse) {
option (rpc_core.method_default_deadline_ms) = 5000;
}
rpc UnaryStream(TestRequest) returns (stream TestResponse) {
option (rpc_core.method_no_deadline) = true;
}
...
}
複製程式碼
在可用性章節,我們提到了所有的 Courier 方法都必須擁有截止期限。通過下面的 protobuf 選項可以對整個服務進行設定。
option (rpc_core.service_default_deadline_ms) = 1000;
複製程式碼
也可以對每個方法單獨設定截止期限,並覆蓋服務範圍的設定(如果存在的話)。
option (rpc_core.method_default_deadline_ms) = 5000;
複製程式碼
在極少情況下,截止期限確實沒用(例如監視資源的方法),這時便允許開發者顯式禁用它:
option (rpc_core.method_no_deadline) = true;
複製程式碼
真正的服務定義將會有詳細的 API 文件,甚至會有使用的例子。
存根生成
Courier 不依賴攔截器(Java 除外,它的攔截器 API 已經足夠強大了),它會生成特有的存根,這讓我們用起來很靈活。我們來比較下下我們的存根和 Golang 預設的存根。
這是預設的 gRPC 伺服器存根:
func _Test_UnaryUnary_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(TestRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(TestServer).UnaryUnary(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/test.Test/UnaryUnary",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(TestServer).UnaryUnary(ctx, req.(*TestRequest))
}
return interceptor(ctx, in, info, handler)
}
複製程式碼
這裡所有的處理過程都在一行內完成:解碼 protobuf、執行攔截器、呼叫 UnaryUnary
處理器。
我們再看看 Courier 的存根:
func _Test_UnaryUnary_dbxHandler(
srv interface{},
ctx context.Context,
dec func(interface{}) error,
interceptor grpc.UnaryServerInterceptor) (
interface{},
error) {
defer processor.PanicHandler()
impl := srv.(*dbxTestServerImpl)
metadata := impl.testUnaryUnaryMetadata
ctx = metadata.SetupContext(ctx)
clientId = client_info.ClientId(ctx)
stats := metadata.StatsMap.GetOrCreatePerClientStats(clientId)
stats.TotalCount.Inc()
req := &processor.UnaryUnaryRequest{
Srv: srv,
Ctx: ctx,
Dec: dec,
Interceptor: interceptor,
RpcStats: stats,
Metadata: metadata,
FullMethodPath: "/test.Test/UnaryUnary",
Req: &test.TestRequest{},
Handler: impl._UnaryUnary_internalHandler,
ClientId: clientId,
EnqueueTime: time.Now(),
}
metadata.WorkPool.Process(req).Wait()
return req.Resp, req.Err
}
複製程式碼
這裡程式碼有點多,我們一行一行來看。
首先,我們推遲用於錯誤收集的應急處理器。這樣就可以將未捕獲的異常傳送到集中的位置,用於後面的聚合和報告:
defer processor.PanicHandler()
複製程式碼
設定自定義應急處理器的另一個原因是為了保證我們在出錯時終止應用。預設 golang/net HTTP 處理器的行為是忽略這些錯誤並繼續處理新的請求(這有崩潰和狀態不一致的風險)
然後我們使用覆蓋請求後設資料中的值的方式傳遞上下文:
ctx = metadata.SetupContext(ctx)
clientId = client_info.ClientId(ctx)
複製程式碼
我們還在服務端給每個客戶端新增了統計,用於更細粒度的歸因:
stats := metadata.StatsMap.GetOrCreatePerClientStats(clientId)
複製程式碼
這在執行時給每個客戶端(就是每個 TLS 身份)動態新增了統計。每個服務的每個方法也會有統計,並且由於存根生成器在生成程式碼的時候擁有所有方法的許可權,我們可以靜態新增,以避免執行時的開銷。
然後我們建立請求結構,將它傳入工作池,等待完成。
req := &processor.UnaryUnaryRequest{
Srv: srv,
Ctx: ctx,
Dec: dec,
Interceptor: interceptor,
RpcStats: stats,
Metadata: metadata,
...
}
metadata.WorkPool.Process(req).Wait()
複製程式碼
請注意,現在所有的工作都還沒完成:沒有解碼 protobuf,沒有執行攔截器等等。在工作池中使用 ACL,優先化和速率限制都在這些之前發生。
注意,golang gRPC 庫支援這個 Tap 介面,這使得初期的請求攔截成為可能,同時給構建高效低耗的速率控制器提供了基礎。
特定應用的錯誤程式碼
我們的存根生成器允許開發者通過自定義選項定義特定應用的錯誤程式碼
enum ErrorCode {
option (rpc_core.rpc_error) = true;
UNKNOWN = 0;
NOT_FOUND = 1 [(rpc_core.grpc_code)="NOT_FOUND"];
ALREADY_EXISTS = 2 [(rpc_core.grpc_code)="ALREADY_EXISTS"];
...
STALE_READ = 7 [(rpc_core.grpc_code)="UNAVAILABLE"];
SHUTTING_DOWN = 8 [(rpc_core.grpc_code)="CANCELLED"];
}
複製程式碼
在同一個服務中,會傳播 gRPC 和應用錯誤,但是所有的錯誤在 API 邊界都會被替換成 UNKOWN。這避免了不同服務之間的意外錯誤代理的問題,修改了語義上的意思。
Python 特定的修改
我們在 Python 存根給所有的 Courier 處理器中加入了顯式的上下文引數,例如:
from dropbox.context import Context
from dropbox.proto.test.service_pb2 import (
TestRequest,
TestResponse,
)
from typing_extensions import Protocol
class TestCourierClient(Protocol):
def UnaryUnary(
self,
ctx, # 型別:Context
request, # 型別:TestRequest
):
# 型別: (...) -> TestResponse
...
複製程式碼
一開始,這看起來有些奇怪,但時候後來開發者們漸漸習慣了顯式的 ctx
,就像他們習慣 self
一樣。
請注意,我們的存根也都是 mypy 型別的,這在大規模重構期間會得到充分的回報。並且 mypy 在像 PyCharm 這樣的 IDE 中也已經得到了很好的整合。
繼續靜態型別的趨勢,我們還可以將 mypy 的註解加入到 proto 中。
class TestMessage(Message):
field: int
def __init__(self,
field : Optional[int] = ...,
) -> None: ...
@staticmethod
def FromString(s: bytes) -> TestMessage: ...
複製程式碼
這些註解避免了許多常見的漏洞,比如將 None
賦值給 Python 中的 string
欄位。
這些程式碼在 dropbox/mypy-protobuf 中開源了。
遷移過程
編寫一個新的 RPC 棧絕非易事,但就操作的複雜性而言還是不能和跨範圍的遷移相提並論。為了保證專案的成功,我們嘗試簡化開發者從傳統 RPC 遷移到 Courier 的過程。由於遷移本身就是個很容易出錯的過程,我們決定分成多個步驟來進行。
第 0 步: 凍結傳統的 RPC
在開始之前,我們會凍結傳統 RPC 的特徵集,這樣他就不會變化了。這樣,由於追蹤和流之類的新特性只能在 Courier 的服務中使用,大家也會更願意遷移到 Courier。
第 1 步:傳統 RPC 和 Courier 的通用介面
我們從給傳統 RPC 和 Courier 定義通用介面開始。我們的程式碼生成會生成適用於這兩種版本介面的存根:
type TestServer interface {
UnaryUnary(
ctx context.Context,
req *test.TestRequest) (
*test.TestResponse,
error)
...
}
複製程式碼
第 2 步:遷移到新介面
然後我們將每個服務都切換到新的介面,但還是使用傳統 RPC。這對於所有服務和客戶端中的方法來說通常都有很大的差異。這個過程很容易出錯,為了儘可能降低風險,我們每次只改一個引數。
處理只有少數方法和備用錯誤預算的低階服務時可以一步完成遷移,不用管這個警告。
第 3 步:將客戶端切換到 Courier RPC
作為遷移到 Courier 的一部分,我們需要在不同的埠上同時執行傳統和 Courier 伺服器的二進位制檔案。然後將客戶端中 RPC 實現的一行進行修改。
class MyClient(object):
def __init__(self):
- self.client = LegacyRPCClient('myservice')
+ self.client = CourierRPCClient('myservice')
複製程式碼
請注意,使用上面的模型一次可以遷移一個客戶端,我們可以從批處理程式和其他一些非同步任務等擁有較低 SLA 的開始。
第 4 步:清理
在所有的服務客戶端都遷移完成之後,我們需要證明傳統的 RPC 已經不再被使用了(可以通過程式碼檢查靜態地完成,或者通過檢查傳統伺服器統計來動態地完成。)這一步完成之後,開發者就可以繼續進行清理並刪掉舊的程式碼了。
經驗教訓
到了最後,Courier 帶給我們的是一個可以加速服務開發的統一 RPC 框架,它簡化了操作並加強了 Dropbox 的可靠性。
這裡我們總結了開發和部署 Courier 過程中主要的經驗教訓:
- 可觀察性是一個特性。在排除故障時,所有現成的度量和故障是非常寶貴的。
- 標準化和一致性很重要。它們可以降低認知壓力並簡化操作和程式碼維護。
- 試著最小化程式碼開發者需要編寫的樣板檔案。程式碼生成器是你的夥伴。
- 儘量讓遷移簡單些。遷移通常需要比開發更多的時間。同時,遷移只有在清理過程完成之後才算結束。
- 可以在 RPC 框架中對基礎設施範圍內的可靠性進行改進,例如,強制截止期限、超載保護等等。常見的可靠性問題可以通過每個季度的事件報告來確定。
工作展望
Courier 和 gRPC 本身都在不斷變化,所以我們最後來總結一下執行時團隊和可靠性團隊的工作路線。
在不遠的將來,我們會給 Python 的 gRPC 程式碼加一個合適的解析器 API,切換到 Python/Rust 中的 C++ 繫結,並加上完整的斷路控制和故障注入的支援。明年我們準備調研一下 ALTS 並且將 TLS 握手移到單獨的程式(可能甚至與服務容器分離開。)
我們在招聘!
你想做執行時相關的工作嗎?Dropbox 在山景城和舊金山的小團隊負責全球分佈的邊緣網路、兆位元流量、每秒數百萬次的請求。
通訊量/執行時/可靠性團隊都在招 SWE 和 SRE,負責開發 TCP/IP 包處理器和負載均衡器、HTTP/gRPC 代理和我們內部的執行時 service mesh:Courier/gRPC、服務發現和 AFS。感覺不合適?我們舊金山、紐約、西雅圖、特拉維等地的辦公室還有各個方向的職位。
鳴謝
專案貢獻者:Ashwin Amit、Can Berk Guder、Dave Zbarsky、Giang Nguyen、Mehrdad Afshari、Patrick Lee、Ross Delinger、Ruslan Nigmatullin、Russ Allbery 和 Santosh Ananthakrishnan。
同時也非常感謝 gRPC 團隊的支援。
如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。