当前位置: 首页 > news >正文

网络编程套接字(三)---简单的TCP网络程序

目录

前言

套接字的封装

创建套接字

绑定套接字

设置监听状态

获取连接

发送连接请求

TCP网络程序服务端

服务端创建套接字

服务端绑定

服务端监听

服务端获取连接

服务端处理请求

TCP网络程序客户端

服务器测试

单执行流服务器的弊端


前言

前两篇文章分别简要介绍了套接字及其相关基础知识,并实现了一个简单的 UDP 网络程序。本文将继续介绍如何实现一个简单的 TCP 网络程序。与 UDP 编程相比,TCP 在实现步骤上稍多两三个环节,但整体结构仍较为相似。

套接字的封装

创建套接字

再次,我们代码书写的方式与UDP改进一下,将对套接字的相关代码封装成一个公共类,然后对于TCP中服务端与客户端之间的代码在分别进行封装。

TCP服务器创建套接字的做法与UDP服务器的细节是一样的。只不过在设定服务的方式是不一样的,对于应用层编程,现在可以先掌握如何使用这些系统调用来实现功能,现在不需要了解太多,后面会统一在网路层学习。

同样我们效仿UDP创建套接字的设计:

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

同样与UDP一样,创建套接字后获得的文件描述符是小于0的,说明套接字创建失败,此时后续的操作就没有必要了,接下来就选择直接终止程序,打印对应的错误信息即可。

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

在实际下,TCP服务器创建套接字的做法与UDP服务器是一样的,只不过创建套接字时TCP需要的是流式服务,而UDP需要的是用户数据报服务。

绑定套接字

套接字创建完成后,在实际上我们知识在系统层面上打开了一个文件,但是该文件还没有与网络联系起来,因此我们要建立联系。

同样TCP的绑定套接字与UDP完全一致,我们首先要创建一个struct sockaddr_in结构体,然后将该结构体的相关属性进行填充后,将其调用bind即可。

其中该结构体我们需要填充的属性分别是:协议家族、IP地址、端口号。

对应的协议家族就是AF_INET,IP地址我们需要根据特定情况单独设置,比如说,使用的是云服务器,那么在设置服务器的IP地址时,不需要显示绑定IP地址,直接将IP地址设置为INADDR_ANY即可,此时服务器就可以从本地任何一张网卡当中读取数据。此外,由于INADDR_ANY本质就是0,因此在设置时不需要进行网络字节序的转换。

当然对于客户端的是进行动态绑定的,所以我们一般来说是不需要自己进行绑定的,所以我们这里代码的编写就只需要考虑服务器端即可,将ip设置为0。对于端口号,就是TCP服务端启动时需要绑定的端口号,需要注意调用htons函数将端口号由主机序列转为网络序列。

void Bind(uint16_t port)
{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(local)) < 0){std::cerr << "bind error" << std::endl;exit(2);}
}

设置监听状态

因为TCP服务器是面向连接的,客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信,所以TCP服务端需要时刻注意客户端是否发来请求,那么此时针对于这种情况,就需要将TCP服务端设置为监听状态,其调用的函数即为listen。

listen函数的原型如下:

int listen(int sockfd, int backlog);

参数说明:

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

返回值说明:

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

补充:对于TCP网络编程,其只需要将服务端设置为监听状态即可,不需要将客户端设置为监听状态。

设置监听状态是保证服务端的套接字创建于绑定是正确且完成的,才可以进行设置监听状态。来监听是否有新的连接到来。同样与前面一致,如果监听设置失败也没必要进行后续操作了,因为监听失败也就意味着TCP服务器无法接收客户端发来的连接请求,因此监听失败我们直接终止程序即可。

// 设置连接请求队列的最大长度为10
const int backlog = 10;void Listen()
{if(listen(_sockfd, backlog) < 0){std::cerr << "listen error" << std::endl;exit(3);}
}

注意:其中的参数backlog现在简单理解即可。其中此时的套接字便不是简单的套接字,而是监听套接字。

获取连接

TCP 服务器完成地址绑定(bind)后,此时服务处于监听就绪状态,但尚未开始接受客户端连接。此时的服务器已经准备好接收连接请求,但需要进一步调用 listen 和 accept 函数,才能正式建立与客户端的通信链路,实现完整的连接处理功能。

对应调用的函数即为accept函数,其该函数的原型如下:

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

参数说明:

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

返回值说明:

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

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

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

服务端获取连接

对于代码,服务端获取连接的函数accept其中有两个参数,分别是sockfd,还有sockaddr结构体,还有socklen_t,我们需要先创建一个sockaddr结构体。然后调用accept参数,然后获得连接的客户端的各个属性,然后此时我们也可以通过输入输出函数,然后获取该客户端的ip与端口号。

同样需要注意的是,要注意各个属性中不同格式的转换。

int Accept(std::string *clientip, uint16_t *clientport)
{struct sockaddr_in peer;socklen_t len = sizeof(peer);int newfd = accept(_sockfd, (struct sockaddr*)&peer, &len);if(newfd < 0){std::cerr << "accept error" << std::endl;exit(4);}char ipstr[64];// 字符串转 IPV4 格式inet_ntop(AF_INET, &peer.sin_addr, ipstr, sizeof(ipstr));*clientip = ipstr;*clientport = ntohs(peer.sin_port);return newfd;
}

发送连接请求

由于客户端不需要绑定,也不需要监听,因此当客户端创建完套接字后就可以向服务端发起连接请求。当客户端想向服务端建立连接时,就需要调用函数connect函数,其函数如下:

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

参数说明:

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

返回值说明:

  • 连接或绑定成功返回0,连接失败返回-1,同时错误码会被设置。

同样,因为connect中的第一个参数时sockaddr,我们需要知道服务端的各个属性,所以我们要自己传参数服务端的ip与端口号。同样需要记得调用htons函数将端口号转化为网络字节序,与调用inet_pton函数,将字符串ip形式转化为整数ip格式。

bool Connect(const std::string &ip, const uint16_t &port)
{struct sockaddr_in peer;memset(&peer, 0, sizeof(peer));peer.sin_family = AF_INET;peer.sin_port = htons(port);inet_pton(AF_INET, ip.c_str(), &(peer.sin_addr));int n = connect(_sockfd, (struct sockaddr*)&peer, sizeof(peer));if(n == -1) {std::cerr << "connect to " << ip << ":" << port << " error" << std::endl;return false;}return true;
}

当我对套接字完成了封装后,我们就可以直接调用即可,以下是完整的封装。

同样添加两个函数,分别是关闭套接字与获取套接字函数。

    void Close(){close(_sockfd);}int Fd(){return _sockfd;}
#pragma once#include <iostream>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <unistd.h>// 设置连接请求队列的最大长度为10
const int backlog = 10;class Sock
{
public:Sock(){}void Socket(){_sockfd = socket(AF_INET, SOCK_STREAM, 0);if(_sockfd < 0){std::cerr << "socket error" << std::endl;exit(1);}}void Bind(uint16_t port){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(local)) < 0){std::cerr << "bind error" << std::endl;exit(2);}}void Listen(){if(listen(_sockfd, backlog) < 0){std::cerr << "listen error" << std::endl;exit(3);}}int Accept(std::string *clientip, uint16_t *clientport){struct sockaddr_in peer;socklen_t len = sizeof(peer);int newfd = accept(_sockfd, (struct sockaddr*)&peer, &len);if(newfd < 0){std::cerr << "accept error" << std::endl;exit(4);}char ipstr[64];// 字符串转 IPV4 格式inet_ntop(AF_INET, &peer.sin_addr, ipstr, sizeof(ipstr));*clientip = ipstr;*clientport = ntohs(peer.sin_port);return newfd;}bool Connect(const std::string &ip, const uint16_t &port){struct sockaddr_in peer;memset(&peer, 0, sizeof(peer));peer.sin_family = AF_INET;peer.sin_port = htons(port);inet_pton(AF_INET, ip.c_str(), &(peer.sin_addr));int n = connect(_sockfd, (struct sockaddr*)&peer, sizeof(peer));if(n == -1) {std::cerr << "connect to " << ip << ":" << port << " error" << std::endl;return false;}return true;}void Close(){close(_sockfd);}int Fd(){return _sockfd;}~Sock(){if (_sockfd >= 0){close(_sockfd);}}
private:int _sockfd;
};

TCP网络程序服务端

同样,我们可以将 TCP 服务器功能封装为一个 TcpServer 类。在该类中,其成员变量之一就是我们之前封装的套接字类所定义的对象。为了区分,我们将该定义的对象命名为listen_sock。除此之外,我们还需要添加别的类成员来表示端口号。

有了前面实现UDP网络程序的经验,我们这里就直接将该类的构造函数与前UDP设计保持一致,这里不解释:

#include "Socket.hpp"static const int defaultport = 8082;class TcpServer
{
public:TcpServer(uint16_t port = defaultport):_port(port){}
private:Sock listen_sock;uint16_t _port;
};

服务端创建套接字

第一步就是创建套接字,可以使用我们刚才封装的现成的函数。

服务端绑定

同样因为我们刚才封装了,就可以直接调用。

服务端监听

一样,直接调用刚才封装的函数

服务端获取连接

无论是 TCP 还是 UDP 服务器,都需要持续运行以保持服务可用,始终处于等待客户端请求的状态。因此,与 UDP 服务器类似,TCP 服务器同样需要通过一个循环结构持续运行,实时监听并处理来自客户端的连接或数据请求。

所以代码整体是一个for死循环。

    void start(){for(;;){std::string clientip;uint16_t clientport;int sockfd = listen_sock.Accept(&clientip, &clientport);if(sockfd < 0)continue;std::cout << "get a new connect, sockfd:" << sockfd << std::endl;}}

服务端处理请求

同样,我们对于接收到的请求,依然不做任何的处理,而是原封不动的直接打印,但是为了区分是谁发的,我们前面加上xxx :。

但是需要注意的是:此时,TCP 服务器已经能够接收客户端的连接请求,接下来需要对已建立的连接进行处理。需要注意的是,直接为客户端提供服务的并非监听套接字本身——监听套接字在成功接收一个连接后,会继续监听后续的连接请求。实际负责与客户端通信的是 accept 函数返回的套接字,下文将其称为“服务套接字”。

read函数

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

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

参数说明:

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

返回值说明:

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

write函数

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

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

参数说明:

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

返回值说明:

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

当我们的客户端write向服务端发送数据,服务端调用read收到后,先打印client_ip:xxx。然后再调用write返回。客户端read收到后,打印:server :。

同样还是需要注意的是:我们调用read中的参数fd是服务套接字中读取的,而不是监听套接字中读取。而写入数据的时候也是写入进服务套接字的。也就是说这里为客户端提供服务的套接字,既可以读取数据也可以写入数据,这就是TCP全双工的通信的体现。

在从服务套接字中读取客户端发来的数据时,如果调用read函数后得到的返回值为0,或者读取出错了,此时就应该直接将服务套接字对应的文件描述符关闭。同样我们要养好良好的代码习惯,用完即关,保证在有限的资源下,避免浪费。

    void start(){for(;;){std::string clientip;uint16_t clientport;int sockfd = listen_sock.Accept(&clientip, &clientport);if(sockfd < 0)continue;std::cout << "get a new connect, sockfd:" << sockfd << std::endl;char buffer[1024];while(true){ssize_t size = read(sockfd, buffer, sizeof(buffer));if (size > 0){buffer[size] = '\0';std::cout << clientip << " : " << buffer << std::endl;write(sockfd, buffer, size);}else if(size == 0){std::cout << clientip << "close" << std::endl;break;}else{std::cerr << sockfd << " read error!" << std::endl;break;}}close(sockfd); //归还文件描述符std::cout << clientip << ":" << clientport << " service done!" << std::endl;}}

TCP网络程序客户端

我们可以直接复用已封装的套接字创建方法。得益于前期的完整封装,客户端在初始化阶段无需额外操作,这就相对于我们前面UDP代码的封装思路相比,极大简化了代码实现。所以对于项目代码的书写,选择正确的封装,可以极大减少代码的书写。

这时,我们就不需要再将客户端进行封装成一个类了,我们可以直接调用我们刚才封装的函数即可。

此时我们就可以用一个简单的.cc文件代替客户端的代码,不需要再添加一个.hpp文件。

对于代码的大致思路就是先通过命令行参数,来获取客户端想要与服务端建立连接的ip地址与端口号。

然后根据此,实例化一个sockaddr_in结构体对象,然后对其属性进行填充。然后创建一个我们封装的socket类的对象,然后进行套接字的创建,又因为客户端的绑定是动态绑定,所以我们下一步就是向服务端发送连接请求,连接成功后,就向服务端发送数据请求。以此进行下去。


#include "Socket.hpp"void Usage(std::string proc)
{std::cout << "Usage: " << proc << "server_ip server_port" << std::endl;
}
int main(int argc, char* argv[])
{if(argc != 3){Usage(argv[0]);exit(1);}std::string serverip = argv[1];uint16_t serverport = std::stoi(argv[2]);struct sockaddr_in server;memset(&server, 0, sizeof(server));server.sin_family = AF_INET;server.sin_port = htons(serverport);inet_pton(AF_INET, serverip.c_str(), &(server.sin_addr));while(true){Sock sock;sock.Socket();int sockfd = sock.Fd();if(sockfd < 0){std::cerr << "socket error" << std::endl;return 1;}int n = sock.Connect(serverip, serverport);if (n < 0){ std::cerr << "connect error..., reconnect: " << std::endl;exit(1);}// 连接成功while(true){std::string message;// 先发std::cout << "Please Enter# ";std::getline(std::cin, message);int size = write(sockfd, message.c_str(), sizeof(message));if (size < 0){std::cerr << "write error..." << std::endl;exit(2);}// 读取server处理后的数据char inbuffer[4096];size = read(sockfd, inbuffer, sizeof(inbuffer));if (size > 0){inbuffer[size] = 0;std::cout << "server : " << inbuffer << std::endl;}else{close(sockfd);}}}return 0;
}

服务器测试

下面我们就完成对于服务端的代码测试代码,这里就直接给出。

#include "TcpServer.hpp"
#include <iostream>
#include <memory>void Usage(std::string proc)
{std::cout << "\n\rUsage: " << proc << " port[1024+]\n" << std::endl;
}    //./tcpserver 8080
int main(int argc, char *argv[])
{if(argc != 2){Usage(argv[0]);exit(1);}uint16_t port = std::stoi(argv[1]);std::unique_ptr<TcpServer> tcp_svr(new TcpServer(port));tcp_svr->Init();tcp_svr->start();return 0;
}

最后给出Makefile文件

.PHONY:all
all:tcpserver tcpclienttcpserver:main.ccg++ -o $@ $^ -std=c++11
tcpclient:TcpClient.ccg++ -o $@ $^ -std=c++11.PHONY:clean
clean:rm -f tcpserver tcpclient

代码运行效果如下:

也是符合我们的预期,所以,目前来看,我们代码是没有错误的。

单执行流服务器的弊端

当我们仅用一个客户端连接服务端时,这一个客户端能够正常享受到服务端的服务。

但在这个客户端正在享受服务端的服务时,我们让另一个客户端也连接服务器,此时虽然在客户端显示连接是成功的,但这个客户端发送给服务端的消息既没有在服务端进行打印,服务端也没有将该数据回显给该客户端。

只有当第一个客户端退出后,服务端才会将第二个客户端发来是数据进行打印,并回显该第二个客户端。

单执行流的服务器

通过实验现象可以看到,这服务端只有服务完一个客户端后才会服务另一个客户端。因为我们目前所写的是一个单执行流版的服务器,这个服务器一次只能为一个客户端提供服务。

当服务端调用accept函数获取到连接后就给该客户端提供服务,但在服务端提供服务期间可能会有其他客户端发起连接请求,但由于当前服务器是单执行流的,只能服务完当前客户端后才能继续服务下一个客户端。

但是客户端为什么会显示连接成功?

当服务端在给第一个客户端提供服务期间,第二个客户端向服务端发起的连接请求时是成功的,只不过服务端没有调用accept函数将该连接获取上来罢了。

实际在底层会为我们维护一个连接队列,服务端没有accept的新连接就会放到这个连接队列当中,而这个连接队列的最大长度就是通过listen函数的第二个参数来指定的,因此服务端虽然没有获取第二个客户端发来的连接请求,但是在第二个客户端那里显示是连接成功的。

对于该问题的解决方式有两种,分别是引进多进程的概念到服务端,或者引入进程池的概念。

对于该部分的引入,会放在下一篇文章。

http://www.dtcms.com/a/395284.html

相关文章:

  • 背景建模(基于视频,超炫)项目实战!
  • ios26版本回退到ios18
  • OpenCV直方图比较:原理与四种方法详解
  • OpenCV - 图像金字塔
  • 寄存柜频繁维护还卡顿?杰和IB2-281主板:智能化升级高效省心
  • 海外短剧系统开发:多语言适配与跨地区部署的架构实践
  • JVM内存模型详解:看内存公寓如何分配“房间“
  • 【论文阅读】4D-VLA:时空视觉-语言-动作预训练与跨场景校准
  • 【论文阅读】MDM : HUMAN MOTION DIFFUSION MODEL
  • 【论文阅读】RynnVLA-001:利用人类示范改进机器人操作
  • Leecode hot100 - 105.从前序与中序遍历序列构造二叉树
  • 联邦学习论文分享:Federated Learning with GAN-based Data Synthesis for Non-IID Clients
  • 绕过百度网盘无限制下载
  • 【自记】PyCharm 更换阿里云国内源教程
  • 【Axure原型分享】区间K线图
  • javascript之Es6八股文
  • npm和pnpm命令大全
  • kali下安装beef-xss报错-启动失败-简单详细
  • 政策法规下的LLM安全:合规之路
  • 《第21课——C typedef:从Java的“实名制”到C的“马甲生成器”——类型伪装术与代码整容的艺术》
  • 【每天一个知识点】什么是知识库?
  • 豆包·Seedream 4.0深度测评:4K多模态时代的图像创作革命(图文增强版)
  • [新启航]发动机喷管推进剂输送孔光学 3D 轮廓测量 - 激光频率梳 3D 轮廓技术
  • 深入理解 TCP 协议:三次握手与四次挥手的底层原理
  • PyTorch 神经网络工具箱
  • 机器学习-多因子线性回归
  • 国产化Excel开发组件Spire.XLS教程:Python 写入 Excel 文件,数据写入自动化实用指南
  • 08 - spring security基于jdbc的账号密码
  • 解决SSL证书导致源站IP被泄露的问题
  • Worst Western Hotel: 1靶场渗透