48、STL与网络编程:socket编程、数据序列化、缓冲区管理
网络编程,说白了就是让两台机器互相传数据。但传什么、怎么传、传的过程中内存怎么管,这里面的门道可不少。我个人习惯是把STL容器和算法揉进网络层,你会发现代码瞬间清爽很多。
今天咱们就聊聊三个核心点:socket编程的基础套路、数据序列化(把结构体变成字节流)、以及缓冲区管理(用STL容器来管收发缓冲)。
Socket 编程的基本套路
先说说最基础的 TCP socket 流程。服务端:socket → bind → listen → accept → recv/send。客户端:socket → connect → send/recv。这个流程我闭着眼都能写出来,但真正坑人的往往不是流程本身,而是数据怎么组织。
我个人习惯用 std::string 来存 IP 地址和端口信息,而不是 C 风格的 char 数组。为什么?因为 string 会自动管理内存,你不需要担心缓冲区溢出。来看个例子:
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string>
#include <vector>
#include <iostream>
// 用 string 管理地址
std::string ip = "127.0.0.1";
int port = 8080;
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
inet_pton(AF_INET, ip.c_str(), &addr.sin_addr);
int sock = socket(AF_INET, SOCK_STREAM, 0);
bind(sock, (struct sockaddr*)&addr, sizeof(addr));
listen(sock, 5);
// 用 vector<char> 做接收缓冲区
std::vector<char> buf(4096);
int client = accept(sock, nullptr, nullptr);
int len = recv(client, buf.data(), buf.size(), 0);
if (len > 0) {
std::cout << "收到数据: " << std::string(buf.data(), len) << std::endl;
}
你看,std::vector<char> 直接当缓冲区用,.data() 返回底层数组指针,传给 recv 完全没问题。而且 vector 可以动态扩容,比固定大小的 char buf[4096] 灵活多了。
数据序列化:结构体 ↔ 字节流
网络上传的都是字节流,但咱们程序里用的是结构体。怎么把结构体变成字节流?这就是序列化。反过来就是反序列化。
最简单的做法是用 memcpy,但这里有个大坑:内存对齐。不同机器、不同编译器,结构体里的字段之间可能有填充字节。我曾经在项目里遇到过,两台机器编译出来的结构体大小不一样,结果数据解析全乱套了。
更稳妥的做法是手动序列化,或者用 STL 的 std::ostringstream 和 std::istringstream。虽然效率不如 memcpy,但跨平台性好得多。
#include <sstream>
#include <cstdint>
struct Message {
uint32_t id;
uint32_t length;
std::string payload;
};
// 序列化:结构体 → 字节流
std::vector<char> serialize(const Message& msg) {
std::ostringstream oss;
// 写入固定大小的字段
uint32_t net_id = htonl(msg.id);
uint32_t net_len = htonl(msg.length);
oss.write(reinterpret_cast<const char*>(&net_id), sizeof(net_id));
oss.write(reinterpret_cast<const char*>(&net_len), sizeof(net_len));
// 写入变长字段
oss.write(msg.payload.data(), msg.payload.size());
std::string str = oss.str();
return std::vector<char>(str.begin(), str.end());
}
// 反序列化:字节流 → 结构体
Message deserialize(const std::vector<char>& data) {
std::istringstream iss(std::string(data.begin(), data.end()));
Message msg;
uint32_t net_id, net_len;
iss.read(reinterpret_cast<char*>(&net_id), sizeof(net_id));
iss.read(reinterpret_cast<char*>(&net_len), sizeof(net_len));
msg.id = ntohl(net_id);
msg.length = ntohl(net_len);
// 读取变长 payload
std::vector<char> payload_buf(msg.length);
iss.read(payload_buf.data(), msg.length);
msg.payload.assign(payload_buf.data(), payload_buf.size());
return msg;
}
缓冲区管理:用 STL 容器代替裸指针
网络编程里,缓冲区管理是个老生常谈的话题。传统 C 风格的做法是 malloc 一块内存,然后自己维护读写指针。一不小心就内存泄漏、越界访问。
用 STL 容器就好多了。我个人最常用的是 std::deque<char> 来做环形缓冲区。为什么是 deque?因为它可以在头部和尾部高效插入删除,非常适合网络收发的场景。
#include <deque>
#include <algorithm>
class Buffer {
public:
// 写入数据到缓冲区
void write(const char* data, size_t len) {
buf_.insert(buf_.end(), data, data + len);
}
// 从缓冲区读取指定长度的数据
std::vector<char> read(size_t len) {
len = std::min(len, buf_.size());
std::vector<char> result(buf_.begin(), buf_.begin() + len);
buf_.erase(buf_.begin(), buf_.begin() + len);
return result;
}
// 查看缓冲区数据但不移除
const char* peek() const {
return buf_.empty() ? nullptr : &buf_.front();
}
size_t size() const { return buf_.size(); }
bool empty() const { return buf_.empty(); }
private:
std::deque<char> buf_;
};
// 使用示例
Buffer recv_buf;
recv_buf.write("hello", 5);
recv_buf.write(" world", 6);
auto data = recv_buf.read(5); // 取出 "hello"
std::cout << std::string(data.begin(), data.end()) << std::endl; // 输出 hello
你想想看,如果用裸指针 + malloc,你得自己记录写位置、读位置、容量,还得处理扩容。用 deque 这些全帮你搞定了。而且 deque 在头部删除元素时不会像 vector 那样移动大量数据,性能更好。
避坑指南
最后分享几个我踩过的坑:
- recv 不一定能一次收完所有数据。 TCP 是流式协议,你可能只收到半条消息。所以一定要自己维护消息边界,比如用固定长度的头部表示消息长度。
- send 也不一定一次发完。 send 返回实际发送的字节数,可能小于你要发送的长度。需要循环发送直到全部发完。
- vector 的 data() 在 resize 后可能失效。 如果你先取了 data() 指针,然后 resize 了 vector,之前的指针就悬空了。记得先 resize 再取指针。
- 序列化时别用 memcpy 直接拷 std::string。 string 内部有指针,直接拷内存拷的是指针值,不是字符串内容。一定要用 string::data() 或 string::c_str() 取出字符数组再拷。
嗯,今天就聊这么多。STL 和网络编程结合,说白了就是让容器帮你管内存,让算法帮你处理数据。你只要把精力放在协议设计和业务逻辑上,剩下的交给标准库就好。
公众号:蓝海资料掘金营,微信deep3321