目录
前言
基本概念
源ip&&目的ip
源端口号&&目的端口号
udp&&tcp初识
socket编程
网络字节序
socket常见接口
socket
bind
listen
accept
connect
地址转换函数
字符串转in_addr
in_addr转字符串
套接字读写函数
recvfrom&&recv
sendto&&send
简单udp服务器客户端程序
udp服务器
udp客户端
简单tcp服务器客户端程序
接口理解
tcp服务器
tcp客户端
后记
前言
在学习网络部分知识时,只学习或只背诵概念是没有用的,面试过程中面试官肯定会究其细节或理解从各方面考察,因此上章节介绍完网络初步相关概念之后,我们先上代码去直观地了解网络,为后面更深层次或者复杂的概念或网络传输过程的细节打下基础。那么,在网络部分socket编程就是用于在不同计算机之间进行通信,因此本章节就是介绍socket编程相关接口以及运用其编写简单的程序去了解通信过程。
基本概念
-
源ip&&目的ip
在ip数据包的头部中存在两个IP地址——源ip&&目的ip,即此数据包从哪来&&去哪里。我们知道,IP地址(此处说的是公网ip),标定了主机的唯一性,但通过IP地址将数据发送到主机并不是完整的网络通信过程,也就是说一台主机的信息到达了目标主机上真的就算是数据发送成功了吗?并不是,其实网络通信过程的本质就是进程间通信(由一台主机的进程发送到另一台主机的进程),将数据在主机间转发只是手段,主机收到后需要将数据交付给相应进程。但是,数据到达目标主机后该走向哪个进程呢?这就需要下一个概念——端口号了。
-
源端口号&&目的端口号
端口号(2字节16位的整数)是传输层的内容,标识特定主机上网络进程的唯一性,也就是标识一个进程,告诉os数据交给哪个进程来处理。可见,一个端口号只能被一个进程占用,但一个进程可以绑定多个端口号。
源端口号&&目的端口号存在于传输层的数据段中,标识哪个进程发的&&发给哪个进程。综上ip+端口号表示指定主机的指定进程,这是一个数据进入网络前要确定的东西。
-
udp&&tcp初识
先来简单初识一下TCP和UDP,在后面讲解传输层时会详解。TCP(Transmission Control Protocol 传输控制协议)和UDP(User Datagram Protocol 用户数据报协议)都是传输层协议,但TCP需要连接、可靠传输、面向字节流,而UDP无需连接、不可靠传输、面向数据报,如下图很具象地展现了TCP和UDP传输数据地过程。
其中,(无需)连接说的是udp没有tcp在通信的三次握手与四次挥手,而是没有建立连接过程,即“发送即结束”;可靠传输说的是tcp传输数据是不会出错的,udp传输数据可以出错;面向字节流与面向数据报是指看待数据的方式不同,tcp需要根据应用层协议对字节流作序列化和反序列化识别出一个报文,而udp直接默认拿到的就是一个报文。但是,综上来看真的意思是tcp比udp好吗?其实不然,两者的使用需要看场景,tcp可以多应用在需要数据准确交付的场景,比如重要文件传输,而udp可以多应用在对传输错误也可容忍的场景,比如游戏,直播等实时性要求高的环境下(有时看直播会卡,但是无伤大雅)。
-
socket编程
Socket编程是一种网络编程技术,它提供了一种在不同计算机之间进行通信的方式。
基于TCP/IP协议,它允许计算机之间通过网络进行数据传输。Socket编程提供了一些函数和方法,用于建立连接、传输数据和关闭连接。
在Socket编程中,有两个主要的角色:服务器和客户端。服务器程序监听指定的端口,等待客户端的连接请求。客户端程序通过指定服务器的IP地址和端口,发起连接请求。以上会在下面详细介绍。
网络字节序
我们知道,内存中的多字节数据相对于内存地址有大端和小端之分,而网络数据流也同样有大端小端之分,因此,TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节.。不管这台主机是大端机还是小端机,都要按照这个TCP/IP规定的网络字节序来发送/接收数据,如果当前发送主机是小端,就需要先将数据转成大端,否则就忽略,直接发送即可。那么,如下接口就是转大端的相关接口,调用其可以做网络字节序和主机字节序的转换。
其中h表示host,n表示net,l表示32位长整数,s表示16位短整数,如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回,如果主机是大端字节序,这些函数将不做转换,将参数原封不动的返回。
socket常见接口
-
socket
功能:创建socket文件描述符(tcp和udp均会用到,服务器和客户端均需要)
参数:
domain:对于ipv4,指定为AF_INET,对于ipv6,指定为AF_INET6
type:对于UDP,指定为SOCK_DGRAM,表示面向数据报的传输协议,对于TCP,指定为SOCK_STREAM,表示面向字节流的传输协议
protocol:指定为0即可
返回值:成功返回一个文件描述符,类似读写文件一样,向此文件描述符读写就是在网络上收发数据,否则返回-1
-
bind
功能:绑定端口号(tcp和udp均会用到,仅服务器需要,因为服务器程序所监听的网络地址和端口号通常是固定不变的,客户端程序得知服务器程序的地址和端口号后就可以向服务器发起连接,但是客户端程序的端口号通常是变化的,所以不需要bind),将参数sockfd和myaddr绑定在一起,使sockfd这个用于网络通讯的文件描述符监听addr所描述的地址和端口号
参数:
sockfd:需要绑定的sock套接字,其实就是socket函数的返回值
addr:通用指针类型,可以接受多种协议的sockaddr结构体地址(多种sockaddr结构体类型在下面介绍),但长短不同,需要传入长度
addrlen:addr指向的结构体长度
返回值:成功返回0,否则返回-1
sockaddr结构:
socket的接口适用于各种底层网络协议,但是各种网络协议的地址格式并不相同,又不想设计过多的接口,因此将所有接口进行统一,接口只有一套,协议格式统一使用struct sockaddr*,传其他协议地址(sockaddr_in、sockaddr_un,如下图)需要通过强转,但若需要知道是哪一种套接字地址,可以通过16位地址类型判断。
这三者中,本章节常用到sockaddr_in结构,也就是说常用到将sockaddr结构体强转成sockaddr_in结构体,这里详细说明一下sockaddr_in结构体,以下为其内部结构,在这个结构体中,最主要的就是端口号和IP地址,即图中标星号的部分。
在我们写的程序中一般是这样初始化sockaddr结构体的(如下代码块),首先清零,再指定地址类型,然后填入ip和端口号信息。注意转大端字节序,并且其中INADDR_ANY为宏(实则为0.0.0.0),表示本地的任意IP地址,则最后一个语句代表若需要指定ip地址则赋值给_ip,否则使用任意IP地址。
代码:
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
peer.sin_family = AF_INET;
peer.sin_port = htons(_port);
peer.sin_addr.s_addr = _ip.size() == 0 ? INADDR_ANY : inet_addr(_ip.c_str());
-
listen
功能:开始监听socket(仅tcp会用到,仅服务器需要)
参数:
sockfd:如上
backlog:表示最多允许有backlog个客户端处于连接等待状态,如果接收到更多的连接请求就忽略,常设置为20,后面会详细讲到
返回值:成功返回0,否则返回-1
-
accept
功能:接受请求(仅tcp会用到,仅服务器需要),三次握手完成后,服务器调用accept()接受连接,如果服务器调用accept()时还没有客户端的连接请求,就阻塞等待直到有客户端连接上来
参数:
sockfd:如上
addr和addrlen:输出型参数,需提前定义传入函数,函数返回后带出来对方的ip和端口号信息,若传NULL,则代表不关心客户端的地址
返回值:成功返回一个文件描述符,否则返回-1
-
connect
功能:建立连接(仅tcp会用到,仅客户端需要,因为客户端需要调用此函数连接服务器)
参数:
sockfd:自己(客户端)的fd
addr和addrlen:传入对方的IP地址和端口号信息
返回值:成功返回0,失败返回-1
地址转换函数
在上面介绍到的sockaddr中,sockaddr_in的成员sin_addr(struct in_addr)表示32位的IP地址,但是我们通常都是用点分十进制(比如192.168.1.10)表示IP地址,因此需要函数可以将字符串表示和in_addr之间来回转换。
-
字符串转in_addr
首先,三者的功能是一样的——将字符串转in_addr,先看前两个,cp就是需要转换的字符串,inp是一个输出型参数,结果由此参数传出,而第二个函数是直接返回出来,第三个函数较于前两个函数是新的,其中af可以填AF_INET或AF_INET6表示ipv4或ipv6,src还是传入需要转换的字符串,dst与inp的功能是一样的,只是需要接收ipv4的in_addr或ipv6的in6_addr,因此设置为void*可以同样接收(强转)。
-
in_addr转字符串
同样地,in传入in_addr,函数返回转换地字符串形式,而inet_ntop是新接口,可以接收ipv6,src就是传入不同类型的in_addr,dst接收转换的字符串指针,size传入字符串指针可以接收的字符串大小。
套接字读写函数
-
recvfrom&&recv
注意,recv用于tcp,recvfrom函数用于udp。可以看到,recvfrom函数除了比recv函数多了两个参数,其他的参数是一样的,因此只要介绍recvfrom即可。
sockfd:读写的套接字(文件描述符)
buffer&&len:读取的数据所放的缓冲区及大小
flags:控制接收行为,通常可设为0
src_addr&&addrlen:输出型参数,用来接收数据来源的地址信息及大小,注意addrlen在传入时就要是sre_addr的大小
返回值:正常返回接收成功的字符个数若数据读完或套接字关闭,则返回值为0,若出错则返回-1并设置错误码
-
sendto&&send
注意,send用于tcp,sendto函数用于udp。同样地,sendto函数也是比send函数多了两个参数。
sockfd:读写的套接字(文件描述符)
buf&&len:指向需要发送的数据的字符串指针及大小
flags:同recvfrom函数
dest_addr&&addrlen:不是输出型函数,需要传入需要发送的目的地址信息及大小
返回值:同recvfrom函数
注意:
思考一下recvfrom函数和sendto函数的使用顺序,比如说,你使用sendto函数给张三发信息,此时你需要知道张三的地址信息,张三使用recvfrom函数接收到了你的信息,并且他通过输出型参数得到了你的地址信息,之后张三拿着你的地址信息使用sendto函数给你回信,你通过recvfrom函数拿到了张三的回信并且拿到了他的地址信息。
在这个过程中,你与张三通信的前提是你知道张三的地址信息,你其实是知道的,无论是之前见面留下的联系方式还是曾经通信过,你们之间必须至少有一方是知道的,否则无法通信。类比到服务器与客户端,“你”就是客户端,“张三”就是服务器,一般来说都是客户端主动给服务器发信息,客户端都是提前知道服务器的地址信息的,客户端给服务器发送请求,服务器处理需求,将结果回信给客户端,客户端拿到需求即为享受到服务。
简单udp服务器客户端程序
-
udp服务器
下面的代码块是udp服务器的封装及主程序调用。先看udp服务器封装的类,属性包括监听的文件描述符sockfd、IP地址、端口,这些是一个udp服务器最基本需要的属性,构造函数中传入所决定的端口号、IP地址、sock(还没有生成,初始化为-1)。
在启动udp服务器前,先做一下准备工作——initserver(),创建监听的文件描述符sock和绑定地址信息(包括端口号和IP地址),之后启动udp服务器——start(),显然是死循环运行,作为服务器,我们需要先使用recvfrom函数接收客户端的请求(数据),返回值大于0,说明正确读到了数据,这里当作字符串打印出来,并调用sendto函数将读到的字符串写回给客户端(处理方式有很多,也可以客户端传来两个数字,服务器计算出结果将其返回,但是这涉及到http协议的设定及序列化、反序列化)。最后若关闭服务器,莫要忘记close监听的sockfd。
可以看到,udp服务器只能用到前面所谈到的socket常用接口其中的两个——socket()、bind(),这可以联想到udp的特性——无连接,而listen()、accept()、connect()意味着是有连接,在tcp服务器的实现中才会用到。
代码:
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <strings.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "log.hpp"
using std::cout;
using std::endl;
#define SIZE 1024class UdpServer
{
public:UdpServer(uint16_t port,std::string ipAddr=""):_port(port),_ipAddr(ipAddr),_sock(-1){}bool initserver(){//1.创建套接字(ip+port)_sock=socket(AF_INET,SOCK_DGRAM,0);if(_sock<0){logMessage(FATAL,"%d:%s",errno,strerror(errno));exit(2); }logMessage(NORMAL,"create socket success");//2.通过bind函数将此ip和端口号与此进程绑定struct sockaddr_in local;bzero(&local,sizeof(local)); //设置内容之前将所有字段设置为0local.sin_family=AF_INET;//端口需要发送给对方主机,需要通过网络,因此将数据的字节序改为大端存储local.sin_port=htons(_port);//ip也是一样,但同时还需要将ip的字符串形式改为4个字节存储,因此4个字节足够存储一个ip,//比如说192.168.10.1,每段数字都是[0,255],用1字节即可存储//因此使用inet_addr函数,可同时完成上面两件事//同时,INADDR_ANY表示服务器在工作过程中,可以从任意IP中获取数据,也就是说不建议bind一个确定的地址local.sin_addr.s_addr = _ipAddr.size()==0 ? INADDR_ANY : inet_addr(_ipAddr.c_str());if(bind(_sock,(struct sockaddr*)&local,sizeof(local))<0){logMessage(FATAL,"%d:%s",errno,strerror(errno));exit(3);}logMessage(NORMAL,"bind success");return true;}void start(){char buffer[SIZE];for( ; ; ){//读取数据struct sockaddr_in peer;socklen_t len=sizeof(peer);ssize_t rf=recvfrom(_sock,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&peer,&len);if(rf>0){buffer[rf]=0; //缓冲区末尾放上‘\0’,当作字符串uint16_t cli_port=ntohs(peer.sin_port);std::string cli_ip=inet_ntoa(peer.sin_addr);printf("[%s:%d]: %s\n",cli_ip.c_str(),cli_port,buffer);}//分析处理数据//写回数据sendto(_sock,buffer,sizeof(buffer),0,(struct sockaddr*)&peer,len);}}~UdpServer(){if(_sock>=0) close(_sock);}
private:uint16_t _port;std::string _ipAddr;int _sock;
};static void usage(std::string proc)
{cout<<"\nUsage:"<<proc<<" port\n"<<endl;
}int main(int argc,char* argv[])
{if(argc!=2){usage(argv[0]);exit(1);}//std::string ip=argv[1];uint16_t port=stoi(argv[1]);std::unique_ptr<UdpServer> server(new UdpServer(port));server->initserver();server->start();return 0;
}
-
udp客户端
下面代码块是udp客户端的实现(主程序实现,未封装udp客户端),与服务器实现并没有本质的不同(但可以发现客户端不需要bind)。通过命令行参数传入服务器的地址信息(端口号和ip地址),首先依旧是创建监听的文件描述符sock,之后就可以将服务器的地址信息填进struct sockaddr_in中,等待后面的sendto函数的使用,但是会发现客户端无需bind,为什么?因为客户端通过哪个端口与服务器通信并不重要,socket函数的函数体中会自动为客户端程序选择一个未被占用的端口号,并不需要用户去操心,那又为什么服务器程序需要指定端口号呢?因为服务器的端口号和IP地址是需要固定的,不然客户端程序如何得知新的变化的端口号和IP地址,因此需要调用bind函数固定下来。
显然,客户端程序主体也是一个死循环,先产生用户的需求,之后调用sendto函数将需求发给服务器程序,服务器处理之后,客户端调用rcvfrom函数接收结果,这里将结果显示出来(举例而已,也可以将结果作为下一次传入的需求),若客户端程序关闭,也莫要忘记关闭监听的文件描述符。
代码:
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <string.h>
#include <unistd.h>
#include <strings.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
using std::cout;
using std::endl;
#define SIZE 1024static void usage(std::string proc)
{cout<<"\nUsage:"<<proc<<" ip port\n"<<endl;
}int main(int argc,char* argv[]) //传入服务器ip、port
{if(argc!=3){usage(argv[0]);exit(1);}int sock=socket(AF_INET,SOCK_DGRAM,0);if(sock<0){std::cerr<<"socket error"<<endl;exit(2);}uint16_t serverport=atoi(argv[2]);std::string serverip=argv[1];std::string message; //从键盘拿信息char buffer[SIZE]; //缓冲区struct sockaddr_in server;memset(&server,0,sizeof(server)); //提前置零server.sin_addr.s_addr=inet_addr(serverip.c_str());server.sin_port=htons(serverport);while(true){cout<<"请输入:";std::getline(std::cin,message);if(message=="quit")break;sendto(sock,message.c_str(),message.size(),0,(struct sockaddr*)&server,sizeof(server));struct sockaddr_in tmp;socklen_t len=sizeof(tmp);ssize_t rf=recvfrom(sock,buffer,sizeof(buffer),0,(struct sockaddr*)&tmp,&len); //tmp与server相同,都是服务端ip、port相关信息if(rf>0){buffer[rf]=0;cout<<"server # "<<buffer<<endl;}}close(sock);return 0;
}
运行结果:
简单tcp服务器客户端程序
-
tcp服务器
以下代码块是tcp服务器的封装及主程序调用,先看tcp服务器封装,属性和构造函数与udp服务器封装一样。对于initserver(),可以看到,调用socket创建一个文件描述符及bind()绑定地址信息也都是一样的,但是tcp服务器紧接着调用listen()监听sockfd是否有客户端连接,这是tcp服务器必须要做的,其实本质就是使sockfd成为一个监听描述符。
之后,依旧是死循环开启tcp服务器——start()。首先,服务器会调用accept函数去阻塞等待客户端的连接,当有客户端调用connect函数去建立连接时,accept函数就会返回一个新的文件描述符serverfd,这个文件描述符专门用来和客户端通信,而原本的监听描述符继续监听客户端的connect函数申请连接,若存在就会继续返回另一个新的文件描述符与此客户端通信。这个过程就好像是,监听文件描述符是一个在饭店门口拉客的服务员,当拉到客人之后进店就会找另一个服务员(与此客户端通信的文件描述符)来接待这个客人(比如点菜,服务),而拉客服务员继续去门外拉客。
当accept函数返回一个新的文件描述符来与客户端通信,服务器就可以通过此文件描述符收到客户端的需求,并返回应答给客户端,这里封装成了一个服务函数——service(),首先recv函数(也可以read函数)读取客户端的数据,处理以后通过write函数(也可以send函数)返回结果给客户端,这里依旧是把数据当作字符串并写回给客户端。
代码:
#include <iostream>
#include <string>
#include <string.h>
#include <cstdlib>
#include <cstdio>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <signal.h>
#include <pthread.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "log.hpp"using std::cout;
using std::endl;
using std::string;static void service(int servicesock,string cli_ip,uint16_t cli_port)
{char buffer[1024];while(true){int n=read(servicesock,buffer,sizeof(buffer)-1);if(n>0){buffer[n]='\0';cout<<cli_ip<<"-"<<cli_port<<"# "<<buffer<<endl;}else if(n==0) //连接被关闭了{logMessage(NORMAL,"link shutdown");break;}else{logMessage(ERROR,"read error");break;}string msg;msg+=buffer;write(servicesock,msg.c_str(),msg.size());}
}class TcpServer
{
private:const static int g_backlog=20;
public:TcpServer(uint16_t port,string ip=""):_port(port),_ip(ip),_sock(-1){}void initserver(){//创建socket_sock=socket(AF_INET,SOCK_STREAM,0);if(_sock<0){logMessage(FATAL,"create socket error");exit(2);}//bindstruct sockaddr_in peer;memset(&peer,0,sizeof(peer));peer.sin_port=htons(_port);peer.sin_family=AF_INET;peer.sin_addr.s_addr=_ip.size()==0 ? INADDR_ANY : inet_addr(_ip.c_str());if(bind(_sock,(struct sockaddr*)&peer,sizeof(peer))<0){logMessage(FATAL,"bind error");exit(3);}//listen函数用以建立连接,相当于饭店的一个服务员在外面拉客,其中_sock就是在外面拉客的服务员if(listen(_sock,g_backlog)<0){logMessage(FATAL,"listen error");exit(4);}logMessage(NORMAL,"initserver success");}void start(){while(true){struct sockaddr_in peer;socklen_t len=sizeof(peer);int servicesock=accept(_sock,(struct sockaddr*)&peer,&len); //_sock将拉的客人带到饭店,店里接待的服务员出来一个招呼他,其中servicesock就是一个招待的服务员if(servicesock<0){logMessage(ERROR,"accept error");continue;}logMessage(NORMAL,"accept success");uint16_t clientport=ntohs(peer.sin_port);string clientip=inet_ntoa(peer.sin_addr);service(servicesock,clientip,clientport);}}~TcpServer(){if(_sock>0)close(_sock);}
private:uint16_t _port;string _ip;int _sock;
};static void usage(string proc)
{cout<<"usage: "<<proc<<" port"<<endl;
}// ./tcpserver port
int main(int argc,char* argv[])
{if(argc!=2){usage(argv[0]);exit(1);}uint16_t serverport=atoi(argv[1]);unique_ptr<TcpServer> server(new TcpServer(serverport));server->initserver();server->start();return 0;
}
-
tcp客户端
tcp客户端也并未做出封装,依旧是在主程序中直接实现,与udp客户端程序实现的差别在于socket函数创建文件描述符(依旧不需要bind函数)之后,调用connect函数与服务器建立连接,成功以后就可以正常的通信了,这一点与udp客户端实现也是一样。
这里重点讲的就是,其实connect函数就是在发起三次握手,三次握手成功以后服务器的accept函数就会返回一个与客户端通信的文件描述符。也可以提一嘴,四次挥手是在客户端关闭(close函数)了socket函数创建出的文件描述符,详细会在传输层详解中讲到。
代码:
#include <iostream>
#include <string>
#include <string.h>
#include <cstdlib>
#include <cstdio>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "thread.hpp"
#include "log.hpp"using std::cout;
using std::cerr;
using std::endl;
using std::string;static void usage(string proc)
{cout<<"usage: "<<proc<<" port"<<endl;
}// ./tcpclient serverip serverport
int main(int argc,char* argv[])
{if(argc!=3){usage(argv[0]);exit(1);}string serverip=argv[1];uint16_t serverport=atoi(argv[2]);int sock=socket(AF_INET,SOCK_STREAM,0);if(sock<0){cerr<<"create socket error"<<endl;exit(2);}struct sockaddr_in server;memset(&server,0,sizeof(server));server.sin_family=AF_INET;server.sin_port=htons(serverport);server.sin_addr.s_addr=inet_addr(serverip.c_str());if(connect(sock,(struct sockaddr*)&server,sizeof(server))<0){cerr<<"connect error"<<endl;exit(3);}cout<<"connect success"<<endl;while(true){string msg;cout<<"请输入:";getline(cin,msg);if(msg=="quit")break;send(sock,msg.c_str(),msg.size(),0);char buffer[1024];ssize_t s=recv(sock,buffer,sizeof(buffer)-1,0);if(s>0){buffer[s]=0;cout<<"server# "<<buffer<<endl;}else if(s==0){cout<<"link break"<<endl;break;}elsebreak;}close(sock);return 0;
}
运行结果:
后记
在本章节中,我们先从网络编程所需要的概念基础下手,再从运用的视角去详细介绍了各种供数据在网络中传输的socket接口,接着编写简单的udp、tcp服务器综合使用这些接口加以巩固。虽接口看起来很多并且细节也多而且复杂,但是用于网络数据传输的接口大部分也就涉及到这些,也就是说只要掌握这些,网络传输的功能就掌握了一大半了,因此在学习这些接口时要仔细,最后的简单udp、tcp服务器可以反复的独立编写,加强记忆,在学习后面较为复杂的服务器时可以相对轻松,加油。