1. 程式人生 > >深度解析gRPC以及京東分散式服務框架跨語言實戰

深度解析gRPC以及京東分散式服務框架跨語言實戰

 gRPC是什麼

  gRPC是Google 開發的基於HTTP/2和Protocol Buffer 3的RPC 框架。

  gRPC是開源的,有 C、Java、Go等多種語言的實現,可以輕鬆實現跨語言呼叫。

  聲稱是"一個高效能,開源,將移動和HTTP/2放在首位的通用的RPC框架"

  當前版本1.1.1,主要技術棧:Netty-4.1.8,Protobuff-3.1.0,Guava-20.0

  Multi-language, multi-platform framework

  ● Native implementations in C, Java, and Go

  ● C stack wrapped by C++, C#, Node, ObjC, Python, Ruby, PHP

  ● Platforms supported: Linux, Android, iOS, MacOS, Windows

  

  gRPC設計動機和原則

  最初由Louis Ryan在谷歌其他同事幫助下寫成,如下文:

  設計動機

  十多年來,谷歌一直使用一個叫做Stubby的通用RPC基礎框架,用它來連線在其資料中心內和跨資料中心執行的大量微服務。其內部系統早就接受了如今越來越流行的微服務架構。擁有一個統一的、跨平臺的RPC的基礎框架,使得服務的首次發行在效率、安全性、可靠性和行為分析上得到全面提升,這是支撐這一時期谷歌快速增長的關鍵。

  Stubby有許多非常棒的特性,然而,它沒有基於任何標準,而且與其內部的基礎框架耦合得太緊密以至於被認為不適合公開發布。隨著SPDY、HTTP/2和QUIC的到來,許多類似特性在公共標準中出現,並提供了Stubby不支援的其它功能。很明顯,是時候利用這些標準來重寫Stubby,並將其適用性擴充套件到移動、物聯網和雲場景。

  設計原則

  ● 服務非物件、訊息非引用 —— 促進微服務的系統間粗粒度訊息互動設計理念,同時避免分散式物件的陷阱和分散式計算的謬誤。

  ● 普遍並且簡單 —— 該基礎框架應該在任何流行的開發平臺上適用,並且易於被個人在自己的平臺上構建。它在CPU和記憶體有限的裝置上也應該切實可行。

  ● 免費並且開源 —— 所有人可免費使用基本特性。以友好的許可協議開源方式釋出所有交付件。

  ● 互通性 —— 該報文協議(Wire Protocol)必須遵循普通網際網路基礎框架。

  ● 通用並且高效能 —— 該框架應該適用於絕大多數用例場景,相比針對特定用例的框架,該框架只會犧牲一點效能。

  ● 分層的 —— 該框架的關鍵是必須能夠獨立演進。對報文格式(Wire Format)的修改不應該影響應用層。

  ● 負載無關的 —— 不同的服務需要使用不同的訊息型別和編碼,例如protocol buffers、JSON、XML和Thrift,協議上和實現上必須滿足這樣的訴求。類似地,對負載壓縮的訴求也因應用場景和負載型別不同而不同,協議上應該支援可插拔的壓縮機制。

  ● 流 —— 儲存系統依賴於流和流控來傳遞大資料集。像語音轉文字或股票程式碼等其它服務,依靠流表達時間相關的訊息序列。

  ● 阻塞式和非阻塞式 —— 支援非同步和同步處理在客戶端和服務端間互動的訊息序列。這是在某些平臺上縮放和處理流的關鍵。

  ● 取消和超時 —— 有的操作可能會用時很長,客戶端執行正常時,可以通過取消操作讓服務端回收資源。當任務因果鏈被追蹤時,取消可以級聯。客戶端可能會被告知呼叫超時,此時服務就可以根據客戶端的需求來調整自己的行為。

  ● Lameducking —— 服務端必須支援優雅關閉,優雅關閉時拒絕新請求,但繼續處理正在執行中的請求。

  ● 流控 —— 在客戶端和服務端之間,計算能力和網路容量往往是不平衡的。流控可以更好的緩衝管理,以及保護系統免受來自異常活躍對端的拒絕服務(DOS)攻擊。

  ● 可插拔的 —— 資料傳輸協議(Wire Protocol)只是功能完備API基礎框架的一部分。大型分散式系統需要安全、健康檢查、負載均衡和故障恢復、監控、跟蹤、日誌等。實 現上應該提供擴充套件點,以允許插入這些特性和預設實現。

  ● API擴充套件 —— 可能的話,在服務間協作的擴充套件應該最好使用介面擴充套件,而不是協議擴充套件。這種型別的擴充套件可以包括健康檢查、服務內省、負載監測和負載均衡分配。

  ● 元資料交換 —— 常見的橫切關注點,如認證或跟蹤,依賴資料交換,但這不是服務公共介面中的一部分。部署依賴於他們將這些特性以不同速度演進到服務暴露的個別API的能力。

  ● 標準化狀態碼 —— 客戶端通常以有限的方式響應API呼叫返回的錯誤。應該限制狀態程式碼名字空間,使得這些錯誤處理決定更清晰。如果需要更豐富的特定域的狀態,可以使用元資料交換機制來提供。

  關於HTTP/2和Protocol Buffer 3簡介

  HTTP/2是什麼

  HTTP/2是下一代的HTTP協議。

  起源於 GOOGLE 帶頭開發的 SPDY 協議,由 IETF 的 HTTPbis 工作組修改釋出。

  由兩個RFC組成:

  ● RFC 7540 - Hypertext Transfer Protocol Version 2 (HTTP/2)

  ● RFC 7541 - HPACK: Header Compression for HTTP/2

  這兩個 RFC 目前的狀態是 PROPOSED STANDARD

  HTTP/1的主要問題

  Head-of-line blocking,新請求的發起必須等待伺服器對前一個請求的迴應,無法同時發起多個請求,導致很難充分利用TCP連線。

  

  ● 頭部冗餘

  HTTP頭部包含大量重複資料,比如cookies,多個請求的cookie可能完全一樣

  HTTP/2改進

  ● 二進位制協議、分幀(Frame)

  ● 雙向流,多路複用

  ● 頭部壓縮

  ● 伺服器推送(Server Push)

  ● 優先順序

  ● 流量控制

  ● 流重置

  

  HTTP/2-幀

  ●HTTP/2拋棄HTTP/1的文字協議改為二進位制協議。HTTP/2的基本傳輸單元為幀。每個幀都從屬於某個流。

  ● Length: Payload 長度

  ● Type: 幀型別

  ● Stream identifier:流ID

  ● Frame Payload: 依幀型別而不同

  HTTP/2-幀的型別

  ● HEADERS 對應HTTP/1的 Headers

  ● DATA 對應HTTP/1的 Body

  ● CONTINUATION 頭部太大,分多個幀傳輸(一個HEADERS+若干CONTINUATION)

  ● SETTINGS 連線設定

  ● WINDOW_UPDATE 流量控制

  ● PUSH_PROMISE 服務端推送

  ● PRIORITY 流優先順序更改

  ● PING 心跳或計算RTT

  ● RST_STREAM 馬上中止一個流

  ● GOAWAY 關閉連線並且傳送錯誤資訊

  HTTP/2-流

  HTTP/2連線上傳輸的每個幀都關聯到一個流,一個連線上可以同時有多個流。同一個流的幀按序傳輸,不同流的幀交錯混合傳輸。客戶端、服務端雙方都可以建立流,流也可以被任意一方關閉。客戶端發起的流使用奇數流ID,服務端發起的使用偶數。

  Protocol Buffers是什麼

  一個語言無關,平臺無關,可擴充套件的結構化資料序列化方案,用於協議通訊,資料儲存和其他更多用途。

  一個靈活,高效,自動化的結構化資料序列化機制(想象xml),但是更小,更快並且更簡單,一旦定義好資料如何構造, 就可以使用特殊的生成的原始碼來輕易的讀寫你的結構化資料到和從不同的資料流,用不同的語言。你甚至可以更新你的資料結構而不打破已部署的使用"舊有"格式編譯的程式。

  為什麼使用HTTP協議

  將移動和HTTP/2放在首位的通用的RPC框架,

  ● 網路基礎設施設計良好的支援HTTP,比如防火牆, 負載, 加密, 認證, 壓縮, ...

  gRPC原理-從一個HelloWorld開始

  第1步. 定義 hello-dto.proto 檔案

  syntax = "proto3";

  option java_package = "com.jd.jsf.grpc.dto";

  option java_multiple_files = true;

  option java_outer_classname = "HelloServiceDTO";

  package grpc;

  // The request message containing the user's name.

  message HelloRequest {

  string name = 1;

  }

  // The response message containing the greetings

  message HelloReply {

  string message = 1;

  }

  第2步. 定義hello-service.proto檔案(可以和第一步合併)

  syntax = "proto3";

  import "hello-dto.proto";

  option java_package = "com.jd.jsf.grpc.service";

  option java_multiple_files = true;

  option java_outer_classname = "IHelloService";

  package grpc;

  // The greeting service definition.

  service HelloService {

  // Sends a greeting

  rpc SayHello (grpc.HelloRequest) returns (grpc.HelloReply) {}

  }

  第3步. 生成 原始碼 檔案

  #! /bin/bash

  PROTOC3="/grpc/protoc-3.1.0"

  PROJECT_HOME="./"

  echo "gen dto"

  ${PROTOC3}/bin/protoc

  -I=${PROJECT_HOME}/src/main/proto/

  --java_out=${PROJECT_HOME}/src/main/java

  ${PROJECT_HOME}/src/main/proto/hello-dto.proto

  echo "gen service"

  ${PROTOC3}/bin/protoc

  -I=${PROJECT_HOME}/src/main/proto/

  --java_out=${PROJECT_HOME}/src/main/java

  ${PROJECT_HOME}/src/main/proto/hello-service.proto

  echo "gen grpc service"

  ${PROTOC3}/bin/protoc

  --plugin=protoc-gen-grpc-java=${PROTOC3}/bin/protoc-gen-grpc-java-1.1.1-linux-x86_64.exe

  --grpc-java_out=${PROJECT_HOME}/src/main/java

  -I=${PROJECT_HOME}/src/main/proto/

  ${PROJECT_HOME}/src/main/proto/hello-service.proto

  echo "over!"

  第4步. 編寫Server端

  int port = 50051;

  server = ServerBuilder.forPort(port).addService(new GreeterImpl()).build() .start();

  server.awaitTermination();

  class GreeterImpl extends HelloServiceGrpc.HelloServiceImplBase {

  @Override

  public void sayHello(HelloRequest req, StreamObserver<HelloReply> responseObserver) {

  HelloReply reply = HelloReply.newBuilder().setMessage("Hello " + req.getName()).build();

  responseObserver.onNext(reply);

  responseObserver.onCompleted();

  }

  }

  第5步. 編寫Client端

  ManagedChannel

  channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext(true);

  HelloServiceGrpc.HelloServiceBlockingStub

  blockingStub = HelloServiceGrpc.newBlockingStub(channel);

  HelloServiceGrpc.HelloServiceFutureStub

  futureStubStub = HelloServiceGrpc.newFutureStub(channel);

  HelloRequest request = HelloRequest.newBuilder().setName(name).build();

  HelloReply response = blockingStub.sayHello(request);

  ListenableFuture<HelloReply> future = futureStubStub.sayHello(request);

  HelloReply response = future.get();

  

  gRPC原理- 概念

  從HelloWorld中,映射出gRPC的基本概念

  1. Channels

  在建立客戶端存根時,一個gRPC通道提供一個特定主機和埠服務端的連線。客戶端可以通過指定通道引數來修改gRPC的預設行為,比如開啟關閉訊息壓縮。一個通道具有狀態,包含已連線和空閒 。

  2. Stub

  Proxy, Channel, Marshaller, MethodDeor

  利用程式碼生成器生成client和server端stub程式碼,為了跨語言只能這麼玩,這也體現了靜態語言和動態語言的區別。Stub程式碼包含了客戶端和服務端靜態代理類,分別處理訊息的加工和傳送。還包括序列化方法,服務定義相關的方法描述。

  3. Service Def

  gRPC 基於如下思想:定義一個服務, 指定其可以被遠端呼叫的方法及其引數和返回型別。gRPC 預設使用 protocol buffers 3 作為介面定義語言.

  service HelloService {

  rpc SayHello (HelloRequest) returns (HelloResponse);

  }

  message HelloRequest {

  required string greeting = 1;

  }

  message HelloResponse {

  required string reply = 1;

  }

  gRPC 允許你定義四類服務方法:

  1). 單項 RPC,即客戶端傳送一個請求給服務端,從服務端獲取一個應答,就像一次普通的函式呼叫

  rpc SayHello(HelloRequest) returns (HelloResponse){}

  2). 服務端流式 RPC,即客戶端傳送一個請求給服務端,可獲取一個數據流用來讀取一系列訊息。客戶端從返回的資料流裡一直讀取直到沒有更多訊息為止。

  rpc LotsOfReplies(HelloRequest) returns (stream HelloResponse){}

  3). 客戶端流式 RPC,即客戶端用提供的一個數據流寫入併發送一系列訊息給服務端。一旦客戶端完成訊息寫入,就等待服務端讀取這些訊息並返回應答。

  rpc LotsOfGreetings(stream HelloRequest) returns (HelloResponse) {}

  4). 雙向流式 RPC,即兩邊都可以分別通過一個讀寫資料流來發送一系列訊息。這兩個資料流操作是相互獨立的,所以客戶端和服務端能按其希望的任意順序讀寫,例如:服務端可以在寫應答前等待所有的客戶端訊息,或者它可以先讀一個訊息再寫一個訊息,或者是讀寫相結合的其他方式。每個資料流裡訊息的順序會被保持。

  rpc BidiHello(stream HelloRequest) returns (stream HelloResponse){}

  

  4. DEADLINE

  gRPC 允許客戶端在呼叫一個遠端方法前指定一個最後期限值。這個值指定了在客戶端可以等待服務端多長時間來應答,超過這個時間值 RPC 將結束並返回DEADLINE_EXCEEDED錯誤。在服務端可以查詢這個期限值來看是否一個特定的方法已經過期,或者還剩多長時間來完成這個方法。

  5. Metadata

  Headers

  RPC原理- 協議與實現

  gRPC中,把HTTP2的Steam Identifier當作呼叫標識,每一次請求都發起一個新的流。

  每個請求的呼叫哪個服務和方法、迴應的呼叫結果狀態碼都在HEADER Frame中指定。

  請求內容和迴應內容由Protocol Buffer序列化後使用DATA Frame傳輸

  以下是 gRPC 請求和應答訊息流中一般的訊息順序:

  請求 → 請求報頭 * 有定界符的訊息 EOS

  應答 → 應答報頭 * 有定界符的訊息 EOS

  應答 → (應答報頭 * 有定界符的訊息 跟蹤資訊) / 僅僅跟蹤時

  界定的訊息的重複序列通過資料幀來進行傳輸。

  界定的訊息 → 壓縮標誌 訊息長度 訊息

  壓縮標誌 → 0 / 1 # 編碼為 1 byte 的無符號整數

  訊息長度 → {訊息長度} # 編碼為 4 byte 的無符號整數

  訊息 → *{二進位制位元組}

  1. 請求

  HEADERS (flags = END_HEADERS)

  :method = POST

  :scheme = http

  :path = /google.pubsub.v2.PublisherService/CreateTopic

  :authority = pubsub.googleapis.com

  grpc-timeout = 1S

  content-type = application/grpc+proto

  grpc-encoding = gzip

  authorization = Bearer y235.wef315yfh138vh31hv93hv8h3v

  DATA (flags = END_STREAM)

  <Delimited Message>

  2. 應答

  HEADERS (flags = END_HEADERS)

  :status = 200

  grpc-encoding = gzip

  DATA

  <Delimited Message>

  HEADERS (flags = END_STREAM, END_HEADERS)

  grpc-status = 0 # OK

  trace-proto-bin = jher831yy13JHy3hc

  gRPC原理- Server端

  gRPC而言,只是對Netty Server的簡單封裝,底層使用了PlaintextHandler、Http2ConnectionHandler的相關封裝等。具體Framer、Stream方式請參考Http2相關文件。

  followControlWindow:

  流量控制的視窗大小,單位:位元組,預設值為1M,HTTP2中的“Flow Control”特性;連線上,已經發送尚未ACK的資料幀大小,比如window大小為100K,且winow已滿,每次向Client傳送訊息時,如果客戶端反饋ACK(攜帶此次ACK資料的大小),window將會減掉此大小;每次向window中新增亟待發送的資料時,window增加;如果window中的資料已達到限定值,它將不能繼續新增資料,只能等待Client端ACK。

  maxConcurrentCallPerConnection:

  每個connection允許的最大併發請求數,預設值為Integer.MAX_VALUE;如果此連線上已經接受但尚未響應的streams個數達到此值,新的請求將會被拒絕。為了避免TCP通道的過度擁堵,我們可以適度調整此值,以便Server端平穩處理,畢竟buffer太多的streams會對server的記憶體造成巨大壓力。

  maxMessageSize:每次呼叫允許傳送的最大資料量,預設為100M。

  maxHeaderListSize:每次呼叫允許傳送的header的最大條數,gRPC中預設為8192。

  gRPC Server端,有個重要的方法:addService。【如下文service代理模式】

  在此之前,我們需要介紹一下bindService方法,每個gRPC生成的service程式碼中都有此方法,它以硬編碼的方式遍歷此service的方法列表,將每個方法的呼叫過程都與“被代理例項”繫結,這個模式有點類似於靜態代理,比如呼叫sayHello方法時,其實內部直接呼叫“被代理例項”的sayHello方法(參見MethodHandler.invoke方法,每個方法都有一個唯一的index,通過硬編碼方式執行);bindService方法的最終目的是建立一個ServerServiceDefinition物件,這個物件內部位置一個map,key為此Service的方法的全名(fullname,{package}.{service}.{method}),value就是此方法的gRPC封裝類(ServerMethodDefinition)

  addService方法可以新增多個Service,即一個Netty Server可以為多個service服務,這並不違背設計模式和架構模式。addService方法將會把service儲存在內部的一個map中,key為serviceName(即{package}.{service}),value就是上述bindService生成的物件。

  如下是服務定義的類結構:

  

  那麼究竟Server端是如何解析RPC過程的?Client在呼叫時會將呼叫的service名稱 + method資訊儲存在一個GRPC“保留”的header中,那麼Server端即可通過獲取這個特定的header資訊,就可以得知此stream需要請求的service、以及其method,那麼接下來只需要從上述提到的map中找到service,然後找到此method,直接代理呼叫即可。執行結果在Encoder之後傳送給Client。

  如下是Server端啟動過程:

  

  gRPC原理- Client端

  ManagedChannelBuilder來建立客戶端channel,ManagedChannelBuilder使用了provider機制,具體是建立了哪種channel有provider決定,可以參看META-INF下同類名的檔案中的註冊資訊。當前Channel有2種:NettyChannelBuilder與OkHttpChannelBuilder。當前版本中為NettyChannelBuilder;可以直接使用NettyChannelBuilder來構建channel。

  ManagedChannel是客戶端最核心的類,它表示邏輯上的一個channel;底層持有一個物理的transport(TCP通道,參見NettyClientTransport),並負責維護此transport的活性;即在RPC呼叫的任何時機,如果檢測到底層transport處於關閉狀態(terminated),將會嘗試重建transport。(參見TransportSet.obtainActiveTransport())

  通常情況下,我們不需要在RPC呼叫結束後就關閉Channel,Channel可以被一直重用,直到Client不再需要請求為止或者Channel無法真的異常中斷而無法繼續使用。

  每個Service客戶端,都生成了2種stub:BlockingStub和FutureStub;這兩個Stub內部呼叫過程幾乎一樣,唯一不同的是BlockingStub的方法直接返回Response, 而FutureStub返回一個Future物件。BlockingStub內部也是基於Future機制,只是封裝了阻塞等待的過程。

  如下是Client端關鍵元件:

  

  如下是Client端的啟動流程:

  

  關於Client負載均衡

  

  gRPC分層設計

  

  JSF相容gRPC

  目前JSF支援Java和C++兩種客戶端,其他小眾語言無法支援,為了解決跨語言問題,JSF系統增加了基於HTTP/1的閘道器服務。這可能是目前業內RPC框架解決跨語言問題的普遍解決方案。

  

  針對gRPC的技術預言,就是為了解決JSF跨語言問題,如何解決?目前JSF框架釋出的JSF協議服務,天然支援JSF、HTTP、Dubbo、Telnet協議。這都得益於Netty的偉大。就Netty而言,客戶端與服務端建立TCP連線後,初始化Channel時,可以根據報文頭的特徵碼進行協議匹配,進而針對當前連線設定相應協議的解碼器。就gRPC而言,其報文頭就是HTTP/2的報文頭-稜鏡。

  

  針對JSF服務提供端,解析gRPC協議報文,獲取介面、方法、引數,然後進行方法呼叫,最後模擬gRPC協議返回給客戶端。

  針對JSF服務呼叫端,模擬gRPC協議,傳送gRPC協議報文。

  JSF相容gRPC如下圖:

  

  至此,JSF跨語言問題解決了。NO!目前gRPC各種語言客戶端可以訪問Java版的JSF服務,Java版的JSF客戶端也可以訪問gRPC各種語言服務端。我們要解決的問題是Java版的JSF服務,可以讓其他gRPC各種語言客戶端訪問,目前僅解決了一小步。gRPC各種語言客戶端不具備JSF的服務訂閱功能,只能借道gRPC自身的負載策略DNS。

  預設gRPC通過本地的域名解析,拉取服務列表,進而負載均衡。為了支援這種策略,Java版的JSF服務註冊服務時,需要將資訊同步註冊到DNS服務,其他gRPC各種語言客戶端訪問DNS服務實現服務發現。這裡需要按照服務申請域名,這是個弊端。這也違背了gRPC移動端為主、跨資料中心訪問的初衷。

  採用DNS服務發現的設計如下圖:

  

  為了解決DNS服務發現帶來的系統複雜度,正能對gRPC進行動刀,由於gRPC的擴充套件性良好,而且只需要將C、Go語言的客戶端進行擴充套件即可。gRPC服務發現的機制是通過NameResolver來解決的,而且是基於Plugin方式,故NameResolver的實現目標指向JSF系統現有的註冊中心即可,同時為了更徹底的改變gRPC服務註冊、訂閱,又將C、Go語言的客戶端增加了服務註冊、訂閱功能。至此,Java版的JSF服務與gRPC版的服務之間相互呼叫打通了。

  

  gRPC總結

  跨語言,針對移動端:省電、省流量、高效能、雙向流、支援DNS負載。關於效能,肯定比HTTP/1好,比TCP差,網上好多效能對比,都是和TCP相關的RPC對比,沒有可比性。