第六章:网络编程封装——跨平台socket抽象、asio库入门、TCP/UDP封装

网络编程,说白了就是让两台机器能互相说话。但麻烦的是,Windows 和 Linux 的 socket API 长得不一样。你写个收发数据的逻辑,在 Linux 上跑得欢,换到 Windows 上就报错——这种事我早年遇到过好几次。

所以这一章,我们来解决这个痛点。我会带你做一套跨平台的 socket 抽象层,再顺手把 asio 库的用法讲明白。最后,把 TCP 和 UDP 封装成好用的接口。

6.1 跨平台 socket 抽象:别让平台细节污染业务

先看一个最基础的问题:Windows 上用 WSAStartup 初始化,Linux 上不用。Windows 的 socket 句柄是 SOCKET 类型,Linux 是 int。关闭时,Windows 用 closesocket,Linux 用 close

这些差异,不该出现在你的业务代码里。我的做法是:写一个 Socket 类,把平台差异全部封装在内部。

// socket_platform.h
#ifdef _WIN32
    #include <winsock2.h>
    #include <ws2tcpip.h>
    using socket_t = SOCKET;
    constexpr socket_t INVALID_SOCK = INVALID_SOCKET;
    #define CLOSE_SOCK(s) closesocket(s)
#else
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <unistd.h>
    using socket_t = int;
    constexpr socket_t INVALID_SOCK = -1;
    #define CLOSE_SOCK(s) close(s)
#endif

然后写一个 Socket 类,把 socket()bind()listen()accept()connect()send()recv() 全部包一层。注意,Windows 下 send()recv() 的参数类型是 char*,Linux 下是 void*。我习惯在封装层统一转成 void*,内部做强制转换。

避坑指南:我曾经在 Windows 上忘记调用 WSAStartup,结果 socket() 一直返回 INVALID_SOCKET。排查了半天才发现。所以建议在 Socket 类的构造函数里自动调用 WSAStartup,析构时调用 WSACleanup。用引用计数来管理,避免多次初始化。

6.2 asio 库入门:异步编程的瑞士军刀

同步 socket 编程很简单,但性能有限。你想想看,一个线程只能处理一个连接,要处理上千个连接就得开上千个线程——这显然不现实。

所以我们需要异步 I/O。Boost.Asio 是 C++ 领域最成熟的异步网络库,后来被标准化为 std::net(虽然还没正式进标准)。我个人习惯直接用 standalone 版本的 asio,不依赖 Boost,轻量又好用。

asio 的核心概念就三个:

  • io_context:事件循环,相当于一个调度器
  • socket:封装了 TCP/UDP 的异步操作
  • handler:回调函数,数据到了就调用它

看一个最简单的异步 TCP 客户端:

#include <asio.hpp>
#include <iostream>

using asio::ip::tcp;

int main() {
    asio::io_context io_context;
    tcp::socket socket(io_context);
    tcp::resolver resolver(io_context);
    auto endpoints = resolver.resolve("example.com", "80");

    asio::connect(socket, endpoints);

    std::string msg = "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n";
    asio::write(socket, asio::buffer(msg));

    char data[1024];
    asio::error_code ec;
    size_t len = socket.read_some(asio::buffer(data), ec);
    if (!ec) {
        std::cout.write(data, len);
    }
    return 0;
}

嗯,这里要注意:asio::write 是同步的,会阻塞直到写完。如果你想异步,用 async_write 配合回调。

我的经验:刚开始用 asio 时,我总搞混 read_someasync_read_some。前者是同步读,后者是异步读。异步读需要传入一个回调,数据到了之后回调会被调用。如果你需要保证读到指定字节数,用 async_read 配合 transfer_exactly

6.3 TCP 封装:可靠、有序、面向连接

TCP 的特点是可靠、有序、面向连接。封装时,我一般提供三个核心操作:

  • connect(host, port):建立连接
  • send(data):发送数据
  • recv(buffer):接收数据

但 TCP 是流协议,没有消息边界。你发两次 100 字节,对方可能一次收到 200 字节,也可能分三次收到。所以封装时,我习惯在数据前面加一个 4 字节的长度头。

class TcpSocket {
public:
    void send(const std::vector<char>& data) {
        uint32_t len = htonl(data.size());
        asio::write(socket_, asio::buffer(&len, 4));
        asio::write(socket_, asio::buffer(data));
    }

    std::vector<char> recv() {
        uint32_t len;
        asio::read(socket_, asio::buffer(&len, 4));
        len = ntohl(len);
        std::vector<char> buf(len);
        asio::read(socket_, asio::buffer(buf));
        return buf;
    }
private:
    asio::ip::tcp::socket socket_;
};

这样,每次 recv() 返回的就是完整的一条消息。注意 htonlntohl 处理字节序,不然不同架构的机器通信会乱码——我踩过这个坑。

6.4 UDP 封装:快速、无连接、不保证到达

UDP 就简单多了。没有连接,没有重传,发出去就不管了。封装时,我提供两个操作:

  • send_to(data, host, port):发送到指定地址
  • recv_from(buffer):接收数据,同时获取发送方地址
class UdpSocket {
public:
    void send_to(const std::vector<char>& data,
                 const std::string& host, uint16_t port) {
        asio::ip::udp::endpoint dest(
            asio::ip::address::from_string(host), port);
        socket_.send_to(asio::buffer(data), dest);
    }

    std::pair<std::vector<char>, asio::ip::udp::endpoint> recv_from() {
        std::vector<char> buf(65535);
        asio::ip::udp::endpoint sender;
        size_t len = socket_.receive_from(asio::buffer(buf), sender);
        buf.resize(len);
        return {buf, sender};
    }
private:
    asio::ip::udp::socket socket_;
};

UDP 没有粘包问题,因为每个 send_to 对应一个独立的数据报。但要注意,UDP 数据报最大 65535 字节,实际网络环境可能更小(MTU 通常 1500 字节)。

核心要点:TCP 封装要处理粘包和字节序,UDP 封装要处理丢包和乱序。如果你需要可靠 UDP,可以在应用层实现 ACK 和重传——但那就变成 TCP 了,何必呢?

6.5 知识体系总览

下面这张图,把本章的核心逻辑串起来了。你可以看到,从平台抽象到 asio 封装,再到 TCP/UDP 的具体实现,是一条清晰的路径。

网络编程封装知识体系 跨平台 Socket 抽象 asio 库核心 TCP/UDP 封装 平台差异封装 WSAStartup / socket_t closesocket / close 统一接口:Socket 类 异步 I/O 模型 io_context 事件循环 async_read / async_write 回调 handler 机制 协议封装 TCP:长度头 + 粘包处理 UDP:无连接 + 数据报 字节序转换 (htonl/ntohl) 业务代码:统一调用,无需关心平台

6.6 封装后的使用示例

最后,看看封装后的代码有多清爽:

// TCP 客户端
TcpSocket client;
client.connect("192.168.1.100", 8080);
client.send("Hello");
auto reply = client.recv();

// UDP 客户端
UdpSocket udp;
udp.send_to("Ping", "192.168.1.100", 9090);
auto [data, from] = udp.recv_from();

你看,底层是 asio 还是原生 socket,Windows 还是 Linux,调用方完全不用关心。这就是封装的价值。

我的建议:如果你只是写个小工具,直接用 asio 的原始接口没问题。但如果是团队项目,一定要封装一层。不然每个人写的网络代码风格都不一样,维护起来想哭。

好了,这一章就到这里。网络编程封装的核心思路就是:把平台差异关在门内,把简洁接口留给门外。下一章我们会聊序列化——数据怎么打包才能在网络上高效传输。到时候见。


公众号:蓝海资料掘金营,微信deep3321