目录
1、实现一个TCP网络程序(单进程版)
1.1、服务端serverTcp文件
服务端创建套接字
服务端绑定
服务端监听
服务端获取连接
服务端提供服务
服务端main函数命令行参数
服务端serverTcp总代码
1.2、客户端clientTcp文件
客户端main函数命令行参数
客户端创建套接字
客户端的bind、listen、accept问题
客户端连接服务器
客户端发起请求
客户端clinetTcp总代码
1.3、服务器测试
1.4、单执行流服务器的问题
2、多进程版的TCP网络程序
捕捉SIGCHLD信号
让孙子进程提供服务
3、多线程版的TCP网络程序
4、线程池版的TCP网络程序
线程池变形
5、总代码gitee链接
我们把服务器封装成一个ServerTcp类,该类里主要有如下几个任务:
- 服务端创建套接字
- 服务端绑定
- 服务端监听
- 服务端获取链接
- 服务端提供服务
- 服务端main函数命令行参数
下面依次演示:
我们把服务器封装成一个ServerTcp类,当我们定义出一个服务器对象后需要马上初始化服务器,而初始化服务器首先要创建套接字。创建套接字的函数叫做socket函数,再回顾下其函数原型:
int socket(int domain, int type, int protocol);
这里TCP服务器在调用socket函数创建套接字时,参数设置如下:
- domain:协议家族选择AF_INET,因为我们要进行的是网络通信。
- type:创建套接字时所需的服务器类型应该是SOCK_STREAM,因为我们编写的是TCP服务器,SOCK_STREAM提供的就是一个有序的、可靠的、全双工的、基于连接的流式服务。注意我UDP是用户数据报服务。
- protocol:协议类型默认设置为0即可。
若socket创建失败,则复用logMessage函数打印相关日志信息,并直接exit退出程序。
class ServerTcp { public:// 构造函数 + 析构函数 public:// 初始化void init(){// 1、创建socketsock_ = socket(AF_INET, SOCK_STREAM, 0);if (sock_ < 0){logMessage(FATAL, "socket: %s", strerror(errno)); // 创建失败,打印日志exit(SOCKET_ERR);}logMessage(DEBUG, "socket: %s, %d", strerror(errno), sock_);} private:int sock_; // socketuint16_t port_; // portstring ip_; // ip };
- 当套接字已经创建成功了,但作为一款服务器来讲,如果只是把套接字创建好了,那我们也只是在系统层面上打开了一个文件,操作系统将来并不知道是要将数据写入到磁盘还是刷到网卡,此时该文件还没有与网络关联起来。所以我们需要调用bind函数进行绑定操作。
绑定的步骤如下:
- 1、绑定网络信息,先填充基本信息到struc sockaddr_in结构体。
- 定义struc sockaddr_in结构体对象local,复用memset函数对local进行初始化。将协议家族、端口号、IP地址等信息填充到该结构体变量当中。注意协议家族这里设定的是PF_INET。
- 服务器的端口号是要发给对方的,在发送到网络之前要复用htons主机转网络函数把端口号port_转成网络序列,才能向外发送。
- ip地址默认是字符串风格点分十进制的,这里复用inet_aton函数将字符串IP转换成整数IP(inet_addr除了做转换,还会自动给我们做主机转网络)。注意若ip地址是空的,那就用INADDR_ANY这个宏,否则再用inet_addr函数。这个宏就是0,因此在设置时不需要进行网络字节序的转换。
- 2、绑定网络信息,上述local临时变量(struc sockaddr_in结构体对象)是在用户栈上开辟的,要将其写入内核中。复用bind函数完成绑定操作。bind成功与否均复用logMessage函数打印相关日志信息。
- 由于bind函数提供的是通用参数类型,因此在传入结构体地址时还需要将struct sockaddr_in*强转为struct sockaddr*类型后再进行传入。
class ServerTcp { public:// 构造函数 + 析构函数 public:// 初始化void init(){// 1、创建socket// 2、bind绑定// 2.1、填充服务器信息struct sockaddr_in local; // 用户栈memset(&local, 0, sizeof(local));local.sin_family = PF_INET;local.sin_port = htons(port_);ip_.empty() ? (local.sin_addr.s_addr = INADDR_ANY) : (inet_aton(ip_.c_str(), &local.sin_addr));// 2.2、将本地socket信息,写入sock_对应的内核区域if (bind(sock_, (const struct sockaddr *)&local, sizeof(local)) == -1){logMessage(FATAL, "bind: %s", strerror(errno)); // 绑定失败,打印日志exit(BIND_ERR);}logMessage(DEBUG, "bind: %s, %d", strerror(errno), sock_);} private:int sock_;// socketuint16_t port_; // portstring ip_; // ip };
listen接口说明
- UDP服务器的初始化操作只有两步,第一步就是创建套接字,第二步就是绑定。而TCP服务器是面向连接的,客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信。
- 因此TCP服务器需要时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态。
设置套接字为监听状态的函数叫做listen,该函数的函数原型如下:
int listen(int sockfd, int backlog);
参数说明:
- sockfd:需要设置为监听状态的套接字对应的文件描述符。
- backlog:全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或10即可。
返回值说明:
- 监听成功返回0,监听失败返回-1,同时错误码会被设置。
代码逻辑如下
- TCP是面向连接的,所以要让TCP服务器时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态。监听失败就打印日志信息,并直接退出。因为监听失败就意味着TCP服务器无法接受客户端发来的连接请求。
class ServerTcp { public:// 构造函数 + 析构函数 public:// 初始化void init(){// 1、创建socket// 2、bind绑定// 3、监听socketif (listen(sock_, 5) < 0){logMessage(FATAL, "listen: %s", strerror(errno)); // 监听失败,打印日志exit(LISTEN_ERR);}logMessage(DEBUG, "listen: %s, %d", strerror(errno), sock_); } private:int sock_; // socketuint16_t port_; // portstring ip_; // ip };
初始化TCP服务器时创建的套接字并不是普通的套接字,而应该叫做监听套接字。为了表明寓意,我们将代码中套接字的名字由sock_改为listensock_。
accept接口说明
- TCP服务器初始化后就可以开始运行了,但TCP服务器在与客户端进行网络通信之前,服务器需要先获取到客户端的连接请求。究竟是谁连接我的。
获取连接的函数叫做accept,该函数的函数原型如下:
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数说明:
- sockfd:特定的监听套接字,表示从该监听套接字中获取连接。
- addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:调用时传入期望读取的addr结构体的长度,返回时代表实际读取到的addr结构体的长度,这是一个输入输出型参数。
返回值说明:
- 获取连接成功返回接收到的套接字的文件描述符,获取连接失败返回-1,同时错误码会被设置。
调用accept函数获取连接时,是从监听套接字当中获取的。如果accept函数获取连接成功,此时会返回接收到的套接字对应的文件描述符。监听套接字与accept函数返回的套接字的作用:
- 监听套接字:用于获取客户端发来的连接请求。accept函数会不断从监听套接字当中获取新连接。
- accept函数返回的套接字:用于为本次accept获取到的连接提供服务(为用户提供网络服务,主要是进行IO)。监听套接字的任务只是不断获取新连接,而真正为这些连接提供服务的套接字是accept函数返回的套接字,而不是监听套接字。
代码逻辑如下
- 定义struct sockaddr_in的对象peer,定义len为peer的字节数
- 复用accept函数获取连接。若返回值<0说明连接失败,但是TCP服务器不会因为某个连接失败而退出,因此服务端获取连接失败后应该继续获取连接。
- 获取连接成功后,要获取客户端的基本信息,将客户端的IP地址和端口号信息进行输出,需要调用inet_ntoa函数将整数IP转换成字符串IP,调用ntohs函数将端口号由网络序列转换成主机序列。
class ServerTcp { public:// 构造函数 + 析构函数 public:// 初始化void init(){// 1、创建socket// 2、bind绑定// 3、监听socket}// 启动服务端void loop(){while (true){// 4、获取连接struct sockaddr_in peer;socklen_t len = sizeof(peer);int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len);if (serviceSock < 0){logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 获取连接失败continue;}// 4.1、获取客户端基本信息uint16_t peerPort = ntohs(peer.sin_port);string peerIp = inet_ntoa(peer.sin_addr);logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock);}}private:int listensock_;// socketuint16_t port_; // portstring ip_; // ip };
服务端接受连接测试
- 这里我们客户端还没有写,但是我们可以先允许服务端,然后在windows下的浏览器上用当前云服务器ip(124.71.25.237)+端口号(8080)进行访问测试
- 浏览器常见的应用层协议是http或https,其底层对应的也是TCP协议,因此浏览器也可以向当前这个TCP服务器发起请求连接。测试如下:
注意:
- 至于这里为什么浏览器一次会向我们的TCP服务器发起两次请求这个问题,这里就不作讨论了,我们只是要证明当前TCP服务器能够正常接收外部的请求连接。
read接口说明
- 现在TCP服务器已经能够获取连接请求了,下面当然就是要对获取到的连接进行处理。为了让通信双方都能看到对应的现象,我们这里就实现一个简单的回声TCP服务器,服务端在为客户端提供服务时就简单的将客户端发来的数据进行输出,并且将客户端发来的数据重新发回给客户端即可。当客户端拿到服务端的响应数据后再将该数据进行打印输出,此时就能确保服务端和客户端能够正常通信了。
TCP服务器读取数据的函数叫做read,该函数的函数原型如下:
ssize_t read(int fd, void *buf, size_t count);
参数说明:
- fd:特定的文件描述符,表示从该文件描述符中读取数据。
- buf:数据的存储位置,表示将读取到的数据存储到该位置。
- count:数据的个数,表示从该文件描述符中读取数据的字节数。
返回值说明:
- 如果返回值大于0,则表示本次实际读取到的字节个数。
- 如果返回值等于0,则表示对端已经把连接关闭了。
- 如果返回值小于0,则表示读取时遇到了错误。
read返回值为0表示对端连接关闭。这实际和本地进程间通信中的管道通信是类似的,当使用管道进行通信时,可能会出现如下情况:
- 写端进程不写,读端进程一直读,此时读端进程就会被挂起,因为此时数据没有就绪。
- 读端进程不读,写端进程一直写,此时当管道被写满后写端进程就会被挂起,因为此时空间没有就绪。
- 写端进程将数据写完后将写端关闭,此时当读端进程将管道当中的数据读完后就会读到0。
- 读端进程将读端关闭,此时写端进程就会被操作系统杀掉,因为此时写端进程写入的数据不会被读取。
这里的写端就对应客户端,如果客户端将连接关闭了,那么此时服务端将套接字当中的信息读完后就会读取到0,因此如果服务端调用read函数后得到的返回值为0,此时服务端就不必再为该客户端提供服务了。
write接口说明
- TCP服务器写入数据的函数叫做write,该函数的函数原型如下:
ssize_t write(int fd, const void *buf, size_t count);
参数说明:
- fd:特定的文件描述符,表示将数据写入该文件描述符对应的套接字。
- buf:需要写入的数据。
- count:需要写入数据的字节个数。
返回值说明:
- 写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置。
当服务端调用read函数收到客户端的数据后,就可以再调用write函数将该数据再响应给客户端。
代码逻辑如下
- 注意:服务端读取数据是服务套接字中读取的,而写入数据的时候也是写入进服务套接字的。也就是说这里为客户端提供服务的套接字,既可以读取数据也可以写入数据,这就是TCP全双工的通信的体现。
- 这里我们把服务端提供服务的过程封装成一个transService函数,其内部完成的主要功能是完成大小写转化
- 首先,调用read函数读取客户端发来的数据,这里且假定读取的是字符串。read函数返回值为s。
- 若返回值s > 0,说明读取成功,在内部首先调用strcasecmp函数判断客户端是否需要服务端提供服务,若不需要(quit),则打印日志并退出,若需要,在内部完成大小写转化的功能。转化完成后调用write函数将结果返回给客户端
- 若返回值s = 0或s < 0,此时就应该直接将服务套接字对应的文件描述符关闭。因为文件描述符本质就是数组的下标,因此文件描述符的资源是有限的,如果我们一直占用,那么可用的文件描述符就会越来越少,因此服务完客户端后要及时关闭对应的文件描述符,否则会导致文件描述符泄漏。
class ServerTcp { public:// 构造函数 + 析构函数 public:// 初始化void init(){// 1、创建socket// 2、bind绑定// 3、监听socket}// 启动服务端void loop(){while (true){// 4、获取连接// 4.1、获取客户端基本信息// 5、提供服务,echo ( 小写 -> 大写 )// 5.0 v0版本transService(serviceSock, peerIp, peerPort);}}// 大小写转化服务// TCP && UDP: 支持全双工void transService(int sock, const string &clientIp, uint16_t clientPort){assert(socket >= 0);assert(!pty());assert(clientPort >= 1024);char inbuffer[BUFFER_SIZE];while (true){ssize_t s = read(sock, inbuffer, sizeof(inbuffer) - 1); // 我们认为读取到的都是字符串if (s > 0) // 读取成功{inbuffer[s] = '