一、网络编程中的一些基础知识
1、认识端口号
在前面我们说过可以使用IP地址来标识一台主机,但是我们光有IP地址就可以完成通信了嘛?
答案是:不可以,当我们的主机接收到了数据以后还要确定这个数据是发送给哪一个进程的,两台主机的两个软件进行网络通信时,我们还需要有一个其他的标识来区分出这个数据要给哪个程序进行解析,于是就有了端口号。
端口号(port)是传输层协议的内容,它有以下特点:
- 端口号是一个
2
字节16
位的整数。 - 端口号用来标识一个进程, 告诉操作系统当前的这个数据要交给哪一个进程来处理。
- IP地址 + 端口号能够标识网络上的某一台主机的某一个进程。
- 一个进程可以绑定多个端口号,但是一个端口号不能被多个进程绑定。
理解 “端口号” 和 “进程ID”
我们之前在学习系统编程的时候, 学习了 pid
表示唯一一个进程; 此处我们的端口号也是唯一表示一个进程. 那么这两者之间是怎样的关系? 那在进行网络通信时为什么不直接用PID来代替port呢?
进程ID(PID)是用来标识系统内所有进程的唯一性的,它是属于系统级的概念;而端口号(port)是用来标识需要对外进行网络数据请求的进程的唯一性的,它是属于网络的概念。
一台机器上可能会有大量的进程,但并不是所有的进程都要进行网络通信,可能有很大一部分的进程是不需要进行网络通信的本地进程,此时PID虽然也可以标识这些网络进程的唯一性,但在该场景下就不太合适了,而且如果用PID代替端口号,会导致网络管理模块与进程管理模块产生耦合关系,不利于设计出高内聚低耦合的软件。
所以在网络通信中我们可以使用:IP地址+Port号 标识互联网中唯一的一个进程。
此外,从上面通信的例子我们能看出网络通信的本质:其实是进程间通信!,位于不同主机中的两个进程通过网络进行了进程间通信。
2、认识TCP协议和UDP协议
传输层协议(TCP和UDP)的数据段中有两个端口号,分别叫做源端口号和目的端口号。 描述的是 “数据是谁发的, 要发给谁”。
认识TCP协议
此处我们先对TCP(Transmission Control Protocol 传输控制协议)有一个直观的认识; 后面我们再详细讨论TCP的一些细节问题。
- 传输层协议
- 有连接,TCP协议是面向连接的,如果两台主机之间想要进行数据传输,那么必须要先建立连接,当连接建立成功后才能进行数据传输。
- 可靠传输,TCP协议是保证可靠的协议,数据在传输过程中如果出现了丢包、乱序等情况,TCP协议都有对应的解决方法。
- 面向字节流
认识UDP协议
此处我们也是对UDP(User Datagram Protocol 用户数据报协议)有一个直观的认识,后面再详细讨论。
- 传输层协议
- 无连接,无需建立连接就可以进行网络传输
- 不可靠传输,无连接也就意味着UDP协议是不可靠的,数据在传输过程中如果出现了丢包、乱序等情况,是没有办法进行处理的。
- 面向数据报
既然UDP协议是不可靠的,那为什么还要有UDP协议的存在?
首先,要保证数据传输的可靠性是需要我们做更多的工作的,TCP协议虽然是一种可靠的传输协议,但这一定意味着TCP协议在底层需要做更多的工作,因此TCP协议底层的实现是比较复杂的。
同样的,UDP协议虽然是一种不可靠的传输协议,但这一定意味着UDP协议在底层不需要做过多的工作,因此UDP协议底层的实现一定比TCP协议要简单,UDP协议虽然不可靠,但是它能够快速的将数据发送给对方。
编写网络通信代码时具体采用TCP协议还是UDP协议,完全取决于上层的应用场景。如果应用场景严格要求数据在传输过程中的可靠性,此时我们就必须采用TCP协议,如果应用场景允许数据在传输出现少量丢包,那么我们肯定优先选择UDP协议,因为UDP协议足够简单。
ps: 一些优秀的网站在设计网络通信算法时,会同时采用TCP协议和UDP协议,当网络流畅时就使用UDP协议进行数据传输,而当网络信号差时就使用TCP协议进行数据传输,这样既保证了数据的可靠性又保障了传输的速率。
3、网络字节序
计算机在存储数据时是有大小端的概念的:
- 大端模式: 数据的高字节内容保存在内存的低地址处。
- 小端模式: 数据的高字节内容保存在内存的高地址处。
如果我们编写的程序只在本地机器上运行,那么是不需要考虑大小端问题的,因为同一台机器上的数据采用的存储方式都是一样的,要么采用的都是大端存储模式,要么采用的都是小端存储模式。但如果涉及网络通信,那就必须考虑大小端的问题,否则对端主机识别出来的数据可能与发送端想要发送的数据是不一致的,那么如何定义网络数据流的地址呢?
- TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节。
- 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出。
- 接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存。
- 因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址.
- 不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据。
- 如果当前发送主机是小端,就需要先将数据转成大端,否则就忽略直接发送即可;
需要注意的是,所有的大小端的转化工作是由操作系统来完成的,因为该操作属于通信细节,不过也有部分的信息需要我们自行进行处理,比如端口号和IP地址。
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换。
这些函数名很好记,h表示host,n表示network,l表示32位长整数,s表示16位短整数。
- 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;
- 如果主机是大端字节序,这些 函数不做转换,将参数原封不动地返回。
例如htonl
表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
#include <arpa/inet.h> 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 编程,也就是学习计算机之间如何通信,并用编程语言来实现它。
socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、IPv6、UNIX Domain Socket。然而各种网络协议的地址格式并不相同。
1、sockaddr结构
套接字不仅支持跨网络的进程间通信,还支持本地的进程间通信(域间套接字)。在进行跨网络通信时我们需要传递的端口号和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 结构体
- IPv4和IPv6的地址格式定义在
netinet/in.h
中,IPv4地址用sockaddr_in
结构体表示,包括16位地址类型,16位端口号和32位IP地址。 - IPv4、IPv6地址类型分别定义为常数
AF_INET、AF_INET6
。这样,只要取得某种sockaddr
结构体的首地址,不需要知道具体是哪种类型的sockaddr
结构体,就可以根据地址类型字段确定结构体中的内容。 socket API
可以都用struct sockaddr*
类型表示,在使用的时候需要强制转化成sockaddr_in
;这样的好处是程序的通用性,可以接收IPv4、IPv6,以及UNIX Domain Socket各种类型的sockaddr
结构体指针做为参数。
2、简单的UDP网络程序
Ⅰ、服务器的创建
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 <iostream> #include <cstring> #include <cerrno> #include <sys/socket.h> #include <sys/types.h> #include <netinet/in.h> #include <arpa/inet.h> #include <unistd.h> 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 <iostream> #include <memory> 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
结构体的长度。
返回值说明:
- 绑定成功返回0,绑定失败返回-1,同时错误码会被设置。
将点分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 <iostream> #include <string> #include <cstring> #include <cerrno> #include <sys/socket.h> #include <sys/types.h> #include <netinet/in.h> #include <arpa/inet.h> #include <unistd.h> 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 <iostream> #include <memory> 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,不存在大小端的问题,因此在设置时可以不进行网络字节序的转换。