本文主要探讨了消息总线支持Thrift RPC的实现过程。鉴于RabbitMQ官方的Java Client提供了基于RabbitMQ的JSON-RPC,消息总线也顺道提供了JSON-RPC的API。然后也尝试了为消息总线增加对Thrift-RPC的扩展支持,希望此举能让消息总线同时为SOA提供基础设施。
Thrift简介
Thrift是一个跨语言的服务部署框架,最初由Facebook于2007年开发,2008年进入Apache开源项目。Thrift通过一个中间语言(IDL, 接口定义语言)来定义RPC的接口和数据类型,然后通过一个编译器生成不同语言的代码(目前支持C++,Java, Python,PHP, Ruby, Erlang, Perl, Haskell, C#, Cocoa, Smalltalk和OCaml),并由生成的代码负责RPC协议层和传输层的实现。
初衷
做这件事情的初衷是RabbitMQ可以用于模拟request/response这样的通信模型,而这个通信模型就是通常C/S以及B/S架构的通信模型。并且因为RPC的流行,其官方java client已经提供了对基于JSON(文本协议)的RPC的实现。而Thrift本身是个RPC框架,提供跨语言、多协议、多传输通信机制的实现。如果能将两者衔接起来,消息总线对RPC的支持无疑更加完善。
思路
Thrift的实现是基于类似TCP/IP的多层协议栈模型。它的特点是对等通信,逻辑分离,分层解耦。如下图:
在协议层,目前Thrift支持众多的协议,这些协议大致分为两类:
- 二进制协议
- 文本协议(以XML、JSON为代表)
Thrift同样支持众多的通信传输机制:Socket,IOStream,HttpClient,file,memory-input,Nonblocking等,由于实现类太多,此处就不上类图了。
由于其实现是分层解耦并面向接口的,因此任意的通信协议都可以与任意的通信传输机制结合起来形成一种特定的RPC实现!也正是得益于这种设计,使得我们可以实现一种新的通信传输机制而不用对整个库大动干戈。
因此,如果我们需要实现基于RabbitMQ的通信机制,是否意味着我们只需要在C/S两端简单得实现通信传输层就可以了呢。准确地说,RPC的客户端实现(服务消费者)是这样的,但RPC的服务端(服务提供者)的实现却不仅仅只是实现一个通信传输层就可以的。因为服务器作为一个应答者(Responser),其本身必须被构建为一个Process Loop(简单地理解就是个while(true))。因此在服务端的实现,还需要维护基于MQ Message的处理循环。大致示意图如下:
现状
思路有了,但现状是我们需要接入的不仅仅是RabbitMQ而是消息总线(消息总线是基于RabbitMQ封装而成)。所以我们在验证可行性的时候还是要分两步走:第一用RabbitMQ的消息机制,是否能够实现Thrift的C/S两端通信;第二是否能够很好得适配消息总线的Proxy的机制并提供合适的API。
实现
客户端实现
RPC的客户端相对来说比较简单,因此我们先从客户端下手。客户端这边主要任务就是将方法的调用信息根据在协议层转换后得到的二进制请求数据发送到目标队列,然后阻塞等待获取响应后,将二进制响应数据再根据客户端所用的协议进行还原,这是两个相反的过程,一个是自上向下,一个是自底向上,如果你理解TCP/IP协议栈,那么这里应该很容易理解。
得益于RabbitMQ官方提供的Java Client,它提供了一个RPClient类,可以直接以阻塞的方式发起一个request请求,并获取数据。这是我们在客户端通过RabbitMQ的java client收发数据的主要接口。现在我们需要适配Thrift,只要重新实现Thrift的通信层的客户端接口:TTransport即可。
Thrift默认提供了很多客户端通信技术的实现,而这些实现类都必须实现TTransport这一基接口,该接口的类图如下:
该接口定义了输入、输出、打开、关闭等接口方法。而衔接输入输出的关键方法是flush,其大致的实现如下:
public void flush() throws TTransportException { byte[] data = this.reqMsgStream.toByteArray(); this.reqMsgStream.reset(); byte[] responseData = new byte[0]; try { responseData = this.client.primitiveRequest(secret, target, data, token, timeout); } catch (Exception e) { ExceptionHelper.logException(logger, e, "[flush]"); } this.respMsgStream = new ByteArrayInputStream(responseData); }
可以看到它先从输出流中获取请求数据,然后调用总线的消息发送请求,并阻塞等待响应数据,最后基于响应数据构建输入流。
就RPC的客户端而言,我们只需要实现基于消息总线的通信传输机制即可。下面我们来看一下客户端以消息总线作为通信机制后的使用代码:
public void testThriftRpc() throws Exception { TTransport transport = new TAMQPClientTransport(this.client, "kliwhiduhaiucvarkjajksdbfkjabw", "emapDemoRpcResponse", "klasehnfkljashdnflhkjahwlekdjf", 10000); transport.open(); TProtocol protocol = new TJSONProtocol(transport); CalcService.Client client = new CalcService.Client(protocol); int result = client.calcSum(); logger.info(result); transport.close(); }
大致来看层次还是比较清晰的,构建的流程是从低层向上层的构建方式:
首先构建一个客户端的传输对象,这里我们注入消息总线的客户端,用于在内部处理客户端跟服务器端的通信。
然后open客户端的传输对象,如果客户端确实需要在通信之前或之后做些事情,可以override open/close这一方法对,因为确实有些通信机制是需要打开或回收一些资源的,不过如果你不需要在open/close方法里做额外操作,也请在编码时写上这两句,就当作它是模式代码吧。
最后构建一个协议处理器,接着将协议处理器传递给IDL生成的客户端代码。在使用完之后可以关闭传输对象。在这里作为额外的一个步骤:如果你不再需要使用消息总线的客户端,请归还该对象。
服务器端实现
通过之前对实现机制探讨,我们已经知道对于服务端的实现,我们除了需要给出以消息总线作为通信传输机制的实现,还需要一个基于消息总线构建的处理循环。如客户端一样, RabbitMQ 官方java client已经提供了一个RpcServer的基础实现,我们干脆以此作为RPC服务端的实现原型(没必要重复造轮子),只沿用thrift的协议处理相关的组件。也就是说我们需要构建一个listen某个队列的consumer,并hlod住它,然后再嵌入Thrift处理器以及协议处理的代码。
这次我们先来看看服务端的使用代码,然后再去看它的实现方式:
MessagebusSinglePool singlePool = new MessagebusSinglePool(host, port);; Messagebus client = singlePool.getResource(); //server code WrappedRpcServer rpcServer = null; try { TProcessor processor = new CalcService.Processor(new CalcServiceImpl()); TProtocolFactory inProtocolFactory = new TJSONProtocol.Factory(); TProtocolFactory outProtocolFactory = new TJSONProtocol.Factory(); rpcServer = client.buildRpcServer("mshdfjbqwejhfgasdfbjqkygaksdfa", new ThriftMessageHandler(processor, inProtocolFactory, outProtocolFactory)); rpcServer.mainLoop(); } finally { rpcServer.close(); singlePool.returnResource(client); singlePool.destroy(); }
它直接替换了Thrift提供的Server实现(跟客户端通信一样thrift同样提供了多个Server的实现),而是采用了基于RabbitMQ构建的一个RPCServer,然后启动一个event loop将其block住,等待客户端的请求,并进行处理。
消息总线提供了一个API:buildRpcServer,它构建出一个封装后的WrappedRpcServer。该WrappedRpcServer封装了上文提到的RabbitMQ java client自带的RpcServer。为什么要封装?主要的原因还是信息隐藏。其实,如果只接入RabbitMQ,不封装是没有问题的。但现在的目的是接入消息总线,而消息总线在RabbitMQjava client之上又封装了一层,屏蔽了一些不必要的设置,而这些设置恰好又是构建这个RpcServer类的实例所必备的参数(比如 queue name,channel等)。
因此这里我们基于组合的方式将RpcServer从WrappedRpcServer类的构造器注入进来,使得WrappedRpcServer成为RpcServer的代理,WrappedRpcServer的构造器访问标识符被设置为private,这是因为我们在消息总线内部构建了RpcServer的实例,然后通过反射机制来构造WrappedRpcServer的实例。看代码:
public WrappedRpcServer buildRpcServer(String secret, final IRpcMessageProcessor rpcMsgProcessor) { Node source = this.getContext().getConfigManager().getNodeView(secret).getCurrentQueue(); try { RpcServer aServer = new RpcServer(this.getContext().getChannel(), source.getValue()) { @Override public byte[] handleCall(QueueingConsumer.Delivery request, AMQP.BasicProperties replyProperties) { return rpcMsgProcessor.onRpcMessage(request.getBody()); } }; Constructor<WrappedRpcServer> rpcServerConstructor = WrappedRpcServer.class.getDeclaredConstructor(RpcServer.class); rpcServerConstructor.setAccessible(true); WrappedRpcServer wrappedRpcServer = rpcServerConstructor.newInstance(aServer); rpcServerConstructor.setAccessible(false); return wrappedRpcServer;
buildRpcServerAPI需要的参数除了有一个用于自身标识的secret,还有一个rpc消息处理器接口:IRpcMessageProcessor。它的定义如下:
public interface IRpcMessageProcessor { public byte[] onRpcMessage(byte[] in); }
它的主要作用是给Thrift RPC(或后续可能接入的其他RPC框架)提供一个跟消息总线衔接的入口。它拥有一个输入作为参数,每个RPC Server在其内部处理请求并将结构作为输出参数返回。可以看到,这个接口没有对第三方产生任何依赖,而且输入参数跟输出参数都是byte[],因此它可以适配任何类似Thrift这样的RPC框架(当然前提是这些RPC框架都能像thrift具有这么好的扩展性)。
有了输入跟输出,就类似有了一个“管道”,中间的拦截处理过程就可以嵌入thrift RPC的第三方处理代码。这里针对thrift,我们实现了一个ThriftMessageHandler,它展示了thrift如何无缝衔接消息总线:
public byte[] onRpcMessage(byte[] inMsg) { InputStream in = new ByteArrayInputStream(inMsg); OutputStream out = new ByteArrayOutputStream(); TTransport transport = new TIOStreamTransport(in, out); TProtocol inProtocol = inProtocolFactory.getProtocol(transport); TProtocol outProtocol = outProtocolFactory.getProtocol(transport); try { processor.process(inProtocol, outProtocol); return ((ByteArrayOutputStream) out).toByteArray(); } catch (TException e) { ExceptionHelper.logException(logger, e, "onRpcMessage"); throw new RuntimeException(e); } finally { transport.close(); } }
首先它将方法参数作为输入,基于该输入构建一个输入流,然后构造一个空的输出流。通过输入、输出流可以构建出服务端的TTransport对象(它是thrift通信传输的基础也是,thrift协议栈的最底层)。接下来构造用于对输入解码以及对输出编码的协议处理对象。然后传入thrift的核心处理对象:processor进行处理,产生的原始输出作为方法返回。
上面讲解了thrift的服务端处理逻辑以及thrift通过IRpcMessageProcessor无缝接入了消息总线,那么我们还没有提到这段代码是在什么时候以及怎么被触发调用的(也就是IRpcMessageProcessor怎么衔接消息总线这端的消息事件的)?这是通过覆盖RpcServer的消息处理代码来实现的。见代码:
RpcServer aServer = new RpcServer(this.getContext().getChannel(), source.getValue()) { @Override public byte[] handleCall(QueueingConsumer.Delivery request, AMQP.BasicProperties replyProperties) { return rpcMsgProcessor.onRpcMessage(request.getBody()); } };
我们在构建RpcServer实例的时候,覆盖了消息的处理方式,使其触发对IRpcMessageProcessor的onRpcMessage方法的调用。该方法的调用就衔接起了thrift的代码逻辑,如果这里的RPC框架不是thrift,那么它也可以用于衔接其他的RPC框架。
写在最后
如同thrift自身提供的多种通信传输方式,将消息总线作为thrift的通信机制使得基于thrift的RPC的通信方式多了一种可能。
排队处理&负载均衡
基于MQ的RPC传输机制的好处是,由于队列本身具有的缓冲性质,可以在应对高并发的时候保证请求不丢失(事实上针对web请求,在面对高并发时常用的一种机制也是排队处理);另外MQ提供的分布式集群前面加个HAProxy就能提供很好的负载均衡。
吞吐量低
当然可以肯定是协议越轻(数据量越少),通信的处理方式越简单,RPCServer处理的请求越快,单位时间处理的请求数就越多,吞吐量也就越高。最终的测试结果显示也表明了这一点,在单线程的情况下,基于消息总线的Thrift-RPC比基于Socket的Thrift-RPC慢了一个数量级(一台非专用的普通PC机,QPS为300左右)。通过对基于消息总线的Thrift的JSON-RPC与基于消息总线的原生提供的JSON-RPC测试对比后,发现两者的测试结果类似,因此可以推测主要的瓶颈还是在基于RabbitMQ的传输上。因为首先AMQP协议面向的场景主要是高可用性的场景,所以其服务端的实现略复杂,并且协议数据比直接socket的裸数据大不少,另外无论是服务端还是客户端都存在着成对的拆包和封包的过程,这些都拉低了基于消息总线的RPC的性能表现。
平衡点
性能是否是唯一的考虑因素?这是一个好问题,或者说性能是否是最关键的因素。其实很多事情都是权衡的过程,你要做的是看看能否在你关注的所有面上找到一个平衡点。一切面向通用的解决方案都不是最精简以及性能最好的。这一点就像那些ESB一样,它们的性能表现也同样比一台提供restful API的web server差了很多。