文章目录
- 引言
- 一、为什么需要序列化与反序列化?
- 二、登录场景的序列化与反序列化实现
-
- [2.1 登录请求(LoginRequest)的实现](#2.1 登录请求(LoginRequest)的实现)
- [2.2 登录响应(LoginResponse)的实现](#2.2 登录响应(LoginResponse)的实现)
- [三、Server 端的修改](#三、Server 端的修改)
- 四、客户端代码的修改
- 五、编译与登录测试
-
- [5.1 Makefile 配置](#5.1 Makefile 配置)
- [5.2 登录测试](#5.2 登录测试)
-
- [步骤 1:启动服务器](#步骤 1:启动服务器)
- [步骤 2:启动客户端](#步骤 2:启动客户端)
- [步骤 3:发送数据](#步骤 3:发送数据)
- 步骤4:模拟登录失败的场景
- 六、手写序列化的优缺点分析
-
- [6.1 优点](#6.1 优点)
- [6.2 缺点](#6.2 缺点)
- 七、业务层的抽象设计思路
- 八、总结
引言
在 TCP 通信中,数据在网络上传输的形式是字节流。当我们需要传输复杂的数据结构(如用户登录信息)时,就必须解决两个核心问题:如何将内存中的数据结构转换为可传输的字节流(序列化),以及如何将接收到的字节流还原为原始数据结构(反序列化)。本文结合登录场景的代码实现,通过复用上一篇博客的代码,详细讲解序列化与反序列化的设计与实现。
一、为什么需要序列化与反序列化?
网络传输的基本单位是字节,而我们在程序中处理的是结构化数据(如包含用户名和密码的登录请求)。以登录场景为例,客户端需要将username和password这两个字符串传递给服务器,服务器验证后返回包含状态码、消息和 Token 的响应。这个过程中需要解决:
- 数据边界问题:如何区分不同字段(如哪里是用户名结束,哪里是密码开始)
- 字节序问题:不同系统可能采用不同的字节序(大端 / 小端)
- 数据类型一致性:确保接收方正确解析数据长度和内容
序列化与反序列化正是为解决这些问题而设计的机制。
二、登录场景的序列化与反序列化实现
我们通过 LoginRequest 和 LoginResponse 两个类实现登录数据的序列化与反序列化,核心思路是先传输长度,再传输内容,并统一使用网络字节序(大端)。
2.1 登录请求(LoginRequest)的实现
登录字段包含 "用户名" 和 "密码":
cpp
class LoginRequest {
public:
LoginRequest() = default;
LoginRequest(const std::string& username, const std::string& password)
: _username(username), _password(password) {}
// 序列化:const函数,仅读取成员,不修改
void serialize(char* buffer) const {
if (buffer == nullptr) return;
uint32_t username_len = _username.size();
uint32_t password_len = _password.size();
// 动态计算总数据长度(局部变量,不修改成员)
uint32_t data_len = sizeof(username_len) + username_len + sizeof(password_len) + password_len;
uint32_t offset = 0;
// 写入总长度(网络字节序)
uint32_t data_len_net = htonl(data_len);
memcpy(buffer + offset, &data_len_net, sizeof(data_len_net));
offset += sizeof(data_len_net);
// 写入用户名(长度转网络序)
uint32_t username_len_net = htonl(username_len);
memcpy(buffer + offset, &username_len_net, sizeof(username_len_net));
offset += sizeof(username_len_net);
memcpy(buffer + offset, _username.c_str(), username_len);
offset += username_len;
// 写入密码(长度转网络序)
uint32_t password_len_net = htonl(password_len);
memcpy(buffer + offset, &password_len_net, sizeof(password_len_net));
offset += sizeof(password_len_net);
memcpy(buffer + offset, _password.c_str(), password_len);
}
// 反序列化
bool deserialize(const char* buffer, uint32_t buffer_len) {
if (buffer == nullptr || buffer_len < sizeof(uint32_t)) return false;
uint32_t offset = 0;
// 读取总长度(网络序转主机序)
uint32_t data_len_net;
memcpy(&data_len_net, buffer + offset, sizeof(data_len_net));
uint32_t data_len = ntohl(data_len_net);
offset += sizeof(data_len_net);
if (buffer_len < sizeof(uint32_t) + data_len) return false;
// 读取用户名
uint32_t username_len_net;
memcpy(&username_len_net, buffer + offset, sizeof(username_len_net));
uint32_t username_len = ntohl(username_len_net);
offset += sizeof(username_len_net);
if (offset + username_len > buffer_len) return false;
_username.assign(buffer + offset, username_len);
offset += username_len;
// 读取密码
uint32_t password_len_net;
memcpy(&password_len_net, buffer + offset, sizeof(password_len_net));
uint32_t password_len = ntohl(password_len_net);
offset += sizeof(password_len_net);
if (offset + password_len > buffer_len) return false;
_password.assign(buffer + offset, password_len);
return true;
}
// 动态计算序列化所需大小(替代原GetDataLen)
uint32_t GetSerializeSize() const {
uint32_t username_len = _username.size();
uint32_t password_len = _password.size();
return sizeof(uint32_t) // 总长度字段
+ sizeof(username_len) + username_len // 用户名
+ sizeof(password_len) + password_len; // 密码
}
// Getter/Setter
std::string GetUserName() const { return _username; }
std::string GetPassWord() const { return _password; }
void SetUserName(const std::string& username) { _username = username; }
void SetPassWord(const std::string& password) { _password = password; }
std::string SerializeToString() const {
uint32_t size = GetSerializeSize();
std::string buffer(size, '\0');
serialize(&buffer[0]);
return buffer;
}
private:
std::string _username;
std::string _password;
};
关键设计说明:
- 因为不同主机的字节序可能会有差异,所以我们在数据序列化之前统一先把本地字节序转换成网络字节序;反之在反序列化的时候,则把网络字节序转换成本地字节序。
- 序列化函数存在
const修饰,而 反序列化函数不存在const修饰:在序列化的时候,我们是把请求中的数据序列化成一个字符串,而这个序列化的字符串是由上层传入一个缓冲区(buffer)进行接收,那么我们也就不需要也不能对请求中的数据进行修改;而反序列化则是我们要把recv到的数据存储在buffer中,然后读取这个buffer将它存储在本地的请求实例中。 - 在序列化的时候,因为我们存在着多个字段,那么就涉及到字段的边界问题。首先对于整个数据报文,我们使用一个
data_len去记录数据的总长度,然后每一个字段前面,我们都添加一个相应字段的字段长度。所以整体所发送的报文应该是data_len + username_len + username + password_len + password。 - 在反序列化的时候,需要先进行数据判断,如果读取到的数据长度还没有
data_len(类型) 的大小那么大,那也就没必要进行数据处理了。在具体的反序列化的时候,我们就按照先读取data_len,然后读取第一个字段长度username_len,再读取第一个字段username,然后第二个字段长度password_len,最后第二个字段password。其实看着自己设计的序列化,跟着它反着来就行。只不过为了防止数据读取失败,加一个字段长度判断就行。
2.2 登录响应(LoginResponse)的实现
响应与请求的设计思路一致,包含状态码(0 表示成功,非 0 表示错误)、消息提示和 Token(登录成功时返回)。
注意:在真正的业务场景下 Token 的生成是需要使用加密算法生成的,我这里就随便写了一下模拟。
cpp
class LoginResponse {
public:
LoginResponse() = default;
LoginResponse(uint32_t status_code, const std::string& msg, const std::string& token)
: _status_code(status_code), _msg(msg), _token(token) {}
// 序列化:const函数,不修改成员
void serialize(char* buffer) const {
if (buffer == nullptr) return;
uint32_t msg_len = _msg.size();
uint32_t token_len = _token.size();
// 动态计算总长度(局部变量)
uint32_t data_len = sizeof(_status_code) + sizeof(msg_len) + msg_len + sizeof(token_len) + token_len;
uint32_t offset = 0;
// 写入总长度(网络序)
uint32_t data_len_net = htonl(data_len);
memcpy(buffer + offset, &data_len_net, sizeof(data_len_net));
offset += sizeof(data_len_net);
// 写入状态码(网络序)
uint32_t status_code_net = htonl(_status_code);
memcpy(buffer + offset, &status_code_net, sizeof(status_code_net));
offset += sizeof(status_code_net);
// 写入消息
uint32_t msg_len_net = htonl(msg_len);
memcpy(buffer + offset, &msg_len_net, sizeof(msg_len_net));
offset += sizeof(msg_len_net);
memcpy(buffer + offset, _msg.c_str(), msg_len);
offset += msg_len;
// 写入Token
uint32_t token_len_net = htonl(token_len);
memcpy(buffer + offset, &token_len_net, sizeof(token_len_net));
offset += sizeof(token_len_net);
memcpy(buffer + offset, _token.c_str(), token_len);
}
// 反序列化
bool deserialize(const char* buffer, uint32_t buffer_len) {
if (buffer == nullptr || buffer_len < sizeof(uint32_t)) return false;
uint32_t offset = 0;
// 读取总长度(网络序转主机序)
uint32_t data_len_net;
memcpy(&data_len_net, buffer + offset, sizeof(data_len_net));
uint32_t data_len = ntohl(data_len_net);
offset += sizeof(data_len_net);
if (buffer_len < sizeof(uint32_t) + data_len) return false;
// 读取状态码
uint32_t status_code_net;
memcpy(&status_code_net, buffer + offset, sizeof(status_code_net));
_status_code = ntohl(status_code_net);
offset += sizeof(status_code_net);
// 读取消息
uint32_t msg_len_net;
memcpy(&msg_len_net, buffer + offset, sizeof(msg_len_net));
uint32_t msg_len = ntohl(msg_len_net);
offset += sizeof(msg_len_net);
if (offset + msg_len > buffer_len) return false;
_msg.assign(buffer + offset, msg_len);
offset += msg_len;
// 读取Token
uint32_t token_len_net;
memcpy(&token_len_net, buffer + offset, sizeof(token_len_net));
uint32_t token_len = ntohl(token_len_net);
offset += sizeof(token_len_net);
if (offset + token_len > buffer_len) return false;
_token.assign(buffer + offset, token_len);
return true;
}
// 动态计算序列化大小
uint32_t GetSerializeSize() const {
uint32_t msg_len = _msg.size();
uint32_t token_len = _token.size();
return sizeof(uint32_t) // 总长度字段
+ sizeof(_status_code) // 状态码
+ sizeof(msg_len) + msg_len // 消息
+ sizeof(token_len) + token_len; // Token
}
// Getter/Setter
uint32_t GetStatusCode() const { return _status_code; }
std::string GetMsg() const { return _msg; }
std::string GetToken() const { return _token; }
void SetStatusCode(uint32_t status_code) { _status_code = status_code; }
void SetMsg(const std::string& msg) { _msg = msg; }
void SetToken(const std::string& token) { _token = token; }
std::string SerializeToString() const {
uint32_t size = GetSerializeSize();
std::string buffer(size, '\0');
serialize(&buffer[0]);
return buffer;
}
private:
uint32_t _status_code = 0;
std::string _msg;
std::string _token;
};
关键设计说明:
- 和前面请求报文一样,这里就不再赘述。
三、Server 端的修改
其实也没什么好改造的,就是把原有的通用数据处理函数替换成我们登录业务数据处理函数就行,其中登陆业务数据处理需包含序列化和反序列化。
cpp
// 服务器数据处理函数:替换原有通用处理
std::string LoginDataHandler(const std::string& client_data) {
// 1. 反序列化登录请求
LoginRequest req;
if (!req.deserialize(client_data.c_str(), client_data.size())) {
return LoginResponse(1, "无效的请求格式", "").SerializeToString();
}
// 2. 模拟登录验证(实际项目中应连接数据库)
std::string username = req.GetUserName();
std::string password = req.GetPassWord();
bool auth_success = (username == "admin" && password == "123456");
// 3. 构建响应并序列化
LoginResponse resp;
if (auth_success) {
resp.SetStatusCode(0);
resp.SetMsg("登录成功");
resp.SetToken("fake_token_" + std::to_string(rand() % 10000));
} else {
resp.SetStatusCode(2);
resp.SetMsg("用户名或密码错误");
}
return resp.SerializeToString();
}
// 服务器初始化时绑定登录处理函数
std::unique_ptr<TcpServer> tcp_server =
std::make_unique<TcpServer>(listen_port, LoginDataHandler, thread_num);
四、客户端代码的修改
因为我们新增了登录业务,所以客户端需要新增登录流程的处理,核心是构建LoginRequest 并序列化发送,再接收并反序列化 LoginResponse,直接看代码吧:
cpp
// 打印用法提示
void Usage(std::string proc) {
std::cerr << "Usage: " << proc << " server_ip server_port" << std::endl;
std::cerr << "示例:" << proc << " 127.0.0.1 8888" << std::endl;
}
// 接收指定长度的数据
bool RecvAll(int fd, char* buffer, size_t len) {
size_t total_recv = 0;
while (total_recv < len) {
ssize_t recv_len = recv(fd, buffer + total_recv, len - total_recv, 0);
if (recv_len <= 0) {
return false; // 接收失败或连接关闭
}
total_recv += recv_len;
}
return true;
}
int main(int argc, char* argv[]) {
// 检查命令行参数
if (argc != 3) {
Usage(argv[0]);
return 1;
}
// 解析服务器地址和端口
std::string server_ip = argv[1];
uint16_t server_port = std::stoi(argv[2]);
// 1. 创建TCP套接字
int client_fd = socket(AF_INET, SOCK_STREAM, 0);
if (client_fd == -1) {
perror("socket 创建失败");
return 2;
}
std::cout << "客户端创建套接字成功,client_fd: " << client_fd << std::endl;
// 2. 填充服务器地址结构
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(server_port);
server_addr.sin_addr.s_addr = inet_addr(server_ip.c_str());
// 3. 连接服务器
int connect_ret = connect(client_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
if (connect_ret == -1) {
perror("connect 失败(请检查服务器IP和端口是否正确)");
close(client_fd);
return 3;
}
std::cout << "已成功连接到服务器[" << server_ip << ":" << server_port << "]" << std::endl;
// 4. 登录流程
std::string username, password;
std::cout << "\n===== 登录 =====" << std::endl;
std::cout << "请输入用户名: ";
std::getline(std::cin, username);
std::cout << "请输入密码: ";
std::getline(std::cin, password);
// 构建登录请求
LoginRequest login_req(username, password);
std::string req_data;
req_data.resize(login_req.GetSerializeSize());
login_req.serialize(&req_data[0]);
// 发送请求(先发送长度,再发送数据)
uint32_t data_len = htonl(req_data.size());
if (send(client_fd, &data_len, sizeof(data_len), 0) != sizeof(data_len)) {
perror("发送长度失败");
close(client_fd);
return 4;
}
if (send(client_fd, req_data.c_str(), req_data.size(), 0) != (ssize_t)req_data.size()) {
perror("发送登录请求失败");
close(client_fd);
return 5;
}
std::cout << "已发送登录请求" << std::endl;
// 接收登录响应
uint32_t resp_len_net = 0;
if (!RecvAll(client_fd, (char*)&resp_len_net, sizeof(resp_len_net))) {
perror("接收响应长度失败");
close(client_fd);
return 6;
}
uint32_t resp_len = ntohl(resp_len_net);
std::string resp_data(resp_len, '\0');
if (!RecvAll(client_fd, &resp_data[0], resp_len)) {
perror("接收响应数据失败");
close(client_fd);
return 7;
}
// 解析响应
LoginResponse login_resp;
if (!login_resp.deserialize(resp_data.c_str(), resp_data.size())) {
std::cerr << "解析登录响应失败" << std::endl;
close(client_fd);
return 8;
}
// 显示登录结果
std::cout << "\n===== 登录结果 =====" << std::endl;
std::cout << "状态码: " << login_resp.GetStatusCode() << std::endl;
std::cout << "消息: " << login_resp.GetMsg() << std::endl;
if (login_resp.GetStatusCode() == 0) {
std::cout << "Token: " << login_resp.GetToken() << std::endl;
}
// 5. 后续交互(可选)
if (login_resp.GetStatusCode() == 0) {
std::cout << "\n===== 开始交互(输入exit退出) =====" << std::endl;
std::string input_data;
while (true) {
std::cout << "请输入消息: ";
std::getline(std::cin, input_data);
if (input_data == "exit") break;
// 发送普通消息(沿用相同协议格式)
uint32_t msg_len = htonl(input_data.size());
send(client_fd, &msg_len, sizeof(msg_len), 0);
send(client_fd, input_data.c_str(), input_data.size(), 0);
// 接收响应
char recv_buf[1024] = {0};
uint32_t res_len_net = 0;
if (!RecvAll(client_fd, (char*)&res_len_net, sizeof(res_len_net))) break;
uint32_t res_len = ntohl(res_len_net);
if (res_len > sizeof(recv_buf) - 1) res_len = sizeof(recv_buf) - 1;
if (!RecvAll(client_fd, recv_buf, res_len)) break;
std::cout << "服务器响应: " << recv_buf << std::endl;
}
}
// 6. 关闭连接
close(client_fd);
std::cout << "\n客户端已退出,连接已关闭" << std::endl;
return 0;
}
五、编译与登录测试
5.1 Makefile 配置
因为我是通过手写序列化完成的,并没有使用 JSON 和 Protobuf 这种第三方库,所以还是和上一篇线程池版本的服务器一致。
5.2 登录测试
步骤 1:启动服务器
指定监听端口 8888,线程池大小 4:
bash
./tcpserver 8888 4
服务器日志:
bash
套接字创建成功,listen_fd: 3
绑定成功,成功监听端口:8888
监听中,等待客户端连接...
线程池创建工作线程:140481630516800
线程池创建工作线程:140481622124096
线程池创建工作线程:140481613731392
线程池创建工作线程:140481605338688
线程池启动成功(工作线程数:4)
工作线程启动:140481622124096
工作线程启动:140481630516800
工作线程启动:140481613731392
工作线程启动:140481605338688
步骤 2:启动客户端
这篇博客只是测试应用层逻辑,启动一个客户端就可以了:
bash
./tcpclient 127.0.0.1 8888
客户端日志:
bash
客户端创建套接字成功,client_fd: 3
已成功连接到服务器[127.0.0.1:8888]
===== 登录 =====
请输入用户名:
步骤 3:发送数据
客户端输入:admin 和 123456
下面我直接放截图了,日志格式不好用 markdown 写:

服务器日志:
bash
客户端连接成功:[127.0.0.1:59292],client_fd: 4
子线程(tid: 140481622124096)开始处理客户端[127.0.0.1:59292]
子线程(tid: 140481622124096)收到[127.0.0.1:59292]的数据(长度:23):admin123456
子线程(tid: 140481622124096)向[127.0.0.1:59292]发送响应(长度:43):'
登录成功fake_token_9383
登录之后客户端发送数据交互:

服务器日志:
bash
子线程(tid: 140481622124096)收到[127.0.0.1:59292]的数据(长度:6):123456
子线程(tid: 140481622124096)向[127.0.0.1:59292]发送响应(长度:37):!无效的请求格式
我这里并没有写其他业务肯定是无效的请求啦。
步骤4:模拟登录失败的场景
客户端登录一个不存在的客户:

服务器日志:

六、手写序列化的优缺点分析
6.1 优点
- 轻量无依赖:无需引入第三方库(如 JSON 解析器、Protobuf 编译器),适合嵌入式或轻量级场景。
- 性能可控:可根据业务需求优化序列化逻辑(如紧凑存储),减少冗余数据。
- 灵活性高:可自定义协议格式,适配特殊场景(如固定长度字段、加密传输)。
6.2 缺点
- 开发效率低 :需手动处理字段解析、字节序转换、边界校验,代码量大且重复(如
LoginRequest和LoginResponse的序列化逻辑高度相似)。 - 易出错 :字节序转换(
htonl/ntohl)、缓冲区越界等问题容易导致隐蔽 bug。 - 扩展性差:新增字段需修改序列化 / 反序列化代码,且需确保客户端和服务器版本同步,否则会解析失败。
- 可读性差:二进制格式难以调试,需手动解析十六进制数据。
七、业务层的抽象设计思路
当前代码中,服务器的业务逻辑(登录验证)与网络框架耦合在 LoginDataHandler中。当需要扩展新业务(如注册、修改密码)时,需要修改服务器核心代码,不利于维护。
改进方案:抽象业务基类,通过多态实现业务扩展:
-
定义抽象业务基类
BusinessBase:cppclass BusinessBase { public: virtual ~BusinessBase() = default; // 纯虚函数:处理请求并返回响应 virtual std::string Handle(const std::string& req_data) = 0; // 获取业务类型(用于路由) virtual int GetType() const = 0; }; -
派生具体业务类(如登录、注册):
cppclass LoginBusiness : public BusinessBase { public: std::string Handle(const std::string& req_data) override { // 登录处理逻辑(复用原LoginDataHandler代码) } int GetType() const override { return 1; } // 登录业务类型为1 }; class RegisterBusiness : public BusinessBase { public: std::string Handle(const std::string& req_data) override { // 注册处理逻辑 } int GetType() const override { return 2; } // 注册业务类型为2 };- 服务器通过业务工厂类路由请求:
cppclass BusinessFactory { public: static std::unique_ptr<BusinessBase> Create(int type) { switch (type) { case 1: return std::make_unique<LoginBusiness>(); case 2: return std::make_unique<RegisterBusiness>(); default: return nullptr; } } };
八、总结
本文通过补充测试案例验证了手写序列化在登录场景的可行性,并分析了其优缺点。对于简单场景,手写序列化可满足需求;但随着业务扩展,建议使用 JSON(可读性优先)或 Protobuf(性能优先)等成熟方案。
后续可进一步优化的方向:
- 引入
Protobuf替代手写序列化,提升开发效率和兼容性。 - 完善业务层抽象(如前文提到的
BusinessBase基类),支持多业务并行处理。 - 增加数据校验和加密,提升通信安全性。