跳转至内容
  • 版块
  • 最新
  • 标签
  • 热门
  • Online Tools
  • 用户
  • 群组
折叠
品牌标识

D2Learn Forums

  1. 主页
  2. Blogs | 博客
  3. xin
  4. 【 基于 io_uring 的 C++20 协程网络库】05 Protocol与Endpoint的封装

【 基于 io_uring 的 C++20 协程网络库】05 Protocol与Endpoint的封装

已定时 已固定 已锁定 已移动 xin
c++20协程
1 帖子 1 发布者 7 浏览
  • 从旧到新
  • 从新到旧
  • 最多赞同
登录后回复
此主题已被删除。只有拥有主题管理权限的用户可以查看。
  • DoomjustinD 离线
    DoomjustinD 离线
    Doomjustin
    编写于 最后由 Doomjustin 编辑
    #1

    在构建了底层的异步 I/O 引擎(IOContext)与核心的 Awaiter 机制后,我们的网络库已经具备了处理并发事件的能力。但要让它真正与网络世界通信,我们必须跨越网络编程的第一道门槛:地址与端点(Endpoint)的封装。

    在原生的 POSIX C API 中,网络地址的表示极其繁琐。开发者需要手动处理 sockaddr_in(IPv4)、sockaddr_in6(IPv6)甚至 sockaddr_un(Unix Domain Socket),并充斥着各种宏与危险的指针强制转换。

    本章的目标是:从零开始,一步步利用 C++20 的语言特性,构建出一套强类型、内存安全且零运行时开销的 Endpoint 体系。(也可以看做对asio的cosplay)

    我们最终期望的业务层 API 形态,应该是极致简洁的:

    // 期望的现代 C++ 用法:自动推导,透明解析
    auto ep1 = ip::tcp::endpoint::from_string("127.0.0.1", 12345); 
    auto ep2 = ip::tcp::endpoint::from_string("::1", 12345);       
    

    要实现这一目标,我们需要拆解三个核心概念:协议(Protocol)、IP 地址(Address)与端点(Endpoint)。


    1. 协议抽象:静态特征与运行期状态的权衡

    任何网络通信都需要指定协议。在 C++ 中,为了追求性能,我们通常倾向于将一切可能的信息在编译期固化。对于 Protocol,第一时间,我们可能会设计出一个如下的纯模板类:

    // 纯静态协议封装(存在局限性)
    template<int Domain, int Type, int Protocol>
    struct BasicProtocol {
        static constexpr int domain = Domain;
        static constexpr int type = Type;
        static constexpr int protocol = Protocol;
    };
    

    对于 Type(如 SOCK_STREAM 代表 TCP)和 Protocol(如 IPPROTO_TCP),它们确实是静态不变的。然而,Domain(地址族,即 AF_INET 或 AF_INET6)在现代网络编程中,并非总能在编译期绝对固化。

    考虑一个支持 双栈(Dual-Stack) 的服务器:当你创建一个监听 :: 的 Acceptor 时,它在运行期需要同时处理 IPv4 和 IPv6 的接入。如果 Domain 被彻底写死在模板参数中,我们将无法用单一的泛型类型来描述这个 Acceptor。

    因此,正确的设计哲学是:固化协议的本原特征,保留地址族的运行期决议能力。

    以下是我们对 ip::tcp 的完整实现:

    namespace ip {
    class tcp {
    public:
        // 1. 本原特征:使用 consteval 强制在编译期求值,拒绝任何运行时状态的介入
        [[nodiscard]] consteval auto type() const noexcept -> int { return SOCK_STREAM; }
        [[nodiscard]] consteval auto protocol() const noexcept -> int { return IPPROTO_TCP; }
    
        // 2. 运行期特征:使用 constexpr,允许在运行时根据双栈需求进行动态切换
        [[nodiscard]] constexpr auto domain() const noexcept -> int { return domain_; }
    
        // 具名构造器,语义清晰
        static auto v4() noexcept -> tcp { return tcp{ AF_INET }; }
        static auto v6() noexcept -> tcp { return tcp{ AF_INET6 }; }
    
    private:
        int domain_ = AF_INET;
        explicit tcp(int domain) : domain_{ domain } {}
    };
    } // namespace ip
    

    通过将 type() 和 protocol() 声明为 consteval,我们在编译器层面建立了一条严格的契约,这在后续作为 Type Traits 提取协议参数时,提供了与静态常量完全一致的零开销保证。

    2. IP 地址封装

    端点是由 IP 地址和端口组成的。在封装端点之前,我们需要先解决繁琐的 IP 地址解析。

    在 POSIX 中,IPv4 被存储为 4 字节的 in_addr,IPv6 被存储为 16 字节的 in6_addr。

    我们首先利用 RAII 将它们分别封装,并隐藏丑陋的 inet_pton(字符串转网络字节序)系统调用。

    以 AddressV4 为例:

    struct AddressV4 {
        using address_type = in_addr;
        address_type address{};
    
        // 字符串解析工厂
        static auto from_string(std::string_view address) -> AddressV4 {
            AddressV4 result;
            if (::inet_pton(AF_INET, address.data(), &result.address) != 1)
                throw_system_error("Failed to convert string to IPv4 address");
            return result;
        }
    
        // 格式化输出
        [[nodiscard]] auto to_string() const -> std::string {
            std::string buffer(INET_ADDRSTRLEN, '\0');
            if (::inet_ntop(AF_INET, &address, buffer.data(), INET_ADDRSTRLEN) == nullptr)
                throw_system_error("Failed to convert IPv4 address to string");
            return buffer;
        }
    };
    // AddressV6 的实现高度对称,此处省略
    

    统一抽象:构建 Address 类

    在业务代码中,我们不希望用户去手动 if/else 判断当前字符串是 V4 还是 V6。我们需要一个统一的 Address 类来容纳它们。

    此时,std::variant 成为了最完美的工具。我们可以给出如下实现:

    class Address {
    public:
        using address_type = std::variant<AddressV4, AddressV6>;
    
        Address() = default;
        Address(const AddressV4& ipv4) : address_{ ipv4 } {}
        Address(const AddressV6& ipv6) : address_{ ipv6 } {}
    
        [[nodiscard]] constexpr auto is_v4() const noexcept -> bool {
            return std::holds_alternative<AddressV4>(address_);
        }
    
        [[nodiscard]] auto to_string() const -> std::string {
            // 优雅的多态调用
            return std::visit([](const auto& addr) { return addr.to_string(); }, address_);
        }
    
        static auto from_string(std::string_view address) -> Address {
            AddressV4 ipv4{};
            if (::inet_pton(AF_INET, address.data(), &ipv4.address) == 1)
                return { ipv4 };
    
            AddressV6 ipv6{};
            if (::inet_pton(AF_INET6, address.data(), &ipv6.address) == 1)
                return { ipv6 };
    
            throw_system_error("Invalid IP address format");
            std::unreachable();
        }
    
    private:
        address_type address_;
    };
    

    通过 from_string,我们实现了一个健壮的解析器:它会依次尝试按 IPv4 和 IPv6 解析字符串,并将成功的结果打包进安全的 variant 容器中。

    Address完整代码

    3. Endpoint 封装:跨越 ABI 边界的内存博弈

    现在,我们来到了最核心的部件 BasicEndpoint。它需要将前面实现的 Protocol、Address 与端口(Port)结合起来,并最终生成底层的 sockaddr 结构供内核使用。

    3.1 为什么引入 Protocol 模板参数?

    你可能会疑惑:既然 IP 层的底层表示都是 sockaddr,为什么我们要设计成模板类 BasicEndpoint<Protocol>,而不是一个通用的 Endpoint 类?

    这是出于 强类型安全 的考量。
    TCP 的 127.0.0.1:80 和 UDP 的 127.0.0.1:80 在底层字节上完全一致,但在物理逻辑上是截然不同的通道。如果它们是同一个类型,开发者极易将 UDP 的端点传给 TCP 的 Socket 进行 connect,这种谬误只能在运行时由内核抛出异常。
    通过 Protocol 模板,endpoint<tcp> 和 endpoint<udp> 在 C++ 编译器眼中变成了绝对正交的两种类型,任何混用都会在编译期被拦截,这是零开销抽象的典范。

    3.2 致命陷阱:为何 Endpoint 内部必须摒弃 std::variant?

    在封装 Address 时,我们使用了 std::variant。但在 Endpoint 内部存储底层结构时,却不能继续使用 std::variant<sockaddr_in, sockaddr_in6> 了,

    Endpoint 的内存不仅用于读取,更要以裸指针(sockaddr*)的形式交给内核 API(如 accept 或 recvfrom)。这些 API 具有 Overwrite 语义:内核会直接根据实际接收到的连接,向这块内存灌入 IPv4 或 IPv6 的字节流。

    如果底层是 std::variant:

    1. 内存布局破坏:variant 内部存在一个用于记录当前类型的 index 标记(以及可能的对齐 Padding)。内核如果从首地址开始写 sa_family,会直接破坏这个标记。
    2. 状态脱节(UB):假设 variant 当前为 IPv4(16字节),内核写入了 IPv6(28字节)的数据。内核无从知晓 C++ 的机制,绝不会去更新 variant 的 index。当 C++ 代码再次读取时,将发生严重的未定义行为(Undefined Behavior)。

    3.3 解决方案:Union 与 sockaddr_storage

    为了在确保 C 兼容性的同时提供 C++ 视图,最标准的解决方案是:使用 union 配合 sockaddr_storage。 也借此机会,强调一下C++的底层哲学:程序的世界没有银弹。对于不同场景选择适合的方式,所以C++提供了大量特性。

    template<typename Protocol>
    class BasicEndpoint {
    public:
        using protocol_type = Protocol;
        using address_type = Address;
    
        // ... 构造函数见下文 ...
    
        // 提供给内核 API 的多态强转接口
        auto data() noexcept -> sockaddr* 
        {
            return reinterpret_cast<sockaddr*>(&data_.storage);
        }
    
    private:
        // 经典的多态内存视图 (Aliased Views)
        union AddressType {
            sockaddr_storage storage; // 128字节,最严格对齐,提供绝对安全的物理容量兜底
            sockaddr_in v4;           // 提供给 C++ 侧的 IPv4 具象化读写视图
            sockaddr_in6 v6;          // 提供给 C++ 侧的 IPv6 具象化读写视图
        } data_;
    };
    

    这里巧妙利用了 C/C++ 语言规范中的公共初始序列机制:所有 sockaddr 家族结构体的头两个字节都是 sa_family_t。

    因此,即便内核粗暴地覆盖了 storage,我们依然可以通过 data_.storage.ss_family 安全且合法地获知当前内存中实际装载的协议。

    4. 严守边界:Size 与 Capacity 的严格隔离

    底层封装中最容易触发“缓冲区截断”Bug 的,是如何向内核报告这块 union 的长度。我们必须显式隔离 size() 和 capacity() 的语义:

        // 专供 输出型 API (如 accept, recvfrom) 使用
        [[nodiscard]] constexpr auto capacity() const noexcept -> socklen_t {
            return sizeof(sockaddr_storage); // 永远返回最大容量 128 字节
        }
    
        // 专供 输入型 API (如 bind, connect) 使用
        [[nodiscard]] constexpr auto size() const noexcept -> socklen_t {
            if (data_.storage.ss_family == AF_INET) return sizeof(sockaddr_in); // 16 字节
            return sizeof(sockaddr_in6); // 28 字节
        }
    
    • Input 操作(bind / connect):内核要求精确匹配。如果你调用 bind 时传入了 128 字节(capacity),内核会因为长度不符合 IPv4(16)或 IPv6(28)的规约而直接返回 -EINVAL。必须严格使用动态计算的 size()。
    • Output 操作(accept):内核要求提供最大安全缓冲。在双栈监听模式下,随时可能接入 28 字节的 IPv6 客户端。如果此时你传入的是 size()(若端点默认初始化为 v4,则为 16),内核会直接截断写入,导致提取到的客户端地址完全损坏。必须严格使用 capacity()。

    5. 拼图闭环:优雅的构造过程

    有了上述坚实的底层基础,我们可以将 Address 对象转换为底层的 union 表示,最终兑现文章开头“一键构造”的承诺:

        BasicEndpoint(const address_type& address, in_port_t port) {
            std::memset(&data_, 0, sizeof(data_)); // 彻底清空,防止残留垃圾数据
            
            if (address.is_v4()) {
                data_.storage.ss_family = AF_INET;
                data_.v4.sin_family = AF_INET;
                data_.v4.sin_port = ::htons(port);
                data_.v4.sin_addr = address.to_v4().address;
            } else {
                data_.storage.ss_family = AF_INET6;
                data_.v6.sin6_family = AF_INET6;
                data_.v6.sin6_port = ::htons(port);
                data_.v6.sin6_addr = address.to_v6().address;
            }
        }
    
        static auto from_string(std::string_view address, in_port_t port) -> BasicEndpoint {
            // 委托给 Address 的 variant 解析引擎,解析完成后交由本类的构造函数填充物理内存
            return BasicEndpoint{ address_type::from_string(address), port };
        }
    

    至此,我们的 Endpoint 彻底打通了从上层字符串抽象到底层 C 语言裸内存的通路。它对外提供了严格的类型契约,对内完美化解了 ABI 边界的内存博弈。

    对了,不要忘了,在tcp中,导出我们的endpoint

    class tcp {
    public:
        using address = Address;
    
        using endpoint = BasicEndpoint<tcp>;
    };
    

    Endpoint完整代码

    1 条回复 最后回复
    0

    • 登录

    • 没有帐号? 注册

    • 登录或注册以进行搜索。
    d2learn forums Powered by NodeBB
    • 第一个帖子
      最后一个帖子
    0
    • 版块
    • 最新
    • 标签
    • 热门
    • Online Tools
    • 用户
    • 群组