数据序列化与RPC
业务数据包的逻辑处理
在前一章中我们介绍了游戏服务器中基础的网络通信是如何实现的,在完成了网络通信之后,业务逻辑层只需要考虑业务数据包的发送和接收,而不需要再去关心底层网络的封包、解包、加密、可靠性等各种问题。但是,此时业务层对接的数据包信息含量还是太低了,只有一个std::string,至于这个数据包内所包含的内容含义,还需要业务层自己做一层数据格式封装。最简单的封装莫过于在这个数据包的前四个字节填充这个数据包的类型,此时网络层对业务逻辑开放的接口则简化为了下面的代码:
struct packet
{
std::uint32_t cmd;
std::string detail;
};
void send_packet(const packet& out_packet);
void handle_packet(const packet& in_packet);
业务逻辑里的每一条指令负责自己将所要传递的数据封装为packet格式,同时接收端要能够对等的通过cmd将数据投递到相应的处理函数,处理函数要知道如何解析这个数据包。在逻辑层整个数据编码、指令路由、数据解码的全套过程等价于实现一个RPC(Remote Procedure Call)。下面我们来展示一个最简单的RPC的例子:
enum rpc_cmd
{
req_auth = 1,
res_auth = 2,
};
// 客户端调用try_auth接口 提供账户名和密码来进行鉴权
// 函数逻辑内部负责将 (rpc_cmd::send_auth_req, (name ,passwd))这两元组进行编码 并调用网络接口进行发送
void send_auth_req(const std::string& name, const std::string& passwd)
{
packet cur_packet;
cur_packet.cmd = uint32_t(rpc_cmd::req_auth);
cur_packet.detail = encode(name, passwd);
send_packet(cur_packet);
}
// 服务器处理业务指令包的函数,对业务包 进行分发
void server_handle_msg(const std::uint32& cmd, const std::string& detail)
{
if(cmd == int32_t(rpc_cmd::req_auth))
{
std::string name, passwd;
std::string auth_res;
bool decode_suc = decode(detail, name, passwd);
if(!decode_suc)
{
auth_res = "decode fail";
}
else
{
// 数据库检查账户密码是否匹配
auth_res = db_try_auth(name, passwd);
}
packet cur_packet;
cur_packet.cmd = uint32_t(rpc_cmd::res_auth);
cur_packet.detail = encode(auth_res);
send_packet(cur_packet);
}
}
// 客户端处理业务指令包的函数,对业务包 进行分发
void client_handle_msg(const std::uint32& cmd, const std::string& detail)
{
if(cmd == int32_t(rpc_cmd::res_auth))
{
std::string auth_res;
bool decode_suc = decode(detail, auth_res);
if(!decode_suc)
{
auth_res = "decode fail";
}
if(auth_res.empty())
{
// 走后续的登录流程
}
}
}
上面的代码中完全忽略掉了网络层的收发packet实现,客户端和服务端的包分发函数都通过cmd字段来一一分发具体的数据包逻辑。业务逻辑非常清晰,但美中不足的是我们这里没有提供一个具体的encode,decode数据包内容的实现,而这部分则是数据序列化的范畴。同时这种通过handle_msg函数来集中式处理所有数据包的形式在面对海量的数据包类型时凸显了其不可维护的缺陷,每次添加一个新的RPC指令都需要写一大段类似的decode后再继续处理的逻辑。为了减轻这种维护性负担我们需要采用某种RPC注册的框架来自动对RPC数据进行路由和解包。
基于Json的RPC
封装JSON序列化接口
目前网络上流行最广的数据容器就是Javascript语言里定义的JSON(JavaScript Object Notation),JSON内部可以包含如下五种数据:
null,代表无数据- 布尔类型,对应
bool - 整数类型,对应
std::int64_t - 浮点类型,对应
double - 字符串,对应
std::string - 数组类型,对应
std::vector<json> - 字典类型,对应
std::map<std::string, json>
有了上述类型,基本可以表示业务逻辑所需数据。这里需要注意的一点是Json标准是不支持int64这么大的数字的,它最大只支持到,如果超过最大值可能会导致Json的解析出错误。不过这种问题基本只会在javascript语言里出现,如果数据交换的两端都是以cpp实现的,现有的主流的json cpp库基本都可以支持到numeric_limits<std::int64_t>::max()和对应的min。这里我才用使用最广且易用性最强的nlohmann::json来作为mosaic_game的json序列化库。
指定了json序列化库之后,我们开始来实现之前引用到的encode,decode函数,由于这两个函数能够支持各种类型的参数,所以我们将这两个函数实现为变参模板函数。
template <typename T>
json encode_impl(const T& arg);
template <typename... Args>
json encode_multi(const Args&... args);
template <typename... Args>
std::string encode(const Args&... args)
{
if constexpr(sizeof...(Args) == 0)
{
return "null";
}
if constexpr(sizeof...(Args) == 1)
{
return encode_impl<Args>(args).to_string();
}
return encode_multi<Args...>(args).to_string();
}
template <typename T>
bool decode_impl(const json& data, T& dest);
template <typename... Args>
bool decode_multi(const json& data, Args&... args);
template<typename... Args>
bool decode(const std::string& data, Args&... args)
{
if(!json::accept(data))
{
return false;
}
auto temp_j = json::parse(data);
if constexpr(sizeof...(Args) == 0)
{
return true;
}
if constexpr(sizeof...(Args) == 1)
{
return decode_impl<Args>(temp_j, args);
}
return decode_multi<Args...>(temp_j, args);
}
由于decode_impl基本等价于encode_impl的逆操作,所以这里只详细介绍encode_impl的实现。对于json规范中规定的物种基本类型,encode_impl分别进行了特化,直接返回原始数据对应的json。对于STL里规定的相关类型,按照类型的具体语义进行特化:
// forward declare
template <typename T>
json encode_impl(const std::optional<T>& data); // data有效则返回encode_impl<T> 否则返回bull
template <typename T>
json encode_impl(const std::vector<T>& data); // 构造一个json vector 然后遍历每个元素调用encode_impl<T>进行push_back
template <typename T1, typename T2>
json encode_impl(const std::pair<T1, T2>& data); //构造两个元素的json vector 分别调用encode_impl<T1> encode_impl<T2>来填充0, 1 两个元素
template <typename T1, std::size_t T2>
json encode_impl(const std::array<T1, T2>& data); // 等价于调用encode_impl<std::vector<T>>()
template <typename... args>
json encode_impl(const std::tuple<args...>& data); // 构造一个json vector 然后对data里每个元素调用encode_impl<args>然后执行push_back
template <typename... Args>
json encode_impl(const std::variant<Args...>& data); // 如果没有有效数据则返回null 否则返回对应具体类型的encode_impl<T>
template <typename T1, typename T2>
json encode_impl(const std::map<T1, T2>& data); // 如果T1是string 则构造一个json object进行序列化,否则当作std::vector<std::pair<T1, T2>>来encode_impl
template <typename T1, typename T2>
json encode_impl(const std::unordered_map<T1, T2>& data); // 当作std::vector<std::pair<T1, T2>>来encode_impl
template <typename T>
json encode_impl(const std::unordered_map<std::string, T>& data); // 构造一个json object进行序列化
template <typename T1, typename T2>
json encode_impl(const std::multimap<T1, T2>& data); // 当作std::vector<std::pair<T1, T2>>来encode_impl
template <typename T1, typename T2>
json encode_impl(const std::unordered_multimap<T1, T2>& data); // 当作std::vector<std::pair<T1, T2>>来encode_impl
template <typename T1>
json encode_impl(const std::set<T1>& data); // 当作std::vector<T1>来encode_impl
template <typename T1>
json encode_impl(const std::unordered_set<T1>& data);// 当作std::vector<T1>来encode_impl
template <typename T1>
json encode_impl(const std::multiset<T1>& data);// 当作std::vector<T1>来encode_impl
template <typename T1>
json encode_impl(const std::unordered_multiset<T1>& data);// 当作std::vector<T1>来encode_impl
template <typename... Args>
json encode_multi(const Args&... args); // 构造一个json vector 遍历args里所有元素进行encode_impl 后push_back
完成了encode的逻辑之后,将对应类型的encode_impl操作进行逆操作即可实现decode_impl,这里就不再赘述。
Json RPC的注册
开头的样例RPC代码中,所有的RPC处理都需要在server_handle_msg, client_handle_msg中加入若干行反序列化代码再转接到真正处理对应RPC的业务函数。随着RPC数量的增多,多人同时编辑此函数导致了维护性急剧下降,为解决这个问题,我们将转向注册制的RPC处理:
class rpc_handler
{
std::unordered_map<std::string, std::function<void(const json&)>> m_registered_rpcs;
void handle_rpc(const std::string& cmd, const std::string& detail)
{
auto temp_iter = g_registered_rpcs.find(cmd);
if(temp_iter == g_registered_rpcs.end())
{
return;
}
if(!json::accept(detail))
{
return;
}
temp_iter->second()(json::parse(detail));
}
void register_rpc(const std::string& cmd, std::function<void(const json&)> cmd_handler)
{
assert(g_registered_rpcs.find(cmd) == g_registered_rpcs.end());
m_registered_rpcs[cmd] = cmd_handler;
}
};
我们采取类似于上面的rpc_handler类型来处理rpc的注册与分发,与此同时修改rpc_cmd的类型从enum改为std::string。这样新添加任意一个RPC都不需要修改公共的文件,只需要在对应RPC逻辑的cpp文件中调用对应的register_rpc函数即可。这种设计极大的减轻了RPC框架维护的心智负担。
void handle_auth_req(const std::string& name, const std::string& passwd);
g_rpc_handler.register_rpc("auth_req", [](const json& data)
{
std::string name, passwd;
if(!decode(data, name, passwd))
{
return;
}
handle_auth_req(name, passwd);
});
基于Schema的RPC
在前面一节内容中我们基于Json序列化和注册机制实现了一个非常简单易用的RPC框架,这个框架极大的减轻了RPC处理代码编写逻辑复杂度,但是每个RPC都需要手动编写胶水代码进行注册这种形式仍然有很大的改进空间。如果想避免掉手写注册代码这种繁杂劳动,我们可以求助于一些基于Schema的RPC框架,即通过某种接口描述语言(IDL-Interface Description Language)来描述所有的RPC接口规范,然后使用框架附带的工具自动生成多种语言的序列化、反序列化、接口注册、接口分发等相关代码,使得使用者可以完全不考虑框架内部细节,只需要关心业务逻辑。目前主流的基于Schema的RPC框架主要有Google出品的grpc和FaceBook出品的Thrift,下面我们分别来介绍。
grpc
grpc的IDL使用的是Google开源的protobuf格式。protobuf是一个跨语言、跨平台的序列化协议,下面是一个非常简单的基于protobuf的消息格式声明:
message SearchRequest {
string query = 1;
int32 page_number = 2; // Which page number do we want?
int32 results_per_page = 3; // Number of results to return per page.
}
上面的结构体中,每个字段的左边都有其类型说明符,右边则带有一个从1开始计数的不重复编号。protobuf对于基础数据类型的支持与Json类似,但是对于int64,uint64的支持则是全范围的。对于复杂类型的支持,protobuf比Json更完善一些,提供了如下四种:
optional可空描述符,代表该字段在具体的消息中不一定存在对应的数据repeated数组描述符,代表该字段其实是一个数组,等价于Json::Vectormap<K,V>字典描述符,代表该字段是一个字典,这里对于K的类型并没有像Json一样限定为stringenum枚举描述符,对应cpp的一个枚举类
下面的代码片段展示了protobuf对于这些容器类型数据结构的支持样例:
message Person {
optional string name = 1;
optional int32 id = 2;
optional string email = 3;
enum PhoneType {
PHONE_TYPE_UNSPECIFIED = 0;
PHONE_TYPE_MOBILE = 1;
PHONE_TYPE_HOME = 2;
PHONE_TYPE_WORK = 3;
}
message PhoneNumber {
optional string number = 1;
optional PhoneType type = 2 [default = PHONE_TYPE_HOME];
}
repeated PhoneNumber phones = 4;
}
message AddressBook {
repeated Person people = 1;
}
有了消息结构的声明之后,我们可以使用protobuf自带的预处理工具protoc来生成各种语言版本的胶水代码。这里我们使用一个包含上面的Person的文件作为输入:
protoc -I=$SRC_DIR --cpp_out=$DST_DIR $SRC_DIR/xxx.proto
protoc --cpp_out=. person.proto
在这个指令执行之后,会在当前目录下生成person.pb.h与person.pb.cc两个文件,h文件作为接口文件,cc文件作为实现文件。上面短短的消息声明触发生成的两个文件包含了近2000行不会有人想去读的代码,以此为代价换来的是接口的全面。不过对于用户而言,在Person类上只关心这么几个接口就行:
// name
inline bool has_name() const;
inline void clear_name();
inline const ::std::string& name() const;
inline void set_name(const ::std::string& value);
inline void set_name(const char* value);
inline ::std::string* mutable_name();
// id
inline bool has_id() const;
inline void clear_id();
inline int32_t id() const;
inline void set_id(int32_t value);
// email
inline bool has_email() const;
inline void clear_email();
inline const ::std::string& email() const;
inline void set_email(const ::std::string& value);
inline void set_email(const char* value);
inline ::std::string* mutable_email();
// phones
inline int phones_size() const;
inline void clear_phones();
inline const ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >& phones() const;
inline ::google::protobuf::RepeatedPtrField< ::tutorial::Person_PhoneNumber >* mutable_phones();
inline const ::tutorial::Person_PhoneNumber& phones(int index) const;
inline ::tutorial::Person_PhoneNumber* mutable_phones(int index);
inline ::tutorial::Person_PhoneNumber* add_phones();
上面的代码里包含了一般用户使用的所有相关字段的get,set接口,此外在这个类型继承的基类上则提供了序列化与反序列化的接口:
bool SerializeToString(string* output) const;
bool ParseFromString(const string& data);
其序列化接口生成的字符串是一个二进制字符串,不像Json生成的是一个人机皆可读的文本字符串。采用二进制进行编码的好处就是使序列化之后的数据长度更少,这样在网络上传递这些消息时所需的带宽就会降低很多。
protobuf不仅仅能声明消息结构,还支持声明RPC服务接口,下面是一个最简单的使用其IDL声明RPC接口的样例:
// The greeter service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
// The request message containing the user's name.
message HelloRequest {
string name = 1;
}
// The response message containing the greetings
message HelloReply {
string message = 1;
}
这里我们需要走两遍protoc分别生成消息协议代码和rpc service代码:
$ protoc --cpp_out=. ./greeter.proto
$ protoc --grpc_out=. --plugin=protoc-gen-grpc ./greeter.proto
在上面的第二行指令会导致生成greeter.grpc.pb.h, greeter.grpc.pb.cc两个额外的文件,这两个文件里包含了300行依然无可读性的生成代码。这些代码里对于用户接口来说重要的只有这么几行:
class Greeter final {
class Service : public ::grpc::Service {
public:
Service();
virtual ~Service();
// Sends a greeting
virtual ::grpc::Status SayHello(::grpc::ServerContext* context, const ::HelloRequest* request, ::HelloReply* response);
};
class Stub final : public StubInterface {
public:
Stub(const std::shared_ptr< ::grpc::ChannelInterface>& channel, const ::grpc::StubOptions& options = ::grpc::StubOptions());
::grpc::Status SayHello(::grpc::ClientContext* context, const ::HelloRequest& request, ::HelloReply* response) override;
};
};
Greeter::Service提供一些虚接口来承接服务器接收到相关RPC数据之后的业务层逻辑,具体的实现需要业务方继承这个类型实现所有的虚接口:
class GreeterServiceImpl final : public Greeter::Service {
Status SayHello(ServerContext* context, const HelloRequest* request,
HelloReply* reply) override {
std::string prefix("Hello ");
reply->set_message(prefix + request->name());
return Status::OK;
}
};
实现了业务层逻辑之后,我们可以把这个Service注册到一个开启好了的grpc server上去,grpc内部负责处理网络收发、数据解析、RPC分发等框架层逻辑:
void RunServer(uint16_t port) {
std::string server_address = absl::StrFormat("0.0.0.0:%d", port);
GreeterServiceImpl service;
grpc::EnableDefaultHealthCheckService(true);
grpc::reflection::InitProtoReflectionServerBuilderPlugin();
ServerBuilder builder;
// Listen on the given address without any authentication mechanism.
builder.AddListeningPort(server_address, grpc::InsecureServerCredentials());
// Register "service" as the instance through which we'll communicate with
// clients. In this case it corresponds to an *synchronous* service.
builder.RegisterService(&service);
// Finally assemble the server.
std::unique_ptr<Server> server(builder.BuildAndStart());
std::cout << "Server listening on " << server_address << std::endl;
// Wait for the server to shutdown. Note that some other thread must be
// responsible for shutting down the server for this call to ever return.
server->Wait();
}
int main(int argc, char** argv) {
absl::ParseCommandLine(argc, argv);
RunServer(absl::GetFlag(FLAGS_port));
return 0;
}
Greeter::Stub则负责提供给客户端作为相关RPC调用的入口:
class GreeterClient {
public:
GreeterClient(std::shared_ptr<Channel> channel)
: stub_(Greeter::NewStub(channel)) {}
std::string SayHello(const std::string& user) {
// Follows the same pattern as SayHello.
HelloRequest request;
request.set_name(user);
HelloReply reply;
ClientContext context;
Status status = stub_->SayHello(&context, request, &reply);
if (status.ok()) {
return reply.message();
} else {
std::cout << status.error_code() << ": " << status.error_message()
<< std::endl;
return "RPC failed";
}
}
private:
std::unique_ptr<Greeter::Stub> stub_;
};
有了这个封装好的GreeterClient之后,往指定grpc server发送一次RPC请求就很简单了:
int main(int argc, char** argv) {
absl::ParseCommandLine(argc, argv);
// Instantiate the client. It requires a channel, out of which the actual RPCs
// are created. This channel models a connection to an endpoint specified by
// the argument "--target=" which is the only expected argument.
std::string target_str = absl::GetFlag(FLAGS_target);
// We indicate that the channel isn't authenticated (use of
// InsecureChannelCredentials()).
GreeterClient greeter(
grpc::CreateChannel(target_str, grpc::InsecureChannelCredentials()));
std::string user("world");
std::string reply = greeter.SayHello(user);
std::cout << "Greeter received: " << reply << std::endl;
return 0;
}
例如整体来说,grpc作为一个RPC框架,给用户层屏蔽了诸多细节的同时,在使用上也非常方便。通信的两端只要共享同一个proto文件,即可轻松跨平台、跨语言。但是在游戏业务内我们却极其不推荐使用grpc作为RPC解决方案,主要有如下四点:
grpc的框架依赖过重,前述的最简rpc样例greeter_client greeter_server编译出来之后文件大小可达55M:
-rwxrwxr-x 1 qian qian 51M 4月 25 00:55 greeter_client
-rwxrwxr-x 1 qian qian 53M 4月 25 00:55 greeter_server
而且这两个二进制还带来了很多动态库的依赖:
qian@qian-desktop:~/Github/grpc/examples/cpp/helloworld/build$ ldd ./greeter_client
linux-vdso.so.1 (0x00007ffd58b9d000)
libsystemd.so.0 => /lib/x86_64-linux-gnu/libsystemd.so.0 (0x00007f62d9bdf000)
libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f62d8000000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f62d8315000)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f62d9bbb000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f62d7c00000)
/lib64/ld-linux-x86-64.so.2 (0x00007f62d9cce000)
libcap.so.2 => /lib/x86_64-linux-gnu/libcap.so.2 (0x00007f62d9bad000)
libgcrypt.so.20 => /lib/x86_64-linux-gnu/libgcrypt.so.20 (0x00007f62d7eb8000)
liblzma.so.5 => /lib/x86_64-linux-gnu/liblzma.so.5 (0x00007f62d9b7b000)
libzstd.so.1 => /lib/x86_64-linux-gnu/libzstd.so.1 (0x00007f62d7b49000)
liblz4.so.1 => /lib/x86_64-linux-gnu/liblz4.so.1 (0x00007f62d9b58000)
libgpg-error.so.0 => /lib/x86_64-linux-gnu/libgpg-error.so.0 (0x00007f62d82ef000)
当proto文件随着RPC数量的增多,protoc生成的代码也越来越大,再加上引入的框架代码,编译时间会增加非常多。如果proto文件频繁更新,则编译时间拖慢工作流的影响会更加显著。
-
protobuf的版本冲突问题,如果一个程序连接了多个静态连接了protobuf的动态库,则使用者需要去保证这些动态库使用的protobuf的版本要强一致,否则会造成运行时crash。一旦某个动态库是由第三方团队维护的,则保证这种一致性基本是不可能的任务。在这种设计下,要确保全链路都使用grpc就只能参考Google的Mono Repo,同时禁止出现第三方的动态库里出现protobuf。 -
grpc对于网络层封装的比较重度,而游戏业务经常需要自己去管理网络层的接入与收发,例如我们在前面一章中提到的KCP,这样就产生了很多冲突。 -
protobuf的编码很多时候无法满足游戏业务尽可能的降低网络带宽的需求,因为业务逻辑经常对移动同步等高频RPC自己实现相关数据的以bit为基础的序列化协议,此时在proto文件中只能定义一个string来代表传输的数据,丧失了序列化的意义还容易触发二次编码。此外protobuf作为纯序列化库,对比flatbuffer、thrift和msgpack有不小的性能劣势。
所以,游戏业务中一般只会有限的采用protobuf作为数据序列化协议的一种,而不会整体采用grpc作为网络通信框架。