随着新的软件系统越来越多地采用动态调度、容器化的微服务,轻量级、高性能、与语言无关的网络通信变得越来越重要。现在有以下的问题需要解决:
- 但是如何将这些不同的服务连接在一起呢?
- 我们怎么打包使用某种语言编写的服务发出的消息,同时怎么在使用另外一种语言编写的服务中读取该消息?
- 我们如何设计出足够快的高性能、后端云系统,但可通过前端脚本技术访问的服务?
- 我们如何保持轻量级以支持高效的容器和嵌入式系统?
- 我们如何创建能够在不破坏现有组件的情况下随时间发展的接口?
- 我们如何以一种开放的、供应商中立的方式实现所有这些,也许最重要的是,我们如何只需要编写一次通信原语,而此原语可以在在一个广泛的平台上重用?
对于脸书、永信和推特等公司来说,答案是Apache Thrift。
本章介绍了Apache Thrift框架及其在现代分布式应用程序中的作用。我们将了解到为什么要创建Apache Thrift,以及它如何帮助程序员构建高性能的跨语言服务。首先,我们将考虑对多语言集成日益增长的需求,并研究Apache Thrift在多种语言应用程序开发中所扮演的角色。接下来,我们将介绍Apache Thrift的两个关键功能:序列化和RPC,并详细介绍一个简单的Apache节俭服务的构建。在本章的最后,我们将把Apache Thrift与其他几个提供类似特性的工具进行比较,以帮助您确定何时Apache Thrift可能是一个不错的选择。
1.1 多语言主义,快乐和痛苦
近年来,常用商业用途的编程语言的数量大幅增长。从下图可以看出,2003年Tiobe索引排行榜中,Java、C、C++、Perl、VB和PHP占据了近80%。到2013年,其中80%的索引中增加了Objective-C、Python、JavaScript、Ruby等语言。2016年初,Tiobe前20名加起来没有占80%。
越来越多的开发人员和架构师选择最适合手头任务的编程语言。开发大数据项目的开发人员可能会认为Clojure是最好的使用语言,前端工作人员可能更加倾向于TypeScript,程序员通常采用使用C语言开发嵌入式系统。多年前,这种类型的多样性在一家公司是很少见的,现在可以在一个团队中找到。
选择一种唯一适合于解决特定问题的编程语言可以提高工作效率和提高更好的软件质量。当语言符合问题时,摩擦就减少了,编程就变得更加直接,代码也变得更简单、更容易维护。例如,在大规模数据分析中,水平扩展有助于实现可接受的性能。像Haskell、Scala和clojule这样的函数式编程语言往往很自然地适合这里,允许分析系统在没有复杂并发问题的情况下进行扩展。
平台也推动了语言的采用。当苹果公司发布iPhone时,Object-C迅速走红,Swift也在效仿。Go是蓬勃发展的容器生态系统的采用的编程语言,负责Docker、K8S以及其他的产品。浏览器相关的编程适合使用TypeScript或者JavaScript。游戏和GUI世界常常使用C++编写高性能图像处理引擎。这些选择都是由历史和令人信服的技术基础所驱动的。即使在某个特定的领域会使用同一种语言,但在跨业务边界进行协作时语言之间会进行混合和交互。
动态编程语言如Groovy和Ruby经常用于测试,而Lua、Perl和Python在原型设计中很流行,而PHP在WEB开发中有着悠久的历史。基于groovy的Gradle和基于ruby的Rake等构建系统也提供了创新的功能。
但引入多语言所带来的并不全是好处。掌握一门新的编程语言不是一件简单的事情,就更不要说与其相关的工具和库了。这种负担随着每一种新的语言而增加,同时公司的收益也会降低。构建基于groovy的Gradle和基于ruby的Rake等系统也提供了创新的功能。
Apache Thrift的关键优势之一是它能够简化、集中化和封装系统的跨语言方面。Apache Thrift为多种语言的应用程序开发提供了广泛的支持。Apache Thrift目前支持超过20种常见的编程语言(表1.1),并且还在不断增长。 这种对多种语言的直接支持,以及Apache Thrift社区对新语言的快速支持,可以帮助组织最大限度地发挥多元语言的潜力,同时最大限度地减少缺点。
1.2 应用程序集成Apache Thrift
无论您的应用程序是否使用多种平台和语言,它的操作都很可能在网络和时间上跨越多个进程。有时,这些进程需要通过磁盘上的文件、内存中的缓冲区或跨网络进行通信。有两个核心问题与进程间的通信有关:
- 类型序列化(Type Serialization)
- 服务实现(Service Implementation)
1.2.1 类型序列化
序列化是任何跨平台/语言交流中的一个基本功能。例如,想象一下一个针对音乐行业的应用程序,它使用NATS作为一个消息传递系统,在进程之间移动歌曲数据(见图1.2)。使用NATS,团队可以在用Java和Python编写的远程进程之间快速发送/接收消息。问题是,当节目由另一种语言发送时,这些节目能读到音乐信息吗?Python对象在内存中的表示方式与Java对象不同。如果一个Python程序将其音乐轨道数据的原始内存位发送给一个Java程序,问题会随着而来。
为了解决这个问题,我们需要在消息平台的上层的设计一个数据序列化层。有人可能会问,为什么使用JSON作为载体发送和接收消息呢?使用像JSON这样的标准格式是解决方案的一部分;然而,我们仍然必须回答以下问题:在发送多字段消息时数据字段是如何排序的,当字段缺失时会发生什么,以及不直接支持数据类型的语言在接收该数据类型时会做什么?这些以及其他的问题不能由比如JSON、XML、YAML等数据布局规范来回答,因为不同的语言经常为同一数据集生成不同的、尽管格式合法的文档。
IDL和类型
Apache Thrift提供了一个模块化的序列化框架来解决这些问题。使用Apache Thrift,开发人员可以使用接口定义语言(IDL)来定义抽象数据类型。然后,可以将此IDL编译为任何受支持的语言的源代码。生成的代码为所有用户定义的类型提供了完整的序列化和反序列化逻辑。Apache Thrift确保由任何语言编写的类型都可以被任何其他语言读取。下面的列表显示了一个假设的音乐应用程序的Apache Thrift的IDL类型定义。
namespace * music
enum PerfRightOrg {
ASCAP = 1,
BMI = 2,
SESAC = 3,
Other = 4
}
typedef double Minutes
struct MusicTrack {
1: string title
2: string artist
3: string publisher
4: string composer
5: Minutes duration
6: PerfRightsOrg pro
}
有些人抱怨说,创建IDL是一个额外的步骤,从而减缓了开发过程。我发现情况恰恰相反。IDL迫使只需要考虑接口定义,而不关系如何实现。这可能是您花在系统设计上的最重要的时间。IDL也是轻量级的,易于修改和实验,并且通常作为业务端的通信工具很有用。
用户可能会说,无模式的系统更加灵活同时IDL很脆弱。事实是,无论您是否记录您的模式,如果您正在阅读和解释数据,您仍然有一个模式。隐式的(未文档化的)模式可能是相当危险的应用程序错误的来源,并给需要与数据交互或扩展系统的开发人员带来负担。如果您只编写了读写数据布局的代码而没有定义数据布局,那么当您想要扩展系统时,速度将会很慢。整个系统中有多少代码依赖于这个隐含的模式?你是如何改变这样的事情的?
NoSQL系统的流行,其中许多系统是无模式的,这为IDL创建了另一个角色。现在,您可以在单个地方记录您的类型,并在服务调用、消息传递系统和Redis、MongoDB等存储系统中使用这些类型。
一些系统逆转了这个过程,并从给定的编码解决方案中生成它们的模式。注释驱动的系统,如Java的JAX-RS,可以以这种方式工作。这种方法很容易导致实现细节与接口定义产生偏差,限制了可移植性和清晰度。修改实现代码通常比修改IDL要工作多得多。此外,您也不能保证另一个供应商的代码生成器将从外部模式创建兼容的代码。在通信解决方案涉及多个供应商时,这都是一个问题。
Apache Thrift通过提供一个单一的真理来源——IDL,避开了许多这些问题。Apache Thrift为单独的IDL提供了跨越广泛的编程语言集合的供应商独立的支持。随着框架的增长,Apache Thrift跨语言测试套装不断地验证互操作性。
接口演化
IDL创建了一个所有各方都可以依赖的合同,代码生成器可以使用它创建可工作的序列化操作,以确保合同得到遵守。然而,IDL模式并不一定会很脆弱。Apache Thrift IDL支持一系列接口演化特性,如果使用得当,这些特性允许添加和删除字段、更改类型等等。
对接口演化的支持大大简化了正在进行的软件维护和扩展的任务。现代工程敏感性,如微服务、持续集成(CI)和持续交付(CD),需要系统支持在不影响平台其他部分的情况下进行增量改进。不提供任何接口演化形式的工具在改变时往往会“打破世界”。在这样的系统中,更改接口意味着使用该接口的所有客户端和服务器必须重写或重新编译,然后在大爆炸中重新部署。
Apache Thrift接口演化特性允许多个接口版本在单个操作环境中无缝共存。这使得增量更新更加可行,支持CI/CD管道,并使单个敏捷团队能够以自己的节奏交付业务价值。
持续集成(CI)和持续交付(CD)
持续集成是软件开发的一种方法,其中对系统的更改经常被合并到中央代码库中。这些更改是不断构建和测试的,通常由自动化系统进行,当补丁产生冲突或测试失败时,为开发人员提供快速反馈。连续交付使CI更进一步,成功地将合并的代码迁移到评估/分段系统中,并最终在每天多次迁移到生产系统中。持续系统的目标是承担许多小风险并提供即时反馈,而不是承担大风险并在长时间的发布周期中延迟反馈。集成延迟的时间越长,涉及的补丁就越多,这使得识别和修复冲突和错误变得更加困难。
模块化“序列化”
Apache Thrift提供了可插拔的序列化器,称为协议,允许您使用几种序列化格式中的任何一种进行数据交换,包括二进制的提供的速度以及紧凑的大小,和JSON的可读性。即使您更改序列化协议,相同的合同(IDL)也可以保持不变。这种模块化的方法还允许添加自定义的序列化协议。因为Apache Thrift是社区管理和开源的,所以您可以轻松地更改或增强功能,并在需要时将其推送到上游(Apache Thrift项目总是欢迎使用补丁)。
1.2.2 服务实现
服务是模块化的应用程序组件,用于提供可通过网络访问的接口。Apache Thrift IDL允许您定义除类型之外的服务(参考如下代码)。与类型一样,也可以编译IDL服务以生成存根代码。服务存根用于连接多种语言中的客户端和服务器。
service SailStats {
double get_sailor_rating(1: string sailor_name)
double get_team_rating(1: string team_name)
double get_boat_rating(1: i64 boat_serial_number)
list<string> get_sailors_on_team(1: string team_name)
list<string> get_sailors_rated_between(1: double min_rating, 2: double max_rating)
string get_team_captain(1: string team_name)
}
想象您有一个模块,可以跟踪和计算帆船队的统计数据,该模块内置到一个Windows C++ GUI应用程序中,旨在可视化气流动力学。碰巧,您公司的web开发团队希望使用帆船统计模块来增强Linux上面向客户端基于Nodejs开发的WEB应用程序。面对多种语言和平台以及“懒惰”公理(希望编写尽可能少的代码),Apache Thrift可能是一个很好的解决方案(见图1.3)。
有了Apache节俭,我们可以将帆船统计功能作为一个微服务重新打包,并向Nodejs编程人员提供一个简单易用的Nodejs客户端存根来访问服务。要创建帆船统计微服务,我们只需要在IDL中定义服务接口,编译IDL,为服务创建客户端和服务器存根,选择一个预先构建的Apache Thrift服务器来托管服务,然后组装各个部分。
预构建的服务器外壳
需要注意的是,与独立的序列化解决方案不同,ApacheThrifth提供了一套完整的服务器外壳,几乎所有支持的语言都可以使用。这就避开了构建自定义网络服务器的困难和重复的过程。预先构建的Apache节俭服务器也很小和集中,只提供了托管服务所必需的功能。一个典型的Apache Thrift服务器将消耗的内存比一个等效的Tomcat部署少一个数量级。这使得Apache Thrift服务器对于容器化的微服务和嵌入式系统是一个不错的选择,这些系统没有运行成熟的web或应用程序服务器所需的资源。
模块化传输
Apache Thrift还提供了一个可插拔的传输系统。Apache Thrift客户端和服务器通过传输进行通信,使Apache Thrift数据流适应到外部世界。例如,TSocket传输允许Apache Thsrift应用程序通过TCP/IP套接字进行通信。您可以为其他通信方案使用预构建的传输,例如命名管道和UNIX域套接字。定制的运输工具也很容易制作。Apache Thrift还支持离线传输,允许将数据序列化到磁盘、内存和其他设备。
Apache Thrift传输模型的一个特别优雅的方面是支持分层传输。协议将应用程序数据序列化为一个位流。Transports读取和写字节,使任何类型的操作都成为可能。例如,TZLibTransport可以在许多Apacheth Thrift语言库中使用,并且可以在任何其他传输之上进行分层,以实现高比率的数据压缩。您可以将数据分支到日志中,分叉请求分支到并行服务器,使用自定义分层传输加密和执行任何其他方式的操作。
1.3 构建一个简单的服务
为了更好地理解Apahce Thrift的实践方面,我们将建立一个简单的“Hello World”微服务。该服务将被设计为为企业的各个部分提供每日问候语,公开一个“hello_func”函数,它不需要任何参数并返回一个问候语字符串。为了了解Apache节俭是如何跨语言工作的,我们将用C-++、Python和Java构建客户端。
1.3.1 Hello IDL
大多数涉及Apache Thrift的项目首先都要仔细考虑所涉及的接口组件。Apache Thrift IDL在符号上类似于C,便于定义跨系统共享的类型和服务。Apache Thrift IDL是保存在扩展名为“.thrift”的文件中的纯文本(参见以下列表)。
service HelloSvc {
string hello_func()
}
hello.thrift文件声明了一个名为HelloSvc的单一服务接口,其中包含一个函数,hello_func()。该函数不接受任何参数并返回一个字符串。要使用这个接口,我们可以用Apache Thrift IDL编译器来编译它。IDL编译器二进制文件在类似unix的系统上被命名为“thrift”,在WINDOWS上被命名为“thrift.exe”。编译器需要两个命令行参数,一个是编译IDL文件,另一个(或多种)目标语言生成代码。下面是一个为HelloSvc生成Python存根的示例会话:
thrift --gen py hello.thrift
在上一个会话中,IDL编译器在执行时指定了-gen py选项,这导致编译器创建一个gen-py目录来容纳hello.thrift编译生成的Python代码。该目录包含所有服务的客户机/服务器存根,以及IDL文件中所有用户定义类型的序列化代码。
1.3.2 Hello 服务器
现在我们已经生成了支持代码,我们就可以实现我们的服务,并使用预构建的Apache Thrift服务器来存放它。下面的列表提供了一个用Python编码的示例服务器。
import sys
sys.path.append("gen-py")
from hello import HelloSvc
from thrift.transport import TSocket
from thrift.transport import TTransport
from thrift.protocol import TBinaryProtocol
from thrift.server import TServer
class HelloHandler:
def hello_func(self):
print("[Server] Handling client request")
return "Hello from the python server"
# 处理逻辑
handler = HelloHandler()
# 构建processor
proc = HelloSvc.Processor(handler)
# 创建服务端Socket
trans_svr = TSocket.TServerSocket(port=9090)
# 创建TSocket工厂
trans_fac = TTransport.TBufferedTransportFactory()
# 创建序列化工厂
proto_fac = TBinaryProtocol.TBinaryProtocolFactory()
# 创建TServer对象
server = TServer.TSimpleServer(proc, trans_svr, trans_fac, proto_fac)
server.serve()
在服务器列表的顶部,我们使用内置的Python sys模块将gen-py目录添加到Python路径中。这允许为HelloSvc服务导入生成的服务存根。
下一步是导入几个Apache Thrift包。TSocket为我们的客户端提供了一个端点,TTransport提供了一个缓冲层,TBinaryProtocol将处理数据序列化,TServer将让我们访问预构建的Python服务器类。
下一个代码块通过Hello处理程序类实现了HelloSvc服务本身。这个类在Apache Thrift中被称为处理程序,因为它可以处理对该服务的所有调用。所有的服务方法都必须在处理程序类中表示;在我们的例子中,这是hello_func()方法。在实际的项目中,您几乎所有的时间和精力都花在了这里,即实现服务。Apache Thrift负责处理布线和样板文件代码。
接下来,我们创建一个处理程序的实例,并使用它为我们的服务初始化一个处理器。处理器是由IDL编译器生成的服务器端存根,它将网络服务请求转换为对适当的处理程序函数的调用。
Apache Thrift库提供了用于文件、内存和各种网络类型的端点传输:这里的示例创建了一个TCP服务器套接字端点来接受TCP端口9090上的客户端连接。缓冲层确保我们有效地利用底层网络,只有在整个消息被序列化时才传输位。二进制序列化协议以快速二进制格式传输我们的数据,开销很少。
Apache Thrift提供了一系列可供选择的服务器,每个服务器都具有独特的特性。这里使用的服务器是简单的服务器类的一个实例,正如它的名称所示,它提供了最基本的服务器功能。一旦构建完毕,我们就通过调用serve()方法来运行服务器。
python hello_server.py
Python服务器采用了大约7行代码,不包括导入和服务实现。这个故事在C++、Java和大多数其他语言中都很相似。这是一个基本的服务器,但是这个示例应该可以帮助您了解Apache Thrift在快速创建跨语言微服务方面给了您多少杠杆作用。
1.3.3 Python 客户端
现在我们已经运行了服务器,让我们创建一个简单的Python客户端来测试它,如下列表所示。
import sys
sys.path.append("gen-py")
from hello import HelloSvc
from thrift.transport import TSocket
from thrift.transport import TTransport
from thrift.protocol import TBinaryProtocol
# 构建TSocket套接字对象
trans = TSocket.TSocket("localhost", 9090)
# 在套接字的基础上构建可缓存的传输层
trans = TTransport.TBufferedTransport(trans)
# 构建二进制协议层序列化数据
proto = TBinaryProtocol.TBinaryProtocol(trans)
# 获取客户端对象
client = HelloSvc.Client(proto)
trans.open()
# 发起rpc调用
msg = client.hello_func()
# 打印结果
print("[Client] received: %s" % msg)=
trans.close()
我们将从Apache Thrift Python库导入三个模块。第一个是TSocket,它在客户端用于与服务器套接字建立TCP连接;正如您可能猜测的那样,客户端必须使用与服务器传输兼容的客户端传输。下一个导入引入TTransport,它将提供一个网络缓冲区,而TBinaryProtocol允许我们将消息序列化到服务器。同样,这必须与服务器实现匹配。
下一个代码块用主机和端口初始化TSocket以连接到服务器。我们将把套接字传输包装在缓冲区中,最后将整个传输堆栈包装在TBinaryProtoco中,创建一个可以序列化服务器之间的数据的I/O堆栈。
I/O堆栈由客户端存根使用,它作为远程服务的代理。打开传输会使客户端连接到服务器。在客户端对象上调用hello_func()方法,用二进制协议序列化我们的调用请求,并通过套接字将其传输到服务器,然后反序列化返回的结果1。该程序将打印出结果1!然后使用Transport的close()方法关闭连接。
> python hello_client.py
[Client] received: Hello from the python server
虽然这比您运行的“hello world”程序需要更多的工作,但几行IDL和几行Python代码允许我们使用一个工作的客户端和服务器创建一个语言无关、操作系统无关和平台无关的服务API。还不错。
1.3.4 C++客户端
为了拓宽您的视角并演示Apache Thrift的跨语言方面,让我们再为hello服务器构建两个客户端,一个是C++的,另一个是Java的。我们将从C++客户端开始。
thrift --gen cpp hello.thrift
#include "gen-cpp/HelloSvc.h"
#include <thrift/transport/TSocket.h>
#include <thrift/transport/TBufferTransports.h>
#include <thrift/protocol/TBinaryProtocol.h>
#include <boost/make_shared.hpp>
#include <iostream>
#include <string>
using namespace apache::thrift::transport;
using namespace apache::thrift::protocol;
using boost::make_shared;
int main() {
// 创建TScoket
auto trans_ep = make_shared<TSocket>("localhost", 9090);
// 创建传输对象
auto trans_buf = make_shared<TBufferedTransport>(trans_ep);
// 创建协议层对象,用于序列化
auto proto = make_shared<TBinaryProtocol>(trans_buf);
// 创建客户端对象
HelloSvcClient client(proto);
// 连接到服务端
trans_ep->open();
// RPC调用
std::string msg;
client.hello_func(msg);
// 打印结果
std::cout << "[Client] received: " << msg << std::endl;
// 关闭服务器连接
trans_ep->close();
}
我们的C++客户端代码在结构上与Python客户端代码相同。除了少数例外,Apache Thrift元模型在不同语言之间是一致的,这使得开发人员易于跨语言工作。
C++ main()函数与Python代码一行对应,只有一个例外;hello_func()通常不返回字符串,而是通过输出参数引用返回字符串。
Apache Thrift语言库通常包装在名称空间中,以避免全局名称空间中的冲突。在C++中,所有的Apache Thrift库代码都位于“apache::thrift”名称空间中。这里的使用语句提供了对必要的Apache Thrift库代码的隐式访问。
Apache Thrift努力维护尽可能少的依赖关系,以保持开发环境的简单性和可移植性;然而,确实存在例外。例如,Apache Thrift C++库依赖于开源的Boost库。在这个示例中,几个对象被包装在boost::shared_ptr 中。Apache Thrift shared_ptr使用shared_ptr来管理C++服务操作中涉及的几乎所有关键对象的生命周期。
熟悉C++的人会知道,自C++11以来,shared_ptr一直是标准库的一部分。虽然示例代码是用C++11编写的,但Apache Thrift也支持C++98,需要使用shared_ptr的Boost版本(C++98支持将来可能会放弃,将所有Boost名称空间元素移动到std名称空间)。
> g++ --std=c++11 hello_client.cpp gen-cpp/HelloSvc.cpp -lthrift
对于C++构建,我们必须编译在HelloSvc.cpp源文件中找到的生成的客户端存根。在链接阶段,“-thrift”开关告诉链接器扫描标准的Apache Thrift C++库,以解决TSocket和TBinaryProtocol库的依赖关系(这个开关在使用g++时必须遵循.cpp文件的列表,否则它将被忽略,导致链接错误)。
假设Python Hello服务器仍然在运行,我们可以运行我们的可执行C++客户端并进行跨语言RPC调用。C++编译器将我们的源文件构建到一个a.out文件中,该文件在执行时产生与Python客户机相同的结果。
1.3.5 Java客户端
import org.apache.thrift.protocol.TBinaryProtocol;
import org.apache.thrift.transport.TSocket;
import org.apache.thrift.TException;
public class HelloClient {
public static void main(String[] args) throws TException {
TSocket trans = new TSocket("localhost", 9090);
TBinaryProtocol protocol = new TBinaryProtocol(trans);
HelloSvc.Client client = new HelloSvc.Client(protocol);
trans.open();
String str = client.hello_func();
System.out.println("[Client] received: " + str);
trans.close();
}
}
Java代码和之前的Python和C++代码的一个明显的区别是,Java客户端在端点传输上方没有缓冲层,因为Java中的套接字实现是基于一个在内部缓冲的流类,因此不需要额外的缓冲。
总结
- Apache Thrift是一个跨语言的序列化和服务实现框架。
- Apache Thrift支持广泛语言和平台。
- Apache Thrift易于构建高性能服务。
- Apache Thrift非常适合面向服务和微服务架构。
- Apache Thrift是一种基于接口定义语言(IDL)的框架。
- IDL允许您描述接口并自动生成支持接口的代码。
- IDL允许您描述在消息传递、长期存储和服务调用中使用的类型。
- Apache Thrift包括一个模块化的序列化系统,提供了几个内置的序列化协议和支持自定义序列化解决方案。
- Apache Thrift包括一个模块化的传输系统,提供内置的内存磁盘和网络传输,同时使添加额外的传输变得容易。
- Apache Thrift支持接口演化,适用于CI/CD环境和敏捷团队。