TCP协议:面向连接的、可靠稳定的、基于字节流传输的通信协议
面向连接:通信之前首先要建立连接,确保双方具有收发数据的能力
可靠稳定的:通过大量的控制机制,保证数据能够安全有序且完整的到达对端
基于字节流:采用数据流方式,没有传输大小限制,传输比较灵活的一种方式
UDP协议:无连接的、不可靠的、基于数据报的通信协议
无连接:收发数据之前不需要建立连接,只要知道对方地址,就可以进行传输
不可靠:只管数据能够发送出去,不管能否到达对端
基于数据报:有最大传输大小限制,且有最大交付传输限制的一种方式
对于数据传输时实时性大于安全性的选择UDP通信(视频、音频传输)
防火墙:默认设置下,将几乎所有的向内传输都进行了拦截
关闭它:sudo systemctl stop firewalld(停用防火墙)
彻底关闭它:sudo systemctl diable firewalld(禁用防火墙,以后重启都不会开启)
目录
一、Tcp服务端流程
二、TCP客户端流程
三、TCP接口介绍
四、Tcp接口操作的类的封装实现
五、实现TCP通信
使用多进程实现服务端:
使用多线程实现服务端
一、Tcp服务端流程
1、创建套接字 在内核中创建一个socket结构体
2、为套接字绑定地址信息
3、开始监听
Tcp是一个有状态的通信,它会根据自己不同状态来完成不同的功能
开始监听就使自己的socket结构体处于一种listen状态
只有在listen状态下,才能处理来自客户端的请求
4、获取新连接
从监听套接字对应的新建队列中,取出一个套接字结构,并返回这个套接字描述符
5、收发数据
因为tcp是面向连接的通信方式,所以后续收发数据不需要在进行传递自己的五元组信息了,因为新建的套接字结构体中已经有完整的五元组信息了。
6、关闭套接字
以下图来建立TCP通信方式: 客户端五元组信息:192.168.2.3 10000
服务端五元组信息:192.168.2.128 8000
首先,服务端创建套接字,然后将自己的五元组信息进行绑定,得到了右下这个struct socket结构体,该结构体可以称之为门迎结构体(先体会)
这时进行第3步开始监听,将socket结构体中的status状态置为LISTEN,当处于LISTEN状态时服务端才能够接受到来自客户端的请求。
客户端携带着自己的连接请求以及自己的五元组地址信息来与该服务端进行建立连接,这时候socket会创建一个新的socket结构体(服务员结构体)来与客户端进行通信,这个新的socket上不仅绑定着服务端的地址信息,也绑定着来自客户端的地址信息,并且将自身状态由LISTEN改为ESTABLISHED。
从建立该连接之后客户端就可以与该服务端进行数据通信了。
为什么后续不需要绑定信息了?
因为新创建的socket服务员结构体中不仅有服务端的五元组信息,还有客户端的五元组信息,新传输过来的数据只需要识别哪个是与自己的匹配的完整的socket信息即可。
再来简单梳理一下这个过程;
服务端开始监听,客户端发送了一个连接请求,处于监听状态的服务端才会与之建立连接
1、服务端为这个新的连接请求创建一个套接字
2、服务端为这个套接字描述了完整的五元组信息
往后所有的数据通信都会与新创建的套接字进行通信,一个服务端上有多少个客户端需要建立连接,就需要创建多少个新的套接字结构体(服务员),而最早服务端创建的监听套接字--只负责建立请求处理,不负责数据通信(门迎)
二、TCP客户端流程
1、创建套接字
2、绑定地址信息(不推荐)
3、发送连接请求 比UDP客户端多了一个连接请求
4、收发数据
5、关闭套接字
三、TCP接口介绍
int socket(int domain, int type, int protocol)
domain——地址域类型(一般ipv4 - AF_INET)
type:套接字类型 (字节流传输 TCP SOCK_STREAM)
(数据报传输 UDP SOCK_DGRAM)
protocol——协议类型 (IPPROTO_TCP、IPPROTO_UDP)
int bind(int sockfd, struct sockaddr *addr, socklen_t addrlen)
int listen(int sockfd, int backlog)
sockfd——创建套接字返回的监听套接字描述符(门迎)
backlog——同一时间的最大并发连接数(限定同一时间能够有多少个客户端连接)
int connect(int sockfd,struct sockaddr *addr,socklen_t len)
向服务端发送连接建立请求,这个接口只有客户端会用
sockfd 套接字描述符
addr 服务端的地址信息
addrlen 地址长度
获取新建连接
int accept(int sockfd,struct sockaddr *addr,socklen_t *addrlen)
从内核socket指定的监听套接字对应的已完成连接队列中,
取出一个socket,并返回这个socket的描述符
通过addr参数返回具体连接请求来源与那个客户端
addr:accept内部进行填充用户端地址,是一个输出参数
addrlen:地址信息长度,输入输出参数,
用于指定想要获取的地址长度,以及实际返回的地址长度
返回值:成功则返回新建连接的套接字描述符;出错返回-1
注意:accept服务端使用accept与客户端建立连接之后就会返回新建连接的套接字描述符,之后的收发数据中使用的就都是该套接字描述符。
收发数据
ssize_t send(int sockfd,void *data,size_t len,int flag)相对于sendto不用指定地址了
返回值:成功返回实际发送的数据长度,失败返回-1
ssize_t recv(int sockfd,void *buf,size_t len,int flag)相较于recvfrom,不用获取地址了
返回值:成功返回实际接收的字节长度,失败返回-1,连接断开则返回0
tcp是面向连接的,一旦连接断开(对方关闭了连接、或者网络出问题……)将无法继续通信
关闭套接字:int close(int sockfd)
四、Tcp接口操作的类的封装实现
1、头文件
#include<cstdio>
#include<iostream>
#include<string>
#include<sys/socket.h> // 套接字相关接口
#include<netinet/in.h> // 点分十进制与整形转化
#include<arpa/inet.h> // 网络字节序与主机字节序
#include<unistd.h> // close接口
2、 创建套接字
#define MAX_BLOG 10
class tcpSocket
{
private:
int _sockfd; // 内置类型封装socket描述符
public:
tcpSocket():_sockfd(-1){}
~tcpSocket() { /*Close();*/ } // 这里进行注释
因为服务端接收连接之后就会创建一个新的套接字,在在该套接字内部不断循环执行收发信息
如果在析构函数中关闭了套接字,那么最终程序运行时就会直接连接中断了
bool Socket() // 创建套接字
{
_sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (_sockfd < 0) {
perror("create error");
return false;
}
return true;
}
}
3、为套接字绑定地址信息
// int bind(int sockfd, struct sockaddr* addr, socklen_t addrlen)
bool Bind(const std::string &ip, uint16_t port)
{
// 传入参数ip与port来进行绑定
struct sockaddr_in addr; // 定义一个ipv4地址结构的套接字
addr.sin_family = AF_INET; // 初始化sin_family协议簇为AF_INET ipv4地址域
addr.sin_port = htons(port); // 这里是主机字节序向网络字节序转化 使用htons(二字节)
addr.sin_addr.s_addr = inet_addr(ip.c_str());
// 初始化sin_addr中的s_addr注意ip的字符形态转化
socklen_t len = sizeof(struct sockaddr_in);// 地址长度,直接sizeof(对应的地址结构)
int ret = bind(_sockfd, (sockaddr*)&addr, len);// bind进行绑定,注意强转使用
if(ret < 0){
perror("bind error");
return false;
}
return true;
}
4、服务端开始监听状态
#define MAX_BLOG 10
// int listen(int sockfd, int backlog)
bool Listen(int backlog = MAX_BLOG) // 传入一个同一时间最大连接数量
{ // 使用默认参数MAX_BLOG(全缺省函数)
int ret = listen(_sockfd, backlog);// 进入监听状态
if(ret < 0){
perror("listen error");
return false;
}
return true;
}
5、当处于监听状态的服务端可以接受到来自客户端的建立连接请求,当接受请求后,创建一个新的套接字,新的套接字结构体中里面描述了完整的五元组信息(不仅有服务端的五元组、还有客户端的五元组信息)
// int accept(int sockfd,struct sockaddr *peer,socklen_t *addrlen)
bool Accept(tcpSocket *sock, std::string *cli_ip = NULL, uint16_t *cli_port = NULL)
{
// 注意这里的函数参数,使用sock来接收新的套接字描述符,引入参数ip port来接收
// 因为服务端使用了accept接口就会创建一个新的套接字,
// 需要使用上面的三个参数来接收创建的套接字信息
struct sockaddr_in peer;
// peer就是新创建的套接字,使用accept把描述信息装载到这个peer中
socklen_t len = sizeof(struct sockaddr_in);
int newfd = accept(_sockfd, (struct sockaddr*)&peer, &len);
if(newfd < 0){
perror("accept error");
return false;
}
sock->_sockfd = newfd;
// 装载结束使用sock接收accept的返回值(即就是我们的新建套接字描述符)
if(cli_ip) *cli_ip = inet_ntoa(peer.sin_addr);
// 使用ip、port接收新建套接字中的ip 与 port
if(cli_port) *cli_port = ntohs(peer.sin_port);
// 因为后续操作根本不需要使用ip或者port了,
// 这个newfd保存的套接字描述符完全可以进行匹配
return true;
}
6、收发数据;因为上面使用sock接收了accept的返回值(新的套接字描述符——一个具有完整五元组的套接字,后续数据可以直接适配到这个套接字上,所以后续的收发数据就不用传递地址信息了。)
// 因为不需要绑定信息了,传入接收或者发送的body即可
bool Send(const std::string &body)
{
ssize_t ret = send(_sockfd, body.c_str(), body.size(), 0);
if(ret < 0)
{
perror("send error");
return false;
}
return true;
}
bool Recv(std::string *body)
{
char buf[1024] = {0};
ssize_t ret = recv(_sockfd, buf, 1023, 0);
if(ret < 0)
{
perror("revy error");
return false;
}
else if(ret == 0){
std::cout<<"connect fail"<<std::endl;
}
body->assign(buf, ret);// 从buf中取出ret大小的数据放入body中
return true;
}
7、关闭套接字
bool Close()
{
if (_sockfd != -1)
{
close(_sockfd);
_sockfd = -1;
}
return true;
}
五、实现TCP通信
客户端:
#include<assert.h>
int main(int argc, char *argv[])
{
if(argc != 3)
{
printf("./client 192.168.233.128 9000\n");
return -1;
}
std::string ip = argv[1]; // 接收ip
uint16_t port = std::stoi(argv[2]); //接收port
tcpSocket sock; // 1、创建套接字
assert(sock.Connect(ip, port)); //2、进行请求连接
while(1)
{
std::string data;
std::cout<<"client:";
fflush(stdout);
std::cin>>data;
assert(sock.Send(data)); // 3、发送数据
data.clear();
assert(sock.Recv(&data)); // 4、接收数据
std::cout<<"serve say:"<<data<<std::endl;
}
sock.Close(); // 5、关闭套接字
return 0;
}
服务端
#include<assert.h>
int main(int argc, char *argv[])
{
if(argc != 3)
{
printf("./serve 192.168.233.128 9000\n");
return -1;
}
std::string ip = argv[1];
uint16_t port = std::stoi(argv[2]);
tcpSocket doorman_sock;// 定义一个门迎socket
assert(doorman_sock.Socket()); // 1、创建套接字
assert(doorman_sock.Bind(ip, port)); // 2、进行地址信息绑定
assert(doorman_sock.Listen()); // 3、开始监听
while(1)
{
tcpSocket waiter_sock;// 定义一个服务员socket
std::string cli_ip;// 定义一个ip用来接收Accept中绑定的客户端五元组信息
uint16_t cli_port; // 和上面ip一起来绑定信息
// 4、建立连接
int ret = doorman_sock.Accept(&waiter_sock, &cli_ip, &cli_port);
if(ret < 0)
{
// 没有接收到所以没有对该服务员进行初始化,也就不需要进行销毁
continue;
}
// 5.收发数据
std::string data;
ret = waiter_sock.Recv(&data);
if(ret < 0)
{
waiter_sock.Close();// 因为接收这边出问题了,所以把已经初始化的服务员结构体给销毁
continue;
}
std::cout<<cli_ip<<" "<<cli_port<<" client say:"<<data<<std::endl;
data.clear();
std::cout<<"sever say:";
fflush(stdout);
std::cin>>data;
waiter_sock.Send(data);
if(ret < 0)
{
waiter_sock.Close();// 因为接收这边出问题了,所以把初始化的服务员结构体给销毁
continue;
}
doorman_sock.Close();
return 0;
}
}
上述的TCP通信只能实现一个客户端和服务端进行通信,如果要实现另外一个客户端的连接就必须得把当前客户端退出,显然存在着不合理性。
单执行流无法完成TCP多个进程之间的通信,必须使用多进程或多线程来完成
使用多进程实现服务端:
在Accept之后让子进程来完成数据收发的工作,每次有客户端连接后,服务端就新建一个新的套接字结构体来创建子进程,让这个子进程去做后面的事儿。在子进程内部实现当前客户端与服务端的通信,当另一个客户端发送链接请求后,就再创建一个新的套接字来完成新的通信工作
main函数内部
#include<signal.h>
int main(int argc, char *argv[])
{
if(argc != 3)
{
printf("./serve 192.168.233.128 9000\n");
return -1;
}
std::string ip = argv[1];
uint16_t port = std::stoi(argv[2]);
signal(SIGCHLD, SIG_IGN);
tcpSocket doorman_sock;// 定义一个门迎socket
assert(doorman_sock.Socket()); // 1、创建套接字
assert(doorman_sock.Bind(ip, port)); // 2、绑定地址信息
assert(doorman_sock.Listen()); // 开始监听
while(1)
{
tcpSocket waiter_sock;// 定义一个服务员socket
std::string cli_ip;// 定义一个ip用来接收Accept中绑定的客户端五元组信息
uint16_t cli_port; // 和上面ip一起来绑定信息
bool ret = doorman_sock.Accept(&waiter_sock, &cli_ip, &cli_port);
if(ret == false)
{
// 没有接收到所以没有对该服务员进行初始化,也就不需要进行销毁
continue;
}
std::cout<<"newclient:"<<cli_ip<<":"<<cli_port<<std::endl;
create_worker(waiter_sock); // 在最后调用该函数,进行子进程的创建
}
doorman_sock.Close();
return 0;
}
create_worker内部(实现子进程创建及收发数据功能)
void create_worker(tcpSocket new_sock)
{
pid_t pid = fork();
if(pid < 0) // 创建失败 关闭套接字return返回
{
new_sock.Close();
perror("fork error");
return;
}
else if(pid > 0)
{//父进程退出
new_sock.Close(); // 如果为父进程那么直接进行退出即可
return;
}
while(1)
{
std::string buf; // 以下就为收发数据
ssize_t ret = new_sock.Recv(&buf);
if(ret == 0) // 接收数据返回值为0时表示连接断开(因为网络或者其他因素)
{
printf("connect error\n");
new_sock.Close();
break;
}else if(ret == -1)
{
perror("recv error");
break;
}
std::cout<<"client say:"<<buf<<std::endl;
buf.clear();
std::cout<<"sever say:";
fflush(stdout);
std::cin>>buf;
ret = new_sock.Send(buf);
if(ret < 0){
perror("send error");
new_sock.Close();
break;
}
}
exit(-1);
}
使用多线程实现服务端
相较于多进程版本,流程上没有多大区别,也是获得一个新建连接之后创建一个线程出来
线程之间共用同一个文件描述符,因此对这个通信套接字描述符的操作,只能由负责这个描述符操作的线程进行关闭,其他线程不能关闭
main函数和上述基本一致
int main(int argc, char *argv[])
{
if(argc != 3)
{
printf("./serve 192.168.233.128 9000\n");
return -1;
}
std::string ip = argv[1];
uint16_t port = std::stoi(argv[2]);
tcpSocket doorman_sock;// 定义一个门迎socket
assert(doorman_sock.Socket()); // 1、创建套接字
assert(doorman_sock.Bind(ip, port)); // 2、绑定地址信息
assert(doorman_sock.Listen()); // 开始监听
while(1)
{
tcpSocket waiter_sock;// 定义一个服务员socket
std::string cli_ip;// 定义一个ip用来接收Accept中绑定的客户端五元组信息
uint16_t cli_port; // 和上面ip一起来绑定信息
bool ret = doorman_sock.Accept(&waiter_sock, &cli_ip, &cli_port);
if(ret == false)
{
// 没有接收到所以没有对该服务员进行初始化,也就不需要进行销毁
continue;
}
std::cout<<"newclient:"<<cli_ip<<":"<<cli_port<<std::endl;
create_worker(waiter_sock);
}
doorman_sock.Close();
return 0;
}
create_worker中调用线程创建接口
void create_worker(tcpSocket sock)
{
pthread_t tid; // 这里的arg采用一个值来硬传
int ret = pthread_create(&tid, NULL, pthread_entry, (void*)sock.get());
if(ret != 0)
{
sock.Close();
return;
}
// 这里不能进行线程等待,等待就会发生阻塞,因此使用分离属性(退出后自动回收资源)
pthread_detach(tid);
return;
}
线程入口函数执行收发数据功能
void *pthread_entry(void *arg)
{
long fd = (long)arg; // 注意使用long来接收这个值
tcpSocket new_sock;
new_sock.set(fd);
while(1)
{
std::string buf;
ssize_t ret = new_sock.Recv(&buf);
if(ret == 0)
{
printf("connect error\n");
new_sock.Close();
break;
}else if(ret == -1)
{
perror("recv error");
break;
}
std::cout<<"client say:"<<buf<<std::endl;
buf.clear();
std::cout<<"sever say:";
fflush(stdout);
std::cin>>buf;
ret = new_sock.Send(buf);
if(ret < 0){
perror("send error");
new_sock.Close();
break;
}
}
return NULL;
}
使用指针来处理多线程创建时的传参问题:arg
void *pthread_entry(void *arg)
{
tcpSocket *new_sock = (tcpSocket*) arg;
while(1)
{
std::string buf;
ssize_t ret = new_sock->Recv(&buf);
if(ret == 0)
{
printf("connect error\n");
new_sock->Close();
break;
}else if(ret == -1)
{
perror("recv error");
break;
}
std::cout<<"client say:"<<buf<<std::endl;
buf.clear();
std::cout<<"sever say:";
fflush(stdout);
std::cin>>buf;
ret = new_sock->Send(buf);
if(ret < 0){
perror("send error");
new_sock->Close();
break;
}
}
return NULL;
}
void create_worker(tcpSocket *sock)
{
pthread_t tid;
int ret = pthread_create(&tid, NULL, pthread_entry, (void*)sock);
if(ret != 0)
{
sock->Close();
return;
}
// 这里不能进行线程等待,等待就会发生阻塞,因此使用分离属性(退出后自动回收资源)
pthread_detach(tid);
return;
}
int main(int argc, char *argv[])
{
if(argc != 3)
{
printf("./serve 192.168.233.128 9000\n");
return -1;
}
std::string ip = argv[1];
uint16_t port = std::stoi(argv[2]);
tcpSocket doorman_sock;// 定义一个门迎socket
assert(doorman_sock.Socket()); // 1、创建套接字
assert(doorman_sock.Bind(ip, port)); // 2、绑定地址信息
assert(doorman_sock.Listen()); // 开始监听
while(1)
{
tcpSocket *waiter_sock = new tcpSocket;// 定义一个服务员socket
std::string cli_ip;// 定义一个ip用来接收Accept中绑定的客户端五元组信息
uint16_t cli_port; // 和上面ip一起来绑定信息
bool ret = doorman_sock.Accept(waiter_sock, &cli_ip, &cli_port);
if(ret == false)
{
// 没有接收到所以没有对该服务员进行初始化,也就不需要进行销毁
continue;
}
std::cout<<"newclient:"<<cli_ip<<":"<<cli_port<<std::endl;
create_worker(waiter_sock);
}
doorman_sock.Close();
return 0;
}
总结一下载TCP通信部分的特殊情况问题
问题一:
连接断开:TCP是面向连接的通信,一旦断开连接就无法通信
在recv函数接收数据的时候,返回0,代表的不仅仅是没有接收导数据,
更多的是为了表示连接断开了,所以在代码中if()判断之后打印connect error
当send函数发送数据的时候,程序直接异常(SIGPIPE)退出,
比如登录一个qq因为我没联网,就一点开就退出,一点开就退出,这可不合理
因此网络通信中不想让程序因为连接断开而导致发送数据的时候异常退出,
使用SIGPIPE来进行处理
#include<signal.h>
signal(SIGPIPE, SIG_IGN);
模拟实现:首先我正常运行服务端、客户端,接着我关闭掉我的服务端,然后进行输入数据的时候数据发送出去了,但是收到的回复是个空的,接着在进行输入数据,这时发生崩溃
① 为什么第一次输入的时候还可以正常输入呢?
因为数据是发送到缓冲区中去了,等到一次收发结束了到下一次进行收发数据的时候才能检测到已经断开连接了。
② 为什么第二次输入的时候直接崩溃退出了
这是因为触发了异常,通信连接发生中断,触发了13号信号SIGPIPE,(通信失效),所以程序直接退出了,可是正常的逻辑:一个程序不能因为连接不上网络就直接退出了,所以使用上面的signal(SIGPIEPE,SIG_IGN)来屏蔽掉这个异常信号
处理后:就算服务端退出了,客户端也不会主动退出了
问题二:
网络程序关闭之后,无法立即重新启动,会bind绑定地址错误,绑定失败,地址已经被使用
如图:先建立起服务端,接着执行CTRL+C给他一个中断命令,然后我再重新建立服务端就会发生报错
大概过了一俩分钟后,这个地址端口又能绑定了
这是正常的,因为一个程序主动关闭了连接,这个连接并不会立即被释放(对应的地址和端口依然被占用),而是要等待一段时间,这时候要么换一个端口绑定,要么就等一会
然而这项操作放在客户端却不会发生(因为不进行绑定嘛)
netstat命令学习:
使用netstat,查看主机上所有网络连接状态的命令
因为一个网络通信程序运行起来以后,如果没有达到预期的目标,首先就要看看程序对应的网络连接是否正常
a 查看所有信息
n 不要以服务名称显示端口或地址(22号端口被识别为ssh、237.0.0.1会被识别为localhost)
p 显示网络连接信息的时候,顺便显示连接对应的进程ID和名称
t 只显示tcp套接字信息
u 只显示udp套接字信息