博客 / 詳情

返回

gRPC的理解與使用

協議介紹

gRPC 是谷歌開源的一套 RPC 協議框架,底層使用HTTP/2協議,主要有兩部分,數據編碼以及請求映射

數據編碼是將內存對象編碼為可傳輸的字節流,也包括把字節流轉化為內存對象,常見的包含json, msgpack, xml, protobuf,其中該編碼效率比json高一些,grpc選擇使用protobuf

gRPC為什麼基於HTTP2

HTTP1.1遇到的問題

  • 協議繁瑣,包含很多的細節設計,也預留了很多未來擴展選項,所以沒有軟件實現了協議中提及的全部細節
  • 協議規定是一發一收這種模式,相當於一個先進先出的串行隊列,HTTP Pipelining 把多個 HTTP 請求放到一個 TCP 連接中來發送,發送過程中不需要服務器對前一個請求的響應,但是在客户端,還是會按照發送的順序來接收響應請求,導致 HTTP 頭阻塞(Head-of-line blocking)

HTTP2的特性與組成

  • HEAD 頭數據壓縮: 對 HTTP 頭字段進行數據壓縮,因為 HTTP 頭包含了大量冗餘數據,HTTP2對這些數據進行了壓縮,壓縮後對於請求大小的影響顯著,可以將多個請求壓縮到一個包中,減小傳輸負載
  • 多路複用: 每個 HTTP 請求/應答在各自的流(stream,每個流都是相互獨立,有一個整數ID 標識,是存在於TCP連接中的一個虛擬連接通道,可以承載雙向消息)中完成數據交換,如果一個請求/應答阻塞或者速度很慢,也不會影響其它流中的請求/應答處理,在一個 TCP 連接中就可以傳輸多個流數據而無需建立多個連接
  • 流量控制和優先級機制: 可以有效利用流的多路複用機制,流量控制可以確保只有接收者使用的數據會被傳輸,優先級機制可以確保重要的資源被優先傳輸
  • 服務端推送: 即服務端可以推送應答給客户端
  • 消息報文二進制編碼
  • 最小傳輸單元幀(frame)HTTP2 定義了很多類型的幀,每個幀服務於不同的目的,數據幀中有 1 個關鍵數據,這個幀屬於哪個資源,消息由一個或多個幀構成

json

全稱JavaScript Object Notation,一種輕量級的數據交換格式,具有良好的可讀和便於快速編寫的特性。可在不同平台之間進行數據交換,在json出現以前,常用的是xml(Extensiable Markup Language)進行文件傳輸

xmljson的共同優點

  • 可讀性好,結構清晰
  • 分層存儲(層次嵌套)
  • 都可作為Ajaxs傳輸數據
  • 都跨平台,可作為數據傳輸格式

json的優點

  • 數據格式簡單,易讀易寫,且數據都是壓縮的,文件較小,便於傳輸
  • json解析難度較低,而xml需要循環遍歷DOM進行解析,效率較低
  • 服務端和客户端可以直接使用json,便於維護,而不同客户端解析xml可能使用不同方法
  • json 已成為當前服務器與 web 應用之間數據傳輸的公認標準

xml的應用領域

  • xml格式較為嚴謹,可讀性更強,更易於拓展,可以良好的做配置文件
  • 出現較早,在各個領域有廣泛的應用,具有普遍的流行性

json語法規則

json語法是JavaScript語法的子集,而json一般也是用來傳輸對象數組。也就是json語法是JavaScript語法的一部分(滿足特定語法的JavaScript語法)

  • 數據保存在名稱、值對中,數據由逗號分隔
  • 花括號表示對象
  • 中括號表示數組

json名稱/值

json 數據的書寫格式為:"名稱":"值"
對應JavaScript的概念就是:名稱="值"
json的格式和JavaScript對象格式還是有所區別:

  • JavaScript對象的名稱可以不加引號,也可以單引號,也可以雙引號,但json字符串的名稱只能加雙引號的字符表示。
  • JavaScript對象的鍵值可以是除json值之外還可以是函數等其他類型數據,而json字符串的值對只能是數字、字符串(要雙引號)、邏輯值、對象(加大括號)、數組(中括號)、null

json對象

json有兩種表示結構—對象和數組,通過這兩種表示結構可以表示更復雜的結構。對比java的話json數組和json對象就好比java的列表/數組(Object類型)和對象(Map)一樣的關係。並且很多情況其對象值可能相互嵌套多層,對對象中存在對象,對象中存在數組,數組中存在對象

JavaScript對象 / json對象 / json字符串

//JavaScript對象, 除了字符串、數字、true、false、null和undefined之外,JavaScript中的值都是對象
var a1={ name:"pky" , sex:"man", value: 12345 };
var a2={'name':'pky' , 'sex':'man', 'value': 12345};
//滿足json格式的JavaScript對象, json對象
var a3={"name":"pky" , "sex":"man", "value": 12345};
//json字符串
var a4='{"name":"pky" , "sex":"man", "value": 12345}';

json主要缺點是非字符串的編碼效率比較低,上面的數據比如value字段的值,在內存中是12345,佔用2字節,json編碼轉變為json字符串之後佔用5字節

Protobuf

Protobuf 一方面選用了 VarInts 對數字進行編碼,解決了效率問題;另一方面給每個字段指定一個整數編號,傳輸的時候只傳字段編號,解決冗餘問題

數據編碼

protobuf使用.proto文件作為編號與字段映射關係的對照表

message Demo {
  int32 i = 1;
  string s = 2;
  bool b = 3;
}

每個字段後面的數字是tag,不能重複,和字段一一對應

Protobuf 提供了一系列工具,為 proto 描述的 message 生成各種語言的代碼

請求映射

proto文件作為IDL,可以做到RPC描述,比如最簡單的一個hello.proto文件如下

package demo.hello;

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

定義了一個 Greeter 服務,其中有一個 SayHello 的方法,接受 HelloRequest 消息並返回 HelloReply 消息

一個gRPC 定義包含三個部分,包名、服務名和接口名,連接規則如下

/${包名}.${服務名}/${接口名}

上述hello.proto的包名是demo.hello,服務名是Greeter,接口名是SayHello,所以對應的路徑就是 /demo.hello.Greeter/SayHello

gRPC 協議規定Content-Typeheader 的取值為application/grpc或者application/grpc+proto,使用 JSON 編碼,可以設成application/grpc+json

gRPC的流式接口

gRPC可以源源不斷收發消息,有別於HTTP/1.1的一收一發模式

gRPC 持三種流式接口,定義的辦法就是在參數前加上 stream 關鍵字,流類型包含如下

  • 請求流:在 RPC 發起之後不斷髮送新的請求消息,場景有發推送或者短信
  • 響應流:在 RPC 發起之後不斷接收新的響應消息,場景有訂閲消息通知
  • 雙向流:在 RPC 發起之後同時收發消息,場景有實時語音轉字幕

對應.proto如下

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {}
  rpc SayHello (stream HelloRequest) returns (HelloReply) {}
  rpc SayHello (HelloRequest) returns (stream HelloReply) {}
  rpc SayHello (stream HelloRequest) returns (stream HelloReply) {}
}

為了實現流式傳輸,gRPC 引入Length-Prefixed Message,同一個 gRPC 請求的不同消息共用 HTTP 頭信息,給每個消息單獨加一個五字節的前綴來表示壓縮和長度信息,第一個字節表示字節流是否被壓縮,後四個字節存儲數據長度

非流式gRPC請求格式

POST /demo.hello.Greeter/SayHello HTTP/1.1
Host: grpc.demo.com
Content-Type: application/grpc
Content-Length: 1234

<Length-Prefixed Message>

非流式gRPC返回格式

HTTP/1.1 200 OK
Content-Length: 5678
Content-Type: application/grpc

<Length-Prefixed Message>

非流式gRPC調用,跟普通的 HTTP 請求也沒有太大區別,可以使用 HTTP/1.1 來承載 gRPC 流量

流式gRPC請求格式,如下,請求分為header framedata frame,共計傳輸兩個frame

HEADERS (flags = END_HEADERS) # header frame
:method = POST
:scheme = http
:path = /demo.hello.Greeter/SayHello
:authority = grpc.demo.com
content-type = application/grpc+proto

DATA (flags = END_STREAM) # data frame
<Length-Prefixed Message>

流式gRPC響應,共傳輸3個frame

HEADERS (flags = END_HEADERS) # header frame
:status = 200
content-type = application/grpc+proto

DATA # data frame
<Length-Prefixed Message>

HEADERS (flags = END_STREAM, END_HEADERS) # header frame
grpc-status = 0

流式gRPC使用HTTP/2 ,請求與響應的 headerdata 使用獨立的 frame

gRPCrust實踐helloworld

依賴項目tonic

https://github.com/hyperium/tonic

創建一個項目hello

$ cargo new hello
$ cd new

先安裝 protoc Protocol Buffers 編譯器以及 Protocol Buffers 資源文件

Ubuntu

$ sudo apt update && sudo apt upgrade -y
$ sudo apt install -y protobuf-compiler libprotobuf-dev

定義一個helloworld.proto文件

$ mkdir proto
$ touch proto/helloworld.proto
syntax = "proto3";
package helloworld;

service Greeter {
    rpc SayHello (HelloRequest) returns (HelloReply);
}

message HelloRequest {
   string name = 1;
}

message HelloReply {
    string message = 1;
}

修改Cargo.toml新增如下

[dependencies]
# 用於從proto2/proto3文件生成rust 代碼
prost = "0.11"
tokio = { version = "1", features = ["full"] }
tonic = "0.9"

[build-dependencies]
# 用於在build階段生成gRPC的客户端和服務端代碼
tonic-build = "0.9"

在項目根路徑下創建一個build.rs用於編譯時生成代碼

fn main() -> Result<(), Box<dyn std::error::Error>> {
    tonic_build::compile_protos("proto/helloworld.proto")?;
    Ok(())
}

編寫服務端代碼src/bin/server.rs

use tonic::{transport::Server, Request, Response, Status};

use hello_world::greeter_server::{Greeter, GreeterServer};
use hello_world::{HelloReply, HelloRequest};

pub mod hello_world {
    tonic::include_proto!("helloworld");
}

#[derive(Default)]
pub struct MyGreeter {}

#[tonic::async_trait]
impl Greeter for MyGreeter {
    async fn say_hello(
        &self,
        request: Request<HelloRequest>,
    ) -> Result<Response<HelloReply>, Status> {
        println!("Got a request from {:?}", request.remote_addr());

        let reply = hello_world::HelloReply {
            message: format!("Hello {}!", request.into_inner().name),
        };
        Ok(Response::new(reply))
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr = "[::1]:50051".parse().unwrap();
    let greeter = MyGreeter::default();

    println!("GreeterServer listening on {}", addr);

    Server::builder()
        .add_service(GreeterServer::new(greeter))
        .serve(addr)
        .await?;

    Ok(())
}

客户端代碼

use hello_world::greeter_client::GreeterClient;
use hello_world::HelloRequest;

pub mod hello_world {
    tonic::include_proto!("helloworld");
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut client = GreeterClient::connect("http://[::1]:50051").await?;
    for i in 0..3 {
        let request = tonic::Request::new(HelloRequest {
            name: format!("Tonic {i}"),
        });
        let response = client.say_hello(request).await?;
        println!("RESPONSE={:?}", response);
    }
    Ok(())
}

最後整個項目的結構如下

.
├── build.rs
├── Cargo.lock
├── Cargo.toml
├── proto
│   └── helloworld.proto
└── src
    ├── bin
    │   ├── client.rs
    │   └── server.rs
    └── main.rs

啓動服務端

$ cargo run --bin server

新開終端,啓動客户端

$ cargo run --bin client
    Finished dev [unoptimized + debuginfo] target(s) in 0.06s
     Running `target/debug/client`
RESPONSE=Response { metadata: MetadataMap { headers: {"content-type": "application/grpc", "date": "Thu, 24 Aug 2023 06:54:10 GMT", "grpc-status": "0"} }, message: HelloReply { message: "Hello Tonic 0!" }, extensions: Extensions }
RESPONSE=Response { metadata: MetadataMap { headers: {"content-type": "application/grpc", "date": "Thu, 24 Aug 2023 06:54:10 GMT", "grpc-status": "0"} }, message: HelloReply { message: "Hello Tonic 1!" }, extensions: Extensions }
RESPONSE=Response { metadata: MetadataMap { headers: {"content-type": "application/grpc", "date": "Thu, 24 Aug 2023 06:54:10 GMT", "grpc-status": "0"} }, message: HelloReply { message: "Hello Tonic 2!" }, extensions: Extensions }

查看服務端輸出如下

$ cargo run --bin server
....
    Finished dev [unoptimized + debuginfo] target(s) in 0.06s
     Running `target/debug/server`
GreeterServer listening on [::1]:50051
Got a request from Some([::1]:54820)
Got a request from Some([::1]:54820)
Got a request from Some([::1]:54820)

配置wiresharkproto buffers加載.proto文件路徑

配置分析(A)解碼為(decode as),配置解析TCP50051端口為HTTP2協議

使用wireshark抓包如下

有以下幾點需要注意

  • 可以看到共有一次TCP三次握手以及一次揮手斷開
  • TCP連接建立成功之後,會發送一個Magic幀,之後緊跟着SETTINGS幀(幀類型 = 0x4遞影響端點通信方式的配置參數,例如設置對端行為的首選項和約束)
  • 每個gRPC包裏面會有多個stream

閲讀參考

理解 gRPC 協議

json從入門到實踐

HTTP2 協議長文詳解

tonic hello world readme

HTTP2幀定義

user avatar yanglei_5943a8271f6f8 頭像
1 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.