用C/C++实现游戏服务器,由于没有反射,服务器收到消息后该怎么快速地将消息转交给对应的处理函数呢?

发布时间:
2024-09-13 12:08
阅读量:
11

感觉这好像是一道标准的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...> &&params, 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了

END