文章目录
- 1. socket函数:创建套接字
- 2. 服务端
- 2.1 服务端创建套接字
- 2.2 服务端绑定
- 2.3 字符串IP和整数IP
- 2.4 运行服务器
- 3. 客户端
- 3.1 客户端创建套接字
- 3.2 启动客户端
- 4. 本地测试
- 5. INADDR_ANY
1. socket函数:创建套接字
我们把服务封装成一个类,当我们定义出一个服务器对象后需要马上初始化服务器,而初始化服务区需要做的第一件事就是创建套接字。
创建套接字我们需要用到socket函数
参数说明:
- domain:创建套接字的域或者叫做协议家族,也就是套接字的类型。该参数就相当于struct sockaddr的前十六位。如果是本地通信就设为AF_UNIX,如果是网络通信就设置为AF_INET(IPv4)或者AF_INET6(IPv6)。
- type:创建套接字时所需的服务类型。其中最常见的套接字服务类型是SOCK_STREAM和SOCK_DREAM。如果是基于UDP的网络通信,我们采用的就是SOCK_DGRAM,叫做用户数据报服务,如果是基于TCP的网络通信,我们采用的就是SOCK_STREAM,叫做流式套接字,提供的是流式服务。
- protocol:创建套接字的协议类别。可以指明为TCP或者UDP,但该字段一般直接设置为0就可以了,设置为0表示的就是默认,此时会根据传入的前两个参数自动推导出你用的是哪种协议。
返回值说明:
- 套接字创建成功返回文件描述符,创建失败返回-1,同时错误码会被设置。
socket属于什么接口?
网络协议栈是分层的,根据TCP/IP四层协议,自顶向下以此是应用层、传输层、网络层、数据链路层。而我们现在所写的代码都叫做用户级代码,也就是说我们是在应用层编写代码,因此我们调用的实际是下三层的接口,而传输层和网络层都是在操作系统内完成的,也就意味着我们在应用层调用的接口都叫做系统调用接口。
socker函数在底层做了什么?
socket函数是被进程所调用的,而每一个进程在系统层面上都有一个管理进程的PCB、文件描述符表(files_struct)以及对于打开的文件。而文件描述符表内包含了一个数组fd_array,其中数组的0、1、2下标默认被标准输入、标准输出以及标准错误所占用。
当我们调用socket函数创建套接字时,实际相当于我们打开了一个“网络文件”,打开后在内核层面上就形成了一个对应的struct file结构体,同时该结构体被连入到了该进程对应的文件双链表,并将该结构体的地址填入到了fd_array数组当中下标为3的位置,此时fd_array数组中下标为3的指针就指向了这个打开的“网络文件”,最后3号文件描述符作为socket函数的返回值返回给了用户。
其中每一个struct file结构体中包含的就是对应打开文件各种信息,比如文件的属性信息、操作方法以及文件缓冲区等。其中文件对应的属性在内核当中是由struct inode结构体来维护的,而文件对应的操作方法实际就是一堆的函数指针在内核当中就是由struct file_operations结构体来维护的。而文件缓冲区对于打开的普通文件对应的一般是磁盘,但对于现在打开的“网络文件”来说,这里的文件缓冲区对应的就是网卡。
对于一般的普通文件来说,当用户通过文件描述符将数据写到文件缓冲区中,然后再把数据刷到磁盘上就完成了数据的写入操作。而对于现在socket函数打开的“网络文件”来说,当用户将数据写到文件缓冲区之后,操作系统会定期将数据刷到网卡里面,而网卡是负责数据发送的,因此数据最终就被发送到了网络当中。
2. 服务端
2.1 服务端创建套接字
当我们在进行初始化服务器创建套接字时,就是调用socket函数创建套接字,创建套接字我们要传入的协议家族就是AF_INET,表明我们要进行的是网络通信,而我们需要的服务类型是SOCK_DGRAM,因为我们现在编写的UDP服务器是面向数据报的,而第三个参数设置为0即可。
#pragma once
#include <iostream>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
class UdpServer
{
public:
bool InitServer()
{
// 创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
std::cerr << "socket error" << std::endl;
return false;
}
std::cout << "socket create success, sockfd: " << _sockfd << std::endl;
return true;
}
~UdpServer()
{
if (_sockfd >= 0)
close(_sockfd);
}
private:
int _sockfd; // 文件描述符
};
上面那段代码在hpp头文件中,下面我们在源文件中包含它,并运行起来,看看结果。
#include "UdpServer.hpp"
int main()
{
UdpServer* svr = new UdpServer();
svr->InitServer();
return 0;
}
运行结果如下:
2.2 服务端绑定
现在套接字已经创建成功了,但只是在系统层面上打开了一个文件,操作系统并不知道是要将数据写入到磁盘还是网卡,此时该文件还没有与网络关联起来。
由于现在编写的是不面向连接的UDP服务器,所以初始化的第二件事就是绑定。
bind函数
服务端绑定用到的是bind函数
参数说明:
- sockfd:绑定的文件的文件描述符。也就是我们创建套接字时获取到的文件描述符。
- addr:网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:传入的addr结构体的长度
返回值说明:
- 绑定成功返回0,失败返回-1,同时错误码会被设置。
struct sockaddr_in结构体
在绑定时我们需要将网络相关的属性信息填充到一个结构体当中,然后将该结构体作为bind函数的第二个参数进行传入,这实际就是struct sockaddr_in结构体。
struct sockaddr_in当中的成员如下:
- sin_family:表示协议家族
- sin_port:表示端口号,是一个16位的整数
- sin_addr:表示ip地址,是一结构体,该结构体当中只有一个32位的整数,ip地址实际存储在这个整数中。
剩下的字段一般不作处理。
如何理解绑定?
在进行绑定的时候需要将IP地址和端口号告诉对应的网络文件,此时就可以改变网络文件当中文件操作函数的指向,将对应的操作函数改为对应网卡的操作方法,此时读数据和写数据对应的操作对象就是网卡了,所以绑定实际上就是将文件和网络关联起来。
增加IP地址和端口号
由于绑定时需要用到IP地址和端口号,因此我们需要在服务器当中引入IP地址和端口号,在创建服务器对象时需要传入对应的IP地址和端口号,此时我们就可以根据传入的IP地址和端口号进行初始化。
class UdpServer
{
public:
UdpServer(std::string ip, int port)
: _sockfd(-1), _port(port), _ip(ip)
{};
bool InitServer()
{
// 创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
std::cerr << "socket error" << std::endl;
return false;
}
std::cout << "socket create success, sockfd: " << _sockfd << std::endl;
return true;
}
~UdpServer()
{
if (_sockfd >= 0)
close(_sockfd);
}
private:
int _sockfd; // 文件描述符
int _port; // 端口号
std::string _ip; // IP地址
};
服务端绑定
套接字创建完毕之后我们就需要进行绑定了,但在绑定之前我们需要先定义一个struct sockaddr_in结构,将对应的网络属性信息填充到该结构当中。由于该结构体当中还有部分选填字段,因此我们最好在填充之前对该结构体变量里面的内容进行清空,然后再将协议家族、端口号、IP地址等信息填充到该结构体变量当中。
需要注意的是,在发送到网络之前需要将端口号设置为网络序列,由于端口号是16位的,因此我们需要使用前面说到的htons函数将端口号转为网络序列。此外,由于网络中传输的是整数IP,我们需要调用inet_addr函数将字符串转换成整数IP,然后再将转换后的整数IP进行设置。(inet_addr函数同时做了将字符串转换为整数,以及将主机字节序转换为网络字节序两件事)。
当网络信息填充完毕之后,在bind函数传入结构体地址时还需要将struct sockaddr_in* 强转为struct sockaddr* 类型后再进行传入。
bool InitServer()
{
// 创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
std::cerr << "socket error" << std::endl;
return false;
}
std::cout << "socket create success, sockfd: " << _sockfd << std::endl;
// 填充网络相关信息
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = inet_addr(_ip.c_str());
// 绑定
int ret = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
if (ret < 0)
{
std::cerr << "bind error" << std::endl;
return false;
}
std::cout << "bind success" << std::endl;
return true;
}
2.3 字符串IP和整数IP
IP地址的表现形式有两种,一种是点分十进制,另外一种是直接用一个32位整数表示。
整数IP存在的意义
如果我们在网络传输时直接以基于字符串的点分十进制进行IP地址的传送,俺么此时一个IP地址就至少需要15个字节,但实际并不需要耗费这么多字节。
IP地址实际可以划分为四个区域,其中每个区域的取值都是0~255,而这个范围的数字只需要8个比特位就能表示,因此我们实际只需要32个比特位就能够表示一个IP地址。其中这个32位的整数,每一个字节对应的就是IP地址中的某个区域,我们将IP地址的这种表示方法称之为整数IP,此时每一个IP地址只需要4个字节。
因为采用整数IP的方案表示一个IP地址只需要4个字节,并且在网络通信也能表示同样的含义,因此在网络通信时就没有用字符串IP而用的是整数IP,因为这样能够减少网络通信时数据的传送。
inet_addr函数
在进行字符串IP和网络IP的转换时,系统为我们提供了相应的转换函数,我们直接调用即可。
使用该函数,我们只需传入要进行转换的字符串IP,该函数返回的就是转换后的整数IP。
inet_ntoa函数
将整数IP转换为字符串IP函数为inet_ntoa
需要注意的是,传入inet_ntoa函数的参数类型是in_addr,因此我们在传参时不需要选中in_addr结构当中的32位的成员传入,直接传入in_addr结构体即可。
2.4 运行服务器
UDP服务器的初始化只需要创建套接字和绑定,当服务器初始化完毕之后就可以启动服务了。
服务器实际上就是在周而复始地为我们提供某种服务,服务器在运行起来之后就是一个死循环。由于UDP服务器是不面向连接的,因此只要UDP服务器启动后,就可以直接读取客户端发来的数据。
recvfrom
UDP服务器读取数据的函数叫做recvfrom
参数说明:
- sockfd:对应操作的文件描述符,表示从该文件描述符索引的文件当中读取数据。
- buf:读取数据的存放位置
- len:最大读取数据的字节数
- flags:读取方式,一般设置为0,表示阻塞读取
- src_addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等
- addrlen:调用时传入期望读取的src_addr结构体长度,返回时代表实际读取到的src_addr结构体的长度,这是一个输入输出型参数。
返回值说明:
- 读取成功时返回实际读取到的字节数,读取失败返回-1,同时错误码会被设置
注意:
- 由于UDP是不面向连接的,因此我们除了读取到数据意外还需要获取到对端网络相关的属性信息,包括IP地址和端口号。
- 在调用recvfrom读取数据时,必须将addrlen设置为你要读取的结构体对应的大小。
- 由于recvfrom函数提供的参数也是struct sockaddr* 类型的,因此我们传入结构体地址时需要将struct sockaddr_in* 进行强转。
代码实现
现在服务端通过recvfrom函数读取客户端数据,我们可以先将读取到的数据当作字符串来看待,将读取到的数据的最后一个位置设置为 ‘\0’,此时我们就可以将读取到的数据进行输出,同时我们也可以将获取到的客户端的IP地址和端口号也一并进行输出。
需要注意的是,我们获取到的客户端的端口号此时是网络序列,我们需要通过ntohs函数将其转为主机序列后再进行打印输出。同时,我们获取到的客户端的IP地址是整数IP,此时我们需要通过inet_ntoa函数将其转换为字符串IP再进程输出。
void start()
{
#define SIZE 1024
char buffer[SIZE];
while (1)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t size = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);
if (size > 0)
{
buffer[size] = 0;
int port = ntohs(peer.sin_port);
std::string ip = inet_ntoa(peer.sin_addr);
std::cout << ip << " : " << port << "# " << buffer << std::endl;
}
else
{
std::cerr << "recvfrom error" << std::endl;
}
}
}
注意:如果调用recvform函数读取数据失败,我们可以打印一条提示信息,但是不要让服务器退出,服务器不能因为读取某一个客户端的数据失败就退出。
引入命令行参数
鉴于构造服务器需要传入IP地址和端口号,这里引入命令行参数。此时当我们运行服务器器时后面跟上对应的IP地址和端口号即可。
我们可以先不传入IP地址,使用127.0.0.1这个地址,这个地址等价于localhost即本地主机,我们使用127.0.0.1这个地址时可以称为本地环回,相当于我们一会先在本地测试一些能否正常通信,然后再进行网络通信的测试。
int main(int argc, char* argv[])
{
if (argc != 2)
{
std::cerr << "Usage: " << argv[0] << " port" << std::endl;
return 1;
}
std::string ip = "127.0.0.1"; // 本地环回
int port = atoi(argv[1]);
UdpServer* svr = new UdpServer(ip, port);
svr->InitServer();
svr->start();
return 0;
}
此时运行程序,就可以看到套接字创建成功、绑定成功了,现在服务器就是在等待客户端发送数据。
我们可以通过netstat命令查看当前网络的状态
netstat命令常用选项说明:
- -n:以数字格式显示网络地址和端口,而不进行主机名或服务名的解析
- -l:显示监控中服务器的socket
- -t:显示TCP传输协议的连线情况
- -u:显示UDP协议的连线情况
- -p:显示正在使用socket的程序识别码和程序名称
如果去掉 -n 选项,原本显示IP地址的地方就变成了对应的域名服务器
- Proto:表示协议的类型
- Recv-Q:表示网络接收队列
- Send-Q:表示网络发送队列
- Local Address:表示本地地址
- Foreign Address:表示外部地址
- State:表示当前的装填
- PID:表示该进程的进程ID
- Program name:表示该进程的程序名称
其中Foreign Address写成 0.0.0.0:* 表示任意IP地址、任意端口号的程序都可以访问当前进程。
3. 客户端
3.1 客户端创建套接字
同样地,我们把客户端也封装成一个类,当我们定义出一个客户端对象后也是需要对其进行初始化,而客户端在初始化是也需要创建套接字,之后客户端发送数或接收数据也就是对这个套接字进行操作。
客户端创建套接字时选择的协议家族也是AF_INET,需要的服务类型也是SOCK_DGRAM,当客户端被析构时也可以选择对应的套接字。与服务端不同的是,客户端在初始化时只需要创建套接字就行了,不需要程序员进行绑定。
class UdpClient
{
public:
bool InitClient()
{
// 创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
std::cerr << "socket error" << std::endl;
return false;
}
return true;
}
~UdpClient()
{
if (_sockfd > 0)
close(_sockfd);
}
private:
int _sockfd;
}
客户端要不要进行bind?
client必须要进行bind,要有自己的IP和port,说不要bind是错的,只不过client不需要程序员去进行bind。服务端的port为了客户端更快地找到,它是固定不变的,但是客户端有很多,如果都要让客户处去显式地绑定一个port,程序员可能选的port是一样的。为了避免重复,port由操作系统自动生成并隐式地进行绑定。
所以,在UDP通信中写客户端时,只需要创建套接字即可,不需要bind,bind由操作系统自己完成!
3.2 启动客户端
增加服务端IP地址和端口号
作为一个客户端,它必须知道它要访问的服务端的IP地址和端口号,因此在客户端类当中需要引入服务端的IP地址和端口号,此时我们就可以根据传入的服务端的IP地址和端口号对对应的成员进行初始化。
class UdpClient
{
public:
UdpClient(std::string server_ip, int server_port)
: sockfd(-1), _server_ip(server_ip), _server_port(server_port)
{}
bool InitClient()
{
// 创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
std::cerr << "socket error" << std::endl;
return false;
}
return true;
}
~UdpClient()
{
if (_sockfd > 0)
close(_sockfd);
}
private:
int _sockfd;
int _server_port; // 服务端端口号
std::string _server_ip // 服务端IP地址
}
sendto函数
UDP客户端发送数据的函数叫做sendto
参数说明:
- sockfd:对应操作的文件描述符,表示将数据写入该文件描述符索引的文件当中
- buf:待写入数据的存放位置
- len:期望写入数据的字节数
- flags:写入的方式。一般设置为0,表示阻塞写入。
- dest_addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addlen:传入dest_addr结构体的长度
返回值说明:
- 写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置。
注意:
- 由于UDP是不面向连接的,因此除了待发送的数据以外还需要指明对端网络相关的信息,包括IP地址和端口号等。
- 由于sendto函数提供的参数也是struct sockaddr* 类型的,因此我们在传入结构体地址时需要将struct sockaddr_in* 类型进行强转
启动客户端函数
现在客户端要发送数据给服务端,我们可以让客户端获取用户输入,不断将用户输入的数据发送给服务端。
需要注意的是,客户端中存储的服务端的端口号此时是主机序列,我们需要调用htons函数将其转换为网络序列后再设置进struct sockaddr_int结构体。同时,客户端中存储的服务端的IP地址是字符串IP,我们需要通过调用inet_addr函数将其转换为整数IP后再设置进struct sockaddr_in结构体。
void start()
{
std::string msg;
struct sockaddr_int peer;
memset(&peer, 0, sizeof(peer));
peer.sin_family = AF_INET;
peer.sin_port = htons(_server_port);
peer.sin_addr.s_addr = inet_addr(_server_ip.c_str());
while (1)
{
std::cout << "Please Enter# ";
getline(std::cin, msg);
sendto(_sockfd, msg.c_str(), msg.size(), 0, (struct sockaddr*)&peer, sizeof(peer));
}
}
引入命令行参数
构造客户端时需要传入对应服务器的IP地址和端口号,我们这里也可以引入命令行参数。当我们运行客户端时直接在后面跟上对应服务器端的IP地址和端口号即可。
int main(int argc, char* argv[])
{
if (argc != 3)
{
std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;
return 1;
}
std::string server_ip = argv[1];
int server_port = atoi(argv[2]);
UdpClient* clt = new UdpClient(server_ip, server_port);
clt->InitClient();
clt->start();
return 0;
}
4. 本地测试
现在服务端和客户端的代码都已经编写完毕,我们可以先进行本地测试,此时服务器绑定的是本地环回,现在我们运行服务器时指定端口号为8081,再运行客户端,此时客户端要访问的服务器的IP地址就是本地环回127.0.0.1,服务器的端口号是8081。
此时我们再用netstat命令查看网络信息,可以看到服务端端口号为8081,客户端端口号为56577。这里客户端能被netstat命令查看到,说明客户端也已经绑定成功了。
5. INADDR_ANY
现在我们已经经过了本地测试,接下来就需要进行网络测试了,那是不是直接让服务端绑定我的公网IP,此时这个服务端就能够被外网访问了呢?
理论上是这样的,但是我用的是云服务器,云服务器的IP地址是由对应的云厂商提供的,这个IP地址并不一定是真正的公网IP,这个IP地址是不能直接被绑定的,如果要让外网访问,我们需要将IP地址绑定为INADDR_ANY,这个一个宏值,它对应的值就是0。
绑定INADDR_ANY的好处
当一个服务器的宽带足够大时,一台机器接收数据的能力就约束了这台机器的IO效率,因此一台服务器底层可能装有多张网卡,此时这台服务器就有多个IP地址,但一台服务器上端口号为8081的服务只有一个。这台服务器在接收数据时,这里的多张网卡在底层实际都收到了数据,如果这些数据也都想访问端口号为8081的服务。此时如果服务端在绑定的时候是指明绑定的某一个IP地址,那么此时服务器在接收数据的时候就只能从绑定IP对应的网卡接收数据。而如果服务端绑定的是INADDR_ANY,那么只要是发送给端口号为8081的服务的数据,系统都可以将数据自底向上交给该服务器。
因此服务端绑定INADDR_ANY这种方案也是强烈推荐的方案,所有服务器具体在操作的时候也是用的这种方案。
当前,如果想让外网访问服务器,但你又指向绑定某一个IP地址,那么就不能使用云服务器,此时可以选择使用虚拟机或者你自定义安装的Linux操作系统,那个IP地址就是支持你绑定的,而云服务器是不支持的。