grpc框架分析之sprinboot整合grpc

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/qq_38697245/article/details/89555920

gRPC简介:
       gRPC 一开始由 google 开发,是一款语言中立、平台中立、开源的远程过程调用(RPC)系统。面向移动和 HTTP/2 设计。目前提供 C、Java 和 Go 语言版本,分别是:grpc, grpc-java, grpc-go. 其中 C 版本支持 C, C++, Node.js, Python, Ruby, Objective-C, PHP 和 C# 支持。
      gRPC 基于 HTTP/2 标准设计,带来诸如双向流、流控、头部压缩、单 TCP 连接上的多复用请求等特。这些特性使得其在移动设备上表现更好,更省电和节省空间占用。

一、什么是RPC
在讲解gRPC之前先简单介绍下RPC。已经了解的童鞋,可以直接跳过。
RPC(Remote Procedure Call)—远程过程调用,它是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络技术的协议。RPC协议假定某些传输协议的存在,如TCP或UDP,为通信程序之间携带信息数据。在OSI网络通信模型中,RPC跨越了传输层应用层。RPC使得开发包括网络分布式多程序在内的应用程序更加容易。

RPC出现的原因
随着项目越来越大,访问量越来越大,为了突破性能瓶颈,需要将项目拆分成多个部分,这样比起传统的项目都是本地内存调用,而分布式的项目之间需要在网络间进行通信。
RPC要解决的两个问题:
    1.解决分布式系统中,服务之间调用的问题
    2.远程调用时,要能够像本地调用一样方便,让调用者感受不到远程调用的逻辑

为什么需要RPC
1.因为相比于HTTP协议,RPC采用二进制字节码传输,更加高效也更加安全
2.现在业界提倡“微服务“的概念,而服务之间通信目前有两种方式,RPC就是其中一种。RPC可以保证不同服务之间的互相调用 。即使是跨语言跨平台也不是问题,让构建分布式系统更加容易。
3.RPC框架都会有服务降级、流量控制的功能,保证服务的高可用。

RPC调用方式
服务之间的远程调用通常有两种方式,即基于TCP的远程调用和基于Http的远程调用。
1.基于TCP的RPC实现:
主要是服务提供方定义socket端口和提供的方法名称已经需要的参数结构,服务调用方通过连接服务方的socket端口,进而调用相关方法,并且将需要通信的数据作为参数传递,需要值得注意的是参数在传递的时候需要在服务调用端进行序列化然后在服务提供端进行反序列化。netty之间的通信方式,就是一种基于tcp的远程调用

2.基于HTTP的RPC实现:
对于HTTP的RPC实现,与现在的restful风格很类似,主要是在服务调用方通过标识请求,GET,POST,PUT,DELETE等,然后通过url来定位到服务提供方提供的服务,数据通过xml或者json来传输,省去了TCP的序列化和反序列化。

3.两者的区别:
tcp是基于socket通信,在协议层面处于较底层,优点是传输效率高,但是开发难度相对较高;而HTTP处于较高层面,开发难度相对较小,不用维护socket端口和数据序列化相关问题,但是传输效率比起TCP来低了一些。

RPC客户端调用远程服务的过程
 1、客户端client发起服务调用请求。
 2、client stub 可以理解成一个代理,会将调用方法、参数按照一定格式进行封装,通过服务提供的地址,发起网络请求。
 3、消息通过网络传输到服务端。
 4、server stub接受来自socket的消息
 5、server stub将消息进行解包、告诉服务端调用的哪个服务,参数是什么
 6、结果返回给server stub。
 7、sever stub把结果进行打包交给socket
 8、socket通过网络传输消息
 9、client slub 从socket拿到消息。
 10、client stub解包消息将结果返回给client。

RPC框架的服务发现和负载均衡
1.集中式LB (Proxy Model)

在服务消费者和服务提供者之间有一个独立的LB,通常是专门的硬件设备如 F5,或者基于软件如 LVS,HAproxy等实现。LB上有所有服务的地址映射表,通常由运维配置注册,当服务消费方调用某个目标服务时,它向LB发起请求,由LB以某种策略,比如轮询(Round-Robin)做负载均衡后将请求转发到目标服务。LB一般具备健康检查能力,能自动摘除不健康的服务实例。
该方案主要问题:
   1.单点问题,所有服务调用流量都经过LB,当服务数量和调用量大的时候,LB容易成为瓶颈,且一旦LB发生故障影响整个系统
   2.服务消费方、提供方之间增加了一级,有一定的性能开销。

2.进程内LB (Balancing-aware Client)

此方案将LB的功能集成到服务消费方进程里,也被称为软负载或者客户端负载方案。服务提供方启动时,首先将服务地址注册到服务注册表,同时定期报心跳到服务注册表以表明服务的存活状态,相当于健康检查,服务消费方要访问某个服务时,它通过内置的LB组件向服务注册表查询,同时缓存并定期刷新目标服务地址列表,然后以某种负载均衡策略选择一个目标服务地址,最后向目标服务发起请求。LB和服务发现能力被分散到每一个服务消费者的进程内部,同时服务消费方和服务提供方之间是直接调用,没有额外开销,性能比较好。
该方案主要问题:
1.开发成本,该方案将服务调用方集成到客户端的进程里头,如果有多种不同的语言栈,就要配合开发多种不同的客户端,有一 定的研发和维护成本。
 2.另外生产环境中,后续如果要对LB进行升级,势必要求服务调用方修改代码并重新发布,升级较复杂。

3.独立LB进程 (External Load Balancing Service)

 

原理和第二种方案基本类似。不同之处是将LB和服务发现功能从进程内移出来,变成主机上的一个独立进程。主机上的一个或者多个服务要访问目标服务时,他们都通过同一主机上的独立LB进程做服务发现和负载均衡。该方案也是一种分布式方案没有单点问题,一个LB进程挂了只影响该主机上的服务调用方,服务调用方和LB之间是进程内调用性能好,同时该方案还简化了服务调用方,不需要为不同语言开发客户库,LB的升级不需要服务调用方改代码。
该方案主要问题:部署较复杂,环节多,出错调试排查问题不方便。

 

二、GRPC
前面讲到grpc是google开源的一个高性能、跨语言的RPC框架,基于HTTP2协议,基于protobuf 3.x,基于Netty 4.x +

基于HTTP/2
HTTP/2 提供了连接多路复用、双向流、服务器推送、请求优先级、首部压缩等机制。可以节省带宽、降低TCP链接次数、节省CPU,帮助移动设备延长电池寿命等。gRPC 的协议设计上使用了HTTP2 现有的语义,请求和响应的数据使用HTTP Body 发送,其他的控制信息则用Header 表示。

基于Netty (java基于netty)
用Netty优化I/O模型。网络通信中I/O大致可以分为四种:
1.阻塞I/O
2.非阻塞I/O
3.I/O多路复用
4.异步I/O
我们知道I/O处理是非常耗时的,CPU的处理速度非常快,如何最大化的利用CPU的性能,就是要避免线程阻塞在I/O处理上。业界目前比较多的采用I/O多路复用和异步I/O提高性能。
I/O 多路复用最大的优势是用户可以在一个线程内同时处理多个 Socket 的 I/O 请求。用户可以订阅事件,包括文件描述符或者 I/O 可读、可写、可连接事件等。通过一个线程监听全部的 TCP 连接,有任何事件发生就通知用户态处理即可,这么做的目的就是 假设 I/O 是慢的,CPU 是快的,那么要让用户态尽可能的忙碌起来去,也就是最大化 CPU 利用率,避免传统的 I/O 阻塞。

使用ProtoBuf定义IDL
gRPC使用ProtoBuf来定义服务,ProtoBuf是由Google开发的一种数据序列化协议(类似于XML、JSON、hessian)。ProtoBuf能够将数据进行序列化,并广泛应用在数据存储、通信协议等方面。压缩和传输效率高,语法简单,表达力强。

多语言支持
gRPC支持多种语言,并能够基于语言自动生成客户端和服务端功能库。目前已提供了C版本grpc、Java版本grpc-java 和 Go版本grpc-go,其它语言的版本正在积极开发中,其中,grpc支持C、C++、Node.js、Python、Ruby、Objective-C、PHP和C#等语言,grpc-java已经支持Android开发。

GRPC的原理
对于开发者而言:
  1)需要使用protobuf定义接口,即.proto文件
  2)然后使用compile工具生成特定语言的执行代码,比如JAVA、C/C++、Python等。类似于thrift,为了解决跨语言问题
  3)启动一个Server端,server端通过侦听指定的port,来等待Client链接请求,通常使用Netty来构建,GRPC内置了Netty的支持。
  4)启动一个或者多个Client端,Client也是基于Netty,Client通过与Server建立TCP长链接,并发送请求;request与response均被封装成HTTP2的stream Frame,通过Netty Channel进行交互。

grpc有三个核心的抽象层:
Stub
绝大数开发者会直接使用的一部分,proto文件编译所生成的Stub就是在此层的基础之上生成的。它提供了一种调用方和服务之间类型安全的绑定关系,对比http服务:http服务就不提供类型安全的绑定关系,调用方和服务方需要自行处理类型转化的相关工作。

Channel
Channel是一个虚拟的链接,它可以维护任意数量的真实链接,也可以自主选择具体使用的链接。也就是说,在channel上,我们可以实现客户端的 load balancing。另外,注释中建议应用使用stubs,而不是直接调用channel。其实也就说,我们可以避开Stub的创建,直接调用Channel。另外通过实现ClientInterceptor 可以实现对Channel的横切(cross-cutting),这个也是在Channel层做到的。
channel层是对数据传输的抽象,便于用户更方便的进行 拦截器/装饰者 等类似的处理。它旨在使应用程序框架易于使用此层来解决诸如日志记录,监视,身份验证等交叉问题。流控制也暴露在此层,以允许更复杂的应用程序直接与其交互。

Transport
这一层在绝大多数情况下是我们不需要关心的,它的作用就是传输数据,底层基于netty,或者ok http的实现

 

三、ProtoBuf
前面说了grpc使用protobuf协议进行数据系列化,那么什么是protobuf?为什么选则protocol buffers?

什么是protocol buffers
Protobuf是google开发的一种跨语言和平台的序列化数据结构的方式,类似于XML但是更小更快而且更简单,只需要定义一次结构体,通过生成的源代码可以在不同的数据流和不同的语言平台上去读写数据结构。

——为什么说protobuf更小更快?
下面是对Xml,Json,Hessian,Protocol Buffers的序列化和反序列化性能进行对比。分别用100次,1000次,10000次和100000次进行了测试
序列化  -  x序列化次数,y耗时

反序列化  -  x反序列化次数,y耗时

序列化后字节长度  -  x格式,y字节长度

结果:这里分别用100次,1000次,10000次和100000次进行了测试,可以看出Protocol Buffers是最好的。
 

protobuf3的使用和语法定义

先看一个例子:

版本号
对于一个pb文件而言,文件首个非空、非注释的行必须注明pb的版本,即syntax = "proto3";否则默认版本是proto2。

Message
一个message类型看上去很像一个Java class,由多个字段组成。每一个字段都由类型、名称组成,位于等号右边的值不是字段默认值,而是数字标签,可以理解为字段身份的标识符,类似于数据库中的主键,不可重复,标识符用于在编译后的二进制消息格式中对字段进行识别,一旦你的pb消息投入使用,字段的标识就不应该再改变。

分配标识号
每一个字段都有一个对应的数字标签,用于在消息的二进制格式中识别每一个属性。该标签是在此定义中独一无二的,范围为1-536,870,911,另外19000-19999为protobuf协议实施需要的, Protobuf协议实现中对这些进行了预留,不要占用。1-15之内的标识号在编码的时候会占用一个字节,因此为优化使用,我们可以把经常使用或者重复型元素设置在该范围。[16,2047]之内的标识号则占用2个字节。

类型
每个字段的类型都是scalar的类型,下面是和其他语言的对比图:

修饰符
required关键字
顾名思义,就是必须的意思,数据发送方和接收方都必须处理这个字段。
optional关键字
字面意思是可选的意思,具体protobuf里面怎么处理这个字段呢,就是protobuf处理的时候另外加了一个bool的变量,用来标记这个optional字段是否有值,发送方在发送的时候,如果这个字段有值,那么就给bool变量标记为true,否则就标记为false,接收方在收到这个字段的同时,也会收到发送方同时发送的bool变量,拿着bool变量就知道这个字段是否有值了,这就是option的意思。
repeated关键字
字面意思大概是重复的意思,其实protobuf处理这个字段的时候,也是optional字段一样,另外加了一个count计数变量,用于标明这个字段有多少个,这样发送方发送的时候,同时发送了count计数变量和这个字段的起始地址,接收方在接受到数据之后,按照count来解析对应的数据即可。数据格式类似于java的List。
:proto3字段前取消了required和optional两个关键字,目前可用的只有repeated关键字。

保留标识符 (Reserved)
使用reserved修饰符,那么被修饰的数字标签或者字段将会保留。

枚举
每个枚举值有对应的数值,数值不一定是连续的。第一个枚举值的数值必须是0且至少有一个枚举值,否则编译报错。编译后编译器会为你生成对应语言的枚举类。

一个数值可以对应多个枚举值,必须标明option allow_alias = true;

由于编码原因,出于效率考虑,官方不推荐使用负数作为枚举值的数值。

嵌套类型
除了上述基本类型,一个字段的类型也可以是其它的message类型,并且可以一直嵌套下去,类似Java的内部类:

import关键字
一个.proto文件需要引用外部.proto文件需要用import关键字
import关键字导入的定义仅在当前文件有效,不能被上层使用方引用(client.proto无法使用other.proto中的定义),而import public关键字导入的定义可以被上层使用方引用(client.proto可以使用new.proto中的定义),import public的功能可以看作是import的超集,在import的功能上还具有传递引用的作用。
假如引用meifute.proto的message Request 。可以使用import引入meifute.proto文件

默认情况下你只能使用直接导入的.proto文件中的定义。如果meifute.proto文件内部引用了it.proto文件,本文件是无法间接引用it.proto里的message。需要使用public关键字

Any
Any类型允许包装任意的message类型:

可以通过pack()和unpack()(方法名在不同的语言中可能不同)方法装箱/拆箱,以下是Java的例子:
People people = People.newBuilder().setName("proto").setAge(1).build(); // protoc编译后生成的message类
Response r = Response.newBuilder().setData(Any.pack(people)).build(); // 使用Response包装people System.out.println(r.getData().getTypeUrl()); // type.googleapis.com/example.protobuf.people.People System.out.println(r.getData().unpack(People.class).getName()); // proto

Oneof
如果你有一些字段同时最多只有一个能被设置,可以使用oneof关键字来实现,任何一个字段被设置,其它字段会自动被清空(被设为默认值);使用oneof特性节省内存,Oneof字段就像可选字段, 除了它们会共享内存, 至多一个字段会被设置。 设置其中一个字段会清除其它字段。 你可以使用case()或者WhichOneof() 方法检查哪个oneof字段被设置。

你可以增加oneof字段到 oneof 定义中,你可以增加任意类型的字段,但是不能使用repeated 关键字。

Maps
pb中也可以使用map类型(官方并不认为是一种类型,此处称之为类型仅便于理解),绝大多数scalar类型都可以作为key,除了浮点型和bytes,枚举型也不能作为key,value可以是除了map以外的任意类型:

map类型字段不支持repeated,value的顺序是不定的。
map其实是一种语法糖,它等价于以下形式:

默认值
1. string类型的默认值是空字符串
2. bytes类型的默认值是空字节
3. bool类型的默认值是false
4. 数字类型的默认值是0
5. enum类型的默认值是0,enum取的是第一个定义的枚举值,因为protobuf3强制要求第一个枚举元素的值为0,所以枚举的默认值就是0。
6. message类型(对象,如上文的SearchRequest就是message类型)不是null,而是DEFAULT_INSTANCE
7. repeated修饰的字段默认值是空列表

选项
选项不对message的定义产生任何的效果,只会在一些特定的场景中起到作用,下面是一部分例子,完整的选项列表可以前往google/protobuf/descriptor.proto查看(Java语言可以在jar包中找到):
1.option java_package = "com.example.foo"; 编译器为以此作为生成的Java类的包名,如果没有该选项,则会以pb的package作为包名。
2.option java_multiple_files = true; 该选项为true时,生成的Java类将是包级别的,否则会在一个包装类中。3.option optimize_for = CODE_SIZE; 该选项会对生成的类产生影响,作用是根据指定的选项对代码进行不同方面的优化。
4. option objc_class_prefix = "MFT"; 在object-c中有效,可以生成前缀,java中无效。
4.int32 old_field = 6 [deprecated=true]; 把字段标为过时的。
注:option optimize_for = LITE_RUNTIME;
 optimize_for是文件级别的选项,Protocol Buffer定义三种优化级别SPEED/CODE_SIZE/LITE_RUNTIME。缺省情况下SPEED。
1. SPEED: 表示生成的代码运行效率高,但是由此生成的代码编译后会占用更多的空间。 
2. CODE_SIZE: 和SPEED恰恰相反,代码运行效率较低,但是由此生成的代码编译后会占用更少的空间,通常用于资源有限的平台,如Mobile。
3. LITE_RUNTIME: 生成的代码执行效率高,同时生成代码编译后的所占用的空间也是非常少。这是以牺牲Protocol Buffer提供的反射功能为代价的。因此我们在C++中链接Protocol Buffer库时仅需链接libprotobuf-lite,而非libprotobuf。在Java中仅需包含protobuf-java-2.4.1-lite.jar,而非protobuf-java-2.4.1.jar。
4. SPEED和LITE_RUNTIME相比,在于调试级别上,例如 msg.SerializeToString(&str) 在SPEED模式下会利用反射机制打印出详细字段和字段值,但是LITE_RUNTIME则仅仅打印字段值组成的字符串;
因此:可以在程序调试阶段使用 SPEED模式,而上线以后使用提升性能使用 LITE_RUNTIME 模式优化。

四、那么下面将结合java解读如何使用一个简单的grpc:
grpc有四种数据传递方式:

1. 简单 RPC。客户端发送一个对象,服务端返回一个对象
2. 服务器端流式 RPC。客户端发送一个对象,服务端返回一个Stream对象
3. 客户端流式 RPC。客户端发送一个Stream对象,服务端返回一个简单对象
4. 双向流式 RPC。客户端和服务端都传输的是Stream对象

准备阶段:springboot2.x,gradle5.x,protobuf3.x
创建springboot聚合工程,项目模块:grpc-lib,grpc-client,grpc-server
1. grpc-lib模块
grpc-lib是client和server的公共模块。client和server需要是用grpc-lib生成的stub。
build.gradle文件配置

在 src/main/下创建proto文件夹,创建一个student.proto文件
定义服务,要定义一个服务,你必须在你的 .proto 文件中指定 service

通过 protocol buffer 的编译器 protoc 以及一个特殊的gRPC Java 插件来完成。为了生成 gRPC服务,你必须 使用proto3编译器。
生成的代码在../build/generated/source/proto/main下面的grpc和java下

在编译生成以后,上述 Protobuf 会生成 Server stub 和 Client stub。分别用在 client 和 server 代码中。

2. grpc-server模块
gradle.build配置文件

application.yml配置

创建StudentServiceImpl继承StudentServiceGrpc.StudentServiceImplBase重写定义的RPC服务接口。

getStudent() 接收两个参数:

  • Request: 请求
  • StreamObserver<Student>: 一个应答的观察者,实际上是服务器调用它应答的一个特殊接口。

要将应答返回给客户端,并完成调用:

  1. 如在我们的服务定义中指定的那样,我们组织并填充一个 Student 应答对象返回给客户端。
  2. 我们使用应答观察者的 onNext() 方法返回 Student
  3. 我们使用应答观察者的 onCompleted() 方法来指出我们已经完成了和 RPC的交互。

3. grpc-client模块
gradle.build配置文件

application.yml

创建客户端StudentService,使用@GrpcClient注解定义channel通道。调用StudentServiceGrpc.newBIockingStub(channel),创建一个阻塞存根stub。在阻塞存根上调用简单 RPC getStudent() 几乎是和调用一个本地方法一样直观。

我们创建和填充了一个请求 protocol buffer 对象(在这个场景下是 Request),在我们的阻塞存根上将其传给 getStudent() 方法,拿回一个 Student

接下来启动服务端grpc-server,并启动grpc-client。
在浏览器访问: http://localhost:8081/student/草莓君,返回如下:

上面的例子简单的演示了简单GRPC的调用,接下来将简单介绍下,另外三种传递方式:
1. 服务端流式RPC
  服务端流式RPC,客户端发送一个请求,服务端返回一段连续的数据流。
  getSanmeStudentList是一个服务端流式RPC,我们需要将相同姓名的Student返回给客户端。

和简单 RPC 类似,这个方法拿到了一个请求对象(客户端期望从 Request 找到 Student)和一个应答观察者 StreamObserver。模拟根据request条件获取指定用户,并且使用 onNext() 方法轮流往响应观察者写入,最后使用响应观察者的 onCompleted() 方法去告诉 gRPC 写入应答已完成。
2. 客户端流式RPC
  客户端流式RPC,客户端源源不断的发送请求给服务端,直到客户端发送请求完毕,服务端开始响应数据。
  2.1服务端方法,getStudentRecord,通过它可以从客户端拿到一个Request的流,并且返回学生信息Response

这个方法没有请求参数。在这个方法中,我们返回了一个匿名的StreamObServer实例,实际上是requestObserver,其中:
1.覆写了onNext()方法,每次客户端写入一个Requset到消息流是,拿到相关信息。
2.覆写了onCompleted()方法(在客户端结束写入消息时调用),用来填充和建构我们的Response。然后我们用Response调用方法自己的响应观察者的onNext(),之后调用它的onCompleted()方法,结束服务端调用。
所以在客户端的request每调用一次onNext方法,服务端都会接收到一个Request值
  2.2客户端方法,

a. 这里调用服务方法getStudentRecord使用的是stub(异步),而非之前的blockingStub(同步)。
b. 调用getStudentRecord需要传入responseObserver作为参数,同时返回值是requestObserver
c. 通过requestObserveronNext()不断发送数据。
这里,我们可以看到有两个StreamObserver对象,根据我给定的变量名responseObserver和requestObserver,requestObserver用于进行请求的发送,这里会用它的onNext方法发一串Request给服务端,在发送完毕后,执行onComplete方法。
在这里responseObserver是在客户端定义的,而requestObserver是在服务端返回的,两者互相调用对方定义的方法
3.双向流式RPC
 客户端发送每发送一个请求,服务端及时返回一个数据流。
3.1服务端方法

1. onNext表示接收到一个request,这里通过responseObserveronNext()立刻返回了一条数据。
2. onCompleted表示客户端已经发送数据完毕,这里调用responseObserveronCompleted()也告诉客户端连接关闭。
3.2 客户端方法。和客户端流式RPC一样,客户端不仅需要发送数据,而且需要实现一个responseObserver:

a. 这里调用服务方法biStream使用的是stub(异步),而非之前的blockingStub(同步)。
b. 调用biStream需要传入responseObserver作为参数,同时返回值是requestObserver
c. 通过requestObserveronNext()不断发送数据。
d. 执行程序,客户端发送多条数据,服务端每接收到一条数据就响应一条from server给客户端。

示例代码:

 

展开阅读全文

没有更多推荐了,返回首页