网络Socket编程基于UDP协议模拟简易网络通信
一、预备知识
网络编程(Network Programming)是指编写程序来实现计算机网络之间的通信。这通常涉及到使用套接字(sockets)来建立连接、发送和接收数据。
(一)套接字
套接字(Socket):套接字是网络编程中的核心概念,它是一种抽象的软件接口,用于在不同计算机之间建立通信。套接字可以分为两种类型:
-
流套接字:基于TCP协议,提供可靠、面向连接的服务,数据以字节流的形式传输,适用于需要保证数据完整性和顺序的应用场景,如网页浏览、文件传输等。
-
数据报套接字:基于UDP协议,提供无连接、不可靠的服务,数据以数据报的形式传输,适用于对实时性要求较高但对数据完整性要求不高的场景,如视频会议、在线游戏等。
(二)IP地址
IP 地址是用于标识网络中设备的唯一地址。它类似于现实生活中的门牌号码,用于在网络中定位设备。在全球互联网中,IP 地址是跨网络通信的基础。通过 IP 地址,数据包可以在复杂的网络拓扑结构中找到目标主机。
IP 地址分为两种主要版本:
-
IPv4:32位地址,通常以点分十进制形式表示(如192.168.1.1)。由于地址数量有限,IPv4地址逐渐耗尽。
-
IPv6:128位地址,以冒号分隔的十六进制形式表示(如2001:0db8:85a3:0000:0000:8a2e:0370:7334)。IPv6提供了更多的地址空间,解决了IPv4地址不足的问题。
(三)端口号
端口号是一个16位的数字,用于标识主机上的特定服务或应用程序。它类似于一个“房间号”,用于在目标主机上找到最终目的地。
端口号的范围:
-
0-1023:系统端口,通常被系统或常用服务使用(如HTTP服务使用80端口,HTTPS服务使用443端口)。
-
1024-49151:注册端口,可以被用户或应用程序使用。
-
49152-65535:动态端口,通常由操作系统动态分配给临时服务。
IP 地址与端口号的关系
在网络通信中,IP 地址和端口号共同构成了一个完整的通信地址。IP 地址用于定位目标主机,而端口号用于在目标主机上找到特定的服务或应用程序。
例如:
当你访问一个网站时,你的浏览器会向目标服务器的IP地址发送一个HTTP请求,目标端口号为80(HTTP服务的默认端口)。
如果目标服务器的IP地址是192.168.1.100,目标端口号是80,那么完整的通信地址就是192.168.1.100:80。
(四)端口号与进程PID
端口号和进程PID都是用于标识进程的,但它们的作用范围不同:
-
端口号是网络层面的标识符,用于在网络通信中标识进程。
-
进程PID是操作系统层面的标识符,用于在操作系统内部标识进程。
为什么不能直接使用PID进行网络通信?
-
操作系统差异:不同操作系统的进程管理方式可能不同,直接使用PID会导致网络标准与操作系统强耦合。
-
网络标准统一性:网络通信需要一个统一的标准,而端口号提供了一个独立于操作系统的解决方案。
-
效率问题:并不是所有进程都需要进行网络通信,如果将端口号和PID混用,会影响网络管理的效率。
所以综上所述,网络中的 端口号 需要通过一种全新的方式实现,也就是一个2
字节的整数 port
,进程 A
运行后,可以给它绑定 端口号 N
,在进行网络通信时,根据 端口号 N
来确定信息是交给主机Z的进程 A
的(IP + Port 可以标识公网环境下,唯一的网络进程):
网络传输中的必备信息组 [目的IP 源 IP && 目的 Port 源 Port]
- 目的 IP:需要把信息发送到哪一台主机
- 源 IP:信息从哪台主机中发出
- 目的 Port:将信息交给哪一个进程
- 源 Port:信息从哪一个进程中发出
一个进程可以绑定多个 端口号 吗?一个 端口号 可以被多个进程绑定吗?
- 端口号 的作用是配合
IP
地址标识网络世界中进程的唯一性,如果一个进程绑定多个 端口号,依然可以保证唯一性(因为无论使用哪个 端口号,信息始终只会交给一个进程);但如果一个 端口号 被多个进程绑定了,在信息递达时,是无法分辨该信息的最终目的进程的,存在二义性。主机(操作系统)是如何根据 端口号 定位具体进程的?
创建一张哈希表,维护 <端口号, 进程
PID
> 之间的映射关系,当信息通过网络传输到目标主机时,操作系统可以根据其中的 [目的Port
],直接定位到具体的进程PID
,然后进行通信。
(五)传输层协议
TCP 协议
TCP(传输控制协议) 是一种面向连接的、可靠的、基于字节流的传输层通信协议。它的主要特点包括:
面向连接:
-
在数据传输之前,TCP 协议需要在客户端和服务器之间建立一个可靠的连接。这个过程通常称为“三次握手”(SYN、SYN-ACK、ACK)。
-
连接建立后,数据可以在两个方向上可靠地传输。
-
当数据传输完成后,TCP 协议会通过“四次挥手”(FIN、ACK、FIN、ACK)来关闭连接。
可靠传输:
-
TCP 协议通过一系列机制(如确认应答、超时重传、滑动窗口等)确保数据能够可靠地传输到对端。
-
如果发送的数据包在传输过程中丢失或损坏,TCP 协议会自动重传这些数据包,直到对端确认收到为止。
-
这种机制使得 TCP 协议非常适合对数据完整性要求较高的应用,如文件传输、数据库通信等。
面向字节流:
-
TCP 协议将数据视为一个连续的字节流,不保留数据的边界信息。
-
这意味着发送方可以连续不断地发送数据,而接收方需要根据应用层协议来解析数据的边界。
-
例如,在 HTTP 协议中,客户端发送请求后,服务器会返回一个连续的字节流,客户端需要根据 HTTP 协议来解析响应头和响应体。
UDP 协议
UDP(用户数据报协议) 是一种无连接的、不可靠的、面向数据报的传输层通信协议。它的主要特点包括:
无连接:
-
UDP 协议不需要在数据传输之前建立连接,发送方可以直接发送数据报。
-
这种无连接的特性使得 UDP 协议的开销较小,适合对实时性要求较高的应用,如视频会议、在线游戏等。
不可靠传输:
-
UDP 协议不保证数据能够可靠地传输到对端。如果数据在传输过程中丢失或损坏,UDP 协议不会自动重传这些数据。
-
这种不可靠的特性使得 UDP 协议的传输速度较快,但数据完整性无法保证。
-
适合对实时性要求较高,但对数据完整性要求不高的应用,如实时音视频传输、在线游戏等。
面向数据报:
-
UDP 协议将数据封装成一个个独立的数据报,每个数据报都有自己的头部信息,包含源地址、目的地址等。
-
数据报在传输过程中可能会丢失、重复或乱序到达,接收方需要根据应用层协议来处理这些问题。
-
例如,在实时音视频传输中,即使某个数据报丢失,也不会影响后续数据报的传输,接收方可以根据时间戳等信息来处理乱序或丢失的数据。
(六)网络字节序
高权值位和低权值位
在计算机中,数据通常以字节(8位)为单位存储。
对于一个32位的整数(如0x11223344
),可以将其分为4个字节:
-
11
(最高权值位,也称为最高有效字节,MSB) -
22
-
33
-
44
(最低权值位,也称为最低有效字节,LSB)
大端字节序
定义:将高权值字节存储在低地址处,低权值字节存储在高地址处。
示例:对于0x11223344
,在内存中的存储顺序为:
-
地址0:
11
-
地址1:
22
-
地址2:
33
-
地址3:
44
小端字节序
定义:将低权值字节存储在低地址处,高权值字节存储在高地址处。
示例:对于0x11223344
,在内存中的存储顺序为:
-
地址0:
44
-
地址1:
33
-
地址2:
22
-
地址3:
11
TCP/IP
协议规定:网络中传输的数据,统一采用大端存储方案,也就是网络字节序, 现在大端/小端称为 主机字节序。发送数据时,将 主机字节序 转化为 网络字节序,接收到数据后,再转回 主机字节序 就好了,完美解决不同机器中的大小端差异
字节序转换函数
函数 | 返回值 | 参数 | 备注 |
uint32_t htonl(uint32_t hostlong); | 转换后的32位网络字节序整数。 | uint32_t hostlong ,表示主机字节序的32位整数。 | 在发送32位数据(如IP地址、端口号等)之前,将其转换为网络字节序。 |
uint32_t ntohl(uint32_t netlong); | 转换后的32位主机字节序整数。 | uint32_t netlong ,表示网络字节序的32位整数。 | 在接收32位数据(如IP地址、端口号等)之后,将其转换为主机字节序。 |
uint16_t htons(uint16_t hostshort); | 转换后的16位网络字节序整数。 | uint16_t hostshort ,表示主机字节序的16位整数。 | 在发送16位数据(如端口号等)之前,将其转换为网络字节序。 |
uint16_t ntohs(uint16_t netshort); | 将16位网络字节序转换为主机字节序。 | uint16_t netshort ,表示网络字节序的16位整数。 | 在接收16位数据(如端口号等)之后,将其转换为主机字节序。 |
网络协议中不同字段有严格的位数定义(因此32位的是操作IP地址,而16位操作端口号):
IPv4地址:32位(4字节)
TCP/UDP端口号:16位(2字节)
二、socket 套接字
(一)socket 常见API
Socket(套接字)提供了下面这一批常用接口,用于实现网络通信
#include <sys/types.h>
#include <sys/socket.h>
// 创建socket文件描述符(TCP/UDP 服务器/客户端)
int socket(int domain, int type, int protocol);
// 绑定端口号(TCP/UDP 服务器)
int bind(int socket, const struct sockaddr* address, socklen_t address_len);
// 开始监听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);
可以看到在这一批 API 中,频繁出现了一个结构体类型 sockaddr,该结构体支持网络通信,也支持本地通信。socket 就是用于描述 sockaddr结构体的字段,复用了文件描述符的解决方案。
(二)sockaddr 结构体
socket 这套网络通信标准隶属于 POSIX 通信标准,该标准的设计初衷就是为了实现可移植性,程序可以直接在使用该标准的不同机器中运行,但有的机器使用的是网络通信,有的则是使用本地通信,socket 套接字为了能同时兼顾这两种通信方式,提供了 sockaddr结构体。
由 sockaddr 结构体衍生出了两个不同的结构体:sockaddr_in 网络套接字、sockaddr_un 域间套接字,前者用于网络通信,后者用于本地通信。
- 可以根据 16 位地址类型,判断是网络通信,还是本地通信
- 在进行网络通信时,需要提供 IP 地址、端口号 等网络通信必备项,本地通信只需要提供一个路径名,通过文件读写的方式进行通信(类似于命名管道)
struct sockaddr {
sa_family_t sa_family; // 地址族
char sa_data[14]; // 地址数据,长度为14字节
};
typedef uint32_t in_addr_t;
struct in_addr
{
in_addr_t s_addr;
};
struct sockaddr_in {
sa_family_t sin_family; // 地址族,通常是 AF_INET
in_port_t sin_port; // 端口号
struct in_addr sin_addr; // IP地址
unsigned char sin_zero[8]; // 填充,确保结构体大小为16字节
};
struct sockaddr_un {
sa_family_t sun_family; // 地址族,通常是 AF_UNIX
char sun_path[108]; // 路径名
};
socket 提供的接口参数为 sockaddr*,我们既可以传入 &sockaddr_in 进行网络通信,也可以传入 &sockaddr_un 进行本地通信,传参时将参数进行强制类型转换即可,这是使用C语言实现多态 的典型做法,确保该标准的通用性。
地址类型(sa_family_t)告诉操作系统和应用程序应该使用哪种结构体来解析地址。
- 如果地址类型是 AF_INET,则使用 IPv4 协议。
- 如果地址类型是 AF_INET6,则使用 IPv6 协议。
- 如果地址类型是 AF_UNIX,则使用 Unix 域套接字协议。
为什么不将参数设置为 void* ?
因为在该标准设计时,C语言还不支持 void* 这种类型,为了确保向前兼容性,即便后续支持后也不能进行修改了。
三、基于UDP模拟实现网络通信
(一)UDP网络通信函数
函数 | 返回值 | 参数 | 备注 |
// 创建socket文件描述符(TCP/UDP 服务器/客户端) int socket(int domain, int type, int protocol); | 成功:返回一个非负整数,即新创建的套接字文件描述符。 失败:返回-1,并设置 |
| 简单来说:
备注:
|
// 绑定端口号(TCP/UDP 服务器) int bind(int socket, const struct sockaddr* address, socklen_t address_len); | 成功:返回0。 失败:返回-1,并设置 |
| 备注:
|
// 读取信息(TCP/UDP 服务器/客户端) ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, | 成功:返回0。 失败:返回-1,并设置 |
| |
// 发送信息 ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen); | 成功:返回0。 失败:返回-1,并设置 |
| |
//将一块内存区域清零的函数 void bzero(void* s, size_t n); |
| bzero 的作用是将指定内存区域的前 n 个字节设置为 0。它通常用于初始化结构体或数组,确保它们的初始值为零。 | |
//将网络字节序的 IP 地址转换为点分十进制字符串的函数 char* inet_ntoa(struct in_addr in); | 返回一个指向点分十进制字符串的指针 |
| 备注: 他的成员类型为struct in_addr,也就是: typedef uint32_t in_addr_t; |
//将点分十进制的 IP 地址字符串转换为网络字节序的 32 位整数的函数 in_addr_t inet_addr(const char* cp); | 返回一个 32 位的网络字节序整数,表示 IP 地址。如果输入的字符串无效,则返回 | cp :指向点分十进制的 IP 地址字符串(例如 "192.168.1.1" ) | inet_addr 的主要功能是将一个点分十进制的 IP 地址字符串(例如 "192.168.1.1" )转换为一个 32 位的网络字节序整数。这种格式的整数可以直接用于网络编程中的 struct in_addr 类型。 |
(二)实现可以进行业务处理的客户-服务端
1.服务端
#pragma once
#include<iostream>
#include<cerrno>
#include<cstring>
#include<functional>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
namespace y_server
{
enum
{
SOCKET_ERR=1,
BIND_ERR
};
const static uint16_t default_port=8080;
using func_t = std::function<std::string(std::string)>;
class UdpServer
{
public:
//构造
UdpServer(const func_t func,uint16_t port=default_port)
:_port(port),_service(func)
{
std::cout << "server addr: " << _port << std::endl;
}
//析构
~UdpServer(){}
//初始化
void Init()
{
//创建套接字
_sock=socket(AF_INET,SOCK_DGRAM,0);
if(_sock<0)
{
std::cerr << "create socket error: " <<std::strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
std::cout << "create socket success: " << _sock << std::endl;
//给服务器绑定IP地址和端口号
struct sockaddr_in local;
bzero(&local,sizeof(local));//置为0
//填充结构体
local.sin_family=AF_INET;
local.sin_port=htons(_port);
local.sin_addr.s_addr=INADDR_ANY;
//绑定IP地址和端口号
if(bind(_sock,(struct sockaddr*)&local,sizeof(local))<0)
{
std::cerr << "bind socket error: " << strerror(errno) << std::endl;
exit(BIND_ERR);
}
std::cout << "bind socket success: " << _sock << std::endl;
}
//启动
void Start()
{
char buff[1024];
while(true)
{
//接收信息
struct sockaddr_in peer;
socklen_t len=sizeof(peer);
ssize_t n=recvfrom(_sock,buff,sizeof(buff)-1,0,(struct sockaddr*)&peer,&len);
if(n>0) buff[n]='\0';
else continue;
//处理数据
std::string clientIP=inet_ntoa(peer.sin_addr);
uint16_t clientPort=ntohs(peer.sin_port);
printf("Server got messages from [%s:%d]$ %s\n", clientIP.c_str(), clientPort, buff);
//获取业务处理后结果
std::string respond=_service(buff);
//回响给客户端
n=sendto(_sock,respond.c_str(),respond.size(),0,(struct sockaddr*)&peer,len);
if(n==-1) std::cout << "Send messages fali: " << strerror(errno) << std::endl;
}
}
private:
int _sock;//套接字
uint16_t _port;
func_t _service;//业务处理函数
};
}
1.整体架构
- 这是一个基于UDP协议的服务器类
UdpServer
,用于接收客户端的请求,处理请求,并将处理结果发送回客户端。 - 使用了面向对象的设计思想,将UDP服务器的功能封装在一个类中,便于使用和扩展。
2.类成员变量
_sock
:表示UDP套接字,用于网络通信。_port
:服务器监听的端口号,默认值为8080。_service
:一个函数对象(std::function
),用于处理客户端发送的数据。这种设计使得服务器的业务逻辑可以灵活地通过传入不同的函数来实现。
3.构造函数
- 接收一个业务处理函数
_service
和一个端口号_port
(默认为8080)。 - 在构造函数中打印服务器监听的端口号,方便调试。
4.初始化(Init
方法)
创建套接字:
- 使用
socket
函数创建一个UDP套接字(SOCK_DGRAM
)。 - 如果创建失败,打印错误信息并退出程序。
绑定地址和端口:
- 使用
sockaddr_in
结构体填充服务器的IP地址和端口号,IP地址使用INADDR_ANY说明可以绑定任意IP地址。 - 使用
bind
函数将套接字绑定到指定的地址和端口。 - 如果绑定失败,打印错误信息并退出程序。
5.启动(Start
方法)
进入一个无限循环,持续接收客户端的请求。
接收数据:
- 使用
recvfrom
函数接收来自客户端的数据。 recvfrom
会返回发送方的地址信息(IP和端口),存储在peer
中。- 如果接收成功,将接收到的数据存储在
buff
中。
处理数据:
- 使用
_service
函数对象对客户端发送的数据进行处理。 _service
函数的输入是客户端发送的字符串,输出是处理后的结果。
发送响应:
- 使用
sendto
函数将处理后的结果发送回客户端。 - 如果发送失败,打印错误信息。
6.业务处理函数(_service
)
_service
是一个函数对象,其类型为 std::function<std::string(std::string)>
。这种设计使得服务器的业务逻辑可以灵活地通过传入不同的函数来实现。例如,可以传入一个简单的字符串反转函数,或者一个更复杂的业务逻辑函数。
为什么需要清空结构体?
在 C 语言中,定义一个结构体变量时,其内容是未初始化的,这意味着结构体中的字段可能包含随机的垃圾值。如果不清空结构体,直接使用它可能会导致以下问题:
(1) 未初始化字段的随机值
结构体中的字段可能包含随机的值,这些值可能是之前内存中残留的数据。如果这些字段被误用,可能会导致程序行为异常或崩溃。
(2) 填充字段的影响
struct sockaddr_in 中有填充字段(如 sin_zero),这些字段的存在是为了确保结构体的大小与 struct sockaddr 一致。如果不清空结构体,填充字段中的随机值可能会影响程序的正确性。
(3) 兼容性和可移植性
清空结构体可以确保程序在不同平台和编译器下的行为一致。某些平台或编译器可能会对未初始化的结构体字段进行特殊处理,清空结构体可以避免这些潜在的问题。
2.服务端业务处理
#include<memory>
#include<iostream>
#include<stdio.h>
#include<vector>
#include"udp_server.hpp"
std::string transactionString(const std::string& resquest)
{
std::string result(resquest);
for(auto &r : result)
{
if(isupper(r))
r += 32;
}
return result;
}
bool checkSafe(const std::string& comm)
{
std::vector<std::string> unsafeComms{"kill", "mv", "rm", "while :; do", "shutdown"};
// 查找 comm 中是否包含敏感命令
for(auto &str : unsafeComms)
{
if(comm.find(str) != std::string::npos)
return false;
}
return true;
}
std::string ExecuteCommand(const std::string& request)
{
// 1. 安全检查
// ...
if(checkSafe(request)==0) return "";
// 2. 业务逻辑处理
FILE* fp = popen(request.c_str(), "r");
if(fp == NULL) return "create fork or pipe fail";
// 3. 获取结果
char line[2024];
std::string result;
while(fgets(line, sizeof(line), fp) != NULL)
{
result += line;
}
// 4.关闭文件流
fclose(fp);
// 5. 返回结果
return result;
}
int main()
{
std::unique_ptr<y_server::UdpServer> us(new y_server::UdpServer(ExecuteCommand));
us->Init();
us->Start();
return 0;
}
1. 功能概述
- UDP 服务器:使用
y_server::UdpServer
类创建一个 UDP 服务器,监听客户端的请求。 - 命令执行:服务器接收客户端发送的命令字符串,执行该命令,并将执行结果返回给客户端。
- 安全检查:在执行命令之前,服务器会检查命令是否包含敏感操作(如
kill
、rm
等),以防止潜在的安全风险。
2. 关键功能模块
(1) 字符串转换函数:transactionString
功能:将输入字符串中的所有大写字母转换为小写字母。
实现:
- 遍历字符串中的每个字符。
- 如果字符是大写字母(通过
isupper
检查),则将其转换为小写字母(通过加 32 实现)。
(2) 安全检查函数:checkSafe
功能:检查输入的命令字符串是否包含敏感命令。
实现:
- 定义一个包含敏感命令的列表(如
kill
、rm
、mv
等)。 - 遍历列表,检查输入的命令字符串是否包含这些敏感命令(通过
std::string::find
)。 - 如果包含敏感命令,返回
false
;否则返回true
。
用途:防止客户端执行可能对系统造成危害的命令。
(3) 命令执行函数:ExecuteCommand
功能:执行客户端发送的命令,并返回执行结果。
实现步骤:
安全检查:
- 调用
checkSafe
函数检查命令是否安全。 - 如果命令不安全,直接返回空字符串。
执行命令:
- 使用
popen
函数执行命令。popen
会创建一个子进程来执行命令,并返回一个文件流指针。 - 如果
popen
失败,返回错误信息。
获取结果:
- 使用
fgets
从文件流中逐行读取命令的输出。 - 将所有输出拼接为一个字符串。
关闭文件流:
- 使用
fclose
关闭文件流。
返回结果:
- 将命令的执行结果返回给客户端。
(4) 主函数:main
功能:初始化并启动 UDP 服务器。
实现:
- 创建一个
y_server::UdpServer
对象,传入业务处理函数。 - 调用
Init
方法初始化服务器(创建套接字并绑定端口)。 - 调用
Start
方法启动服务器,进入循环接收客户端请求并处理。
以实现远程bash窗口为例实现思路总结:
(1) 服务器初始化
- 使用
y_server::UdpServer
类创建一个 UDP 服务器对象。- 在构造函数中传入业务处理函数
ExecuteCommand
。- 调用
Init
方法完成套接字的创建和绑定操作。(2) 安全检查
- 在执行客户端命令之前,调用
checkSafe
函数检查命令是否包含敏感操作。- 如果命令不安全,直接返回空字符串,避免执行可能对系统造成危害的命令。
(3) 命令执行
- 使用
popen
执行客户端发送的命令。- 通过文件流读取命令的输出,并将其拼接为一个字符串。
- 关闭文件流,返回命令的执行结果。
(4) 客户端请求处理
- 服务器进入循环,持续接收客户端的请求。
- 对每个请求,调用
ExecuteCommand
函数处理,并将结果发送回客户端。
示例运行流程:
假设客户端发送了以下命令:
ls -l
服务器接收请求:
服务器通过
recvfrom
接收到客户端发送的命令字符串"ls -l"
。安全检查:
调用
checkSafe
函数检查命令是否包含敏感操作。由于"ls -l"
不包含敏感命令,检查通过。命令执行:
调用
popen
执行命令"ls -l"
。通过文件流读取命令的输出,例如:
total 0 -rw-r--r-- 1 user user 0 Apr 7 12:00 file.txt
将输出拼接为一个字符串。
返回结果:
将命令的执行结果通过
sendto
发送给客户端。如果客户端发送了包含敏感命令的请求,例如:
rm -rf /
服务器接收请求:
服务器接收到命令字符串
"rm -rf /"
。安全检查:
调用
checkSafe
函数检查命令是否包含敏感操作。由于"rm -rf /"
包含敏感命令"rm"
,检查失败。返回空结果:
服务器直接返回空字符串,不执行该命令。
3.客户端
#pragma once
#include <iostream>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
namespace y_client
{
enum
{
USAGE_ERR = 1,
SOCKET_ERR,
BIND_ERR
};
class UdpClient
{
public:
// 构造函数
UdpClient(std::string &ip, uint16_t port)
: _server_ip(ip), _server_port(port)
{
}
// 析构函数
~UdpClient() {}
// 初始化
void Init()
{
_sock = socket(AF_INET, SOCK_DGRAM, 0);
if (_sock < 0)
{
std::cerr << "create socket error: " << std::strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
std::cout << "create socket success: " << _sock << std::endl;
// 构建服务器sockaddr_in结构体信息
bzero(&_svr, sizeof(_svr));
_svr.sin_family = AF_INET;
_svr.sin_addr.s_addr = inet_addr(_server_ip.c_str());
_svr.sin_port = htons(_server_port);
}
// 启动
void Start()
{
char buffer[1024];
while (true)
{
std::string msg;
std::cout << "please input message# ";
std::getline(std::cin, msg);
socklen_t len = sizeof(_svr);
ssize_t n = sendto(_sock, msg.c_str(), msg.size(), 0, (struct sockaddr *)&_svr, len);
if (n == -1)
{
std::cout << "Send messages fail: " << strerror(errno) << std::endl;
continue; // 重新输入消息并发送
}
//接收信息
n=recvfrom(_sock,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&_svr,&len);
if(n>0) buffer[n]='\0';
else continue;
std::string ip=inet_ntoa(_svr.sin_addr);
uint16_t port=ntohs(_svr.sin_port);
printf("client got message from [%s : %d]# %s\n", ip.c_str(), port, buffer);
}
}
private:
std::string _server_ip;
uint16_t _server_port;
int _sock;
struct sockaddr_in _svr;
};
}
1. 功能概述
- UDP 客户端:通过
UdpClient
类创建一个 UDP 客户端,用于与服务器进行通信。 - 发送消息:客户端从标准输入读取用户输入的消息,并将其发送到指定的服务器。
- 接收响应:客户端接收服务器的响应,并将响应内容打印到标准输出。
2. 关键功能模块
(1) 构造函数
功能:初始化客户端的基本信息,包括服务器的 IP 地址和端口号。
实现:
- 接收服务器的 IP 地址和端口号作为参数。
- 将这些参数存储在类的成员变量
_server_ip
和_server_port
中。
(2) 初始化方法:Init
功能:创建 UDP 套接字并初始化服务器的地址信息。
实现步骤:
创建套接字:
- 使用
socket
函数创建一个 UDP 套接字(SOCK_DGRAM
)。 - 如果创建失败,打印错误信息并退出程序。
初始化服务器地址:
- 使用
bzero
将_svr
结构体清零。 - 设置
_svr
的sin_family
为AF_INET
,表示使用 IPv4 协议。 - 使用
inet_addr
将服务器的点分十进制 IP 地址字符串转换为网络字节序的整数,并存储在_svr.sin_addr.s_addr
中。 - 使用
htons
将服务器的端口号转换为网络字节序,并存储在_svr.sin_port
中。
(3) 启动方法:Start
功能:启动客户端,进入循环,持续发送消息并接收响应。
实现步骤:
发送消息:
- 从标准输入读取用户输入的消息(使用
std::getline
)。 - 使用
sendto
函数将消息发送到服务器。 - 如果发送失败,打印错误信息并继续下一次循环。
接收响应:
- 使用
recvfrom
函数接收服务器的响应。 - 如果接收成功,将接收到的数据存储在
buffer
中,并将其转换为字符串。 - 使用
inet_ntoa
将服务器的 IP 地址从网络字节序转换为点分十进制字符串。 - 使用
ntohs
将服务器的端口号从网络字节序转换为主机字节序。 - 打印服务器的 IP 地址、端口号和响应内容。
4.客户端程序入口
#include<memory>
#include"udp_client.hpp"
void Usage(const char* program)
{
std::cout<<"Usage: "<<std::endl;
std::cout<<"\t"<<program<<" ServerIP ServerPort"<<std::endl;
}
int main(int argc,char* argv[])
{
if(argc!=3)
{
Usage(argv[0]);
return y_client::USAGE_ERR;
}
std::string ip=argv[1];
uint16_t port=std::stoi(argv[2]);
std::unique_ptr<y_client::UdpClient> us(new y_client::UdpClient(ip,port));
us->Init();
us->Start();
return 0;
}
1. 功能概述
- 命令行参数解析:从命令行参数中获取服务器的 IP 地址和端口号。
- 客户端初始化:创建
UdpClient
对象,并初始化套接字和服务器地址。 - 客户端启动:启动客户端,进入循环,持续发送消息并接收服务器的响应。
2. 关键功能模块
(1) 使用说明函数:Usage
功能:打印程序的使用说明。
实现:
- 接收程序名称作为参数。
- 打印程序的使用方法,包括命令行参数的格式。
用途:当用户输入的命令行参数不正确时,提示用户正确的使用方法。
(2) 主函数:main
功能:解析命令行参数,初始化并启动 UDP 客户端。
实现步骤:
检查命令行参数:
- 检查是否提供了正确的参数个数(程序名、服务器 IP 和端口号)。
- 如果参数个数不正确,调用
Usage
函数打印使用说明,并返回错误码。
解析参数:
- 将命令行参数中的服务器 IP 地址存储为字符串。
- 将命令行参数中的端口号转换为整数(使用
std::stoi
)。
创建客户端对象:
- 使用
std::unique_ptr
创建一个UdpClient
对象,传入服务器的 IP 地址和端口号。
初始化客户端:
- 调用
UdpClient
的Init
方法,初始化套接字和服务器地址。
启动客户端:
- 调用
UdpClient
的Start
方法,启动客户端,进入消息发送和接收循环。
(三)实现多人聊天室
1.业务处理功能
这是基于 UDP
协议实现的一个网络程序,主要功能是 构建一个多人聊天室,当某个用户发送消息时,其他用户可以立即收到,形成一个群聊。
在这个程序中,服务器扮演了一个接收消息和分发消息的角色,将消息发送给已知的用户主机
2.程序结构
将服务器接收消息看作生产商品、分发消息看作消费商品
生产者消费者模型 必备 321 原则
- 3:三组关系
- 2:两个角色
- 1:一个交易场所
其中两个角色可以分别创建两个线程,一个负责接收消息,放入 生产者消费者模型,另一个则是负责从 生产者消费者模型 中拿去消息,分发给用户主机。
这里的交易场所可以选则 阻塞队列,也可以选择 环形队列
注意: 并非只有客户端 A 可以向环形队列中放消息,所有客户端主机的地位都是平等的,允许存放消息,也允许接收别人发的消息。
2.1额外封装头文件
2.1.1环形队列
#pragma once
#include<queue>
#include<mutex>
#include<pthread.h>
#include<iostream>
#define BQ_SIZE 5
template<class T>
class MyBlockQueue
{
public:
MyBlockQueue(size_t cap=BQ_SIZE)
:_cap(cap)
{
//初始化锁和条件变量
pthread_mutex_init(&_mtx,nullptr);
pthread_cond_init(&_pro_cond,nullptr);
pthread_cond_init(&_con_cond,nullptr);
}
~MyBlockQueue()
{
//销毁锁和条件变量
pthread_mutex_destroy(&_mtx);
pthread_cond_destroy(&_pro_cond);
pthread_cond_destroy(&_con_cond);
}
//生产数据(入队)
void push(const T& indata)
{
pthread_mutex_lock(&_mtx);
while(isfull())
{
pthread_cond_wait(&_pro_cond,&_mtx);
}
_queue.push(indata);
// 可以加一些策略,比如生产了一半就唤醒消费者
//pthread_cond_broadcast(&_con_cond);
pthread_cond_signal(&_con_cond);
pthread_mutex_unlock(&_mtx);
}
//消费数据(出队)
void pop(T* outdata)
{
// 加锁解锁
pthread_mutex_lock(&_mtx);
// 容量为空就等待
while(Isempty())
{
pthread_cond_wait(&_con_cond, &_mtx);
}
*outdata = _queue.front();
_queue.pop();
// 可以加一些策略,比如消费了一半就唤醒生产者
pthread_cond_signal(&_pro_cond);
//pthread_cond_broadcast(&_pro_cond);
pthread_mutex_unlock(&_mtx);
}
pthread_mutex_t* getMutex() { return &_mtx; }
private:
//判断队列是否为满
bool isfull()
{
return _queue.size()==_cap;
}
//判断队列是否为空
bool Isempty()
{
return _queue.empty();
}
private:
std::queue<T> _queue;
size_t _cap;
//无论是生产者 还是 消费者 ,它们需要看到同一个阻塞队列,因此使用一把互斥锁进行保护
pthread_mutex_t _mtx;
//在 生产者消费者模型 中,有 满、空 两个条件,
//这两个条件是 绝对互斥 的,不可能同时满足, 生产者关心 是否为满,消费者 关心是否为空,
//两者关注的点不一样,也就是说不能只使用一个条件变量来控制两个条件,
//而是需要 一个生产者条件变量、一个消费者条件变量
pthread_cond_t _pro_cond;//条件变量
pthread_cond_t _con_cond;//条件变量
};
(1)总体设计目标
- 线程安全:确保在多线程环境下,队列的操作(如入队和出队)是安全的,不会出现数据竞争或状态不一致的问题。
- 阻塞功能:当队列为空时,消费者线程会阻塞等待;当队列已满时,生产者线程会阻塞等待。
- 高效同步:通过条件变量实现生产者和消费者之间的同步,避免不必要的线程唤醒和上下文切换。
(2)类成员变量
_queue
:底层使用std::queue
来存储数据。_cap
:队列的最大容量,默认值为BQ_SIZE
(定义为 5)。_mtx
:互斥锁,用于保护队列的访问,确保线程安全。_pro_cond
:生产者条件变量,用于在队列满时阻塞生产者线程。_con_cond
:消费者条件变量,用于在队列空时阻塞消费者线程。
(3)构造函数和析构函数
- 构造函数:
- 初始化队列的最大容量
_cap
。 - 初始化互斥锁
_mtx
和两个条件变量_pro_cond
和_con_cond
。
析构函数:
- 销毁互斥锁和条件变量,释放资源。
(4)push
方法(生产者操作)
加锁:
- 使用
pthread_mutex_lock
锁定互斥锁,确保对队列的访问是线程安全的。
检查队列是否已满:
- 如果队列已满(
isfull()
返回true
),调用pthread_cond_wait
阻塞当前线程,直到队列有空间可用(被消费者唤醒)。 - 入队操作:
- 将数据
indata
放入队列。
唤醒消费者:
- 使用
pthread_cond_signal
唤醒一个等待的消费者线程。也可以使用pthread_cond_broadcast
唤醒所有等待的消费者线程,但通常signal
更高效。
解锁:
- 释放互斥锁。
(5)pop
方法(消费者操作)
加锁:
- 使用
pthread_mutex_lock
锁定互斥锁。
检查队列是否为空:
- 如果队列为空(
Isempty()
返回true
),调用pthread_cond_wait
阻塞当前线程,直到队列中有数据可用(被生产者唤醒)。
出队操作:
- 从队列中取出数据,并将其存储到
outdata
指向的变量中。
唤醒生产者:
- 使用
pthread_cond_signal
唤醒一个等待的生产者线程。同样,也可以使用pthread_cond_broadcast
,但通常signal
更高效。
解锁:
- 释放互斥锁。
(6)辅助方法
isfull
:检查队列是否已满,通过比较队列的当前大小和最大容量_cap
来判断。Isempty
:检查队列是否为空,通过调用std::queue
的empty
方法来判断。
(7)线程同步机制
互斥锁 _mtx
:
- 保护队列的访问,确保同一时间只有一个线程可以修改队列。
条件变量 _pro_cond
和 _con_cond
:
- 生产者线程在队列满时阻塞,等待消费者线程消费数据后唤醒。
- 消费者线程在队列空时阻塞,等待生产者线程生产数据后唤醒。
- 使用
pthread_cond_wait
使线程阻塞,并在条件满足时被唤醒。 - 使用
pthread_cond_signal
或pthread_cond_broadcast
唤醒等待的线程。
(8)阻塞机制
生产者阻塞:
- 当队列已满时,生产者线程调用
pthread_cond_wait
,释放互斥锁并进入等待状态。 - 当消费者线程消费数据后,调用
pthread_cond_signal
唤醒生产者线程。
消费者阻塞:
- 当队列为空时,消费者线程调用
pthread_cond_wait
,释放互斥锁并进入等待状态。 - 当生产者线程生产数据后,调用
pthread_cond_signal
唤醒消费者线程。
(9)性能优化
条件变量的使用:
- 使用
pthread_cond_signal
唤醒单个线程,避免不必要的上下文切换。 - 在某些场景下,可以使用
pthread_cond_broadcast
唤醒所有等待的线程,但这可能会导致更多的上下文切换。
2.1.2RAIl风格锁管理类
#pragma once
#include<pthread.h>
class LockGuard
{
public:
LockGuard(pthread_mutex_t* pmtx)
:_pmtx(pmtx)
{
//加锁
pthread_mutex_lock(_pmtx);
}
~LockGuard()
{
//解锁
pthread_mutex_unlock(_pmtx);
}
private:
pthread_mutex_t* _pmtx;
};
(1)设计思路
-
当创建
LockGuard
对象时,立即对指定的互斥锁进行加锁,确保在对象的生命周期内,互斥锁始终处于锁定状态。这种设计利用了构造函数的执行时机,确保在对象创建的瞬间完成加锁操作。 -
当
LockGuard
对象超出作用域时,析构函数会被自动调用。无论对象是正常结束生命周期,还是由于异常退出作用域,析构函数都会确保互斥锁被正确释放。这种机制避免了手动解锁时可能出现的错误,例如忘记解锁或解锁顺序错误
(2)RAII 机制的核心作用
自动资源管理:
-
在
LockGuard
对象的生命周期内,互斥锁的状态(加锁或解锁)完全由该对象控制。 -
当对象被创建时,资源(互斥锁)被获取(加锁);当对象被销毁时,资源被释放(解锁)。
异常安全:
-
如果在加锁后发生异常,
LockGuard
对象的析构函数仍然会被调用,从而确保互斥锁被解锁。 -
这种机制避免了因异常导致的死锁问题,提高了程序的健壮性。
2.1.3封装Thread
#pragma once
#include <iostream>
#include <string>
#include <cstdlib>
#include <pthread.h>
#include <functional>
class MyThread
{
private:
using func_t = std::function<void(void(*))>;
public:
MyThread(int num, func_t func, void *args)
: _tid(0), _func(func), _args(args), _status(NEW)
{
char name[128];
snprintf(name, sizeof(name), "thread-%d", num);
_name = name;
}
~MyThread(){}
// 获取线程ID
pthread_t getID()
{
if (_status == RUNNING)
return _tid;
else
return 0;
}
// 获取线程名
std::string getName() { return _name; }
// 获取线程状态
int getStatus() { return _status; }
// 启动线程
void run()
{
int tid = pthread_create(&_tid, nullptr, runHelper, this);
if (tid != 0)
{
std::cerr << "create thread fail" << std::endl;
exit(1);
}
_status = RUNNING;
}
// 等待线程
void join()
{
int tid = pthread_join(_tid, nullptr);
if (tid != 0)
{
std::cerr << "join thread fail" << std::endl;
exit(1);
}
_status = EXITED; // 线程等待成功后状态为退出
}
private:
static void *runHelper(void *args)
{
MyThread* ts = static_cast<MyThread*>(args);
(*ts)();
return nullptr;
}
void operator()()
{
if (_func != nullptr)
_func(_args);
}
private:
typedef enum
{
NEW = 0,
RUNNING,
EXITED
} ThreadStatus;
private:
pthread_t _tid; // 线程ID
func_t _func; // 回调函数
ThreadStatus _status; // 线程状态
void *_args; // 回调函数参数
std::string _name; // 线程名
};
2.2服务端
#pragma once
#include<iostream>
#include<cerrno>
#include<cstring>
#include<functional>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include<unordered_map>
#include"BlockingQueue.hpp"
#include"Mutex.hpp"
#include"MyThread.hpp"
namespace y_server
{
enum
{
SOCKET_ERR=1,
BIND_ERR
};
const static uint16_t default_port=8080;
using func_t = std::function<std::string(std::string)>;
class UdpServer
{
public:
//构造
UdpServer(uint16_t port=default_port)
:_port(port)
{
pthread_mutex_init(&_mtx,nullptr);
_producer=new MyThread(1,std::bind(&UdpServer::RecvMessage,this),nullptr);
_consumer=new MyThread(2,std::bind(&UdpServer::BroadCast,this),nullptr);
}
//析构
~UdpServer()
{
_producer->join();
_consumer->join();
pthread_mutex_destroy(&_mtx);
delete _producer;
delete _consumer;
}
//初始化
void Start()
{
//创建套接字
_sock=socket(AF_INET,SOCK_DGRAM,0);
if(_sock<0)
{
std::cerr << "create socket error: " <<std::strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
std::cout << "create socket success: " << _sock << std::endl;
//给服务器绑定IP地址和端口号
struct sockaddr_in local;
bzero(&local,sizeof(local));//置为0
//填充结构体
local.sin_family=AF_INET;
local.sin_port=htons(_port);
local.sin_addr.s_addr=INADDR_ANY;
//绑定IP地址和端口号
if(bind(_sock,(struct sockaddr*)&local,sizeof(local))<0)
{
std::cerr << "bind socket error: " << strerror(errno) << std::endl;
exit(BIND_ERR);
}
std::cout << "bind socket success: " << _sock << std::endl;
_producer->run();
_consumer->run();
}
//启动
void RecvMessage()
{
char buff[1024];
while(true)
{
//接收信息
struct sockaddr_in peer;
socklen_t len=sizeof(peer);
ssize_t n=recvfrom(_sock,buff,sizeof(buff)-1,0,(struct sockaddr*)&peer,&len);
if(n>0) buff[n]='\0';
else continue;
//处理数据
std::string clientIP=inet_ntoa(peer.sin_addr);
uint16_t clientPort=ntohs(peer.sin_port);
printf("Server got messages from [%s:%d]$ %s\n", clientIP.c_str(), clientPort, buff);
//判断是否需要添加用户
std::string user=clientIP+"-"+std::to_string(clientPort);
{
LockGuard lockguard(&_mtx);
if(usrTable.find(user)==usrTable.end()) usrTable[user]=peer;
}
//将消息添加至环形队列
std::string msg = "[" + clientIP + ":" + std::to_string(clientPort) + "] say# " + buff;
_rq.push(msg);
}
}
//广播信息
void BroadCast()
{
while(true)
{
//从环形队列中获取信息
std::string msg;
_rq.pop(&msg);
//将消息发给客户
std::vector<struct sockaddr_in> arr;
{
LockGuard lockguard(&_mtx);
for(auto& user:usrTable) arr.push_back(user.second);
}
for(auto& addr:arr) sendto(_sock,msg.c_str(),msg.size(),0,(struct sockaddr*)&addr,sizeof(addr));
}
}
private:
int _sock;//套接字
uint16_t _port;
MyBlockQueue<std::string> _rq;//环形队列
std::unordered_map<std::string,struct sockaddr_in> usrTable;
pthread_mutex_t _mtx;
MyThread* _producer;//生产者
MyThread* _consumer;//消费者
};
}
(1)总体架构
这是一个基于 UDP 协议的多线程服务器程序,主要用于接收客户端消息并进行广播。程序的核心是 UdpServer
类,它通过多线程实现消息的接收和广播功能,同时使用了线程安全的环形队列和互斥锁来保证数据的正确性和线程之间的同步。
(2)类成员变量
_sock
:用于存储 UDP 套接字的文件描述符。_port
:服务器监听的端口号,默认值为 8080。_rq
:环形队列,用于存储接收到的消息,供广播线程消费。usrTable
:存储客户端信息的哈希表,键为客户端的 IP 和端口组合的字符串,值为客户端的sockaddr_in
结构体。_mtx
:互斥锁,用于保护共享资源(如usrTable
和_rq
)的线程安全。_producer
和_consumer
:分别表示生产者线程和消费者线程,生产者线程负责接收消息,消费者线程负责广播消息。
(3)构造函数和析构函数
构造函数:
- 初始化
_port
,默认值为 8080。 - 初始化互斥锁
_mtx
。 - 创建生产者线程
_producer
和消费者线程_consumer
,并绑定到RecvMessage
和BroadCast
成员函数。
析构函数:
- 等待生产者和消费者线程完成。
- 销毁互斥锁。
- 释放生产者和消费者线程的内存。
(4)Start
方法
创建 UDP 套接字:
- 使用
socket
函数创建套接字,如果失败则退出程序。
绑定套接字:
- 使用
bind
函数将套接字绑定到指定的 IP 地址和端口号,如果失败则退出程序。
启动生产者和消费者线程:
- 调用线程的
run
方法,开始接收和广播消息。
(5)RecvMessage
方法
生产者线程的主要逻辑:
- 使用
recvfrom
函数接收来自客户端的消息。 - 如果接收到的消息有效,则将其格式化为字符串,并打印到控制台。
- 检查客户端是否已经在
usrTable
中,如果不在,则将其添加到表中。 - 将接收到的消息添加到环形队列
_rq
中。
(6)BroadCast
方法
消费者线程的主要逻辑:
- 从环形队列
_rq
中获取消息。 - 遍历
usrTable
,获取所有客户端的地址信息。 - 使用
sendto
函数将消息广播给所有客户端。
(7)线程安全机制
使用互斥锁 _mtx
保护共享资源:
- 在操作
usrTable
和_rq
时,使用LockGuard
对象自动加锁和解锁,确保线程安全。 LockGuard
是一个 RAII(资源获取即初始化)风格的锁管理类,它在构造时加锁,在析构时解锁,避免了忘记解锁的问题。
(8)环形队列
- 环形队列
_rq
是一个线程安全的队列,用于在生产者和消费者之间传递消息。 - 生产者线程将消息推入队列,消费者线程从队列中取出消息。
(9)客户端管理
- 使用
usrTable
哈希表存储客户端信息,键为客户端的 IP 和端口组合的字符串,值为客户端的sockaddr_in
结构体。 - 当接收到新的客户端消息时,检查该客户端是否已经在表中,如果不在,则将其添加到表中。
(10)广播机制
- 消费者线程从环形队列中取出消息后,遍历
usrTable
中的所有客户端地址,使用sendto
函数将消息发送给每个客户端。
(11)错误处理
- 在创建套接字和绑定套接字时,如果发生错误,程序会打印错误信息并退出。
- 使用
errno
和strerror
函数获取和打印错误信息。
(12)线程管理
- 使用自定义的
MyThread
类来管理线程。 - 生产者和消费者线程分别绑定到
RecvMessage
和BroadCast
方法。
(13)服务端启动入口
#include<memory>
#include<iostream>
#include<stdio.h>
#include<vector>
#include"udp_chat_server.hpp"
int main()
{
std::unique_ptr<y_server::UdpServer> us(new y_server::UdpServer());
us->Start();
return 0;
}
2.3客户端
#pragma once
#include <iostream>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include"MyThread.hpp"
namespace y_client
{
enum
{
USAGE_ERR = 1,
SOCKET_ERR,
BIND_ERR
};
class UdpClient
{
public:
// 构造函数
UdpClient(std::string &ip, uint16_t port)
: _server_ip(ip), _server_port(port)
{
_recv=new MyThread(1,std::bind(&UdpClient::RecvMessage,this),nullptr);
_send=new MyThread(2,std::bind(&UdpClient::SendMessage,this),nullptr);
}
// 析构函数
~UdpClient()
{
_recv->join();
_send->join();
delete _recv;
delete _send;
}
// 初始化
void Start()
{
_sock = socket(AF_INET, SOCK_DGRAM, 0);
if (_sock < 0)
{
std::cerr << "create socket error: " << std::strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
std::cout << "create socket success: " << _sock << std::endl;
// 构建服务器sockaddr_in结构体信息
bzero(&_svr, sizeof(_svr));
_svr.sin_family = AF_INET;
_svr.sin_addr.s_addr = inet_addr(_server_ip.c_str());
_svr.sin_port = htons(_server_port);
_recv->run();
_send->run();
}
//发送信息
void SendMessage()
{
char buffer[1024];
while (true)
{
std::string msg;
std::cout << "please input message# ";
std::getline(std::cin, msg);
socklen_t len = sizeof(_svr);
ssize_t n = sendto(_sock, msg.c_str(), msg.size(), 0, (struct sockaddr *)&_svr, len);
if (n == -1)
{
std::cout << "Send messages fail: " << strerror(errno) << std::endl;
continue; // 重新输入消息并发送
}
}
}
// 接收消息
void RecvMessage()
{
char buffer[1024];
while (true)
{
socklen_t len = sizeof(_svr);
//接收信息
ssize_t n=recvfrom(_sock,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&_svr,&len);
if(n>0) buffer[n]='\0';
else continue;
std::cout << "Client get message " << buffer << std::endl;
}
}
private:
std::string _server_ip;
uint16_t _server_port;
int _sock;
struct sockaddr_in _svr;
MyThread* _recv;//发送消息
MyThread* _send;//接收消息
};
}
(1)总体架构
这是一个简单的 UDP 客户端程序,它通过多线程实现消息的发送和接收功能。程序的核心是 UdpClient
类,它包含两个线程:
- 发送线程:负责从用户输入中获取消息并发送给服务器。
- 接收线程:负责接收服务器广播的消息并打印到控制台。
(2)类成员变量
_server_ip
和_server_port
:存储服务器的 IP 地址和端口号。_sock
:UDP 套接字的文件描述符。_svr
:sockaddr_in
结构体,用于存储服务器的地址信息。_recv
和_send
:分别表示接收线程和发送线程。
(3)构造函数和析构函数
构造函数:
- 初始化服务器的 IP 地址和端口号。
- 创建发送线程
_send
和接收线程_recv
,并分别绑定到SendMessage
和RecvMessage
成员函数。
析构函数:
- 等待发送线程和接收线程完成。
- 释放线程对象的内存。
(4)Start
方法
创建 UDP 套接字:
- 使用
socket
函数创建套接字,如果失败则打印错误信息并退出程序。
初始化服务器地址信息:
- 使用
inet_addr
将服务器的 IP 地址从字符串转换为网络字节序。 - 设置
sockaddr_in
结构体的其他字段(如协议族、端口号)。
启动发送线程和接收线程:
- 调用线程的
run
方法,开始发送和接收消息。
(5)SendMessage
方法
发送线程的主要逻辑:
- 使用
std::getline
从标准输入获取用户输入的消息。 - 使用
sendto
函数将消息发送到服务器。 - 如果发送失败,打印错误信息并让用户重新输入消息。
(6)RecvMessage
方法
接收线程的主要逻辑:
- 使用
recvfrom
函数接收来自服务器的消息。 - 如果接收到的消息有效,则将其打印到控制台。
(7)线程管理
- 使用自定义的
MyThread
类来管理线程。 - 发送线程和接收线程分别绑定到
SendMessage
和RecvMessage
方法。
(8)错误处理
- 在创建套接字时,如果发生错误,程序会打印错误信息并退出。
- 在发送消息时,如果
sendto
返回-1
,程序会打印错误信息并让用户重新输入消息。
(9)线程安全
- 由于发送和接收线程分别操作不同的资源(发送线程操作标准输入,接收线程操作套接字),因此代码中没有使用互斥锁。
- 如果需要进一步扩展功能(例如在发送线程中动态更新服务器地址),可能需要引入线程同步机制。
(10)客户端启动入口
#include<memory>
#include"udp_chat_client.hpp"
void Usage(const char* program)
{
std::cout<<"Usage: "<<std::endl;
std::cout<<"\t"<<program<<" ServerIP ServerPort"<<std::endl;
}
int main(int argc,char* argv[])
{
if(argc!=3)
{
Usage(argv[0]);
return y_client::USAGE_ERR;
}
std::string ip=argv[1];
uint16_t port=std::stoi(argv[2]);
std::unique_ptr<y_client::UdpClient> us(new y_client::UdpClient(ip,port));
us->Start();
return 0;
}