在前面本喵对网络的整体轮廓做了一个大概的介绍,比如分层,协议等等内容,现在我们直接进入socket(套接字)编程,先来感受到网络编程。
我们知道,在网络通信中,存在两套地址,一套是IP地址,另一套是MAC地址。
- IP地址:标识计算机在网络中的唯一性。
而IP地址又分为源IP地址和目的IP地址:
在网络通信的报文中,其中报头包含着源IP和目的IP。
如上图所示,报文从用户A的计算机传送到了用户B的计算机,但是网络通信的目的就是将报文从一台计算机传送到另一台计算机吗?
- 将数据从计算机A传送到计算机B是手段,并不是网络通信的目的。
- 真正进行通信的是用户A和用户B,也就是计算机A上的某个应用程序和计算机B上的某个应用程序之间在通信。
网络通信的目的就是让两台计算机上的两个进程在进行通信。
IP地址可以标识两台计算机的唯一性,但是每台计算机上会存在大量的进程,如何保证计算机A某个进程发送的数据能让计算机B指定的进程接收到呢?
换句话说,如何标识一台计算机上进程的唯一性呢?
采用端口号port来标识计算机上进程的唯一性。
- 端口号是一个2字节16位的整数。
- 端口号用来标识一个进程,告诉操作系统要把数据交给哪一个进程。
- 一个端口号只能被一个进程占用。
现在我们有用来标识计算机在网络中唯一性的IP地址,又有用来标识进程在计算机中唯一性的端口号port。
- socket(套接字) = IP地址 + 端口号port。
所以要想两个进程间实现通信,必须各自有各自的套接字。
网络通信实际上是两台计算机或者多台计算机上的进程之间在通信,和我们之前Linux学习的进程间通信的区别在于进程位于不同的计算机上。
- 网络通信的本质:进程间通信。
- Linux下一切皆文件,所以网络在系统中也是一个"文件",也有
struct
结构体,也有文件描述符。
我们知道,每个进程都有一个pid
来标识它在当前计算机上的唯一性,为什么网络中还需要一个端口号port
来标识进程的唯一性呢?不能用pid
吗?
在技术实现上是完全可以用pid
的,所以就需要考虑为什么偏偏就用了端口号port
?
pid
,网络使用port
来标识进程的唯一性,实现了系统与网络的解耦。pid
,只有需要网络的进程才会分配一个port
。比如我们平时使用的QQ,我们手机上的QQ都是客户端,我们打开QQ使用都是在向服务器上的QQ进程发起网络请求,而这个服务器位腾讯公司,服务进程根据用户的网络请求再做出对应的反馈交给用户。
- 我们下载了某个应用程序以后,该程序里就绑定了服务端对应进程的IP地址和端口号。
- 所以使用应用程序的时候,就能精准的和服务端上对应的进程进行网络通信。
服务器的IP地址并不会随意变化,为了保证客户端每次都能找到服务端的进程,服务端的port
也不能变化。
pid
来代替端口号的话,服务器每重启一次,服务进程的pid
值就会改变,客户端就无法找到服务进程了。绑定了port
的进程PCB会被维护在一个哈希表中,port
就是key值,操作系统能够根据key值找到对应的PCB,然后再执行它。
这两个协议的具体原理和细节在后面本喵会详细讲解,这里仅需要大概了解一下它两的特定即可。
TCP协议:(Transmission Control Protocol 传输控制协议)。
- 传输层协议。
- 需要通信双方建立连接。
- 是一种可靠传输,不会发生丢包等问题。
- 面向字节流。
UDP协议:(User Datagram Protocol 用户数据报协议)。
- 传输层协议。
- 不需要通信双方建立连接,直接发生即可。
- 不可靠传输,可能会发生丢包等问题。
- 描写数据报。
具体这些特点是什么意思,以后会讲解到,这里只需要记住以上内容即可。
我们的计算机分为大端机和小端机,不同的电脑型号就不一样,两台计算机大小端不同,接收到的数据解释出来意义也不同。
- 规定:网络中的字节序都采用大端。
如果你的计算机是大端机,那么就可以直接向网络中发数据和从网络中接收数据,不用做转换。
如果你的计算机是小端机,那么在向网络中发送数据时,需要先将数据转换成大端,再发送到网络中。从网络中接收下来的数据,需要先转换成小端再使用。
此时就存在两个问题:
这两个问题虽然我们自己能解决,但是比较繁琐,而且很容易出错,所以操作系统提供了相应的接口来进行大小端转换:
#include //必须包含的头文件
// 主机序列转网络序列
uint32_t htonl(uint32_t hostlong);//将主机上unsigned int类型的数据转换成对应网络字节序
uint16_t htons(uint16_t hostshort);//将主机上unsigned short类型的数据转换成对应网络字节序
// 网络序列转主机序列
uint32_t ntohl(uint32_t netlong);//将从网络中读取的unsigned int类型的数据转换成当前计算机字节序
uint16_t ntohs(uint16_t netshort);//将从网络中读取的unsigned short类型的数据转换成当前计算机字节序
host
,代表着主机,n表示network
,代表着网络,s表示unit16_t
,l表示uint32_t
。
socket
系统调用专门用来创建套接字,在创建的时候指定使用哪种通信协议。
参数解释:
这是地址族,用来指定创建的套接字进行的是网络通信还是本地通信。
该参数可以填上图所示中的任何一个,经常使用的是AF_INET
表示使用IPv4
的网络套接字进行网络通信
这是用来指定socket
提供的能力类型,比如是面向字节流还是面向用户数据报。
该参数可以使用上图中的任何一个,其中画红色框的是面向字节流和面向用户数据报,也就是TCP和UDP。
该参数是用来指定具体的协议名的,比如指定TCP或者DUP,但是根据前两个参数就可以确定使用哪个协议了,这个一般设置为0即可。
- 成功返回一个int类型的值,其实就是一个文件描述符
sockfd
。- 失败返回-1,并且设置错误码
errno
。
bind
用来将IP地址和端口号port创建的socket
套接字绑定,也就是将IP地址和端口号port和系统绑定。
参数解释:
使用socket()
返回的文件描述符sockfd
,用来指定绑定哪个套接字。
struct sockaddr
是一个结构体。
这个参数是表示sockaddr
结构体大小的,单位是字节,socklen_t
本质是unsigned int
类型的32位变量。
成功返回0,失败返回-1,并且设置错误码errno
。
// 开始监听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协议才会用到,后面本喵再详细讲解。
套接字有很多种类型,常见的有三种:
这些套接字的应用场景完全不同,所以不同种类的套接字就对应一套系统调用接口,所以三套就会对应三套不同的接口。
网络套接字:
struct sockaddr_in {
short int sin_family; // 地址族,一般为AF_INET
unsigned short int sin_port; // 端口号,网络字节序
struct in_addr sin_addr; // IP地址
unsigned char sin_zero[8]; // 用于填充,使sizeof(sockaddr_in)等于16
};
通过sockaddr_in
结构体,将IP地址,端口号,以及网络通信AF_INET
通过系统调用bind
与系统绑定,从而进行网络通信。
域间套接字:
struct sockaddr_un {
sa_family_t sun_family; /* AF_UNIX */
char sun_path[108]; /* 带有路径的文件名 */
};
sockaddr_un
只有域间通信方式AF_UNIX
以及域间通信的路径名。
而设计者为了方便使用,无论是网络通信还是域间通信,都使用一套接口,通过设置不同参数来解决所有通信场景。
sockaddr_in
和sockaddr_un
是用于网络通信和域间通信两个不同的通信场景,它们的区别就在于结构体起始处的16位地址类型不同,网络通信使用AF_INET
,域间通信使用AF_UNIX
。
sockaddr
结构体。
- 在填充IP地址,端口号,以及地址类型的时候,仍然是对
sockaddr_in
进行填充。- 在使用
bind
系统调用时,将sockaddr_in
强转成sockaddr
类型,在函数内部它会根据前两个字节自行判断是什么类型的通信,然后再强制转回去。
可以将sockaddr
看成是基类,把sockaddr_in
和sockaddr_un
看出是派生类,此时就构成了多态体系。
网络通信一定是双方的,一端是服务端(Server)接收数据,另一端是客户端(Client)发送数据。
网络通信首先就是要有套接字,也就是要有IP地址和端口号port,所以我创建的服务器中必须有这两项。
class udpSever
{
private:
uint16_t _port;//服务进程端口号
string _ip;//服务器ip地址
int _sockfd;//socket返回值
};
使用系统调用socket()
时,相当于打开了服务器的网卡,在系统中会创建一个struct file
类型的结构体,来描述网卡的信息,和文件操作类型,所以会返回一个sockfd
,这其实就是一个文件描述符。所以服务器的成员变量有上面代码所示的三个。
在构造函数构造服务器对象的时候,需要传入端口号port和IP地址。
- 端口号
port
是指定的,一旦指定就不能再更改,这里本喵指定为8080。- IP地址是服务器的IP地址。
在shell
上使用指令ifconfig
,可以看到服务器的IP信息,如上图所示,红色框中的inet:127.0.0.1
是本地环回,使用这个IP地址不会经过物理层将数据发出去,而且从链路层又返回到了当前计算机。
如上图所示,数据从当前计算机上的客户端进程流向了当前计算机上的服务的进程。所以可以使用本地环回IP地址来进行测试。
但是我们知道,每台计算机在网络中都有一个公网IP,这个IP和本地回环不一样,还有局域网IP等等。存在这么多IP,都标识着服务器这一台计算机。
如果服务器仅仅绑定本地换行的IP地址,当另一台计算机的服务端想要通过公网IP地址发起申请的时候,由于两个IP地址不一样,所以就会忽略客户端的申请。
- 为了能够让服务端接收所有的网络请求,将服务器的IP地址绑定为
0.0.0.0
,转换成uint32_t
类型就是0。
其实每台计算机绑定的都不止一个IP,当使用bind
绑定的IP地址是0.0.0.0
的时候,这台计算机收到的所有网络请求都会接收,并且根据相应的端口进行处理。
所以本喵将udpSever
中构造函数IP地址形参设为缺省值:
static const string defaultIP = "0.0.0.0";
//构造函数
udpSever(const uint16_t &port, const string &ip = defaultIP)
: _port(port), _ip(ip), _sockfd(-1)
{}
initSever()
在这个初始化函数中创建socket
,并且和操作系统进行绑定bind
。
socket()
系统调用创建套接字,并且进行判断,创建失败打印相应错误信息并且退出进程。sockaddr_in
结构体:首先需要对该结构体进行初始化,也就是清0和填充,使用函数bzero
进行初始化:
这是一个库函数,需要包含头文件
,该函数的作用和memset
一样,不同之处在于bzero
只能清零,第一个参数是目标地址,第二个参数是要清零的字节数。
在填充sockaddr_in
结构体的时候,将地址类型sin_family
填充为AF_INET
表示网络通信。
在填充端口号sin_port
的时候,需要使用htons()
函数,将主机字节序转换成网络字节序,然后再进行填充。
我们平时看到的IP地址是都是点分十进制的,如127.0.0.1
,这是一个字符串,这是为了方便用户阅读,其实在网络中,IP地址是一个uint32_t
类型的数据。
所以在填充IP地址地址的时候,需要先将点分十进制的IP地址,使用函数inet_addr()
转换成网络类型的uint32_t
类型,然后再进行填充。
形参是const char* cp
,也就是点分十进制的IP地址,返回类型是in_addr_t
。
typedef uint32_t in_addr_t;
其实就是uint32_t
类型,只是重命名成了in_addr_t
。
再使用bind
将IP地址和端口号和系统绑定,在绑定之前,sockaddr_in
是在栈区上的,操作系统根本不知道设的值是什么,只有使用bind
之后,才真正将IP地址和端口号绑定到了操作系统中。
在绑定完成后进行判断,如果失败,打印相应错误信息,并且退出进程。
这里使用枚举来列举错误码,在创建套接字和绑定的时候,如果发生了错误,在进程退出时给与相关的错误码。
至此,UDP服务器网络通信的预备工作全部完成,此时就可以进行网络通信了。
start()
在预备工作做好以后,还需启动服务器,服务器进程是一个常驻内存的进程,也就是一个while(1)
的死循环,在这个循环中进行网络数据的接收,处理,以及发送。
上图所示的系统调用recvfrom()
用来接收网络中发过来的数据,也就是从套接字中接收。
- 第一个参数是
sockfd
,是创建套接字时返回的文件描述符fd
。- 第二个参数
buf
是用来存储从网络中读取下来的数据的。- 第三个参数是
buf
缓冲区的大小。- 第四个参数
flags
是读取的方式,一般设置为0,即阻塞读取数据。- 第五个参数
sockaddr* src_addr
是一个输出型参数,同样传参sockaddr_in
结构体,系统会自动对这个结构体进行填充,可以获取数据的来源,包括发送方的地址类型,端口号port以及IP地址。- 第六个参数
addrlen
是第五个输出型结构体变量的大小所在的地址,注意类型是socklen_t*
的,和bind
的时候不一样。- 返回值ssize_t,返回读取到的数据个数,单位是字节,如果读取失败则返回-1。
如上图所示代码,服务端启动后便常驻内存,网络中有数据便读,没有数据便阻塞等待。
读取数据时,除了数据本身,还有发送方的数据,都在sockaddr_in
类型的结构体peer
中。
从网络中获取到发送方的IP地址是uint32_t
类型的,所以需要使用inet_ntoa()
函数将其转换成点分十进制的,方便用户阅读。
红色框中所示就是inet_ntoa
的函数声明,以字符串的形式返回点分十进制的IP地址,参数类型是struct in_addr in
。
in_addr
结构体中的变量其实就是一个uint32_t
类型的,但是这里传参的时候,不需要具体到s_addr
,直接传结构体就行,和绑定IP地址时填充sockaddr_in
不一样。
此时我们就可以创建服务器对象并且开始使用他了。
udpSever.cpp
在运行可执行程序的时候,需要输入命令行参数,由于IP地址设置了缺省值,所以命令行参数只需要一个端口号即可。
如果执行时,命令行参数没有输对,进程就会退出,并且打印使用信息,如果命令行参数输入正确,就向下执行。
使用一个智能指针unique_ptr
来管理服务器对象,然后再初始化initSever
,之后再开始执行服务器进程start
。
此时服务器进程就开始运行了。那么我们怎么知道它是否运行了呢?
使用指令sudo netstat -nuap
可以查看当前服务器上的网络进程,如上图所示。
- 加
sudo
提权,如果不加,打印的信息不完整。
可以看到,存在一个网络进程,IP地址是0.0.0.0
,端口号是8080
,进程pid是376
,进程名字是udpSever
,和我们设定的一样。
至此服务端的工作就做完了,只有客户端发送数据,服务端就可以收到。
class udpClient
{
public:
//构造函数,初始化服务端IP地址和端口号
udpClient(const string& severip, const uint16_t& port)
:_severip(severip),_severport(severport),_sockfd(-1)
{}
private:
int _socketfd;//套接字文件描述符
string _severip;//服务器IP地址
uint16_t _severport;//服务器端口号
};
对于客户端,只需要知道服务端计算机的IP地址和端口号port即可,不需要知道自己的,在使用构造函数构建客户端对象的时候,需要传入服务端的IP地址和端口号。
- 客户端有无数个,但是服务端只有一个。
initClient
客户端的初始化中,只需要创建套接字即可,不需要显式bind
。
- 对于客户端而言,指定唯一的IP地址和端口号没有意义,除了服务器以外,没有其他用户会向客户端发起网络请求。
run()
在发送数据之前,需要先告诉操作系统要把数据发送到哪里,所以需要指定服务器的IP地址和端口号port,所以同样需要填充sockaddr_in
结构体,创建一个sever
变量。
- 指定地址类型为网络通信,将点分十进制的IP地址转换成
uint32_t
类型,再将端口号转换成网络字节序。
向网络中发送数据的时候使用到的系统调用是sendto()
:
- 第一个参数
sockfd
是创建的套接字的文件描述符。- 第二个参数
buf
是要发送的数据所在的缓冲区。- 第三个参数
len
是要发生的数据个数,以字节为单位。- 第四个参数
flags
是发送方式,一般设置为0,表示阻塞发送。- 第五个参数
dest_addr
是存放服务器IP地址和端口号port的sockaddr_in
结构体变量,在传参的时候需要强转为struct sockaddr*
。- 第六个参数,是第五个参数中结构体变量的大小,以字节为单位。
在调用sendto
向网络中发送数据的时候,操作系统会自动将客户端的IP地址和端口号绑定,并且一起发送出去。
在服务端收到客户端的报文中,就包含着客户端的IP地址和端口号,通过从recvfrom
填充的sockaddr_in
结构体中可以提取客户端操作系统随机分配的那个端口号。
服务端同样可以根据这个IP地址和端口号再给客户端发送消息。
udpClient.cpp
在运行udpClient
的时候,需要传入两个命令行参数,一个是服务器的IP地址,一个是服务器的端口号port。
同样使用unique_ptr
智能指针来管理客户端对象,创建好后进行初始化,然后开始运行。
由于本喵是在一台服务器上测试客户端和服务端,所以使用的是本地环回,本地环回的IP地址就是127.0.0.1
,所以第一个命令行参数就是这个,第二个命令行参数是服务器指定的端口号,前面本喵指定的是8080
。
- IP地址使用公网IP,局域网IP,还有本地环回IP,服务端都可以收到客户端的请求,因为服务端绑定的是
0.0.0.0
。
可以看到,在客户端运行起来以后,打印信息socket success: 3
,打印的这个数字就是套接字的文件描述符,之所以是3,是因为0,1,2被标准输入输出以及标准错误占用了。
- 更进一步说明了,套接字创建好后,在操作系统中就是一个文件,也是一个结构体。
此时客户端和服务端都执行起来了,双方就可以进行通信了:
可以看到,客户端发送的内容,服务端收到了,并且还有客户端的IP地址和端口号。
重新运行客户端程序,发送数据,可以看到,客户端新收到的数据中,端口号变了,从45839
变成了47530
,这是因为客户端的端口号是由操作系统分配的,并不是自己指定的,所以每次运行时端口号都不一样。
上面我们成功的让客户端和服务端建立起了连接,并且能正常进行网络通信,现在我们让服务端用户层和网络进行解耦。
在服务端中增加回调函数_callback
,使用function
包装,回调函数的类型是void(int, string, uint16_t, string)
。
现在我们实现一个英汉互译的词典,客户端输入英文单词,服务端查它对应的汉语,并且返回给客户端。
dict.txt
文件,里面放入英文和对应的汉语。main
函数中,加载词典,调用initDict()
函数。ifstream
类打开dict.txt
词典文本,再调用cutString()
将文本中的英文和中文分开。unordered_map
中。此时我们的词典就建成了,本喵只是简单的写了几个英文单词。
服务器用户层的回调函数:
在回调函数中,使用从网络中接收到的客户端请求,也就是英文单词,在unordered_map
中查找对应的中文意思,如果找到了,将中文赋值给response_message
,如果没有找到,则将unkonwn
赋值给response_message
。
然后将查询的结构response_message
通过网络发送给客户端。
- 可以看到,此时就实现了用户层和网络的解耦,用户要进行的操作完全由用户自己在回调函数中实现即可。
- 网络底层的配置不用做任何改变。
由于客户端还要接收服务端的反馈信息,所以客户端的网络底层需要做一些修改:
在客户端发送完数据以后,便开始等待服务端的反馈,如果服务端发送来信息则接收,并且打印在屏幕上。
这个例子中,打印的就是客户端发送英文单词对应的中文意思。
客户端程序运行起来后,输入英文单词,如上图所示红色框,就会得到对应的中文,如上图绿色框所示。
- 翻译的过程是由服务器完成的,客户端只需要将英文单词交给服务器即可。
服务端可以看到客户端发来的英文单词,如上图绿色框中所示。具体的翻译逻辑是在回调函数中实现的。
如此一来我们英汉互译词典的功能就实现了。
还可以做一些相关的改进,此时的客户端,发送数据和读取数据都是阻塞的,在发送完毕后只能等数据到来再去读取,程序不会执行下去。可以将发送和读取弄成两个线程去执行,此时发送和读取就不不干涉了。
UDP协议的网络通信非常简单,只需要使用socket()
创建套接字,服务器的话需要显式bind
,客户端不需要显式bind
,还有就是需要使用recvfrom
从网络中(套接字)中读取数据,以及使用sendto
向网络中(套接字)中发送数据。