4、TCP回显服务器实战:设计回显协议、实现服务器端、实现客户端、测试与调试。
好,咱们今天来干点实际的。前面讲了那么多TCP的理论,什么三次握手、四次挥手,说实话,光看概念容易犯困。我个人习惯是,学网络编程,一定要亲手写一个能跑起来的例子。今天这个回显服务器,就是最好的入门实战。
什么叫回显?说白了,就是客户端发什么,服务器就原封不动地给你弹回来。你发个"Hello",它回你"Hello"。听起来简单吧?但你别小看它,这里面包含了TCP通信的完整流程:连接、收发数据、断开连接。我当年刚入行时,第一个网络程序就是写这个。
核心目标: 实现一个完整的TCP回显服务器,包含自定义协议、服务端、客户端,并完成联调测试。
4.1 设计回显协议
写网络程序,第一步不是敲代码,而是定协议。你想想看,如果客户端和服务器各说各的,那不乱套了?
回显协议很简单,但咱们也得把它说清楚。我设计了一个轻量级的协议格式:
| 字段 | 长度 | 说明 |
|---|---|---|
| 起始标志 | 2字节 | 固定为 0xAA 0x55,用于帧头识别 |
| 数据长度 | 2字节 | 大端序,表示后续数据的字节数 |
| 数据内容 | N字节 | 实际要回显的文本或二进制数据 |
| 校验和 | 1字节 | 对前面所有字节进行异或校验 |
嗯,这里要注意。为什么加起始标志?因为TCP是流式协议,数据没有边界。你发两次数据,接收方可能一次就收完了,也可能分好几次收。起始标志就是帮我们找到每一帧的起点。我在项目中遇到过,不加帧头,结果数据粘包后解析全乱套了。
校验和也很重要。虽然TCP本身有校验,但应用层再加一道保险,能防止一些奇奇怪怪的问题。我曾经调试一个设备,网络环境差,数据偶尔会错一个字节,加了校验和之后,问题立刻定位到了。
4.2 实现服务器端
服务器端用QTcpServer监听,有客户端连接时,创建一个QTcpSocket来处理。核心逻辑都在槽函数里。
先看代码:
// EchoServer.h
class EchoServer : public QObject {
Q_OBJECT
public:
explicit EchoServer(QObject *parent = nullptr);
bool start(quint16 port);
private slots:
void onNewConnection();
void onReadyRead();
void onClientDisconnected();
private:
QTcpServer *m_server;
QList<QTcpSocket*> m_clients;
bool parsePacket(QTcpSocket *socket);
void sendEcho(QTcpSocket *socket, const QByteArray &data);
};
// EchoServer.cpp
bool EchoServer::start(quint16 port) {
m_server = new QTcpServer(this);
if (!m_server->listen(QHostAddress::Any, port)) {
qWarning() << "服务器启动失败:" << m_server->errorString();
return false;
}
connect(m_server, &QTcpServer::newConnection, this, &EchoServer::onNewConnection);
qDebug() << "服务器已启动,监听端口:" << port;
return true;
}
void EchoServer::onNewConnection() {
QTcpSocket *client = m_server->nextPendingConnection();
m_clients.append(client);
connect(client, &QTcpSocket::readyRead, this, &EchoServer::onReadyRead);
connect(client, &QTcpSocket::disconnected, this, &EchoServer::onClientDisconnected);
qDebug() << "新客户端连接:" << client->peerAddress().toString();
}
void EchoServer::onReadyRead() {
QTcpSocket *socket = qobject_cast<QTcpSocket*>(sender());
if (!socket) return;
// 循环解析,因为可能一次收到多个包
while (socket->bytesAvailable() >= 5) { // 至少5字节:2帧头+2长度+1校验
if (!parsePacket(socket)) {
break; // 数据不够,等下一次
}
}
}
bool EchoServer::parsePacket(QTcpSocket *socket) {
// 检查起始标志
QByteArray header = socket->peek(2);
if (header[0] != (char)0xAA || header[1] != (char)0x55) {
qWarning() << "无效的起始标志,丢弃数据";
socket->read(1); // 逐字节丢弃,直到找到帧头
return true;
}
// 读取长度
QByteArray lenBytes = socket->peek(2);
// 跳过起始标志,从第3字节开始读长度
lenBytes = socket->peek(4).mid(2, 2);
quint16 dataLen = ((quint8)lenBytes[0] << 8) | (quint8)lenBytes[1];
// 检查是否收够了完整的一帧
int totalLen = 2 + 2 + dataLen + 1; // 帧头+长度+数据+校验
if (socket->bytesAvailable() < totalLen) {
return false; // 数据不够,等下一次
}
// 读取完整帧
QByteArray frame = socket->read(totalLen);
QByteArray data = frame.mid(4, dataLen);
quint8 checksum = frame[totalLen - 1];
// 校验
quint8 calcChecksum = 0;
for (int i = 0; i < totalLen - 1; ++i) {
calcChecksum ^= (quint8)frame[i];
}
if (calcChecksum != checksum) {
qWarning() << "校验和错误,丢弃数据";
return true;
}
// 回显
sendEcho(socket, data);
return true;
}
void EchoServer::sendEcho(QTcpSocket *socket, const QByteArray &data) {
QByteArray packet;
packet.append((char)0xAA);
packet.append((char)0x55);
quint16 len = data.size();
packet.append((len >> 8) & 0xFF);
packet.append(len & 0xFF);
packet.append(data);
quint8 checksum = 0;
for (int i = 0; i < packet.size(); ++i) {
checksum ^= (quint8)packet[i];
}
packet.append(checksum);
socket->write(packet);
}
void EchoServer::onClientDisconnected() {
QTcpSocket *socket = qobject_cast<QTcpSocket*>(sender());
if (socket) {
m_clients.removeOne(socket);
socket->deleteLater();
qDebug() << "客户端断开连接";
}
}
这段代码里,我用了peek来预览数据,而不是直接read。为什么?因为数据可能还没收全。你先看一眼,够了再取,不够就等着。这个技巧我在做高并发服务器时经常用,能避免很多麻烦。
小提示: 解析协议时,尽量用peek而不是read。等确认数据完整了,再一次性read出来。这样可以避免数据被部分读取后,下次处理时上下文丢失。
4.3 实现客户端
客户端相对简单。连接服务器,用户输入一行文本,客户端按协议打包发送,然后等待回显并显示。
// EchoClient.h
class EchoClient : public QObject {
Q_OBJECT
public:
explicit EchoClient(QObject *parent = nullptr);
void connectToServer(const QString &host, quint16 port);
void sendMessage(const QString &msg);
private slots:
void onConnected();
void onReadyRead();
void onError(QAbstractSocket::SocketError error);
private:
QTcpSocket *m_socket;
QByteArray m_buffer; // 用于缓存接收的数据
};
// EchoClient.cpp
void EchoClient::connectToServer(const QString &host, quint16 port) {
m_socket = new QTcpSocket(this);
connect(m_socket, &QTcpSocket::connected, this, &EchoClient::onConnected);
connect(m_socket, &QTcpSocket::readyRead, this, &EchoClient::onReadyRead);
connect(m_socket, &QTcpSocket::errorOccurred, this, &EchoClient::onError);
m_socket->connectToHost(host, port);
}
void EchoClient::sendMessage(const QString &msg) {
QByteArray data = msg.toUtf8();
// 按协议打包,和服务器端一样
QByteArray packet;
packet.append((char)0xAA);
packet.append((char)0x55);
quint16 len = data.size();
packet.append((len >> 8) & 0xFF);
packet.append(len & 0xFF);
packet.append(data);
quint8 checksum = 0;
for (int i = 0; i < packet.size(); ++i) {
checksum ^= (quint8)packet[i];
}
packet.append(checksum);
m_socket->write(packet);
}
void EchoClient::onReadyRead() {
m_buffer.append(m_socket->readAll());
// 解析协议,和服务器端类似
while (m_buffer.size() >= 5) {
// 检查帧头
if ((quint8)m_buffer[0] != 0xAA || (quint8)m_buffer[1] != 0x55) {
m_buffer.remove(0, 1);
continue;
}
quint16 dataLen = ((quint8)m_buffer[2] << 8) | (quint8)m_buffer[3];
int totalLen = 2 + 2 + dataLen + 1;
if (m_buffer.size() < totalLen) break;
QByteArray frame = m_buffer.left(totalLen);
m_buffer.remove(0, totalLen);
// 校验
quint8 calcChecksum = 0;
for (int i = 0; i < totalLen - 1; ++i) {
calcChecksum ^= (quint8)frame[i];
}
if (calcChecksum != (quint8)frame[totalLen - 1]) {
qWarning() << "客户端校验失败";
continue;
}
QByteArray data = frame.mid(4, dataLen);
qDebug() << "收到回显:" << QString::fromUtf8(data);
}
}
客户端这边,我用了m_buffer来缓存数据。因为readyRead信号可能多次触发,每次收到的数据不一定完整。把数据先攒起来,再统一解析,这是标准做法。
注意: 客户端的协议解析逻辑要和服务器端完全一致。我曾经犯过一个低级错误,服务器端用大端序,客户端用小端序,结果解析出来的长度完全不对。调试了一下午才发现。
4.4 测试与调试
代码写完了,怎么测?我一般分三步走:
- 启动服务器,看控制台输出"服务器已启动,监听端口:xxxx"。
- 启动客户端,连接服务器。服务器端会打印"新客户端连接"。
- 在客户端输入文本,看回显是否正确。
我建议你用两个终端窗口,一个跑服务器,一个跑客户端。这样看得清楚。
如果回显不对,先检查协议解析。我常用的调试手段是:在parsePacket和sendEcho里加qDebug,打印收到的原始字节和解析后的数据。你想想看,数据是二进制流,光看代码很难发现问题,但把字节打出来,一眼就能看出帧头对不对、长度对不对。
还有一个坑:防火墙。Windows和Linux的防火墙默认会拦截外部连接。如果你在局域网内测试,记得关掉防火墙,或者添加例外规则。我刚开始学的时候,服务器怎么都连不上,折腾了半天,结果是防火墙在作怪。
下面这张图,是我总结的整个回显流程,你一看就明白:
这张图把整个流程串起来了。客户端打包发送,服务器解析、回显,客户端再接收。你照着这个逻辑去调试,哪里断了就查哪里。
调试口诀: 先看连接通不通,再看协议对不对,最后看数据对不对。三步走,稳如狗。
好了,今天的实战就到这。代码你可以直接拿去用,但建议你亲手敲一遍。只有自己踩过坑,才能真正记住。我当年写这个程序,至少重写了三遍,每一遍都有新的收获。