一、TCP网络程序
1.1 服务端初始化
1.1.1 创建套接字
TCP服务器在调用socket函数创建套接字时,参数设置如下:
- 协议家族选择AF_INET,进行网络通信
- 创建套接字时所需的服务类型应该是SOCK_STREAM,因为编写的是TCP服务器,SOCK_STREAM提供的就是一个有序的、可靠的、全双工的、基于连接的流式服务
- 协议类型默认设置为0即可
若创建套接字后获得的文件描述符是小于0,则套接字创建失败,此时就没必要进行后续操作,直接终止程序即可
class TcpServer { public: void InitSrever(); ~TcpServer(); private: int _socket_fd; }; void TcpServer::InitSrever() { //创建套接字 _socket_fd = socket(AF_INET, SOCK_STREAM, 0); if(_socket_fd < 0) { cerr << "socket fail" << endl; exit(1); } cout << "socket success" << endl; } TcpServer::~TcpServer() { if(_socket_fd >= 0) close(_socket_fd);
- TCP服务器创建套接字的做法与UDP服务器基本一致,不过创建套接字时TCP使用的是流式服务,而UDP使用的是用户数据报服务
- 当析构服务器时,可将服务器对应的文件描述符进行关闭
1.1.2 服务端绑定
套接字创建完毕后只是在系统层面上打开了一个文件,该文件并没有与网络关联,因此创建完套接字后还需要调用bind函数进行绑定操作
绑定的步骤如下:
- 定义struct sockaddr_in结构体变量,将服务器网络相关的属性信息填充到该变量中,如协议家族、IP地址、端口号等
- 填充服务器网络相关的属性信息时,协议家族对应就是AF_INET,端口号就是当前TCP服务器程序的端口号。在设置端口号时,需要调用htons()函数将端口号由主机序列转为网络序列
- 在设置服务器的IP地址时,可以设置为本地环回127.0.0.1,表示本地通信。也可以设置为公网IP地址,表示网络通信
- 若使用的是云服务器,那么在设置服务器的IP地址时,不需要绑定固定IP地址,直接将IP地址设置为INADDR_ANY即可,此时服务器可以从本地任何一张网卡中读取数据。INADDR_ANY本质是0,因此在设置时不需要进行网络字节序的转换
- 填充完服务器网络相关的属性信息后,调用bind函数进行绑定。绑定实际就是将文件与网络关联,若绑定失败没必要进行后续操作,直接终止程序即可
TCP服务器初始化时需要服务器的端口号,因此在服务器类中需要引入端口号,当实例化服务器对象时就需传入一个端口号。而由于当前使用的是云服务器,因此在绑定TCP服务器的IP地址时不绑定公网IP地址,直接绑定INADDR_ANY即可,因此下面代码中没有在服务器类中引入IP地址
class TcpServer { public: TcpServer(uint16_t port):_socket_fd(-1),_server_port(port) {} void InitSrever(); ~TcpServer(); private: int _socket_fd; uint16_t _server_port; }; void TcpServer::InitSrever() { //创建套接字 _socket_fd = socket(AF_INET, SOCK_STREAM, 0); if(_socket_fd < 0) { cerr << "socket fail" << endl; exit(1); } cout << "socket success" << endl; //绑定 struct sockaddr_in local; memset(&local, '\0', sizeof local); local.sin_family = AF_INET; local.sin_port = htons(_server_port); local.sin_addr.s_addr = INADDR_ANY; if(bind(_socket_fd, (struct sockaddr*)&local, sizeof local) < 0) { cerr << "bind fail" << endl; exit(2); } cout << "bind success" << endl;
1.1.3 服务端监听
UDP服务器的初始化操作只有两步,创建套接字和绑定。而TCP服务器是面向连接的,客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信
因此TCP服务器需要时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态
int listen(int sockfd, int backlog);
- sockfd:需要设置为监听状态的套接字对应的文件描述符
- backlog:全连接队列的最大长度。若有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或10即可
返回值:监听成功返回0,监听失败返回-1,同时errno被设置
服务器监听代码实现
若监听失败没必要进行后续操作,因为监听失败意味着TCP服务器无法接收客户端发来的连接请求,直接终止程序即可
void TcpServer::InitSrever() { //创建套接字 _socket_fd = socket(AF_INET, SOCK_STREAM, 0); if(_socket_fd < 0) { cerr << "socket fail" << endl; exit(1); } cout << "socket success" << endl; //绑定 struct sockaddr_in local; memset(&local, '\0', sizeof local); local.sin_family = AF_INET; local.sin_port = htons(_server_port); local.sin_addr.s_addr = INADDR_ANY; if(bind(_socket_fd, (struct sockaddr*)&local, sizeof local) < 0) { cerr << "bind fail" << endl; exit(2); } cout << "bind success" << endl; //设置服务器监听状态 if(listen(_socket_fd, BACKLOG) < 0) { cerr << "listen fail" << endl; exit(3); } cout << "listen success" << endl; } class TcpServer { public: TcpServer(uint16_t port):_socket_listen_fd(-1),_server_port(port) {} void InitSrever(); ~TcpServer(); private: int _socket_listen_fd; uint16_t _server_port; }
- 初始化TCP服务器时创建的套接字并不是普通的套接字,而应该被称为监听套接字。为了表明寓意,将代码中套接字的名字由_socket_fd改为_socket_listen_fd
- 初始化TCP服务器时,只有创建套接字成功、绑定成功、监听成功,此时TCP服务器的初始化才算完成
1.2 服务端启动
1.2.1 服务端获取连接
TCP服务器初始化后即可开始运行,但TCP服务端在与客户端网络通信前,服务端需先获取到客户端的连接请求
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- sockfd:特定的监听套接字,表示从该监听套接字中获取连接
- addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等,输出型参数
- addrlen:调用时传入期望读取的addr结构体的长度,返回时代表实际读取到的addr结构体的长度,这是一个输入输出型参数
返回值:获取连接成功则返回接收到的套接字的文件描述符,失败返回-1,同时错误码被设置
accept函数返回的套接字是什么?
调用accept函数获取连接时,是从监听套接字中获取的。若accept函数获取连接成功,此时会返回接收到的套接字对应的文件描述符
监听套接字与accept函数返回的套接字的作用:
- 监听套接字:用于获取客户端发来的连接请求。accept函数会不断从监听套接字中获取新连接
- accept函数返回的套接字:用于为本次accept获取到的连接提供服务。监听套接字的任务只是不断获取新连接,而真正为这些连接提供服务的套接字是accept函数返回的套接字
服务端获取连接代码实现
- accept函数获取连接时可能会失败,但TCP服务器不会因为获取某个连接失败而退出,因此服务端获取连接失败后应继续获取连接
- 若要将获取到的连接对应客户端的IP地址和端口号信息进行输出,需要调用inet_ntoa函数将整数IP转换成字符串IP(转为主机序列),调用ntohs函数将端口号由网络序列转换成主机序列
- inet_ntoa函数在底层实际做了两个工作,一是将网络序列转换成主机序列,二是将主机序列的整数IP转换成字符串风格的点分十进制的IP
void TcpServer::StartUp() { //获取连接 while(true) { struct sockaddr_in foreign; memset(&foreign, '\0', sizeof foreign); socklen_t length = sizeof foreign; int server_socket_fd = accept(_socket_listen_fd, (struct sockaddr*)&foreign, &length); if(server_socket_fd < 0) { cerr << "accept fail" << endl; continue; } string client_ip = inet_ntoa(foreign.sin_addr); uint16_t client_port = ntohs(foreign.sin_port); cout << "New Link: [" << server_socket_fd << "] [" << client_ip << "] [" << client_port << "]" << endl; }
1.2.2 服务端处理请求
TCP服务器已能够获取连接请求,接下来要对获取到的连接进行处理。为客户端提供服务的不是监听套接字,因为监听套接字获取到一个连接后会继续获取下一个请求连接,为对应客户端提供服务的套接字实际是accept函数返回的套接字,下面就将其称为"服务套接字"
为了让通信双方都能看到对应的现象,下面实现一个回声TCP服务器,服务端在为客户端提供服务时将客户端发来的数据进行输出,并且将客户端发来的数据重新发回给客户端即可。当客户端拿到服务端的响应数据后将该数据进行打印输出,此时就能确保服务端和客户端能够正常通信了
read函数
ssize_t read(int fd, void *buf, size_t count);
- fd:特定的文件描述符,表示从该文件描述符中读取数据
- buf:数据的存储位置,表示将读取到的数据存储到该位置
- count:数据的个数,表示从该文件描述符中读取数据的字节数
- 若返回值大于0,则表示本次实际读取到的字节数
- 若返回值等于0,则表示对端已将连接关闭
- 若返回值小于0,则表示读取时出现错误
若客户端将连接关闭了,那么此时服务端将套接字中的信息读完后就会读取到0,因此若服务端调用read函数后的返回值为0,此时服务端就不必再为该客户端提供服务了
write函数
ssize_t write(int fd, const void *buf, size_t count);
- fd:特定的文件描述符,表示将数据写入该文件描述符对应的套接字
- buf:需要写入的数据
- count:需要写入数据的字节个数
写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置
当服务端调用read函数收到客户端的数据后,就可以再调用write函数将该数据再响应给客户端
服务端处理请求代码实现
服务端读取数据是服务套接字中读取的,写入数据的时候也是写入进服务套接字。服务套接字既可以读取数据也可以写入数据,这就是TCP全双工的通信的体现
在从服务套接字中读取客户端发来的数据时,若调用read函数后得到的返回值为0,或者读取出错了,此时就应该直接将服务套接字对应的文件描述符关闭。因为文件描述符本质就是数组的下标,因此文件描述符的资源是有限的,若一直占用,那么可用的文件描述符就会越来越少,因此服务完客户端后要及时关闭对应的文件描述符,否则会导致文件描述符泄漏
void TcpServer::StartUp() { while(true) { //获取连接 struct sockaddr_in foreign; memset(&foreign, '\0', sizeof foreign); socklen_t length = sizeof foreign; int server_socket_fd = accept(_socket_listen_fd, (struct sockaddr*)&foreign, &length); if(server_socket_fd < 0) { cerr << "accept fail" << endl; continue; } string client_ip = inet_ntoa(foreign.sin_addr); uint16_t client_port = ntohs(foreign.sin_port); cout << "New Link: [" << server_socket_fd << "] [" << client_ip << "] [" << client_port << "]" << endl; //处理客户端请求 Service(server_socket_fd, client_ip, client_port); } } void TcpServer::Service(int server_socket_fd, string client_ip, uint16_t client_port) { char buffer[BUFF_SIZE]; while(true) { ssize_t size = read(server_socket_fd, buffer, sizeof(buffer) - 1); if(size > 0) //读取成功 { buffer[size] = '\0'; cout << buffer << endl; write(server_socket_fd, buffer, size); } else if(size == 0) //对端关闭连接 { cout << client_ip << ":" << client_port << " close" << endl; break; } else //读取失败 { cerr << server_socket_fd << " read error" << endl; break; } } close(server_socket_fd); cout << client_ip << ":" << client_port << "server done" << endl;
1.3 客户端初始化
创建套接字
客户端不需要进行绑定和监听:
- 服务端要进行绑定是因为服务端的IP地址和端口号不能随意改变。而客户端虽然也需要IP地址和端口号,但是客户端并不需要程序员手动进行绑定操作,客户端连接服务端时系统会自动指定一个端口号给客户端
- 服务端需要进行监听是因为服务端需要通过监听来获取新连接,但是不会有人主动连接客户端,因此客户端是不需要进行监听操作的
客户端必须要知道要连接的服务端的IP地址和端口号,因此客户端除了要有自己的套接字之外,还需要知道服务端的IP地址和端口号,这样客户端才能够通过套接字向指定服务器进行通信
class TcpClient { public: TcpClient(string ip, uint16_t port):_socket_fd(-1),_server_ip(ip),_server_port(port) {} void InitClient(); ~TcpClient(); private: int _socket_fd; string _server_ip; uint16_t _server_port; }; void TcpClient::InitClient() { //创建套接字 _socket_fd = socket(AF_INET, SOCK_STREAM, 0); if(_socket_fd < 0) { cerr << "socket fail" << endl; exit(1); } } TcpClient::~TcpClient() { if(_socket_fd >= 0) close(_socket_fd);
1.4 客户端启动
1.4.1 发起连接
客户端不需要绑定,也不需要监听,客户端创建完套接字后可直接向服务端发起连接请求
connect函数
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- sockfd:特定的套接字,表示通过该套接字发起连接请求
- addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等
- addrlen:传入的addr结构体的长度
返回值:连接或绑定成功返回0,连接失败返回-1,同时错误码会被设置
客户端连接服务器代码
客户端不是不需要进行绑定,而是不需要程序员手动进行绑定操作,当客户端向服务端发起连接请求时,系统会给客户端随机指定一个空闲端口号进行绑定。因为通信双方都必须要有IP地址和端口号,否则无法唯一标识通信双方。若connect函数调用成功了,客户端本地会随机给该客户端绑定一个端口号发送给对端服务器
调用connect函数向服务端发起连接请求时,需要传入服务端对应的网络信息,否则connect函数也不知道该客户端到底是要向哪一个服务端发起连接请求
void TcpClient::StartUp() { //发起连接 struct sockaddr_in server; memset(&server, '\0', sizeof server); server.sin_family = AF_INET; server.sin_addr.s_addr = inet_addr(_server_ip.c_str()); server.sin_port = htons(_server_port); if(connect(_socket_fd, (struct sockaddr*)&server, sizeof(server)) == 0) { cout << "connect success" << endl; Request(); //发起请求 } else { cerr << "connect fail" << endl; exit(2); } }
1.4.2 发起请求
当客户端连接到服务端后,客户端就可以向服务端发送数据了,可以让客户端将用户输入的数据发送给服务端,发送时调用send函数向套接字当中写入数据即可
当客户端将数据发送给服务端后,由于服务端读取到数据后还会进行回显,因此客户端在发送数据后还需要调用recv函数读取服务端的响应数据,然后将该响应数据进行打印,以确定双方通信无误
void TcpClient::Request() { char buffer[BUFF_SIZE]; string message; while(true) { cout << "Pleses Entre#"; getline(cin, message); send(_socket_fd, message.c_str(), message.size(), 0); ssize_t size = recv(_socket_fd, buffer, sizeof(buffer) - 1, 0); if (size > 0){ buffer[size] = '\0'; cout << "server echo# " << buffer << endl; } else if (size == 0) { cout << "server close!" << endl; break; } else { cerr << "read error!" << endl; break; } } }
通过服务端的IP地址和端口号即可构造出一个客户端对象
void Usage(std::string proc) { cout << "Usage: " << proc << "server_ip server_port" << endl; } int main(int argc, char* argv[]) { if (argc != 3) { Usage(argv[0]); exit(1); } string server_ip = argv[1]; int server_port = atoi(argv[2]); TcpClient* client = new TcpClient(server_ip, server_port); client->InitClient(); client->StartUp(); return 0; }
1.5 网络测试
服务端和客户端均已编写完毕,下面进行网络测试。测试时先启动服务端,然后使用 netstat 命令进行查看,此时能看到 ./server 服务进程,该进程当前处于监听状态
然后再通过 ./client IP号 端口号 的形式运行客户端,此时客户端就会向服务端发起连接请求,服务端获取到请求后就会为该客户端提供服务
当客户端向服务端发送消息后,服务端可以通过打印的IP地址和端口号识别出对应的客户端,而客户端也可以通过服务端响应回来的消息来判断服务端是否收到了自己发送的消息
若此时客户端退出了,那么服务端调用read函数时返回值就为0,此时服务端就知道客户端退出了,进而会终止对该客户端的服务
此时服务端对该客户端的服务终止了,但服务器并没有终止,依旧在运行,等待下一个客户端的连接请求
1.6 单执行流服务端的弊端
当仅用一个客户端连接服务端时,该客户端能够正常享受到服务端的服务
但在这个客户端正在享受服务端的服务时,另一个客户端也连接服务器,此时虽然在客户端显示连接是成功的,服务端并没有显示有新的连接,并且这个客户端发送给服务端的消息既没有在服务端进行打印,服务端也没有将该数据回显给该客户端
第一个客户端退出后,服务端才会将第二个客户端发来的数据进行打印,并回显给第二个客户端
单执行流的服务端
通过上图可以看出,服务端只有服务完一个客户端后才会服务另一个客户端。因为目前所写的是一个单执行流版的服务器
当服务端调用accept函数获取到连接后就给该客户端提供服务,但在服务端提供服务期间可能会有其他客户端发起连接请求,但服务完当前客户端后才会accept下一个客户端的连接请求,导致服务端一次只能为一个客户端提供服务
客户端为什么会显示连接成功?
当服务端在给第一个客户端提供服务期间,第二个客户端向服务端发起的连接请求时是成功的,只不过服务端没有调用accept函数将该连接获取
实际在底层会维护一个连接队列,服务端没有accept的新连接就会放到这个连接队列中,而这个连接队列的最大长度就是通过listen函数的第二个参数来指定的,因此服务端虽然没有获取第二个客户端发来的连接请求,但是在第二个客户端那里显示是连接成功的
解决方案
单执行流的服务器一次只能给一个客户端提供服务,此时服务器的资源并没有得到充分利用,因此服务端一般是不会编写成单执行流的。要解决这个问题就需要将服务器改为多执行流的,此时就要引入多进程或多线程