《Apache Thrift编程指南》第二章 Apache Thrift架构

779 阅读22分钟

在第一章中,我们讨论了Apache Thrift在分布式应用程序开发领域中的地位,并创建了一组程序来演示一个简单的跨语言服务。在本章中,我们将全面了解整个Apache Thrift框架。我们将把框架分解成几层,依次检查每一层。理解Apache Thrift的各个方面是如何在高层次上结合在一起的,将允许我们深入研究本书第二部分的主题,对Apache Thrift的整体概念有一个深入的理解。

Apache Thrift框架可以被组织成五个层(见图2.1):

  • RPC Server库
  • RPC服务存根
  • User-Defined序列化
  • 序列化Protocol库
  • Transport库

需要一种通用方法来序列化存储或传递数据结构的应用程序可能只需要这个模型的底层三层。

image.png

前两层包括RPC服务器的Apache Thrift库和IDL编译生成的服务存根,并向堆栈添加了RPC支持。

Apache Thrift在概念上是一个面向对象的框架,尽管它支持面向对象和非面向对象的语言。Transport库、Protocol库和Server库通常被称为类库,尽管它们可以在非面向对象的语言中以其他方式实现。Apache Thrift库中的类通常以首写的大写T命名,例如TTransport、TProtocol和TServer。

2.1 Transports

image.png

堆栈的底部是Transprt。Transport库将设备的上层与特定的细节隔离开来。特别是,Transport使协议能够读取和写字节流而不需要了解底层设备的细节。这允许对新设备和中间件系统的支持被添加到平台上,而不影响软件的上层。

例如,想象一下您开发了一组程序来在套接字网络API上传输股票价格报价。在部署应用程序后,需求就会扩展,并要求您添加AMQP消息传递系统对股价传输的支持。

有了Apache Thrift,扩展的能力将相当容易实现。新的AMQP代码可以实现现有的Apache Thrift Transport接口,允许上层代码在不知道区别的情况下使用套接字解决方案或AMQP解决方案(见图2.3)。

Apache Thrift Transport的模块化特性允许在编译时或运行时选择和更改它们,从而使应用程序插件化支持一系列设备(见图2.4)。

image.png

2.1.1 Transport接口

Apache Thrift Transport层向代码的上层公开了一个简单的面向字节的I/O接口。此接口通常在名为TTransport的抽象基类中定义。表2.1描述了在大多数语言实现中出现的TTransport方法。每个Apache Thrift语言实现都有自己的微妙之处。Apache Thrift语言库的实现倾向于发挥相关语言的优势,使跨实现级别的多样性成为规范。

image.png

例如,某些语言使用用于性能或其他目的的附加方法来定义传输接口。作为一个例子,C++语言TTransport接口定义了borrow()和consume()方法,从而实现了更有效的缓冲区处理。这里的例子集中在Apache Thrift的概念架构上。

2.1.2 端点传输

在这本书中,我们将Apache Thrift写入物理或逻辑设备的Transport称为“端点传输”。端点传输总是位于Apache Thrift Transport堆栈的底部,并且大多数用例都需要明确地指定一个端点传输。

Apache Thrift语言为内存、文件和网络设备提供端点传输。面向内存的传输,如TMemoryBuffer,通常用于收集多个小的写入操作,然后作为单个块传输。基于文件的传输,如TSimpleFileTransport,通常用于日志记录和状态持久性。

最重要的Apache Thrift Transport类型是面向网络的,用于支持RPC操作。最常用的Apache Thrift网络传输是TSocket。TSocket传输使用Socket API通过TCP/IP传输字节(参见图2.5)。

image.png

其他设备和网络协议也可以通过TTransport接口公开。例如,许多Apache Thrift语言库提供HTTP传输,以使用HTTP协议进行HTTP读写。为一个不受支持的网络协议构建一个自定义的Transport通常不是困难的,这样做可以使用整个框架在新的端点类型上操作。

2.1.3 分层传输

因为Apache Thrfit Transport是由通用的TTransport接口定义的,客户端代码独立于底层传输实现。这使得Transport能够覆盖任何东西,甚至是其他的Transport。分层允许将通用的Transport行为划分为可互操作的组件和可重用的组件。

想象您正在构建一个银行应用程序,它调用另一家公司托管的服务,您需要加密客户端与RPC服务器之间传输的所有字节。如果创建分层传输以提供加密,则客户端和服务器代码可以在原始网络传输之上使用新的加密层。在分层传输中隔离这种新加密特性的好处有几个,最重要的是它可以插入现有客户端代码和旧网络传输之间,而可能不会产生影响。客户端代码将把加密传输层视为另一个传输层。网络端点传输将把加密传输视为另一个客户端

该加密传输可以在任何端点传输之上进行分层,从而允许您对网络I/O、文件I/O和内存I/O进行加密。分层方法允许将加密从设备I/O中分离出来。

在这本书中,我们将所有不是端点运输的Apache Thrfit Transport称为“分层运输”。分层传输将标准的Apache Thrift TTransport接口公开给客户端,并依赖于下面层的TTransport接口。通过这种方式,可以使用一个或多个传输层来形成传输堆栈(参见图2.6)。

image.png

一种常用的Apache Thrift分层传输是framing传输。这种传输在大多数语言库中称为TFramedTransport,它为每个Apache Thrift Message添加一个四字节大小的前缀。这使得在某些场景中能够更有效地处理消息,允许接收方读取帧大小,然后提供帧所需的精确大小的缓冲区。

客户机和服务器必须使用兼容的传输堆栈来进行通信。如果服务器正在使用TSocket传输,则客户端将需要使用TSocket传输。如果服务器在TSocket上使用TFramedTransport,那么客户端将必须在TSocket上使用TFramedTransport。Apache Thrift没有内置的运行时传输或协议发现机制,尽管可以在ApacheTh Thrift之上创建自定义发现系统。

分层传输提供的另一个重要特性是缓冲。TFramedTransport隐式写入到缓冲区,直到调用flush()方法,此时帧大小和数据被写入下面的传输层。TFramedTransport是TBufferedTransport的替代品,它可以在不需要Framing时提供缓冲。有几种语言在端点解决方案中构建缓冲,并且不提供TBufferedTransport(Java就是一个例子)。

2.1.4 服务器传输

当两个进程通过网络连接以方便通信时,服务器必须侦听客户端连接,并在新连接到达时接受新连接。服务器的连接接受者的抽象接口通常被命名为TServerTransport。TServerTransport中最流行的实现是TServerSocket,用于TCP/IP网络。服务器传输将每个新连接连接到TTransport实现,以处理单个连接的I/O。服务器传输遵循工厂模式,包括制造服务器套接字、服务器管道制造管道等(参见图2.7)。

image.png

服务器传输通常只有四个方法(请参见表2.2)。listen()和close()方法分别为准备使用服务器传输和关闭它。在调用listen()之前或调用close()之后,客户端无法连接。accept()方法一直阻塞到客户端连接到达。当客户端启动连接时,accept()方法返回TTransport实例,该TTransport实例与用于支持与客户端的正常RPC操作的Connection连接。interrupt()方法将服务器传输从阻塞accept()调用中中断,导致其返回。

image.png

2.2 Protocols

在Apache Thrift的上下文中,协议是序列化类型的一种方法。Apache Thrift RPC并不支持每种语言中定义的所有类型。相反,Apache Thrift类型系统包括大多数语言中所有重要的基类型(int、double、string等),以及一些大量使用和广泛支持的容器类型(map、set、list)。所有协议必须能够读取和写入Apache Thrift类型系统中的所有类型。

协议位于传输堆栈的顶部(请参见图2.8)。人工分为负责操作字节的Transport和负责处理数据类型的Protocol。Transport只看到不透明的字节流;Protocol将数据类型转换为字节流(参见图2.9),反之亦然。

image.png

例如,如果您想将一个整数存储到一个系统上的磁盘文件中,并使其在另一个系统上可读,则需要确保该整数按照约定的字节顺序存储。最重要或最不重要的字节必须是第一个。通过序列化协议,可以在这两个选项之间进行选择。传输只需按照显示的顺序将提供给磁盘的字节写入。

Apache Thrift提供了几个序列化协议,每个协议都有自己的目标:

  • 二进制协议(Binary Protocol):简单快速
  • 紧凑协议(Compact Protocol):更小的数据大小,而没有过多的开销
  • JSON协议(JSON Protocol):基于标准、可读以及广泛的互操作性

二进制协议是默认的Apache Thrift协议,在初始发布时,它是唯一的协议。二进制协议需要最小的CPU开销,本质上是在处理字节排序、类型规范化和其他一些任务之后,将字节类型写入字节流中。因此,当使用二进制协议时,一个64位的整数将在线上占用大约64位。

紧凑协议的设计目的是为了最小化数据的序列化表示。紧凑协议相当简单,但在将比特转换到更小空间的过程中使用了更多的CPU。在I/O是瓶颈和CPU丰富的情况下(常见情况),这是一个需要考虑的好协议。

JSON协议会将输入转换为JSON格式的文本。在三种常见的Apache Thrift协议中,JSON可能在传输中产生最大的数据表示,并消耗最多的CPU。JSON的优点是广泛的互操作性和可读性。

Apache Thrift语言通常提供一个抽象的协议接口,称为TProtocol。此接口定义了读取和写入每个Apache Thrift类型的方法,以及用于序列化容器(list, set, map)、用户定义类型和RPC消息的组合方法。

Apache Thrift类型系统的一个关键特性是它支持以Struct形式存在的用户定义类型。Apache Thrift Struct是由一组字段构建的基于idl的复合类型。字段可以是任何合法的Apache Thrift类型,包括基本类型、容器(list, set, map)和其他Struct。

Apache Thrift Message是用于通过Transport传递RPC调用和响应的载体。这些Message被实现为专门的Apache Thrift Struct。

表2.3列出了定义Apache Thrift类型系统的几种典型的TProtocol方法。这里列出的每个写方法都有一个具有相同的后缀的读取方法(例如,writeBool()/readBool())。(完整的协议清单请参见第5章。)

image.png

2.3 Thrift IDL

结合Apache Thrift Protocol和Transport,提供了一种序列化double、string集合和其他此类通用数据表示的方法。虽然很有用,但大多数应用程序也会处理用户定义的数据类型。例如,股票交易应用程序可以处理交易报告,社交平台可以处理状态更新,飞行模拟器可以处理遥测数据。

接口定义语言(IDL)可用于定义应用程序级类型和服务接口,使工具能够生成代码来自动化这些类型的序列化。您可以在IDL中描述交易类型,并让Apache Thrift IDL编译器为您生成序列化代码,而不是为股票交易程序手写交易报告的序列化代码。

Apache Thrift IDL是独立于实现语言的。IDL编译器读取IDL文件,并可以以任何Apache Thrift目标语言输出序列化代码和RPC客户端/服务器存根(参见图2.10)。

image.png

想象一下,你正在用Python为加州渔业局编写一个程序,你想调用由西雅图海洋研究中心维护的服务器来检索大比目鱼捕获级别,但你会发现服务器是用Java编写的。如果服务器是用Apache Thrift API编码的,那么您可以编译IDL为Python生成客户端存根,然后使用Python存根直接调用Java服务器。

下面的列表显示了这样种接口定义的示例。

struct Date {
    1: i16 year
    2: i16 month
    3: i16 day
}

service HalibutTracking {
    i32 get_catch_in_pounds_today()
    i32 get_catch_in_pounds_by_date(1: Date dt, 2: double tm)
}

在上面的IDL文件中定义的服务称为HalibutTracking. 此服务依赖于用户定义的类型:Date。 要将IDL编译成特定于语言的代码,将使用一个开关调用IDL编译器,以指示要为其生成代码的目标语言。thrift --gen java halibut命令将输出一组java文件,用于日期类型的序列化和客户端/服务器RPC存根,以支持共享跟踪服务。thrift --gen py halibut会在Python中生成相同接口的存根。

2.3.1 用户自定义类型和序列化

用户自定义类型(UDT)是外部接口的一个重要方面。虽然在我们上面的示例中,可以使用离散的年/月/日参数组合get_catch_in_pounds_by_date方法,但Date类型更具表现力、可重用性和更简洁。Apache Thrift IDL允许使用“struct”关键字创建用户定义的类型。

image.png

IDL编译器从IDL类型生成特定于语言的类型;例如,struct关键字将导致IDL编译器在C++中生成一个类,在Erlang中生成一个record,在Perl中生成一个package。这些生成的类型具有内置的序列化功能,这使得使用任何Apache Thrift Protocol/Transport堆栈来序列化它们变得很容易(参见图2.11)。为了保持通用性,下面的IDL编译器代码输出示例使用伪代码,近似于您可能看到的任何给定语言的输出。

下面的列表显示了一个伪代码示例,说明了IDL编译生成的UDT在某些语言中的样子。

class Date {
    public:
        short year;
        short month;
        short day;
        read(TProtocol protocol) {…};
        write(TProtocol protocol) {…};
};

上面的伪代码中说明的简单日期类型具有IDL中描述的确切的字段,并被组织成一个与IDL结构同名的类。Apache Thrift IDL编译器创建read()和write()方法,以通过Apache Thrift协议接口自动序列化该类型的过程。这使得传输复杂的数据结构就像以目标Apache Thrift Protocol作为参数在结构上调用读或写一样容易。

Apache Thrift Sturct在Apache Thrift框架的内部使用,作为打包所有RPC数据传输的手段。每个Apache Thrift服务方法的参数列表都是在一个“args”结构体中定义的。这允许Apache Thrift使用相同的方便的结构read()和write()方法来发送和接收RPC参数列表。

Struct的写方法的实现是对适当的协议方法的简单顺序调用。下面的列表显示了日期结构体的写入方法的伪代码。

Date::write(TProtocol protocol) {
    // 开始写结构
    protocol.writeStructBegin("Date");
    
    // 开始写字段year
    protocol.writeFieldBegin("year", T_I16, 1);
    // 字段类型为I16,写入数据
    protocol.writeI16(this.year);
    // 字段写完成
    protocol.writeFieldEnd();
    
    // 开始写字段month
    protocol.writeFieldBegin("month", T_I16, 2);
    protocol.writeI16(this.month);
    protocol.writeFieldEnd();
    
    // 开始写字段day
    protocol.writeFieldBegin("day", T_I16, 3);
    protocol.writeI16(this.day);
    protocol.writeFieldEnd();
    
    // 停止写字段
    protocol.writeFieldStop();
    
    // 写结构体结束
    protocol.writeStructEnd();
}

编写可序列化的、与语言无关的类型的能力是Apacheth Thrift IDL的一个关键特性。类型可以序列化到内存中,然后通过消息传递系统发送,类型可以直接在RPC方法中使用,并且类型可以序列化到文件中。

2.3.2 RPC服务

对于许多程序员来说,构建跨语言RPC服务是使用Apache Thrift的主要原因。在Apache Thrift IDL中定义服务允许IDL编译器生成客户端和服务器存根,这些存根提供远程调用函数所需的所有管道(参见图2.12)。

image.png

下面的列表显示了由编译器生成的健康跟踪服务接口的伪代码。

interface HalibutTracking {
    int32 get_catch_in_pounds_today();
    int32 get_catch_in_pounds_by_date(Date dt, double tm);
};

这个服务有两种方法,它们都返回一个32位的整数,另一种方法采用一个Date结构和一个double作为输入。除了用目标语言定义接口之外,IDL编译器还将生成一对类来支持这个接口上的RPC:一个用于客户端进程的客户端存根和一个用于服务器进程的被称为Processor服务器存根。客户端类被用作远程服务的代理。Processor用于代表远程客户端调用用户定义的服务实现。

客户端存根

对调用远程服务器中的服务感兴趣的客户端可以在客户端代理对象上调用所需的方法。在覆盖范围下,客户端代理向服务器发送消息,包括有关要调用的方法和任何参数的信息。通常,客户端代理然后等待接收来自服务器的调用结果(参见图2.13)。使用生成的客户端代理使得开发软件利用RPC服务就像编码到本地函数一样自然。

image.png

下面的列表显示了IDL编译器生成的客户端实现的get_catch_in_pounds_by_date方法的伪代码。

int32 HalibutTrackingClient::get_catch_in_pounds_by_date(Date dt, double tm)
{
    // 向服务器发起调用
    send_get_catch_in_pounds_by_date(dt, tm);
    // 接收服务器返回的处理结果
    return recv_get_catch_in_pounds_by_date();
}

void HalibutTrackingClient::send_get_catch_in_pounds_by_date(Date dt, double tm)
{
    // 写消息,消息是数据传输的载体
    protocol.writeMessageBegin("get_catch_in_pounds_by_date", T_CALL, 0);
    // 构建args结构体
    HalibutTracking_get_catch_in_pounds_by_date_args args;
    // 设置参数
    args.dt = dt;
    args.tm = tm;
    // 写到协议/传输堆栈中
    args.write(protocol);
    // 写Message结束
    protocol.writeMessageEnd();
    // 通过传输层发送
    protocol.getTransport().flush();
}

在本例中,get_catch_in_pounds_by_date的客户端实现调用内部“send_”方法来向服务器发送消息。接下来是调用第二个“recv_”方法来接收结果。这是Apache Thrift RPC协议的基础。客户端向服务器发送消息以调用方法,然后服务器将结果发送回来。

列表中的第二个方法是发送方法的伪代码。send方法将创建一个要发送到服务器的消息。消息以protocol.writeMessageBegin开始。序列化T_CALL常量,通知服务器这是一个“RPC调用”类型的消息。字符串“get_catch_in_bonbgs_by_date”被序列化,以表示我们要调用的方法。在这里传递的0表示我们不会使用序列号。消息序列号在某些应用程序中很有用,但在大多数正常的Apache Thrift RPC中并不使用(关于使用Apache Thrift Message的更多信息,请参阅第8章,“实现服务”)。

正如我们在上一节中发现的,Apache Thrift IDL编译器可以为IDL中定义的任何结构生成read()和write()序列化方法。Apache Thrift并没有重新发明轮子,而是为每个方法的参数列表生成一个内部结构,称为args。要将方法的参数添加到字节流中,将实例化args结构,并使用方法调用的参数进行初始化。调用args对象的write()方法序列化调用send_get_catch_in_pounds_by_date方法所需的所有参数。

消息序列化通过调用writeMessageEnd来结束消息写入。一旦消息被完全序列化,传输堆栈被要求将字节Flush到网络(以防它们被缓冲)。

客户端使用recv_get_catch_in_pounds_by_date()接收服务端响应。服务器可以用两个Message中的一个来响应RPC调用。第一个是正常的T_REPLY,第二个是T_EXCEPTION。与args类的创建相一致,Thrift编译器为每个服务方法生成一个结果类,以打包该方法的结果。recv_get_catch_in_pounds_by_date()方法执行与send_get_catch_in_pounds_by_date()方法相同的操作,不同的是,使用结果对象read方法读取服务器响应。如果recv_get_catch_in_pounds_by_date()方法解码了一个正常的结果,则返回该结果。如果解码了异常,则会发生特定于语言的处理,例如抛出异常。

虽然上述功能是Thrift内部实现的,但这是从客户端的角度对Apache Thrift RPC功能的相当简洁的总结。

服务Processor

Processor依赖于一个用户编码的服务“handler”来实现服务接口(嘿,你必须在这里做一些工作)。该处理程序被提供给处理器,以完成RPC支持链。服务处理程序是实现完整的Apache Thrift服务所需要编写的唯一代码。

2.4 Server

在Apache Thrift的上下文中,服务器是专门为托管一个或多个Apache Thrift服务而设计的程序。事实证明,Apache Thrift RPC服务器的工作是相当公式化的。服务器侦听客户端连接,使用生成的Processor向服务分派调用,并且偶尔会被管理员关闭(见图2.14)。

image.png

服务器操作的样板特性允许Thrift提供一个具有广泛实现的服务器类库。不同的语言库可以根据社区的需求和语言的功能来支持不同的服务器类。例如,Java提供了单线程和多线程服务器,以及使用专用客户端线程的服务器和使用线程池来处理请求的服务器,而Go服务器则使用go例程。并发模型是所提供的各种服务器之间的关键区别(更多服务器详细信息请参见第10章)。

大多数生产需求都可以通过现有的库服务器来满足。Apache Thrift是开源的,所以即使是独特的需求也可以通过定制现有的服务器来满足。让我们来看看下面列表中的一个简化的Java程序,它使用一个Apache Thrift库服务器来支持共享跟踪服务。

public class JavaServer {
    public static void main(String[] args) {
        // 创建一个ServerSocket用于监听客户端连接
        TServerTransport svrTransport = new TServerSocket(8585);
        // 创建自定义handler
        HalibutTrackingHandler handler = new HalibutTrackingHandler();
        // 创建Processor,将请求转发到handler
        HalibutTracking.Processor<HalibutTrackingHandler> processor =
                                        new HalibutTracking.Processor<>(handler);
        // 创建服务器对象
        TServer server = new TSimpleServer(
                        new Args(svrTransport).processor(processor));
        server.serve();
    }
}

这个简单的Java服务器首先创建一个TServerSocket服务器传输,它将监听端口8585以获得新的客户端连接。创建一个跟踪处理程序对象来实现服务(处理程序代码没有显示;它将包含实现所需要的任何逻辑)。接下来,我们创建一个Processor来管理RPC调用调度。然后,使用服务器传输和处理器/处理器对作为输入来构建一个TSimpleServer对象。我们没有指定任何协议,因此将使用默认的二进制协议。作为最后一步,我们调用服务器的serve()方法,此时服务器开始接受连接并处理对健康跟踪服务的调用。

使用来自Apache Thrift Java语言库的简单服务器类,我们可以在大约5行代码中创建一个功能齐全的服务器。复杂的服务可能需要许多行处理程序代码;然而,服务器外壳并不比您在上面看到的要复杂得多。一个多线程异步服务器可以在大致相同的占用空间中实现。

2.5 安全

Apache Thrift没有在框架层面明确规定安全。通过将安全作为外部问题,Apache Thrift允许应用适当的安全机制,而不复杂化或不必要地影响其性能。

许多Apache Thrift实现完全位于私人数据中心。在这些场景中,许多所需的安全性可能以隔离的形式出现,包括防火墙、dmz和其他方案。微服务编排平台通常通过像Istio这样的服务网格来实现服务间的安全和策略。各种Apache Thrift语言库包括对安全特性的支持程度,如SASL(简单身份验证和安全层)和SSL/TLS。可以使用前面讨论的分层模型将自定义安全机制集成到Apache Thrift中。Apache Thrift架构为许多可能性敞开了大门。

在下一章中,我们将介绍建立一个工作的Apache Thrift开发环境的各种方法,并介绍几种一般的RPC调试技术。

总结

  • Transport为Apache Thrift框架的其他部分提供了设备独立性。
  • 端点传输在物理或逻辑设备上执行字节I/O,如网络、文件和内存。
  • 分层传输以模块化的方式为现有Transport添加了功能,例如消息Framing和缓冲。
  • 任意数量的分层传输都可以堆叠在单个端点传输之上,以创建传输堆栈。
  • Server Transport不是真正的传输;相反,它们是工厂,接受客户端连接,并为每个连接的客户端制造新的Transport。
  • Apache Thrift IDL允许定义用户定义的类型和服务接口。
  • Apache Thrift IDL编译器以各种输出语言生成IDL用户定义类型的自序列化表示。
  • Apache Thrift IDL编译器以各种输出语言为IDL定义的服务接口生成客户机和服务器存根。
  • Apache节俭服务器库允许以最小的编码工作和一系列并发模型部署IDL定义的服务。