来写一个udp的代码
1.socket编程接口
1 2 3 4 5 6 7 8 9 10 11 12 13
| int socket(int domain, int type, int protocol);
int bind(int socket, const struct sockaddr *address, socklen_t address_len);
int listen(int socket, int backlog);
int accept(int socket, struct sockaddr* address, socklen_t* address_len);
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
|
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 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| class UdpServer { public: UdpServer(uint16_t port,const string& ip="") : _port((uint16_t)port), _ip(ip), _sockfd(-1) {}
private: uint16_t _port; string _ip; int _sockfd; };
|
2.1 创建套接字
这里需要用的是下面这个接口
1
| int socket(int domain, int type, int protocol);
|
可以设置为本地,也可以设置为网络。支持如下参数
1 2 3 4 5 6 7 8 9 10 11
| Name Purpose Man page AF_UNIX, AF_LOCAL Local communication unix(7) AF_INET IPv4 Internet protocols ip(7) AF_INET6 IPv6 Internet protocols ipv6(7) AF_IPX IPX - Novell protocols AF_NETLINK Kernel user interface device netlink(7) AF_X25 ITU-T X.25 / ISO-8208 protocol x25(7) AF_AX25 Amateur radio AX.25 protocol AF_ATMPVC Access to raw ATM PVCs AF_APPLETALK Appletalk ddp(7) AF_PACKET Low level packet interface packet(7)
|
因为我们要创建的是一个网络服务器,所以这里设置为AF_INET
,也就是IPV4的服务
- 第二个参数type指代套接字的类型,决定了通信时的报文类型
这里支持流式(TCP)或者用户数据报(UDP),以及RAW原始格式(能够直接访问协议,方便debug)
1 2 3 4 5
| SOCK_STREAM Provides sequenced, reliable, two-way, connection-based byte streams. An out-of-band data transmission mechanism may be supported.
SOCK_DGRAM Supports datagrams (connectionless, unreliable messages of a fixed maximum length).
SOCK_RAW Provides raw network protocol access.
|
更多支持的参数参考man手册
返回值是一个linux系统的文件描述符
1 2
| RETURN VALUE On success, a file descriptor for the new socket is returned. On error, -1 is returned, and errno is set appropriately.
|
这样,我们就能写出第一行代码,以及对这个代码的返回值判断
1 2 3 4 5 6
| _sockfd = socket(AF_INET, SOCK_DGRAM, 0); if (_sockfd < 0) { logging(FATAL, "socket:%s:%d", strerror(errno), _sockfd); exit(1); }
|
因为socket是文件描述符,为了规范,我们还可以在析构函数里面调用一下close
1 2 3 4
| ~UdpServer() { close(_sockfd); }
|
2.2 配置sockaddr_in
1 2 3
| struct sockaddr_in local; memset(&local,0,sizeof(local));
|
因为用的是ipv4
的网络通信,所以这里需要初始化一个sockaddr_in
类型
此时在vscode的代码补全中,可以看到4个成员,需要对它们赋值以配置服务器信息
首先是把协议家族设置为IPV4
,端口配置为函数参数中的端口
1 2 3 4
| local.sin_family = AF_INET;
local.sin_port = htons(_port);
|
随后配置ip
1 2
| local.sin_addr.s_addr = _ip.empty() ? htonl(INADDR_ANY) : inet_addr(ip_.c_str());
|
这里采用了?:
三目操作符,如果类构造的时候传入的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,而是要用网络字节流规定的类型。
1 2 3 4 5 6
| typedef uint32_t in_addr_t; struct in_addr { in_addr_t s_addr; };
|
对于该接口的底层做一个简单的说明:其实就是利用位段
,将数据转换为一个特殊的类型
1 2 3 4 5 6 7 8
| struct ip { uint32_t part1:8; uint32_t part2:8; uint32_t part3:8; uint32_t part4:8; }
|
2.2.2 inet_ntoa
同样的,如果我们作为客户端接受到了网络请求中的ip,可以用inet_ntoa
将其转换为字符串类型。
1
| char *inet_ntoa(struct in_addr in);
|
这里就引申出了一个问题:返回值的char*
是存在哪里的?是静态区还是malloc
?
手册告诉我们,这个函数是维护了一个static变量来存放返回的ip的。
因此,该函数并不是一个线程安全的函数,在APUE
中明确标明了这一点
2.3 bind绑定ip端口
1 2 3 4 5
| #include <sys/types.h> #include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
|
这个接口的作用就是指定socket和sockaddr进行绑定。第三个参数是addr元素的大小(不是指针大小,别搞错了)
1 2 3 4 5 6 7
| if (bind(_sockfd,(const struct sockaddr *)&local, sizeof(local)) == -1) { logging(FATAL, "bind: %s:%d", strerror(errno), _sockfd); exit(2); } logging(DEBUG,"socket bind success: %d", _sockfd);
|
绑定了之后,我们的服务器就配置成功了
测试一下,可以看到编译没有报错,也能正常运行!
1 2 3 4 5
| [muxue@bt-7274:~/git/linux/code/23-02-01 udp]$ make udpServer g++ -o udpServer udpServer.cpp -std=c++11 [muxue@bt-7274:~/git/linux/code/23-02-01 udp]$ ./udpServer DEBUG | 1675326460 | muxue | socket create success: 3 DEBUG | 1675326460 | muxue | socket bind success: 3
|
2.3.1 main
现在先来简单写一下main函数中启动服务的命令行参数吧
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| int main(int argc,char* argv[]) { if(argc!=2 && argc!=3) { cout << "Usage: " << argv[0] << " port [ip]" << endl; return 1; }
string ip; if(argc==3) { ip = argv[2]; } UdpServer s(atoi(argv[1]),ip); s.start();
return 0; }
|
为了测试,先把start()
函数设置为一个死循环
1 2 3 4 5 6 7 8
| void start() { while(1) { cout << "running " << getpid() << endl; sleep(1); } }
|
编译运行,可以看到错误提示是可以用的。正确添加参数之后,也能绑定并开始运行
1 2 3 4 5 6 7 8 9 10 11
| [muxue@bt-7274:~/git/linux/code/23-02-01 udp]$ make udpServer g++ -o udpServer udpServer.cpp -std=c++11 [muxue@bt-7274:~/git/linux/code/23-02-01 udp]$ ./udpServer Usage: ./udpServer port [ip] [muxue@bt-7274:~/git/linux/code/23-02-01 udp]$ ./udpServer 8080 DEBUG | 1675327610 | muxue | socket create success: 3 DEBUG | 1675327610 | muxue | socket bind success: 3 running 4467 running 4467 running 4467 ^C
|
注意,bind
这个函数是不允许你绑定云服务器的公网ip的。因为云服务器并不是直接暴露在公网上的,而是由提供商的入口服务器进入内网,在进入你的服务器。所以他不允许你绑定公网ip;
1 2 3
| $ ./udpServer 8080 云服务器公网ip DEBUG | 1675327690 | muxue | socket create success: 3 FATAL | 1675327690 | muxue | bind: Cannot assign requested address:3
|
一般情况下,可以选择不绑定ip,或者绑定本地端口127.0.0.1
如果绑定了127.0.0.1
,那么服务只有本地可以访问。不绑定端口,就会默认绑定成0.0.0.0
,允许本地和远程端口连接
1 2 3 4 5
| $ ./udpServer 8080 127.0.0.1 DEBUG | 1675327757 | muxue | socket create success: 3 DEBUG | 1675327757 | muxue | socket bind success: 3 running 5067 running 5067
|
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 2 3 4 5
| #include <sys/types.h> #include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
|
- 第一个参数是前面创建的套接字
- 第二个参数是用来接收信息的缓冲区
- 第三个参数是缓冲区的大小
- 第四个参数是标识符,设置为0,代表阻塞等待
- 第五个参数,输出型参数,获取发送方的信息
- 第六个参数,输入输出型参数,需要初始化为
sizeof(src_addr)
函数的返回值是接收到的数据的长度,没有接收到或者接受失败,则为-1
示例如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| char inBuf[BUF_SIZE]; struct sockaddr_in peer; socklen_t len = sizeof(peer);
ssize_t s = recvfrom(_sockfd, inBuf, sizeof(inBuf)-1,0,(struct sockaddr *)&peer, &len); if (s > 0) { inBuf[s] = '\0'; } else if (s == -1) { logging(WARINING, "recvfrom: %s:%d", strerror(errno), _sockfd); continue; }
|
这样就能在inBuf
中直接获取到发送信息的内容
2.5 服务端start
以下是服务端运行的完整代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| void start() { char inBuf[BUF_SIZE]; while(1) { struct sockaddr_in peer; socklen_t len = sizeof(peer);
ssize_t s = recvfrom(_sockfd, inBuf, sizeof(inBuf)-1,0,(struct sockaddr *)&peer, &len); if (s > 0) { inBuf[s] = '\0'; } else if (s == -1) { logging(WARINING, "recvfrom: %s:%d", strerror(errno), _sockfd); continue; }
string senderIP = inet_ntoa(peer.sin_addr); uint16_t senderPort = ntohs(peer.sin_port); logging(NOTICE, "[%s:%d]# %s", senderIP.c_str(),senderPort, inBuf); } }
|
如果你想让另外一台主机访问这个服务,则需要在云服务器控制台和linux系统中同时开放对应的udp端口
参考 【Linux】设置系统防火墙
3.client
有了服务端,也要有对应的客户端来发送消息;除了发送消息的部分,其余操作和服务端基本一致。
3.1 sendto
1 2 3 4 5 6 7 8 9
| #include <sys/types.h> #include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
|
这里我们要用的是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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| #include <iostream> #include <string> #include <cstdlib> #include <cassert> #include <unistd.h> #include <strings.h> #include <netinet/in.h> #include <arpa/inet.h> #include <sys/types.h> #include <sys/socket.h> using namespace std;
struct sockaddr_in server;
int main(int argc, char *argv[]) { if (argc != 3) { cout << "Usage:\n\t" << argv[0] << " server_ip server_port" << endl; return 1; } string server_ip = argv[1]; uint16_t server_port = atoi(argv[2]);
int sockfd = socket(AF_INET, SOCK_DGRAM, 0); if(sockfd<0) { cout << "socket 创建失败" << endl; return 2; }
bzero(&server, sizeof(server));
server.sin_family = AF_INET; server.sin_port = htons(server_port); server.sin_addr.s_addr = inet_addr(server_ip.c_str());
string buffer; while (true) { cerr << "Please Enter# "; getline(cin, buffer); sendto(sockfd, buffer.c_str(), buffer.size(), 0, (const struct sockaddr *)&server, sizeof(server)); } close(sockfd);
return 0; }
|
3.4 运行测试
这里提供一个makefile,来快速编译服务端/客户端的源码
1 2 3 4 5 6 7 8 9 10 11
| .PHONY:all all:udpClient udpServer
udpClient: udpClient.cpp g++ -o $@ $^ -std=c++11 -lpthread udpServer:udpServer.cpp g++ -o $@ $^ -std=c++11
.PHONY:clean clean: rm -f udpClient udpServer
|
运行服务器,指定8080端口启动。再运行客户端,指定127.0.0.1
本地ip和8080端口
可以看到,右侧我们收到的信息,都在左侧被打印了出来,同时显示了来源ip和端口
3.5 windows客户端
让我没想到的是,windows上网络的接口和linux很相似;这里提供一个windows下的udp客户端,向我们的云服务器发送信息
注:进行测试前,一定要在防火墙里面开放云服务器对应的udp端口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| #define _WINSOCK_DEPRECATED_NO_WARNINGS 1
#include<winsock2.h> #include<stdio.h> #include<string.h> #include<string> #include<iostream> using namespace std; #pragma comment(lib,"ws2_32.lib") #define BUFFER_SIZE 1024
int main() { WSADATA WSAData; if (WSAStartup(MAKEWORD(2, 2), &WSAData) != 0) { printf("初始化失败!"); return -1; } SOCKET sock_Client = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); SOCKADDR_IN addr_server; addr_server.sin_family = AF_INET; addr_server.sin_port = htons(10000); addr_server.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
string sendBuf; while (true) { cout << "请输入要传送的数据: "; getline(cin,sendBuf); sendto(sock_Client, sendBuf.c_str(),sendBuf.size(), 0, (const SOCKADDR*)&addr_server, sizeof(addr_server)); cout << sendBuf.size() << ": " << sendBuf << endl; } closesocket(sock_Client); WSACleanup();
return 0; }
|
测试一下,可以看到云服务器成功收到了信息,但因为windows和linux的文字编码问题,没能正确显示出中文
发送英文信息是没有问题的!
4.更进一步
4.1 记录用户
有用户给你发送信息,理论上来说,服务端应该记录下用户,以备debug;
这部分并不难,我们记录下用户的ip和端口,还有用户的peer结构体,在服务器里面维护一个map来存放就可以了
1 2 3 4 5 6 7 8 9 10 11 12 13
| void CheckUser(struct sockaddr_in peer) { string tmp = inet_ntoa(peer.sin_addr); tmp += ':'; tmp += to_string(ntohs(peer.sin_port));
auto it = _usrMap.find(tmp); if(it == _usrMap.end()) { _usrMap.insert({tmp,peer}); } }
|
4.2 客户端接收回信
客户端发送信息给服务器后,可以来接收一下服务器的回信。比如在日常生活中,我们发邮件的时候,需要等待对方回信,这才表明你的信对方确实收到了,而不是丢在半路上了
1 2 3
| pthread_t t; pthread_create(&t, nullptr, recverAndPrint, (void *)&sockfd);
|
为了方便,这里采用多线程的方式来操作;客户端在接收到服务器的回信后,会打印出来
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| void *recverAndPrint(void *args) { while (true) { int sockfd = *(int *)args; char buffer[1024]; struct sockaddr_in temp; socklen_t len = sizeof(temp); ssize_t s = recvfrom(sockfd, buffer, sizeof(buffer), 0, (struct sockaddr *)&temp, &len); if (s > 0) { buffer[s] = 0; cout << "server echo# " << buffer << "\n"; } } }
|
4.3 消息路由
所谓消息路由,就是把接收到的消息广播给所有用户。可以理解为一个简单的聊天室。
上面我们已经获取并记录了信息,下面要做的就是把信息重新发给其他用户;操作和客户端的发送是一样的
1 2 3 4 5 6 7 8 9 10 11 12 13
| void MsgRoute(const char* inBuf,size_t len) { struct sockaddr_in user; for(auto e:_usrMap) { user.sin_family = AF_INET; user.sin_port = e.second.sin_port; user.sin_addr.s_addr = e.second.sin_addr.s_addr; sendto(_sockfd, inBuf, len, 0, (const struct sockaddr *)&user, sizeof(user)); } }
|
测试,可以看到,服务端把收到的消息发送给了用户
再新增一个客户端进行测试,可以看到两个客户都收到了服务器的回信
这里对于聊天室来说还有一个小问题,那就是聊天框里面并不会二次出现你的消息。也就是服务器不会把你发送的消息再转发给你。
我们在消息路由函数里面进行判断即可!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| void MsgRoute(struct sockaddr_in peer,const char* inBuf,size_t len) { struct sockaddr_in user; for(auto e:_usrMap) { if(e.second.sin_port != peer.sin_port || e.second.sin_addr.s_addr != peer.sin_addr.s_addr) { user.sin_family = AF_INET; user.sin_port = e.second.sin_port; user.sin_addr.s_addr = e.second.sin_addr.s_addr; sendto(_sockfd, inBuf, len, 0, (const struct sockaddr *)&user, sizeof(user)); } } }
|
因为乱序打印的问题,所以看的可能不是很明显。但是我们的目的已经达到了!
这样打印看的不是很清楚,可以使用管道文件来实现输出重定向
运行客户端的时候,指定输出
1
| ./udpClient 127.0.0.1 1000 > fifo
|
在另外一个bash里面,用cat来获取输出
这就不会出现乱序打印的问题了。
fifo
是一个管道文件,需要执行cat后(读端),客户端(写端)才能运行
5.more…
关于udp编程的操作到这里就Over啦,现在我们认识了大部分的网络接口,下一步的目标,就是实现tcp服务器啦!