引言:

    2011年12月,基础架构部总经理暨搜索业务线首席架构师朱会灿以《云计算平台的构架,设计和实现》为主题为大家做了一次技术讲座,对我们的“台风”云计算平台做了介绍。其中概要地介绍了云计算平台的底层通讯机制——Poppy。现在我们在这里向大家对Poppy做一个更详细的介绍。


背景


    Poppy是基于Protocol Buffer的网络通讯解决方案。


    众所周知,分布式网络程序对通讯协议的灵活性、容错性、可扩展性、安全性、性能等都有较高的要求,使得其复杂性比单机程序高很多。


    最原始的网络程序往往采用自己定义协议,自己编写打包和解包代码的方式进行通讯,繁琐而又容易出错,灵活性和可扩充性也不好。


    Protocol Buffer是Google实现的数据存储和传输格式,具有效率高,编码紧凑,使用方便,格式灵活,支持二进制数据兼容,功能强大等诸多优点。Protocol Buffer在google内得到了大量的应用,配套的工具以及跨语言的支持也都很成熟。因此,搜索业务线的数据存储格式也采用Protocol Buffer。


    其次,传统网络程序往往采用基于消息包等通讯模型,而现代网络程序广泛使用RPC的方式来降低开发的难度,比如CORBA、RMI、WCF等。


    RPC是“远程过程调用”的缩写,通过把网络通讯抽象为远程的过程调用,调用远程的过程就像调用本地的子程序一样方便,从而屏蔽了通讯复杂性,使开发人员可以无需关注网络编程的细节,将更多的时间和精力放在业务逻辑本身的实现上,提高工作效率。但是开源的Protocol Buffer只提供了数据格式的处理功能,并未提供RPC实现,因此我们以Protocol Buffer为基础实现了RPC,就有了Poppy。Poppy的出现,进一步整合了Protocol Buffer。


    另外,除了RPC方式的编程接口之外,对于服务器程序来说,监控、调试、性能分析等功能也很重要。因此Poppy还提供了 Web监控、form提交、在线profiling等附加功能,为开发和测试提供了更大的便利性。


    在开发Poppy之前,我们调查过Thrift等开源的RPC库,Thrift用的数据格式与Protocol Buffer不一样,无法满足我们的要求。我们还调查了一些开源的基于Protocol Buffer的RPC库,功能上也都不能满足我们的要求。因此我们最终选择了自主开发。


Poppy的特性

    因为使用protobuf,客户端和服务器端可以单独升级,只要协议兼容即可。这为软件的发布和部署带来了很大的灵活性。


    同时支持同步和异步的RPC调用和处理方式,同步方式简单,异步方式略复杂但是更高效。


    内嵌http server,http server的服务端口就是poppy的rpc服务端口,用户可以自由扩充自己的页面。


    web方式展现统计和状态等监控信息,方便监控服务和诊断错误。


    集成了perf-tools,可以远程动态profiling正在运行的server。


    自动连接管理,无需用户显式处理。


    支持连接多个对等的无状态同构服务器,并自动进行负载均衡。


    支持zookeeper方式的服务地址解析,并能动态响应其变化,方便动态增减服务器。


    支持可选的压缩,不需额外写任何代码。


    支持protobuf的textformat以及Json两种文本格式的访问接口,在脚本语言甚至命令行界面都能发起调用。


    Form提交: 不需要写程序,在浏览器里填个表单就能发起调用,表单是根据proto文件的描述自动生成的。


    多语言:除了C++外,还支持Java, Python, PHP三种语言的客户端。


    集成了可选的对统一认证/授权系统的支持,可以识别和控制客户端的身份,提供更高的安全性。


使用示例
    千言万语不如例子,下面我们以几个简单的例子来展示Poppy如何使用,让大家先对Poppy有个总体的印象。
获取和构建Poppy

    Poppy是源代码发布的,需要使用搜索业务线统一构建系统Blade进行构建。


第一步:定义协议

    定义协议只需要编写一个proto文件即可。


    范例:echo_service.proto


// 定义你自己的 package,package会被映射到C++中的namespace,为了避免可能的冲突,强烈建议总是使用package。
package rpc_examples;

// 这是请求消息
message EchoRequest {
    required string user = 1;
    required string message = 2;
}
// 这是回应消息
message EchoResponse {
    required string user = 1;
    required string message = 2;
}
// 这是服务,只包含一个方法,Service 的命名建议以 Service 为后缀。
service EchoService {
    rpc SimpleEcho(EchoRequest) returns(EchoResponse);
}


    编译proto文件的功能已经集成到了Blade里,自动生成接口定义文件echo_service.pb.h和echo_service.pb.cc,不需要自己动手。注意这里表面上EchoRequest和EchoResponse的成员完全相同。是因为这只是例子而已,实际中请求与回应往往有很大差异。


第二步:实现服务器

    1、必须要包含的头文件


#include "poppy/rpc_server.h"               // 这是poppy的
#include "poppy/examples/echo_service.pb.h"   // 这是自己定义service


    其中 echo_service.pb.h是 protoc 编译器生成的。


    2、继承编译生成的服务接口类,实现其各个方法:


class EchoServiceImpl : public EchoService {
private:
    // 实现服务器端的 Handler 方法
    // 方法名就是 proto 中的方法名
    // 第一个参数固定为 controller,后面两个参数分别是请求和回应的消息
    // 你可以读取请求消息,填充回应消息,一切都就绪后,调done->Run()就完成了对请求的处理。
    virtual void SimpleEcho(
        google::protobuf::RpcController* controller,
        const EchoRequest* request,
        EchoResponse* response,
        google::protobuf::Closure* done)
    {
        // 填充回应消息,实际代码往往还需要读取请求消息。
        response->set_user(request->user());
        response->set_message(
             "simple echo from server: " + FLAGS_server_address +
             ", message: " + request->message());
        LOG(INFO) << "request: " << request->message();
        LOG(INFO) << "response: " << response->message();
        done->Run(); // 处理完成后调用 done->Run() 结束
    }
};


      注意调了done->Run()之后,所有的四个参数都不再能访问。只要 done 还没 Run,就还有效。 这里演示的是简单的同步处理,因此如果把done转到别的线程里运行,就实现了异步处理。


    3、把服务对象注册给RPC Server,并启动服务


int main()
{
    // 定义 rpc_server 对象。
    poppy::RpcServer rpc_server;
    // 创建 service
    rpc_examples::EchoServerImpl* echo_service = new rpc_examples::EchoServerImpl();

    // 注册给 rpc_server,注册后,echo_service 就由 rpc_server 来负责释放了。
    rpc_server.RegisterService(echo_service);
    // 启动服务器
    if (!rpc_server.Start("10.6.222.21:10000")) {
        return EXIT_FAILURE;
    }

    // 运行 rpc_server,可以被信号退出。
    return rpc_server.Run();
}


第三步:实现客户端

    1、包含头文件

#include "poppy/examples/echo_service.pb.h"
#include "poppy/rpc_client.h"


    其中 echo_service.pb.h是 protoc 编译器生成的。


    2、同步调用


    同步调用就是像大多数本地函数一样,调用者发起后,等待被调过程返回,然后继续。


// 定义 client 对象,一个 client 程序只需要一个 client 对象。
poppy::RpcClient rpc_client;
// 定义 channel,代表通讯通道,每个服务器地址对应一个 channel。
poppy::RpcChannel rpc_channel(&rpc_client, "10.6.222.21:10000");

// 定义代表 Service 在 Client 端的表示:Stub 对象。
rpc_examples::EchoServer::Stub echo_client(&rpc_channel);

// 定义和填充调用的请求消息。
rpc_examples::EchoRequest request;
request.set_user("echo_test_user");
request.set_message("hello, poppy server!");
// 定义方法的回应消息,会在调用返回后被填充。
rpc_examples::EchoResponse response;

// 定义  controller,代表本次调用。
poppy::RpcController rpc_controller;
// 发起调用,最后一个参数为 NULL 即为同步调用。
echo_client.SimpleEcho(&rpc_controller, &request, &response, NULL);


3、异步调用


    异步调用则是,调用者发起后,不等待被调过程返回,就继续。因为可以同时发起多个请求,因此异步模式性能高一些。不过用起来也略微麻烦一些。异步调用完成后,通过回调函数来通知调用者:


// 定义异步调用完成时的回调函数
void EchoCallback(poppy::RpcController* controller,
    rpc_examples::EchoRequest* request,
    rpc_examples::EchoResponse* response)
{
    LOG(INFO)
        << "request: " << controller->sequence_id()
        << ", message: " << request->message();

if (controller->Failed()) {
        LOG(INFO) << "failed: " << controller->ErrorText();
    } else {
        LOG(INFO) << "response: " << response->message();
    }

    // 清理异步调用分配的资源
    delete controller;
    delete request;
    delete response;
}

poppy::RpcClient rpc_client;
poppy::RpcChannel rpc_channel(&rpc_client, "10.6.222.21:10000");
rpc_examples::EchoServer::Stub echo_client(&rpc_channel);
// 异步调用时,回调运行前,request,reponse,controller 都必须一直有效。
// 因此这里用 new 创建。调用完成后,用户可以回收或者释放,done则由Poppy来释放。rpc_examples::EchoRequest* request = new rpc_examples::EchoRequest();
request->set_user("echo_test_user");
request->set_message("hello, poppy server!");

rpc_examples::EchoResponse* response = new rpc_examples::EchoResponse();
poppy::RpcController* rpc_controller = new poppy::RpcController();
google::protobuf::Closure* done = NewClosure(&EchoCallback,
        rpc_controller, request, response);

// 无需等待,EchoCallback 就会在将来完成或者失败时被调用。
echo_client.SimpleEcho(rpc_controller, request, response, done);


    回调函数也可以是成员函数,具体参考 Closure test 里的用法。


更多示例

    更多示例可以在 poppy 下的 examples 子目录里找到。


编程接口

    Poppy的API都在poppy命名空间下,下面描述均省略命名空间。


RpcServer

    该类仅用于服务器端。它是服务器端的具体业务服务对象的容器,负责监听和接收客户端的请求,分发并调用实际的服务对象方法并将结果回送给客户端。 server程序的实现者需要把具体Service注册给RpcServer。


    RpcServer 从 HttpServer 派生,因此也可以注册 Http Handler 给它,以响应 Http 请求。


    需要注意的是,无论是RpcService还是HttpHandler,注册给RpcServer后,ownership就属于RpcServer了,其生存期由RpcServer负责,你不能再去delete了。


RpcClient

    该类仅用于客户端。一个客户端程序只需要一个RpcClient对象,其负责所有RpcChannel对象的管理和对服务器端应答的处理。


RpcChannel

    该类仅用于客户端。它代表通讯通道,每组服务器地址对应一个RpcChannel对象,客户端通过它向服务器端发送方法调用请求并接收结果。Poppy内部以keepalive的方式来管理活动的连接,支持无状态服务器的自动负载均衡。


    客户端要发起调用,需要先以RpcClient*为参数构造RpcChannel。


RpcController

    该类既用于客户端,也用于服务器端。它存储一次RPC方法调用的上下文,包括对应的连接标识、该次调用的序列号以及方法执行结果。由于Poppy是全异步的,调用的序列号是为了便于客户端识别服务器的某个应答包对应具体哪次方法调用。每个活动的controller代表一次已经发出还未完成的调用。 在完成前,controller不能被用作其他用途;调用完成后,则可以用来发起下一次调用。


    RpcController的方法:


    Rpc调用发起之前可以调用的方法:


    void SetTimeout(int64_t timeout); // 设置期望超时,如果不是0,覆盖proto里的超时设置。


    Rpc调用发起之后可以调用的方法:


    bool Failed() const; // 返回上次调用是否失败


    int ErrorCode() const; // 返回上次调用的错误码,实际类型为 RpcErrorCode


    std::string ErrorText() const; // 返回错误信息的文字描述


    服务器方可以调用的方法:


    int64_t Time() const; // 请求接收的时间


    int64_t Timeout() const; // 客户端期望的超时


    void SetFailed(const std::string& reason); // 主动设置为失败


    更详细的介绍可以阅读Poppy文档和范例。


文本协议

    除了二进制协议外,Poppy支持还以普通的HTTP协议,传输以JSON/protobuf文本格式定义的消息。很方便用各种脚本语言调用,甚至用 bash,调 wget/curl 都能发起 RPC 调用。

多语言

    根据需要,我们还开发了Java,Python和PHP版的客户端。服务器端目前还只有C++。如果有其他需求,欢迎给我们提。


Web界面

    Poppy不止是RPC,还提供了服务器开发的有用特性,比如Web监控。 通过同一个端口,同时提供RPC服务和Web监控服务,通过浏览器就能监控和调试服务。


    Poppy在Rpc的同一个端口上,提供了一个简单的监控界面,只需要在浏览器输入地址,就能进入相应页面。


使用Web界面

    前面说到,使用Poppy的服务器只需要使用一个端口,就能同时提供了RPC和HTTP服务,默认的Http服务包含了一个简单的监控界面。 Poppy的web监控界面如下:


<IGNORE_JS_OP>


    假设服务器ip端口为:http://10.6.222.127:8080 ,内置可访问列表包括:


http://10.6.222.127:8080/ 主页,提供了所有内置可访问页的入口


http://10.6.222.127:8080/flags Dump 出程序所有的 Flags。


http://10.6.222.127:8080/rpc/ JSON 格式 RPC 的入口 URL 前缀,后面需要跟方法全名(包名.服务名.方法名)。


http://10.6.222.127:8080/rpc/form 通过浏览器以交互的方式发送 RPC 请求。


http://10.6.222.127:8080/health 状态监控页,server进程是否正常。若正常则返回OK。


http://10.6.222.127:8080/status 统计监控页,提供所有统计变量的值展示,还包括当前server所使用的CPU、内存值。


http://10.6.222.127:8080/vars 导出变量页,展示所有用户注册的导出变量的名字及值。


    如果程序运行在我们的开发网上,可以在办公网用浏览器直接连接。但是如果是IDC上的程序,8080端口可能是开放的,或者可以通过代理访问。


状态监控与统计

    Poppy提供状态的监控和统计页面,分别对应于health page和status page。


    其中,health page 返回server端的运行是否健康。


    status page 显示了 每一个service的每个方法的统计信息。


<IGNORE_JS_OP>


    其中, global是全局的统计。


    统计的有三项, 包括请求的数量, 请求成功的数量, 失败的请求数量和请求的平均时延。


Form提交

    Poppy支持通过浏览器,以填表格的形式直接向服务器提交RPC请求,省去写测试客户端的麻烦,是调试利器。 使用方法如下:


    打开form列表的页面,点击相应的RPC方法的链接,进入form提交的页面。


<IGNORE_JS_OP>


<IGNORE_JS_OP>


    输入完值后,点Send Request,得到就能得到回应。为了方便复制结果,以带缩进的文本格式显示。这个 form 是根据 proto 中定义的 Message 自动生成的,使用者无需写任何代码。


<IGNORE_JS_OP>


查看Flags

    Poppy支持与Google gflags整合,在web界面上查看显示进程的 Flags。


<IGNORE_JS_OP>


    分别以flags所在的源文件为单位,列出其中定义的每个flag,包括名字,类型,当前值,默认值,描述。方便在运行期间了解程序的配置情况。


    红色表示已经值被修改过,不再是默认值。


扩充Web界面

    除了以上的页面,Poppy还支持用户注册自己的页面,目前支持三种方式:

    注册简单路径处理:把特定的路径上的请求发送到用户注册的函数。
    注册前缀规则处理:把前缀的路径的请求都发送到用户注册的函数。
    注册静态资源:对特定的前缀,返回用户注册的数据。
在线Profiling

    Profiling是一种很常用的调试技术,profiling可以提供真实的运行状态,找出代码的热点,效率瓶颈。可能很多人都用过GCC自带的gprof,gprof需要在编译时加上特殊的选项,在每个函数的入口和出口插入代码,程序运行时进行统计,程序优雅退出时输出统计结果,再用gprof进行分析。这种方式有几点不方便:


    额外的函数调用对程序的效率有影响,尤其是短小的函数,可能造成结果和实际有较大差别。
    只能在程序退出后,统计程序运行的总体信息,不方便得到某个时间段的分析结果。

    Google perftools 是一款针对 C/C++ 程序的性能分析工具,使用该工具可以对 CPU 时间片、内存等系统资源的分配和使用进行分析。通常用以进行内存泄露检查,以及程序耗时分析,从而优化系统性能。


    因此,Poppy集成了perf tools。可以在不停止server运行的情况下,动态的profiling服务器的运行状况。


    这是分析某server 30秒得到的文本报告:


<IGNORE_JS_OP>


    Pprof也能生成图形化的报告,能更直观地进行分析:


<IGNORE_JS_OP>


基本结构

    本文的主要目的是向大家介绍Poppy,而非架构设计分享。因此这里只是粗略地介绍一下。


    我们知道,所有的RPC实现,都是把远程过程调用封装成本地调用的接口,Poppy也不例外:


<IGNORE_JS_OP>


RPC调用示意图


    但是其内部一样要向下走到底层的协议栈


<IGNORE_JS_OP>


RPC消息的传递


    这里可以注意到,RpcServer是建立在HttpServer的基础上的,Rpc消息也是一种特殊的HTTP数据流。其内部详细的消息流转和调度关系如图所示:


<IGNORE_JS_OP>


Poppy基本结构


    从图中可以看出,因为涉及到超时,连接管理,负载均衡等,Poppy客户端远比服务器端更复杂。因为Poppy同时支持HTTP协议的文本格式的请求和Protobuf格式的二进制请求和回应,所以Poppy内部有两种协议:


<IGNORE_JS_OP>


Poppy的通讯协议


    Poppy的二进制协议与一般的设计不一样的是,它是以HTTP协议头为基础建立起来的,只是建立连接后的最初的采用HTTP协议,后续的消息往来直接用二进制协议,所以效率还是比较高的。


Mock测试

    单元测试是软件质量的重要保障方式,搜索业务线在过去的一年多中,大力推广单元测试,使得代码质量上了一个台阶。对网络程序而言,因为涉及到通讯的双方,单元测试比较麻烦。因此Poppy也集成了对单元测试的支持,专门基于gmock开发了PoppyMock。通过PoppyMock,不需要起服务器,就能进行RPC测试,大大简化了单元测试。


性能

    我们进行了大量的测试,这里给出一些典型的性能测试数据:

    对于很短小的消息,服务器起一个处理线程,单个单线程客户端,同步方式调用,可以达到9000次/s的处理速度。多客户端,QPS最大为9万次。
    同样的消息,如果是异步方式,则QPS最多可以达到16万次。
    同样的消息,单个服务器,4个工作线程,多个同步调用的客户端,QPS最大24万次,8时则可以达到40万次。
    对于较大的消息(单条10k以上),单客户端同步调用可以达到85M/s的吞吐量。
    对于较大的消息(单条10k以上),单客户端异步调用,或者多客户端,最大可以达到125M/s的吞吐量。
    在80台服务器上,单个服务器端,4个工作线程,最多测试过32000个连接,QPS从峰值的24万下降到18万次。

    目前看来,Poppy的性能还是比较令人满意的,如果将来有需要,我们会进一步优化性能。


    Protobuf的C++实现使用了较多的动态内存分配,参照其文档推荐,我们测试时使用了tcmalloc,效率的确有较大的提升,因此我们也建议用户搭配tcmalloc使用。


使用情况

    Poppy诞生将近一年,已在如下项目中得到应用:


    基础架构部:的XFS,XCUBE,TBORG,MAPREDUCE项目。
    广告平台部:内容广告项目。
    搜索平台部:统一下载中心。
    搜索平台部:网页搜索WOB,GOB项目。
    社区搜索部:Discuz项目。

    MapReduce使用Poppy做开发,两个月的时间就推出了Demo版,开发效率得到的较大的提高。


    我们也欢迎其他项目使用Poppy。


未来
    进一步优化性能。
    基于用户身份的流量控制。
    优先级控制。
    跨IDC的代理支持。