目录
前言
1.Socket编程准备
1.理解源IP地址和目的IP地址
2.认识端口号
3.socket源来
4.传输层的典型代表
5.网络字节序
6.socket编程接口
2.Socket编程UDP
1.服务端创建套接字
2.服务端绑定
3.运行服务器
4.客户端访问服务器
5.测试
6.补充参考内容
总结
前言
这是我们网络部分socket套接字上篇的内容,本篇篇幅还是比较长的,大概在14000字左右,可以预见到内容还是比较细的并且也有包含代码的编写,这一篇是非常重要的,因为上难度了并且知识点也非常重要qwq
1.Socket编程准备
在正式讲解socket套接字之前,我们不妨先来看看一些预备知识来酝酿一下:
但是系统中, 同时会存在非常多的进程, 当数据到达目标主机之后, 怎么转发给目标进程? 这就要在网络的背景下, 在系统中, 标识主机的唯一性
1.理解源IP地址和目的IP地址
因特网上的每台计算机都有一个唯一的IP地址,如果一台主机上的数据要传输到另一台主机,那么对端主机的IP地址就应该作为该数据传输时的目的IP地址。但仅仅知道目的IP地址是不够的,当对端主机收到该数据后,对端主机还需要对该主机做出响应,因此对端主机也需要发送数据给该主机,此时对端主机就必须知道该主机的IP地址。因此一个传输的数据当中应该涵盖其源IP地址和目的IP地址,目的IP地址表明该数据传输的目的地,源IP地址作为对端主机响应时的目的IP地址。
在数据进行传输之前,会先自顶向下贯穿网络协议栈完成数据的封装,其中在网络层封装的IP报头当中就涵盖了源IP地址和目的IP地址。而除了源IP地址和目的IP地址之外,还有源MAC地址和目的MAC地址的概念
2.认识端口号
端口号(port)是传输层协议的内容 ,可以用来标识系统中唯一的一个网络进程
端口号是一个 2 字节 16 位的整数
端口号用来标识一个进程,告诉操作系统,当前的这个数据要交给哪一个进程来处理
IP 地址 + 端口号能够标识网络上的某一台主机的某一个进程
一个端口号只能被一个进程占用,但一个进程可以绑定多个端口号
端口号范围划分
-
0 - 1023: 知名端口号, HTTP, FTP, SSH 等这些广为使用的应用层协议,他们的端口号都是固定的
-
1024 - 65535:操作系统动态分配的端口号. 客户端程序的端口号,就是由操作系统从这个范围分配的
我们在系统部分就知道进程有pid来标识该进程,那么这里的端口号和pid有啥不一样的地方呢?
-
不是所有的进程都需要进程网络通信,也就是说所有的进程都有pid,但是不一定会有端口号
-
从技术角度pid可以胜任这里的端口号的作用,但是pid是一个系统概念,如果使用pid,那么pid变化了网络也得跟着变,解耦性低;换句话说设计端口号就是为了和系统解耦
3.socket源来
现在我们知道了:
-
IP地址可以用来标识全网内唯一的一个主机
-
port(端口号)可以用来标识该主机内唯一的一个网络进程
所以:IP + port 可以表示全网内唯一的一个进程
{源IP,源port ——》 目的IP,目的port}(所以不必说就知道源port和目的port是啥啦)
也就是:一个进程——》另一个进程(全网内唯二的两个进程间在通信——网络通信的本质)
我们用对方的IP和port标识对方的唯一性
而我们的IP + port就是接下来要学习的socket(套接字)
socket通信本质也就是进程间通信
直抒胸臆,我们就可以形象的把套接字理解为一个插座(在通信之前先拿IP+port进行链接,相当于插座和插头链接就可以输送电力了)
4.传输层的典型代表
如果我们了解了系统,也了解了网络协议栈, 我们就会清楚, 传输层是属于内核的, 那么我们要通过网络协议栈进行通信, 必定调用的是传输层提供的系统调用, 来进行的网络通信
传输层最典型的两种协议就是 TCP协议 和 UDP协议
-
TCP协议是面向连接的,如果两台主机之间想要进行数据传输,那么必须要先建立连接,当连接建立成功后才能进行数据传输。其次,TCP协议是保证可靠的协议,数据在传输过程中如果出现了丢包、乱序等情况,TCP协议都有对应的解决方法
-
使用UDP协议进行通信时无需建立连接,如果两台主机之间想要进行数据传输,那么直接将数据发送给对端主机就行了,但这也就意味着UDP协议是不可靠的,数据在传输过程中如果出现了丢包、乱序等情况,UDP协议本身是不知道的
TCP协议是一种可靠的传输协议,使用TCP协议能够在一定程度上保证数据传输时的可靠性,而UDP协议是一种不可靠的传输协议,UDP协议的存在有什么意义?
-
首先,可靠是需要我们做更多的工作的,TCP协议虽然是一种可靠的传输协议,但这一定意味着TCP协议在底层需要做更多的工作,因此TCP协议底层的实现是比较复杂的,占有的资源也比较多,我们不能只看到TCP协议面向连接可靠这一个特点,我们也要能看到TCP协议对应的缺点。
(银行转账、获取网页...)
-
同样的,UDP协议虽然是一种不可靠的传输协议,但这一定意味着UDP协议在底层不需要做过多的工作,因此UDP协议底层的实现一定比TCP协议要简单,也不需要占有过多资源,UDP协议虽然不可靠,但是它能够快速的将数据发送给对方,虽然在数据在传输的过程中可能会出错。
(网课端、直播...)
(注意:可靠和不可靠是它们的特性,而不是缺点)
编写网络通信代码时具体采用TCP协议还是UDP协议,完全取决于上层的应用场景。如果应用场景严格要求数据在传输过程中的可靠性,此时我们就必须采用TCP协议,如果应用场景允许数据在传输出现少量丢包,那么我们肯定优先选择UDP协议,因为UDP协议足够简单。
(注意: 一些优秀的网站在设计网络通信算法时,会同时采用TCP协议和UDP协议,当网络流畅时就使用UDP协议进行数据传输,而当网速不好时就使用TCP协议进行数据传输,此时就可以动态的调整后台数据通信的算法)
5.网络字节序
这里先简单回顾一下大小端存储
-
数据拥有高权值位和低权值位,比如在 32 位操作系统中,十六进制数 0x11223344,其中的 11 称为 最高权值位,44 称为 最低权值位
-
内存有高地址和低地址之分
如果将数据的高权值存放在内存的低地址处,低权值存放在高地址处,此时就称为 大端字节序,反之则称为 小端字节序,这两种字节序没有好坏之分,只是系统设计者的使用习惯问题
内存中的多字节数据相对于内存地址有大端和小端之分, 磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分, 网络数据流同样有大端小端之分. 那么如何定义网络数据流的地址呢?
在网络出现之前,使用大端或小端存储都没有问题,网络出现之后,就需要考虑使用同一种存储方案了,因为网络通信时,两台主机存储方案可能不同,会出现无法解读对方数据的问题
TCP/IP 协议规定:网络中传输的数据,统一采用大端存储方案,也就是网络字节序, 之前的大端/小端存储称为 主机字节序,发送数据时,将 主机字节序 转化为 网络字节序,接收到数据后,再转回 主机字节序 就好了,完美解决不同机器中的大小端差异
为何需要这样来规定?
答:前面我们知道网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址
如这幅图,如果不是大端存储的方式,那么在低地址处存的是cd,以此类推,那么从低地址向高地址依次发出形成的地址是反过来的,大端存储之后从低地址到高地址就是0x1234abcd——符合我们的阅读习惯,更直观
为使网络程序具有可移植性,使同样的 C 代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换
#include <arpa/inet.h>
// 主机字节序转网络字节序
uint32_t htonl(uint32_t hostlong); // l 表示32位长整数
uint16_t htons(uint16_t hostshort); // s 表示16位短整数
// 网络字节序转主机字节序
uint32_t ntohl(uint32_t netlong); // l 表示32位长整数
uint16_t ntohs(uint16_t netshort); // s 表示16位短整数
结论:网络规定所有发送到网络上的数据都必须是大端的!
6.socket编程接口
socket 常见API
socket套接字提供了下面这一批常用接口,用于实现网络通信
#include <sys/types.h>
#include <sys/socket.h> // 创建socket文件描述符(TCP/UDP 服务器 + 客户端)
int socket(int domain, int type, int protocol);
// 绑定端口号(TCP/UDP 服务器)
int bind(int socket, const struct sockaddr* address, socklen_t address_len);
// 开始监听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);
通过上面的接口我们可以看到3/5的接口参数中都有sockaddr这个结构体存在
关于sockaddr结构
1背景:
网络通信的本质就是进程间通信
-
system V——本地进程间通信
-
posix标准——网络通信,也就是进程间通信,亦可进行本地通信
(所以system V的通信方式我们基本不用了)
socket API 是一层抽象的网络编程接口,适用于各种底层网络协议,如 IPv4、 IPv6,以及后面要讲的 UNIX Domain Socket. 然而, 各种网络协议的地址格式并不相同
2内容:
socket 这套网络通信标准隶属于 POSIX 通信标准,该标准的设计初衷就是为了实现可移植性,程序可以直接在使用该标准的不同机器中运行,但有的机器使用的是网络通信,有的则是使用本地通信,socket 套接字为了能同时兼顾这两种通信方式,提供了 sockaddr 结构体
由 sockaddr 结构体衍生出了两个不同的结构体:sockaddr_in 网络套接字、sockaddr_un 域间套接字,前者用于网络通信,后者用于本地通信
-
可以根据 16 位地址类型,判断是网络通信(AF_INET),还是本地通信(AF_UNIX)
-
在进行网络通信时,需要提供 IP 地址、端口号 等网络通信必备项,本地通信只需要提供一个路径名,通过文件读写的方式进行通信(类似于命名管道)
(网络通信就创建sockaddr_in结构,本地通信就创建sockaddr_un 结构,但是要调用socket的API接口就必须要强转成sockaddr结构,在接口内部拿出sockaddr的16位地址类型来区分是网络通信还是本地通信——函数内部自行区分)也就是继承和多态的体现
socket 提供的接口参数为 sockaddr,我们既可以传入 &sockaddr_in 进行网络通信,也可以传入 &sockaddr_un 进行本地通信,传参时将参数进行强制类型转换即可,这是使用 C语言 实现 多态 的典型做法,确保该标准的通用性 > 为什么不将参数设置为 void ? > 因为在该标准设计时,C语言还不支持 void* 这种类型,为了确保向前兼容性,即便后续支持后也不能进行修改了
2.Socket编程UDP
1.服务端创建套接字
这里就要使用我们前面的socket编程接口了
-
socket():创建一个通信的一端(另一端在后面也需要创建一次)
[^] 成功返回新的文件描述符,失败返回-1并设置错误码
参数说明:
-
domain:创建套接字的域或者叫做协议家族,也就是创建套接字的类型。该参数就相当于 struct sockaddr 结构的前16个位。如果是本地通信就设置为AF_UNIX,如果是网络通信就设置为 AF_INET(IPv4)或 AF_INET6(IPv6)。
-
type:创建套接字时所需的服务类型。其中最常见的服务类型是 SOCK_STREAM 和 SOCK_DGRAM ,如果是基于UDP的网络通信,我们采用的就是SOCK_DGRAM,叫做用户数据报服务,如果是基于TCP的网络通信,我们采用的就是 SOCK_STREAM ,叫做 流式套接字 ,提供的是 流式服务 。
-
protocol:创建套接字的协议类别。你可以指明为 TCP 或 UDP ,但该字段一般直接设置为0就可以了,设置为0表示的就是默认,此时会根据传入的前两个参数自动推导出你最终需要使用的是哪种协议
2.服务端绑定
-
bind() 绑定端口号
参数说明:
-
sockfd:绑定的文件的文件描述符。也就是我们创建套接字时获取到的文件描述符。
-
addr:网络相关的属性信息,包括协议家族、IP地址、端口号等。
-
addrlen:传入的addr结构体的长度。
返回值说明:
-
绑定成功返回0,绑定失败返回-1,同时错误码会被设置
这个时候我们第二个参数需要先创建网络/本地的addr结构,需要两个头文件,然后这个sockaddr——in/sockaddr_un结构类型才会存在
#include<netinet/in.h>
#include<arpa/inet.h>
一般我们做网络套接字设计的时候需要这四个头文件
可以在in.h中看到struct sockaddr_in结构的定义,需要注意的是,struct sockaddr_in 属于系统级的概念,不同的平台接口设计可能会有点差别
关于其中的成员
sin_port:表示端口号,是一个16位的整数。——要绑定的端口号
sin_addr:表示IP地址,是一个32位的整数。 ——要绑定的ip地址
其中sin_addr的类型是struct in_addr,实际该结构体当中就只有一个成员,该成员就是一个32位的整数,IP地址实际就是存储在这个整数当中的
关于我们的协议家族在哪里呢,欸,我们转到第一段代码 __ SOCKADDR_COMMON的定义就会看到(__SOCKADDR_COMMON是一个宏)
#define __SOCKADDR_COMMON(sa_prefix) \sa_family_t sa_prefix##family
其中的sa_prefix是前缀,##的作用就是把##左右两边的符号合并成为一个符号,我们在上一层传进来的是sin_,所以在这里就合并成了sin _family;对应的我们的sa_family_t是一种数据类型
(上述字段一般得进行初始化,剩下为填充字段一般不做处理,保证结构体的大小是固定的)
我们可以使用bzero函数来进行清空该结构体中缓存再初始化各字段
[^] 参数1为指定的内存空间,参数2为指定的空间大小
由于该结构体当中还有部分选填字段,因此我们最好在填充之前对该结构体变量里面的内容进行清空,然后再将协议家族、端口号、IP地址等信息填充到该结构体变量当中
(注意:在发送到网络之前需要将端口号设置为网络序列,由于端口号是16位的,因此我们需要使用前面说到的 htons 函数将端口号转为网络序列。此外,由于网络当中传输的是整数IP,我们需要调用inet_addr函数将字符串IP转换成整数IP,然后再将转换后的整数IP进行设置)
[^] 这个函数也有一个地方得注意,那就是传进来的字符串得是c语言风格的,所以我们的string类型的字符串得调用c_str()函数
上述内容写出的暂时初始化代码
// 初始化服务器void Init(){// 1. 创建套接字_socket = socket(AF_INET, SOCK_DGRAM, 0);if (_socket < 0){LOG(LogLevel::FATAL) << "socket error!";exit(1);}
// 创建socket成功LOG(LogLevel::INFO) << "socket success,sockfd: " << _socket;
// 2. 绑定socket信息:ip和端口// 2.1 填充sockaddr_in结构体struct sockaddr_in local;// 清空local中的缓存bzero(&local, sizeof(local));local.sin_family = AF_INET;// 在进行通信时我需要把我的ip地址和端口号发送给对方// 所以ip信息和端口信息一定要发送到网络// 那么我们的ip地址和端口号目前是本地存储格式// 我们发送到网络前首先要进行转换成网络序列// 本地格式 -> 网络序列local.sin_port = htons(_port);// ip亦如此,不过我们保存的ip现在是字符串风格// 所以得将ip转成4字节,再转成网络序列 ——> inet_addr函数一步到位local.sin_addr.s_addr = inet_addr(_ip.c_str());// 2.2 绑定本地的套接字——bind函数int n = bind(_socket, (struct sockaddr *)&local, sizeof(local));if (n < 0){LOG(LogLevel::FATAL) << "bind error";exit(2);}LOG(LogLevel::INFO) << "bind success,sockfd: " << _socket;}
我们再来说说为啥上面要把字符串IP先转换成整数IP:
网络传输数据时是寸土寸金的,如果我们在网络传输时直接以基于字符串的点分十进制IP的形式进行IP地址的传送,那么此时一个IP地址至少就需要15个字节,但实际并不需要耗费这么多字节。
IP地址实际可以划分为四个区域,其中每一个区域的取值都是0~255,而这个范围的数字只需要用8个比特位就能表示,因此我们实际只需要32个比特位就能够表示一个IP地址。其中这个32位的整数的每一个字节对应的就是IP地址中的某个区域,我们将IP地址的这种表示方法称之为整数IP,此时表示一个IP地址只需要4个字节
如果我们自己去将ip转换成4字节
这个转成字符串实际上也不需要我们手动去完成,已经有现成的接口啦
:也就是和inet_addr同头文件的inet_ntoa()函数——将整数IP转换成字符串IP
char *inet_ntoa(struct in_addr in);
需要注意的是,传入inet_ntoa函数的参数类型是in_addr,因此我们在传参时不需要选中in_addr结构当中的32位的成员传入,直接传入in_addr结构体即可
3.运行服务器
服务器实际上就是在周而复始的为我们提供某种服务,服务器之所以称为服务器,是因为服务器运行起来后就永远不会退出,因此服务器实际执行的是一个死循环代码。由于UDP服务器是不面向连接的,因此只要UDP服务器启动后,就可以直接读取客户端发来的数据。
-
UDP服务器读取数据(收消息)的函数叫做recvfrom
参数说明:
-
sockfd:对应操作的文件描述符。表示从该文件描述符索引的文件当中读取数据
-
buf:读取数据的存放位置(缓冲区)
-
len:期望读取数据的字节数(缓冲区的长度)
-
flags:表示读取的方式;一般设置为0,表示阻塞读取
(阻塞式IO:如果对方不发数据,该函数(进程)就会一直阻塞,等同于scanf)
-
src_addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等
-
addrlen:调用时传入期望读取的src_addr结构体的长度,返回时代表实际读取到的src_addr结构体的长度,这是一个输入输出型参数
返回值说明:
-
读取成功返回实际读取到的字节数,读取失败返回-1,同时错误码会被设置
注意:
-
ssize_t实际是long int类型(有符号整数),socklen_t为无符号整数
-
由于UDP是不面向连接的,因此我们除了获取到数据以外还需要获取到对端网络相关的属性信息,包括IP地址和端口号等。
-
在调用 recvfrom 读取数据时,必须将 addrlen 设置为你要读取的结构体对应的大小。
-
由于 recvfrom 函数提供的参数也是 struct sockaddr* 类型的,因此我们在传入结构体地址时需要将 struct sockaddr_in* 类型进行强转
-
如果调用recvfrom函数读取数据失败,我们可以打印一条提示信息,但是不要让服务器退出,服务器不能因为读取某一个客户端的数据失败就退出
现在服务端通过recvfrom函数读取客户端数据,我们可以先将读取到的数据当作字符串看待,将读取到的数据的最后一个位置设置为’\0’,此时我们就可以将读取到的数据进行输出,同时我们也可以将获取到的客户端的IP地址和端口号也一并进行输出。
这里要注意:我们获取到的客户端的端口号此时是网络序列,我们需要调用 ntohs 函数将其转为主机序列再进行打印输出。同时,我们获取到的客户端的IP地址是整数IP,我们需要通过调用 inet_ntoa 函数将其转为字符串IP再进行打印输出
-
UDP服务器输出数据(发消息)的函数叫做sendto
参数和上面的读函数差不多,不过多赘述,后两个参数就代表要发给谁(且都是输入型的参数)
返回值也和recvform一样的
从我们的udp有这两读写函数就可以明白:udp sockfd既可以读,又可以写;udp通信是全双工的
鉴于构造服务器时需要传入IP地址和端口号,我们这里可以引入命令行参数。此时当我们运行服务器时在后面跟上对应的IP地址和端口号即可
我们手动将IP地址设置为127.0.0.1。IP地址为127.0.0.1实际上等价于localhost表示本地主机,我们将它称之为本地环回,相当于我们一会先在本地测试一下能否正常通信,然后再进行网络通信的测试
// ./udpserver ip port
int main(int argc, char *argv[])
{// 判断命令行参数是否传了上面的三个if (argc != 3){std::cerr << "Usage: " << argv[0] << "ip port" << std::endl;return 1;}// 到这就说明已经传了ip和port了std::string ip = argv[1];uint16_t port = std::stoi(argv[2]);
Enable_Console_Log_Strategy();std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(ip, port); // c++14usvr->Init();usvr->Start();
return 0;
}
注意:agrv数组里面存储的是字符串,而端口号是一个整数,因此需要使用stoi函数将字符串转换成整数。然后我们就可以用这个IP地址和端口号来构造服务器了,服务器构造完成并初始化后就可以调用Start函数启动服务器了
此时带上端口号运行程序就可以看到套接字创建成功、绑定成功,现在服务器就在等待客户端向它发送数据
4.客户端访问服务器
关于客户端的绑定问题
首先,由于是网络通信,通信双方都需要找到对方,因此服务端和客户端都需要有各自的IP地址和端口号,只不过服务端需要进行端口号的绑定,而客户端不需要。
因为服务器就是为了给别人提供服务的,会有很多个客户端来访问,因此服务器必须要让别人知道自己的IP地址和端口号,IP地址一般对应的就是域名,而端口号一般没有显示指明过,因此服务端的端口号一定要是一个众所周知的端口号,并且选定后不能轻易改变,否则客户端是无法知道该服务端的端口号的,这就是服务端要进行绑定的原因,只有绑定之后这个端口号才真正属于自己,因为一个端口只能被一个进程所绑定,服务器绑定一个端口就是为了独占这个端口。
而客户端在通信时虽然也需要端口号,但客户端一般是不进行显式调用bind进行绑定的,客户端访问服务端的时候,端口号只要是唯一的就行了,不需要和特定客户端进程强相关。
如果客户端绑定了某个端口号,那么以后这个端口号就只能给这一个客户端使用,就是这个客户端没有启动,这个端口号也无法分配给别人,并且如果这个端口号被别人使用了,那么这个客户端就无法启动了(比如说可能京东的app绑定了某个端口号,淘宝的app也绑定了这个端口号,那么一个软件启动之后就无法启动其他软件了)
所以客户端的端口只要保证唯一性就行了(为了避免client端口号冲突),客户端的端口号是多少不重要,因此客户端端口可以动态的进行设置,并且客户端的端口号不需要我们来设置,当我们调用类似于sendto这样的接口首次发送消息时,操作系统会自动给当前客户端bind绑定(我们不需要显式调用bind),ip操作系统是知道的,端口号采用随机端口号
也就是说,客户端每次启动时使用的端口号可能是变化的,此时只要我们的端口号没有被耗尽,客户端就永远可以启动
5.测试
前面我们已经有了服务端的测试代码了
那么客户端测试代码:
#include <iostream>
#include <string.h>
#include <string>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/types.h>
#include <sys/socket.h>
// 客户端在命令行启动时
// ./udpclient server_ip server_port
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];uint16_t server_port = std::stoi(argv[2]);
// 1. 创建socket套接字int sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (sockfd < 0){std::cerr << "socket error" << std::endl;return 2;}
// 2.本地的ip和端口号要不要和上面的“文件”关联呢?// 问题:client要不要bind?需要bind绑定// client要不要显式的bind?不要!!首次发送消息,os会自动给client进行bind// os知道ip,端口号采用随机端口号的方式// 为什么:一个端口号只能被一个进程bind,避免client端口号冲突
// 填写服务器信息struct sockaddr_in server;// 初始化清空servermemset(&server, 0, sizeof(server));server.sin_family = AF_INET;server.sin_port = htons(server_port);server.sin_addr.s_addr = inet_addr(server_ip.c_str());
// 3.客户端开始发送消息while (true){std::string input;std::cout << "Please Enter: ";std::getline(std::cin, input);int n = sendto(sockfd, input.c_str(), input.size(), 0, (struct sockaddr *)&server, sizeof(server));(void)n;
// 读取服务端消息char buffer[1024];// 一个客户端可能访问多个服务器// 这里直接创建struct sockaddr_in就行(当作占位符)struct sockaddr_in peer;socklen_t len = sizeof(peer);int m = recvfrom(sockfd, buffer, sizeof(buffer), 0, (struct sockaddr *)&peer, &len);if (m > 0){// 读取成功// 先在字符串结尾添加\0buffer[m] = 0;std::cout << buffer << std::endl;}}return 0;
}
客户端运行之后提示我们进行输入,当我们在客户端输入数据后,客户端将数据发送给服务端,此时服务端再将收到的数据打印输出,这时我们在服务端的窗口也看到我们输入的内容
我们可以通过netstat命令来查看当前网络的状态,这里我们可以选择 携带nlup 选项。
netstat常用选项说明:
-
-a:表示显示所有
-
-n:直接使用IP地址,而不通过域名服务器(能写成数字的全部数字化)
-
-l:显示监控中的服务器的Socket。
-
-t:显示TCP传输协议的连线状况。
-
-u:显示UDP传输协议的连线状况(只查看udp协议)
-
-p:显示正在使用Socket的程序识别码和程序名称(显示进程相关的信息)
此时你就能查看到对应网络相关的信息,在这些信息中程序名称为 ./udpserver 的那一行显示的就是我们运行的 UDP服务器 的网络信息
其中 netstat 命令显示的信息中,Proto表示协议的类型,Recv-Q表示网络接收队列,Send-Q表示网络发送队列,Local Address表示本地地址,Foreign Address表示外部地址,State表示当前的状态,PID表示该进程的进程ID,Program name表示该进程的程序名称。
其中Foreign Address写成0.0.0.0:*表示任意IP地址、任意的端口号的程序都可以访问当前进程
我们可以测试一下绑定公网ip、内网ip,就会有以下几种现象
-
bind公网ip ——绑定失败
原因:公网ip其实没有配置到你的ip上,所以公网ip无法被直接bind
-
bind 127.0.0.1 或者 内网ip ——成功绑定
-
server端bind内网ip,但是客户端用127.0.0.1访问 ——访问不了(反之也是不行 )
原因:如果我们显式的进行地址bind,client未来访问的时候就必须使用server端bind的地址信息
从上面的三种现象我们不禁要问:到底要如何实现跨网络(外网)访问呢?
答:如果需要让外网访问,不建议手动bind特定的ip,而是此时我们需要bind 0。系统当当中提供的一个INADDR_ANY,这是一个宏值,它对应的值就是0;因此如果我们需要让外网访问,那么在云服务器上进行绑定时就应该绑定INADDR_ANY,此时我们的服务器才能够被外网访问
关于绑定INADDR_ANY的好处
当一个服务器的带宽足够大时,一台机器接收数据的能力就约束了这台机器的IO效率,因此一台服务器底层可能装有多张网卡,此时这台服务器就可能会有多个IP地址,但一台服务器上端口号为8080的服务只有一个。这台服务器在接收数据时,这里的多张网卡在底层实际都收到了数据,如果这些数据也都想访问端口号为8080的服务。此时如果服务端在绑定的时候是指明绑定的某一个IP地址,那么此时服务端在接收数据的时候就只能从绑定IP对应的网卡接收数据。而如果服务端绑定的是INADDR_ANY,那么只要是发送给端口号为8080的服务的数据,系统都会可以将数据自底向上交给该服务端
那么我们在服务端的初始化代码中socket的ip信息就应该修改为
这样在下面bind时绑定的ip就是我们的INADDR_ANY了(做任意地址绑定)
当然,如果你既想让外网访问你的服务器,但你又指向绑定某一个IP地址,那么就不能用云服务器,此时可以选择使用虚拟机或者你自定义安装的Linux操作系统,那个IP地址就是支持你绑定的,而云服务器是不支持的
此时当我们再用netstat命令查看时会发现,该服务器的本地IP地址变成了0.0.0.0,这就意味着该UDP服务器可以在本地读取任何一张网卡里面的数据(可以收到任意一个ip对应的报文了,无论是公网ip、内网IP或者本地环回)
网络测试:
我们可以将生成的客户端的可执行程序发送给你的其他朋友,进行网络级别的测试。为了保证程序在你们的机器是严格一致的,可以选择在编译客户端时 携带 -static 选项进行静态编译。
此时我们可以先使用 sz命令 将该客户端可执行程序下载到本地机器,然后将该程序发送给你的朋友,这就跟我们自己在PC端上下载文件是一样的道理;接着你先把你的服务器启动起来,然后你的朋友将你的IP地址和端口号作为命令行参数运行客户端,就可以访问你的服务器了
我们可以再进一步修改我们的代码来实现一个简单的聊天室
需要借助我们之前学过写过的线程池(网络提供数据任务,让线程池来分发给各个用户的客户端)
这个转发消息的服务器其实就是一个生产者消费者模型
我们的线程将来要执行的是消息转发的任务,所以我们在服务器和线程池之间还应该存在一个消息路由(route)的模块
补充知识:
我们可以通过 ls /dev/pts/命令来查看我们所开启的终端数与序号
(原因是我们所开的终端都会保存在这个路径下)
比如说我目前所处的终端就是序列0
这个知识点可以帮助我们解决用两个线程分别负责发消息和收消息任务时导致的收发消息输出时的杂糅到一起的问题:
在用重定向之前
在用这个知识点重定向了标准错误到这个终端之后就解决了这一问题
关于我们简单聊天室的代码在: 单进程的简易聊天室
6.补充参考内容
-
地址转换函数
本节只介绍基于 IPv4 的 socket 网络编程,sockaddr_in 中的成员 struct in_addr sin_addr 表示 32 位 的 IP 地址,但是我们通常用点分十进制的字符串表示 IP 地址,以下函数可以在字符串表示 和in_addr 表示之间转换
字符串转 in_addr 的函数:
这里我们推荐使用inet_pton函数,比较安全
in_addr 转字符串的函数:
其中 inet_pton 和 inet_ntop 不仅可以转换 IPv4 的 in_addr,还可以转换 IPv6 的in6_addr,因此函数接口是 void *addrptr
代码样例:
-
关于inet_ntoa函数
inet_ntoa 这个函数返回了一个 char*, 很显然是这个函数自己在内部为我们申请了一块内存来保存 ip 的结果. 那么是否需要调用者手动释放呢 ?
man 手册上说, inet_ntoa 函数, 是把这个返回结果放到了静态存储区. 这个时候不需要我们手动进行释放;那么问题来了, 如果我们调用多次这个函数, 会有什么样的效果呢? 参见如下代码:
运行结果如下:
因为 inet_ntoa 把结果放到自己内部的一个静态存储区, 这样第二次调用时的结果会覆盖掉上一次的结果
思考: 如果有多个线程调用 inet_ntoa, 是否会出现异常情况呢?
在 APUE这个教程中, 明确提出 inet_ntoa 不是线程安全的函数,所以我们想说的是在多线程环境下, 推荐使用 inet_ntop函数,这个函数由调用者提供一个缓冲区保存结果, 可以规避线程安全问题
[^] 其实在上面就讲过
总结
ok,在这一篇中我们讲述了socket套接字编程的一些前置知识点储备(这个在整个章节都是需要用上的),同时也讲述了socket编程UDP版本的接口以及代码实现了基于UDP实现的简易网络聊天室;那么本篇内容到这就结束啦,不过我们的socket套接字章节的内容还在继续,我们可以先把上面的这么多内容好好消化一下再接着继续!!