在前面我们说过可以使用IP地址来标识一台主机,但是我们光有IP地址就可以完成通信了嘛?
答案是:不可以,当我们的主机接收到了数据以后还要确定这个数据是发送给哪一个进程的,两台主机的两个软件进行网络通信时,我们还需要有一个其他的标识来区分出这个数据要给哪个程序进行解析,于是就有了端口号。
端口号(port)是传输层协议的内容,它有以下特点:
2
字节16
位的整数。理解 “端口号” 和 “进程ID”
我们之前在学习系统编程的时候, 学习了 pid
表示唯一一个进程; 此处我们的端口号也是唯一表示一个进程. 那么这两者之间是怎样的关系? 那在进行网络通信时为什么不直接用PID来代替port呢?
进程ID(PID)是用来标识系统内所有进程的唯一性的,它是属于系统级的概念;而端口号(port)是用来标识需要对外进行网络数据请求的进程的唯一性的,它是属于网络的概念。
一台机器上可能会有大量的进程,但并不是所有的进程都要进行网络通信,可能有很大一部分的进程是不需要进行网络通信的本地进程,此时PID虽然也可以标识这些网络进程的唯一性,但在该场景下就不太合适了,而且如果用PID代替端口号,会导致网络管理模块与进程管理模块产生耦合关系,不利于设计出高内聚低耦合的软件。
所以在网络通信中我们可以使用:IP地址+Port号 标识互联网中唯一的一个进程。
此外,从上面通信的例子我们能看出网络通信的本质:其实是进程间通信!,位于不同主机中的两个进程通过网络进行了进程间通信。
传输层协议(TCP和UDP)的数据段中有两个端口号,分别叫做源端口号和目的端口号。 描述的是 “数据是谁发的, 要发给谁”。
认识TCP协议
此处我们先对TCP(Transmission Control Protocol 传输控制协议)有一个直观的认识; 后面我们再详细讨论TCP的一些细节问题。
认识UDP协议
此处我们也是对UDP(User Datagram Protocol 用户数据报协议)有一个直观的认识,后面再详细讨论。
既然UDP协议是不可靠的,那为什么还要有UDP协议的存在?
首先,要保证数据传输的可靠性是需要我们做更多的工作的,TCP协议虽然是一种可靠的传输协议,但这一定意味着TCP协议在底层需要做更多的工作,因此TCP协议底层的实现是比较复杂的。
同样的,UDP协议虽然是一种不可靠的传输协议,但这一定意味着UDP协议在底层不需要做过多的工作,因此UDP协议底层的实现一定比TCP协议要简单,UDP协议虽然不可靠,但是它能够快速的将数据发送给对方。
编写网络通信代码时具体采用TCP协议还是UDP协议,完全取决于上层的应用场景。如果应用场景严格要求数据在传输过程中的可靠性,此时我们就必须采用TCP协议,如果应用场景允许数据在传输出现少量丢包,那么我们肯定优先选择UDP协议,因为UDP协议足够简单。
ps: 一些优秀的网站在设计网络通信算法时,会同时采用TCP协议和UDP协议,当网络流畅时就使用UDP协议进行数据传输,而当网络信号差时就使用TCP协议进行数据传输,这样既保证了数据的可靠性又保障了传输的速率。
计算机在存储数据时是有大小端的概念的:
如果我们编写的程序只在本地机器上运行,那么是不需要考虑大小端问题的,因为同一台机器上的数据采用的存储方式都是一样的,要么采用的都是大端存储模式,要么采用的都是小端存储模式。但如果涉及网络通信,那就必须考虑大小端的问题,否则对端主机识别出来的数据可能与发送端想要发送的数据是不一致的,那么如何定义网络数据流的地址呢?
需要注意的是,所有的大小端的转化工作是由操作系统来完成的,因为该操作属于通信细节,不过也有部分的信息需要我们自行进行处理,比如端口号和IP地址。
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换。
这些函数名很好记,h表示host,n表示network,l表示32位长整数,s表示16位短整数。
例如htonl
表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
#include
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
socket 是“套接字”的意思,学习 socket 编程,也就是学习计算机之间如何通信,并用编程语言来实现它。
socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、IPv6、UNIX Domain Socket。然而各种网络协议的地址格式并不相同。
套接字不仅支持跨网络的进程间通信,还支持本地的进程间通信(域间套接字)。在进行跨网络通信时我们需要传递的端口号和IP地址,而本地通信则不需要,因此套接字提供了sockaddr_in
结构体和sockaddr_un
结构体,其中sockaddr_in
结构体是用于跨网络通信的,而sockaddr_un
结构体是用于本地通信的。
为了让套接字的网络通信和本地通信能够使用同一套函数接口,于是就出现了sockaddr
结构体,该结构体与sockaddr_in
和sockaddr_un
的结构都不相同,但这三个结构体头部的16个比特位都是一样的,这个字段叫做协议家族。
此时当我们在传递在传参时,就不用传入sockeaddr_in *
或sockeaddr_un *
这样的结构体,而统一传入sockeaddr *
这样的结构体。在设置参数时就可以通过设置协议家族这个字段,来表明我们是要进行网络通信还是本地通信,在这些API内部就可以提取sockeaddr
结构头部的16位进行识别,进而得出我们是要进行网络通信还是本地通信,然后执行对应的操作。此时我们就通过通用sockaddr
结构,将套接字网络通信和本地通信的参数类型进行了统一。
sockaddr结构体
sockaddr_in 结构体
netinet/in.h
中,IPv4地址用sockaddr_in
结构体表示,包括16位地址类型,16位端口号和32位IP地址。AF_INET、AF_INET6
。这样,只要取得某种sockaddr
结构体的首地址,不需要知道具体是哪种类型的sockaddr
结构体,就可以根据地址类型字段确定结构体中的内容。socket API
可以都用struct sockaddr*
类型表示,在使用的时候需要强制转化成sockaddr_in
;这样的好处是程序的通用性,可以接收IPv4、IPv6,以及UNIX Domain Socket各种类型的sockaddr
结构体指针做为参数。UDP服务器的初始化就只需要创建套接字和绑定就行了
创建套接字
// 创建 socket 文件描述符
int socket(int domain, int type, int protocol);
功能:socket
函数可以打开一个网络文件,用于网络数据的通信。
对于一般的普通文件来说,当用户通过文件描述符将数据写到文件缓冲区,然后再把数据刷到磁盘上就完成了数据的写入操作。
而对于现在socket
函数打开的“网络文件”来说,当用户将数据写到文件缓冲区后,操作系统会定期将数据刷到网卡里面,而网卡则是负责数据发送的,因此数据最终就发送到了网络当中。
参数说明:
domain
:创建套接字的域(协议家族),也就是创建套接字的类型。该参数就相当于struct sockaddr
结构的前16个位。如果是本地通信就设置为AF_UNIX
,如果是网络通信就设置为AF_INET
(IPv4)或AF_INET6
(IPv6)。type
:创建套接字时所需的服务类型。如果是基于UDP的网络通信,我们采用的就是SOCK_DGRAM
,叫做用户数据报服务,如果是基于TCP的网络通信,我们采用的就是SOCK_STREAM
,叫做流式套接字,提供的是流式服务。protocol
:创建套接字的协议类别。你可以指明为TCP或UDP,但该字段一般直接设置为0就可以了,设置为0表示的就是默认,此时会根据传入的前两个参数自动推导出你最终需要使用的是哪种协议。返回值说明:
-1
,同时错误码会被设置。示例代码:
// udp_server.hpp
#include
#include
#include
#include
#include
#include
#include
#include
class UdpServer
{
public:
UdpServer()
{}
void UdpServerInit()
{
// 1. 创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
std::cerr << "create socket fail : " << strerror(errno) << std::endl;
exit(1);
}
std::cout << "create socket success! " << "sockfd : " << _sockfd << std::endl;
}
~UdpServer()
{
if (_sockfd > 0)
{
close(_sockfd);
}
}
private:
int _sockfd; // 套接字的文件描述符
};
// udp_server.cpp
#include "udp_server.hpp"
#include
#include
int main()
{
std::unique_ptr<UdpServer> up(new UdpServer());
up->UdpServerInit();
return 0;
}
绑定函数
将程序的端口号,IP地址等数据设置进入操作系统内核中
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数说明:
sockfd
:要绑定的文件的文件描述符。也就是我们创建套接字时获取到的文件描述符。addr
:网络相关的属性信息,包括协议家族、IP地址、端口号等。addrlen
:传入的addr
结构体的长度。返回值说明:
将点分10进制的ip转换为整数
in_addr_t inet_addr(const char *cp);
功能:该函数可以将主机序列的字符串风格类型的IP, 转换成为网络序列中的整数风格的IP地址。
将整数转换为点分10进制的ip
char *inet_ntoa(struct in_addr in);
功能: 该函数可以将网络序列中的整数风格的IP地址,转换成为主机序列的字符串风格类型的数据。
ps : 这两个函数调用完毕以后不需要再进行网络序列与主机序列的转化了。
套接字创建完毕后我们就需要进行绑定了,但在绑定之前我们需要先定义一个struct sockaddr_in
结构,将对应的网络属性信息填充到该结构当中,然后通过bind
函数设置进入操作系统内核当中,由于该结构体当中还有部分选填字段,因此我们最好在填充之前对该结构体变量里面的内容进行清空,然后再将协议家族、端口号、IP地址等信息填充到该结构体变量当中。
需要注意的是,在发送到网络之前需要将端口号和IP转换为网络序列,由于端口号是16位的,因此我们需要使用前面说到的htons
函数将端口号转为网络序列。此外,由于网络当中传输的是整数IP,我们需要调用inet_addr
函数将字符串IP转换成整数IP。
当网络属性信息填充完毕后,由于bind
函数提供的是通用参数类型,因此在传入结构体地址时还需要将struct sockaddr_in*
强转为struct sockaddr*
类型后再进行传入。
// udp_server.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
enum { SOCKET_ERR = 1, BIND_ERR};
// 默认端口号
const static uint16_t default_port = 8080;
class UdpServer
{
public:
UdpServer(std::string ip, uint16_t port = default_port)
:_port(port), _ip(ip)
{
std::cout << "ip : " << _ip << " port : " << _port << std::endl;
}
void UdpServerInit()
{
// 1. 创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
std::cerr << "create socket fail : " << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
std::cout << "create socket success! " << "sockfd : " << _sockfd << std::endl;
// 2. 填充sockaddr_in结构体
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
// 将主机序列转换为网络序列
local.sin_addr.s_addr = inet_addr(_ip.c_str());
local.sin_port = htons(_port);
// 3. 绑定IP,端口号
if (bind(_sockfd, (struct sockaddr*)&local, sizeof(local)) != 0)
{
std::cerr << "bind fail :" << strerror(errno) << std::endl;
exit(BIND_ERR);
}
std::cout << "bind success :" << std::endl;
}
~UdpServer()
{
if (_sockfd > 0)
{
close(_sockfd);
}
}
private:
int _sockfd; // 套接字的文件描述符
std::string _ip; // ip地址
uint16_t _port; // 端口号
};
// udp_server.cpp
#include "udp_server.hpp"
#include
#include
int main()
{
std::unique_ptr<UdpServer> up(new UdpServer("1.1.1.1", 8080));
up->UdpServerInit();
return 0;
}
运行结果,可以看出bind
失败了,这与云服务器有关,云服务器不允许我们随意绑定ip
,需要让服务器自己指定IP地址。
当然,云服务器不允许我们随意绑定ip
,也有一定的道理,因为对于一款服务器来说,这台设备可能有多个网卡,这台设备可能有多个IP,如果我们只绑定某个特定的IP就会导致只有某个IP能够收到数据,当数据量很大的时候,传输的效率并不是很高,所以我们可以设置IP为INADDR_ANY
,设置这个IP表示:绑定本主机上面的所有IP。
INADDR_ANY
的值本质就是0,不存在大小端的问题,因此在设置时可以不进行网络字节序的转换。
当服务器初始化完毕后我们就可以启动服务器了,由于服务器是一个永不退出的进程,所以服务器运行以后一定是一个死循环!
读取数据
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
功能:
参数说明:
sockfd
:创建的套接字对应的文件描述符,表示从该文件描述符索引的文件当中读取数据。buf
:读取到的数据的存放位置。len
:期望读取数据的字节数。flags
:读取的方式。一般设置为0,表示阻塞读取。src_addr
:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。addrlen
:src_addr
结构体的长度,返回时此值会被修改为实际读取到的src_addr
结构体的长度,这是一个输入输出型参数。返回值说明:
-1
,同时错误码会被设置。发送数据
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
功能:
参数说明:
sockfd
:创建的套接字对应的文件描述符,表示将数据写入该文件描述符索引的文件当中。buf
:待写入数据的起始地址。len
:期望写入数据的字节数。flags
:写入的方式,一般设置为0,表示阻塞写入。dest_addr
:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。addrlen
:传入dest_addr
结构体的长度。返回值说明:
现在服务端通过recvfrom
函数读取客户端数据,我们可以先将读取到的数据当作字符串看待,将读取到的数据的最后一个位置设置为’\0’,此时我们就可以将读取到的数据进行输出,同时我们也可以将获取到的客户端的IP地址和端口号也一并进行输出。
需要注意的是,我们获取到的客户端的端口号此时是网络序列,我们需要调用ntohs
函数将其转为主机序列再进行打印输出。同时,我们获取到的客户端的IP地址是整数IP,我们需要通过调用inet_ntoa
函数将其转为字符串IP再进行打印输出。
// udp_server.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
enum
{
SOCKET_ERR = 1,
BIND_ERR,
USAGE_ERR
};
class UdpServer
{
public:
UdpServer(uint16_t port)
:_port(port)
{
std::cout << "port : " << _port << std::endl;
}
void UdpServerInit()
{
// 1. 创建套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
std::cerr << "create socket fail : " << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
std::cout << "create socket success! " << "sockfd : " << _sockfd << std::endl;
// 2. 填充sockaddr_in结构体
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
// 将主机序列转换为网络序列
local.sin_addr.s_addr = INADDR_ANY;
local.sin_port = htons(_port);
// 3. 绑定IP,端口号
if (bind(_sockfd, (struct sockaddr*)&local, sizeof(local)) != 0)
{
std::cerr << "bind fail :" << strerror(errno) << std::endl;
exit(BIND_ERR);
}
std::cout << "bind success !" << std::endl;
}
void UdpServerStart()
{
// 缓冲区
char buf[2048];
// 网络信息结构体
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// 死循环不能让服务器退出
while (true)
{
memset(&peer, 0, len);
// 收取消息
ssize_t num = recvfrom(_sockfd, buf, sizeof(buf) - 1, 0, (struct sockaddr*)&peer, &len);
if (num < 0)
{
std::cerr << "recvfrom fail !" << std::endl;
continue;
}
else
{
// 结尾补上\0,形成C风格字符串
buf[num] = '\0';
}
// 提取客户端的ip和端口号
std::string peer_ip = inet_ntoa(peer.sin_addr);
uint16_t peer_port = ntohs(peer.sin_port);
std::cout << peer_ip << " | " << peer_port << " |# " << buf << std::endl;
// 发消息
sendto(_sockfd, buf, strlen(buf), 0, (struct sockaddr*)&peer, len);
}
}
~UdpServer()
{
if (_sockfd > 0)
{
close(_sockfd);
}
}
private:
int _sockfd; // 套接字的文件描述符
uint16_t _port; // 端口号
};
我们服务器启动的时候需要指定端口号,所以这里使用了命令行参数。
// udp_server.cpp
#include "udp_server.hpp"
#include
#include
// 使用手册
static void usage(std::string proc)
{
std::cout << "usage\n\t" << proc << " 端口号" << std::endl;
}
// 命令行参数,必须输入两个参数,一个是程序名,一个是端口号
int main(int argc, char* argv[])
{
if (argc != 2)
{
usage(argv[0]);
exit(USAGE_ERR);
}
// 提取本地端口号
uint16_t port = atoi(argv[1]);
std::unique_ptr<UdpServer> up(new UdpServer(port));
up->UdpServerInit();
up->UdpServerStart();
return 0;
}
程序启动以后我们可以使用netstat -naup
显示进程的网络信息。
netstat
常用选项说明:
运行结果
查看网络信息
netstat
命令显示的信息中:
其中Foreign Address写成0.0.0.0:*表示任意IP地址、任意的端口号的程序都可以访问当前进程。
首先,由于是网络通信,通信双方都需要找到对方,因此服务端和客户端都需要有各自的IP地址和端口号,只不过服务端需要显示的进行IP和端口号的绑定,而客户端不需要显示的进行绑定的,这个绑定的工作由操作系统来进行绑定,当我们调用类似于sendto
这样的接口时,操作系统会自动给当前客户端获取一个唯一的端口号。
服务器是为了给客户提供服务的,因此服务器必须要让客户知道自己的IP地址和端口号,否则客户端是无法向服务端发起请求的,这就是服务端要进行显示绑定的原因,只有一个进程绑定了端口号之后这个端口号才真正属于自己,因为一个端口只能被一个进程所绑定,服务器绑定一个端口就是为了独占这个端口。
而客户端在通信时虽然也需要端口号,但客户端一般是不进行绑定的,客户端访问服务端的时候,端口号只要是唯一的就行了,不需要明确是那个特定的端口号。
一台设备上可以运行很多客户端,例如:B站客户端绑定了8080端口号,那么以后8080端口号就只能给B站客户端使用,如果8080端口号又被淘宝客户端绑定了并且淘宝先启动了,那么B站客户端就无法启动了,因此客户端端口通常是不绑定,由OS动态分配,也就是说,客户端每次启动时使用的端口号可能是变化的,此时只要我们的端口号没有被耗尽,客户端就永远可以启动。
客户端的编写与服务端类似,只不过客户端不需要我们进行绑定工作的,此外作为一个客户端,它必须知道它要访问的服务端的IP地址和端口号,因此在我们启动客户端时中需要引入服务端的IP地址和端口号。
客户端和服务端在功能上是相互补充的,我们上面的服务器是在读取客户端发来的数据然后回发回去,那么这里我们的客户端就应该向服务端发送数据,然后接收服务器回发的数据。
// client.cpp
#include
#include
#include
#include
#include
#include
#include
enum
{
SOCKET_ERR = 1,
BIND_ERR,
USAGE_ERR
};
// 使用手册
static void usage(std::string proc)
{
std::cout << "usage\n\t" << proc << " IP 端口" << std::endl;
}
// 命令行参数,必须输入三个参数,一个是程序名,一个是IP,一个是端口号
int main(int argc, char* argv[])
{
if (argc != 3)
{
usage(argv[0]);
exit(USAGE_ERR);
}
// 1. 得到服务器的IP和端口
std::string server_ip = argv[1];
uint16_t server_port = atoi(argv[2]);
// 2. 创建套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
std::cerr << "create socket fail : " << strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
// 3. 填充server结构体
struct sockaddr_in server;
socklen_t len = sizeof(server);
memset(&server, 0, len);
server.sin_family = AF_INET;
server.sin_addr.s_addr = inet_addr(server_ip.c_str());
server.sin_port = htons(server_port);
// 4. 业务处理
std::string message;
char buf[2048];
while (true)
{
std::cout << "[pan的服务器] :> ";
getline(std::cin, message);
// 发送消息
// 在我们首次调用系统调用发送数据时,OS会随机选择一个端口号 + 自己的IP进行bind
ssize_t num = sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&server, len);
if (num < 0)
{
std::cerr << "sendto fail !" << std::endl;
continue;
}
struct sockaddr_in temp;
socklen_t temp_len = sizeof(temp);
memset(&temp, 0, temp_len);
// 收消息
num = recvfrom(sockfd, buf, sizeof(buf) - 1, 0, (struct sockaddr*)&temp, &temp_len);
if (num < 0)
{
std::cerr << "recvfrom fail !" << std::endl;
continue;
}
else
{
buf[num] = '\0';
}
std::cout << "server's message | " << buf << std::endl;
}
return 0;
}
现在服务端和客户端的代码都已经编写完毕,我们可以先进行本地测试,现在我们运行服务器时指明端口号为8080,再运行客户端,此时客户端要访问的服务器的IP地址就是本地环回127.0.0.1地址,服务端的端口号就是8080。
127.0.0.1
:本地环回,表示当前主机的地址,通常用来进行本地通信或测试。我们要让服务端先运行,然后再让客户端运行,之后提示我们进行输入,当我们在客户端输入数据后,客户端将数据发送给服务端,此时服务端再将收到的数据打印输出后回发,这时我们在服务端和客户端的窗口都能看到我们输入的内容。
此时我们再用netstat
命令查看网络信息,可以看到服务端的端口是8080,客户端的端口是44777。这里客户端能被netstat命令查看到,说明客户端也已经动态绑定成功了,这就是我们所谓的网络通信。
如果你是云服务器,请确保你想使用的端口已经开放,下面是腾讯云的云服务器开放端口的方法:
好了,我们开始进行网络测试:
你可以将此客户端软件给更多的人,让它们都能够连接你的服务器,进行网络通信。