【Linux】来写一个udp的服务端+客户端
来写一个udp的代码
1.socket编程接口
1 |
|
linux下一切皆文件,socket接口也不例外。其返回值本质上就是一个fd文件描述符,这样我们对网络的发送/接收操作,就转换成了对文件的写入/读取操作了
在这里面有一个比较重要的结构sockaddr
需要说明一番
1.1 sockaddr
socket是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4/IPv6。同时,这个接口还可以用于系统内部的通信。这就实现了用一个接口来干两件事。
为此,就必须要在传值中进行一些修改。该接口新增了一个sockaddr
,用来接收目标信息。这个值的参数可以是sockaddr_in/scokaddr_un/sockadd_in6
之中的任意一个(需要强转指针)
sockaddr
本身不存放任何信息。
这个参数可接收的结构体中,固定前16位就是用于标识符的。传到处理函数中,就会判断前16位中的标识符的类型,以确定传入参数的类型,再执行不同的实现
- 比如传入的
scokaddr_un
,前16位是AF_UNIX
,那么当前使用的就是本地通信 sockaddr_in
是ipv4通信,sockaddr_in6
是ipv6通信
你可能会有疑惑,既然sockaddr
不存放信息,那为何不把这个参数设置为一个void*
的指针?反正最后都是进了函数之后判断参数类型,void*
指针也能达成目标呀🧐
这个问题的答案很简单:当初设计这套接口的时候,C语言还不支持void*
😂
1.2 存放位置
因为sockaddr_in
这类的结构体,最终都需要被操作系统载入并实现网络操作。所以它们肯定是需要载入内核中的
但这并不意味着这类结构体是存放在内核里面的,而是存放在用户栈,用户态和内核态交换的时候,通过接口传值载入到内核的空间进行使用
2.server
了解了上面的信息,接下来,认识一下如果想建立一个udp
server,需要怎么操作吧!
以下是一个server的类,包含了端口、ip、socker fd三个基本信息
1 | class UdpServer |
2.1 创建套接字
这里需要用的是下面这个接口
1 | int socket(int domain, int type, int protocol); |
- 第一个参数domain标识该socker的作用域
可以设置为本地,也可以设置为网络。支持如下参数
1 | Name Purpose Man page |
因为我们要创建的是一个网络服务器,所以这里设置为AF_INET
,也就是IPV4的服务
- 第二个参数type指代套接字的类型,决定了通信时的报文类型
这里支持流式(TCP)或者用户数据报(UDP),以及RAW原始格式(能够直接访问协议,方便debug)
1 | SOCK_STREAM Provides sequenced, reliable, two-way, connection-based byte streams. An out-of-band data transmission mechanism may be supported. |
更多支持的参数参考man手册
- 第三个参数指代协议,在网络应用中,设置为0即可
返回值是一个linux系统的文件描述符
1 | RETURN VALUE |
这样,我们就能写出第一行代码,以及对这个代码的返回值判断
1 | _sockfd = socket(AF_INET, SOCK_DGRAM, 0); |
因为socket是文件描述符,为了规范,我们还可以在析构函数里面调用一下close
1 | ~UdpServer() |
2.2 配置sockaddr_in
1 | // 2. 绑定网络信息,指明ip+port |
因为用的是ipv4
的网络通信,所以这里需要初始化一个sockaddr_in
类型
此时在vscode的代码补全中,可以看到4个成员,需要对它们赋值以配置服务器信息
首先是把协议家族设置为IPV4
,端口配置为函数参数中的端口
1 | // 协议家族,设置为ipv4 |
随后配置ip
1 | // 如果初始化时候的ip为空,则调用INADDR_ANY代表任意ip。否则对传入的ip进行转换后赋值 |
这里采用了?:
三目操作符,如果类构造的时候传入的ip是空(没有配置ip)那就直接设置为任意ip,否则传入成员变量;
这样对sockaddr_in
的配置就完成了。
2.2.1 inet_addr
这里需要使用inet_addr
函数对传入的字符串类型的ip(如192.168.0.1
)进行转换
1 | in_addr_t inet_addr(const char *cp);//对字符串ip进行转换 |
因为对于网络来说,它并不认识字符串类型的ip,而是要用网络字节流规定的类型。
1 | /* Internet address. */ |
对于该接口的底层做一个简单的说明:其实就是利用位段
,将数据转换为一个特殊的类型
1 | //示例,非底层实现 |
2.2.2 inet_ntoa
同样的,如果我们作为客户端接受到了网络请求中的ip,可以用inet_ntoa
将其转换为字符串类型。
1 | char *inet_ntoa(struct in_addr in); |
这里就引申出了一个问题:返回值的char*
是存在哪里的?是静态区还是malloc
?
1 | The inet_ntoa() function converts the Internet host address in, given in network byte order, to a string in IPv4 dotted-decimal notation. The string is returned in a statically allocated buffer, which subsequent calls will overwrite. |
手册告诉我们,这个函数是维护了一个static变量来存放返回的ip的。
因此,该函数并不是一个线程安全的函数,在APUE
中明确标明了这一点;后续的调用会覆盖掉这个IP地址;
2023.09.15的面试问道了这个问题,感兴趣的可以看看我的面经里面的题目和解释,这里把题目贴出来;如下的函数调用会不会有什么问题?
1 | printf("%s %s\n",inet_ntoa(ip1),inet_ntoa(ip2)); |
说结论:对于inet_aton
函数而言,正确的调用办法是每次调用后都立即取走返回的IP地址字符串,可以用std::string
接收,也可以用strcmp拷贝到一个自己定义的字符串数组变量中;
具体的介绍请移步面经哦!
2.3 bind绑定ip端口
1 |
|
这个接口的作用就是指定socket和sockaddr进行绑定。第三个参数是addr元素的大小(不是指针大小,别搞错了)
1 | // 2.2 绑定ip端口 |
绑定了之后,我们的服务器就配置成功了
测试一下,可以看到编译没有报错,也能正常运行!
1 | [muxue@bt-7274:~/git/linux/code/23-02-01 udp]$ make udpServer |
在使用UDP进行通信的时候,我们不一定需要绑定具体的IP地址,可以用INADR_ANY
来代替具体的本机IP地址,但是端口号一定要进行绑定。(不然系统没办法让你的进程和某个端口关联来接收信息)
2.3.1 main
现在先来简单写一下main函数中启动服务的命令行参数吧
1 | int main(int argc,char* argv[]) |
为了测试,先把start()
函数设置为一个死循环
1 | void start() |
编译运行,可以看到错误提示是可以用的。正确添加参数之后,也能绑定并开始运行
1 | [muxue@bt-7274:~/git/linux/code/23-02-01 udp]$ make udpServer |
注意,bind
这个函数是不允许你绑定云服务器的公网ip的。因为云服务器并不是直接暴露在公网上的,而是由提供商的入口服务器进入内网,在进入你的服务器。所以他不允许你绑定公网ip;
1 | $ ./udpServer 8080 云服务器公网ip |
一般情况下,可以选择不绑定ip,或者绑定本地端口127.0.0.1
如果绑定了
127.0.0.1
,那么服务只有本地可以访问。不绑定端口,就会默认绑定成0.0.0.0
,允许本地和远程端口连接
1 | $ ./udpServer 8080 127.0.0.1 |
2.3.2 netstat
可以用netstat -lnup
命令查看当前开放的端口信息
可以看到,第一行就是我们的udp服务器,本地端口是我们绑定的127.0.0.1:8080
,远程端口是0.0.0.0:*
,代表允许任何远程ip的任何端口来访问
2.4 开始运行
上面的操作只是初始化了这个udp服务器的信息,并没有让它真正的运行起来;
接下来要做的就是让服务器开始接收信息,并在屏幕上打印出来
2.4.1 recvfrom
这个接口的作用是来接收信息
1 |
|
- 第一个参数是前面创建的套接字
- 第二个参数是用来接收信息的缓冲区
- 第三个参数是缓冲区的大小
- 第四个参数是标识符,设置为0,代表阻塞等待
- 第五个参数,输出型参数,获取发送方的信息
- 第六个参数,输入输出型参数,需要初始化为
sizeof(src_addr)
函数的返回值是接收到的数据的长度,没有接收到或者接受失败,则为-1
示例如下
1 | char inBuf[BUF_SIZE]; |
这样就能在inBuf
中直接获取到发送信息的内容
2.5 服务端start
以下是服务端运行的完整代码
1 | void start() |
如果你想让另外一台主机访问这个服务,则需要在云服务器控制台和linux系统中同时开放对应的udp端口
3.client
有了服务端,也要有对应的客户端来发送消息;除了发送消息的部分,其余操作和服务端基本一致。
3.1 sendto
1 |
|
这里我们要用的是sendto
接口
- 第一个参数是socket套接字
- 第二个参数是用于输入的缓冲区
- 第三个参数是缓冲区的类型
- 第四个参数是标识符,也设置为0
- 第五个参数和第六个参数与
recvfrom
一致,为目标服务器的信息
关于flag参数,man手册中有更多选项,这里我们依旧传入0采用默认策略
1 | The flags argument is the bitwise OR of zero or more of the following flags. |
3.2 客户端需不需要手动bind?
首先我们要明确一点,bind函数并没有规定一定要是服务端才能使用。也就是说,要不要使用bind是程序猿自己的选择。
答案其实很简单:那就是不需要手动bind
首先我们要知道一点:如果一个网络进程在启动的时候没有手动bind端口,系统是会自动分配一个未使用的端口给它的
- 对于服务器来说,
IP:端口
必须固定,否则没有办法给客户端提供稳定的服务。客户又不能拆了你的应用程序修改源码中的端口! - 而对于客户端来说,端口应该让系统自动分配。因为这样能避免冲突问题。不然如果有另外一个应用占用了客户端bind的端口,那这个程序就会因为端口冲突而一直打不开!
所以,客户端不需要我们调用bind函数,只需要配置好服务端的目标ip和目标端口就行了
3.3 代码示例
1 |
|
3.4 运行测试
这里提供一个makefile,来快速编译服务端/客户端的源码
1 |
|
运行服务器,指定8080端口启动。再运行客户端,指定127.0.0.1
本地ip和8080端口
可以看到,右侧我们收到的信息,都在左侧被打印了出来,同时显示了来源ip和端口
3.5 windows客户端
让我没想到的是,windows上网络的接口和linux很相似;这里提供一个windows下的udp客户端,向我们的云服务器发送信息
注:进行测试前,一定要在防火墙里面开放云服务器对应的udp端口
1 |
|
测试一下,可以看到云服务器成功收到了信息,但因为windows和linux的文字编码问题,没能正确显示出中文
发送英文信息是没有问题的!
4.更进一步
4.1 记录用户
有用户给你发送信息,理论上来说,服务端应该记录下用户,以备debug;
这部分并不难,我们记录下用户的ip和端口,还有用户的peer结构体,在服务器里面维护一个map来存放就可以了
1 | void CheckUser(struct sockaddr_in peer) |
4.2 客户端接收回信
客户端发送信息给服务器后,可以来接收一下服务器的回信。比如在日常生活中,我们发邮件的时候,需要等待对方回信,这才表明你的信对方确实收到了,而不是丢在半路上了
1 | // 使用多线程操作,来获取服务器传回的信息 |
为了方便,这里采用多线程的方式来操作;客户端在接收到服务器的回信后,会打印出来
1 | void *recverAndPrint(void *args) |
4.3 消息路由
所谓消息路由,就是把接收到的消息广播给所有用户。可以理解为一个简单的聊天室。
上面我们已经获取并记录了信息,下面要做的就是把信息重新发给其他用户;操作和客户端的发送是一样的
1 | void MsgRoute(const char* inBuf,size_t len) |
测试,可以看到,服务端把收到的消息发送给了用户
再新增一个客户端进行测试,可以看到两个客户都收到了服务器的回信
这里对于聊天室来说还有一个小问题,那就是聊天框里面并不会二次出现你的消息。也就是服务器不会把你发送的消息再转发给你。
我们在消息路由函数里面进行判断即可!
1 | void MsgRoute(struct sockaddr_in peer,const char* inBuf,size_t len) |
因为乱序打印的问题,所以看的可能不是很明显。但是我们的目的已经达到了!
这样打印看的不是很清楚,可以使用管道文件来实现输出重定向
1 | mkfifo fifo #创建一个fifo管道文件 |
运行客户端的时候,指定输出
1 | ./udpClient 127.0.0.1 1000 > fifo |
在另外一个bash里面,用cat来获取输出
1 | cat < fifo |
这就不会出现乱序打印的问题了。
fifo
是一个管道文件,需要执行cat后(读端),客户端(写端)才能运行
5.关于什么时候需要bind
本文以下内容建议学习了UDP底层报文和相关网络协议栈的知识后再来看,会方便理解一些。
参考文章:socket 通信关于bind IP地址
5.1 情况一
情况一:若有客户端和服务器之分的程序,创建sock后即可在该socket上用recvfrom/sendto
方法发送接受数据了。
因为客户端只需要用sendto发送数据到指定的地址,所以不需要bind本地的ip和端口。当然若是bind了,程序也没什么问题,区别就是不bind的时候,系统会自动bind()
指定本机的socket参数地址来进行发送数据库。
而服务器因为必须要有一个显式的、固定的IP端口供客户端连接,所以接收方是必须要进行bind的。
那UDP服务器是怎么知道客户端的IP地址和UDP端口?一般来说有两种方式:
- 一种是客户端发消息显式地告诉服务器IP地址和端口,消息内容就包括IP地址和UDP端口。
- 另外一种就是隐式的,服务器从收到的包的报文头部中得到包的源IP地址和端口。
5.2 情况二
若是没有客户端和服务器之分的程序,即自己指定特定端口的UDP对等通信(双向对等通信),则客户端和服务器都需要bind()
IP地址和端口了。因为双方都需要知道对方的IP和端口才能进行数据收发。
5.3 多播
但UDP中更常用的是广播分发,服务端socket设定一个X.X.X.255
的广播地址并始终向它发送,每个客户端建立的socket只需要加入这个广播地址便可以收到,这个行为称为加入多播组。这便是多播
的概念。
一个多播组可以被多个进程加入,加入了这个多播组后,所有进程将收到相同的消息。这样就实现了一对多通信。
请注意,多播地址是不能进行bind的,我们需要用相关的接口将自己的fd加入目标多播组,才能从这个多播组中收到信息。发送方是不需要加入多播组的,直接往多播组的ip地址里面发就行了。
- 接收方需要调用接口加入指定多播组
- 发送方不需要加入多播组
重点: 加入了多播组后,将无法收到原本bind的ip地址的消息!比如一个udp的socket,原本bind了
0.0.0.0:5000
,在加入多播组之前,我能正常从5000端口中收到信息;但是加入了多播组后,我就只能从多播组里面收信息了。
接收方加入多播组的接口如下,在接收方的bind之后调用就行了
1 |
|
因为多播通常需要绑定特殊的IP地址(例如 224.0.0.0
到 239.255.255.255
),这些IP地址是无法在公网上使用的,所以多播是只存在于局域网中的概念。要想实现多播,发送端和接受端必须处于同一局域网。
5.4 直播推流
以下都是我根据自己的理解瞎逼逼的,有问题还请指出。
公网上,也有使用UDP进行“多播”的例子,比如我们常用的直播就是使用的UDP(因为直播是一个视频流推送给N个用户,如果每个用户都建立一个tcp连接会有巨大的消耗,服务器压根顶不住。再加上tcp需要等待用户发回ack,在一对多的大型推流场景下,等待ack的消耗也不容忽视)
直播推流情况下,其实并不是用局域网内的多播实现的,而是用UDP模拟实现一个类似“多播”的一对多通信。
下面举个例子,我们暂时认为客户机也是拥有公网IP的,后续学习到IP层,会提到NAT技术,到时候就能理解家宽是怎么和服务器通信的了
- 客户机想看主播的直播,直播app会发送一个请求到特定的
IP:端口
,请求建立一个直播推流的连接。这个请求中就会包含客户机自己的IP地址和端口号 - 服务端接受请求,解析出客户机的IP和端口号,并将其加入到推流队列中
- 服务端每一次推流视频,都遍历全部连接到这个直播上的客户机的IP端口,通过UDP向他们发送视频数据。
- 这样就实现了一个服务端向多个客户机“广播”直播推流数据的通信。
需要注意的是,这和前文提到的局域网内的多播完全不同!这里我们依旧是用公网IP和端口进行一对一通信的,只不过服务器端进行了处理,会向所有客户机发送视频流数据罢了。
你可能会有疑惑,现在的直播那么多人看,一个热门的直播间几万人甚至上十几万人,服务器用遍历发送的方式来得及吗?如果遍历发送一次所有客户机的耗时超过了每次发送的数据大小,岂不是大家都卡顿了吗?
如果你了解UDP报文的结构的话,就知道UDP一次发送最多只能发送64KB的数据,对于直播推流/视频来说,64KB的数据恐怕只有1秒的视频。
其实我们直播出现卡顿,就是这么个原因。服务端没有办法推流那么多用户了,就会出现卡顿乃至中断的情况。而且,只用同一台服务器对所有用户进行广播肯定是不够的,这时候就会引入不同线路来进行优化:
- 主播连接一个主服务器,推流自己的直播视频数据
- 主服务器将该直播视频流数据发送给全国各地的子服务器
- 用户A进入直播间,将请求主服务器,发送自己的IP和端口号
- 主服务器将用户A的IP和端口号解析,根据IP属地,发送给一个距离用户A最近的子服务器B,让子服务器B来给用户A进行直播的推流
- 此时用户A收到的视频数据:
主播->主服务器->子服务器B->用户A
;
这样,推流的压力就能分散给不同的子服务器线路。
我们看直播的时候可以选择线路切换,其实也就是在不同的子服务器中进行切换,如果某个子服务器压力较低,此时在这个子服务器的线路上接收直播推流,用户的观看体验就比较流畅了。
more…
关于udp编程的操作到这里就Over啦,现在我们认识了大部分的网络接口,下一步的目标,就是实现tcp服务器啦!