之前我们粗浅的认识了一下网络的一些知识,比如OSI七层模型,TCP/IP四层模型,那么我们具体怎么实现两台主机的交互呢?
在学习这些之前,我们需要准备一些预备知识。
目录
预备知识
1:认识源IP地址和目的IP地址
2:认识端口号(port)
3:理解端口号和进程ID
4:认识源port和目的port
认识TCP协议
认识UDP协议
网络字节序
SOCKET编程接口
简单的UDP程序
简单的UDP服务器
IP地址简单讲解
简单的UDP客户端
简单的TCP程序
TCP服务器
TCP客户端
守护进程
会话,前台任务和后台任务
如何创建守护进程
忽略异常信号
不可选择组长
关闭或者重定向默认打开的文件
TCP通讯流程
三次握手
服务器初始化
建立连接
四次挥手
断开连接
TCP协议和UDP协议对比
预备知识
1:认识源IP地址和目的IP地址
在我们的IP数据包报头中,有两个IP地址,一个是源IP地址,一个是目的IP地址,而这些IP我们一般称之为公网IP,用来标示一台唯一的主机的。
源IP地址:标示数据包的发送主机
目的IP地址:标示数据包的接收主机
但是我们在日常生活中使用手机或者计算机,实际上并非只需要有对方的IP地址就能够相互交流,在日常生活中,我们都是通过微信,或者QQ之类的软件才能相互之间发送数据。
而只有一个IP地址显然是不够用的,那么就轮到端口号出场了。
2:认识端口号(port)
端口号 :端口号是传输层协议的内容,它是一个两字节16位的整数(uint16_t),用来标示一个进程,用来告诉操作系统这个数据要交给哪个进程来处理,并且一个端口号只能由一个进程占用。
当我们使用 IP+port 就能标示网络上的某一台主机上的某一个进程,这样用户就能够成功建立通信。
说到这里我们就应该明白,实际上网络通信的本质就是进程间通信。
port 用来标示进程的唯一性,而 pid 也是用来标示进程的唯一性的,这两者有什么关联吗?
3:理解端口号和进程ID
我们都知道在主机中,pid 是用来标示进程的唯一性的,这里的 port 号也是用来标示进程的唯一性的,那么为什么不直接用 pid 来表示 port 呢?
理由
1:系统是系统,网络是网络,需要将系统和网络解耦
2:客户端每次都需要找到服务器进程,就决定了服务器的唯一性不能改变,而 pid 是能够轻易改变的
4:认识源port和目的port
源port 就是指发送数据的进程,而目的 port 就是接收数据的进程。
当我们了解了IP地址和 port 之后,我们就需要初步了解下网络的两个协议——TCP和UDP
认识TCP协议
TCP协议,又叫传输控制协议,它是传输层的协议,具有以下特点
1.有连接——在IO之前需要先建立连接
2.可靠传输——当出现错误的时候能够有相应的策略应对
3.面向字节流——通过字节流进行IO
认识UDP协议
UDP协议,又叫用户数据报协议,它也是传输层的协议,具有以下特点
1.无连接——IO之前不需要建立连接
2.不可靠传输——没有相应的错误应对策略
3.面向数据报——通过数据报进行IO
网络字节序
我们都知道,在内存中,数据的存储分为大端和小端,而网络的数据量也有大小端之分。
TCP/IP协议规定,任何一台主机,它的网络数据流都采用大端字节序。
而为了使网络程序具有可移植性,有以下库函数做网络字节序和主机字节序的转换。
#include<arpa/inet.h>
uint32_t htonl(uint32_t hostlong);//主机字节序转网络字节序——长整型
uint16_t htons(uint16_t hostshort);//主机字节序转网络字节序——短整型
uint32_t ntohl(uint32_t netlong);//网络字节序转主机字节序——长整型
uint16_t ntohs(uint16_t netshort);//网络字节序转主机字节序——短整型
虽然看上去比较难记,但实际上很好记,其中 h 表示 host ,也就是指主机,n 标示 net,也就是指网络,s 指 short,就是指16位的整数,l 是指 long ,就是指32位的整数。
SOCKET编程接口
socket 这个东西想必大家都很陌生。
在上面我们都知道,两个主机上的进程想要通信,就一定需要知道对方主机的 ip 和进程的 port,而 socket 实际上就是指 ip + port ;
而 socket 也是有自身的编程接口
// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器 )int socket(int domain, int type, int protocol);// 绑定端口号 (TCP/UDP, 服务器 )int bind(int socket, const struct sockaddr *address, socklen_t address_len);// 开始监听 socket (TCP, 服务器 )int listen(int socket, int backlog);// 接收请求 (TCP, 服务器 )int accept(int socket, struct sockaddr* address, socklen_t* address_len);// 建立连接 (TCP, 客户端 )int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
这些接口有点只能在TCP协议中用,有的TCP和UDP都能使用,有的只能在服务器上用,有点能在客户端上用,根据场景不同使用。
而在这些函数中,我们都看见一个参数类型是 struct sockaddr* 类型的,这个类型我们也需要了解。
socket API 是一层抽象的网络编程接口,适用于各种底层网络协议。
但是各种网络协议的地址各不相同。
在上图中,我们发现最左边的sockaddr是函数中所需要传参的变量类型,而右边则是两种没有看到过的类型。
实际上这是这套接口的发明者所用的一个小技巧,他将所有接收的变量类型定义为sockaddr,而不同协议则需要使用不同的 sockaddr,例如TCP和UDP协议都是使用的sockaddr_in的类型,而sockaddr_un 则有不同的协议使用。
因此,我们在外部调用这类函数的时候,只需要根据自己使用的协议来使用不同的sockaddr类型,然后在里面填充数据后强转再传参,而具体的操作和判断就交给函数内部进行。
sockaddr结构
sockaddr_in结构
当我们有了以上的预备知识后,我们便先来学习下UDP协议下的通信以及代码实现。
简单的UDP程序
经过上面的预备知识,我们知道,要进行网络通信,那么一定是需要 IP地址 和 端口号 port 的,而且socket 套接字也是必不可少的,因此,我们想要编写一个UDP程序,这三者缺一不可。
简单的UDP服务器
我们首先以UDP服务器的编写为示范。
在定义好UDPSever的成员变量后,便是需要对这些变量初始化。
这里我们依靠外面传参决定服务器使用几号端口进行通信,而 IP 地址,我们默认设为 0.0.0.0;
至于为什么 IP 是 0.0.0.0,则在后面进行解答。
在初始化完成员变量后,我们便是需要将服务器给初始化完成。
在start函数中,我们首先创建了一个 socket 套接字。
其中,我们传了三个参数:AF_INET,SOCK_DGREAM,0。
先来了解这三个参数分别代表什么吧。
首先第一个 domain 参数,表示我们是想进行网络通信还是本地通信。这里我们的domain 参数是 AF_INET,表示我们是网络通信,且IP地址是 IPv4 地址格式。
而第二个参数 type 表示我们的socket 所提供的能力类型,即是使用流式套接字还是使用用户数据报套接字。
而第三个参数protocol 则是表示我们采用TCP还是UDP,不过实际上我们前两个参数就已经决定我们使用什么协议了,因此我们这里默认给 0;
而在拿到套接字之后,我们需要将 ip 地址和 port 号绑定给该套接字中,而要将 ip 地址 和 port 号两个变量绑定给套接字,我们需要使用bind函数。
bind 函数需要三个参数:sockfd, addr, addrlen。
这里需要提一嘴的是,实际上我们在上面所申请的 socket 实际上类似于文件描述符,如果说之前的socket函数的操作都属于文件范畴,那么这里的 bind 函数的操作就属于网络操作的范畴。
它会告诉操作系统,这个套接字是属于哪个端口号的,这样我们才能成功的通过套接字来进行网络通信。
而addr 这个变量在预备知识我们已经了解过,我们并非是使用 sockaddr 这个类型,而是使用sockaddr_in 这个类型。
预备知识中也说过,sockaddr_in这个类型需要我们手动填充,我们首先将协议定为AF_INET
然后将 IP 地址通过 inet_addr 转换后,填入其中。
关于IP地址这里需要简单讲解一下。
IP地址简单讲解
IP地址分为点分十进制风格和整数风格。
其中点分十进制是一个字符串,可读性好,而整数风格的IP地址则用于网络通信。
因此我们的IP地址都是需要通过函数转换一下才能使用。
此外,我们在前面的预备知识都了解过,网络也有自己的字节序,因此我们的port 端口号需要使用的话,也是需要通过相应的函数进行转化才能在网络上使用。
当我们填充完该变量后,就只需要直接将该变量和socket直接绑定即可。
至此,我们的服务器就初始化完成了。
接着我们就需要让服务器开始运行。
在 start 函数中,我们使用buffer作为缓冲区,用recvfrom函数从套接字中接收数据,把数据放入buffer 中,并且用peer变量来接收客户端的 ip 地址 和 port 号。
虽然服务器并不主动开始通信,但是服务器有时需要返回数据,到时候就需要使用客户端的 ip 地址 和 port 号。
接着我们用 cip 和 cport 分别将peer 中的字段转换后接收,并且输出客户端发送的信息。
一个简易版的UDP服务器就完成了。
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string>
#include <string.h>
using namespace std;
namespace UDP
{
static const string defaultip = "0.0.0.0";
class Sever
{
public:
Sever(uint16_t port, string ip = defaultip)
: _port(port), _ip(ip)
{
}
void start()
{
//初始化套接字
_socket = socket(AF_INET,SOCK_DGRAM,0);
if(_socket == -1)
{
cerr<<"socket err!"<<endl;
exit(-1);
}
cout<<"socket successed!"<<endl;
//绑定IP 和 port
struct sockaddr_in local;
bzero(&local,sizeof(local));
local.sin_family = AF_INET;
local.sin_addr.s_addr = inet_addr(_ip.c_str());
local.sin_port = htons(_port);
int n = bind(_socket,(struct sockaddr*)&local,sizeof(local));
if(n == -1)
{
cerr<<"bind failed!"<<endl;
exit(-1);
}
cout<<"bind successed!"<<endl;
}
void run()
{
while(true)
{
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
bzero(&peer,sizeof(peer));
size_t n = recvfrom(_socket,buffer,sizeof(buffer),0,(sockaddr*)&peer,&len);
if(n == -1)
{
cout<<"recvfrom failed!"<<endl;
exit(-1);
}
cout<<"recvfrom successed!"<<endl;
string cip = inet_ntoa(peer.sin_addr);
uint16_t cport = ntohs(peer.sin_port);
buffer[strlen(buffer) - 1] = 0;
cout<<"Client["<<cip<<"]["<<cport<<"] # "<<buffer<<endl;
}
}
~Sever()
{
}
private:
int _socket;
string _ip;
uint16_t _port;
};
}
接着我们就需要写一个外部调用代码。
我们在UDPSever.cc中写下如下代码,就能够成功运行了。
#include"UDPSever.hpp"
#include<memory>
using namespace UDP;
int main(int args,char* argv[])
{
if(args != 2)
{
cout<<"./UDPSever port"<<endl;
exit(-1);
}
uint16_t port = atoi(argv[1]);
unique_ptr<UDP::Sever> usp(new Sever(port));
usp->start();
usp->run();
return 0;
}
写完服务器,接着就是客户端了。
简单的UDP客户端
一个客户端和服务器都是需要相同的成员变量,但是不同的是客户端的IP地址不能为0.0.0.0;
而必须是服务器的IP地址,这样才能使得客户端成功连接到服务器。
而之后的操作,客户端和服务器基本一致。
客户端的初始化相比服务器而言,少了绑定操作,因为客户端一般是主动向其他主机发送连接请求,只需要通过函数发送数据即可。
然后就是客户端的运行了。
我们也是和服务器一样的操作,不过不同的是,客户端是将数据送出去,而非拿进来。
#include <iostream>
#include <string>
#include <stdlib.h>
#include <strings.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <sys/types.h>
using namespace std;
namespace UDP
{
class Client
{
public:
Client(string ip, uint16_t port)
: _ip(ip), _port(port)
{
}
void start()
{
_socket = socket(AF_INET, SOCK_DGRAM, 0);
if (_socket == -1)
{
cout << "socket failed!" << endl;
exit(-1);
}
cout << "socket successed!" << endl;
}
void run()
{
struct sockaddr_in peer;
peer.sin_family = AF_INET;
peer.sin_addr.s_addr = inet_addr(_ip.c_str());
peer.sin_port = htons(_port);
while (true)
{
char buffer[1024];
cout<<"Enter # "<<endl;
fgets(buffer,sizeof(buffer),stdin);
sendto(_socket,buffer,sizeof(buffer),0,(sockaddr*)&peer,sizeof(peer));
}
}
~Client()
{
}
private:
int _socket;
string _ip;
uint16_t _port;
};
}
此外就是客户端的外部调用了。
#include"UDPClient.hpp"
#include<memory>
using namespace UDP;
int main(int args,char* argv[])
{
if(args != 3)
{
cout<<"./UDPClient ip port"<<endl;
exit(-1);
}
string ip = argv[1];
uint16_t port = atoi(argv[2]);
unique_ptr<Client> upc(new Client(ip,port));
upc->start();
upc->run();
}
这样我们的简单的UDP客户端就完成了。
我们来看看成果吧!
由于笔者是在一台电脑上进行通信,因此这里客户端输入的 IP 地址为 127.0.0.1,即本地回流,表明接收的IP地址是本机。
此外,客户端上输入的端口号应该和服务器的端口号相同,因为客户端输入的IP地址和端口号都是服务器的IP地址和端口号。
那么这里就有一个问题,既然我们客户端所有初始化的 IP 地址 和 端口号都是服务器的,那么服务器又是怎么知道客户端的 IP 地址和 端口号的呢?
实际上,客户端在发送数据之前,操作系统会自动选择一个可用的本地IP地址和端口号来和目标主机建立连接,因此不用我们手动建立连接。
简单的TCP程序
TCP服务器
在做完一个简单的UDP协议服务器和客户端后,自然就要试试写一个TCP服务器了。
实际上TC服务器和UDP服务器的代码十分相似,只有个别不同,我们直接看代码。
在代码中我们能够看到,TCP服务器的初始化和UDP服务器的初始化是十分相似的,只有两点不同。
1:socket函数创建套接字的时候,第二个参数是SOCK_STREM。
2:初始化时有listen函数,来设置套接字的状态。
关于为何socket函数第二个参数是SOCK_STREM,这是因为TCP协议是面向字节流的,因而使用SOCK_STREM。
而listen函数也是因为TCP协议是面向连接的,listen函数使套接字处于监听状态。
listen函数一共两个参数,第一个就是需要改变状态的socket,第二个参数表明最多允许backlog个客户端处于连接等待状态,连接到更多请求就会忽略。
将服务器初始化后,我们就需要写运行代码。
在运行代码中,我们发现,有一个accept函数,我们先来看一下这个函数是干嘛的。、
对于accept函数,它的三个参数我们都不陌生,第一个就是进入监听状态的listensocket,后面两个参数我们在UDP协议中使用recvfrom的时候就已经见识到了。
重点在于accept函数的返回值。
根据手册的描述,accept函数返回值也是一个socket套接字!
那么这个返回的socket和我们设为监听的listensocket又有什么区别呢?
实际上,listensocket的作用并不是用来通信的,是用来监听是否有新的连接到来。
而真正进行通信的实际上是accept函数返回的socket。
紧接着,我们就能够使用seviceIO函数,在函数内部进行真正的通信。
我们的函数内部使用read函数从socket里面接收信息,从而成功通信。
然而,我们这里有一串代码需要详细的讲一下:
这串代码从字面意思上来看,就是父进程创建一个子进程,然后在子进程里面创建一个孙子进程,然后将子进程关闭,由孙子进程进行通信,然后父进程回收子进程,这是为什么呢?
首先我们都知道,子进程在退出的时候,若是父进程不等待,就会导致子进程变成僵尸进程,从而导致资源泄露等问题。
但是,如果我们的父进程等待子进程,就会造成串行的问题,因为等待是阻塞的,这样我们的服务器就不能对多个客户端请求进行处理。
因此,我们这里创建了一个孙子进程,然后将子进程退出,这样孙子进程就会被1号进程领养,当孙子进程退出时,会自动回收资源,而父进程在子进程退出时就已经回收它的资源了。
这样就不会出现串行的问题,也不会出现资源泄露的问题。
当然,这里有多种写法,我们可以使用多线程的写法,就不用关心这么多了。
TCP客户端
对于客户端,实际上也和UDP协议相差不多。
首先是客户端初始化的时候,创建套接字。
其次便是运行。
我们发现TCP的客户端比UDP的客户端多一个connect的步骤,实际上也是因为TCP是面向连接的,因此需要用connect与服务器建立连接。
我们先来看看connect的描述。
实际上connect的具体用法相信不用说都能够明白了,重点是这一串描述。
这句话说:如果连接或者绑定成功了,返回0,错误返回1.
也就是说,TCP客户端的隐式绑定是在connet的时候发生的,这和UDP客户端不同,UDP绑定是在客户端初次sendto的时候绑定的。
不过需要注意的是,客户端所保存的ip和port都是服务器的。
接着我们就能顺利运行起来了。
守护进程
目前,我们已经将TCP和UDP的服务器和客户端分别写了一遍,但是,真正的服务器并不是像我们这样,将程序一关就结束了,这个时候就需要讲一讲守护进程了。
在了解守护进程前需要一些预备知识。
会话,前台任务和后台任务
当我们打开xshell连接到服务器时,服务器内部会创建一个会话,然后再在会话里面创建一个bash,也就是命令行解释器,其中,bash就是一个前台任务。
在会话中,只能有一个前台任务,但是可以有多个后台任务。
而我们的后台任务可以创建多个。
我们创建两串进程组,分别是sleep 10000| sleep 20000| sleep 30000 和 sleep 40000| sleep 50000| sleep 60000,然后通过 & 将它们放到后台去。并且通过jobs指令查看到他们都在后台中。
而这两串进程组就是后台任务。
然后我们通过 ps 命令查看这些后台任务的时候,发现一件事。
这些后台任务有一个PGID,并且这两个进程组的PGID各不相同。
实际上PGID指的是该进程组的组长的PID,我们可以看到都有对应的进程ID。
此外,我们发现PGID旁边还有一个SID,而且这两个进程组的SID都一样,SID又是什么呢?
SIG:指该会话(session)的ID
此时,若是我们将后台进程变为前台任务就会发现我们的命令行解释器bash失效了。
通过fg + 后台任务号,我们将2号后台任务提到前台中,发现bash命令都不能用了,然后ctrl+z将2号任务暂停,才能够使用bash,然后使用bg + 后台任务号让2号任务继续运行起来。
而我们发现,前台任务和后台任务都是受用户的退出和注销的影响的,而想避免这种影响,就必须自成会话,自成进程组,和终端设备无关,这就是守护进程。
如何创建守护进程
创建守护进程需要我们自己写一个函数。
创建守护进程一共三步:
1.忽略异常信号
2.不可选择组长
3.关闭或者重定向进程默认打开的文件
忽略异常信号
有时候当一个客户端在写,而服务器未接收时,服务器会收到信号从而导致服务器崩溃,因此需要忽略异常信号。
不可选择组长
创建守护进程最重要的就是setsid函数,他会将调用该函数的进程设置为守护进程,但是前提是该进程不能是组长。
该函数的描述中就确定不能是组长,否则会报错。
关闭或者重定向默认打开的文件
在这里我先隆重讲解一下传说中的文件黑洞——/dev/null
想要创建一个守护进程,就必须将该进程的三个文件描述符全部重定向到该文件。
/dev/null是一个文件黑洞,任何放入其中的文件都会被丢弃。
说完三个需要注意的点,我们直接看代码。
#pragma once
#include <unistd.h>
#include <signal.h>
#include <assert.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
using namespace std;
#define DEV "/dev/null"
void daemonSelf()
{
// 1.忽略异常信号
signal(SIGPIPE, SIG_IGN);
// 2.让自己不成为组长
if (fork() != 0)
exit(0);
pid_t n = setsid();
assert(n != -1);
// 3.关闭或者重定向进程默认打开的文件
int fd = open(DEV,O_RDWR);
if(fd >= 0)
{
dup2(fd,0);
dup2(fd,1);
dup2(fd,2);
close(fd);
}
else
{
close(0);
close(1);
close(2);
}
}
首先第一步,我们直接忽略掉异常信号SIGPIPE;
其次,我们fork一下,让子进程调用setsid,这样就不是组长进程了;
最后,我们打开文件黑洞,然后将文件都重定向到里面,否则就关闭它们。
这样就完成了,然后在服务器的调用中使用该函数即可。
然后我们看看表现。
我们发现确实这个进程不是前台任务了,而且它的PID,PGID和SID都是它自己,说明它确实已经成为了守护进程了!
TCP通讯流程
TCP的客户端和服务器的通信流程用一句话就是三次握手和四次挥手。
三次握手
服务器初始化
1.通过socket函数创建文件描述符。
2.调用bind函数,将该文件描述符和ip与port绑定,若port号被占用,绑定失败。
3.通过listen函数,声明该文件描述符作为服务器的文件描述符,为accept做准备。
4.阻塞等待accept返回,等待客户端连接。
建立连接
1.当客户端初始化后,通过connect向服务器发起连接请求。
2.connect发送SYN段并且阻塞等待服务器应答(第一次)。
3.服务器收到SYN段会返回SYN-ATK表示同意建立连接(第二次)。
4.客户端收到SYN-ATK段从connect函数返回,并且应答一个ATK段(第三次)。
当客户端和服务器通过三次握手建立连接后,两边可以重复通过write和read函数进行通信。直到调用close函数关闭文件描述符。
四次挥手
断开连接
1.当客户端调用close关闭连接时,客户端向服务器发送FIN段(第一次)。
2.服务器收到FIN段后,会回应ACK段,同时read返回0(第二次)。
3.read返回后,服务器就知道客户端断开连接,会调用close断开连接,同时发送FIN(第三次)。
4.客户端收到FIN,再返回一个ACK给服务器(第四次)。
通过四次挥手,客户端和服务器就断开了连接。
TCP协议和UDP协议对比
TCP特点
1.字节流。
2.面向连接。
3.可靠传输。
UDP特点
1.用户数据报。
2.无连接。
3.不可靠传输。
也许这里大家看到UDP是不可靠的时候,就会认为UDP不好用,其实这里的不可靠是个中性词,不可靠意味着UDP简单,快速,而TCP可靠也意味着TCP复杂。
因此大部分时间都是通过具体环境使用两种协议。