RPC (Remote Procedure Call) 架构理论与框架详解

RPC 理论基础

RPC (Remote Procedure Call) 即远程过程调用,它是一种通过网络从远程计算机上请求服务的机制。整个调用过程涉及以下两个概念:

  • 远程过程

    远程过程不同于本地过程的函数调用,因为发起方与被调用方不在同一台机器上,无法直接通过指针访问。远程过程将部分程序逻辑放到其他机器上,这被称为“业务拆解”。这样,每个服务都具备了独立性和可扩展性,并且更易于维护。这种在远程机器上提供的服务被称为远程过程。

  • 过程调用

    过程调用即我们平常见到的方法调用。

“过程调用” 与 “远程过程” 相配合,便实现了调用过程的跨机器、跨网络控制传输。

RPC 需要解决的问题

RPC 的出现的确为 分布式系统 构建带来了便利,但与此同时分布式系统本身的问题也暴露了出来:

  • 延迟问题

    首先就是延迟的问题,最直观的表现就是响应时间变长:用户的一次点击事件可能需要经过多个服务处理,每个服务都被部署在不同的机器上,这种跨机器、跨网络的进程间通信更容易出现网络延迟。此外,数据编/解码带来的性能损耗也会拖慢响应速度。

    要解决这个问题,就得投入更大的网络带宽以及更强的硬件设备。

  • 地址空间被隔离

    内存地址只在同一台机器上有效,在一台机器内,可以通过内存共享实现地址空间不被隔离,但是在跨机器场景下,地址空间是被完全隔离的。

    比如在使用指针时,本地地址空间的指针在另一台机器上是无法使用的,所以需要 RPC 通过编程范式对开发者隐藏这种区别。而作为开发者,也应该清楚 RPC 框架下的开发不可以直接使用原始指针。

  • 局部故障

    在分布式架构中,不同的服务部署在不同的机器上,而且每个服务也会部署多个节点。当某服务的其中一个或几个节点发生故障时,若没有一个合适的发现机制,流量依旧请求到了故障节点,就会造成响应失败。

    为了解决这个问题,我们可以引入注册中心。注册中心可以发现并屏蔽掉发生故障的节点,但是注册中心也可能使故障类型变得模糊,定位问题的过程会更加复杂。

    除了发现问题、定位问题难度的上升,注册中心在解决局部故障上的难度也不小:因为出现局部故障后,需要保证整个集群的处理结果是一致的。比如需要通过分布式事务来保证整个集群所有节点写入数据是一致的,不会因为局部故障而出现故障节点写入数据失败但是非故障节点写入成功,导致数据不一致的情况。

  • 并发问题

    在分布式架构中,每个服务都有多个节点,如果多个节点同时对某个服务发起调用,就会产生并发问题。与本地多线程调用不同,分布式架构无法做到完全控制调用顺序,因为每个节点在不同的机器上,它们发起调用的时间也难以被统一管控。

RPC 架构核心组件

RPC 技术发展至今,底层核心组成部分始终没有很大变化。无论是几十年前的 CORBA,还是如今流行的 Dubbo、gRPC 等,它们基本都由以下五个部分组成:

  • Consumer(服务调用方);
  • Consumer-Stub(调用方本地存根);
  • RPC Runtime(RPC 运行时);
  • Provider-Stub(提供方本地存根);
  • Provider(服务提供方)。

服务调用方

服务调用方也叫服务消费者 (Consumer),它的职责之一是指定需要调用的接口的全限定名和方法,将调用的方法需要的参数提供给 Consumer-Stub。职责之二是从 Consumer-Stub 中获取执行的结果。

服务提供方

服务提供方 (Provider) 用于执行接口实现的方法逻辑,也就是为 Provider-Stub 提供方法的具体实现。

本地存根

RPC 会带来空间地址被隔离的问题,在远程调用的过程中,Consumer 端的地址空间中的任何一个内存地址在 Provider 端都是没有意义的。除了内存地址无法匹配,在不同的机器上还可能出现位宽不同、处理器大小端不同、编译环境导致的结构体系内存布局不同、字符串编码不同等情况,这些情况会导致远程调用的过程中 Consumer 端的方法调用无法像本地调用一样正确匹配到真实的方法实现,从而导致调用失败。所以在远程调用过程中,Consumer 端发起的方法调用让 Provider 端精确的知道自己应该执行哪个方法就是必须要解决的问题,Stub 的职责便是抽象这个调用过程。

本地存根 (Stub) 分为服务调用方本地存根 (Consumer-Stub) 和服务提供方本地存根 (Provider-Stub) 两种:

  • Consumer-Stub

    Consumer-Stub 与 Consumer 同属消费端,它们存在于同一台机器上。

    Consumer-Stub 会接受 Consumer 的方法调用,然后解析被调方法的方法名、参数等信息,然后整理、组装这些数据,将整理好的数据以指定的协议进行序列化,打包成可以传输的消息体,交给 RPC Runtime。

    Consumer-Stub 除了处理消费者提供的方法和参数,还会处理服务提供方返回的结果,它会将 RPC Runtime 返回的数据反序列化成服务调用方需要的数据并传递给 Consumer。

    从服务调用者的角度来看,Consumer-Stub 隐藏了远程调用的实现细节,就像是远程服务的一个代理对象,可以让服务调用者感觉自己在调用本地方法一样。

  • Provider-Stub

    Provider-Stub 与 Provider 都属于服务提供端,它们也一起位于同一台机器上。

    当 Provider 端的 RPC Runtime 收到请求包后,交由 Provider-Stub 进行参数等数据的转化。

    Provider-Stub 会重新转换客户端传递的数据,以便在 Provider 端的机器上找到对应的方法,传递正确的参数数据,最终正确地执行实际方法的调用。

    等方法执行完毕后,Provider 会将执行结果返回给 Provider-Stub,Provider-Stub 再将数据序列化、打包,经由 RPC Runtime 返回给服务调用方。

RPC Runtime

RPC 依赖互联网传递数据,远程过程调用的本质就是远程通信,所以 RPC 必不可缺的就是通信模块,RPC Runtime 的职责便是承载服务之间的通信。

RPC Runtime 肩负数据包的传输、重传、确认、路由和加密等职责,在 Consumer 端和 Provider 端都会有一个 RPC Runtime 实例,负责双方的通信。

RPC 调用过程

RPC 调用过程可以分为四个阶段:

  1. 服务暴露过程;
  2. 服务发现过程;
  3. 服务引用过程;
  4. 方法调用过程。

服务暴露过程

服务暴露过程发生在 Provider 端,根据服务是否暴露到远程可以将其分为“服务暴露到本地”和“服务暴露到远程”两种。

  • 本地暴露

    在一台机器上,一个应用服务可以认为是机器上的一个进程。当 Provider 进程启动后,RPC Runtime 会监听一个端口以对外提供服务(例如 Dubbo 默认监听的 20880 端口),此时一个 Consumer 如果想要访问我们新建的 Provider,则需要手动指定 Provider 的 Host 与 Port,否则是没办法知道这个新增的 Provider 的具体访问信息的,这种暴露方式称为“本地暴露”。

  • 远程暴露

    如果 Provider 进程启动后,紧接着将自己的 IP、端口等信息注册到公共的注册中心,如此一来所有的 Consumer 都可以通过这个注册中心发现新添加的 Provider 的连接信息,进而实现自动连接,这种暴露方式便是“远程暴露”。

    Provider 端的应用服务信息包括 Provider 的地址、端口、应用服务需要暴露的接口定义等信息。Provider 除了会在应用服务启动时将服务信息注册到注册中心,还会与注册中心保持心跳连接,如果 Provider 端某个节点异常下线,注册中心在一段时间内如果没有收到心跳,就会将该节点从服务列表中移除,以防 Consumer 将请求发送到异常节点。

    通过注册中心管理服务的地址信息,实现了 Consumer 对服务变动的动态感知,如此一来客户端不再需要显式的配置服务端地址,只需连接到注册中心即可,而注册中心集群地址往往是相对固定的。

服务发现过程

服务发现过程发生在 Consumer 端,服务发现的过程也就是“寻址过程”,Consumer 端如果要发起 RPC 调用,首先需要知道自己想要调用的服务有哪几个 Provider,服务发现的方式分为“直联式”和“注册中心式”两种。

  • 直联式

    服务消费者可以根据服务暴露的地址和端口直接连接远程服务,但是每次服务提供者的地址或端口更改了,服务消费者都需要手动重新配置。这种方式不建议在生产环境使用,仅可作为测试。

    如果 Provider 只暴露到了本地,那么 Consumer 只能通过该模式连接 Provider。

  • 注册中心式

    服务消费者通过注册中心发现服务,也就是从注册中心动态的获取服务提供者的地址和端口。

    如果 Provider 暴露到了远程,则 Consumer 最好选择注册模式连接 Provider。

服务引用过程

服务引用过程发生在服务发现之后,当 Consumer 端通过服务发现获取所有服务提供者的地址后,通过负载均衡策略选择其中一个服务提供者的节点进行服务引用。服务引用的过程就是与某一个服务节点建立连接(双方 RPC Runtime 建立网络连接),以及在 Consumer 端创建接口代理的过程。

方法调用过程

当服务引用完成后,Consumer 端便可进行方法调用了,整个调用过程如下:

RPC runtime 示意图
  1. Consumer 以本地调用方式(往往是接口方式)调用服务,它会将需要调用的方法、参数类型、参数列表传递给服务 Consumer-Stub;
  2. Consumer-Stub 收到调用后,将方法、参数等数据序列化成可以通过网络传输的消息体,并将该消息体传递给 Consumer 端的 RPC Runtime;
  3. Consumer 端的 RPC Runtime 通过 Socket 将消息发送到 Provider 端,由 Provider 端的 RPC Runtime 接收;
  4. Provider 端的 RPC Runtime 收到消息后,将其传递给 Provider-Stub;
  5. Provider-Stub 将收到的消息反序列化,然后解析出服务调用的方法、参数类型和参数列表,并调用 Provider 的服务;
  6. Provider 执行对应的方法后,将执行结果返回给 Provider-Stub;
  7. Provider-Stub 将结果序列化,打包成可传输消息体,传递给 Provider 端的 RPC Runtime;
  8. Provider 端的 RPC Runtime 通过 Socket 将消息发送到 Consumer 端,由 Consumer 端的 RPC Runtime 接收;
  9. Consumer 端的 RPC Runtime 将收到的消息传递给 Consumer-Stub;
  10. Consumer-Stub 根将消息反序列化,然后将得到的结果传递给 Consumer。

RPC 框架简介

随着微服务架构的兴起,服务类型与服务数量也随之增加,服务治理相关需求也越来越大。根据是否提供服务治理能力,可以将 RPC 框架分为两类,一类是仅提供 RPC 调用能力的框架;一类是除了提供基本的 RPC 调用能力,还承载了各种服务治理能力的框架。

  • 第一类框架的代表有:gRPCThrift 等;
  • 第二类框架的代表有:Dubbo、Open Feign 等。

Dubbo

Dubbo 是国内最早开源的 RPC 框架,在 2008 年成为 Alibaba 公司的 SOA 解决方案,在 2011 年对外开源,开源时仅支持 Java 语言,2014 年 10 月停止维护。在停止维护期间,部分互联网公司接替阿里开源了自行维护的 Dubbo 版本,例如当当维护的 Dubbox,在这段时间内,使用 Dubbox 的公司日益增加,因此,2017 年 9 月,阿里重启了 Dubbo 项目,随后 Dubbo 的迭代就变得非常频繁。2018 年 2 月,Alibaba 将 Dubbo 捐献给了 Apache 基金会,并于 2019 年 5 月 20 日结束孵化,成为了 Apache 的顶级项目。

Dubbo 生态支持许多微服务架构领域中的技术,并且它设计了一套自己的 SPI (Service Provider Interface) 扩展机制来保证 Dubbo 的可扩展性,社区也在持续地支持各种技术,比如光注册中心 Dubbo 就支持 Zookeeper、Nacos、Consul、etcd 等。

Dubbo 除了提供 RPC 本身的能力,还支持许多服务治理方面的特性,比如路由规则、优雅下线等。

Dubbo 分支

以下是几个 Dubbo 的重要分支:

  • 2.5.x:已停止维护;
  • 2.6.x:Alibaba 捐献给 Apache 之前的版本,现在仅仅处理一些 bug,不再集成新特性;
  • master:目前最活跃的分支,包名是 org.apache,即 Alibaba 捐献给 Apache 的版本;
  • 3.x:目前最新的默认分支,致力于云原生的支持,例如应用注册和发现、协议改造等。

Dubbo 特性

在十多年的发展历程中,Dubbo 新增了许多特性,这些特性主要分为以下三个方面。

扩展性

Dubbo 作为一个 RPC 框架,最基础的就是提供 RPC 能力,目前 RPC 各个组成部分都有许多优秀的开源框架可供选择,例如网络通信框架有 Netty、Grizzly,序列化框架有 Hessian、Kryo 等,Dubbo 并不是仅仅选择其中一种方案,而是实现了许多方案的组合以供开发者选择,这归功于 Dubbo 设计的 SPI 协议带来的强大的扩展性。

我们知道 JDK 也有自己的 SPI 设计,在面向对象的设计中,模块之间推荐基于接口编程,而不是对实现类进行硬编码,这样做也是基于模块设计的可插拔原则。为了避免在模块装配的时候在程序里指定实现类,就需要引入一种服务发现机制:JDK 提供了一个工具类 java.util.ServiceLoader,它会加载 META-INF/service/ 目录下所有相关的配置文件,以获取接口支持的实现类。

Dubbo 在 JDK 的 SPI 基础上做了以下改进:

  1. 实现了扩展点的按需加载。我们知道 JDK 标准的 SPI 只能通过遍历来查找扩展点和实例化,所以有可能导致一次性加载了所有的扩展点,导致资源的浪费。
  2. 增加了对 Spring IoC、AOP 的支持。一个扩展点可以直接通过 setter 注入其他扩展点。

依托于 SPI 设计,Dubbo 在执行 RPC 调用时可以通过配置自由选择使用哪种底层方案,极大的提高了程序的扩展性。

服务治理能力

当服务达到了一定量级,服务治理就显得至关重要了,此时人工运维这些服务的难度极大。Dubbo 提供了许多能力来支持运维以及服务管理,比如提供了 telnet 命令、Dubbo Admin 等。

除了服务的运维方面,Dubbo 还提供了多种路由规则、多种负载均衡策略、多种集群容错规则等。比如路由规则有条件路由规则、集群容错规则、标签路由规则、脚本路由规则等,负载均衡策略有基于权重随机算法的策略、基于最少活跃调用数算法的策略、基于 Hash 一致性算法的策略、基于加权轮训算法的策略。

除了这些,Dubbo 还提供了链路追踪、链路监控等适配能力,实现更完善的服务治理。

配置粒度精细

Dubbo 的许多特性都是可配置的,比如超时配置,重试次数等。Dubbo 在提供这些特性的同时,还支持了不同粒度的配置。例如当配置了一个超时配置时,可以控制该超时配置的受用对象是谁,是某个接口的下的一个方法,还是整个接口/应用。

除了对配置粒度的划分,Dubbo 在服务发现和服务注册上也支持接口粒度和应用粒度。接口粒度是最先支持的方案,后来为了对接云原生思想,Dubbo 支持了应用粒度的服务发现和服务注册。

Dubbo 开发示例

Dubbo 支持三种开发方式,分别是 XML 开发方式、注解开发方式和 API 开发方式。

这里我们以 XML 开发方式为例进行讲解。XML 配置可以做到透明化介入应用,对应用没有任何 API 入侵,只需用 Spring 加载 Dubbo 配置即可:

POM 配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<dependencies>
    <dependency>
        <groupId>org.apache.dubbo</groupId>
        <artifactId>dubbo</artifactId>
        <version>3.0.10</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-core</artifactId>
        <version>5.3.22</version>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.3</version>
            <configuration>
                <source>${maven.compiler.source}</source>
                <target>${maven.compiler.target}</target>
                <encoding>UTF-8</encoding>
            </configuration>
        </plugin>
    </plugins>
</build>

服务提供者

  1. 创建用于暴露的接口 DemoService.java

    1
    2
    3
    4
    5
    
    package me.iling.dubbo_demo.api;
    
    public interface DemoService {
        String sayHello(String name);
    }
  2. 创建接口的实现类

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    
    package me.iling.dubbo_demo.api.impl;
    
    import me.iling.dubbo_demo.api.DemoService;
    import org.apache.dubbo.rpc.RpcContext;
    
    import java.text.SimpleDateFormat;
    import java.util.Date;
    
    public class DemoServiceImpl implements DemoService {
        @Override
        public String sayHello(String name) {
            System.out.println("[" 
                    + new SimpleDateFormat("HH:mm:ss").format(new Date())
                    + "] Hello " + name + ", response from provider: " 
                    + RpcContext.getServiceContext());
    
            return "Hello " + name + ", response from provider: " 
                + RpcContext.getServiceContext().getLocalAddressString();
        }
    }
  3. 通过 XML 暴露 Dubbo 服务

    在 src/resources 下创建 dubbo-provider.xml:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    
    <?xml version="1.0" encoding="UTF-8" ?>
    <beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns:dubbo="http://dubbo.apache.org/schema/dubbo"
        xmlns="http://www.springframework.org/schema/beans"
        xmlns:context="http://www.springframework.org/schema/context"
        xsi:schemaLocation="http://www.springframework.org/schema/beans
                http://www.springframework.org/schema/beans/spring-beans.xsd
                http://dubbo.apache.org/schema/dubbo
                http://dubbo.apache.org/schema/dubbo/dubbo.xsd
                http://www.springframework.org/schema/context
                http://www.springframework.org/schema/context/spring-context.xsd">
        <context:property-placeholder/>
    
        <dubbo:application name="provider"/>
        <dubbo:registry address="multicast://127.0.0.1:1234"/>
        <dubbo:protocol name="dubbo" port="20880"/>
        <bean id="demoService" class="me.iling.dubbo_demo.api.impl.DemoServiceImpl"/>
        <dubbo:service interface="me.iling.dubbo_demo.api.DemoService" ref="demoService" group="test"/>
    </beans>
    • 这里直接使用 multicast 暴露 Dubbo 服务的服务地址,否则必须配置 Registry,因为 Dubbo 目前与注册中心强相关。
  4. 创建启动类 DemoProvider.java

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    package me.iling.dubbo_demo;
    
    import org.springframework.context.support.ClassPathXmlApplicationContext;
    
    import java.util.concurrent.CountDownLatch;
    
    public class DemoProvider {
        public static void main(String[] args) throws InterruptedException {
            ClassPathXmlApplicationContext context =
                    new ClassPathXmlApplicationContext("dubbo-provider.xml");
            context.start();
            System.out.println("Dubbo Provider Started!");
            new CountDownLatch(1).await();
        }
    }
  5. 启动 Provider

    启动后打印如下:

    1
    2
    3
    
    ...
    信息:  [DUBBO] Dubbo Application[1.1](provider) is ready., dubbo version: 3.0.10, current host: 192.168.126.1
    Dubbo Provider Started!

    可知,我们的 Provider 暴露的地址为 192.168.126.1,这里记下来供 Consumer 使用。

服务消费者

创建一个新工程,pom.xml 与提供者一样,然后完成以下操作:

  1. 编写用做服务引用的 DemoService 接口

    1
    2
    3
    4
    5
    
    package me.iling.dubbo_demo.api;
    
    public interface DemoService {
        String sayHello(String name);
    }
    • 需要注意,这里只是创建了一个规范与 Provider 相同的接口,但并未创建其实现类,真正的实现在 Provider 端。
  2. 在 src/resources 下创建 dubbo-consumer.xml

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    
    <?xml version="1.0" encoding="UTF-8" ?>
    <beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns:dubbo="http://dubbo.apache.org/schema/dubbo"
        xmlns="http://www.springframework.org/schema/beans"
        xmlns:context="http://www.springframework.org/schema/context"
        xsi:schemaLocation="http://www.springframework.org/schema/beans
            http://www.springframework.org/schema/beans/spring-beans.xsd
            http://dubbo.apache.org/schema/dubbo
            http://dubbo.apache.org/schema/dubbo/dubbo.xsd
            http://www.springframework.org/schema/context
            http://www.springframework.org/schema/context/spring-context.xsd">
        <context:property-placeholder/>
    
        <dubbo:application name="consumer"/>
        <dubbo:reference interface="me.iling.dubbo_demo.api.DemoService"
            id="demoService" group="test" url="192.168.126.1:20880"/>
    </beans>
    • 该示例采用直联式,不通过注册中心发现,所以 url 直接指向 Provider 的 IP:192.168.126.1。
  3. 编写消费服务的逻辑,也就是发起 RPC 调用过程的 DemoConsumer.java

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    
    package me.iling.dubbo_demo;
    
    import me.iling.dubbo_demo.api.DemoService;
    import org.springframework.context.support.ClassPathXmlApplicationContext;
    
    public class DemoConsumer {
        public static void main(String[] args) {
            ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("dubbo-consumer.xml");
            context.start();
            DemoService demoService = (DemoService) context.getBean("demoService");
            String hello = demoService.sayHello("小翎哥");
            System.out.println(hello);
        }
    }

运行测试

运行 DemoConsumer#main 后,Provider 端与 Consumer 端分别有如下打印:

1
2
信息:  [DUBBO] The connection of /192.168.126.1:57180 -> /192.168.126.1:20880 is established., dubbo version: 3.0.10, current host: 192.168.126.1
[14:30:08] Hello 小翎哥, response from provider: org.apache.dubbo.rpc.RpcServiceContext@3f945d91
1
Hello 小翎哥, response from provider: 192.168.126.1:20880

这意味着我们成功通过 Dubbo 发起了一次 RPC 调用。

gRPC

gRPC 是 Google 于 2015 年对外开源的一款语言中立、平台中立的高性能 RPC 框架,可以在任何环境中运行。gRPC 支持负载平衡、链路跟踪、运行状况检查和身份验证,且这些特性是可插拔的。它可以有效地连接数据中心和跨数据中心的服务,此外它还适用于分布式计算场景,支持将设备、移动应用程序和浏览器连接到后端服务。

gRPC 特性

Dubbo 不同,gRPC 更注重 RPC 本身的能力,除了上述提到的链路追踪等可插拔能力,并没有支持过多的服务治理能力,但其在 RPC 调用上有许多优点:

  1. gRPC 采用了 Google 自主研发的 ProtoBuf 作为序列化的解决方案,它解决了 gRPC 的跨语言调用的问题。由于定制了对应的 IDL,规范了接口定义,ProtoBuf 可以针对一些场景做性能优化,所以其序列化性能非常高。
  2. gRPC 采用的传输层协议是 HTTP/2,HTTP/2 相较于 HTTP 1.1 有许多优点:
    • 在性能上有更大的提升;
    • 支持流式通信 (Streaming Communication);
    • 安全性更高,天然支持 SSL,且具备成熟的权限功能。
  3. gRPC 的权限验证机制丰富,它内置了以下权限验证机制:
    • SSL/TLS:gRPC 具有 SSL/TLS 认证的集成,使用 SSL/TLS 认证服务器并加密客户端与服务器之间交换的所有数据。客户端可以使用可选机制来提供用于双向校验的证书;
    • ALTS (应用层传输安全):gRPC 支持 ALTS 以验证服务之间的通信,保证传输中的数据的安全。它是由 Google 研发的安全解决方案,具有良好的可扩展性。该方案还能适应大量的 RPC 的身份验证和加密需求。可以非常方便的在 Google Cloud Platform 云服务上使用。
    • 支持基于令牌的身份验证:gRPC 提供了一种通用机制,可将基于元数据的凭据附加到请求和响应中。对于某些身份验证流,还提供了通过 gRPC 访问 Google API 时获取访问令牌(OAuth2 令牌)的支持。
  4. 错误码遵循 Google API 错误码规范,这套规范与 HTTP 错误码有对应关系,发生异常时能够快速定位问题。

开发示例

编写 IDL 文件

首先在 src/main/resources 目录下创建 proto-idl 文件夹,并在 src/main/resources/proto-idl 目录下创建 greeter 文件夹:

1
2
3
4
5
src
└── main
    └── resources
        └── proto-idl
            └── greeter

创建完成后,在 src/main/resources/proto-idl 文件夹下创建 greeter.proto 文件,内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
syntax = "proto3";

option java_multiple_files = true;
option java_package = "proto";
option java_outer_classname = "GreeterProto";
package greeter;

import "greeter/message.proto";

service Greeter {
  rpc SayHello (Request) returns (Response) {}
}

并在 src/main/resources/proto-idl/greeter 文件夹下创建 message.proto 文件,内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
syntax = "proto3";

option java_multiple_files = true;
option java_package = "proto.greeter";
option java_outer_classname = "MessageProto";
package greeter;

// 包含用户名的请求消息。
message Request {
  string name = 1;
}

// 包含问候语的响应消息。
message Response {
  string message = 1;
}

配置 Maven

  1. 引入依赖

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    <dependency>
        <groupId>io.grpc</groupId>
        <artifactId>grpc-all</artifactId>
        <version>1.56.0</version>
    </dependency>
    <dependency>
        <groupId>javax.annotation</groupId>
        <artifactId>javax.annotation-api</artifactId>
        <version>1.3.2</version>
    </dependency>
  2. 配置 proto 编译插件

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    
    <build>
        <extensions>
            <extension>
            <groupId>kr.motd.maven</groupId>
            <artifactId>os-maven-plugin</artifactId>
            <version>1.7.0</version>
            </extension>
        </extensions>
        <plugins>
            <plugin>
            <groupId>org.xolstice.maven.plugins</groupId>
            <artifactId>protobuf-maven-plugin</artifactId>
            <version>0.6.1</version>
            <configuration>
                <protocArtifact>
                com.google.protobuf:protoc:3.21.5:exe:${os.detected.classifier}
                </protocArtifact>
                <pluginId>grpc-java</pluginId>
                <pluginArtifact>io.grpc:protoc-gen-grpc-java:1.56.0:exe:${os.detected.classifier}</pluginArtifact>
                <protoSourceRoot>src/main/resources/proto-idl</protoSourceRoot>
            </configuration>
            <executions>
                <execution>
                <goals>
                    <goal>compile</goal>
                    <goal>compile-custom</goal>
                </goals>
                </execution>
            </executions>
            </plugin>
        </plugins>
    </build>
  3. 配置输出路径

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    <properties>
        <!-- Message 源文件输出目录-->
        <javaOutputDirectory>
            ${project.basedir}/src/main/java-message
        </javaOutputDirectory>
        <!-- gRPC 源文件输出目录-->
        <protocPluginOutputDirectory>
            ${project.basedir}/src/main/java-grpc
        </protocPluginOutputDirectory>
    </properties>

编译 proto 文件

执行 mvn clean compile 命令,protoc 编译插件就会根据 Proto 配置文件自动生成代码。然后将 src/main/java-messagesrc/main/java-grpc 文件夹下的内容移动到 src/main/java 下即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
src
└── main
    └── java
        └── proto
            ├── greeter
            │   ├── MessageProto.java
            │   ├── Request.java
            │   ├── RequestOrBuilder.java
            │   ├── Response.java
            │   └── ResponseOrBuilder.java
            ├── GreeterGrpc.java
            └── GreeterProto.java

实现具体服务

上文示例中,我们通过 Proto 编译插件自动生成了 GreeterGrpc 类。该类不仅提供了 gRPC 所需的方法,还提供了一个我们在配置文件中指定的sayHello方法的默认实现。我们可以通过重写这个方法来实现自己的业务逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package me.iling.grpc_demo;

import proto.GreeterGrpc;
import proto.greeter.Request;
import proto.greeter.Response;

public class GreeterRpcService extends GreeterGrpc.GreeterImplBase {
    @Override
    public void sayHello(Request request,
                         io.grpc.stub.StreamObserver<Response> responseObserver) {
        String name = request.getName();
        Response response = Response.newBuilder()
                .setMessage("Hello " + name + "!")
                .build();
        responseObserver.onNext(response);
        responseObserver.onCompleted();
    }
}

创建 Provider 端

Provider 端需要使用我们重写了业务逻辑的 GreeterRpcService 类,实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package me.iling.grpc_demo;

import io.grpc.Server;
import io.grpc.ServerBuilder;

import java.io.IOException;

public class GrpcProvider {
    private final Server server;

    public GrpcProvider(int port) {
        server = ServerBuilder.forPort(port)
                .addService(new GreeterRpcService())
                .build();
    }

    public void start() throws IOException {
        server.start();
    }

    public void shutdown() {
        server.shutdown();
    }
}

创建 Consumer 端

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package me.iling.grpc_demo;

import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import proto.GreeterGrpc;
import proto.greeter.Request;
import proto.greeter.Response;

public class GrpcConsumer {
    private final GreeterGrpc.GreeterBlockingStub blockingStub;

    public GrpcConsumer(String host, int port) {
        ManagedChannel managedChannel = ManagedChannelBuilder.forAddress(host, port)
                .usePlaintext()
                .build();
        blockingStub = GreeterGrpc.newBlockingStub(managedChannel);
    }

    public String sayHello(String name) {
        Request greeting = Request.newBuilder()
                .setName(name)
                .build();
        Response response = blockingStub.sayHello(greeting);
        return response.getMessage();
    }
}

执行测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package me.iling.grpc_demo;

import java.io.IOException;

public class DemoApplication {
    public static void main(String[] args) throws IOException {
        int port = 1234;
        GrpcProvider grpcProvider = new GrpcProvider(port);
        grpcProvider.start();

        GrpcConsumer grpcConsumer = new GrpcConsumer("127.0.0.1", port);
        String reply = grpcConsumer.sayHello("小翎哥");
        System.out.println(reply);
        grpcProvider.shutdown();
    }
}

执行 main 方法后,返回结果:Hello 小翎哥!,说明我们成功的完成了一次 gRPC 调用。

ProtoBuf

ProtoBuf 的全称是 Google Protocol Buffer,是 Google 公司开发的一种混合语言数据标准。它是一种轻量高效的结构化数据存储格式,可用于序列化。ProtoBuf 具有快速的解析速度,序列化和反序列化的 API 非常简单,文档也非常丰富,并且可以与各种传输层协议结合使用。它还具有 IDL 的设计,并提供了 IDL 的编译器,支持多种编程语言,如 C++、C#、Dart、Golang、Java、Python、Rust 等。

IDL 与异构语言序列化

IDL 是一种异构语言序列化方案。异构语言指的是不同的编程语言。在序列化技术中,它可以被理解为应用程序是用 Java 开发的,但是某个序列化框架并不是针对 Java 的语法设计的,可能是针对 Golang 设计的。此外,该序列化框架采用二进制的数据格式。因此,该序列化框架无法为 Java 开发的应用程序提供序列化和反序列化能力,导致在解析二进制数据时无法正确解析内容。因此,只支持单一语言的序列化框架的应用市场减少了很多。

对异构语言的支持在数据传输中尤为重要,特别涉及到异构语言应用之间的 RPC 调用。目前有两种方案可以解决异构语言问题:

  1. 第一种方案是利用相同的机制重新实现对应语言的序列化框架。

    相同的机制指的是对数据的编排必须保持一致,例如 Kryo 只支持 Java,现在需要支持 Golang 的序列化,以便 Golang 版本的 Kryo 框架能够反序列化 Java 版本的 Kryo 框架序列化的数据(该版本不存在)。在 Golang 版本的 Kryo 框架中,需要遵循 Java 版本的 Kryo 框架的设计原则,才能正确地将 Java 的数据转化为 Golang 的数据,例如将 Java 中的 int 类型数据转化为 Golang 中的 int32 类型数据。目前市面上也有一些开源框架为了满足异构语言的需求而衍生出许多语言的版本,Hessian 就是非常典型的例子:

    Hession 目前已经支持 Java、Python、C++、C#、Erlang、PHP、Ruby、C 等多种语司。它也是一个比较高效的序列化框架,它的实现机制比较注重数据的简化。Hessian 会简化数据类型的元数据表达,对于复杂对象,通过 Java 的反射机制,把对象所有的属性当成一个 Map 来序列化。阿里巴巴在 Hession 的基础上开源了 Hession-lite 版本,解决了一些原版 Hession 存在的问题,并且提升了一些性能,该版本目前已经捐献给 Apache。

  2. 第二种方案就是通过与编程语言无关的 IDL 来解决异构语言的问题,这种方案目前较为常见的有 ThriftProtoBuf

ProtoBuf 的特性

ProtoBuf 除了支持的语言种类多,在性能上也具有非常大的优势,主要体现在以下三个方面:

  • 用标识符来识别字段

    我们知道 JSON 这种 key-value 的数据格式增加了很多额外的字符,比如 [ 等。在上文的开发示例中可以看到 string name=1 这样的定义,后面的 1 并不是初始值,而是该属性的标识符。

    ProtoBuf 就是用该标识符代替了属性的定义,它被用来在消息的二进制格式中识别各个字段,所以这里的标识符必须是唯一的。序列化时会将该编号及该属性的值一起转化为二进制数据,反序列化时通过标识符就知道该 value 是哪个属性的。这样做的好处是序列化后的数据包大大减小了。

  • 自定义可变数据类型 Varint 用来存储整数

    int 数类型在计算机中占用 4 字节,但是绝大部分的整数都是比较小的整数,实际用不到 4 字节。ProtoBuf 中定义了 Varint 这种数据类型,可以以不同的长度来存储整数,将数据进步进行了压缩,减少了序列化后数据包的大小。

  • 记录字符串长度,解析时直接截取

    上面讲到用标识符来表示字段,后面紧跟着数据,这样可以直接解析数据。如果是字符串类型,则不能直接解析。所以 ProtoBuf 在真实数据前还添加了该字符串的长度,也用 Varint 类型表示。这种策略可以保证反序列化时直接通过字符串长度来截取后面的真实数据 value。

ProtoBuf 文件格式详解

假设有以下 .proto 文件:

1
2
3
4
5
6
7
8
9
syntax = "proto3";
option java_multiple_files = true;
option java_package = "message";
option java_outer_classname = "Messageproto";
package message;
message Person {
    string name =1;
    int32 age= 2;
}
  • 第一行说明使用的是 ProtoBuf 哪个版本的语法,这里使用的是 proto3 的语法,syntax 语句必须是 .proto 文件除注释和空行外的首行。
  • 第二到第四行是 ProtoBuf 的一些可选配置项:
    • java_multiple_files 设置为 true 代表将编译完成的文件分成多个;
    • java_package 表示编译完后的包名称;
    • java_outer_classname 表示产生的类的类名,编译完成后,会产生一个 MessageProto.java 文件。
  • 第五行定义了类所在的包名,它是一个默认值,当在 *.proto 文件中提供了一个明确的 java_package 配置时,以 java_package 的配置优先。
  • 第六行则是真正的类定义,一个结构化的数据被称为一个 message,在 Java 中一个类就是一个 message,上面的示例中定义了一个 Person 类。
  • 第七行定义了 Person 类中的 name 属性,类型是字符串,标识符为 1。
  • 第八行定义了 Person 类中的 age 属性,类型是 32 位的整型,标识符为 2。

ProtoBuf 数据类型

ProtoBuf 支持很多语言,比如 C++、Java Python Golang、Ruby、C# 和 PHP 等,并且 ProtoBuf 中的消息类型也对应不同语言中的数据类型,比如上述代码中的 int32 在 Java 中对应的就是 int 类型、uint64 对应的就是 long 类型,部分示例如下:

proto3 TypeC++ TypeJava/Kotlin TypePython TypeGo Type
doubledoubledoublefloatfloat64
floatfloatfloatfloatfloat32
int32int32intintint32
int64int64longint/longint64
uint32uint32intint/longuint32
uint64uint64longint/longuint64

详情可以参考 ProtoBuf 官网,可以看到 ProtoBuf 支持非常多的语言类型,这也是它应用广泛的一个原因。

Thrift

Thrift 是 FaceBook 在 2007 年 4 月份开源的一个 RPC 框架,2008 年 5 月进入 Apache 孵化器,2010 年 10 月成为 Apache 顶级项目。如今被运用在了很多产品中,例如 HBase、Hadoop 等。

Thrift 是一个跨平台、跨语言的 RPC 框架。与 gRPC 类似,需要开发人员事先在 IDL 文件中定义数据类型和接口,然后生成某种语言所必要的代码,最后基于这些代码构建 Provider 端与 Consumer 端。

Thrift 特性

语言中立

Thrift 是一款保持语言中立、平台中立的 RPC 框架,支持非常多的编程语言。而且 Thrift 设计了自己的 IDL,并提供了自己的跨平台 IDL 编译器,用于将 IDL 编译成支持的语言。

支持三种通信数据格式

Thrift 通信传输提供了三种数据格式,第一种是二进制编码格式,第二种是 JSON 格式,第三种是 Thrift 私有的 Compact 压缩格式,Compact 格式使用 Variable-Length Quantity (VLQ) 编码对数据进行压缩,大大减少了通信数据包的大小,提高了带宽利用能力。

除此之外,Thrift 还提供了一种 TDebugProtocol,它能够在直接以文本格式请求数据,方便在开发阶段抓包或调试。

支持多种传输方式

Thrift 支持阻塞 I/O 以及非阻塞 I/O 方式传输,此外还支持使用文件传输、使用内存 I/O 传输,以及支持使用 Zlib 压缩传输。

Thrift 开发示例

安装 Thrift 编译工具

这里我们以 Windows 10 开发环境为例,首访问 官网 下载当前最新的编译工具,需要注意这里下载的 exe 文件是带版本号的,因此我们需要先将文件名中的版本号删掉,否则 Maven 编译插件无法找到应用,然后移动到合适的位置,以站长存放的路径为例:

1
C:\Program Files\thrift\thrift.exe

然后在“系统属性”中编辑环境变量 Path,将上述路径添加到环境变量。

再然后,打开终端执行以下命令,确认是否配置成功:

1
2
$ thrift.exe -version
Thrift version 0.20.0

如上可以显式版本号,就说明编译工具安装成功了。其他操作系统也类似,去官网下载对应版本即可。

编写 IDL 文件

在 src/main/resources 下新建 thrift-idl 文件夹,然后在里边新建 IDL 文件 demo.thrift,并编写以下内容:

1
2
3
4
5
namespace java thrift

service GreeterService {
    string sayHello(1:string username)
}

配置 Maven

  1. 引入依赖

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    <dependency>
        <groupId>org.apache.thrift</groupId>
        <artifactId>libthrift</artifactId>
        <version>0.20.0</version>
    </dependency>
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-api</artifactId>
        <version>1.7.36</version>
        <type>jar</type>
        <scope>compile</scope>
    </dependency>
  2. 配置编译插件

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    
    <build>
        <plugins>
            <plugin>
            <groupId>org.apache.thrift.tools</groupId>
            <artifactId>maven-thrift-plugin</artifactId>
            <version>0.1.11</version>
            <configuration>
                <thriftSourceRoot>src/main/resources/thrift-idl</thriftSourceRoot>
                <outputDirectory>src/main/java</outputDirectory>
                <generator>java</generator>
            </configuration>
            <executions>
                <execution>
                <id>thrift-sources</id>
                <phase>generate-sources</phase>
                <goals>
                    <goal>compile</goal>
                </goals>
                </execution>
            </executions>
            </plugin>
        </plugins>
    </build>
    • thriftSourceRoot 指定 IDL 文件所在目录;
    • outputDirectory 指定生成的 Java 代码所在目录。

编译 IDL 文件

由于我们已经配置了 maven-thrift-plugin,所以直接在 IDEA 执行 maven clean compile 命令,即可在 src/main/java/thrift 下生成文件 GreeterService.java

创建 GreeterService 服务的实现类

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package me.iling.thrift_demo;

import org.apache.thrift.TException;
import thrift.GreeterService;

public class GreeterServerImpl implements GreeterService.Iface {
    @Override
    public String sayHello(String username) throws TException {
        return "Hello " + username + "!";
    }
}

创建 Provider 程序

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package me.iling.thrift_demo;

import org.apache.thrift.TProcessor;
import org.apache.thrift.protocol.TBinaryProtocol;
import org.apache.thrift.server.TServer;
import org.apache.thrift.server.TSimpleServer;
import org.apache.thrift.transport.TServerSocket;
import org.apache.thrift.transport.TTransportException;
import thrift.GreeterService;

public class GreeterServiceProvider {
    public static void main(String[] args) {
        try {
            System.out.println("服务端开启 ...");
            TProcessor tProcessor = new GreeterService.Processor<>(new GreeterServerImpl());
            TServerSocket tServerSocket = new TServerSocket(3333);
            TServer.Args tArgs = new TServer.Args(tServerSocket);
            tArgs.processor(tProcessor);
            tArgs.protocolFactory(new TBinaryProtocol.Factory());
            TSimpleServer tSimpleServer = new TSimpleServer(tArgs);
            tSimpleServer.serve();
        } catch (TTransportException e) {
            e.printStackTrace();
        }
    }
}

创建 Consumer 程序

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package me.iling.thrift_demo;

import org.apache.thrift.TException;
import org.apache.thrift.protocol.TBinaryProtocol;
import org.apache.thrift.transport.TSocket;
import org.apache.thrift.transport.TTransport;
import thrift.GreeterService;

public class GreeterServiceConsumer {
    public static void main(String[] args) {
        System.out.println("客户端启动 ...");
        TTransport transport = null;
        try {
            transport = new TSocket("127.0.0.1", 3333);
            TBinaryProtocol tBinaryProtocol = new TBinaryProtocol(transport);
            GreeterService.Client client = new GreeterService.Client(tBinaryProtocol);
            transport.open();
            String result = client.sayHello("小翎哥");
            System.out.println(result);
        } catch (TException e) {
            throw new RuntimeException(e);
        }
    }
}

测试

首先启动 GreeterServiceProvider,然后运行 GreeterServiceConsumer,客户端控制台打印如下:

1
2
3
客户端启动 ...

Hello 小翎哥!

说明我们成功的完成了一次 Thrift 调用。

至此,与 RPC 有关的内容就介绍完了。

0%