预备知识
源IP地址与目的IP地址
IP地址(公网IP)通常用来区分互联网中的唯一一台主机。
源IP与目的IP对一个报文来讲回答了两个问题:从哪来,到哪去。其最大的意义是指导一个报文该如何进行路径选择,到哪里去就是我们根据目标进行路径选择的依据。
端口号
两个主机之间通信,数据从一个主机到达另一个主机不是目的,而数据到目标主机上的一个进程,进程给他提供数据处理的服务,这才是目的。
数据最开始的时候,不是在计算机上凭空产生的,计算机本身不产生数据,产生数据的是人,人是通过特定的客户端产生的数据。所以本质上,所有的网络通信,在人的角度,是人与人之间的通信,而技术视角,本质是进程间通信!!。 比如:抖音APP的客户端(启动客户端不就是一个进程)<—>抖音的服务器(也是进程)
但是IP仅仅是解决了两台物理机器之间互相通信,但是我们还要考虑如何保证双方的用户之间能够看到发送的和接受的数据呢?
真正让报文离开的是客户端进程,然后报文经过一些列封装,解包,路由转发,再封装最终到达了目的主机,但是到达主机不是目的,还要向上解包分用,找到服务端进程,然后对这个数据进行处理服务,再让这个报文从服务端主机一路传递到客户端主机,再在客户端主机解包分用,最终交付到客户端进程,这才完成了进程与进程间的通信,这两个进程分别代表的就是客户与服务端APP比如抖音、快手给我们提供的服务。所以,所谓的服务就是一个写好的进程。
当一个报文被送到主机B,但是主机B上不一定只有一个进程,同理,主机A上也可能有多个进程,IP只能保证数据能够传输到正确的主机上,如何保证数据可以传输到正确的进程中?使用的就是端口号。
端口号(PORT):唯一的标识一台(特定)机器上的唯一的一个进程。
端口号是一个2字节,16bit位的整数 一个端口号只能关联一个进程,但是一个进程可以关联多个端口号 ip有源ip和目的ip,那么端口号也有源端口号和目的端口号
IP可以确定唯一的一台主机,PORT可以确定主机上唯一的一个进程,那么IP+PORT这样就可以确定互联网中唯一的一对进程之间通信。
如果把整个网络看作是一个大的OS,所有的网络上网行为,基本上都是在这个大的OS内进行进程间通信
而IP地址+端口号(port)=套接字(socket)
端口号与进程ID
为什么要用端口号标识进程,OS内不是有进程ID吗? 用进程ID标识是绝对可以实现的,但是这里重新定义端口号最主要的目的是为了解耦,这样无论操作系统怎么变化,都不会影响到网络通信。
IP地址与端口号
互联网本质上是以进程为代表让特定的人与服务进行数据层面上的交互,其本质就是进程间通信,而通信的进程一定是在某一个手机、单片机,电脑、云服务器等 一定要有一个对应的具体设备,所以,要进行通信: 1.先找到目标主机 2.再找到该主机上的服务(进程) 结论:互联网世界,是一个进程间通信的世界。
万物互联的本质: 所有的设备都具备一个可以代表该设备的进程。这些进程之间进行通信。
进程具有独立性,进程间通信的前提是:先让不同的进程看到同一份资源->网络(系统角度:临界资源)
TCP与UDP协议
在整个协议栈中,应用层是在用户层面的,剩下的传输层,网络层,数据链路层是在操作系统内部的,所以基于FTP向上,就可以写很多的应用,而自己写应用,访问的一定是OS提供的接口,里应用层最近的就是传输层,所以写常规套接字时,用的都是传输层的接口(TCP/UDP)
TCP(Transmission Control Protocol 传输控制协议)
好处: 传输层协议 有连接 可靠传输 面向字节流
用来保证可靠性,可以理解为一个靠谱的协议
UDP(User Datagram Protocol 用户数据报协议)
好处: 传输层协议 无连接 不可靠传输 面向数据报
不可靠传输。不提供可靠性。
TCP有链接,UDP无链接(编写代码的差异)。
TCP与UDP是两种性格的协议,如何理解TCP可靠与UDP不可靠 这里的可靠与不可靠是一个中性词,TCP保证可靠性哪他一定会花费更多的资源,UDP不保证可靠有那一定意味着他更简单,所以这两种协议的选择需要考虑使用场景,没有单纯的好与坏。
网络字节序
我们已经知道,机器是有大端机器和小端机器的,因为数据是有高权值位和低权值位的,而内存是有高地址和低地址之分的,所以数据存储就是以字节为单位,把哪些字节放在哪些地址上,如同在一间教室我要放一些桌子,并且给内每张桌子都编了号,我是让编号大的放在教师前面还是让编号小的放在教室前面。大小端本身是一种地址和数据的对应关系,假设我是一个大端机器,实际在网络中发数据时我永远按照我内存中的低地址处开始发,所以在大端的情况下,先发的永远是低地址,而大端机的低地址放的永远是高位的数据,那如果两台机器不一样,那我把我的数据发给你,但是我们两台机器看待这个数据的方式是相反的,这种情况怎么解决?
为了解决上面的问题,规定:凡是到达网络中的数据,必须是大端,如果两个主机不通信,那可以各玩各的,但是如果你们要通信,小段机器必须把数据转成大端,大端不用改变,这就给双方带来了确定性,发的人一定会发大段,收的人知道自己收的一定是大段,这就是网络字节序。
在计算机中虽然常规发的数据会做大小端转化,但是实际上在编写套接字接口用一些套接字时有些数据是需要我们自己把某些数据转成大小端的,比如: 在通信时有两个数据是一定要从一个主机发送到另一个主机上的:我的ip,我的端口。(因为我发送了消息别的主机还可能会回复我)这样的数据就可能会有主机转网络,网络转主机这样的字节序变化,所以系统给我们提供了一些函数,只做16和32位的转化
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong);
uint32_t htons(uint16_t hostshort);
uint32_t htonl(uint32_t netlong);
uint32_t htons(uint16_t netshort);
函数名中的h(host)表示主机,to表示变成,n(net)表示网络,l(long)表示32位,s(short)表示16位
常用的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);
我们在进行网络通信时,我们一定要告知我运行起来的程序要绑定那个端口,绑定那个ip,所以我们需要用户向系统传入一些参数,这里会有一个数据类型sockaddr,这是网络特意给我们设置的一个结构体。网络通信的标准方式有很多种,我们学习的是基于ip的网络通信,对应的通信协议的家族叫做AF_INET,还有通信方式比如原始套接字(绕过TCP,直接使用IP的接口),域间套接字(与管道类似),如果每一种方式都有一套接口的话,按理应该有三套接口。但是网络的设计者不想把这些套接字分开,想把这些套接字至少系统接口统一化,不同种类的通信方式可以使用同一套接口完成,就是现在学习的这一套。 所以要实现不同的通信种类,只要改变里面的参数就可以,为了支持不同形态的套接字接口,就设计出了sockaddr结构,这个结构是一种通用结构。
sockaddr结构
如果不使用同一套接口,假设使用的是struct sockaddr_in(IPv4)这种数据通信格式,这个格式字段里具体会包含三个字段,16位类型表示代表的通信类型也叫做协议家族,16位端口号,32位IP。域间套接字通信方式是16位通信类型和一个108字节的路径,这种通信方式通常是本主机内通信使用。这是两种不一样的类型,但是接口的设计者就设计了一个struct sockaddr的结构,只有一个16位地址类型,所有的接口传入的都是sockaddr(可以强转传入),在接口内部对16位类型做判断,然后根据他的类型决定使用哪种通信方式。
这些结构类型都定义在头文件 <netinet/in.h> 中。
sockaddr结构: sockaddr_in结构:
为什么要这样单独设计一个结构,用void岂不是更方便?按理说当然可以用void,但是在这套网络结构被提出来的时候,C语言的标准还不支持void*,所以无法使用,那在计算机里一旦一个标准被提出并应用了,为了兼容以前的代码,就不能对他进行修改了。
编写简单的UDP服务
现在我要编写一个UDP服务,要实现的功能是: 客户端写一个字符串给服务器,服务器收到字符串再把相同的字符串返回回来(C(client)/S(server)模式) 编写流程:
写套接字必备头文件:
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
服务端
第一步:创建套接字
man socket
参数:
domain:套接字的种类,需要操作系统提供哪种通信服务 type:套接字类型,代表的是想要那种套接字 protocol:这个套接字使用的协议类型,TCP/UDP可以设为0,因为可以通过前面的参数明确协议类型。
返回值: 成功了就返回一个文件描述符,可以理解为创建套接字时,以文件方式打开了网卡设备,所以打开socket的进程就相当于打开了一个文件,所以他们之间的关系就像进程与文件的关系。进程底层会有一个指针数组,数组元素指向的就是一个个被打开的文件,数组的下标称作文件描述符。
作为一个服务器,肯定要让客户知道对应服务器的地址(ip+端口)
ping +域名//命令:可以查看该域名的ip,
我们用上面的命令随便查看一个网站得到对应额ip,然后直接用IP地址去访问可以发现也是可以的。说明线上互联网公司的服务必须要有服务的IP地址。
那端口号如何得知? 默认情况下,浏览器会自己选择端口号,网站型的服务都必须待被用户知道,所以每次访问就是浏览器在通过这些服务的ip和端口号发起套接字通信,让用户看到对应的内容。
那为什么我用APP的时候从来没输入过域名? 因为这些APP里面一定有配置文件或者代码里面就已经写好了服务器地址,用户也不用输入。
所以无论时直接让用户输入还是间接让用户使用,服务器的socket信息(ip+port)必须待被客户知道。 一般服务器的port,必须是众所周知(不仅仅是人,也可以是被各种软件,app,浏览器等)的,而且轻易不能被改变!!!
第二步:给该服务器绑定端口和ip
man 2 bind
参数:
sockfd:用socket创建好的套接字 addr:通用结构。需要用户指定对应的服务器的相关套接字信息,通过结构体的方式传给操作系统 addrlen:传入结构体的大小
返回值: 成功返回0,失败返回-1。
传参数addr时有几点需要注意: 定义好了一个sockaddr结构的变量以后,需要自己向里面填参数,一共有以下一个参数: sin_family:协议家族/通信类型(我们用的是AF_INET) sin_port:16位的端口号。 可以自己定义一个uint16_t类型的变量做端口号。 也可以让端口号被命令行参数传进来,如果是从命令行参数获得的端口号,注意要把字符串类型转换成整形。 注意:此处的端口号是计算机上的变量,是属于主机序列,将来通信时可能需要互相交换,就需要使用系统提供的接口主机转网络
man htons
sin_addr:自己也是一个结构体,里面就一个成员变量s_addr,这里要填的是IP地址 注意:一般IP地址用点分十进制,字符串风格表示,每一个范围【0-255】,一共四个,传入这个参数时需要做两件事:a.需要把人识别的点分十进制,字符串风格的IP地址转化成为4字节整数IP。b.也要考虑转换成网络字节序。这两步内容可以让用户自己做,但是系统给我们提供了接口inet_addr可以帮我们同时完成这两步。
man inet_addr
这里的参数直接把字符串形式的IP地址传进去就可以了,但是: 如果你的linux使用的是云服务器,是不允许用户直接bind公网IP的,并且实际正常编写的时候也不会指明IP,因为如果你绑定的是一个确定的IP(主机),意味着只有发到该IP主机上数据,才会交给你的网络进程,但是一般服务器可能有多张网卡,配置多个IP,我们需要的不是某个IP上面的数据,我们需要的是所有发送到该主机,发送到该端口的数据,那我要手动让socket把服务器上的IP都绑了?这样也不行,因为端口是一样的,就可能出现绑定重复的问题,这就需要设置选项端口复用,需要经过特殊处理。而一般比较简单常用的方式是使用宏:INADDR_ANY 它的效果就是不直接绑定IP地址,相当于不关心数据从那个IP上来,只要是我的端口,都会给我。其数值相当于0值。
第三步:提供服务 网络服务要一直提供,所以所有的网络服务都一定是一个死循环
首先,我要以udp的方式从网络中读数据
man recvfrom
参数:
sockfd:从哪个套接字读(刚刚创建好的) buf:读出来的数据要自己放在你提供的缓冲区里 len:你自己提供的缓冲区大小 flags:读的方式,默认为0就可以 剩下的两个参数是两个输入输出型参数,表明的是和你的服务器通信的客户端的socket信息! src_addr: addrlen:
返回值 返回你读取到了多少字节,失败就返回-1。
在网络通信中,只有报文大小,或者时字节流中字节的个数,没有C/C++字符串这样的概念
注意:这里我们默认认为通信的数据是双方在互发字符串。但是系统在发的时候我只认为我发了这么大的一块缓冲区信息,所以如果真的把内容当作字符串的话,还需要自己对缓冲区做一下相关处理 所以在接收时,要注意其返回值,然后依此对缓冲区进行处理(需要自己加’\0’,C/C++的字符串结束标志)
我提供的服务就是根据用户的输入,构建一个新的字符串返回,使用的接口就是sendto 读取完消息后,还要再把消息返回去
man sendto
参数: sockfd:你要发送消息的套接字 后面两个参数就是缓冲区,你要发什么数据和发送数据的长度 buf:里面存放的是你要发送的数据 len:缓冲区的长度 flags:发送方式,可以置0 dest_addr:向哪个套接字发,对端的套接字信息,可以通过刚刚的recvfrom得到。 addrlen:对端套接字信息的长度
UDP服务端的代码:
#include <iostream>
#include <cerrno>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
std::string Usage(std::string proc)
{
std::cout<<"Usage: "<<proc<<" port"<<std::endl;
}
int main(int argc,char* argv[])
{
if(argc!=2)
{
Usage(argv[0]);
return -1;
}
uint16_t port=atoi(argv[1]);
int sock=socket(AF_INET,SOCK_DGRAM,0);
if(sock<0)
{
std::cout<<std::cout<<"socket create error: "<<errno<<std::endl;
return 1;
}
struct sockaddr_in local;
local.sin_family=AF_INET;
local.sin_port=htons(port);
local.sin_addr.s_addr=INADDR_ANY;
if(bind(sock,(struct sockaddr*)&local,sizeof(local))<0)
{
std::cout<<std::cout<<"bind error: "<<errno<<std::endl;
return 2;
}
bool quit=false;
#define NUM 1024
char buffer[NUM];
while(!quit)
{
struct sockaddr_in peer;
socklen_t len=sizeof(peer);
ssize_t cnt=recvfrom(sock,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&peer,&len);
if(cnt>0){
buffer[cnt]=0;
std::cout<<"client: "<<buffer<<std::endl;
std::string echo_hello=buffer;
echo_hello+="...";
sendto(sock,echo_hello.c_str(),echo_hello.size(),0,(struct sockaddr*)&peer,len);
}
}
return 0;
}
客户端
第一步:创建套接字
和服务端一样,使用socket接口创建一个套接字。
与服务端不同的是,客户端不需要显示的绑定。 首先,客户端必须要有ip和port,但是客户端不需要显示的bind,因为一旦显示bind,就必须明确client要和哪一个端口号相连,而client指明的端口号不一定在client中一定存在,也就是说该端口号可能已经被其他的客户端绑定了。服务端也可能会出现这样的情况,但是一般服务器会有严格的管理,所以一般不会出现端口冲突,但是客户端就不一样,一般不会对端口进行管理,而一旦端口被其他客户端绑定,那客户端就不可能启动成功了,会导致客户端无法使用。 所以网络通信对服务端的要求是:port必须明确而且不变!!而对客户端的要求是:只要有就行。 但是绑还是要绑,只是不用用户显示的绑,操作系统会自动帮你bind,啥时候bind? 客户端发送数据的时候操作系统就会自动给你绑定,采用的是随机端口的方式,因为这样肯定能找到一个不被占用的端口,避免端口冲突。
第二步:使用服务
我要实现的功能是客户可以给服务端发消息,那么首先我要从用户这里拿到数据,也就是让用户输入数据。 其次,我用户要给服务端发数据,那我客户端待知道服务端的IP和端口号,那这个服务端的IP和端口号我们就可以通过命令行参数的方式传入到客户端的进程中,创建好端口之后就可以先创建一个sockaddr_in结构变量,把在命令行参数找到对应的IP和端口号,然后填到这个变量中,然后用sendto函数给服务端发送刚刚用户输入的内容。
发送完成后,还需要用recvfrom函数接收客户端的返回消息,那就还需要一个缓冲区和一个通用结构,通用结构用来接收给我发消息的套接字信息,虽然我们知道这个肯定是服务端给我们发的,但是还是要创建一个填到参数中,否侧无法使用接口,相当于一个占位符。
然后我们把缓冲区的内容打印出来,证明这是从服务端发来的数据,
UDP客户端代码:
#include <iostream>
#include <string>
#include <cerrno>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
void Usage(std::string proc)
{
std::cout<<"Usage: \n\t"<<proc<<" server_ip server_port"<<std::endl;
}
int main(int argc,char* argv[])
{
if(argc!=3)
{
Usage(argv[0]);
return 0;
}
int sock=socket(AF_INET,SOCK_DGRAM,0);
if(sock<0)
{
std::cout<<"socket error"<<errno<<std::endl;
return 1;
}
struct sockaddr_in server;
server.sin_family=AF_INET;
server.sin_port=htons(atoi(argv[2]));
server.sin_addr.s_addr=inet_addr(argv[1]);
while(1)
{
std::string message;
std::cout<<"输入要发送的数据# ";
std::cin>>message;
sendto(sock,message.c_str(),message.size(),0,(struct sockaddr*)&server,sizeof(server));
struct sockaddr_in tmp;
socklen_t len=sizeof(tmp);
char buffer[1024];
ssize_t cnt=recvfrom(sock,buffer,sizeof(buffer),0,(struct sockaddr*)&tmp,&len);
if(cnt>0)
{
buffer[cnt]=0;
std::cout<<"server: "<<buffer<<std::endl;
}
}
return 0;
}
那这就完成了客户端与服务端的通信。 下面是运行演示:
首先把服务端跑起来,然后检测可以发现是有服务端一个进程一直在运行的
直接运行客户端,发现我们没有输入IP和端口,会打印正确的格式然后退出
然后输入正确的格式,就可以正常运行,实现通信 注意: 如果你使用的是云服务器并且使用云服务器的IP编写的udp无法通信,是因为云服务器需要开放服务,首先需要开放端口,默认的云平台是没有开放特定的端口的,需要所有者在网页后端找: 安全组–>开放端口 用**127.0.0.1(本地回旋)**在自己的机器上是通信是没问题的,但是如果你想跨网络,则需要开放你云服务器的端口。
实现简单的终端模拟服务
需求:客户端给我发的是命令,然后在我这里执行,我再把执行结果返回给客户端,可让客户端的命令在我的服务器上跑。 如果要真正实现一个,工作量是比较大的,这里只是一个极其简陋版本,算是对UDP服务应用的理解。通过对上面的代码进行改造来实现。
如果服务端把传来的字符串看作一个命令?怎么执行?有很多种方案,这里我们以下面这种为例
man popen
popen的底层原理就是管道,底层会创建子进程执行参数command,执行的时候结果会以文件的方式返回给父进程,所以我们需要读文件。第二个参数就是我们打开这个文件的方式,简单的用读就可以。命令执行完用pclose把文件直接关掉就可以了。 怎么读?
man fgets
一次读一行。 可以把读到的一行拼接到一个string中当作结果,当文件读取完成,把拼接好的结果发送给客户端让客户端打印即可。
因为命令可能有很多空格,所以不适合用cin获取命令,因为cin是以空格作为分隔的,所以客户端也使用fgets读取命令,把读取到的命令发送给服务器,然后自己再打印从服务器接收到的内容即可。
服务端实现方法只需要把上面的代码中的提供服务部分稍作修改,代码如下:
bool quit=false;
#define NUM 1024
char buffer[NUM];
while(!quit)
{
struct sockaddr_in peer;
socklen_t len=sizeof(peer);
ssize_t cnt=recvfrom(sock,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&peer,&len);
if(cnt>0){
buffer[cnt]=0;
FILE* fp=popen(buffer,"r");
std::string echo_hello;
char line[1024]={0};
while(fgets(line,sizeof(line),fp)!=NULL)
{
echo_hello+=line;
}
pclose(fp);
std::cout<<"client: "<<buffer<<std::endl;
echo_hello+="...";
sendto(sock,echo_hello.c_str(),echo_hello.size(),0,(struct sockaddr*)&peer,len);
}
}
客户端则需要修改一下获取输入命令的方式
while(1)
{
std::cout<<"MyShell $ ";
char line[1024];
fgets(line,sizeof(line),stdin);
sendto(sock,line,strlen(line),0,(struct sockaddr*)&server,sizeof(server));
struct sockaddr_in tmp;
socklen_t len=sizeof(tmp);
char buffer[1024];
ssize_t cnt=recvfrom(sock,buffer,sizeof(buffer),0,(struct sockaddr*)&tmp,&len);
if(cnt>0)
{
buffer[cnt]=0;
std::cout<<buffer<<std::endl;
}
}
运行结果: 这就相当于一个简易的xshell
编写简单的TCP服务
需求也和UDP一样,客户端可以给服务端发字符串,服务端对发来的字符串进行分析后再发送回去
服务端
第一步:创建套接字
man socket
创建方式和udp的接口一样,只不过第二个参数使用的是SOCK_STREAM(流式套接)。 而udp中返回值我们认为可以看作是文件描述符,可以像文件一样读写,tcp这里的返回值就是和文件强相关的,因为tcp叫流式套接的,是面向流式服务的,而文件也是面向流式服务的,udp是用户数据报式的,所以不能这样使用。
第二步:绑定 创建sockaddr_in结构,填充各种信息,然后绑定。 使用方法也和udp一样。
一直到这里,代码和udp的代码都一样,只有创建套接字的时候使用的类型不同,区别在第三步
第三步:设置监听状态 因为tcp是面向连接的,也就是通信的时候必须先建立连接, UDP就像我们发邮件,知道地址,直接就发了。 TCP就像我们打电话,要给对方传递消息之前要先和人把电话打通,建立连接。 所以TCP在通信之前多了一个步骤,在通信前需要建立连接。
要建立链接,那么一定有一个人主动建立,一个人被动接收。谁主动,谁被动?客户端主动,因为客户端需要服务,服务器被动接受,因为它提供服务。
我们这里写的是一个server,我们无法预料客户什么时候来,那我们就只能一直等着,特点:周而复始的不间断的等待客户到来。(类似于udp中我们用死循环不断等待。) 所以我们的第一件工作:要不断给用户提供一个建立连接的功能,设置套接字是Listen状态。本质是允许用户连接。
man 2 liston
设置套接字状态为监听套接字。
返回值 成功返回0,失败返回-1;
状态这设置好了,怎么连?
第四步:获取链接
man 2 accept
通过该套接字获取新的链接
返回值: 如果成功,返回一个非0的整数(也是文件描述符)。
创建套接字有一个,我们说可以把它理解为文件描述符,这怎么也有一个,两个之间有什么关系? 就好比一个饭馆,在门口会有一个负责拉客的人,他在门口不停的吆喝,把人吸引到他家饭馆来吃饭,客人进去以后,又会有专门的服务员给客人提供服务,accept接收的套接字就是我们创建的套接字经过绑定,监听传到这里的,我们称之为监听套接字,他就是拉客少年的角色,当有人来了以后,出来的服务员就是accept的返回值,这个可以说是真正提供IO服务的套接字。所以两组套接字并不冲突,他们共同协同,构建了tcp套接字的世界。
所以参数sockdf代表的就是listen_sock。 后两个参数包含了给服务器发消息(对端)的套接字信息,和recvfrom的后两个参数没有区别。 属于输入输出型参数
填好参数,直接accept,如果accept失败怎么办?继续链接。
连接好了之后,就不断的提供服务 因为tcp是面向字节流的,就可以像文件一样进行正常的读写。可以用下面这组接口
man 2 read
man 2 write
man 2 recv
man 2 send
这里我们用read和write来演示
read的返回值有讲究 如果读取成功,返回读到了多少个字节,0表示读到了结尾,-1表示出错了。
参数: fd:可以填套接字 buf:读到的数据存放的缓冲区 count:期望读多少字节,一般和返回值:实际读到的字节数相等
读到的内容在加上一段,用write写回给对端套接字
对端把链接关了,就会读取到0
单进程版本:
#include <iostream>
#include <cerrno>
#include <cstring>
#include <string>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
void Usage(std::string proc)
{
std::cout << "Usage: " << proc << " port" << std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
return 1;
}
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock < 0)
{
std::cout << "socket error: " << errno << std::endl;
return 2;
}
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(atoi(argv[1]));
local.sin_addr.s_addr = INADDR_ANY;
if (bind(listen_sock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
std::cout << "bind error: " << errno << std::endl;
return 3;
}
const int back_log = 5;
if (listen(listen_sock, back_log) < 0)
{
std::cout << "listen error" << errno << std::endl;
return 4;
}
for (;;)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int new_sock = accept(listen_sock, (struct sockaddr *)&peer, &len);
if (new_sock < 0)
{
continue;
}
uint16_t cli_port=ntohs(peer.sin_port);
std::string cli_ip=inet_ntoa(peer.sin_addr);
std::cout << "get a new link -> : ["<<cli_ip<<":"<<cli_port<<"]# " << new_sock << std::endl;
while (true)
{
char buffer[1024];
memset(buffer, 0, sizeof(buffer));
ssize_t s = read(new_sock, buffer, sizeof(buffer) - 1);
if (s > 0)
{
buffer[s] = 0;
std::cout << "client: " << buffer << std::endl;
std::string echo_string = ">>>server<<<, ";
echo_string += buffer;
write(new_sock, echo_string.c_str(), echo_string.size());
}
else if (s == 0)
{
std::cout << "client quit ..." << std::endl;
break;
}
else
{
std::cerr << "read error" << std::endl;
break;
}
}
}
return 0;
}
TCP是一个文件描述符在不断增多的过程
客户端:
和前面一样,先设计使用模式,如果输入错误打印正确的使用模式后退出。
第一步:创建套接字,
客户端和udp一样,不用显示的绑定,客户端不需要一个固定的端口号,端口号固定反而可能会出问题。
第二步:建立连接 客户端要连服务器,那就是客户端主动,不用listen,也就不用accept获取链接,而要做的是主动连接connect
man 2 connect
参数: sockfd:用哪个套接字连接 addr:服务器的套接字信息 addrlen:套接字信息长度
返回值: 连接和绑定成功返回0,绑定失败返回-1
填套接字信息,填写之前可以先对其进行初始化,初始化的方法除了memset(),还有一个函数:bzero() 功能和memset()一样
要构建服务端的套接字信息,那我们就要从命令行参数解析出服务端的IP和端口号,可以用uint16_t类型接收端口号 uint16_t:只表示字节的含义,16个bit位,2个字节,可以保证代码的兼容性和可移植性,不同的平台char,double,int类型的大小可能会不一样,但是unit…类型的大小是确定的,不会随代码在不同平台下进行编译而导致不同。
填完之后,进行连接,连接成功后,打印一次连接成功的提示,就可以进行正常的业务请求了。
用一个buffer,fgets从stdin向buffer输入数据,然后再把数据write进套接字,然后再从套接字里读取服务器发给我们的数据,然后打印。
#include <iostream>
#include <cstring>
#include <string>
#include <unistd.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
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]);
return 1;
}
std::string svr_ip=argv[1];
uint16_t svr_port=atoi(argv[2]);
int sock=socket(AF_INET,SOCK_STREAM,0);
if(sock<0)
{
std::cout<<"socket error! "<<std::endl;
return 2;
}
struct sockaddr_in server;
bzero(&server,sizeof(server));
server.sin_family=AF_INET;
server.sin_addr.s_addr=inet_addr(svr_ip.c_str());
server.sin_port=htons(svr_port);
if(connect(sock,(struct sockaddr*)&server,sizeof(server))<0)
{
std::cout<<"connect server failed!"<<std::endl;
return 3;
}
std::cout<<"connect srccess!! "<<std::endl;
while(true)
{
std::cout<<"输入: ";
char buffer[1024];
fgets(buffer,sizeof(buffer)-1,stdin);
write(sock,buffer,strlen(buffer));
ssize_t s=read(sock,buffer,sizeof(buffer)-1);
if(s>0)
{
buffer[s]=0;
std::cout<<"server echo: "<<buffer<<std::endl;
}
}
return 0;
}
下面是运行结果
当前的服务器在任何一个时刻只支持给一个人提供服务,udp为什么没有这样的问题?因为udp是不断在进行循环读取,有数据吧就接收,没数据就阻塞在哪里,所以udp不会出现这样的情况,而tcp不一样,这个tcp是一个单进程的代码,而其服务代码是一个死循环,一但有服务进入,其他主机连接成功后也无法进入这个循环,这就是导致我们服务是串行的根本原因。
多进程版本的服务端:
服务端收到连接之后,可以通过accept的参数获取到对端的信息 从accept参数获取的客户端套接字信息是网络字节序的,所以注意用ntohs函数转换成主机序列
怎么拿到点分十进制的IP地址可以用inet_ntoa()函数。
man 3 inet_ntoa
网络转主机,四字节转字符串 通过上面的步骤,获取到客户端的信息,连接成功后可以连同提示一起打印出来
而为了解决服务串行的问题,可以用子进程执行服务代码,把服务封装到一个函数,子进程只要传new_sock,服务完成退出。父进程可以用忽略信号SIGCHLD的方式,让子进程退出自动回收资源。
注意:每获取一个连接,就创建一个子进程,让子进程提供服务,服务结束子进程退出,还要关闭掉对应的套接字 为什么必须关?因为文件描述符是有上限的。如果不关,会导致可用的文件描述符越来越少,会造成文件描述符泄漏
曾经被父进程打开的文件描述符会被子进程继承吗?父子进程的文件描述符之间是什么关系呢? 子进程创建,代码会共享,数据写时拷贝,而子进程以父进程为模板进行拷贝,所以打开的文件不变,而文件描述符表会被拷贝一份,所有其双方都有一个管理文件的数组,数组的内容是一样的,所以管理的是同一批文件。当子进程提供服务时,是可以看到父进程打开的文件描述符,也可以通过父进程之前打开的文件描述符做一些事情,这就可能出现问题,万一子进程误读了父进程使用的文件描述符,比如监听套接字?所以无论父子进程中的哪一个,都强烈建议关闭掉不需要的fd,父进程负责获取新连接,子进程负责提供服务,子进程只需要一个文件描述符,就是需要提供连接的那个。
最开始文件描述符从4开始,之后退出再连接文件描述符越来越大? 服务提供完没有关闭?子进程和父进程之间的文件描述符的关系?
实现代码:
#include <iostream>
#include <cerrno>
#include <cstring>
#include <string>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
void Usage(std::string proc)
{
std::cout << "Usage: " << proc << " port" << std::endl;
}
void ServiceIO(int new_sock)
{
while (true)
{
char buffer[1024];
memset(buffer, 0, sizeof(buffer));
ssize_t s = read(new_sock, buffer, sizeof(buffer) - 1);
if (s > 0)
{
buffer[s] = 0;
std::cout << "client: " << buffer << std::endl;
std::string echo_string = ">>>server<<<, ";
echo_string += buffer;
write(new_sock, echo_string.c_str(), echo_string.size());
}
else if (s == 0)
{
std::cout << "client quit ..." << std::endl;
break;
}
else
{
std::cout << "read error" << std::endl;
break;
}
}
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
Usage(argv[0]);
return 1;
}
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock < 0)
{
std::cout << "socket error: " << errno << std::endl;
return 2;
}
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(atoi(argv[1]));
local.sin_addr.s_addr = INADDR_ANY;
if (bind(listen_sock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
std::cout << "bind error: " << errno << std::endl;
return 3;
}
const int back_log = 5;
if (listen(listen_sock, back_log) < 0)
{
std::cout << "listen error" << errno << std::endl;
return 4;
}
signal(SIGCHLD, SIG_IGN);
for (;;)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int new_sock = accept(listen_sock, (struct sockaddr *)&peer, &len);
if (new_sock < 0)
{
continue;
}
uint16_t cli_port=ntohs(peer.sin_port);
std::string cli_ip=inet_ntoa(peer.sin_addr);
std::cout << "get a new link -> : ["<<cli_ip<<":"<<cli_port<<"]# " << new_sock << std::endl;
pid_t id = fork();
if (id < 0)
continue;
else if (id == 0)
{
close(listen_sock);
ServiceIO(new_sock);
close(new_sock);
exit(0);
}
else
{
close(new_sock);父进程要关闭自己不需要的文件描述符
}
}
return 0;
还有一种不用忽略信号的方法 在子进程代码里加入以下代码:
if(fork()>0) exit(0);
退出的是子进程,向后走的进程其实是孙子进程,孙子进程不需要父进程等待,而孙子进程的父进程已经退出了,现在是一个孤儿进程,那他的资源回收就不归我的进程负责了,而是操作系统负责回收孤儿进程。 父进程现在是不用等待孙子进程了,但是子进程退出了还是要等待的。
多线程版本的服务端
可以用多进程,那也就可以用多线程!获取到新连接后,创建一个新线程,新线程执行HandRequest函数,用一个int* 把套接字传到该函数中,在函数里,为了防止进程等待而导致的串行,所以待在里面分离线程,这样主进程就完全不用管线程了,线程拿出套接字后,再调用服务函数,服务结束关闭文件描述符即可
子线程和主线程之间的文件描述符是什么关系?他们之间的文件描述符是否共享? 地址空间是共享的,文件上会继承文件描述符表,所以创建出来的线程不用和多进程一样关闭文件描述符,每个线程只要用完把自己的套接字关掉就可以了
下面是对代码的修改部分 获取到新连接之后: 子线程的方法: 注意编译的时候要加:-lpthread
上面的多线程和多进程版本有两个问题: 1.创建线程或进程无上限,(如果有一大批机器发起连接请求,进程和线程过多,会导致主要性能消耗从服务变成了进程切换),系统的运行会变慢,服务器无法正常对外服务 2.客户来了,才给客户创建进程或者线程,把创建进程和线程这种服务器的时间成本记在了用户头上
所以还有改进方法,就是线程池版本
线程池版本的服务端
这里有一个我之前写好的简单线程池: threal_pool.hpp
#pragma once
#include <iostream>
#include <string>
#include <queue>
#include <unistd.h>
namespace ns_threadpool
{
const int g_num=5;
template<class T>
class ThreadPool
{
private:
int _num;
std::queue<T> _taskqueue;
pthread_mutex_t _mtx;
pthread_cond_t _cond;
public:
void Lock()
{
pthread_mutex_lock(&_mtx);
}
void UnLock()
{
pthread_mutex_unlock(&_mtx);
}
bool IsEmpty()
{
return _taskqueue.empty();
}
void Wait()
{
pthread_cond_wait(&_cond,&_mtx);
}
void WakeUp()
{
pthread_cond_signal(&_cond);
}
public:
ThreadPool(int num=g_num)
:_num(num)
{
pthread_mutex_init(&_mtx,nullptr);
pthread_cond_init(&_cond,nullptr);
}
~ThreadPool()
{
pthread_mutex_destroy(&_mtx);
pthread_cond_destroy(&_cond);
}
static void* Rountine(void* args)
{
pthread_detach(pthread_self());
ThreadPool<T>* tp=(ThreadPool<T>*)args;
while(true)
{
tp->Lock();
while(tp->IsEmpty())
{
tp->Wait();
}
T t;
tp->PopTask(&t);
tp->UnLock();
t.Run();
}
}
void InitThreadPool()
{
pthread_t tid;
for(int i=0;i<_num;i++)
{
pthread_create(&tid,nullptr,Rountine,(void*)this);
}
}
void PushTask(const T& in)
{
Lock();
_taskqueue.push(in);
UnLock();
WakeUp();
}
void PopTask(T* out)
{
*out=_taskqueue.front();
_taskqueue.pop();
}
};
}
需要定制一下线程池的任务,让服务代码变成线程的任务 Task.hpp
#pragma once
#include <iostream>
#include <pthread.h>
#include <cstring>
#include <string>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
namespace ns_task
{
class Task
{
public:
Task()
: new_sock(-1)
{
}
Task(int _sock)
: new_sock(_sock)
{
}
~Task()
{
}
int Run()
{
char buffer[1024];
memset(buffer, 0, sizeof(buffer));
ssize_t s = read(new_sock, buffer, sizeof(buffer) - 1);
if (s > 0)
{
buffer[s] = 0;
std::cout << "client: " << buffer << std::endl;
std::string echo_string = ">>>server<<<, ";
echo_string += buffer;
write(new_sock, echo_string.c_str(), echo_string.size());
}
else if (s == 0)
{
std::cout << "client quit ..." << std::endl;
}
else
{
std::cerr << "read error" << std::endl;
}
close(new_sock);
}
int operator()()
{
return Run();
}
private:
int new_sock;
};
}
以上工作做好以后,我们的服务端获取到新连接之后只要做两件事: 1.构建一个任务 2.把任务push到后端的线程池即可 这样就完成了一个线程池版本的服务端
套接字编程的流程
1.创建socket的过程,本质是打开文件——仅仅有系统相关的内容
2.bind绑定时,要填充一个sockaddr_in结构,最重要的是ip+port,本质是把ip+port和文件信息进行关联。
3.listen本质是设置该socket文件的状态,允许别人来连接我
4.accept获取新连接到应用层,这个连接以fd为代表 什么是连接:当有很多个连接连上服务器时,操作系统中就会存在大量的连接,操作系统为了管理这些已经建立好的连接,描述,组织 所谓的“连接”,在OS层面,其实就是一个描述连接的结构体,等连接多了以后OS会把这些结构体通过某些数据结构组织起来,以fd的形式可以让进程能找到这个连接,所以这个连接结构体本质也是“文件”。
5.recv/write,本质就是进行网络通信,但是对应用户来讲,调用这些接口就相当于在进行正常的文件读写。
6.close(fd),关闭文件,a.系统层面:释放曾经申请的文件资源,连接资源等 b.网络层面:通知对方我的文件已经关闭了。
7.connect,本质发起连接,在系统层面,就是构建一个请求报文发送过去。网络层面:发起tcp链接的三次握手。
8.close,client&&server 网络层面,其实就是在进行四次挥手。
三次握手:客户端connect,服务器处于listen状态,客户端就可以向服务器发起一次链接请求,SYN->SYN+ACK->ACK,底层自动会握手三次,报文交换上三次,然后双方才认为他们的链接是ESTABLISHED(建立成功),connect返回就可以正常读写了,accept返回就会得到一个新的文件描述符,发送数据的时候就是读写的过程。双方调用close时,调一次就是一来一回两次挥手,两边都调就是四次挥手。
这些上面我们用套接字编写的服务本质是什么? 套接字这些接口其实都是系统调用接口。上面的代码属于是从零开始,在编写应用层。这要与使用应用层区分,既然有应用层,那就说明应用层是已经被写好的,我们可以直接使用其应用层。我们只是在应用层是基于TCP和UDP传输了一下数据,没有搭建应用逻辑。目的主要是为了让我们更好的理解应用层。
|