EmberEmu/Hexi

GitHub: EmberEmu/Hexi

一个轻量级、仅头文件的 C++23 二进制流处理库,专注于安全高效地序列化和反序列化网络数据。

Stars: 286 | Forks: 8

Hexi, Easy Peasy Binary Streaming

Hexi 是一个轻量级、仅头文件的 C++23 库,用于安全地处理来自任意源(但主要是网络数据)的二进制数据。它介于手动从网络缓冲区 memcpy 字节和全功能序列化库之间。 Hexi 被用于处理的一些协议包括 [DNS](https://en.wikipedia.org/wiki/Domain_Name_System)、[STUN](https://en.wikipedia.org/wiki/STUN)、[NAT 端口映射协议](https://en.wikipedia.org/wiki/NAT_Port_Mapping_Protocol)、[端口控制协议](https://en.wikipedia.org/wiki/Port_Control_Protocol)、魔兽世界(模拟器)和 GameSpy(模拟器)。当然,没有什么能阻止你定义自己的协议! 其设计目标是易于使用、在处理不受信任的数据和面对程序员错误时保证安全、具备合理的灵活性,并将开销保持在最低水平。 Hexi 不提供的功能:版本控制、不同格式间的转换、基于文本的格式处理、卸载洗碗机。 Hexi 采用 MIT 和 Apache License, Version 2.0 双重许可。这意味着你可以根据自己偏好的许可证使用 Hexi。 Getting started 将 Hexi 整合到你的项目中非常简单!最简单的方法是直接将 `single_include` 中的 `hexi.h` 复制到你自己的项目中。如果你只想包含你使用的内容,可以将 `include` 添加到你的包含路径中,或者通过 `target_link_library` 将其整合到你自己的 CMake 项目中。要构建单元测试,请使用 `ENABLE_TESTING` 运行 CMake。 以下是一些库可能称之为非常简单的入门示例: ``` #include #include #include #include struct LoginPacket { uint64_t user_id; uint64_t timestamp; std::array ipv6; }; auto deserialise(std::span network_buffer) { hexi::buffer_adaptor adaptor(network_buffer); // wrap the buffer hexi::binary_stream stream(adaptor); // create a binary stream // deserialise! LoginPacket packet; stream >> packet; return packet; } auto serialise(const LoginPacket& packet) { std::vector buffer; hexi::buffer_adaptor adaptor(buffer); // wrap the buffer hexi::binary_stream stream(adaptor); // create a binary stream // serialise! stream << packet; return buffer; } ``` 默认情况下,如果基本结构(如我们的 `LoginPacket`)满足可以直接复制字节的安全要求,Hexi 将尝试对其进行序列化。现在,出于可移植性的原因,不建议你这样做,除非你确定写入数据的系统上的数据布局是相同的。别担心,这很容易解决。另外,我们没有做任何错误或字节序处理。一切都会适时解决。 Remember these two classes, if nothing else! 你主要会打交道的两个类是 `buffer_adaptor` 和 `binary_stream`。 `binary_stream` 接受一个容器作为参数,用于进行读取和写入。它不太了解底层容器的细节。 为了支持非专为 Hexi 编写的容器,`buffer_adaptor` 被用作 `binary_stream` 可以对接的包装器。与 `binary_stream` 一样,它也提供读取和写入操作,但级别更低。 `buffer_adaptor` 可以包装任何提供 `data` 和 `size` 成员函数的连续容器或视图。对于标准库而言,这意味着以下容器可以直接使用: - [x] std::array - [x] std::span - [x] std::string_view - [x] std::string - [x] std::vector 许多非标准库容器也可以直接使用,只要它们提供大致相似的 API。 容器的值类型必须是字节类型(例如 `char`、`std::byte`、`uint8_t`)。如果这成为问题,可以使用 `std::as_bytes` 作为变通方法。 Hexi 支持自定义容器,包括非连续容器。事实上,库中包含了一个非连续容器。你只需要提供一些函数(如 `read` 和 `size`)即可允许 `binary_stream` 类使用它。 `static_buffer.h` 提供了一个可以直接与 `binary_stream` 一起使用的自定义容器的简单示例。 如前所述,Hexi 旨在即使在处理不受信任的数据时也能安全使用。一个例子可能是被操纵以试图欺骗你的代码进行越界读取的网络消息。 `binary_stream` 执行边界检查,以确保它永远不会读取超过缓冲区可用数据量的内容,并可选择允许你指定读取数据量的上限。当你在一个缓冲区中有多条消息并希望限制反序列化以免潜在地侵入下一条消息时,这会很有用。 ``` buffer_t buffer; // ... read data hexi::binary_stream stream(buffer, 32); // will never read more than 32 bytes ``` Errors happen, it's up to you to handle 'em 默认的错误处理机制是异常。在遇到读取数据问题时,将抛出派生自 `hexi::exception` 的异常。这些异常包括: - `hexi::buffer_underrun` - 尝试越界读取 - `hexi::stream_read_limit` - 尝试读取超过 imposed limit 的数据 通过指定 `no_throw` 作为参数,可以禁用来自 `binary_stream` 的异常。此参数在编译时消除了异常分支,因此零运行时开销。 ``` hexi::binary_stream stream(buffer, hexi::no_throw); ``` 无论你使用哪种错误处理机制,都可以按如下方式检查 `binary_stream` 的状态: ``` hexi::binary_stream stream(buffer, hexi::no_throw); // ... assume an error happens // simplest way to check whether any errors have occurred if (!stream) { // handle error } // or we can get the state if (auto state = stream.state(); state != hexi::stream_state::ok) { // handle error } ``` Writing portable code is easy peasy 在第一个示例中,读取我们的 `LoginPacket` 只有在写入数据的程序将所有内容以与我们自己的程序相同的方式布局时,才能按预期工作。 由于架构差异、编译器标志等原因,情况可能并非如此。 这是同一个示例,但以可移植的方式进行。 ``` #include #include #include #include #include #include // an example structure that has separate serialise and deserialise functions struct LoginPacket { uint64_t user_id; std::string username; uint64_t timestamp; uint8_t has_optional_field; uint32_t optional_field; // pretend this is big-endian in the protocol // deserialise auto& operator>>(auto& stream) { stream >> user_id >> username >> timestamp >> has_optional_field; if (has_optional_field) { // fetch explicitly as big-endian ('be') value stream >> hexi::endian::be(optional_field); } // we can manually trigger an error if something went wrong // stream.set_error_state(); return stream; } // serialise auto& operator<<(auto& stream) const { stream << user_id << username << timestamp << has_optional_field; if (has_optional_field) { // write explicitly as big-endian ('be') value stream << hexi::endian::be(optional_field); } return stream; } }; // an example of a packet that can serialise and deserialise with the same function struct LogoutPacket { std::string username; std::uint32_t user_id; std::uint8_t has_timestamp; std::uint64_t timestamp; // pretend this is optional & big endian in the protocol void serialise(auto& stream) const { stream(username, user_id); if (has_timestamp) { stream(hexi::endian::be(timestamp)); // can also do this to write a single field: // stream & hexi::endian::be(timestamp); } } }; // pretend we're reading network data void read() { std::vector buffer; const auto bytes_read = socket.read(buffer); // ... logic for determining packet type, etc bool result {}; switch (packet_type) { case login_packet: result = handle_login_packet(buffer); break; case logout_packet: result = handle_logout_packet(buffer); break; } // ... handle result } auto handle_login_packet(std::span buffer) { hexi::buffer_adaptor adaptor(buffer); /** * hexi::endian::little tells the stream to convert to/from * little-endian unless told otherwise by using the endian * adaptors. If no argument is provided, it does not perform * any conversions by default. */ hexi::binary_stream stream(adaptor, hexi::endian::little); LoginPacket packet; stream >> packet; if (stream) { // ... do something with the packet return true; } else { return false; } } auto handle_logout_packet(std::span buffer) { hexi::buffer_adaptor adaptor(buffer); hexi::binary_stream stream(adaptor, hexi::endian::little); LogoutPacket packet; stream << packet; /** * alternative methods: * stream.serialise(packet); * * or: * * hexi::stream_read_adaptor adaptor(stream); * packet.serialise(adaptor); */ if (stream) { // ... do something with the packet return true; } else { return false; } } ``` 此示例完全可移植,甚至独立于平台字节序。通过指定流的字节序,它会自动将所有对字节序敏感的数据转换为请求的字节顺序。 默认参数是 `hexi::endian::native`,它不执行任何转换,而 `hexi::endian::big` 和 `hexi::endian::little` 将在需要时执行转换。 如果你的协议包含混合字节序,你可以使用字节序适配器在流式传输数据时指定所需的字节顺序,如上例所示。 最棒的是,因为这是由模板处理的,如果不需要转换(即本机字节顺序与请求的字节顺序匹配),则零运行时成本,并且常量可以在编译时转换。例如,在 little-endian 平台上指定 `hexi::endian::little` 将生成零代码。 `docs/examples/endian.cpp` 提供了字节顺序处理功能的示例。 至于序列化函数,如果你希望函数体位于源文件中,建议你为你的 `binary_stream` 类型提供自己的 `using` 别名。 另一种方法是使用多态等价物 `pmc::buffer_adaptor` 和 `pmc::binary_stream`,它们允许你在运行时更改底层缓冲区类型,但潜在代价是虚拟调用开销(尽管存在去虚拟化)以及缺乏一些与多态性不太契合的功能。 如何构建代码取决于你,这只是其中一种方法。 Uh, one more thing... 处理字符串需要一点思考。`std::string` 和 `std::string_view` 允许包含嵌入式空字符,即使很少这样做。 为了遵守最小惊讶原则,Hexi 默认使用长度前缀来读取和写入这些类型。这确保写入这样的字符串并将其读回将为你提供正确的结果,无论内容如何。 在大多数情况下,你会希望以 null 结尾的方式读/写字符串。为此,请使用字符串适配器,如下所示: ``` hexi::binary_stream stream(...); std::string foo { "No surprises here!" }; // write it stream << hexi::null_terminated(foo); // read it back stream >> hexi::null_terminated(foo); ``` 这不是默认设置,因为写入可能包含嵌入式空字节的字符串会导致读回时字符串被截断。这是 Hexi 让你郑重承诺你对字符串数据处理方式正确的方式。 `const char*` 字符串*总是*以 null 结尾的字符串形式写入,因为这种类型中的嵌入式空字符几乎没有意义。使用 `null_terminated` 适配器读回它们。 其他适配器(如 `prefixed_varint`)也可用。有关用法示例,请参见 `docs/examples/string_handling.cpp`。 What else is in the box? 以下是对一些包含的额外功能的非常简明的介绍。 - `hexi::file_buffer` - 用于处理二进制文件。简单。 - `hexi::static_buffer` - 固定大小的网络缓冲区,用于当你知道一次发送或接收的数据量的上限时。本质上是一个 `std::array` 的包装器,但增加了状态跟踪。如果你需要分多步反序列化(读取数据包头、分发、读取数据包体),这很方便。 - `hexi::dynamic_buffer` - 可调整大小的缓冲区,用于当你想要处理偶尔的大读/写而不必预先分配空间时。在内部,它添加额外的分配来容纳额外的数据,而不是像 `std::vector` 那样请求更大的分配并复制数据。它尽可能重用已分配的块,并支持 Asio(Boost 或 standalone)。实际上,它是一个链表缓冲区。 - `hexi::tls_block_allocator` - 允许 `dynamic_buffer` 的许多实例共享更大的预分配内存池,每个线程都有自己的池。当你有许多网络套接字要处理并希望避免通用分配器时,这很有用。需要注意的是,释放必须由进行分配的同一线程进行,从而将缓冲区的访问限制为单线程(有一些例外)。 - `hexi::endian` - 提供处理整数类型字节序的功能。 - `hexi::null_buffer` - Hexi 的 /dev/null 等价物。如果你想知道序列化后类型的确切大小,通常用于分配或保留精确数量的内存,则此缓冲区很有用。运行两次序列化可能听起来效率低下,但编译器通常可以在编译时计算序列化到 `null_buffer` 的最终结果,或者将其提炼为几条算术指令。 Before we wrap up, look at these tidbits... 我们已到达概述的结尾,但如果你决定尝试 Hexi,还有更多内容等待发现。以下是一些精选的美味佳肴: - `binary_stream` 允许你在底层缓冲区支持的情况下在流中执行写入查找。这很好,例如,如果你需要使用可能直到消息其余部分写入后才知道的信息更新消息头;校验和、大小等。 - `binary_stream` 提供重载的 `put` 和 `get` 成员函数,允许进行细粒度控制,例如读取/写入特定数量的字节。 - `binary_stream` 允许使用 `view()` 和 `span()` 反序列化为 `std::string_view` 和 `std::span`,只要底层容器是连续的。这允许你创建进入缓冲区数据的视图,提供一种从流中读取字符串和数组的快速、零复制方法。如果这样做,你应避免在持有数据视图的同时写入同一缓冲区。 - `buffer_adaptor` 提供一个模板选项 `space_optimise`。默认情况下启用,它允许在流已读取所有数据的情况下避免调整容器大小。禁用它允许即使在已读取后也保留数据。此选项仅与单个缓冲区既被写入又被读取的场景相关。 - `buffer_adaptor` 提供 `find_first_of`,使得在缓冲区中查找特定的哨兵值变得容易。 欲了解更多信息,请查看 `docs/examples` 中的示例! Thanks for listening! Now go unload the dis[C Make Lists](include/CMakeLists.txt)hwasher.
标签:Bash脚本, C++23, DNS, Emulator, Header-only, NAT穿透, Socket通信, STUN, 二进制安全, 二进制流, 序列化, 游戏开发, 游戏服务器模拟, 缓冲区管理, 网络安全, 网络编程, 轻量级库, 隐私保护