5-3 网络编程—编写服务端与客户端demo
1. 编写简易客户端与服务端程序
1.1 服务端构建流程
如上图所示,使用Socket API建立简易的TCP服务端过程为:
- 建立一个socket()
- 绑定接受客户端连接的端口 bind()
- 监听网络端口 listen()
- 等待接受客户端连接 accept()
- 向客户端发送一条数据 send()
- 接收客户端发送的数据 recv()
- 关闭socket()
1.2 服务端源码
//
// Created by Evila on 2021/6/27.
//
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include<iostream>
using namespace std;
int main(int argc, char** argv)
{
// 1. 建立一个Socket
// 参数 ipv4 面向字节流的 tcp协议
int _sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if( _sock < 0)
{
cout << "create socket error: " << strerror(errno) << "%s(errno: " << errno << endl;
exit(0);
}
// 2. 绑定端口45678
sockaddr_in _sin = {}; // sockaddr_in为网络地址的结构体
_sin.sin_family = AF_INET; // 设置协议类型
int _port = 45678;
_sin.sin_port = htons(_port); // 设置协议源端口号
// 计算机数据表示存在两种字节顺序:
// 网络字节顺序(Network Byte Order, NBO)与主机字节顺序(Host Byte Order, HBO)
// NBO是大端模式(big-endian),也就是整数的高位字节存放在内存的低地址处
// 在网络上使用统一的网络字节顺序,可以避免兼容性问题
// 而主机字节序与CPU或操作系统相关, 无法统一; 因此使用htons()将主机字节序转换成网络字节序
_sin.sin_addr.s_addr = htonl(INADDR_ANY); // 协议源地址 随机ip
if (bind(_sock, (sockaddr*)&_sin, sizeof(_sin)) == -1) //sockaddr 不利于编码
{
cout << "ERROR: 绑定用于接受客户端连接的网络端口失败..." << endl;
exit(0);
}
else
{
cout << "SUCCESS: 绑定端口" << _port << "成功..." << endl;
}
// 3. 监听网络端口 listen
if (listen(_sock, 5) == -1) // 第二个参数 backbag 为连接为完成队列长度
{
cout << "ERROR: 监听用于接受客户端连接的网络端口失败..." << endl;
exit(0);
}
else
{
cout << "SUCCESS: 监听端口成功..." << endl;
}
// 4. 等待接受客户端连接 accept
sockaddr_in _clientAddr = {};
int cliendAddrLen = sizeof(_clientAddr);
int _clientSock = -1; // 初始化无效的socket 用来存储接入的客户端
char msgBuf[] = "Hello, I'm Server";
char recvBuff[2048];
// 这里为了方便测试 只接受10次连接就关闭
int n = 10;
while (n--)
{
// 当客户端接入时 accept函数会得到客户端的socket地址和长度
_clientSock = accept(_sock, (sockaddr*)&_clientAddr, (socklen_t *)&cliendAddrLen);
if (-1 == _clientSock) //接受到无效接入
{
cout << "ERROR: 接受到无效客户端SOCKET..." << endl;
continue;
}
else
{
//inet_ntoa 将ip地址转换成可读的字符串
cout << "新Client加入: IP = " << inet_ntoa(_clientAddr.sin_addr) << endl;
// 5. 向客户端发送数据 send()
send(_clientSock, msgBuf, strlen(msgBuf) + 1, 0); // +1是为了把\0算进去
}
int recvLen = recv(_clientSock, recvBuff, 2048, 0);
recvBuff[recvLen] = '\0'; // 设置字符串结束符
cout << "recv msg from client: " << recvBuff << endl;
}
// 7. 关闭socket
close(_sock);
return 0;
}1.3 编译并运行
使用g++命令编译并生成可执行文件server,执行后该进程阻塞在accept()函数处。
1.4 客户端源码
//
// Created by Evila on 2021/6/27.
//
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <string>
using namespace std;
int main(int argc, char** argv)
{
// 1. 建立一个Socket
int _sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (_sock < 0)
{
cout << "create socket error: " << strerror(errno) << "%s(errno: " << errno << endl;
exit(0);
}
// 2. 设置请求连接的server 地址
sockaddr_in _sevrAddr = {}; // sockaddr_in为网络地址的结构体
_sevrAddr.sin_family = AF_INET;
int port = 45678;
_sevrAddr.sin_port = htons(port);
string server_ip;
cout << "please input server ip: ";
cin >> server_ip;
if (inet_pton(AF_INET, server_ip.c_str(), &_sevrAddr.sin_addr) <= 0)
{
cout <<"inet_pton error for " << server_ip << endl;
exit(0);
}
// 3. 连接服务端
int ret = connect(_sock, (struct sockaddr*)&_sevrAddr, sizeof(_sevrAddr));
if (ret < 0)
{
cout << "connect error: " << strerror(errno) << "errno: " << errno << endl;
exit(0);
}
else
{
cout << "connect to server: " << server_ip << ":" << port << " success!" << endl;
}
// 4. 向服务端发送数据
string sendData;
cout << "please input send data: ";
getchar();
getline(cin, sendData);
if(send(_sock, sendData.c_str(), sendData.length(), 0) < 0)
{
cout << "send msg error: " << strerror(errno) << "errno: " << errno << endl;
exit(0);
}
close(_sock);
exit(0);
}1.5 编译并运行
首先编译客户端代码,并运行可执行文件:
输入服务端的ip地址为: 127.0.0.1,在成功建立连接后,向服务端发送数据。
此时服务端进程也打印了相关日志:
2. 网络粘包
在上一章中介绍了,tcp连接建立后,send()和recv()函数会使用两个内核空间,也叫做发送/接收缓冲区;recv()/send()实际上并不立即向网络中传输数据,而是先将数据写入缓冲区中,再由TCP协议将数据从缓冲区发送到目标机器。一旦将数据写入到缓冲区,函数就可以成功返回,不管它们有没有到达目标机器,也不管它们何时被发送到网络,这些都是TCP协议负责的事情。
因此,当我们调用send()发送数据时,数据有可能刚被写入缓冲区就发送到网络,也可能在缓冲区中不断积压,多次写入的数据被一次性发送到网络,这取决于当时的网络情况、当前线程是否空闲等诸多因素,不由程序员控制。
以下三种现象都是粘包:
- 发送端: 发送的数据量小,并且间隔短。
- 接收端:一次性读取了两次数据的内容。
- 接收端:没有接收完整报文内容或接收的内容另一个报文粘在一起。
粘包问题本质是因为TCP是字节流协议,原始数据之间是没有边界,接收方不知道消息之间的界限所造成的。
- 接收端:没有接收完整报文内容或接收的内容另一个报文粘在一起。
2.1 粘包解决方案——发送和接收定义传输报文固定长度
发送方和接收方设置每个完整报文都有固定长度,该方案缺点较为明显:
- 若报文长度设置较大,则可能会造成浪费;即通信报文实际只有小部分有效数据。
- 若报文长度设置较小,则会产生大量通信报文;降低通信效率。
2.2 粘包解决方案——消息包之间定义明确结束标志
发送方和接收方之间约定,每个完整的通信报文都有特有的结束标志,例如'\n','$'等。
2.3 粘包解决方案——定义报文头部(头部包含整个报文长度)
本节完整代码可访问:链接:https://share.weiyun.com/sXC5irhh 密码:f74ygi
2.3.1 定义消息头部
// 数据类型枚举
enum MsgType {
Login = 0,
Logout = 1,
others = 2,
};
//消息头
class MsgHeader
{
public:
MsgHeader() {
_msg_length = sizeof(MsgHeader);
_msg_type = others;
}
short _msg_length; // 数据长度 最大支持32767字节
MsgType _msg_type; // 消息类型
};
/**
* 定义登陆消息 继承于消息头
* 接受两个参数 用户名和密码
*/
class LoginMsg : public MsgHeader {
public:
LoginMsg(string username, string passwd) {
_msg_type = Login;
username = _user_name;
passwd = _passwd;
}
~LoginMsg() {}
public:
string _user_name;
string _passwd;
};2.3.2 封装数据发送函数
//发送数据
void TcpSocket::SendData(int client_sock, MsgHeader* header)
{
//要发送的数据长度
int nSendlen = header->_msg_length;
//要发送的消息 header指向的是消息对象 它继承于MsgHeader
const char* pSendData = (const char*)header;
while (true)
{
if (_lastSendPos + nSendlen >= RECV_BUFF_SIZE)
{
//若要发送的数据 大于当前发送缓冲区能容纳的长度 则计算可以拷贝的数据长度
int nCpyLen = RECV_BUFF_SIZE - _lastSendPos;
//将这部分数据先拷贝到发送缓冲区
memcpy(_szSendBuf + _lastSendPos, pSendData, nCpyLen);
//剩余数据位置 需要发送的数据进行偏移
pSendData += nCpyLen;
//剩余未发送的数据长度
nSendlen -= nCpyLen;
// 将发送缓冲区一次性全部发送
send(client_sock, _szSendBuf, RECV_BUFF_SIZE, 0);
// 尾部偏移归0 目的是为了循环发送未发送完的包
_lastSendPos = 0;
}
else
{
// 若要发送的数据 小于当前发送缓冲区能容纳的长度 则直接将该数据写入到发送缓冲区中
memcpy(_szSendBuf + _lastSendPos, pSendData, nSendlen);
// 发送缓冲区尾部偏移向后移动
_lastSendPos += nSendlen;
nSendlen = 0;
// 退出循环
break;
}
}
}2.3.3 封装数据接收函数:
// 封装recv方法 按照包头包体的方式接受网络数据包
void TcpSocket::RecvData() {
// 读取消息 全部读取到接收缓冲区中
int nlen = recv(_sock, _szRecvBuf, RECV_BUFF_SIZE, 0);
cout << "nlen = " << nlen << endl; //打印出接收到的数据长度
if (nlen <= 0)
{
//客户端退出
cout << "客户端:Socket = " << _sock << " 与服务器断开连接,任务结束" << endl;
}
char msg[RECV_BUFF_SIZE];
//接收缓冲区尾部偏移量
_lastRecvPos += nlen;
//当前接收的消息长度大于数据头部长度时 表明已经有一个消息头接受到 循环处理粘包
while (_lastRecvPos >= sizeof(MsgHeader))
{
MsgHeader* header = (MsgHeader*)_szRecvBuf; //取出包头信息
if (_lastRecvPos >= header->_msg_length)
{
//若当前尾部偏移量大于这个包的长度 则处理剩余缓冲区数据的长度
int nSize = header->_msg_length;
// 处理该消息
OnNetMsg(header);
//将剩余消息 前移方便下一次处理
memcpy(_szRecvBuf, _szRecvBuf + nSize, _lastRecvPos - nSize);
//尾部移动
_lastRecvPos = _lastRecvPos - nSize;
}
else
{
//剩余数据不够一条完整消息时 退出循环
break;
}
}
}3. 使用epoll管理网络I/O
在5-1文章中介绍了epoll的概念、用法以及相对select、poll的区别,相信大家已经对epoll有一定的了解,这里我们继续改造将epoll引入到server代码中,进行高效的管理客户端的连接以及网络数据I/O。
我们在main函数中做如下改造,使得server进程在无限循环中只关注epoll相关的事件,当有事件响应时再去处理。
int RegisterSock(TcpSocket *pSocket, int event, int epoll_fd)
{
// epoll event结构体
struct epoll_event ev = { 0 };
ev.data.ptr = pSocket;
// 装载socket的关注事件
ev.events = event;
return epoll_ctl(epoll_fd, EPOLL_CTL_ADD, pSocket->GetSock(), &ev );
}TcpSocket listen_socket;
// 1. 初始化socket
listen_socket.InitSock();
// 2. 绑定地址
listen_socket.BindAddress(45678);
// 3. 监听端口
listen_socket.Listen();
// 4. 创建epoll对象
int epoll_fd = epoll_create(0xCAFE);
// 5. 向epoll注册listen sock的in和out事件
int ret = RegisterSock(&listen_socket, EPOLLIN | EPOLLOUT, epoll_fd);
// 无限循环 epoll_wait等待事件响应
while(true)
{
// 等待10秒,检索少于20个epoll event并将它们存储到epoll event数组中
int ret = epoll_wait(epoll_fd, pevents, 20, 1000);
// 检查epoll的执行结果
if ( ret == -1 )
{
// 上报错误和异常
}
else if ( ret == 0 )
{
// 超时 没有任何事件响应
continue;
}
else
{
// 检查关注的事件
for ( int i = 0; i < ret; i++ )
{
// pevents为有事件响应的集合,我们在epoll_ctl注册时,
// epoll_data字段使用的是TcpSocket结构体指针,因此我们可以通过该指针获取到响应的socket
if ( pevents[i].events & EPOLLIN )
{
TcpSocket* pTcpSocket = (TcpSocket*) pevents[i].data.ptr;
if (pTcpSocket->GetSock() == listen_socket.GetSock())
{
// 如果服务端的listen socket响应 EPOLLIN 表明有客户端连接
// 调用Accept()接受连接
int client_sock = pTcpSocket->Accept();
client_queue.push(TcpSocket(client_sock));
// 将新增的客户端连接注册到epoll
RegisterSock(&(client_queue.front()), EPOLLIN | EPOLLOUT, epoll_fd);
}
else
{
// 客户端socket响应 处理响应消息
pTcpSocket->RecvData();
}
}
}
}
}