第六章:网络编程封装——跨平台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_some 和 async_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() 返回的就是完整的一条消息。注意 htonl 和 ntohl 处理字节序,不然不同架构的机器通信会乱码——我踩过这个坑。
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 的具体实现,是一条清晰的路径。
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