学习了网络的概念了,接下来我们开始实践,本次我们会通过UDP来模拟实现UDP客户端和UDP服务器之间的通信,以及在此基础上扩展几个应用。
下面,我们将使用socket,bind,htons等接口实现UDP网络通信。
v1 版本 - echo server(回显服务器)
服务端
Init 服务器的初始化
这里我们先定义几个全局默认值,并且了解下有没有static修饰的区别
socket函数
- 第一个参数用于确认使用说明协议家族,AF_INET 表示IPv4协议,AF_INET6 表示IPv6协议;
- 第二个参数 指定socket类型, SOCK_STREAM 表示TCP Socket,SOCK_DGRAM表示UDP Socket;
- 第三个参数 指定具体的协议,通常为0,表示让系统根据domain和type自动选择合适的协议。
返回值是一个fd文件描述符,所以,socket函数本质上就是创建了一个文件描述符
UdpServer.hpp
这里引用我们之前写的日志功能文档,
这里解释下为什么不直接在构造函数中创建套接字,而是另外写了一个接口来创建套接字,有下面几点考虑:
- 构造函数异常处理局限性: 如果构造函数在执行过程中抛出异常,对象就无法完整构造,对象未完全构造成功,析构函数就不会被调用,可能没法正确释放已分配的部分资源,也就可能会造成资源泄漏。
- 独立出init接口则就规避了这个问题,如果Socket申请失败,可以返回错误码,我们可以通过错误码进行相应的处理,同时也能保证对象已经正确构造,析构函数可以正常释放资源。
- 构造函数的初衷只是创建对象并进行必要的成员变量初始化,将 Socket 申请逻辑放入构造函数会让构造函数变得复杂,违背了单一职责原则。独立的
init
接口能让构造函数保持简洁,仅完成对象的基本创建。 - 复用初始化逻辑:将 Socket 申请逻辑放入构造函数会让构造函数变得复杂,违背了单一职责原则。独立的
init
接口能让构造函数保持简洁,仅完成对象的基本创建。
基于以上几点,我们选择将创建套接字独立成一个init接口。
bind函数
服务器程序所监听的网络地址和端口号是固定不变的,客户端程序得知服务器程序的ip地址和端口号就可以向服务器发起连接,所以服务器需要调用bind绑定一个固定的网络地址和端口号来表示自己。
- 第一个参数是刚刚socket函数所创建的套接字描述符也就是它的返回值;
- 第二个参数是一个指向 struct sockaddr类型的指针,它包含了要绑定的网络地址和端口信息,不过在实际编程汇总,我们通常会使用 struct sockadd_in(用于IPv4)或者struct sockaddr_in6(用于IPv6)来填充地址,之后再强转为 struct sockaddr类型;
- 第三个参数 表示的是 addr指向地址的结构体的长度。
主机中有那么多进程需要通信,每一个进程都会有一个sockfd,端口什么的一信息,那么OS是如何进行管理的呢?
答案就是先描述在组织,只不过因为是我们进行网络编程,信息是什么只有我们知道,所以这次描述的这个结构体信息,是由我们来进行填写的,那么怎么将我们填写的信息和真正的socketfd绑定到一起呢?
用的就是bind函数,来完成绑定的。绑定之后,OS就知道了这个socketfd和哪个结构体是关联的了。
所以bind函数的作用就是将参数soketfd和myaddr绑定在一起,使sockfd这个用于网络通讯的文件描述符监听 myaddr 所描述的地址和端口号。
需要注意的是,第二个参数:我们是要进行IPv4网络通信的,需要使用的是 struct sockaddr_in 这个结构体,而不是使用struct sockaddr。 struct sockaddr 更像是C++ 中的基类,是专门为设计接口用的。
struct sockaddr* 是一个通用指针类型,myaddr参数实际上可以接受多种协议的sockaddr结构体,而它们的长度各不相同,所以需要第三个参数addrlen指定结构体的长度。
bzero函数
bzero 函数的主要作用是将一块内存区域的所有字节都设置为零。在网络通信中,常常用于需要对存储网络地址、套接字地址等信息的结构体进行初始化,确保这些结构体的数据是已知状态,避免了未初始化导致的错误。
参数: s一个void类型指针,指向要清零的内存区域的起始地址。 n表示要清零的字节数。
功能上也可以使用memset来代替。
在正式填写结构体前,我们需要使用bzero来将结构体内容初始化为0。
sockaddr_in 结构体,我们只需要填写前三个字段,sin_zero 称之为这个结构体的填充字段。什么是填充字段? 就是为了使sockaddr_in结构体的长度和通用的strcut sockaddr结构体长度相同,在使用的时候,一般将其全部置为0,起到占位符的效果。
需要注意的是: sockaddr_in 结构体位于 netinet/in.h 头文件中,sockaddr 结构体位于 sys/socket.h 头文件中。
我们可以通过跳转的方式,查看这个结构体和要填写的三个字段分别是什么
sin_port 本质上是一个16位短整型整数,代表进程端口号。
通过上面的sockaddr_in 结构体,我们发现里面好像找不到sin_family字段啊。
其实这个字段就是sin_family 字段
我们跳转到它的定义看看
这里实际上是用来一个宏定义函数,将传入的参数作为了变量的名称,##的用法还记得吗?
##的用法
在C++中,## 是预处理运算符,被称为 连接符 或者 记号连接运算符 作用就是在预处理阶段把左右两个记号连接成一个新的记号。
用法 :
可以用于 动态生成标识符,也可以用于实现函数重载辅助宏
例如:
#include <iostream>
// 定义函数重载辅助宏
#define FUNCTION_OVERLOAD(type) void func_##type(type value) { std::cout << "Function for " #type ": " << value << std::endl; }
// 生成不同类型的函数
FUNCTION_OVERLOAD(int)
FUNCTION_OVERLOAD(double)
int main() {
func_int(10);
func_double(3.14);
return 0;
}
很显然,在这里## 是第一种用法,将左右两种记号生成了一个新的记号。这个字段的类型是sa_family_t ,本质上是一种无符号短整型类型。
sin_addr本质上也是一种结构体, 结构体 in_addr 里面是一个无符号整型。这个字段可以理解为socket inet,表示IP地址。
我们存储IP的时候,使用的是字符串类型。为什么使用字符串呢? 因为用户使用的是点分十进制字符串格式的IP地址。
结构体存储IP地址使用整型格式,我们存储使用字符串,这就涉及到整型和字符串之间的转换。那怎么转换? 思路如下图伪代码:
点分十进制转换成整数,是非常常用的功能。C程序设计者早就考虑到了,已经在库中帮我们实现了,不需要我们自己实现。
inet_addr函数
我们将点分十进制格式的IP转成整数后,赋值给sin_addr,发现报错了。这是为啥? 这是因为sin_addr 是一个in_addr_t的结构体,我们可以通过inet_addr函数将我们的ip点分十进制字符串格式转化为in_addr_t 的结构体,同时补充我们的成员变量,以及改写下构造函数。
设置好 sockaddr_in 结构体之后,就把socket信息,设置进入内核中了??
并没有,只是填充了结构体!
接下来,我们建立bind绑定,才是设置进内核中。
绑定之后,我们服务器就初始化结束了,接下来就该处理服务器运行时该处理的行为了—— 收发信息
Run 服务器的运行
虽然UDP使用的是socketfd,本质上是一个文件,但UDP是无法直接使用read和write函数进行通信的,因为read和write是直接面向字节流套接字的,而UDP是面向数据包的。
recvfrom 函数
这里我们使用的函数是recvfrom
recvfrom函数从制定套接字读到一个报文,读报文需要传一个缓冲区和缓冲区的长度,flag设置为0即可。要收到一条信息,你要不要知道是谁发的,这就是最后两个参数的作用。
如何知道对方是谁呢? 不要忘了 sockaddr_in 里面可是有端口号和IP的,通过端口号+IP就可以表示一个进程。
当我们把数据处理完后,怎么发回去给对方呢?
sendto 函数
这里我们使用的是sendto函数
参数部分,都很熟悉,就不做过多介绍了。
Stop 服务器的退出
接下来,虽然服务器一般情况下,是不会停止的,但是在部分情况下,服务器也是需要停下来,维护的,所以这里我们可以写一个stop接口,来表示服务器的结束。
当服务器结束退出的时候,不要忘记关闭socketfd,socketfd本质上就是一个文件描述符,所以我们可以使用关闭文件描述符的方式关闭它。
测试服务器
写一个简单的Main.cc 测试下服务器,这里端口号随便设置一个在1024 - 49151范围内的就行,IP就用我们的云服务器的IP。
Main.cc
编译运行,我们发现报错了。
错误码99, 错误码 99
对应的是 EADDRNOTAVAIL
,表示指定的地址不可用。
我们填写的事我们的云服务器的IP啊,为什么指定的地址不可用呢?
这是因为云服务器是禁止直接bind公网IP的。一台主机可能有两张网卡,如果我们bind一张网卡,就无法接受另一张网卡发来的数据。我们想要bind任意网址,怎么操作?
第一种就是使用 0.0.0.0 IP , 在IPv4 地址体系里,0.0.0.0 表示 接受任意IP发来的数据。
第二种方法,将s_addr的值设置为INADDR_ANY。
INADDR_ANY 是一个在 <netinet/in.h>
头文件中定义的常量,通常被定义为 0.0.0.0,表示本地的任意IP地址,因为服务器有可能有多个网卡,每个网卡都可能绑定多个iP地址,这样设置可以在所有的IP地址上监听,直到与某个客户端建立了连接才确定下来到底用哪个IP地址。
我们这里先尝试下第二种方式,将local中sin_addr中的s_addr设置为 INADDR_ANY
编译运行,发现程序成功运行出来了。
接下来,我们来尝试第一种方式上。
由于我们把IP的缺省值设置为0.0.0.0,我们只用传端口号就可以了。
编译运行,服务器就也运行起来了。
当我们把服务器运行起来后,可以使用 netstat -naup指令
-n 选项 表示能显示成数字的字段,就显示成数字; - u选项,表示协议为UDP的;-a选项,表示所有的;-p选项,表示需要显示PID信息。
Recv-Q和Send-Q表示收发报文的个数。Loacl Address 是本地地址的意思。 Foreign Address 是接受外来地址的意思,0.0.0.0:* 表示接受任何客户端发来的信息。
封装InetAddr&&Common
对于服务器和客户机,填写sockaddr_in结构体都是必须的,那与其写那么多重复的代码,不如我们直接封装一个sockaddr_in ,用于填写sockaddr_in 结构体以及网络地址和主机地址之间的转换。
在此之间,我们先简单封装一个Common.hpp ,这个头文件放置一些,在服务器和客户端编程中都会用到的通用的部分,一般用于放置,错误码,报错信息,宏定义什么的。
Common.hpp
封装过程中,我们会用到下面这个函数 inet_ntop 用法就如NAME处所写的那样,将IPv4和IPv6地址的从二进制转化为文本格式,返回值是存储文本格式dst缓冲区的地址。
其实还有一个函数 inet_ntoa 这个函数也用的比较多。
在上图中我们还看到了我们之前使用的inet_addr函数,其实这两个函数才是一对,它们可以相互转换,只不过和inet_ntop函数不同的是,它们两个只能用于Ipv4,而inet_ntop 则也适用于Ipv6,。
地址转换函数
需要注意的是:
inet_ntoa 返回的值是一个地址,也不是一个常量地址,那我们没有创建任何字符数组,那这个地址是在哪开辟的呢?
其实inet_ntoa 是使用内部静态缓冲区来存储转换后的字符串的, 内部静态缓冲区,在多线程的环境下,这不就是一个临界资源吗!
所以inet_ntoa是一个线程不安全函数,当多个线程访问时,可能会造成数据混乱,并且这个缓冲区是开在内部的,缓冲区大小,我们也无法知道,而 inet_ntop 则不会有这个问题,缓冲区是我们自己开辟的,大小我们自己决定,生命周期也由我们自己决定,所以这也是我们之后选择使用inet_ntop的原因。
InetAddr.hpp
接下来,我们来将我们封装的InetAddr和 Common 加入到我们的Udpsever中
我们也重新写一下Main.cc,改为交互式的,增加灵活性,不要硬编程端口号
客户端
客户端的写法和服务器的写法基本一致,进行网络通信,创建属于自己的socketfd,再创建sockaddr_in ,填写信息,但不同的是这里填写的是服务器的信息,因为我们是要和服务器进行通信,sendto 需要的是目标服务器信息,填写之后就可以发送了。
这里还有一个问题,就是Client需要bind吗? 要! 只不过不需要用户来自己写结构体自己来bind,一般是由OS自由随机选择。一个端口只能被一个进程bind,对server也是如此,对于client,也是如此,其实Client的port是多少,并不重要,只要能保证主机上的唯一性就可以了,那么系统是什么时候给我bind的呢? 首次发送数据的时候!
编译一下,我们就可以正常运行了
这里需要注意的是,我这里是同一个主机进行的通信,所以这里Client的目的ip是127.0.0.1,任何一个主机都可以绑定127.0.0.1这个地址, 127.0.0.1 是本地回环地址,只能用于本地通信,通信的数据不会被发送到网络上,只会送到底层,再发回来。
服务器Run的扩展
为了方便我们后续的应用扩展,我们将让udp服务器进行优化,将run函数封装为一个回调函数,将处理数据的部分单独提取成一个函数,之后我们只需要将服务器执行的部分传入服务器,服务器就可以执行了。
编译之后,就可以正常运行了
Echo Server 服务器应用扩展
如果服务器接受的数据不再是普通的数据,而是指令呢? 之前,我们模拟过一个微型xshell,这次我们就不再用这种方式了。
有一个函数叫popen。
函数popen
函数popen,它会创建一个管道,和一个子进程,并且通过管道连接到子进程,并返回一个文件指针,用于读取或写入子进程的输入输出。
具体执行就是你把你要执行的指令以字符串的形式交给popen,popen的底层会调用fork创建子进程,再创建一个管道帮我们建立父子进程之间的通信,在子进程调用我们输入的指令,并通过管道将运行结果,返回给调用方。
当然,在指令通信中,我们为了防止Client传过来的指令对我们的服务器造成影响,比如删除所有数据等等,这里我们可以做一个检查,设置好不安全指令,分析Client传来的指令是否安全,如果不安全则不执行。
编译运行,客户端属于指令,我们就可以看到在服务器上执行的结果了
这里,我们之所以可以完成这个类似于shell的操作,是因为后台运行了一个服务,端口号为22的TCP服务。
我们可以通过 netstat -ntlp 来查看 tcp的服务
Windows访问Linux服务器
还记得我们之前说的网络模型吗,在不同操作系统中,网络部分的实现功能都是一致的,这也就使得网络的编程是可以跨平台的,只有一小部分系统调用的代码不同,下面,我们就把我们的客户端移植到Windows上。
先加上一些前置准备。
WinSock2.h 是Windows系统下用于支持 Windows Sockets 2.0 规范的头文件,它提供了在 Windows 平台上进行网络编程的接口。
Windows.h 是Windows 编程中最基础且核心的头文件之一,它几乎包含了 Windows API(应用程序编程接口)的所有基础定义和声明,为开发者提供了操作 Windows 操作系统底层功能的接口。
一般网络编程,通常需要同时包含这两个头文件,不过需要注意包含顺序,一般建议先包含WinSock2.h,再包含Windows.h
#pragma comment(lib, "ws2_32.lib")
指定链接ws2_32.lib
库,该库实现了WinSock2.h中声明的函数。
WSADATA 类型是用于存储Windows Sockets API的数据,
WSAStartup (MAKEWORD(2, 2), &wsd);
调用WSAStartup
函数初始化 Windows Sockets API,参数MAKEWORD(2, 2)
指定使用的 Windows Sockets 版本为 2.2。
WSACleanup();
清理 Windows Sockets API 环境,释放相关资源。
配置好这些之后,我们就可以将我们的客户端代码拷贝过来了。
编译运行,就实现了Windows和Linux平台的通信了。
v2 版本 - Dict Server 字典服务器
接下来,我们基于上一个Echo Server 来简单实现一个Dict Server,功能就是实现单词的英译汉。
具体思路就是,我们再额外封装一个dict,它的功能是将我们外置放着的字典加载进内存,并且给我们服务器提供翻译功能
剩下的就和上面的Echo实现的一样了。
具体如下:
Dictionary.hpp
UdpServer.cc
编译运行
下面我们给一个封装版的,这样也方便我们以后使用
封装版本
socket.hpp
万能udp_server.hpp 模版
万能udp_client.hpp 模版
dict_server.cc
dict_client.cc
V3 版本 - 多人chat聊天室
接下来,我们来写我们第三个版本 多线程群聊
多个人进行群聊的时候,我们可以看到别人的信息,并且知道是谁发的信息。
我们这里就使用IP+端口号来表示一个人,使用unordered_map记录有哪些群里有哪些人。每有一个人发消息,就把这条消息发给map中的所有人。
但是,上面的写法是单线程进行运行的,当用户多起来了,那么单进程是完全不够多,所以我们可以将信息封装成可以转发的任务,让其他线程去转发,主线程只负责数据的接收。
同时,我们引入线程池,将我们接受到的信息转给线程池,让线程池去进行转发,这样即便在高负荷的情况下,我们的系统也能轻松拿捏
那么对于每一个用户,我们都要进行增删查改的管理,那么我们要怎么进行管理呢?
很简单,先描述,在组织!
我们设计出来一个User类,然后再次基础上在设计一个User类的管理类UserManager,使用UserManager在进行管理,之后将转发给所有用户的接口交给线程池让其进行执行即可。
接下来我们就来完成这些操作
我们首先先设计一个接口类,给我们的用户定下接口,他的作用等我们全部写完了,就能够感受到了。
接着实现一个User类继承User Interface类,并实现所对应的接口
内部区分唯一性的标识符,我们这里采用我们之间写的InetAddr来标识,原因就是方便,这也是封装好解偶性好的好处。
标识符是通过InetAddr来标识的,所以我们在写一下InetAddr的比较重载运算符
至此对于User的描述就完成了,接下来我们来进行管理User,我们在实现一个Usermanager类来实现管理User
由于对User的管理,实现增删查改,经常需要我们进行查找某个用户,所以UserManager我们底层选择采用unordered_map来实现,当然也可以采用其他容器例如list之类的,
需要注意的是,Adduser和DelUser对共同临界区的访问,需要加锁保护。
上面我们对于User和UserManger的实现,其实是基于一种叫观察者的行为设计模式
观察者模式(Observe Pattern)
观察者模式(Observer Pattern)是一种行为设计模式,它定义了对象之间一对多的依赖关系,当一个对象(主题)的状态发生变化时,所有依赖它的对象(观察者)都会被调用接口从而得到通知并自动更新。
模式结构
- 主题(Subject):也被叫做被观察对象,它维护了一个观察者列表,提供了添加、删除观察者的方法,并且在状态变化时通过接口通知所有观察者(具备一个通知所有观察者的接口)。
- 观察者(Observer):定义了一个更新接口,当主题状态改变时,主题会调用该接口通知观察者。
- 具体主题(Concrete Subject):继承自主题,实现了主题的抽象方法,当自身状态发生变化时,调用通知方法通知所有观察者。
- 具体观察者(Concrete Observer):实现了观察者接口,在接收到主题的通知后,执行相应的更新操作。
优缺点
优点
- 松耦合:主题和观察者之间的耦合度较低,主题只需要知道观察者实现了更新接口,而不需要了解具体的观察者类。
- 可扩展性:可以方便地添加或删除观察者,而不影响主题和其他观察者。
- 支持广播通信:主题可以一次性通知所有观察者,实现广播式的消息传递。
缺点
- 性能问题:如果观察者数量较多,通知所有观察者可能会带来性能开销。
- 循环依赖问题:如果主题和观察者之间存在循环依赖,可能会导致系统不稳定。
应用场景
- 消息通知系统:如邮件订阅、即时通讯等,当有新消息时,通知所有订阅者。
- GUI 组件:当一个组件的状态发生变化时,通知其他相关组件进行更新。
- 股票行情系统:股票价格的变化会通知所有关注该股票的投资者。
在上面我们实现的User.hpp中,Userinterfa就是观察者(抽象的观察者接口),User就是具体的观察者,Usermanager就是具体的主题,这里我们并没有写主题(抽象的主题接口)。
这么写的好处就是,当我们想要扩展的时候,比如我们不仅是想要转发用户消息,还想转发的时候给一个备份文件里面发,这样就可以将整个聊天记录存储到一个文件里面,这时,我们就可以基于抽象的观察者接口,在设计出来一个具体的观察者,然后将其放入具体的主题里的容器里,通知的时候就会自动调用我们在文件中设计的接口,将消息存储到一个文件里。
整体来说就是,易于代码扩展。
我们完成了User用户的描述管理代码的编写,那么我们应该怎么做才能将User和我们的Udp服务器关联起来呢?
使两个类之间相互关联的方法有很多,像类继承,类的嵌套都可以实现。
但像这种模块之间的关联,我们为了让两个模块的耦合性最低,我们采用的是类间调用。
各个服务与业务之间最好尽量降低耦合想,方便后期代码维护,以及代码扩展。
模块之间,可以采用std::bind,lamba表达式以及回调函数这样的策略来实现模块间的耦合。
不使用类对象嵌套是因为类间带哦用更加的灵活,只依赖接口,可以随时切换接口而执行的底层不用变,类嵌套则改变的时候,往往还需要修改类的定义。
我们在Udpsever中定义几个任务类型,运行的时候,直接注册进去相应的任务就可以了。
上面三个我们都能理解,那么这个task_t 是干什么用的?
这是线程池的入队列接口,当我们直接使用_route写入两个参数传入Equeue接口的话,实际传入的事route_t 中的返回值 void,无法转换所以就会报错
所以这里需要提前将route绑定好,然后直接将变量传进去。
task_t是任务类型,在外面我们通过bind将route_t的两个参数绑定好,然后赋值给task_t类型传给线程池。
bind需要注意的是,_route 是一个函数对象,并不是成员函数,所以不需要传this指针
整个Run部分代码
最后,我们只需要给我们的服务器写一个注册接口,将我们所需要的服务,由外部注册进去就可以了
不要忘了,我们引入的线程池是单例的,所以这里我们的服务器也必须要将拷贝以及赋值运算符删除掉。
这里我们采用继承的方式删除,即继承一个删除了拷贝构造和赋值运算符重载的类
还有一点非常关键需要注意的是
我们这UserManager这里使用的容器是unordered_map 并且里面的key值,是我们自己写的自定义类型,而std::unordered_map
需要为键类型提供哈希函数,用于计算键的哈希值,保证key其唯一性。若没有为 InetAddr
类型定义哈希函数,编译器就会报错。
所以这里我们就需要为InetAddr写一个hash类
直接写到最后即可,因为hash类本身是一个模版,所以我们这里只需要写一个InterAddr的特例即可,写法就是这样,简单的保证下唯一性。
最后直接运行就可以了
本机之间通信
线程运行成功了,启动客户端
启动成功,我们发送的消息服务器可以收到,并且可以转给目前的所有在线用户。
不同主机之间通信
我们既然是网络那么肯定就能够进行不同网络,不同子网之间的通信,那么接下来,我们来用另一台主机通过IP+端口号的形式访问我们的Udp服务器吧
安全组策略开放端口
我们直接在另一台主机上发送消息给Udp服务器,发现我们的Udp服务器好像没有收到任何信息,这是为什么呢?我们代码哪里写错了吗?
其实并不是这只是因为我们的云服务器安全组策略没有开放对应的端口而已。
这也就导致 Udp服务器没有收到消息,那么我们应该怎么开放端口呢?
我们以华为云服务器举例(其实其他的都大差不差的)
打开华为云官方网站,再进入到控制台管理界面,找到我们的服务器,也就是下面这样
之后点击续费旁边的三个点,找到配置安全组规则
点开后,就可以配置安全策略规则了
可以选择所有协议端口,或者TCP全部端口,UDP所有端口,也可以自定义端口,根据需求配置即可。
这里配置好了之后,
我们再来访问下Udp服务器看下,是否可以访问上了
我们可以看到我们另一台主机已经可以访问上主机了,可以我们还发现一个问题,在客户端我们想要接收消息,似乎必须得先发送一条消息,才能够接收到消息,那么这个问题应该怎么解决呢?
答案就是多线程!
我们将读和写分成两个线程,一个线程只负责读信息,主线程只负责发信息,这样就解决掉上面的情况了。
那这就有问题了,两个线程共享同一个sockfd,那这不就线程不安全了吗?
其实并没有,udp的scoket是全双工的,允许被同时读写,所以我们的操作是可以合理的,不用加锁。
我们引入我们之前封装的线程,将读写操作分开执行。
编译好之后,再次运行
这样就可以了
客户端输入输出分离
在上面我们已经成功将多人chat聊天室实现了,但是客户端部分,输入输出在同一个显示屏,多少还是有点乱,那有没有什么方式,能够让他们分开呢? 当然有,这里我们提出两种方式
1. 基于管道实现输入输出分离
还记得C++中的输入流吗? 0为标准输入,代表键盘,1为标准输出,代表显示器,2为标准错误,代表显示器。
那么为什么C/C++要提供标准输出、标准错误呢?? 可以通过重定向、让标准输出、标准错误分批打印到不同的文件中,方便我们进行debug!
我们可以测试下
测试代码:
我们发现只有标准输出输出到类文件中,标准错误依旧输出到了屏幕上。这是因为 > 重定向,起始默认省略了1,完整写就是 1>, ./a.out 1> log.txt 所以就只有标准输出到了文件中
我们换了一种写法,这次标准输出和标准错误确实没有输出到屏幕上,但是文件中却只有标准错误的信息,却没有标准输出,这是为什么呢?
其实标准输出确实也输出到文件中了,但是却被标准错误给覆盖了,所以才看不到了,使用>输出重定向,当打开一个文件的时候就会自动清空,如果不想被覆盖就使用追加重定向>>,
这样就可以了,其实还有一种写法,也可以追加输入到文件中,我们上面提到过当重新打开一个文件的时候会自动清空,那如果不会重新打开呢?
这样&也是可以的。
接下来开始实现,首先创建一个管道
需要注意的是,管道有一个特性,就是管道的读写需要同时打开,才会继续向后执行
所以我们在发送信息时最好先重定向FIFO的输出(先重定向FIFO的输入也是可以的)
这样就实现了客户端的输入输出分离
2.基于终端显示文件
相较于这个方式,更推荐第一种,因为这种方式具有一定的局限性,代码迁移性受损,有些系统(例如Mac),他是不支持的,
在服务器终端,/dev/pts目录下对应的是各个终端。
有两个终端就有两个文件
我们可以测试下
由此,我们便可以利用这个特性,将输出内容分离开,我们还是将输出内容分为标准输出和标准错误,然后通过重定向的方式将标准错误重定向到其他终端即可
需要注意,新的终端分配的文件时由小到大分配的,并且不会发生改变除非退出终端。
现在,我们先来编写一个简单的测试代码验证一下吧
编译运行
那我们怎么把这个技术用到我们的客户端呢? 很简单,只需要把这个文件的输出去掉,再改成头文件,加到我们的Udp客户端就可以了。
我们这里是封装成成了RAII风格的类了(纯属最近封装封上瘾了,完全可以用更简单的方法的,只要注意不要忘了最后释放掉申请的fd就行)。
加入头文件后,在开头,创造一个变量就行了
编译运行
至此Udp章节结束,接下来下一个章节 TCP