作者:@小萌新
专栏:@网络
作者简介:大二学生 希望能和大家一起进步
本篇博客简介:简单介绍网络的基础概念

网络编程套接字(二)

  • 简单的TCP网络程序
    • 服务端创建套接字
    • 服务端绑定
    • 服务器监听
    • 服务端获取连接
    • 服务端处理请求
    • 客户端创建套接字
    • 客户端发起请求
    • 客户端发起请求
    • 服务器测试

简单的TCP网络程序

服务端创建套接字

我们使用一个类来封装服务端 当我们定义一个服务器对象之后马上就进行初始化 初始化TCP服务器第一时间就要创建套接字

我们在使用TCP服务的时候用socket函数创建套接字 参数设置如下

int socket(int domain, int type, int protocol);
  • 协议家族选择AF_INET 因为我们要进行的是网络通信
  • 创建套接字时所需的服务类型应该是SOCK_STREAM 因为我们编写的是TCP服务器 SOCK_STREAM提供的就是一个有序的、可靠的、全双工的、基于连接的流式服务
  • 协议类型默认设置为0即可 它会自动推导服务

如果创建套接字后获得的文件描述符是小于0的 说明套接字创建失败 此时也就没必要进行后续操作了 直接终止程序即可

class TcpSever{private:int _sockfd;public:void Init(){_sockfd = socket(AF_INET, SOCK_STREAM , 0);if (_sockfd < 0){cout << "socket error" << endl;exit(2);}}~TcpSever(){if (_sockfd > 0){ close(_sockfd);}}}; 

这里需要注意的是:

  • 我们创建TCP套接字和UDP套接字的做法是一样的 只不过创建TCP是流式服务 而UDP是数据报服务
  • 当我们析构服务器时 可以将服务器对应的文件描述符关闭

服务端绑定

套接字创建完毕之后我们实际上只是在系统层面上打开了一个文件 该文件还没有和网络关联起来 因此我们创建之后还需要使用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 TcpSever{private:int _sockfd;int _port;public:void Init(){_sockfd = socket(AF_INET, SOCK_STREAM , 0);if (_sockfd < 0){cout << "socket error" << endl;exit(2);}struct sockaddr_in local;memset(&local , 0 , sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(_port);local.sin_addr.s_addr = INADDR_ANY;if(bind(_sockfd , (struct sockaddr*)&local , sizeof(sockaddr)) < 0){cout << "bind error" << endl;exit(3);}}public:TcpSever(int port):_sockfd(-1),_port(port){}~TcpSever(){ if (_sockfd > 0){ close(_sockfd);}}}; 

在这之后我们的服务端绑定便完成了 此时我们会发现TCP和UDP的创建套接字和绑定步骤没有任何的区别

我们真正有区别的是下一步 服务器监听

服务器监听

因为TCP服务器是面向连接的 客户端在正式向TCP服务器发送数据之前需要建立连接

因此TCP服务器需要随时注意是否有客户端的连接请求 此时我们需要将状态设置为监听状态

listen函数

设置套接字为监听状态的函数叫做listen 该函数的函数原型如下:

int listen(int sockfd, int backlog);

返回值说明:

  • 监听成功返回0,监听失败返回-1,同时错误码会被设置。

参数说明:

  • sockfd:需要设置为监听状态的套接字对应的文件描述符。
  • backlog:全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或10即可。

服务器监听

我们在创建完套接字和绑定之后 需要再进一步将状态设置为监听状态 监听后续是否有新的连接 如果监听失败就意味着TCP无法接受服务器发送的请求了 此时服务器也没有了启动的意义 直接退出即可

void Init(){_sockfd = socket(AF_INET, SOCK_STREAM , 0);if (_sockfd < 0){cout << "socket error" << endl;exit(2);}struct sockaddr_in local;memset(&local , 0 , sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(_port);local.sin_addr.s_addr = INADDR_ANY;if(bind(_sockfd , (struct sockaddr*)&local , sizeof(sockaddr)) < 0){cout << "bind error" << endl;exit(3);}if(listen(_sockfd , 5) < 0){cout << "listen error" << endl;exit(4);}} 

我们在初始化TCP服务器的时候只有在创建套接字完毕绑定成功

服务端获取连接

TCP服务器初始化后就可以开始运行了,但TCP服务器在与客户端进行网络通信之前,服务器需要先获取到客户端的连接请求。

accept函数

获取连接的函数叫做accept,该函数的函数原型如下:

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

返回值说明:

  • 获取连接成功返回接收到的套接字的文件描述符,获取连接失败返回-1,同时错误码会被设置。

参数说明:

  • sockfd:特定的监听套接字,表示从该监听套接字中获取连接。
  • addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
  • addrlen:调用时传入期望读取的addr结构体的长度,返回时代表实际读取到的addr结构体的长度,这是一个输入输出型参数。

accept函数返回的套接字是什么?

调用accept函数获取连接时,是从监听套接字当中获取的。如果accept函数获取连接成功,此时会返回接收到的套接字对应的文件描述符。

监听套接字与accept函数返回的套接字的作用:

  • 监听套接字:用于获取客户端发来的连接请求。accept函数会不断从监听套接字当中获取新连接。
  • accept函数返回的套接字:用于为本次accept获取到的连接提供服务。监听套接字的任务只是不断获取新连接,而真正为这些连接提供服务的套接字是accept函数返回的套接字,而不是监听套接字。

服务端获取连接

服务端在获取连接时需要注意:

  • accept函数获取连接时可能会失败,但TCP服务器不会因为获取某个连接失败而退出,因此服务端获取连接失败后应该继续获取连接。
  • 如果要将获取到的连接对应客户端的IP地址和端口号信息进行输出,需要调用inet_ntoa函数将整数IP转换成字符串IP,调用ntohs函数将端口号由网络序列转换成主机序列。
  • inet_ntoa函数在底层实际做了两个工作,一是将网络序列转换成主机序列,二是将主机序列的整数IP转换成字符串风格的点分十进制的IP。
void Start(){for(;;){struct sockaddr_in peer;memset(&peer , 0 , sizeof(peer));socklen_t len = sizeof(peer);int sock = accept(_sockfd , (struct sockaddr*)&peer , &len);if (sock < 0){cout << "accept error" << endl;continue; // do not stop server }string client_ip = inet_ntoa(peer.sin_addr);int client_port = ntohs(peer.sin_port);cout << "get a new link " << sock <<"new port is " << client_port <<endl ;}} 

服务端接受连接测试

我们现在做个测试 看看当前服务器能够接受请求

我们在服务器运行的时候传入一个端口号作为我们的服务端口 服务端初始化之后启动

编译代码后 我们使用8082端口号初始化服务器

服务端运行之后我们可以通过netstat命令查看服务


它绑定的端口就是8082 而由于服务器绑定的是INADDR_ANY 因此该服务器的本地IP地址是0.0.0.0 这就意味着该TCP服务器可以读取本地任何一张网卡里面的数据

此时最重要的是服务器状态处于listen状态

虽然我们现在还没有编写客户端相关的代码 但是我们现在已经能登录这个服务器了

我们可以使用telnet指令来登录当前服务器 因为itelntt指令底层就是使用tcp实现的

我们发现此时分配的文件描述符是4 这是因为在运行一个C++程序的时候默认会打开0 1 2 文件输入流 文件输出流 文件错误流

而3号文件描述符在初始化时分配给了监视套接字 因此当一个客户端发起连接请求的时候 为该客户端提供服务的文件套接字就是4

服务端处理请求

现在TCP服务器已经能够获取连接请求了 下面当然就是要对获取到的连接进行处理

但此时为客户端提供服务的不是监听套接字 因为监听套接字获取到一个连接后会继续获取下一个请求连接 为对应客户端提供服务的套接字实际是accept函数返回的套接字 下面就将其称为“服务套接字”

为了让通信双方都能看到对应的现象 我们这里就实现一个简单的回声TCP服务器 服务端在为客户端提供服务时就简单的将客户端发来的数据进行输出 并且将客户端发来的数据重新发回给客户端即可

当客户端拿到服务端的响应数据后再将该数据进行打印输出 此时就能确保服务端和客户端能够正常通信了

read函数

TCP服务器读取数据的函数叫做read,该函数的函数原型如下:

ssize_t read(int fd, void *buf, size_t count);

返回值说明:

  • 如果返回值大于0,则表示本次实际读取到的字节个数。
  • 如果返回值等于0,则表示对端已经把连接关闭了。
  • 如果返回值小于0,则表示读取时遇到了错误。

参数说明:

  • fd:特定的文件描述符,表示从该文件描述符中读取数据。
  • buf:数据的存储位置,表示将读取到的数据存储到该位置。
  • count:数据的个数,表示从该文件描述符中读取数据的字节数。

read返回值为0表示对端连接关闭

这实际和本地进程间通信中的管道通信是类似的,当使用管道进行通信时,可能会出现如下情况:

  • 写端进程不写,读端进程一直读,此时读端进程就会被挂起,因为此时数据没有就绪。
  • 读端进程不读,写端进程一直写,此时当管道被写满后写端进程就会被挂起,因为此时空间没有就绪。
  • 写端进程将数据写完后将写端关闭,此时当读端进程将管道当中的数据读完后就会读到0。
  • 读端进程将读端关闭,此时写端进程就会被操作系统杀掉,因为此时写端进程写入的数据不会被读取。

这里的写端就对应客户端,如果客户端将连接关闭了,那么此时服务端将套接字当中的信息读完后就会读取到0,因此如果服务端调用read函数后得到的返回值为0,此时服务端就不必再为该客户端提供服务了。

write函数

TCP服务器写入数据的函数叫做write,该函数的函数原型如下:

ssize_t write(int fd, const void *buf, size_t count);

返回值说明:

  • 写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置。

参数说明:

  • fd:特定的文件描述符,表示将数据写入该文件描述符对应的套接字。
  • buf:需要写入的数据。
  • count:需要写入数据的字节个数。

当服务端调用read函数收到客户端的数据后,就可以再调用write函数将该数据再响应给客户端。

服务端处理请求

需要注意的是,服务端读取数据是服务套接字中读取的,而写入数据的时候也是写入进服务套接字的。也就是说这里为客户端提供服务的套接字,既可以读取数据也可以写入数据,这就是TCP全双工的通信的体现。

在从服务套接字中读取客户端发来的数据时,如果调用read函数后得到的返回值为0,或者读取出错了,此时就应该直接将服务套接字对应的文件描述符关闭。因为文件描述符本质就是数组的下标,因此文件描述符的资源是有限的,如果我们一直占用,那么可用的文件描述符就会越来越少,因此服务完客户端后要及时关闭对应的文件描述符,否则会导致文件描述符泄漏。

void Service(int sock){char buff[1024];while(true){ssize_t size = read(sock , buff , sizeof(buff)-1);if (size > 0) {buff[size] = 0;write(sock , buff , size);}else if (size == 0) // 对端关闭{close(sock);cout << "read cloes " << endl;break;}else{cout << "error : " << errno<< endl;break;}}}

客户端创建套接字

同样的 我们也可以将客户端封装成一个类 当我们需要一个客户端的时候将其进行实例化

而我们客户端只需要创建套接字就可以 至于绑定和监听则不需要

为什么客户端不需要绑定和监听

  • 客户端不需要绑定 因为客户端的port可以是随机的 服务端需要固定是因为客户端需要一个确定的port才能找到服务端
  • 客户端不需要监听 因为客户端不需要接受除了服务端之外任何人的连接

此外 客户端必须要知道它要连接的服务端的IP地址和端口号 因此客户端除了要有自己的套接字之外 还需要知道服务端的IP地址和端口号 这样客户端才能够通过套接字向指定服务器进行通信

class TcpClient{private:int _sockfd;int _sever_port;string _sever_ip;public:TcpClient(string ip , int port):_sever_ip(ip),_sever_port(port),_sockfd(-1){}~TcpClient(){if(_sockfd >= 0){close(_sockfd);}}public:void ClientInit(){_sockfd = socket(AF_INET , SOCK_STREAM , 0);if (_sockfd < 0){cout << "ClientInit error" << endl;exit(1); }}}; 

客户端发起请求

由于我们的客户端不需要绑定监听 所以当创建完毕之后就可以开始处理请求了 我们使用connect函数来处理请求

connect函数

该函数原型如下

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

返回值说明:

  • 如果连接成功则返回0 如果失败则返回-1 错误码被设置

参数说明:

  • sockfd:特定的套接字,表示通过该套接字发起连接请求。
  • addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
  • addrlen:传入的addr结构体的长度。

客户端连接服务器

这里需要特别注意的一点是 客户端只是不自己绑定端口号和ip 并不是不需要端口号和ip

因为通信双方都必须要有IP地址和端口号 否则无法唯一标识通信双方

也就是说 如果connect函数调用成功了 客户端本地会随机给该客户端绑定一个端口号发送给对端服务器

但是我们进行连接的时候传入的并不是客户端的IP和PORT 而是服务器的 因为我们要连接的是服务器 在连接的时候客户端的IP和PORT会自动传给服务器

void ClientStart(){struct sockaddr_in peer;memset(&peer , 0 , sizeof(peer));peer.sin_family = AF_INET;peer.sin_port = htons(_sever_port);peer.sin_addr.s_addr = inet_addr(_sever_ip.c_str());if (connect(_sockfd , (struct sockaddr*)&peer , sizeof(peer)) == 0){cout << "connect success" << endl;// request }else{cout << "connect fail" << endl;exit(2);}}

客户端发起请求

由于我们实现的是一个简单的回声服务器 因此当客户端连接到服务端之后就可以向服务端发送数据了 这里我们可以将客户端发送的数据利用write函数发送给服务端

当我们的客户端发送数据给服务端之后 由于服务端读取到数据后还会进行回显 因此客户端在发送数据后还需要调用read函数读取服务端的响应数据 然后将该响应数据进行打印以确定双方通信无误

void Request(){string msg;char buff[1024];while(true){cout << "please enter#" << endl;fflush(nullptr);getline(cin , msg);write(_sockfd , msg.c_str() , msg.size()); ssize_t size = read(_sockfd , buff , sizeof(buff) -1);if(size > 0){buff[size] = 0; // '\0'cout << "sever echo: " << buff << endl ; }else if (size == 0){cout << "sever exit " << endl ;break;}else {cout << "sever error" << endl;break;}}close(_sockfd);}

在运行客户端程序时我们就需要携带上服务端对应的IP地址和端口号 然后我们就可以通过服务端的IP地址和端口号构造出一个客户端对象 对客户端进行初始后启动客户端即可

服务器测试

我们首先启动服务器

之后使用netstat来查看网络状态

之后我们再运行客户端

我们发现简单的回声服务器就制作完毕了