协议介绍

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帧定义


龚正阳
29 声望5 粉丝

粗犷型程序员