RPC 快速上手远程过程调用
1. RPC的基本概念
远程过程调用
wikipedia 中关于 RPC 的定义如下:
In distributed computing, a remote procedure call (RPC) is when a computer
program causes a procedure (subroutine) to execute in a different address
space(commonly on another computer on a shared network), which is coded as if
it were a normal (local) procedure call, without the programmer explicitly
coding the details for the remote interaction.
用中文翻译过来大概就是:
RPC的核心在于使进程间通信像进程内通信一样简单,更直白一点调用其他应用的方法好似调用本地方法一样简单方便。进程内之所以方便是因为写操作系统的人把复杂的事情给处理了,让业务研发专注于业务逻辑。进程间的调用简单是因为写RPC框架的人把复杂的工作给做了,使业务研发继续专注于业务逻辑。
举个例子:比如计算 a + b
如果仅仅是封装为一个函数,其实是很简单的。俩个入参,一个a, 一个b。
public int myAdd(int a, int b){
return a + b;
}
远程过程调用是指将这个函数封装到远程的另外一台机器上,也就是本机将 a 和 b 俩个参数打包起来放到一个数据包中,然后通过网络传输的方式,传递到远程的计算机,远程计算机通过函数计算完以后,将结果通过网络传输的方式 返回给调用方。最终就将结果计算出来了。
为什么需要远程过程调用
在分布式系统中,要想让服务A调用服务B中的方法,最先想到的就是通过HTTP请求实现。是的,这是很常见的,例如服务B暴露Restful接口,然后让服务A调用它的接口。基于Restful的调用方式因为可读性好(服务B暴露出的是Restful接口,可读性当然好)而且HTTP请求可以通过各种防火墙,因此非常不错。
然而,在高并发的时候,基于Restful的远程过程调用有着明显的缺点,主要是效率低、封装调用复杂。当存在大量的服务间调用时,这些缺点变得更为突出。因为 HTTP 协议属于无状态协议,客户端无法对请求和响应进行关联,每次请求都需要重新建立连接,响应完成后再关闭连接。因此,对于要求高性能的 RPC 来说,HTTP 协议基本很难满足需求,所以 RPC 会选择设计更紧凑的私有协议。
服务A调用服务B的过程是应用间的内部过程,牺牲可读性提升效率、易用性是可取的。基于这种思路,RPC产生了。
通常,RPC要求在调用方中放置被调用的方法的接口。调用方只要调用了这些接口,就相当于调用了被调用方的实际方法,十分易用。于是,调用方可以像调用内部接口一样调用远程的方法,而不用封装参数名和参数值等操作。
和 http 的区别和联系
在广义上来说, http 是实现 rpc的一种方式。rpc 是一种思想,是指利用网络中的另外一台计算机,进行一个函数的计算,然后调用方进行一次调用,拿到这个结果。
从狭义上来说,一些比较常见的 rpc框架,比如 thrift, dubbo, grpc等,一次http 调用和 一次 rpc调用在真正执行的时候,还是有比较大的区别的。
2. RPC中使用到的技术
序列化
网络传输的数据必须是二进制数据,但调用方请求的出入参数都是对象。对象是肯定没法直接在网络中传输的,需要提前把它转成可传输的二进制,并且要求转换算法是可逆的,这个过程我们一般叫做“序列化”。
也就是将 程序内存中的数据结构 转化为 传输过程中的数据结构,一般来说,就是 纯文本结构 或者是 byte数组,序列化 : JSON 序列化, xml , 属于文本类型的序列化方式。JDK 序列化, Hessian, Protobuf。
首选的还是 Hessian 与 Protobuf,因为他们在性能、时间开销、空间开销、通用性、兼容性和安全性上,都满足了我们的要求。其中 Hessian 在使用上更加方便,在对象的兼容性上更好;Protobuf 则更加高效,通用性上更有优势。
网络IO
RPC 是解决进程间通信的一种方式。一次 RPC 调用,本质就是服务消费者与服务提供者间的一次网络信息交换的过程。服务调用者通过网络 IO 发送一条请求消息,服务提供者接收并解析,处理完相关的业务逻辑之后,再发送一条响应消息给服务调用者,服务调用者接收并解析响应消息,处理完相关的响应逻辑,一次 RPC 调用便结束了。可以说,网络通信是整个 RPC 调用流程的基础。
网络IO 中涉及到很多的系统调用。java中有一个比较有名的rpc框架, dubbo. 底层网络传输结构就使用了 netty, netty 又是基于 nio的,nio 是基于 IO 多路复用的,这其中会涉及到系统调用 syscall.
考虑到系统内核的支持、编程语言的支持以及 IO 模型本身的特点,RPC 框架在网络通信的处理上,我们更倾向选择 IO 多路复用的方式。
动态代理: 面向接口编程,屏蔽RPC处理流程
在项目中,当我们要使用 RPC 的时候,我们一般的做法是先找服务提供方要接口,通过 Maven 或者其他的工具把接口依赖到我们项目中。我们在编写业务逻辑的时候,如果要调用提供方的接口,我们就只需要通过依赖注入的方式把接口注入到项目中就行了,然后在代码里面直接调用接口的方法。
我们都知道,接口里并不会包含真实的业务逻辑,业务逻辑都在服务提供方应用里,但我们通过调用接口方法,确实拿到了想要的结果,是不是感觉有点神奇呢?想一下,在 RPC 里面,我们是怎么完成这个魔术的。
这里面用到的核心技术就是前面说的动态代理。RPC 会自动给接口生成一个代理类,当我们在项目中注入接口的时候,运行过程中实际绑定的是这个接口生成的代理类。这样在接口方法被调用的时候,它实际上是被生成代理类拦截到了,这样我们就可以在生成的代理类里面,加入远程调用逻辑。
3. 远程过程调用在使用过程中的注意事项
-
下游服务的服务能力,避免因为你的调用把别人给调挂了,要事前协商好qps等,做好限流。
-
调用服务异常时,要考虑降级、重试等措施。
-
核心的服务不能强依赖非核心的服务,避免核心服务因为非核心服务异常而不可用。
-
设置合理超时时间。因为 rpc毕竟需要走网络调用,存在网络耗时。超时间太短,可能导致服务提供端实际执行成功,消费端却因为超时报错结束。这就有可能导致数据状态不一致。另外,整个链路的超时需要合理设置,如A-> B-> C,A的超时时间要大于B。
-
设置合理的重试次数。默认情况下,如 dubbo 重试次数为2,调用失败的情况下,框架会重新调用。而有些服务不能重复调用。服务提供者应该是最熟悉自己服务的,所以服务提供者可以设置默认超时时间以及重试次数,消费者不设置,就会采用服务提供者参数设置。
4. 快速上手GRPC
GRPC 是由 Google 开发并且开源的一款高性能、跨语言的 RPC 框架,当前支持 C、Java 和 Go 等语言,当前 Java 版本最新 Release 版为 1.27.0。gRPC 有很多特点,比如跨语言,通信协议是基于标准的 HTTP/2 设计的,序列化支持 PB(Protocol Buffer)和 JSON,整个调用示例如下图所示:
现在以计算 a+b 为例,看一下如何使用 GRPC。
引入以下的 maven 依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>a.b.c</groupId>
<artifactId>grpc-demo</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<grpc.version>1.34.1</grpc.version><!-- CURRENT_GRPC_VERSION -->
<protobuf.version>3.12.0</protobuf.version>
<protoc.version>3.12.0</protoc.version>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-bom</artifactId>
<version>${grpc.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-netty-shaded</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java-util</artifactId>
<version>${protobuf.version}</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
</dependencies>
<!-- protobuf-maven-plugin 插件 -->
<!-- protoSourceRoot标签定义了protobuf文件的位置-->
<build>
<extensions>
<extension>
<groupId>kr.motd.maven</groupId>
<artifactId>os-maven-plugin</artifactId>
<version>1.6.2</version>
</extension>
</extensions>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>0.6.1</version>
<configuration>
<protocArtifact>com.google.protobuf:protoc:${protoc.version}:exe:${os.detected.classifier}</protocArtifact>
<pluginId>grpc-java</pluginId>
<pluginArtifact>io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}</pluginArtifact>
<protoSourceRoot>src/main/resources/proto</protoSourceRoot>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>compile-custom</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
定义对应的 protobuf 文件。
在 maven 文件中,已经约定好在 src/main/resources/proto 这个目录下定义
protobuf文件。新建 add.proto 文件,定义如下:
syntax = "proto3";
package grpc;
option java_package = "a.b.c";
option java_outer_classname = "AddServiceProto";
option java_multiple_files = true;
service AddService{
rpc add(AddRequest) returns (AddReply){}
}
message AddRequest{
int32 a = 1;
int32 b = 2;
}
message AddReply{
int32 res = 1;
}
使用 mvn clean install 命令,生成相应的 java文件。
使用 mvn clean install 命令,生成的文件,是在 target 目录下的。如下:
定义 server 端
package a.b.c;
import io.grpc.ServerBuilder;
import io.grpc.stub.StreamObserver;
import java.io.IOException;
/**
* Author : LG
*/
public class AddServer extends AddServiceGrpc.AddServiceImplBase {
public static void main(String[] args) throws IOException {
ServerBuilder.forPort(9999)
.addService(new AddServer())
.build()
.start();
System.out.println("server start at 9999");
while (true){
}
}
public void add(AddRequest request, StreamObserver<AddReply> responseObserver) {
int res = myAdd(request.getA(),request.getB());
responseObserver.onNext(AddReply.newBuilder().setRes(res).build());
responseObserver.onCompleted();
}
private int myAdd(int a, int b){
return a + b;
}
}
定义 client 端
package a.b.c;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
public class AddClient {
AddServiceGrpc.AddServiceBlockingStub stub;
ManagedChannel channel;
public static void main(String[] args) {
int a = 101;
int b = 102;
AddClient client = new AddClient();
AddReply reply =
client.stub.add(AddRequest.newBuilder().setA(a).setB(b).build());
System.out.println(reply.getRes());
}
public AddClient(){
channel = ManagedChannelBuilder
.forAddress("127.0.0.1",9999)
.usePlaintext()
.build();
stub =
AddServiceGrpc.newBlockingStub(channel);
}
}
启动
以上步骤定义完以后,先启动 server, 再启动 client。
5. 总结
无论你是一个初级开发者还是高级开发者,RPC 都应该是你日常开发过程中绕不开的一个话题,所以作为软件开发者的我们,真的很有必要详细地了解 RPC 实现细节。只有这样,才能帮助我们更好地在日常工作中使用 RPC。