基于Protobuf和Libuv实现RPC

公司要把产品转架构,虽然和我们驱动开发没什么关系,但还是抱着看热闹的心态研究了下和架构相关的一些问题。这阶段主要研究了下RPC这个东西。

RPC(Remote Procedure Call Protocol)——远程过程调用协议,它是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络技术的协议。RPC协议假定某些传输协议的存在,如TCP或UDP,为通信程序之间携带信息数据。在OSI网络通信模型中,RPC跨越了传输层和应用层。RPC使得开发包括网络分布式多程序在内的应用程序更加容易。
RPC采用客户机/服务器模式。请求程序就是一个客户机,而服务提供程序就是一个服务器。首先,客户机调用进程发送一个有进程参数的调用信息到服务进程,然后等待应答信息。在服务器端,进程保持睡眠状态直到调用信息到达为止。当一个调用信息到达,服务器获得进程参数,计算结果,发送答复信息,然后等待下一个调用信息,最后,客户端调用进程接收答复信息,获得进程结果,然后调用执行继续进行。

说到底就是在调用函数的时候从
call far ptr 目标地址
变为
call ip:port:service:procedure

表现上看和正常函数一样,当已经不是用本地资源了,而是用来远程的资源,这种机制正合分布式的思想。

一般RPC分为两个步骤,一个是序列化和反序列化,另一个是网络通信,这如果想要高效的实现RPC,这两部分都要足够高效,而google正式这样的效率狂人。去年google发布了自己的RPC框架grpc,但一方面grpc环境在windows中极难搭建,另一方面grpc在网络通信这块是基于HTTP2的,效率总归不如私有协议来的快,于是就想着自己动手丰衣足食,自己动手做一个rpc。现在基于protobuf的rpc都是结合libevent实现的,所以想试试使用libuv。在序列化/反序列化和网络通信这块分别用了google的两个开源项目:protobuf和libuv,protobuf是单独的项目,是序列化和反序列化项目,libuv则是集成在nodejs中的,是用CompleteIO实现的高效IO库。代码均下载自github上。

由于工作原因平时习惯了用C语言,所以在protobuf上我们选择使用他的兄弟工程protobuf-c,这个工程在idl的编译上使用了protobuf,而序列化和反序列化则使用了c代码来实现。libuv因为他的异步调用机制和单线程阻塞的工作方式,使得他作为服务器端是异常高效稳定的,但也这是这个特性使得它不太适合用于开发客户端。

先说下思路:客户端首先调用了一个函数,这个调用转换为一个网络请求并提供了一个回调函数,等请求返回时将调用这个回调函数,当这个请求发送到网络后,服务端解包,找到对应服务的例程并调用例程,得到返回值后,将返回值组包发送给客户端,客户端解包后调用刚刚的回调函数。
首先来设计私有协议,协议中需要确定网络包是请求包还是响应包,我们直接在数据包头两个字节设置特征码来区别类型:

#define     REQUEST_MAGIC       "\x90\x90"
#define     RESPONSE_MAGIC      "\x91\x91"
#define     MAGIC_SIZE          2

#define     TYPE_UNKNOWN        0
#define     TYPE_REQUEST        1
#define     TYPE_RESPONSE       2

接下来是头的设计,protobuf中一个服务是用一个service来修饰,而服务中的例程则是一系列类似函数的声明。类似于这样

service RpcTest{
    rpc GetTest(Test1) returns(Test2) {}
}

于是便可以以一个服务名和一个例程索引来定位一个具体的例程,同时还需要有一个标识来确定一个响应对应的是哪个请求,所以请求头可以这样设计:

message RpcRequest{
    required string service = 2;
    required int32 request = 3;
    required int32 id = 4;
}

而响应头只需要告诉客户端这个响应的是哪个请求:

message RpcResponse{
    required int32 id = 2;
}

在头的后面则跟着具体的数据,由于protobuf序列化的二进制数据中不包含有长度信息,所以需要我们自己给出长度信息。这样私有协议就是这样的结构:

MAGIC|req_size|packed request|msg_size|packed message

接下来要设计具体的客户端和服务器数据结构,服务器端需要维护一系列的客户端信息,而客户端则需要维护一系列自己发送的请求:

//一个请求的存根
struct rpcpu_call_stub
{
    uint32_t id;
    request_return cb;
    ProtobufCMessageDescriptor* output;
    void* context;
};

//代表一个与客户端的链接
struct rpcpu_server
{
    uv_tcp_t srv_client;
    uv_tcp_s* srv_server;
    uint8_t status;
};

//代表一个与服务器端的链接
struct rpcpu_client
{
    uv_tcp_t cli_client;
    //请求的存根
    rpcpu_call_stub stub[100];
    uint8_t status;
};

为了寻找到对应的例程,需要用户将服务登记,这里我们仅仅是放到一个数组中:

void rpcpu_reg_service(ProtobufCService* service)
{
    services[srv_iter++] = service;
}

接下来开始考虑客户端的发送请求,发送请求需要知道哪些参数呢,包括了服务对象,例程名称,输入参数和链接信息以及用于回调的回调函数和一个用于保存状态的参数。

void rpcpu_send_request(rpcpu_client* connection,
    ProtobufCService* rpc,
    char* name,
    ProtobufCMessage* message,
    request_return req_cb,
    void* context)

这里遇到一个问题,就像刚刚说到的那样,libuv是不适合做客户端的,因为在其他线程上的请求是不能保证请求的回调函数落在哪个线程上,为了确保在loop线程上调用回调函数,需要用变通的办法来将请求发送到loop线程中,很明显libuv当初考虑到这个问题而设计了uv_async_t来传递参数。
Inter-thread communication
这样我们就要实现一个传递机制来把参数传到loop线程,由loop线程发送请求:

//这个回调发生在loop线程中
void rpcpu_send_request_cb(uv_async_t* handle)
{

    rpcpu_send_request_para* para = (rpcpu_send_request_para*)handle->data;
    if (para == 0)
        return;

    rpcpu_send_request_internal(para->connection, para->rpc, para->name, para->message, para->req_cb, para->context);
    free(para);

}

//这个函数可以在任意线程调用
void rpcpu_send_request(rpcpu_client* connection,
    ProtobufCService* rpc,
    char* name,
    ProtobufCMessage* message,
    request_return req_cb,
    void* context)
{

    rpcpu_send_request_para* para = (rpcpu_send_request_para*)malloc(sizeof(rpcpu_send_request_para));
    para->connection = connection;
    para->rpc = rpc;
    para->name = name;
    para->message = message;
    para->req_cb = req_cb;
    para->context = context;

    send_async.data = para;
    uv_async_send(&send_async);

}

//具体的请求发送函数
void rpcpu_send_request_internal(rpcpu_client* connection,
    ProtobufCService* rpc,
    char* name,
    ProtobufCMessage* message,
    request_return req_cb,
    void* context)
{...}

请求的组包分为这几个过程,创建请求头,创建请求存根,序列化,发送数据:

void rpcpu_send_request_internal(rpcpu_client* connection,
    ProtobufCService* rpc,
    char* name,
    ProtobufCMessage* message,
    request_return req_cb,
    void* context)
{

    //create req_request object
    RpcRequest req = RPC_REQUEST__INIT;
    for (int i = 0;i < rpc->descriptor->n_methods;i++)
    {
        if (!strcmp(rpc->descriptor->methods[i].name, name))
        {
            req.request = i;
            break;
        }
    }
    req.service = (char*)malloc(strlen(rpc->descriptor->name) + 1);
    strcpy(req.service, rpc->descriptor->name);

    //save the request stub
    for (int i = 0;i < 100;i++)
    {
        if (connection->stub[i].id == 0xFFFFFFFF)
        {
            InterlockedExchange(&connection->stub[i].id, i);
            req.id = i;
            connection->stub[i].output = (ProtobufCMessageDescriptor*)rpc->descriptor->methods[req.request].output;
            connection->stub[i].context = context;
            connection->stub[i].cb = req_cb;
            break;
        }
    }

    //get the total send buffer size
    size_t req_size = rpc_request__get_packed_size(&req);
    size_t msg_size = protobuf_c_message_get_packed_size((const ProtobufCMessage*)(message));

    //group the packet
    char* send_buf = (char*)malloc(req_size + msg_size + MAGIC_SIZE + 4);
    char* iter = send_buf;

    //packet format:
    //REQUEST_MAGIC|req_size|packed request|msg_size|packed message

    memcpy(iter, REQUEST_MAGIC, MAGIC_SIZE);
    iter += MAGIC_SIZE;
    *(uint16_t*)iter = req_size;
    iter += 2;
    rpc_request__pack(&req, (uint8_t*)iter);
    iter += req_size;
    *(uint16_t*)iter = msg_size;
    iter += 2;
    protobuf_c_message_pack((const ProtobufCMessage*)message, (uint8_t*)iter);

    ProtobufCMessage* ss = protobuf_c_message_unpack((const ProtobufCMessageDescriptor*)message, NULL, msg_size, (uint8_t*)iter);

    //send it
    uv_buf_t uv_buf = uv_buf_init(send_buf, req_size + msg_size + MAGIC_SIZE);
    uv_write_t* writer = (uv_write_t*)malloc(sizeof(uv_write_t));
    writer->data = send_buf;

    uv_write(writer, (uv_stream_t*)&connection->cli_client, &uv_buf, 1, write_cb);

    //free the resource
    free(req.service);

}

客户端发送请求后,服务器就需要收包并解析了,解析函数是服务器端与客户端通用的,解包后调用对应的服务例程:

    rpcpu_closure_data closure_data;
    closure_data.req = request;
    closure_data.srv = srv;
    service->invoke(service, request->request, rpc_msg, rpcpu_common_closure, &closure_data);

这儿用到了一个protobuf现成的机制,之前想了半天怎么把用户服务的返回值传出来,后来发现protobuf的回调ProtobufCClosure应该就是用于这个目的吧。也就是说用户的回调函数必须调用我们的rpcpu_common_closure才能取到返回值。

void rpcpu_common_closure(const ProtobufCMessage *msg, void *closure_data)
{

    rpcpu_closure_data* closure = (rpcpu_closure_data*)closure_data;
    rpcpu_send_response(closure->srv, closure->req, (ProtobufCMessage*)msg);

}

响应包的组包和请求包基本相同,不再多述。只是用户的响应回调需要放到其他线程中以免阻塞loop线程:

    uv_work_t* work = (uv_work_t*)malloc(sizeof(uv_work_t));
    work->data = stub;
    uv_queue_work(uv_default_loop(), work, rpcpu_worker_return_cb, 0);

为了更像真实的函数调用,可以将发送请求的函数作一个同步版本:

void rpcpu_common_request_return(ProtobufCMessageDescriptor* message, void* context)
{
    rpcpu_sync_context* sync_ctx = (rpcpu_sync_context*)context;
    sync_ctx->msg = (ProtobufCMessage*)message;
    SetEvent((HANDLE)sync_ctx->event);
}

ProtobufCMessage* rpcpu_send_request_sync(rpcpu_client* connection,
    ProtobufCService* rpc,
    char* name,
    ProtobufCMessage* message)
{

    rpcpu_send_request_para* para = (rpcpu_send_request_para*)malloc(sizeof(rpcpu_send_request_para));
    para->connection = connection;
    para->rpc = rpc;
    para->name = name;
    para->message = message;
    para->req_cb = rpcpu_common_request_return;

    rpcpu_sync_context sync_ctx;
    sync_ctx.event = CreateEvent(NULL, FALSE, FALSE, NULL);
    sync_ctx.msg = 0;
    para->context = &sync_ctx;

    send_async.data = para;
    uv_async_send(&send_async);

    WaitForSingleObject(para->context, INFINITE);
    CloseHandle((HANDLE)sync_ctx.event);

    return sync_ctx.msg;

}

代码放到了git上:https://github.com/unsccaptain/rpcpu,里面是一个服务器端的dome,以后会做一个js版本用在nodejs上。

你可能感兴趣的:(分布式)