ProtoBuf-gRPC實踐

目錄介紹

  • 01.gRPC學習背景
    • 1.1 爲什麼要學RPC
    • 1.2 RPC是什麼
    • 1.3 網絡庫收益分析
    • 1.4 學習計劃說明
    • 1.5 學習問題思考
  • 02.ProtoBuf的介紹
    • 2.1 ProtoBuf是什麼
    • 2.2 ProtoBuf和json
    • 2.3 ProtoBuf問題思考
    • 2.4 ProtoBuf特點
    • 2.5 ProtoBuf存儲格式
    • 2.6 ProtoBuf優缺點
    • 2.7 創建proto文件
    • 2.8 ProtoBuf核心思想
    • 2.9 轉化爲Json數據
    • 2.10 ProtoBuf總結
    • 2.11 Proto遇到的坑
  • 03.gRPC實踐的介紹
    • 3.1 gRPC簡單介紹
    • 3.2 爲何要用gRPC
    • 3.3 gRPC定義服務
    • 3.4 gRPC生成代碼
    • 3.5 gRPC如何使用
    • 3.6 同步和異步操作
    • 3.7 gRPC一些操作
    • 3.8 gRPC設置超時
    • 3.9 gRPC問題解決
    • 3.10 理解gRPC協議
  • 04.gRPC通信實踐
    • 4.1 gRPC通信技術點
    • 4.2 Channel創建和複用
    • 4.3 如何添加公參
    • 4.4 請求/響應的讀寫操作
    • 4.5 網絡日誌打印
    • 4.6 如何做網絡緩存
    • 4.7 如何請求域名
    • 4.8 如何處理錯誤和異常
    • 4.9 設置CA證書校驗
    • 4.10 如何保證安全性
    • 4.11 如何兼容OkHttp
  • 05.ProtoBuf核心原理
    • 5.1 ProtoBuf數據結構
    • 5.2 ProtoBuf編碼方式
    • 5.3 充分理解TLV設計
    • 5.4 TLV設計中Type原理
    • 5.5 TLV設計中Length原理
  • 06.gRPC核心設計思想
    • 6.1 Channel核心設計思想
    • 6.2 Stub核心設計思想
    • 6.3 NameResolver核心設計思想
    • 6.4 gRPC網絡框架設計層次
    • 6.5 gRPC包設計說明
  • 07.gRPC核心原理
    • 7.1 gRPC核心設計思路
    • 7.2 NameResolver域名解析
    • 7.3 Channel層設計原理
    • 7.4 Stub層設計原理

01.gRPC學習背景

1.1 爲什麼要學gRPC

  • 在Android開發中,使用gRPC可以帶來以下好處:
    • 高效性:gRPC使用ProtoBuf作爲默認的序列化協議,比JSON和XML等其他序列化協議更高效,可以減少網絡帶寬和CPU使用率。
    • 可靠性:gRPC使用HTTP/2協議作爲底層傳輸協議,可以提供更可靠的連接和流控制,同時支持TLS加密和認證。
    • 易於使用:gRPC提供了自動生成代碼的工具,可以方便地生成客戶端和服務器端的代碼,同時提供了豐富的文檔和示例。
    • 可擴展性:gRPC支持多種類型的RPC,包括簡單RPC、流式RPC和雙向流式RPC,可以滿足不同的應用場景和需求。
  • 項目demo地址

1.2 RPC是什麼

  • rpc概述
    • RPC(Remote Procedure Call)-遠程過程調用,他是一種通過網絡從遠程計算機程序上請求服務,而不需要了解底層網絡技術的協議。
    • RPC 協議包括序列化、協議編解碼器和網絡傳輸棧。
  • gRPC介紹
    • gRPC 一開始由 Google 開發,是一款語言中立、平臺中立、開源的遠程過程調用(RPC)系統。
  • rpc和http區別
    • RPC 和 HTTP都是微服務間通信較爲常用的方案之一,其實RPC 和 HTTP 並不完全是同一個層次的概念,它們之間還是有所區別的。
    • 1.RPC 是遠程過程調用,其調用協議通常包括序列化協議和傳輸協議。序列化協議有基於純文本的 XML 和 JSON、有的二進制編碼的ProtoBuf。傳輸協議是指其底層網絡傳輸所使用的協議,比如 TCP、HTTP。
    • 2.可以看出HTTP是RPC的傳輸協議的一個可選方案,比如說 gRPC 的網絡傳輸協議就是 HTTP。HTTP 既可以和 RPC 一樣作爲服務間通信的解決方案,也可以作爲 RPC 中通信層的傳輸協議(此時與之對比的是 TCP 協議)。

1.3 網絡庫收益分析

  • 使用gRPC作爲網絡方案可以帶來以下收益:
    • 使用gRPC作爲網絡方案可以帶來高效性、跨平臺和語言、可靠性、易於使用和可擴展性等收益。
    • 同時可以減少手動編寫代碼的工作量,提高開發效率。

1.4 學習計劃說明

  • 基礎入門掌握
    • 1.完成ProtoBuf基礎瞭解和學習【3h】【完成】
    • 2.嘗試ProtoBuf和json效率測試【3h】【完成】
    • 3.gRPC環境配置和官方文檔和Demo學習【6h】【完成】
    • 4.熟練使用gRPC,比如將玩Android接口,用gRPC方式去請求【8h】【進行中】
    • 5.gRPC的四種通信模式【4h】【未開始】
  • 原理解讀說明
    • 1.理解gRPC的基礎通信原理【4h】【進行中】
    • 2.gRpc網絡請求的核心設計思路【4h】【未開始】
    • 3.Proto文件如何在編譯期間生成java文件原理研究【4h】【完成】
    • 4.Proto有哪些缺點,如何優化,跟json,xml空間上性能分析【4h】【完成】
  • 技術精進計劃
    • 1.理解各種網絡請求庫的設計,性能對比,各自偏向解決問題【4h】【完成】
    • 2.通透理解protoBuf實現數據緩衝的原理,谷歌如何做性能優化的反思【4h】【完成】
    • 3.講出來,實踐寫Demo,內化。和同事討論關鍵技術點【8h】【完成】

1.5 學習問題思考

  • gRPC框架問題思考
    • gRPC主要是解決什麼問題,其設計初衷是什麼。該框架設計的總思想是什麼樣的?
    • gRPC如何請求網絡,解析流程是什麼,請求過程是怎樣的,如何處理解析異常和請求異常的邏輯?
    • gRPC創建通道的原理是什麼,什麼場景下需要做通道複用?四種通信模式的設計原理是什麼,分別用在什麼場景?
  • Proto編碼傳輸協議問題思考
    • ProtoBuf傳輸協議是如何設計的?其設計初衷是什麼?和序列化json有什麼區別,兩者的效率有何不同?有何優缺點?
  • 網絡請求實踐中問題思考
    • 如何添加網絡攔截器,監聽請求狀態?攔截器的工作原理是什麼?
    • 同步和異常請求分別是如何使用,其原理是什麼,如何處理請求超時的邏輯?
    • Stub相關類是如何在編譯器生成的,網絡請求的框架層次如何理解?

02.ProtoBuf的介紹

2.1 ProtoBuf是什麼

  • ProtoBuf簡單介紹
    • 它是Google公司發佈的一套開源編碼規則,基於二進制流的序列化傳輸,可以轉換成多種編程語言,幾乎涵蓋了市面上所有的主流編程語言,是目前公認的非常高效的序列化技術。
  • ProtoBuf是一種靈活高效可序列化的數據協議
    • 相於XML,具有更快、更簡單、更輕量級等特性。支持多種語言,只需定義好數據結構,利用ProtoBuf框架生成源代碼,就可很輕鬆地實現數據結構的序列化和反序列化。
    • 一旦需求有變,可以更新數據結構,而不會影響已部署程序。
  • ProtoBuf的Github主頁:

2.2 ProtoBuf和json

  • ProtoBuf就好比信息傳輸的媒介
    • 如果用一句話來概括ProtoBuf和JSON的區別的話,那就是:對於較多信息存儲的大文件而言,ProtoBuf的寫入和解析效率明顯高很多,而JSON格式的可讀性明顯要好。
  • 如何對json和ProtoBuf進行效率測試
    • 核心思路:創建同樣數據內容的對象。然後將對象序列化成字節數組,獲取字節數據大小,最後又將字節反序列化成對象。
    • 效率對比:主要是比較序列化耗時,序列化之後的數據大小,以及反序列化的耗時。注意:必須保證創建數據內容是一樣的。
  • 測試案例分析如下
    2023-05-09 09:31:49.699 23442-23442/com.yc.appgrpc E/Test效率測試:: Gson 序列化耗時:14
    2023-05-09 09:31:49.699 23442-23442/com.yc.appgrpc E/Test效率測試:: Gson 序列化數據大小:188
    2023-05-09 09:31:49.701 23442-23442/com.yc.appgrpc E/Test效率測試:: Gson 反序列化耗時:2
    2023-05-09 09:31:49.701 23442-23442/com.yc.appgrpc E/Test效率測試:: Gson 數據:{"persons":[{"id":1,"name":"張三","phones":[{"number":"110","type":"HOME"}]},{"id":2,"name":"李四","phones":[{"number":"130","type":"MOBILE"}]},{"id":3,"name":"王五","phones":[{}]}]}
    2023-05-09 09:31:49.720 23442-23442/com.yc.appgrpc E/Test效率測試:: protobuf 序列化耗時:4
    2023-05-09 09:31:49.720 23442-23442/com.yc.appgrpc E/Test效率測試:: protobuf 序列化數據大小:59
    2023-05-09 09:31:49.722 23442-23442/com.yc.appgrpc E/Test效率測試:: protobuf 反序列化耗時:2
    2023-05-09 09:31:49.725 23442-23442/com.yc.appgrpc E/Test效率測試:: protobuf 數據:# com.yc.appgrpc.AddressBookProto$AddressBook@83d0213a
        people {
          id: 1
          name: "\345\274\240\344\270\211"
          phones {
            number: "110"
            type: HOME
            type_value: 1
          }
        }
        people {
          id: 2
          name: "\346\235\216\345\233\233"
          phones {
            number: "120"
          }
        }
        people {
          id: 3
          name: "\347\216\213\344\272\224"
          phones {
            number: "130"
          }
        }
    
  • 測試結果說明
    • 空間效率:Json:188個字節;ProtoBuf:59個字節
    • 時間效率:Json序列化:14ms,反序列化:2ms;ProtoBuf序列化:4ms 反序列化:2ms
  • 可以得出結論
    • 通過以上的時間效率和空間效率,可以看出protoBuf的空間效率是JSON的2-5倍,時間效率要高,對於數據大小敏感,傳輸效率高的模塊可以採用protoBuf庫。

2.3 ProtoBuf問題思考

  • ProtoBuf 是一個小型的軟件框架,也可以稱爲protocol buffer 語言,帶着疑問會發現Proto 有很多需要了解:
    • Proto 文件書寫格式,關鍵字package、option、Message、enum 等含義和注意點是什麼?
    • 消息等嵌套如何使用?實現的原理?
    • Proto 文件對於不同語言的編譯,和產生的obj 文件的位置?
    • Proto 編譯後的cc 和java 文件中不同函數的意義?
    • 如何實現*.proto 到*.java、.h、.cc 等文件?
    • 數據包的組成方式、repeated 的含義和實現?
    • Proto 在service和client 的使用,在java 端和native 端如何使用?
    • 與xml 、json 等相比時間、空間上的比較如何?

2.4 ProtoBuf特點

  • 先看看Proto文件的一個案例
    message SearchRequest {
      string query = 1;
      int32 page_number = 2;
      int32 result_per_page = 3;
    }
    
  • 序列化數據爲何說數據小
    • 序列化數據時,不序列化key的name,使用key的編號替代,減小數據。
    • 如上面的數據在序列化時query ,page_number以及result_per_page的key不會參與,由編號1,2,3替代。
    • 這樣在反序列的時候可以直接通過編號找到對應的key,這樣做確實可以減小傳輸數據,但是編號一旦確定就不可更改;
  • 沒有賦值的key,不參與序列化:
    • 序列化時只會對賦值的key進行序列化,沒有賦值的不參與,在反序列化的時候直接給默認值即可;
  • 可變長度編碼:
    • 主要縮減整數佔用字節實現,例如java中int佔用4個字節,但是大多數情況下,我們使用的數字都比較小,使用1個字節就夠了,這就是可變長度編碼完成的事;
  • TLV:
    • TLV全稱爲Tag_Length_Value,其中Tag表示後面數據的類型,Length不一定有,根據Tag的值確定,Value就是數據了,TLV表示數據時,減少分隔符的使用,更加緊湊;

2.5 ProtoBuf存儲格式

  • 如何理解TLV結構:TLV全稱爲Type_Length_Value
    • Type塊並不是只表示數據類型,其中數據編號也在Tag塊中,Tag的生成規則如下:Tag塊的後3位表示數據類型,其他位表示數據編號。Tag中1-15編號只佔用1個字節,所以確保編號中1-15爲常用的,減少數據大小。
    • Length不一定有,依據Tag確定,例如數值類型的數據,使用VarInts不需要長度,就只有Tag-Value,string類型的數據就必須是Tag-Length-Value
    • Value:消息字段經過編碼後的值
    • image

2.6 ProtoBuf優缺點

  • ProtoBuf優點
    • 性能:1.體積小,序列化後,數據大小可縮小3-10倍;2.序列化速度快,比XML和JSON快20-100倍;3.傳輸速度快,因爲體積小,傳輸起來帶寬和速度會有優化
    • 使用優點:1.使用簡單,proto編譯器自動進行序列化和反序列化;2.維護成本低,多平臺僅需維護一套對象協議文件(.proto);3.向後兼容性(擴展性)好,不必破壞舊數據格式就可以直接對數據結構進行更新;4.加密性好,Http傳輸內容抓包只能看到字節
    • 使用範圍:跨平臺、跨語言(支持Java, Python, Objective-C, C+, Dart, Go, Ruby, and C#等),可擴展性好
  • ProtoBuf缺點
    • 功能缺點:不適合用於對基於文本的標記文檔(如HTML)建模,因爲文本不適合描述數據結構
    • 通用性較差:json、xml已成爲多種行業標準的編寫工具,而ProtoBuf只是Google公司內部的工具
    • 自解耦性差:以二進制數據流方式存儲(不可讀),需要通過.proto文件才能瞭解到數據結構
    • 閱讀性差:.proto文件去生成相應的模型,而生成出來的模型無論從可讀性還是易用性上來說都是較差的。並且生成出來的模型文件是不允許修改的(protoBuf官方建議),如果有新增字段,都必須依賴於.proto文件重新進行生成。
  • .protoBuf會導致客戶端的體積增加許多
    • protoBuf所生成的模型文件十分巨大,略複雜一些的數據可以達到1MB,請注意,1MB只是一個模型文件。
    • 導致該問題的原因是,protoBuf爲了實現對傳輸數據的信息補全(可以參看編碼原理),將編碼、解碼的代碼都整合到了每一個獨立的模型文件中,因此導致代碼有非常大的冗餘

2.7 創建proto文件

  • proto文件基礎介紹
    syntax = "proto3";
    option java_multiple_files = true;
    option java_package = "com.yc.appgrpc";
    option java_outer_classname = "HelloWorldProto";
    option objc_class_prefix = "HLW";
    package helloworld;
    
    service Greeter {
      rpc SayHello (HelloRequest) returns (HelloReply) {}
    }
    
    message HelloRequest {
      string name = 1;
    }
    message HelloReply {
      string message = 1;
    }
    
  • 版本聲明:syntax = "proto3";
    • .proto文件中非註釋非空的第一行必須使用Proto版本聲明,如果不使用proto3版本聲明,Protobuf編譯器默認使用proto2版本。
  • 指定包名:package
    • .proto文件中可以新增一個可選的package聲明符,用來防止不同的消息類型有命名衝突。包的聲明符會根據使用語言的不同影響生成的代碼
  • 導入外部包:import
    • 通過import聲明符可以引用其他.proto裏的結構數據體
  • 消息定義:message
    • ProtoBuf中,消息即結構化數據。其中變量的聲明結構爲:字段規則 + 字段類型 + 字段名稱 + 【=】 + 標識符 + 【默認值】
    • 字段規則有:optional: 結構體可以包含該字段零次或一次(不超過一次);repeated: 該字段可以在格式良好的消息中重複任意多次(包括0),其中重複值的順序會被保留,相當於數組
  • 定義服務:service
    • 如果想要將消息類型用在RPC(遠程方法調用)系統中,可以在.proto文件中定義一個RPC服務接口,ProtoBuf編譯器將會根據所選擇的不同語言生成服務接口代碼及stub。
  • 關於ProtoBuf更多內容

2.8 ProtoBuf核心思想

  • 第一步:開發者首先需要編寫.proto文件
    • 按照proto格式編寫文件,指定消息結構。
  • 第二步:編譯.proto文件生成對應的代碼
    • 需要把.proto文件丟給目標語言的protoBuf編譯器。protoBuf編譯器將生成相應語言的代碼。
    • 例如,對Java來說,編譯器生成相應的.java文件,以及一個特殊的Builder類(該類用於創建消息類接口)。
  • 第三步:使用代碼傳輸數據,調用api
    • protoBuf編譯器會生成相應的方法,這些方法包括序列化方法和反序列化方法。
    • 序列化方法用於創建和操作object,將它們轉換爲序列化格式,以進行存儲或傳輸。調用toByteArray()方法將object轉爲byte字節數組。
    • 反序列化方法用於將輸入的protoBuf數據轉換爲object。調用parseFrom(bytes)方法將bytes字節數據轉爲object對象。
    • 編譯器完成它的工作後,開發人員所要做的,就是在發送/接收數據的代碼中使用這些方法。
     AddressBookProto.AddressBook addressBook = AddressBookProto.AddressBook.newBuilder()
        .addPeople(zs)
        .addPeople(ls)
        .addPeople(ys)
        .build();
    //序列化
    byte[] bytes = addressBook.toByteArray();
    //反序列化
    AddressBookProto.AddressBook book = AddressBookProto.AddressBook.parseFrom(bytes);
    

2.9 轉化爲Json數據

  • 使用特定的工具或庫將數據轉換爲 JSON 格式。
    // 創建消息對象並設置字段值
    MyMessage.Builder builder = MyMessage.newBuilder();
    builder.setId(1);
    builder.setName("John Doe");
    MyMessage message = builder.build();
    
    // 將消息對象轉換爲 JSON
    String json = new Gson().toJson(message);
    

2.10 ProtoBuf總結

  • Protocol Buffer 利用 varint 原理壓縮數據以後,二進制數據非常緊湊,option 也算是壓縮體積的一個舉措。
    • 所以 pb 體積更小,如果選用它作爲網絡數據傳輸,勢必相同數據,消耗的網絡流量更少。但是並沒有壓縮到極限,float、double 浮點型都沒有壓縮。
    • Protocol Buffer 比 JSON 和 XML 少了 {、}、: 這些符號,體積也減少一些。再加上 varint 壓縮,gzip 壓縮以後體積更小!
    • Protocol Buffer 是 Tag - Value (Tag - Length - Value)的編碼方式的實現,減少了分隔符的使用,數據存儲更加緊湊。
  • Protocol Buffer 另外一個核心價值在於提供了一套工具,一個編譯工具,自動化生成 get/set 代碼。
    • 簡化了多語言交互的複雜度,使得編碼解碼工作有了生產力。
    • Protocol Buffer 不是自我描述的,離開了數據描述 .proto 文件,就無法理解二進制數據流。這點即是優點,使數據具有一定的“加密性”,也是缺點,數據可讀性極差。所以 Protocol Buffer 非常適合內部服務之間 RPC 調用和傳遞數據。

2.11 Proto遇到的坑

  • 字段順序問題:proto 使用字段的編號來標識字段,而不是使用字段的名稱。
    • 因此,如果更改了字段的順序,可能會導致與舊版本的 proto 不兼容。爲了避免這個問題,建議在 proto 文件中爲字段指定唯一的編號,並避免在後續版本中更改字段的編號。
  • 字段類型問題:proto 提供了多種字段類型,如 int32、string、bool 等。
    • 確保選擇正確的字段類型,以適應你的數據需求。如果你更改了字段的類型,可能需要進行相應的代碼更改和數據遷移。

03.gRPC實踐的介紹

3.1 gRPC簡單介紹

  • gRPC的簡單介紹
    • gRPC是一個高性能、開源和通用的RPC框架,面向移動和HTTP/2設計。目前提供C、Java和Go語言版本,分別是grpc、grpc-java、grpc-go。
    • gRPC基於HTTP/2標準設計,帶來諸如雙向流、流控、頭部壓縮、單TCP連接上的多複用請求等特性。這些特性使得其在移動設備上表現更好,更省電和節省空間佔用。
    • gRPC由google開發,是一款語言中立、平臺中立、開源的遠程過程調用系統。
  • gRPC(Java)的Github主頁:
  • gRPC的產生動機和設計原則

3.2 爲何要用gRPC

  • 爲什麼要使用ProtoBuf和gRPC
    • 簡而言之,ProtoBuf就好比信息傳輸的媒介,類似我們常用的json,而grpc則是傳輸他們的通道,類似我們常用的socket。
  • gRPC被谷歌推薦
    • 作爲google公司極力推薦的分佈式網絡架構,基於HTTP2.0標準設計,使用用ProtoBuf作爲序列化工具,在移動設備上表現更好,更省電和節省空間佔用。
  • 像這種國外的開源框架,還是建議大家先直接閱讀官方文檔,再看國內的文章,這樣纔不容易被誤導。

3.3 gRPC定義服務

  • gRPC的四種通信模式
    • gRPC針對不同的業務場景,一共提供了四種通信模式,分別是簡單一元模式,客戶端流模式,服務端流模式和雙向流模式,接下來這個進行介紹。
    • Unary-從客戶機發送單個請求,從服務器發送回單個響應。
    • Server Streaming-從客戶機發送一個請求,然後從服務器發送回一系列消息。
    • Client Streaming -從客戶端向服務器發送一系列消息,服務器用一條消息作出迴應。
    • Bidirectional streaming -客戶端和服務器相互發送消息流的地方。
    service Greeter {
      // 簡單一元模式
      rpc simpleHello (Request) returns (Reply) {}
      // 客戶端流模式
      rpc clientStream (stream Request) returns (Reply) {}
      // 服務端流模式
      rpc serverStream (Request) returns (stream Reply) {}
      // 雙向流模式
      rpc bothFlowStream (stream Request) returns (stream Reply) {}
    }
    
  • 簡單一元模式
    • 所謂簡單一元模式,實際上就是客戶端和服務端進行一問一答的通信。
    • 這種通信模式是最簡單的,應用場景有無線設備之間和客戶端之間保持連接的心跳檢測,每隔一段時間就給服務端發送一個心跳檢測包,服務端接收到心跳包後就知道相應客戶端處於連接狀態。
  • 客戶端流模式
    • 客戶端流模式的意思就是客戶端可以一次性發送多個數據片段,當然數據片段是一個類,具體的類有哪些字段都是你在最開始的proto文件中進行指定的。
    • 這種模式的應用場景就比如客戶端向服務端發送一連串的數據,然後服務端最後發送一個響應數據表示接收成功。
  • 服務端流模式
    • 服務端流模式和客戶端流模式正好相反,本質都是差不多的,應用場景有客戶端發送一個數據包告訴服務端,我需要某某數據,然後服務器將對應的所有信息都發送給客戶端。
  • 雙向流模式
    • 雙向流模式是最後一種,也是最常用的一種,在這種模式中,客戶端和服務端的通信沒有什麼限制,是比較理想的通信模式,應用場景也最爲廣泛,因爲在這種模式中,你也可以只發送一個數據包。

3.4 gRPC生成代碼

  • 從我們的 .proto 服務定義中生成 gRPC 客戶端接口。
    • 使用proto帶有特殊 gRPC Java 插件的協議緩衝區編譯器來執行此操作。你需要使用 proto3編譯器(支持 proto2 和 proto3 語法)以生成 gRPC 服務。
    service Greeter {
      rpc SayHello (HelloRequest) returns (HelloReply) {}
    }
    
    message HelloRequest {
      string name = 1;
    }
    message HelloReply {
      string message = 1;
    }
    
  • 編譯器生成的代碼如下所示
    • Greeter 會編譯生成 GreeterGrpc.java,具有服務中定義的所有方法Greeter。
    • HelloRequest 會編譯生成 HelloRequest.java,HelloRequestOrBuilder.java。
    • HelloReply 會編譯生成 HelloReply.java ,HelloReplyOrBuilder.java。
    • 以message開頭的對象,編譯會生成協議緩衝區代碼的代碼,用於填充、序列化和檢索我們的請求和響應消息類型。

3.5 gRPC如何使用

  • 大概的實踐步驟如下所示
    • 第一步:添加項目中build中的插件配置。添加:classpath "com.google.protobuf:protobuf-gradle-plugin:0.9.1"
    • 第二步:在App模塊下添加plugin插件配置,添加基礎庫依賴等操作。還要做一些proto配置。具體看app模塊下的build.gradle文件。
    • 第三步:在main目錄下創建proto目錄,創建一個和java目錄同級的proto文件夾,這樣做是因爲在build.gradle文件中指定了去proto文件夾中找到*.proto文件,並且編譯成java代碼。
    • 第四步:編譯項目,在build目錄(build->generated->source->proto->debug)下看到對應的java文件
    • 第五步:開始使用gRPC去做網絡請求操作。具體可以看:HelloWorldActivity
  • 問題思考一下
    • 能否把生成的代碼拷貝出來(如果proto不經常變的情況),把插件禁用掉,避免每次都生成???
  • 如何使用gRPC去做網絡請求
    //構建Channel
    channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build();
    //構建服務請求API代理
    GreeterGrpc.GreeterBlockingStub stub = GreeterGrpc.newBlockingStub(channel);
    //構建請求實體,HelloRequest是自動生成的實體類
    HelloRequest request = HelloRequest.newBuilder().setName(message).build();
    //進行請求並且得到響應數據
    HelloReply reply = stub.sayHello(request);
    //得到請求響應的數據
    String replyMessage = reply.getMessage();
    
  • 在這個調用代碼中,其核心的流程,完成了grpc客戶端調用服務器端最重要的三個步驟
    • 創建連接到遠程服務器的 channel
    • 構建使用該channel的客戶端stub
    • 調用服務方法,執行RPC調用,發出請求並且拿到響應數據

3.6 同步和異步操作

  • 創建一個存根,或者更確切地說,兩個存根:
    • 阻塞/同步存根:這意味着 RPC 調用等待服務器響應,並將返回響應或引發異常。
    • 一個非阻塞/異步存根,它對服務器進行非阻塞調用,異步返回響應。您只能使用異步存根進行某些類型的流式調用。
  • 創建同步和異步操作如下
    //同步阻塞
    GreeterGrpc.GreeterBlockingStub stub = GreeterGrpc.newBlockingStub(channel);
    //異步調用
    GreeterGrpc.GreeterStub stub = GreeterGrpc.newStub(channel);
    
  • 這裏思考一下,如果讓你來做,如何實現同步阻塞這個功能?那麼gRPC又是如何設計的?

3.7 gRPC一些操作

  • RPC 超時
    • gRPC 允許客戶指定他們願意等待 RPC 完成多長時間,然後再以錯誤終止 RPC。在server端,可以查詢特定 RPC 是否已過時,或者需要多少時間才能完成 RPC。
  • RPC 終止
    • 在 gRPC 中,客戶端和服務器都對調用的成功做出獨立和本地的判斷,它們的結論可能不一致。
    • 這意味着,例如,您可能有一個 RPC 在服務器端成功完成(“我已經發送了我所有的響應!”)但在客戶端失敗(“響應到達前我已經截至了!”)。
  • RPC 取消
    • 客戶端或服務器可以隨時取消 RPC。RPC會立即響應取消操作而終止,以便不再進行進一步的工作。

3.8 gRPC設置超時

  • 在 gRPC 中沒有找到傳統的超時設置,只看到在 stub 上有 deadline 的設置。但是這個是設置整個 stub 的 deadline,而不是單個請求。
    • 後來通過一個 deadline 的 issue 瞭解到,其實可以這樣來實現針對每次 RPC 請求的超時設置:
    blockingStub.withDeadlineAfter(3, TimeUnit.SECONDS)
    
    • 這裏的 .withDeadlineAfter() 會在原有的 stub 基礎上新建一個 stub,然後如果我們爲每次 RPC 請求都單獨創建一個有設置 deadline 的 stub,就可以實現所謂單個 RPC 請求的 timeout 設置。
    • 但是代價感覺有點高,每次RPC都是一個新的 stub,雖然底層肯定是共享信息。實際跑個測試試試性能,如果沒有明顯下降,就可以考慮採用這種方法。

3.9 gRPC問題解決

  • io.grpc.StatusRuntimeException: UNIMPLEMENTED
    • 這個錯誤網上很多,大部分情況下 是由於方法找不到,即客戶端與服務端proto的內容或者版本不一致,這裏只需要改成一致,一般問題都能解決
  • DEADLINE_EXCEEDED: deadline exceeded after 149944644ns
    • 這種錯誤明細我這裏就不打印了,這裏一般是讀取數據超時,
    • 問題原因:一般是grpc超時時間設置短了,或者下游服務響應超時。
    • 解決方案:修改grpc超時時間,或者檢查grpc服務端是否有問題
  • Exception: NAVAILABLE: upstream request timeout
    • 問題原因:這裏可以理解爲連接超時,這裏說明健康檢查也超時
    • 解決方案:檢查grpc服務端是否有問題。
  • INTERNAL: Received unexpected EOS on DATA frame from server
    • 問題原因:這裏可翻譯爲收到了空消息,這裏可能是服務端沒響應
    • 解決方案:檢查端口是否對應上,服務是否正常,特別是docker中的端口映射配置是否正確。
  • io.grpc.StatusRuntimeException: UNKNOWN
    • 問題原因: 從字面意思是未知錯誤,這個是服務端反饋,主要是服務端報了一些未知異常,比如說參數傳的有問題等
    • 解決方案: 檢查客戶端傳參是否有個別異常,打印出有問題參數

3.10 理解gRPC協議

  • gRPC基於HTTP/2/協議進行通信,使用ProtoBuf組織二進制數據流,gRPC的協議格式如下圖:
    • image
  • gRPC協議在HTTP協議的基礎上,對HTTP/2的幀的有效包體(Frame Payload)做了進一步編碼:gRPC包頭(5字節)+gRPC變長包頭,其中:
    • 5字節的gRPC包頭由:1字節的壓縮標誌(compress flag)和4字節的gRPC包頭長度組成;
    • gRPC包體長度是變長的,其是這樣的一串二進制流:使用指定序列化方式(通常是ProtoBuf)序列化成字節流,再使用指定的壓縮算法對序列化的字節流壓縮而成的。如果對序列化字節流進行了壓縮,gRPC包頭的壓縮標誌爲1。

04.gRPC通信實踐

4.1 gRPC通信技術點

  • gRPC一些關鍵技術點
    • 服務定義:使用Protocol Buffers語言定義服務接口和消息類型。通過定義.proto文件,指定服務的方法、輸入參數和返回類型。
    • 代碼生成:使用Protocol Buffers編譯器將.proto文件生成對應的客戶端和服務器端代碼。生成的代碼提供了類型安全的API,用於在客戶端和服務器端之間進行通信。
    • 傳輸協議:gRPC默認使用HTTP/2作爲傳輸協議,提供了雙向流、多路複用和頭部壓縮等特性,以提高性能和效率。
    • 序列化和反序列化:gRPC使用Protocol Buffers作爲默認的序列化和反序列化機制,將結構化數據轉換爲二進制格式進行傳輸。
    • 服務端實現:在服務器端,通過實現定義的服務接口,處理客戶端發起的請求並返回相應的結果。可以使用各種編程語言(如Java、C++、Python等)來實現服務器端邏輯。
    • 客戶端調用:客戶端通過生成的代碼調用服務器端提供的方法,將請求參數傳遞給服務器,並接收服務器返回的結果。
    • 攔截器和中間件:gRPC提供了攔截器和中間件機制,可以在請求和響應的處理過程中添加自定義的邏輯,例如身份驗證、日誌記錄等。
    • 錯誤處理:gRPC定義了一套錯誤碼和狀態碼,用於標識和處理不同類型的錯誤和異常情況。
    • 安全性:gRPC支持使用Transport Layer Security(TLS)進行通信加密,確保數據的安全性和完整性。
    • 反向代理:gRPC可以與反向代理(如Envoy)結合使用,以提供負載均衡、流量控制和故障恢復等功能。

4.2 Channel創建和複用

  • 爲何通道設置成複用
    • 對於客戶端來說建立一個channel是昂貴的,因爲創建channel需要連接。但是建立一個stub是很簡單的,就像創建一個普通對象,因此Channel就需要複用。
  • Channel通道如何創建
    //構建Channel。一般host和port格式爲:127.0.0.1:8080
    ManagedChannel channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build();
    //構建Channel。一般target格式爲:googleapis.com:8080
    ManagedChannel channel = ManagedChannelBuilder.forTarget(target).usePlaintext().build();
    
  • gRPC 提供了通道複用的功能,可以在客戶端和服務器之間共享和重用網絡連接,以提高性能和效率。
    • 通道複用允許多個 gRPC 調用共享同一個底層的網絡連接,從而減少了連接的建立和拆除的開銷。
    • 通道複用是 gRPC 的默認行爲,但也可以根據需要進行配置和調整。可以通過設置通道的參數來控制連接的複用策略、最大連接數、連接的空閒時間等。
  • 通道複用的工作原理
    • 創建通道:在客戶端代碼中,創建一個 gRPC 通道(Channel)對象。通道對象包含了與服務器建立連接所需的信息,如服務器的地址、端口和傳輸層協議等。
    • 通道連接服務器:客戶端的通道對象與服務器建立連接,並維護該連接的狀態。通道可以保持長時間的連接,以便後續的 gRPC 調用可以複用該連接。
    • 發起 gRPC 調用:客戶端使用通道對象來發起 gRPC 調用。每個 gRPC 調用都會在通道上創建一個新的 gRPC 流(Stream)。
    • 複用連接:如果客戶端發起的多個 gRPC 調用使用相同的通道對象,它們可以共享同一個底層的網絡連接。這樣,多個調用可以在同一個連接上覆用,避免了重複的連接建立和拆除的開銷。

4.3 如何添加公參

  • 可以通過自定義消息類型來添加公共參數。
    • 可以在請求和響應消息中定義公共參數,並在每次通信時將其包含在消息中。
  • 第一步:使用Protocol Buffers定義公共參數:在.proto文件中,定義一個消息類型,用於表示公共參數。
    • 可以創建一個名爲CommonParams的消息類型,並在其中定義公共參數字段,如userId、timestamp等。
    message CommonParams {
      string userId = 1;
      int64 timestamp = 2;
    }
    
  • 第二步:在請求和響應消息中包含公共參數。請求和響應消息中,將公共參數消息類型作爲一個字段包含進去。
    • 可以在請求消息中添加一個commonParams字段,並將CommonParams消息類型作爲其類型。
    message MyRequest {
      CommonParams commonParams = 1;
      // 其他請求參數...
    }
    
    
  • 第三步:在客戶端和服務器端設置公共參數:在每次通信之前,可以在客戶端和服務器端設置公共參數的值。
    • 在客戶端中,可以創建一個CommonParams對象,並將其設置爲請求消息的commonParams字段。
    CommonParams commonParams = CommonParams.newBuilder()
        .setUserId("user123")
        .setTimestamp(System.currentTimeMillis())
        .build();
    
    MyRequest request = MyRequest.newBuilder()
        .setCommonParams(commonParams)
        // 設置其他請求參數...
        .build();
    

4.4 請求/響應的讀寫操作

  • 請求和響應的讀寫是通過客戶端和服務器端的Stub對象進行的。
    • Stub對象是由gRPC編譯器生成的,用於在客戶端和服務器端之間進行通信。如何在gRPC中進行請求和響應的讀寫操作,如下所示:
  • 客戶端請求的寫入
    • 創建了一個請求消息對象request,並使用Stub對象的方法myMethod發送請求。該方法會返回一個響應消息對象response。
    // 創建請求消息對象
    MyRequest request = MyRequest.newBuilder()
        .setName("打工充")
        .setAge(30)
        .build();
    
    // 調用Stub對象的方法發送請求並獲取響應
    MyResponse response = stub.myMethod(request);
    
  • 服務器端請求的讀取
    • 在服務器端的方法中接收請求消息request,並從中讀取字段的值。然後創建一個響應消息對象response,並使用responseObserver對象將響應消息發送給客戶端。
    @Override
    public void myMethod(MyRequest request, StreamObserver<MyResponse> responseObserver) {
        // 從請求消息中讀取字段的值
        String name = request.getName();
        int age = request.getAge();
    
        // 創建響應消息對象
        MyResponse response = MyResponse.newBuilder()
            .setMessage("Hello, " + name + "! You are " + age + " years old.")
            .build();
    
        // 發送響應消息給客戶端
        responseObserver.onNext(response);
        responseObserver.onCompleted();
    }
    

4.5 網絡日誌打印

  • 在Java中使用gRPC時,您可以通過攔截器來實現日誌記錄。
    • 攔截器可以在gRPC調用的不同階段插入自定義邏輯,包括請求發送前、響應接收後等。
  • 創建攔截器的步驟如下所示:
    • 創建攔截器類:創建一個實現ServerInterceptor或ClientInterceptor接口的攔截器類。
    • 實現攔截邏輯:在攔截器類中,實現interceptCall方法,該方法會在每次gRPC調用時被調用。您可以在該方法中添加日誌記錄的邏輯。
    • 註冊攔截器:在服務器端或客戶端的gRPC配置中,註冊攔截器。具體的註冊方式取決於您使用的gRPC框架和版本。
  • 創建攔截器和使用攔截器代碼如下所示:
    //構建Channel中,添加攔截器
    channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext()
            //添加攔截器
            .intercept(new LoggingInterceptor())
            .build();
    
    
    //創建攔截器
    public class LoggingInterceptor implements ServerInterceptor, ClientInterceptor {
    
        private static final String TAG = "gRPC-yc";
    
        @Override
        public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
                MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next) {
            // 在請求發送前記錄日誌
            Log.d(TAG,"Sending request: " + method.getFullMethodName());
            // 調用下一個攔截器或服務實現
            return next.newCall(method,callOptions);
        }
    
        @Override
        public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
                ServerCall<ReqT, RespT> call, Metadata headers, ServerCallHandler<ReqT, RespT> next) {
            // 在請求接收前記錄日誌
            Log.d(TAG,"Received request: " + call.getMethodDescriptor().getFullMethodName());
            // 調用下一個攔截器或服務實現
            return next.startCall(call,headers);
        }
    }
    
  • 思考一下,如何監聽請求狀態操作。其實這裏可以借鑑日誌攔截!
    • 通過使用攔截器(Interceptor)來監聽和處理請求的狀態。攔截器是一種機制,允許您在 gRPC 調用的不同階段插入自定義的邏輯。
    • 創建一個實現 ClientInterceptor 接口的攔截器類,用於監聽客戶端請求狀態。然後在interceptCall方法中創建一個自定義的 ClientCall 實現類,用於監聽請求狀態

4.6 如何做網絡緩存

  • gRPC 並沒有內置的網絡緩存機制
    • 如果需要做緩存操作,則需要藉助代理服務器或其他緩存方案來實現網絡緩存。
  • 可以使用代理服務器來緩存 gRPC 的網絡請求和響應。
    • 代理服務器位於客戶端和服務器之間,攔截並緩存 gRPC 請求和響應。當客戶端發送請求時,代理服務器首先檢查是否有緩存的響應可用。
    • 如果有,代理服務器直接返回緩存的響應,而不需要將請求發送到服務器。這樣可以減少網絡傳輸和服務器負載。
  • 考慮緩存一致性的問題是很有必要的
    • 需要考慮緩存一致性的問題。由於 gRPC 的請求和響應可能包含動態的數據,緩存的數據可能會過時。
    • 因此,需要在緩存中實施一些機制來處理緩存一致性,如使用緩存標記(cache tags)或版本號來標識緩存的數據,並在數據更新時更新緩存。

4.7 如何請求域名

  • 目前請求網絡的方式有兩種:
    • ManagedChannelBuilder#forTarget(target)
    • ManagedChannelBuilder#forAddress(host, port),這種用的最常見。
  • 關於forTarget這個api,需要注意:傳入一個有效的命名解析器兼容(NameResolver-compliant)的URI,或者是一個 authority 字符串。
    • 注:authority 是URI中的術語, URI的標準組成部分如 [scheme:][//authority][path][?query][#fragment]
    • authority代表URI中的 [userinfo@]host[:port],包括host(或者ip)和可選的port和userinfo。
    • 命名解析器兼容(NameResolver-compliant)URI 是被作爲URI定義的抽象層次(absolute hierarchical)URI。實例的URI:
    “dns:///foo.googleapis.com:8080”
    “dns:///foo.googleapis.com”
    “dns:///%5B2001:db8:85a3:8d3:1319:8a2e:370:7348%5D:443”
    “dns://8.8.8.8/foo.googleapis.com:8080”
    “dns://8.8.8.8/foo.googleapis.com”
    “zookeeper://zk.example.com:9900/example_service”
    

4.8 如何處理錯誤和異常

  • gRPC 使用狀態碼來表示請求和響應的狀態,以便在通信過程中進行錯誤處理和故障排除。
    • OK (0): 請求成功完成。
    • CANCELLED (1): 請求被取消。
    • UNKNOWN (2): 發生未知錯誤。
    • INVALID_ARGUMENT (3): 請求參數無效或不符合預期。
    • DEADLINE_EXCEEDED (4): 請求超時。
    • NOT_FOUND (5): 請求的資源未找到。
    • ALREADY_EXISTS (6): 請求的資源已存在。
    • PERMISSION_DENIED (7): 請求被拒絕,沒有足夠的權限。
    • UNAUTHENTICATED (16): 請求未經身份驗證。
    • RESOURCE_EXHAUSTED (8): 資源耗盡,例如超過配額限制。
    • FAILED_PRECONDITION (9): 請求前置條件不滿足。
    • ABORTED (10): 請求被中止。
    • OUT_OF_RANGE (11): 請求超出範圍。
    • UNIMPLEMENTED (12): 請求的操作未實現。
    • INTERNAL (13): 內部服務器錯誤。
    • UNAVAILABLE (14): 服務不可用。
    • DATA_LOSS (15): 數據丟失或損壞。
  • 如何使用這些狀態碼,可以通過捕獲 StatusRuntimeException 異常來獲取 gRPC 調用過程中的狀態碼。
    try {
        MyResponse response = stub.myMethod(MyRequest.newBuilder().build());
        // 處理正常的響應
    } catch (StatusRuntimeException e) {
        // 處理 gRPC 調用過程中的錯誤
        Status status = Status.fromThrowable(e);
        if (status.getCode() == Status.Code.NOT_FOUND) {
            // 處理特定的錯誤碼
        } else if (status.getCode() == Status.Code.INVALID_ARGUMENT) {
            // 處理其他錯誤碼
        } else {
            // 處理其他未知錯誤
        }
    }
    

4.9 設置CA證書校驗

  • 可以使用CA證書來進行TLS/SSL連接的校驗,以確保通信的安全性。
    • 在客戶端,需要加載CA證書,並配置TLS/SSL連接。使用gRPC提供的ManagedChannelBuilder來配置客戶端的TLS/SSL連接。
    • caCertFile是CA證書文件的路徑。通過調用useTransportSecurity方法和sslContext方法,您可以配置客戶端使用TLS/SSL連接,並加載CA證書。
    // 創建 SSLContext
    SSLContext sc = SSLContext.getInstance("TLSv1.2");
    sc.init(null, getTrustManagers(isCa), null);
    // 創建 SSLSocketFactory
    Tls12SocketFactory socketFactory = new Tls12SocketFactory(sc.getSocketFactory());
    // 通過 channel 設置 sslSocketFactory做ssl安全處理
    ((OkHttpChannelBuilder) channelBuilder).sslSocketFactory(socketFactory);
    

4.10 如何保證安全性

  • 在 gRPC 中,可以採取以下措施來確保通信的安全性:
    • 使用 TLS/SSL:使用 TLS/SSL 來加密和保護通信。通過配置服務器和客戶端的 TLS/SSL 連接,可以確保數據在傳輸過程中的機密性和完整性。
    • 使用安全的認證和授權機制:在 gRPC 中,可以使用安全的認證和授權機制來限制對服務的訪問。例如,可以使用基於令牌的認證機制(如 OAuth)來驗證客戶端的身份,有點類似OkHttp請求中token令牌。
    • 安全地處理和傳輸敏感數據:確保在處理和傳輸敏感數據時採取適當的安全措施,如加密、哈希和脫敏等。避免在不安全的環境中傳輸敏感數據,如明文密碼或敏感個人信息。
    • 監控和日誌記錄:實施監控和日誌記錄機制,以便及時檢測和響應安全事件。記錄關鍵事件和異常。

4.11 如何兼容OkHttp

  • 在 gRPC 中使用 OkHttp 進行兼容處理,主要是需要添加 gRPC 和 OkHttp 的依賴項。
    • 創建 OkHttpChannelBuilder:在創建 gRPC 通道時,使用 OkHttpChannelBuilder 替代默認的 ManagedChannelBuilder。
    • 使用 OkHttpChannelBuilder 創建的 gRPC 通道將使用 OkHttp 作爲底層的網絡傳輸層,以提供更高級的功能和配置選項。
    ManagedChannel channel = OkHttpChannelBuilder.forAddress(host, port)
        .usePlaintext() // 使用明文連接,僅用於開發和測試
        .build();
    
  • OkHttpChannelBuilder 還提供了其他一些配置選項,例如:
    • .sslSocketFactory(sslSocketFactory, trustManager):用於設置自定義的 SSL Socket Factory 和 Trust Manager,以支持自定義的 TLS/SSL 配置。
    • .intercept(interceptor):用於添加 OkHttp 攔截器,以便在 gRPC 請求和響應的傳輸過程中進行自定義操作,如添加認證頭、日誌記錄等。
    • .connectionSpec(connectionSpec):用於設置 OkHttp 的連接規範,以控制支持的加密套件和協議版本。

05.ProtoBuf核心原理

5.1 ProtoBuf數據結構

  • Protocol Buffers(ProtoBuf)是一種用於序列化結構化數據的語言無關、平臺無關、可擴展的數據交換格式。
    • ProtoBuf 使用 .proto 文件定義數據結構,然後使用編譯器生成相應的代碼,以便在不同的編程語言中使用。
  • ProtoBuf 數據結構由以下幾個主要元素組成:
    • 消息(Message):消息是 ProtoBuf 中的基本數據單元,用於表示結構化的數據。消息由一個或多個字段組成,每個字段都有一個唯一的標識符和一個數據類型。消息可以嵌套在其他消息中,形成複雜的數據結構。
    • 字段(Field):字段是消息中的數據項,用於存儲具體的數據。每個字段都有一個唯一的標識符和一個數據類型。常見的數據類型包括整數、浮點數、布爾值、字符串等。字段還可以具有可選性、重複性和默認值等屬性。
    • 枚舉(Enum):枚舉用於定義一組有限的取值列表。每個枚舉值都有一個唯一的名稱和一個關聯的整數值。枚舉可以作爲消息的字段類型,用於表示一組預定義的取值。
    • 服務(Service):服務用於定義一組遠程過程調用(RPC)方法。每個方法都有一個唯一的名稱、輸入消息類型和輸出消息類型。服務定義了客戶端和服務器之間的通信接口。

5.2 ProtoBuf編碼方式

  • Protocol Buffers(ProtoBuf)提供了多種編碼方式來序列化和反序列化數據。
    • Binary 編碼:Binary 編碼是默認的編碼方式,將數據序列化爲緊湊的二進制格式。Binary 編碼具有高效的序列化和反序列化性能,以及較小的數據體積。這種編碼方式適用於網絡傳輸和持久化存儲。
    • JSON 編碼:ProtoBuf 還支持將數據序列化爲 JSON 格式。JSON 編碼使得數據更易於閱讀和調試,同時也方便與其他系統進行交互。但相比 Binary 編碼,JSON 編碼會產生更大的數據體積,並且序列化和反序列化的性能較低。
    • XML 編碼:ProtoBuf 還支持將數據序列化爲 XML 格式。XML 編碼使得數據更易於閱讀和處理,特別適用於與現有 XML 數據格式集成的場景。但與 JSON 編碼相比,XML 編碼通常會產生更大的數據體積,並且性能較低。
    • Text 編碼:ProtoBuf 還支持將數據序列化爲可讀的文本格式。Text 編碼使得數據更易於閱讀和調試,但相比 Binary 編碼,它會產生更大的數據體積,並且序列化和反序列化的性能較低。
  • Binary 編碼通常是最常用和推薦的方式
    • 因爲它具有最高的性能和最小的數據體積。這裏思考一下,如何是你,你會如何設計編碼方式來減少數據的體積,又不影響性能?
    • 字段編碼:對於每個字段,Binary 編碼使用變長編碼來表示字段的標識符和值。標識符由字段的編號和類型組成,以便在解碼時能夠正確地識別字段。
    • 消息編碼:對於消息,Binary 編碼將消息的字段按照編號的順序進行編碼。每個字段都使用字段編碼的方式進行編碼,並按照字段的編號進行排序。
    • 可選性和重複性:對於可選字段,Binary 編碼使用一個特殊的標記來表示字段是否存在。如果字段存在,則按照正常的字段編碼方式進行編碼;如果字段不存在,則不進行編碼。
    • 壓縮:Binary 編碼使用一些壓縮技術來減小數據的體積。

5.3 充分理解TLV設計

  • Type-Length-Value(TLV)是一種常見的數據編碼格式,也可以在 Protocol Buffers(ProtoBuf)中使用。TLV 格式將數據分爲三個部分:
    • Type(類型):表示數據的類型或標識符。通常使用一個固定長度的字段來表示類型,以便在解碼時能夠正確地識別數據的類型。
    • Length(長度):表示數據的長度。長度字段指示了接下來的 Value 字段的長度,以便在解碼時能夠正確地讀取數據。
    • Value(值):表示實際的數據內容。Value 字段包含了數據的實際值,其長度由 Length 字段指示。
  • TLV 格式可以通過使用字段的編號和類型來實現。每個字段都有一個唯一的編號和一個數據類型,可以使用這些信息來構建 TLV 格式的數據。
    • 假設有一個 ProtoBuf 消息定義如下:
    message MyMessage {
      int32 id = 1;
      string name = 2;
    }
    
  • 使用 TLV 格式,可以將該消息編碼爲以下格式:
    • 在解碼時,可以按照 TLV 的格式讀取數據,根據 Type 字段來識別字段的類型,根據 Length 字段來讀取相應長度的數據,然後將數據解析爲相應的類型。
    Type: 1
    Length: 4
    Value: <binary representation of id>
    
    Type: 2
    Length: <length of name>
    Value: <binary representation of name>
    

5.4 TLV設計中Type原理

    • 在 TLV 編碼中,Type 的計算方式可以通過將字段的編號與字段的類型信息進行組合來得到。通常,Type 的計算方式如下:
      • 其中,field_number 是字段的編號,wire_type 是字段的類型信息對應的編碼值。通過將字段的編號左移 3 位(相當於乘以 8),然後與字段的類型信息的編碼值進行按位或操作,可以得到 Type 的值。
      Type = (field_number << 3) | wire_type
      
  • 在 ProtoBuf 中,每個字段的類型都有一個對應的編碼值,用於表示字段的類型信息。例如,以下是一些常見的字段類型和對應的編碼值:
    • Varint 類型(包括 int32、int64、uint32、uint64、sint32、sint64、bool、enum)的編碼值爲 0。
    • 64 位固定長度類型(如 fixed64、sfixed64、double)的編碼值爲 1。
    • 字符串類型(如 string)的編碼值爲 2。
    • 嵌套消息類型(如 message)的編碼值爲 2。
    • 32 位固定長度類型(如 fixed32、sfixed32、float)的編碼值爲 5。
  • 例如,假設有一個字段的編號爲 1,類型爲 int32,那麼計算得到的 Type 值爲:
    • Type = (1 << 3) | 0 = 8 這樣就可以將 Type 值與 Length 和 Value 一起構成 TLV 編碼格式的數據。

5.5 TLV設計中Length原理

  • Varints(Variable-Length Integers)是 Protocol Buffers(ProtoBuf)中一種用於編碼整數的變長編碼方式。
    • Varints 可以有效地壓縮整數的表示,並且適用於表示小整數和大整數。
  • 爲什麼會有Varints編碼算法
    • 對於不包含length字段的編碼格式,如何確定value的長度以對各個數據進行分割?
    • 一種方法是根據type類型確定value域長度,這種方法的問題在於會浪費一定的存儲空間,例如存儲數字1,也需要int32的類型,若增加type的數量,則存儲type佔用的空間也會相應增加;
    • 第二種方法則是protobuf採用的Varint類型。
  • Varints 的編碼規則如下:
    • 對於非負整數(包括 0),Varints 使用 7 個比特位來表示每個字節的數據,其中最高位(第 8 位)用於指示是否還有後續字節。如果最高位爲 1,則表示後續字節仍然屬於該整數;如果最高位爲 0,則表示該字節是整數的最後一個字節。
    • 對於負整數,Varints 使用 ZigZag 編碼來表示。ZigZag 編碼將有符號整數轉換爲無符號整數,通過將最低有效位(LSB)移動到最高有效位(MSB),並使用 Varints 進行編碼。這樣可以減小負整數的表示範圍,從而減小編碼的長度。
  • 下面是一個示例,展示了幾個整數的 Varints 編碼:
    • 整數 300 的二進制表示: 00000000 00000000 00000001 00101100
    • 整數 300 Varints 編碼: 10100110 00000010
  • 那麼如何讀取整數 300 Varints 編碼呢
    • 從左往右讀取,第一個字節的msb=1,所以需要繼續往後再讀取一個字節,這時讀取到的字節msb=0,則數據已經讀取到最後一個字節,讀取完畢,若第二個字節的msb依然爲1,則繼續往後讀取,直到讀取到msb=0的字節。之後對這兩個字節的數據進行解析。
    • 第一步:解析的第一步,去除每個字節的msb位,每個字節只剩下 7 bits:
       1010 1100 0000 0010
      → 010 1100  000 0010
      
    • 第二步:之後對字節進行反轉得到補碼,還原成原碼即可:
        010 1100 0000 010 
      → 000 0010 0101 100
      → 1 0010 1100 = 300
      
  • Varints 的本質實際上是每個字節都犧牲一個 bit 位(msb)來表示是否已經結束
    • msb 實際上就起到了 length 的作用,正因爲有了 msb(length),所以可以根據數字大小動態調整需要的字節數量。
    • 通過varints我們可以讓小的數字用更少的字節表示,從而提高了空間利用和效率。

06.gRPC核心設計思想

6.1 Channel核心設計思想

  • gRPC 的 Channel 層是其核心設計思想
    • 負責管理底層的網絡連接和通信。
  • 連接管理:
    • Channel 層負責管理與遠程服務器的連接。它維護一個連接池,可以重用現有的連接,避免頻繁地創建和銷燬連接。這樣可以提高性能並減少資源消耗。
  • 流控制:
    • Channel 層使用 HTTP/2 協議作爲底層的傳輸協議,利用其流控制機制來控制數據的傳輸速率。這樣可以避免發送方過載或接收方無法處理的情況,保證通信的穩定性和可靠性。
  • 消息壓縮:
    • Channel 層支持消息的壓縮和解壓縮,以減少數據的傳輸量。它使用基於 HTTP/2 的頭部壓縮機制來壓縮請求和響應的元數據,以及使用可選的消息壓縮算法來壓縮消息體。這樣可以提高網絡傳輸的效率。
  • 超時和重試:
    • Channel 層支持設置請求的超時時間,並提供重試機制來處理請求失敗的情況。當請求超時或失敗時,Channel 層可以自動重試請求,以增加請求的可靠性和穩定性。

6.2 Stub核心設計思想

  • gRPC 的 Stub 是其核心設計思想
    • 用於在客戶端和服務器之間進行遠程過程調用(RPC)。
  • 接口定義語言(IDL):
    • gRPC 使用 Protocol Buffers(protobuf)作爲接口定義語言,用於定義服務接口和消息格式。Stub 根據接口定義生成客戶端和服務器端的代碼,使得開發人員可以方便地定義和實現遠程服務。
  • 強類型接口:
    • Stub 生成的代碼提供了強類型的接口,使得客戶端可以直接調用遠程服務的方法,就像調用本地方法一樣。這種強類型接口提供了更好的類型安全性和編譯時檢查,減少了錯誤和調試的複雜性。
  • 序列化和反序列化:
    • Stub 使用 Protocol Buffers(protobuf)作爲默認的序列化和反序列化機制。它將請求和響應消息序列化爲二進制格式進行傳輸,以及將二進制數據反序列化爲消息對象。這種序列化機制使得數據傳輸更緊湊和高效。
  • 異步和流式通信:
    • Stub 支持異步和流式通信,使得客戶端和服務器可以以非阻塞的方式進行通信。客戶端可以發送異步請求並接收異步響應,或者使用流式請求和響應來處理流式數據。這種能力使得 gRPC 在實時應用、流式處理等場景中非常有用。

6.3 NameResolver核心設計思想

  • gRPC 的 NameResolver 是其核心設計思想
    • 用於解析服務名稱並獲取對應的服務器地址。
  • 服務名稱解析:NameResolver 負責將服務名稱解析爲對應的服務器地址。
    • 它可以根據不同的解析策略,如 DNS 解析、配置文件解析、服務發現等,將服務名稱映射到一個或多個服務器地址。
  • 動態更新:NameResolver 支持動態更新服務器地址。
    • 它可以監聽服務地址的變化,並在地址發生變化時及時更新客戶端的連接。這樣可以實現服務的動態發現和負載均衡。
  • 負載均衡:NameResolver 支持負載均衡,可以在多個服務器實例之間分配請求。
    • 它可以根據預定義的負載均衡策略選擇合適的服務器,並將請求發送到相應的服務器上。這樣可以實現高可用性和擴展性。
  • 緩存和重試:NameResolver 可以緩存解析的服務器地址
    • 在需要時使用緩存的地址。它還支持重試機制,以處理解析失敗或超時的情況。這樣可以提高解析的效率和可靠性。

6.4 gRPC網絡框架設計層次

  • 應用層:應用層是最高層,包含了實際的業務邏輯和應用程序代碼。
    • 在 gRPC 中,應用層使用 Protocol Buffers(ProtoBuf)定義服務和消息,並通過 gRPC 提供的代碼生成工具生成相應的客戶端和服務器代碼。
  • gRPC Stub/Client
    • gRPC Stub(或稱爲 gRPC Client)是客戶端代碼的一部分,它提供了與服務器進行通信的接口。客戶端使用 Stub 來調用服務器端的方法,併發送請求消息。
  • gRPC Channel通信通道
    • gRPC Channel 是客戶端與服務器之間的通信通道。客戶端使用 Channel 來與服務器建立連接,併發送請求消息。Channel 提供了負載均衡、連接管理和錯誤處理等功能。
  • gRPC Core核心引擎
    • gRPC Core 是 gRPC 的核心引擎,提供了底層的網絡通信和協議處理功能。它實現了 gRPC 的協議規範,處理請求和響應的序列化、反序列化、壓縮、安全性等方面的功能。
  • 傳輸層:傳輸層負責在客戶端和服務器之間傳輸數據。
    • gRPC 支持多種傳輸層協議,如 HTTP/2、TCP、gRPC-over-HTTP/1.1 等。HTTP/2 是 gRPC 的默認傳輸層協議,它提供了高效的多路複用、流控制和頭部壓縮等特性。

07.gRPC核心原理

7.1 gRPC核心設計思路

  • gRpc源碼核心設計說明
    • 類庫有三個不同的層: Stub/樁, Channel/通道 & Transport/傳輸。
  • Stub
    • Stub層暴露給大多數開發者,並提供類型安全的綁定到正在適應(adapting)的數據模型/IDL/接口。
    • gRPC帶有一個protocol-buffer編譯器的 插件用來從.proto 文件中生成Stub接口。當然,到其他數據模型/IDL的綁定應該是容易添加並歡迎的。
  • Channel
    • Channel層是傳輸處理之上的抽象,適合攔截器/裝飾器,並比Stub層暴露更多行爲給應用。
    • 它想讓應用框架可以簡單的使用這個層來定位橫切關注點(address cross-cutting concerns)如日誌,監控,認證等。
    • 流程控制也在這個層上暴露,容許更多複雜的應用來直接使用它交互。
  • Transport
    • Transport層承擔在線上放置和獲取字節的繁重工作。它的接口被抽象到恰好剛剛夠容許插入不同的實現。Transport被建模爲Stream工廠。gRPC帶有三個Transport實現:
    • 基於Netty 的transport是主要的transport實現,基於Netty. 可同時用於客戶端和服務器端。
    • 基於OkHttp 的transport是輕量級的transport,基於OkHttp. 主要用於Android並只作爲客戶端。
    • inProcess transport 是當服務器和客戶端在同一個進程內使用使用。用於測試。

7.3 域名解析流程

  • NameResolver 是可拔插的組件,用於解析目標 URI 並返回地址給調用者。
    • NameResolver 使用 URI 的 scheme 來檢測是否可以解析它, 再使用 scheme 後面的組件來做實際處理。
  • 域名解析涉及到的核心類
    • NameResolver是一個抽象類。抽象方法start()和shutdown()分別是開始解析和停止解析。通過Factory工廠類調用newNameResolver()方法創建對象。
    • DnsNameResolver是一個實現類。包級私有,通過DnsNameResolverFactory類來創建。其dns解析核心邏輯在這個類中。
  • DnsNameResolver中解析域名流程
    • DnsNameResolver#構造函數,傳入一些參數,將參數簡單保存起來。
    • DnsNameResolver#start(),開始解析,傳入 listener 用於接收目標的更新。
    • DnsNameResolver#resolve(),開始做實際的解析,這裏通過線程池創建一個任務,然後看Resolve類
    • DnsNameResolver#Resolve#run(),主要做3步。第一步解析地址,第二步包裝格式,第三步通知listener。注意這個工作是在異步線程中進行的,只能通過listener。
    • DnsNameResolver#shutdown(),將停止解析,同時更新 listener 將會停止。
  • DnsNameResolver中解析地址失敗的流程
    • DnsNameResolver#Resolve#run(),以此爲入口,然後調用doResolve解析返回一個result結果,這裏面判斷result.error如果不爲空,則執行解析失敗處理邏輯。
    • NameResolver#Listener2#onError(),回調這個方法,是一個抽象類,看具體的實現類,可以定位到NameResolverListener
    • ManagedChannelImpl#NameResolverListener#onError(),這裏面處理解析異常邏輯。開啓一個線程做任務
    • NameResolverListener#NameResolverErrorHandler#run(),然後繼續往下看scheduleExponentialBackOffInSyncContext()方法
    • ManagedChannelImpl#scheduleExponentialBackOffInSyncContext(),通過線程池發送一個延遲1分鐘的DelayedNameResolverRefresh刷新任務,在這個任務裏調用nameResolver.refresh()刷新
    • nameResolver.refresh(),將會再次調用解析地址操作。只是在每次解析失敗時,一旦解析成功,就會跳出循環。

7.4 Channel層設計原理

  • 如何理解Channel層設計
    • Channel 到概念上的端點的虛擬連接,用於執行RPC。 通道可以根據配置,負載等自由地實現與端點零或多個實際連接。
    • 通道也可以自由地確定要使用的實際端點,並且可以在每次 RPC 上進行更改,從而允許客戶端負載平衡。應用程序通常期望使用存根(stub),而不是直接調用這個類。
  • Channel抽象類的設計
    • 抽象newCall()方法,構建一個用於遠程操作的 ClientCall 對象,返回的 ClientCall 對象不會觸發任何遠程行爲,直到 ClientCall.start() 方法被調用。
    • 抽象authority()方法,這個 Channel 連接到的目的地的 authority。通常是以 “host:port” 格式。
  • ManagedChannel抽象類的設計
    • 類ManagedChannel 在 Channel 的基礎上提供生命週期管理的功能。
    • shutdown()/shutdownNow() 方法用於關閉 Channel。shutdown方法發起一個有組織的關閉,期間已經存在的調用將繼續,而新的調用將被立即取消。
    • isShutdown()/isTerminated() 方法用於檢測 Channel 狀態。isShutdown返回channel是否是關閉。關閉的channel立即取消任何新的調用,但是將繼續有一些正在處理的調用。
    • awaitTermination() 方法用於等待關閉操作完成。該方法是等待 channel 變成結束,如果超時時間達到則放棄。
  • ManagedChannelImpl實現類的設計
    • exitIdleMode()方法,讓 Channel 退出空閒模式,如果它處於空閒模式中。返回一個新的可以用於處理新請求的 LoadBalancer。如果 Channel 被關閉則返回null。
    • ManagedChannelImpl - InUseStateAggregator,空閒模式的進入和退出是由它來進行控制。實現原理是每個要使用這個聚合器的調用者都要存進來一個對象,然後用完之後再取出來,這樣可以通過保存對象的數量變化判斷開始使用(從無到有)或者已經不再使用(從有到無)。
    • ManagedChannelImpl - Name Resolver,name resolver 的 start() 方法,也就是 name resolver 要開始解析name的這個工作,只有兩種情況下開始:
      • 第一次RPC請求: 此時要調用Channel的 newCall() 方法得到ClientCall的實例,然後調 ClientCall 的 start()方法,期間獲取ClientTransport時激發一次 name resolver 的 start()
      • 如果開啓了空閒模式:則在每次 Channel 從空閒模式退出,進入使用狀態時,再激發一次 name resolver 的 start()
  • ManagedChannelBuilder抽象類的設計
    • forAddress()和forTarget()這個是請求host和port的操作,必須調用該方法。
    • directExecutor(),直接在傳輸的線程中執行應用代碼。取決於底層傳輸,使用一個 direct executor 可能引發重大的性能提升。當然,它也要求應用在任何情況下不阻塞。
    • executor(),如果在channel構建時沒有提供executor,builder將使用一個靜態緩存的線程池。
    • intercept(),添加攔截器,被channel執行它的實際工作前被調用。
    • userAgent(),這是一個可選參數。如果提供,給定的agent將使用grpc User-Agent作爲前綴。
    • overrideAuthority(),覆蓋和TLS和HTTP 虛擬主機服務一起使用的authority。它不會改變實際連接到的主機。通常是以host:port的形式。應該僅用於測試。
    • usePlaintext(),使用 plaintext 連接到服務器。默認將使用加密連接機制如 TLS 。應該僅用於測試或者用於那些API使用或者數據交換並不敏感的API。
    • nameResolverFactory(),爲channel提供一個定製的 NameResolver.Factory。如果這個方法沒有被調用,builder將在全局解析器註冊(global resolver registry)中爲提供的目標尋找一個工廠。
    • idleTimeout(),設置在進入空閒模式前的沒有RPC的期限。在空閒模式中,channel會關閉所有連接,NameResolver 和 LoadBalancer 。新的RPC將把channel帶出空閒模式。channel以空閒模式開始。
    • build(),使用給定參數來構建一個channel
  • ManagedChannelProvider抽象類的設計
    • Channel Provider 的功能在於幫助創建合適的 ManagedChannelBuilder。目前有多套 Channel 的實現,典型如 netty 和 okhttp ,選擇哪套實現就是一個需要特別考慮的問題。
    • Channel Provider 的設計目標是解藕這個事情,不使用配置,hard code等方式,而是將細節交給 Channel Provider 的具體實現。
    • 在 ManagedChannelBuilder 中這樣調用 ManagedChannelProvider,其中 provider() 靜態方法會根據實際情況選擇一套可用的方案,然後 builderForAddress()方法和 forTarget() 方法會創建對應的ManagedChannelBuilder。
    public abstract class ManagedChannelBuilder<T extends ManagedChannelBuilder<T>> {
        public static ManagedChannelBuilder<?> forAddress(String name, int port) {
            return ManagedChannelProvider.provider().builderForAddress(name, port);
        }
        public static ManagedChannelBuilder<?> forTarget(String target) {
            return ManagedChannelProvider.provider().builderForTarget(target);
        }
    }
    
    • 一個抽象基類 ManagedChannelProvider,然後 okhttp(OkHttpChannelProvider) 和 netty(NettyChannelProvider) 各實現了一個子類。

7.4 Stub層設計原理

  • 生成類HelloWorldGrpc.HelloWorldStub介紹
    • 這個類是通過grpc的proto編譯器生成的類,它的package由.proto文件中的 java_package 選項指定。這個XxxStub類是繼承AbstractStub抽象類。
  • 類AbstractStub是stub實現的通用基類設計
    • 類AbstractStub也是生成代碼中的stub類的通用基類。這個類容許重定義,例如,添加攔截器到stub。
    • AbstractStub()構造函數,類AbstractStub有兩個屬性:Channel channel,CallOptions callOptions
    • build()抽象方法,定義了抽象方法build()方法來返回一個新的stub,使用給定的Channel和提供的方法配置。
    • with方法族,定義有多個with×××()方法,通過創建新的 CallOptions 實例,然後調用上面的build()方法來返回一個新的stub。

項目demo地址:https://github.com/yangchong211/YCServerLib

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章