前言:在前一篇博客中我们了解到计算机网络的发展背景,认识了什么是协议与OSI七层模型以及更加实用的TCP/IP五层模型。着重讲述了网络传输的基本流程并学习基于以太局域网的主机通信和不同局域网的流程细节,简单认识了IP地址与MAC地址。
今天我们正式的从编程的角度认识一下UDP协议。
目录
Socket 编程预备
理解源 IP 地址和目的 IP 地址
IP 在网络中,用来标识主机的唯一性。
注意:后面我们会讲 IP 的分类,后面会详细阐述 IP 的特点。
但是这里要思考一个问题:数据传输到主机是目的吗?不是的。因为数据是给人用的。比如:聊天是人在聊天,下载是人在下载,浏览网页是人在浏览?但是人是怎么看到聊天信息的呢?怎么执行下载任务呢?怎么浏览网页信息呢?通过启动的 qq,迅雷,浏览器。而启动的 qq,迅雷,浏览器都是进程。换句话说,进程是人在系统中的代表,只要把数据给进程,人就相当于就拿到了数据。
所以:数据传输到主机不是目的,而是手段。到达主机内部,在交给主机内的进程,
才是目的。但是系统中,同时会存在非常多的进程,当数据到达目标主机之后,怎么转发给目标
进程?这就要在网络的背景下,在系统中,标识主机的唯一性。
认识端口号
端口号(port)是传输层协议的内容
端口号是一个 2 字节 16 位的整数;
端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理;
IP 地址 + 端口号能够标识网络上的某一台主机的某一个进程;
一个端口号只能被一个进程占用.
端口号范围划分
0 - 1023: 知名端口号, HTTP, FTP, SSH 等这些广为使用的应用层协议, 他们的
端口号都是固定的.
1024 - 65535: 操作系统动态分配的端口号. 客户端程序的端口号, 就是由操作
系统从这个范围分配的.
理解 "端口号" 和 "进程 ID"
这里就有一个疑问,一个进程的唯一标识符不是pid吗?为什么又要创建一个端口号作为唯一标识符呢?如果实用pid作为一个进程的标识符与端口号相同,最主要的原因是将网络与系统进行强耦合了,如果有一天不使用pid,那么端口号也要变吗?这样的维护成本就很高。
在OS中有很多进程,有的进程不进行网络通信、只进行本地服务所以就不需要端口号。所以每一个进程都有pid,但是不是每一个进程都有端口号!!!
理解源端口号和目的端口号
传输层协议(TCP 和 UDP)的数据段中有两个端口号, 分别叫做源端口号和目的端口号.
就是在描述 "数据是谁发的, 要发给谁";
理解 socket
综上,IP 地址用来标识互联网中唯一的一台主机,port 用来标识该主机上唯一的
一个网络进程
IP+Port 就能表示互联网中唯一的一个进程
所以,通信的时候,本质是两个互联网进程代表人来进行通信,{srcIp,
srcPort,dstIp,dstPort}这样的 4 元组就能标识互联网中唯二的两个进程
所以,网络通信的本质,也是进程间通信
我们把 ip+port 叫做套接字 socket
传输层的典型代表
如果我们了解了系统,也了解了网络协议栈,我们就会清楚,传输层是属于内核的,那么我们要通过网络协议栈进行通信,必定调用的是传输层提供的系统调用,来进行的网络通信。
认识 TCP 协议
此处我们先对 TCP(Transmission Control Protocol 传输控制协议)有一个直观的认识;
后面我们再详细讨论 TCP 的一些细节问题.
传输层协议
有连接
可靠传输
面向字节流
认识 UDP 协议
此处我们也是对 UDP(User Datagram Protocol 用户数据报协议)有一个直观的认识; 后
面再详细讨论.
传输层协议
无连接
不可靠传输
面向数据报
因为我们暂时还没有深入了解 tcp、udp 协议,此处只做了解即可
网络字节序
我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分, 磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分, 网络数据流同样有大端小端之分. 那么如何定义网络数据流的地址呢?
• 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
• 接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
• 因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址.
• TCP/IP 协议规定,网络数据流应采用大端字节序,即低地址高字节.
• 不管这台主机是大端机还是小端机, 都会按照这个 TCP/IP 规定的网络字节序来发送/接收数据;
• 如果当前发送主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接发送即可;
为使网络程序具有可移植性,使同样的 C 代码在大端和小端计算机上编译后都能正常运
行,可以调用以下库函数做网络字节序和主机字节序的转换。这些函数名很好记,h 表示 host,n 表示 network,l 表示 32 位长整数,s 表示 16 位短整数。
例如 htonl 表示将 32 位的长整数从主机字节序转换为网络字节序,例如将 IP 地址转换后准备发送。如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;
如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。
socket 编程接口
socket 常见 API
// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)
int socket(int domain, int type, int protocol);参数说明:
domain (领域):
- 这个参数指定了通信的协议家族。最常见的值是
AF_INET
,表示 IPv4 网络协议。对于 IPv6,使用AF_INET6
。还有其他的协议家族,如AF_UNIX
用于 Unix 域套接字,AF_NETLINK
用于 Linux 内核与用户空间进程之间的通信等。type (类型):
- 这个参数定义了套接字的通信类型。常见的类型包括:
SOCK_STREAM
:提供基于字节流的、可靠的连接服务,通常用于 TCP。SOCK_DGRAM
:提供数据报服务,如 UDP。常用的就这两个宏类型。
protocol (协议):
- 这个参数指定了使用的特定协议。对于大多数用途,可以设置为 0,表示选择默认协议。例如,对于
SOCK_STREAM
类型,如果 domain 是AF_INET
,那么默认协议是 TCP(IPPROTO_TCP
)。对于SOCK_DGRAM
类型,如果 domain 是AF_INET
,那么默认协议是 UDP(IPPROTO_UDP
)。返回值:
- 成功时,
socket
函数返回一个新的文件描述符,用于标识创建的套接字。- 失败时,返回
-1
并设置全局变量errno
以指示错误。// 绑定端口号 (TCP/UDP, 服务器)
int bind(int socket, const struct sockaddr *address,socklen_t address_len);参数说明:
sockfd (套接字文件描述符):
- 这是由
socket
函数创建的套接字的文件描述符。addr (地址结构体指针):
- 这是一个指向
sockaddr
结构体的指针,该结构体包含了套接字要绑定的地址信息。对于 IPv4,这通常是sockaddr_in
结构体;对于 IPv6,则是sockaddr_in6
结构体。结构体中包含了网络地址、端口号等信息。addrlen (地址长度):
- 这是
addr
参数指向的地址结构体的大小,单位为字节。这个值应该被正确设置,以避免潜在的安全隐患。// 开始监听 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);
socket编程是有不同种类的,有的是专门用来本地通信的,有的是专门用来进行跨网络通信的还有的使用来进行网络管理的。如果本地socket通信、网络通信 以及网络管理的API各不相同成本就太高了,所以将这三种通信类型都做了统一的接口处理。
而这写接口都使用C语言写的,而C语言是面向过程的语言,所以就没有多态的概念,所以肯定就会有统一的类型struct sockaddr!
sockaddr 结构
socket API 是一层抽象的网络编程接口,适用于各种底层网络协议,如 IPv4、IPv6,以及后面要讲的 UNIX Domain Socket. 然而, 各种网络协议的地址格式并不相同.
• IPv4 和 IPv6 的地址格式定义在 netinet/in.h 中,IPv4 地址用 sockaddr_in 结构体表示,包括 16 位地址类型, 16 位端口号和 32 位 IP 地址.
• IPv4、IPv6 地址类型分别定义为常数 AF_INET、AF_INET6. 这样,只要取得某种 sockaddr 结构体的首地址,不需要知道具体是哪种类型的 sockaddr 结构体,就可以根据地址类型字段确定结构体中的内容.
• socket API 可以都用 struct sockaddr *类型表示, 在使用的时候需要强制转化成sockaddr_in; 这样的好处是程序的通用性, 可以接收 IPv4, IPv6, 以及 UNIX DomainSocket 各种类型的 sockaddr 结构体指针做为参数;
我们学习的是网络通信,所以肯定就是使用AF_INET类型,传入struct socket_in结构体。
sockaddr 结构
sockaddr_in 结构
虽然 socket api 的接口是 sockaddr, 但是我们真正在基于 IPv4 编程时, 使用的数据结构是 sockaddr_in; 这个结构里主要有三部分信息: 地址类型, 端口号, IP 地址.
in_addr 结构
in_addr 用来表示一个 IPv4 的 IP 地址. 其实就是一个 32 位的整数;
地址转换函数
本次只介绍基于 IPv4 的 socket 网络编程,sockaddr_in 中的成员 struct in_addr sin_addr 表示 32 位 的 IP 地址。
但是我们通常用点分十进制的字符串表示 IP 地址,以下函数可以在字符串表示 和in_addr 表示之间转换;
字符串转 in_addr 的函数:
我们使用的inet_addr函数,不仅可以将IP转化位in_addr_t类型,还可以直接将IP转换为网络序列,不需要再次调用专门的主机字节序转网络字节序函数。
in_addr 转字符串的函数:
其中 inet_pton 和 inet_ntop 不仅可以转换 IPv4 的 in_addr,还可以转换 IPv6 的
in6_addr,因此函数接口是 void *addrptr。
代码示例:
关于 inet_ntoa
net_ntoa 这个函数返回了一个 char*, 很显然是这个函数自己在内部为我们申请了一块内存来保存 ip 的结果. 那么是否需要调用者手动释放呢?
man 手册上说, inet_ntoa 函数, 是把这个返回结果放到了静态存储区. 这个时候不需要
我们手动进行释放.
在 APUE 中, 明确提出 inet_ntoa 不是线程安全的函数,所以在多线程环境下, 推荐使用 inet_ntop, 这个函数由调用者提供一个缓冲区保存结果, 可以规避线程安全问题;
UDP 网络编程
echo server服务
简单的回显服务器和客户端代码
我们应该创建一个struct sockaddr_in 的结构体将其结构体中的每个字段都填入。
当我们进行网络通信时,我们必须创建一个服务端与一个客户端,而通信就必须知道是谁发给我的和我给谁发的。所以我们就必须知道自己的IP与端口号与对方的IP和端口号。这样我才可以给别人发消息,而别人也有条件给我回消息,这样才是通信!
UdpServer.hpp
#pragma once
#include <iostream>
#include <string>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include "Log.hpp"
#include "InetAddr.hpp"
// echo server -> client -> server
enum
{
SOCKET_ERROR = 1,
BIND_ERROR,
USAGE_ERROR
};
const static int defaultfd = -1;
class UdpServer
{
public:
UdpServer(uint16_t port) : _sockfd(defaultfd), _port(port), _isrunning(false)
{
}
void InitServer()
{
// 1. 创建udp socket 套接字 --- 必须要做的
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
LOG(FATAL, "socket error, %s, %d\n", strerror(errno), errno);
exit(SOCKET_ERROR);
}
LOG(INFO, "socket create success, sockfd: %d\n", _sockfd);
// 2.0 填充sockaddr_in结构
struct sockaddr_in local; // struct sockaddr_in 系统提供的数据类型。local是变量,用户栈上开辟空间。int a = 100; a = 20;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port); // port要经过网络传输给对面,先到网络,_port:主机序列-> 主机序列,转成网络序列
// a. 字符串风格的点分十进制的IP地址转成 4 字节IP
// b. 主机序列,转成网络序列
// in_addr_t inet_addr(const char *cp) -> 同时完成 a & b
// local.sin_addr.s_addr = inet_addr(_ip.c_str()); // "192.168.3.1" -> 字符串风格的点分十进制的IP地址 -> 4字节IP
local.sin_addr.s_addr = INADDR_ANY; // htonl(INADDR_ANY);
// 2.1 bind sockfd和网络信息(IP(?) + Port)
int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local));
if (n < 0)
{
LOG(FATAL, "bind error, %s, %d\n", strerror(errno), errno);
exit(BIND_ERROR);
}
LOG(INFO, "socket bind success\n");
}
void Start()
{
// 一直运行,直到管理者不想运行了, 服务器都是死循环
// UDP是面向数据报的协议
_isrunning = true;
while (true)
{
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer); // 必须初始化成为sizeof(peer)
// 1. 我们要让server先收数据
ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
if (n > 0)
{
buffer[n] = 0;
InetAddr addr(peer);
LOG(DEBUG, "get message from [%s:%d]: %s\n", addr.Ip().c_str(), addr.Port(), buffer);
// 2. 我们要将server收到的数据,发回给对方
sendto(_sockfd, buffer, strlen(buffer), 0, (struct sockaddr *)&peer, len);
}
}
_isrunning = false;
}
~UdpServer()
{
}
private:
int _sockfd;
// std::string _ip; // 暂时这样写,这个地方不是必须的.TODO
uint16_t _port; // 服务器所用的端口号
bool _isrunning;
};
在服务器端我们将IP设为0这样就可以接受所有IP,这样的好处是不会受服务器多IP的影响。如果一个服务器有两个IP(110.110.11.1和1.1.1.1),而我的IP设定一个固定内容110.110.11.1。我们客户端访问110.110.11.1 可以通过IP与端口号找到对应进程,但是1.1.1.1也是其主机的IP却不能访问对应的进程,这样做不合理!所以我们使用0作为IP能接收所有发过来的IP。
在网络编程中,当一个进程需要绑定一个网络端口以进行通信时,可以使用INADDR_ANY 作为 IP 地址参数。这样做意味着该端口可以接受来自任何 IP 地址的连接请求,无论是本地主机还是远程主机。例如,如果服务器有多个网卡(每个网卡上有不同的 IP 地址),使用 INADDR_ANY 可以省去确定数据是从服务器上具体哪个网卡/IP 地址上面获取的。0的大小端是一样的,我们可以不使用htonl函数进行网络字节序的转换。
而我们现在实现的业务逻辑就是从客户端发送的消息进行接受,然后再发送回给客户端。所以我们使用recvfrom函数进行接收,sendto函数进行发送对应的消息。
recvfrom 函数:
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
参数:
- sockfd:这是 socket 文件描述符,用于接收数据。
- buf:指向接收数据的缓冲区的指针。
- len:缓冲区的长度(字节为单位)。
- flags:一些控制标志位,通常设置为 0。也可以设置特定的标志位。
- src_addr:如果提供了这个参数,它将被填充为发送方的地址信息。
- addrlen:输入时,指向变量的指针,该变量包含
src_addr
的大小。函数返回时,它将被设置为填充的src_addr
结构的实际长度。返回值:
成功时,返回接收的字节数。
失败时,返回
-1
并设置全局变量errno
以指示错误。
sendto 函数:
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
参数:
- sockfd:这是 socket 文件描述符,由之前的
socket
调用返回。- buf:指向要发送数据的缓冲区的指针。
- len:要发送的数据长度(字节为单位)。
- flags:一些控制标志位,通常设置为 0。也可以设置特定的标志位,如
MSG_DONTROUTE
。- dest_addr:指向
sockaddr
结构的指针,包含了目标地址的信息,包括网络地址和端口号。- addrlen:
dest_addr
指向的地址结构的长度(字节为单位)。返回值:
成功时,返回发送的字节数。
失败时,返回
-1
并设置全局变量errno
以指示错误。
在udpserver.hpp中我们将我们需要的套接字进行创建设置和绑定,在主函数中我们使用智能指针进行控制即可。
maic.cc
#include <iostream>
#include <memory>
#include "UdpServer.hpp"
void Usage(std::string proc)
{
std::cout << "Usage:\n\t" << proc << " local_port\n" << std::endl;
}
// ./udpserver port
// 云服务器的port默认都是禁止访问的。云服务器放开端口8080 ~ 8085
int main(int argc, char *argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(USAGE_ERROR);
}
EnableScreen();
// std::string ip = argv[1]; // TODO
uint16_t port = std::stoi(argv[1]);
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port); // C++14
usvr->InitServer();
usvr->Start();
return 0;
}
而客户端我们使用面向过程的思维进行创建
udpclient.cc
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
void Usage(std::string proc)
{
std::cout << "Usage:\n\t" << proc << " serverip serverport\n"
<< std::endl;
}
// ./udpclient serverip serverport
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
// 1. 创建socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd < 0)
{
std::cerr << "socket error" << std::endl;
}
// 2. client要不要bind?一定要,client也要有自己的IP和PORT。要不要显式[和server一样用bind函数]的bind?不能!不建议!!
// a. 如何bind呢?udp client首次发送数据的时候,OS会自己自动随机的给client进行bind --- 为什么?防止client port冲突。要bind,必然要和port关联!
// b. 什么时候bind呢?首次发送数据的时候
// 构建目标主机的socket信息
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
server.sin_addr.s_addr = inet_addr(serverip.c_str());
std::string message;
// 2. 直接通信即可
while(true)
{
std::cout << "Please Enter# ";
std::getline(std::cin, message);
sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&server, sizeof(server));
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
char buffer[1024];
ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);
if(n > 0)
{
buffer[n] = 0;
std::cout << "server echo# " << buffer << std::endl;
}
}
return 0;
}
客户端需要进行bind绑定,但不是显示的bind,而是让OS自己随机绑定没有被占用的端口,client 会在首次发送数据的时候会自动进行bind。为了防止客户端的端口号冲突!!
这样我们就可以实现网络通信了,我们服务器只需要输入端口号,而客户端输入服务器的IP以及对应服务器的端口号即可。
这里我们只有一台机器,我们可以使用127.0.0.1回环地址进行本地通信进行测试,也可以使用VS进行windows与Linux通信,而Windows的socket套接字与Linux稍有不同,我们可以试一下。
注意:使用云服务器的端口号可能未开通,需要在对应的云平台的安全组进行开通即可。
Windows版客户端
#define _CRT_SECURE_NO_WARNINGS 1
#include <iostream>
#include <cstdio>
#include <thread>
#include <string>
#include <cstdlib>
#include <WinSock2.h>
#include <Windows.h>
#pragma warning(disable : 4996)
#pragma comment(lib, "ws2_32.lib")
std::string serverip = ""; // 填写你的云服务器 ip
uint16_t serverport = 8888; // 填写你的云服务开放的端口号
int main()
{
WSADATA wsd;
WSAStartup(MAKEWORD(2, 2), &wsd);
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport); //?
server.sin_addr.s_addr = inet_addr(serverip.c_str());
SOCKET sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd == SOCKET_ERROR)
{
std::cout << "socker error" << std::endl;
return 1;
}
std::string message;
char buffer[1024];
while (true)
{
std::cout << "Please Enter@ ";
std::getline(std::cin, message);
if (message.empty()) continue;
sendto(sockfd, message.c_str(), (int)message.size(), 0,
(struct sockaddr*)&server, sizeof(server));
struct sockaddr_in temp;
int len = sizeof(temp);
int s = recvfrom(sockfd, buffer, 1023, 0, (struct sockaddr
*)&temp, &len);
if (s > 0)
{
buffer[s] = 0;
std::cout << buffer << std::endl;
}
}
closesocket(sockfd);
WSACleanup();
return 0;
}
WinSock2.h 是 Windows Sockets API(应用程序接口)的头文件,用于在Windows 平台上进行网络编程。它包含了 Windows Sockets 2(Winsock2)所需的数据类型、函数声明和结构定义,使得开发者能够创建和使用套接字(sockets)进行网络通信。
在编写使用 Winsock2 的程序时,需要在源文件中包含 WinSock2.h 头文件。这样,编译器就能够识别并理解 Winsock2 中定义的数据类型和函数,从而能够正确地编译和链接网络相关的代码。
此外,与 WinSock2.h 头文件相对应的是 ws2_32.lib 库文件。在链接阶段,需要将这个库文件链接到程序中,以确保运行时能够找到并调用 Winsock2 API 中实现的函数。
在 WinSock2.h 中定义了一些重要的数据类型和函数,如:
WSADATA:保存初始化 Winsock 库时返回的信息。
SOCKET:表示一个套接字描述符,用于在网络中唯一标识一个套接字。
sockaddr_in:IPv4 地址结构体,用于存储 IP 地址和端口号等信息。
socket():创建一个新的套接字。
bind():将套接字与本地地址绑定。
listen():将套接字设置为监听模式,等待客户端的连接请求。
accept():接受客户端的连接请求,并返回一个新的套接字描述符,用于与客户端进行通信。
WSAStartup 函数是 Windows Sockets API 的初始化函数,它用于初始化Winsock 库。该函数在应用程序或 DLL 调用任何 Windows 套接字函数之前必须首先执行,它扮演着初始化的角色。
以下是 WSAStartup 函数的一些关键点:
它接受两个参数:wVersionRequested 和 lpWSAData。wVersionRequested 用于指定所请求的 Winsock 版本,通常使用 MAKEWORD(major, minor)宏,其中major 和 minor 分别表示请求的主版本号和次版本号。lpWSAData 是一个指向WSADATA 结构的指针,用于接收初始化信息。
如果函数调用成功,它会返回 0;否则,返回错误代码。
WSAStartup 函数的主要作用是向操作系统说明我们将使用哪个版本的 Winsock库,从而使得该库文件能与当前的操作系统协同工作。成功调用该函数后,Winsock 库的状态会被初始化,应用程序就可以使用 Winsock 提供的一系列套接字服务,如地址家族识别、地址转换、名字查询和连接控制等。这些服务使得应用程序可以与底层的网络协议栈进行交互,实现网络通信。
在调用 WSAStartup 函数后,如果应用程序完成了对请求的 Socket 库的使用,应调用 WSACleanup 函数来解除与 Socket 库的绑定并释放所占用的系统资源。
注意:一定要开放云服务器对应的端口号,在你的阿里云或者腾讯云或者华为云
的网站后台中开放。
我们还可以使用UDP通信干很多事情,比如词典翻译,聊天室,在线下棋等等。只要将内容进行处理封装,将信息通过网络通信即可。我的代码仓库中还有几个使用UDP完成的场景,有需要的可以查看,感谢大家观看。