简单 UDP 服务器和客户端

什么是数据报套接字?

数据报套接字 => Datagram Socket 流套接字 => Stream Socket

创建一个数据报套接字与创建一个流套接字类似, 主要的区别是数据报套接字使用 UDP 协议而不是 TCP 协议,这意味着客户端和服务器不需要在发送数据之前建立连接。

这对于不需要维护连接的应用程序很有用,因为客户端和服务器可以在不必首先建立连接的情况下发送和接收数据。 例如,DNS 服务器使用 UDP 协议,因为它不需要维护连接,只需要发送数据并接收响应。

工作流程

image

图片来源: UDP Server-Client implementation in C

Server 如何工作?

Listener 通过创建一个套接字,将其绑定到一个端口,并等待传入的消息来工作。

  • 首先,listener 创建一个 hints 结构体,该结构体包含了创建套接字所需的信息,例如协议族,套接字类型,以及端口号。随后调用 getaddrinfo() 函数获取地址信息,保存到 servinfo 链表中。

  • 然后,listener 遍历 servinfo 链表,直到找到可以使用的地址信息,然后通过 socket() 函数创建套接字,如果成功,返回套接字描述符,如果失败,返回 -1。

  • 然后,listener 调用 bind 函数将套接字绑定到指定的端口。这允许 listener 接收发送到该端口的消息。

  • 在套接字绑定成功后,程序将监听来自 recvfrom 函数的传入消息。recvfrom 函数将阻塞程序,直到收到消息,将消息存储在 buf 缓冲区中,并将发送者的地址信息存储在 their_addr 中,最后将收到的消息和发送者的 IP 地址打印到屏幕上。

  • 最后,listener 关闭套接字并退出。

Client 如何工作?

Talker 通过设置套接字,向监听器发送消息,关闭套接字来完成工作。

  • 首先,talker 创建一个 hints 结构体,用于保存地址信息,例如协议族,套接字类型,以及端口号。随后调用 getaddrinfo() 函数获取地址信息,保存到 servinfo 链表中。

  • 然后,talker 遍历 servinfo 链表,直到找到可以使用的地址信息,然后通过 socket() 函数创建套接字,如果成功,返回套接字描述符,如果失败,返回 -1。

  • 在成功创建套接字后,talker 调用 sendto() 函数通过套接字发送消息,如果成功,返回发送的字节数,如果失败,返回 -1。

  • 最后,talker 关闭套接字。

服务端 demo

/*
** listener.c: a datagram sockets "server" demo - 数据报套接字监听端
** from Beej's Guide to Network Programming Using Internet Sockets
** https://beej.us/guide/bgnet/html/#datagram
*/

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>

#define MYPORT "4950" /* 监听端口号,即服务器端口号,客户端要连接的端口号 */
#define MAXBUFLEN 100 /* 最大数据包大小 */

/* 根据协议族,返回 sockaddr 结构体中的 IPv4 或 IPv6 地址 */
void *get_in_addr(struct sockaddr *sa)
{
	// 判断地址族
	if (sa->sa_family == AF_INET)
	{
		// 返回 IPv4 地址结构体
		return &(((struct sockaddr_in *)sa)->sin_addr);
	}
	// 返回 IPv6 地址结构体
	return &(((struct sockaddr_in6 *)sa)->sin6_addr);
}

/* 服务器端主函数 */
int main(void)
{
	int sockfd;							// 监听套接字
	struct addrinfo hints;				// hints 用于 getaddrinfo() 函数 - 用于指定地址信息
	struct addrinfo *servinfo;			// servinfo 用于储存 getaddrinfo() 函数返回的链表 - 用于保存地址信息
	struct addrinfo *p;					// 用于遍历 servinfo 链表
	int rv;								// 用于储存 getaddrinfo() 函数返回值 - 错误代码
	int numbytes;						// 用于储存 recvfrom() 函数返回值 - 接收到的字节数
	struct sockaddr_storage their_addr; // 用于储存客户端地址信息 - sockaddr_storage 可用于保存任何地址 IPv4 或 IPv6
	char buf[MAXBUFLEN];				// 用于保存接收到的数据 - 数据包
	socklen_t addr_len;					// 用于保存客户端地址信息的长度 - sockaddr_storage 结构体长度
	char s[INET6_ADDRSTRLEN];			// 用于保存客户端地址信息 - 地址字符串

	memset(&hints, 0, sizeof hints);
	hints.ai_family = AF_INET6;		// 使用 IPv6
	hints.ai_socktype = SOCK_DGRAM; // 使用 UDP 协议
	hints.ai_flags = AI_PASSIVE;	// 使用本机 IP

	// 构建地址信息 servinfo
	if ((rv = getaddrinfo(NULL, MYPORT, &hints, &servinfo)) != 0)
	{
		// gai_strerror 用于将错误代码转换为错误信息字符串
		fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rv));
		return 1;
	}

	// 遍历 servinfo 链表,直到找到可以使用的地址信息
	for (p = servinfo; p != NULL; p = p->ai_next)
	{
		// 创建监听套接字
		if ((sockfd = socket(p->ai_family, p->ai_socktype,
							 p->ai_protocol)) == -1)
		{
			perror("listener: socket");
			continue;
		}

		// 绑定套接字
		if (bind(sockfd, p->ai_addr, p->ai_addrlen) == -1)
		{
			close(sockfd);
			perror("listener: bind");
			continue;
		}

		break;
	}

	// 如果遍历完 servinfo 链表,仍然没有找到可以使用的地址信息,报错退出
	if (p == NULL)
	{
		fprintf(stderr, "listener: failed to bind socket\n");
		return 2;
	}

	// 释放 servinfo 链表
	freeaddrinfo(servinfo);

	printf("listener: waiting to recvfrom...\n");

	// addr_len 用于保存客户端地址信息的长度
	addr_len = sizeof their_addr;

	// 接收数据包并保存到 buf 中,保存的数据包的长度保存到 numbytes 中
	if ((numbytes = recvfrom(sockfd, buf, MAXBUFLEN - 1, 0,
							 (struct sockaddr *)&their_addr, &addr_len)) == -1)
	{
		perror("recvfrom");
		exit(1);
	}

	// inet_ntop 用于将客户端地址信息转换为字符串
	printf("listener: got packet from %s\n",
		   inet_ntop(their_addr.ss_family,
					 get_in_addr((struct sockaddr *)&their_addr),
					 s, sizeof s));
	printf("listener: packet - 然后,listener is %d bytes long\n", numbytes);

	// 将数据包的末尾设置为 '\0',以便于打印
	buf[numbytes] = '\0';

	// 打印数据包
	printf("listener: packet contains \"%s\"\n", buf);

	// 关闭套接字
	close(sockfd);

	return 0;
}

客户端 demo

/*
** talker.c -- a datagram "client" demo - 数据报套接字发送端
** from Beej's Guide to Network Programming Using Internet Sockets
** https://beej.us/guide/bgnet/html/#datagram
*/

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>

#define SERVERPORT "4950" // 服务器端口

int main(int argc, char *argv[])
{
	int sockfd;				   // 套接字描述符
	struct addrinfo hints;	   // hints 用来指定我们想要的地址类型
	struct addrinfo *servinfo; // servinfo 用来指向返回的地址链表
	struct addrinfo *p;		   // p 用来指向 servinfo 的每一个地址
	int rv;					   // rv 用来存储 getaddrinfo 的返回值,用于判断是否出错
	int numbytes;			   // numbytes 用来存储 sendto 的返回值, 记录发送的字节数

	// 检查参数个数
	if (argc != 3)
	{
		fprintf(stderr, "usage: talker hostname message\n");
		exit(1);
	}

	memset(&hints, 0, sizeof hints); // 初始化 hints 结构体
	hints.ai_family = AF_INET6;		 // 使用 IPv6 协议
	hints.ai_socktype = SOCK_DGRAM;	 // 使用 UDP 协议

	// getaddrinfo() 用来获取地址信息, 并将结果存储在 servinfo 中
	if ((rv = getaddrinfo(argv[1], SERVERPORT, &hints, &servinfo)) != 0)
	{
		fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rv));
		return 1;
	}

	// 循环遍历 servinfo 中的每一个地址, 并尝试创建套接字
	for (p = servinfo; p != NULL; p = p->ai_next)
	{
		// 创建套接字
		if ((sockfd = socket(p->ai_family, p->ai_socktype,
							 p->ai_protocol)) == -1)
		{
			perror("talker: socket");
			continue;
		}

		break;
	}

	// 如果 p 为 NULL, 则说明没有找到可用的地址
	if (p == NULL)
	{
		fprintf(stderr, "talker: failed to create socket\n");
		return 2;
	}

	// 发送数据, 并记录发送的字节数
	if ((numbytes = sendto(sockfd, a### Potential Issues
1. Error handling: Both programs do not have robust error handling. For example, in the client program, if sendto returns an error, it just prints an error message and exits, rather than trying to recover from the error.

2. Timeouts: If the server is not running or if the message gets lost in transit, the client program will hang indefinitely, waiting for a response that never comes. It might be a good idea to set a timeout for the sendto and recvfrom calls to avoid this.

3. Limited message size: The programs only allow for messages of up to 100 bytes. If you need to send larger messages, you will need to split them into smaller chunks and send them separately.

4. Lack of security: The programs do not use any form of encryption or authentication, so anyone on the network can potentially intercept or forge messages. If you need to secure your communication, you should consider using TLS or SSH to encrypt the messages.rgv[2], strlen(argv[2]), 0,
						   p->ai_addr, p->ai_addrlen)) == -1)
	{
		perror("talker: sendto");
		exit(1);
	}

	// 释放 servinfo
	freeaddrinfo(servinfo);

	// 打印发送的字节数
	printf("talker: sent %d bytes to %s\n", numbytes, argv[1]);
	
	// 关闭套接字
	close(sockfd);

	return 0;
}

程序编译

gcc -o talker talker.c
gcc -o listener listener.c

运行与输出

./listener      
# 在运行 talker 之前, listener 会一直等待     
listener: waiting to recvfrom...
# 在另一个终端运行 talker 程序之后, listener 会输出以下内容
listener: got packet from ::1
listener: packet is 5 bytes long
listener: packet contains "hello"

# 另一个终端运行 talker 程序
./talker localhost hello 
talker: sent 5 bytes to localhost

本文总结

本文的代码实现了一个简单的 UDP 客户端和服务器端程序, 客户端程序是 talker, 服务器端程序是 listener.

运行 listener 程序, 然后运行 talker 程序. listener 程序会等待来自 talker 程序的消息. 当 talker 程序运行时, 它会发送一个消息给 listener 程序, listener 程序会打印这个消息.

如果没有运行 listener 程序, talker 程序会发送消息, 然后退出. 这些消息会丢失, 因为没有程序来接收它们.

UDP 协议是一个简单的, 轻量级的协议, 它很容易使用. 但是, 它不提供任何错误检查或恢复, 并且不能保证将消息传递到目的地, 因此它不适合需要可靠通信的应用程序.

参考资料

  1. Beej’s Guide to Network Programming Using Internet Sockets

  2. UDP Server-Client implementation in C


最后修改于 2023-02-03


感谢您的支持 :D