用C/C++实现游戏服务器,由于没有反射,服务器收到消息后该怎么快速地将消息转交给对应的处理函数呢?
感觉这好像是一道标准的C++ RPC题,趁着新年将至没啥事写一写个人看法。
虽然C++并没有反射,不能在收到消息后现场查看处理函数的参数类型,但是我们可以把序列化、反序列化逻辑和处理函数一起,封装成一个闭包(lambda),这样一来消息收发接口就统一成一串字节了。就我所知,这一思想最著名的实现当属 rpclib。
rpclib这里有一些这个库的性能数据。如果你有兴趣,我们来草草分析它的一下原理
rpclib 提供了大致形如下面这个例子一样的功能:
// 服务端
rpc::server svr("0.0.0.0:3000");
svr.bind("add", [](int a, int b) {
return a + b;
});
svr.run();
// 客户端
rpc::client cli("localhost:3000");
auto result = cli.call("add", 2, 3).as<int>();
assert(result == 2 + 3);
也就是说
- 在服务端,你可以给某个请求类型绑定一个任意参数和返回类型的处理函数,限制是这些类型都得能序列化。处理函数本身可以是普通函数也可以是闭包。
- 在客户端,你需要提供正确的参数,并把返回值转换为正确的类型。
客户端的逻辑是比较明确,因为不管是call
的时候,还是将返回值转换为所需的类型的时候,类型信息都是编译期确定好的,所以只要正常做序列化/反序列化就行了。当然客户端也不一定要用 C++ 来写,这都无所谓。
核心问题是服务端如何 bind
中接受并存储任意处理函数,这块需要一些初等模板魔法知识
首先,利用模板,我们可以从函数指针类型中提取出返回值类型和参数类型列表:
template <typename R, typename... Args>
struct func_traits<R (*)(Args...)> {
using result_type = R;;
using args_type = std::tuple<typename std::decay<Args>::type...>;
};
如果 handler 不是函数指针也好说,我们再写几个模板重载,覆盖一下常见使用场景:
// 可调用对象
template <typename T>
struct func_traits : func_traits<decltype(&T::operator())> {};
// 成员函数
template <typename C, typename R, typename... Args>
struct func_traits<R (C::*)(Args...)> : func_traits<R (*)(Args...)> {};
// 常成员函数
template <typename C, typename R, typename... Args>
struct func_traits<R (C::*)(Args...) const> : func_traits<R (*)(Args...)> {};
这个玩意使我们拥有了 args_type
类型,它能够把所有参数塞进一个 tuple 里面,这让我们在序列化/反序列化的时候只需处理一个值,而不是不知道多少个。不过为了能用这个 tuple 去真正调用 handler,我们还需要写个函数来帮我们展开参数。
template <typename Functor, typename... Args, std::size_t... I>
decltype(auto) call_helper(Functor f, std::tuple<Args...> &¶ms,
std::index_sequence<I...>) {
return f(std::get<I>(params)...);
}
template <typename Functor, typename... Args>
decltype(auto) call(Functor f, std::tuple<Args...> &args) {
return call_helper(f, std::forward<std::tuple<Args...>>(args),
std::index_sequence_for<Args...>{});
}
好了,现在可以写 bind 了,原理无非就是用模板接受任意类型的 handler,然后把一切和 handler 类型相关的东西都封进 std::function 里面:
class server {
using adaptor_t = std::function<void(uint8_t *, uint8_t *)>;
std::unordered_map(std::string, adaptor_t> handlers;
template <typename F>
void bind(std::string id, F func) {
using result_type = typename func_traits<F>::result_type;
using args_type = typename func_traits<F>::args_type;
handlers[id] = [func](uint8_t *req_buf, uint8_t *resp_buf) {
args_type req_args = deserialize<args_type>(req_buf); // 反序列化
result_type resp = call(func, req_args);
serialize(resp_buf, resp); // 序列化
};
}
};
这就齐活了,非常简单。注意这里面涉及了序列化/反序列化,具体逻辑我没有在这里写;反正世界上有无数个序列化/反序列化轮子供你调用,rpclib 用的是 msgpack,和其他方案相比无非是性能好一点或差一点罢了
现在比如说服务端收到了客户端的一个请求,要调用 add 这个处理函数。客户端会发来一段数据,也就是序列化的参数,服务端直接拿着这串数据和一个准备好的 response buffer 去调用 handlers["add"]
,然后把 response buffer 的内容原样发回客户端,完成。
除了以上的核心逻辑之外,工程中我们实际上还要检查序列化数据的有效性、做任务分发、实现异步等等,不过这和题目问的主要内容就关系不大了
上面这东西涉及到模板,模板完全是编译期的东西。所以一个显然的疑问就是,如果我想在运行时动态注册函数(比如说从动态库中加载一个),这套思路还能不能用?
对此我没有仔细想过,不过我的看法是应该可以。注意到 server 本身其实只需要存储和调用 std::function<void(uint8_t *, uint8_t *)>
就可以了,bind 可以直接接收这玩意而不是模板参数;具体把任意类型的 handler 封装成 function 的逻辑可以写在 server 外面。理论上,我们或许可以搞一个类似 SDK 的东西,在编译期将 handler 封装成上面这个 function,运行的时候把这个 function 传给 server。
这里面似乎需要做巨量的事情来保证程序正确链接,而且 std::function 似乎也不是个透明的东西?鬼知道正确稳定地实现上面这些功能要多少工作量,我就不多bb了