网站怎么做seo关键词,湘潭网站建设厦门网站制作,做网站需要哪些,如何做网商商城的网站#x1f52d;个人主页#xff1a; 北 海 #x1f6dc;所属专栏#xff1a; Linux学习之旅、神奇的网络世界 #x1f4bb;操作环境#xff1a; CentOS 7.6 阿里云远程服务器 文章目录 #x1f324;️前言#x1f326;️正文1.预备知识1.1.IP地址1.2.端口号1.3.端口号与进… 个人主页 北 海 所属专栏 Linux学习之旅、神奇的网络世界 操作环境 CentOS 7.6 阿里云远程服务器 文章目录 ️前言️正文1.预备知识1.1.IP地址1.2.端口号1.3.端口号与进程PID1.4.传输层协议1.5.网络字节序 2.socket 套接字2.1.socket 常见API2.2.sockaddr 结构体 UDP 网络程序3.字符串回响3.1.核心功能3.2.程序结构服务器设计3.3.创建套接字3.4.绑定IP地址和端口号3.5.启动服务器客户端设计3.6.指定IP地址和端口号3.7.初始化客户端3.8.启动客户端 4.大写转小写、远程bash4.1.业务处理函数解耦4.2.大写转小写4.3.远程bash 5.多人聊天室5.1.核心功能5.2.程序结构服务器5.3.引入环形队列5.4.引入用户信息5.5.引入多线程客户端5.6.多线程化 ️总结 ️前言
在当今数字化时代网络通信作为连接世界的桥梁成为计算机科学领域中至关重要的一部分。理解网络编程是每一位程序员必备的技能之一而掌握套接字编程则是深入了解网络通信的关键。本博客将深入讨论套接字编程中的基本概念、常见API以及实际应用通过一步步的学习帮助读者逐渐掌握网络编程的精髓。 ️正文
1.预备知识
1.1.IP地址
在 《网络基础『发展 ‖ 协议 ‖ 传输 ‖ 地址』》一文中我们提到过: IP 是全球网络的基础使用 IP 地址来标识公网环境下主机的唯一性我们可以根据 目的IP地址 进行跨路由器的远端通信将信息从主机 A 发送至主机 Z 仅仅使用 IP 只能定位到目标主机并且目标主机不是最终目的地要想定位目的地需要依靠 端口号 目标主机中存在很多进程网络通信实际是不同主机中的进程在进行通信并非主机与主机直接通信 1.2.端口号
端口号 是一个用于标识网络进程唯一性的标识符是一个 2 字节的整数取值范围为 [0, 65535]可以通过 端口号 定位主机中的目标进程
抛开网络其他知识将信息从主机 A 中的进程 A 发送至主机 B 中的 进程 B这不就是 进程间通信 吗之前学习的 进程间通信 是通过 匿名管道、命名管道、共享内存 等方式实现而如今的 进程间通信 则是通过 网络传输 的方式实现 需要进行网络通信的进程有很多为了方便进行管理就诞生了 端口号 这个概念同进程的 PID 一样端口号 也可以用于标识进程 服务器中的防火墙其实就是端口号限制只有开放的端口号才允许进程用于 网络通信 1.3.端口号与进程PID
端口号 用于标识进程进程 PID 也是用于标识进程为什么在网络中不直接使用进程 PID 呢 进程 PID 隶属于操作系统中的进程管理如果在网络中使用 PID会导致网络标准中被迫中引入进程管理相关概念进程管理与网络强耦合 进程管理 属于 OS 内部中的功能OS 可以有很多标准但网络标准只能有一套在网络中直接使用 PID 无法确保网络标准的统一性 并不是所有的进程都需要进行网络通信如果端口号、PID 都使用同一个解决方案无疑会影响网络管理的效率
所以综上所述网络中的 端口号 需要通过一种全新的方式实现也就是一个 2 字节的整数 port进程 A 运行后可以给它绑定 端口号 N在进行网络通信时根据 端口号 N 来确定信息是交给进程 A 的 所以将之前的结论再具体一点IP Port 可以标识公网环境下唯一的网络进程 网络传输中的必备信息组 [目的IP 源 IP || 目的 Port 源 Port] 目的 IP需要把信息发送到哪一台主机源 IP信息从哪台主机中发出目的 Port将信息交给哪一个进程源 Port信息从哪一个进程中发出 注意 端口号与进程 PID 并不是同一个概念 进程 PID 就好比你的身份证号端口号 相当于学号这两个信息都可以标识唯一的你但对于学校来说使用学号更方便进行管理 一个进程可以绑定多个 端口号 吗一个 端口号 可以被多个进程绑定吗
端口号 的作用是配合 IP 地址标识网络世界中进程的唯一性如果一个进程绑定多个 端口号依然可以保证唯一性因为无论使用哪个 端口号信息始终只会交给一个进程但如果一个 端口号 被多个进程绑定了在信息递达时是无法分辨该信息的最终目的进程的存在二义性 所以一个进程可以绑定多个端口号一个 端口号 不允许被多个进程绑定如果被绑定了可以通过 端口号 顺藤摸瓜找到占用该 端口号 的进程 如果某个端口号被使用了其他进程再继续绑定是会报错的提示 该端口已被占用 主机操作系统是如何根据 端口号 定位具体进程的
这个实现起来比较简单创建一张哈希表维护 端口号, 进程 PID 之间的映射关系当信息通过网络传输到目标主机时操作系统可以根据其中的 [目的 Port]直接定位到具体的进程 PID然后进行通信
1.4.传输层协议
主流的传输层协议有两个TCP 和 UDP
两个协议各有优缺点可以采用不同的协议实现截然不同的网络程序关于 TCP 和 UDP 的详细信息将会放到后面的博客中详谈先来看看简单这两种协议的特点
TCP 协议传输控制协议
传输层协议有连接可靠传输面向字节流
字节流就像水龙头用户可以根据自己的需求获取水流量
UDP 协议用户数据协议
传输层协议无连接不可靠传输面向数据报
数据报则是相当于包裹用户每次获取的都是一个或多个完整的包裹 关于 可靠性 TCP 的可靠传输并不意味着它可以将数据百分百递达而是说它在数据传输过程中如果发生了传输失败的情况它会通过自己独特的机制重新发送数据确保对端百分百能收到数据至于 UDP 就不一样数据发出后如果失败了也不会进行重传好在 UDP 面向数据报并且没有很多复杂的机制所以传输速度很快 总结起来就是TCP 用于对数据传输要求较高的领域比如金融交易、网页请求、文件传输等至于 UDP 可以用于短视频、直播、即时通讯等对传输速度要求较高的领域 如果不知道该使用哪种协议优先考虑 TCP如果对传输速度又要求可以选择 UDP 1.5.网络字节序
在学习网络字节序相关知识前先回顾一下大小端字节序
预备知识
数据拥有高权值位和低权值位比如在 32 位操作系统中十六进制数 0x11223344其中的 11 称为 最高权值位44 称为 最低权值位内存有高地址和低地址之分
如果将数据的高权值存放在内存的低地址处低权值存放在高地址处此时就称为 大端字节序反之则称为 小端字节序这两种字节序没有好坏之分只是系统设计者的使用习惯问题比如我当前的电脑在存储数据时采用的就是 小端字节序 方案 通过内存单元可以看到使用 小端字节序 时数据是倒着放的大端字节序 就是正着存放了 大小端字节序就有点像吃香蕉时的方式有的人是从头部开始剥皮有的人是从尾部开始剥皮两种方式都能吃到香蕉纯属习惯问题 在网络出现之前使用大端或小端存储都没有问题网络出现之后就需要考虑使用同一种存储方案了因为网络通信时两台主机存储方案可能不同会出现无法解读对方数据的问题 如果你是网络标准的设计者你会如何解决 解决方案1数据发送前给报文中添加大小端的标记字段待数据递达后对端在根据标志位进行解读再进行转换。 这个方案实现起来不太方便并且给每一个报文都添加标记字段这个行为比较浪费 解决方案2书同文车同轨直接统一标准。 这种解决方案就很彻底了直接从根源上解决问题也更方便 顶层设计者采用了解决方案2TCP/IP 协议规定网络中传输的数据统一采用大端存储方案也就是网络字节序 现在大端/小端称为 主机字节序
发送数据时将 主机字节序 转化为 网络字节序接收到数据后再转回 主机字节序 就好了完美解决不同机器中的大小端差异可以用下面这批库函数进行转换在发送/接收时调用库函数进行转换即可
#include arpa/inet.h// 主机字节序转网络字节序
uint32_t htonl(uint32_t hostlong); // l 表示32位长整数
uint32_t htons(uint32_t hostshort); // s 表示16位短整数// 网络字节序转主机字节序
uint32_t ntohl(uint32_t netlong); // l 表示32位长整数
uint32_t ntohs(uint32_t netshort); // s 表示16位短整数2.socket 套接字
2.1.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 结构体的字段复用了文件描述符的解决方案 2.2.sockaddr 结构体
socket 这套网络通信标准隶属于 POSIX 通信标准该标准的设计初衷就是为了实现 可移植性程序可以直接在使用该标准的不同机器中运行但有的机器使用的是网络通信有的则是使用本地通信socket 套接字为了能同时兼顾这两种通信方式提供了 sockaddr 结构体
由 sockaddr 结构体衍生出了两个不同的结构体sockaddr_in 网络套接字、sockaddr_un 域间套接字前者用于网络通信后者用于本地通信
可以根据 16 位地址类型判断是网络通信还是本地通信在进行网络通信时需要提供 IP 地址、端口号 等网络通信必备项本地通信只需要提供一个路径名通过文件读写的方式进行通信类似于命名管道 socket 提供的接口参数为 sockaddr*我们既可以传入 sockaddr_in 进行网络通信也可以传入 sockaddr_un 进行本地通信传参时将参数进行强制类型转换即可这是使用 C语言 实现 多态 的典型做法确保该标准的通用性 为什么不将参数设置为 void* 因为在该标准设计时C语言还不支持 void* 这种类型为了确保向前兼容性即便后续支持后也不能进行修改了 关于 socketaddr_in 结构的更多详细信息放到后面写代码时再细谈 UDP 网络程序
接下来实现一批基于 UDP 协议的网络程序 3.字符串回响
3.1.核心功能
分别实现客户端与服务器客户端向服务器发送消息服务器收到消息后回响给客户端有点类似于 echo 指令 该程序的核心在于 使用 socket 套接字接口以 UDP 协议的方式实现简单网络通信
3.2.程序结构
程序由 server.hpp、server.cc、client.hpp、client.cc 组成大体框架如下 创建 server.hpp 服务器头文件 #pragma once#include iostreamnamespace nt_server
{class UdpServer{public:// 构造UdpServer(){} // 析构~UdpServer(){} // 初始化服务器void InitServer(){}// 启动服务器void StartServer(){}private:// 字段};
}创建 server.cc 服务器源文件 #include memory // 智能指针相关头文件
#include server.hppusing namespace std;
using namespace nt_server;int main()
{unique_ptrUdpServer usvr(new UdpServer());// 初始化服务器usvr-InitServer();// 启动服务器usvr-StartServer();return 0;
}创建 client.hpp 客户端头文件 #pragma once#include iostreamnamespace nt_client
{class UdpClient{public:// 构造UdpClient() {} // 析构~UdpClient() {} // 初始化客户端void InitClient() {}// 启动客户端void StartClient() {}private:// 字段};
}创建 client.cc 客户端源文件 #include memory
#include client.hppusing namespace std;
using namespace nt_client;int main()
{unique_ptrUdpClient usvr(new UdpClient());// 初始化客户端usvr-InitClient();// 启动客户端usvr-StartClient();return 0;
}为了方便后续测试再添加一个 Makefile 文件 创建 Makefile 文件 .PHONY:all
all:server clientserver:server.ccg -o $ $^ -stdc11client:client.ccg -o $ $^ -stdc11.PHONY:clean
clean:rm -rf server client准备工作完成后接下来着手填充代码内容 服务器设计 3.3.创建套接字
创建套接字使用 socket 函数
#include sys/types.h
#include sys/socket.h// 创建套接字TCP/UDP 服务器/客户端
int socket(int domain, int type, int protocol);参数解读
domain 创建套接字用于哪种通信网络/本地type 选择数据传输类型流式/数据报protocol 选择协议类型支持根据参数2自动推导
返回值创建成功后返回套接字文件描述符失败返回 -1 因为这里是使用 UDP 协议实现的 网络通信参数2 domain 选择 AF_INET基于 IPv4 标准参数2 type 选择 SOCK_DGRAM数据报传输参数3设置为 0可以根据 SOCK_DGRAM 自动推导出使用 UDP 协议 AF_INET6 基于 IPv6 标准 接下来在 server.hpp 的 InitServer() 函数中创建套接字并对创建成功/失败后的结果做打印 server.hpp 服务器头文件 #pragma once#include iostream
#include cstring
#include cerrno
#include cstdlib
#include sys/types.h
#include sys/socket.hnamespace nt_server
{// 错误码enum{SOCKET_ERR 1};class UdpServer{public:// 构造UdpServer(){} // 析构~UdpServer(){} // 初始化服务器void InitServer(){// 1.创建套接字sock_ socket(AF_INET, SOCK_DGRAM, 0);if(sock_ -1){std::cout Create Socket Fail: strerror(errno) std::endl;exit(SOCKET_ERR);}// 创建成功std::cout Create Success Socket: sock_ std::endl;}// 启动服务器void StartServer(){}private:int sock_; // 套接字};
}文件描述符默认 0、1、2 都已经被占用了如果再创建文件描述符会从 3 开始可以看到程序运行后创建的套接字正是 3证明套接字本质上就是文件描述符不过它用于描述网络资源 3.4.绑定IP地址和端口号
注意 我这里的服务器是云服务器绑定 IP 地址这个操作后面需要修改
使用 bind 函数进行绑定操作
#include sys/types.h
#include sys/socket.h// 绑定IP地址和端口号TCP/UDP 服务器
int bind(int sockfd, const struct sockaddr* addr, socklen_t addrlen);参数解读
sockfd 创建成功的套接字addr 包含通信信息的 sockaddr 结构体地址addrlen 结构体的大小
返回值成功返回 0失败返回 -1 参数1没啥好说的重点在于参数2因为我们这里是 网络通信所以使用的是 sockaddr_in 结构体要想使用该结构体还得包含下面这两个头文件
#include netinet/in.h
#include arpa/inet.hsockaddr_in 结构体的构成如下
/* Structure describing an Internet socket address. */
struct sockaddr_in
{__SOCKADDR_COMMON (sin_);in_port_t sin_port; /* Port number. */struct in_addr sin_addr; /* Internet address. *//* Pad to size of struct sockaddr. */unsigned char sin_zero[sizeof (struct sockaddr) -__SOCKADDR_COMMON_SIZE -sizeof (in_port_t) -sizeof (struct in_addr)];
};首先来看看 16 位地址类型转到定义可以发现它是一个宏函数并且使用了 C语言 中一个非常少用的语法 ##将两个字符串拼接
/* POSIX.1g specifies this type name for the sa_family member. */
typedef unsigned short int sa_family_t;/* This macro is used to declare the initial common membersof the data types used for socket addresses, struct sockaddr,struct sockaddr_in, struct sockaddr_un, etc. */#define __SOCKADDR_COMMON(sa_prefix) \sa_family_t sa_prefix##family当给 __SOCKADDR_COMMON 传入 sin_ 参数后经过 ## 字符串拼接、宏替换等操作后会得到这样一个类型
sa_family_t sin_family;sa_family_t 是一个无符号短整数占 16 位sin_family 字段就是 16 位地址类型 了
接下来看看 端口号转到定义发现 in_port_t 类型是一个 16 位无符号整数同样占 2 字节正好符合 端口号 的取值范围 [0, 65535]
/* Type to represent a port. */
typedef uint16_t in_port_t;最后再来看看 IP 地址同样转到定义发现 in_addr 中包含了一个 32 位无符号整数占 4 字节也就是 IP 地址 的大小
/* Internet address. */
typedef uint32_t in_addr_t;
struct in_addr
{in_addr_t s_addr;
};了解完 sockaddr_in 结构体中的内容后就可以创建该结构体了再定义该结构体后需要清空确保其中的字段干净可用 将变量置为 0 可用使用 bzero 函数 #include cstrins // bzero 函数的头文件struct sockaddr_in local;
bzero(local, sizeof(local));获得一个干净可用的 sockaddr_in 结构体后可以正式绑定 IP 地址 和 端口号 了
注作为服务器需要确定自己的端口号我这里设置的是 8888 server.hpp 服务器头文件 #pragma once#include iostream
#include string
#include cstring
#include cerrno
#include cstdlib
#include strings.h
#include sys/types.h
#include sys/socket.h
#include netinet/in.h
#include arpa/inet.hnamespace nt_server
{// 退出码enum{SOCKET_ERR 1,BIND_ERR};// 端口号默认值const uint16_t default_port 8888;class UdpServer{public:// 构造UdpServer(const std::string ip, const uint16_t port default_port):port_(port), ip_(ip){} // 析构~UdpServer(){} // 初始化服务器void InitServer(){// 1.创建套接字sock_ socket(AF_INET, SOCK_DGRAM, 0);if(sock_ -1){std::cout Create Socket Fail: strerror(errno) std::endl;exit(SOCKET_ERR);}// 创建成功std::cout Create Success Socket: sock_ std::endl;// 2.绑定IP地址和端口号struct sockaddr_in local;bzero(local, sizeof(local)); // 置0// 填充字段local.sin_family AF_INET; // 设置为网络通信PF_INET 也行local.sin_port htons(port_); // 主机序列转为网络序列local.sin_addr.s_addr inet_addr(ip_.c_str()); // 点分十进制转为短整数再将主机序列转为网络序列// 绑定IP地址和端口号if(bind(sock_, (const sockaddr*)local, sizeof(local))){std::cout Bind IPPort Fail: strerror(errno) std::endl;exit(BIND_ERR);}// 绑定成功std::cout Bind IPPort Success std::endl;}// 启动服务器void StartServer(){}private:int sock_; // 套接字uint16_t port_; // 端口号std::string ip_; // IP地址后面需要删除};
}注意
需要把主机序列转换为网络序列可以使用 htons 函数需要把点分十进制的字符串转换为无符号短整数可以使用 inet_addr 函数这个函数在进行转换的同时会将主机序列转换为网络序列绑定IP地址和端口号这个行为并非直接绑定到当前主机中而是在当前程序中将创建的 socket 套接字与目标IP地址与端口号进行绑定当程序终止后这个绑定关系也会随之消失 server.cc 服务器源文件 #include memory // 智能指针相关头文件
#include server.hppusing namespace std;
using namespace nt_server;int main()
{unique_ptrUdpServer usvr(new UdpServer(8.134.110.68));// 初始化服务器usvr-InitServer();// 启动服务器usvr-StartServer();return 0;
}接下来编译并运行程序可以发现绑定失败了这是因为当前我使用的是云服务器云服务器是不允许直接绑定公网 IP 的解决方案是在绑定 IP 地址时让其选择绑定任意可用 IP 地址 修改代码
云服务器中不需要明确 IP 地址构造时也无需传入 IP 地址绑定 IP 地址时选择 INADDR_ANY表示绑定任何可用的 IP 地址 server.hpp 服务器头文件 class UdpServer
{
public:// 构造UdpServer(const uint16_t port default_port):port_(port){} // 初始化服务器
void InitServer()
{// ...// 填充字段local.sin_family AF_INET; // 设置为网络通信PF_INET 也行local.sin_port htons(port_); // 主机序列转为网络序列// local.sin_addr.s_addr inet_addr(ip_.c_str()); // 点分十进制转为短整数再将主机序列转为网络序列local.sin_addr.s_addr INADDR_ANY; // 绑定任何可用IP地址// ...
}private:int sock_; // 套接字uint16_t port_; // 端口号// std::string ip_; // 删除
};server.cc 服务器源文件 #include memory // 智能指针相关头文件
#include server.hppusing namespace std;
using namespace nt_server;int main()
{unique_ptrUdpServer usvr(new UdpServer());// 初始化服务器usvr-InitServer();// 启动服务器usvr-StartServer();return 0;
}再次编译并运行程序可以看到正常运行 服务器设置的端口需要设置为开放状态如果是本地服务器可以使用 systemctl start firewalld.service 指令开启防火墙再使用 firewall-cmd --zonepublic --add-portPort/tcp --permanent 开启指定的端口号 如果是云服务器就需要通过 控制台开放对应的端口 3.5.启动服务器
当前编写的 回响服务器 需要服务器拥有读取信息然后回响给客户端的能力
读取信息使用 recvfrom 函数
#include sys/types.h
#include sys/socket.h// 读取信息TCP/UDP 服务器/客户端
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);这个函数参数比较多首先来看看前半部分
sockfd 使用哪个套接字进行读取buf 读取数据存放缓冲区len 缓冲区的大小flags 读取方式阻塞/非阻塞
前半部分主要用于读取数据并进行存放接下来看看后半部分
src_addr 输入输出型参数对端主机的 sockaddr 结构体包含了对端的 IP地址 和 端口号addrlen 输入输出型参数对端主机的 sockaddr 结构体大小 这个输入输出型参数就类似于送礼时留下自己的信息待对方还礼时可以知道还给谁接收信息也是如此当服务器获取客户端的 sockaddr 结构体信息后同样可以给客户端发送信息双方就可以愉快的进行通信了 返回值成功返回实际读取的字节数失败返回 -1 接收消息步骤
创建缓冲区、对端 sockaddr_in 结构体接收信息判断是否接收成功处理信息
所以接下来编写接收消息的逻辑
注意 因为 recvfrom 函数的参数 src_addr 类型为 sockaddr需要将 sockaddr_in 类型强转后再进行传递 StartServer() 函数 — 位于 server.hpp 服务器源文件中的 UdpServer 类 // 启动服务器
void StartServer()
{// 服务器是不断运行的所以需要使用一个 while(true) 死循环char buff[1024]; // 缓冲区while(true){// 1. 接收消息struct sockaddr_in peer; // 客户端结构体socklen_t len sizeof(peer); // 客户端结构体大小// 传入 sizeof(buff) - 1 表示当前传输的是字符串预留一个位置存储 \0// 传入 0 表示当前是阻塞式读取ssize_t n recvfrom(sock_, buff, sizeof(buff) - 1, 0, (struct sockaddr*)peer, len);if(n 0)buff[n] \0;elsecontinue; // 继续读取// 2.处理数据std::string clientIp inet_ntoa(peer.sin_addr); // 获取IP地址uint16_t clientPort ntohs(peer.sin_port); // 获取端口号printf(Server get message from [%c:%d]$ %s\n,clientIp.c_str(), clientPort, buff);// 3.回响给客户端// ...}
}发送信息使用 sendto 函数
#include sys/types.h
#include sys/socket.h// 读取信息TCP/UDP 服务器/客户端
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);这个函数的参数也是很多几乎与 recvfrom 的一模一样
sockfd 使用哪个套接字进行发送buf 发送数据存放缓冲区len 缓冲区的大小flags 发送方式阻塞/非阻塞src_addr 对端主机的 sockaddr 结构体包含了对端的 IP地址 和 端口号addrlen 对端主机的 sockaddr 结构体大小
返回值成功返回实际发送的字节数失败返回 -1 发送消息时直接调用 sendto 函数把读取到的信息回响给客户端即可如果发送失败了就简单报个错为了方便错误码调整这里顺便把错误码封装成一个单独的 err.hpp 源文件注意包含头文件 StartServer() 函数 — 位于 server.hpp 服务器源文件中的 UdpServer 类 // ...
#include err.hpp// ...// 启动服务器
void StartServer()
{// 服务器是不断运行的所以需要使用一个 while(true) 死循环char buff[1024]; // 缓冲区while(true){// ...// 3.回响给客户端n sendto(sock_, buff, strlen(buff), 0, (const struct sockaddr*)peer, sizeof(peer));if(n -1)std::cout Send Message Fail: strerror(errno) std::endl;}
}err.hpp 头文件 #pragma once// 错误码
enum
{SOCKET_ERR 1,BIND_ERR
};万事具备后就可以启动服务器了可以看到服务器启动后处于阻塞等待状态这是因为还没有客户端给我的服务器发信息所以它就会暂时阻塞 如何证明服务器正在运行
可以通过 Linux 中查看网络状态的指令因为我们这里使用的是 UDP 协议所以只需要输入下面这条指令就可以查看有哪些程序正在运行
netstat -nlup现在服务已经跑起来了并且如期占用了 8888 端口接下来就是编写客户端相关代码 0.0.0.0 表示任意IP地址 客户端设计
3.6.指定IP地址和端口号
客户端在运行时必须知道服务器的 IP 地址 和 端口号否则不知道自己该与谁进行通信所以对于 UdpClient 类来说ip 和 port 者两个字段是肯定少不了的 client.hpp 客户端头文件 #pragma once#include iostream
#include string
#include err.hppnamespace nt_client
{class UdpClient{public:// 构造UdpClient(const std::string ip, uint16_t port):server_ip_(ip), server_port_(port){} // 析构~UdpClient() {} // 初始化客户端void InitClient() {}// 启动客户端void StartClient() {}private:std::string server_ip_; // 服务器IP地址uint16_t server_port_; // 服务器端口号};
}这两个参数由用户主动传输这里就需要 命令行 参数相关知识了在启动客户端时需要以 ./client serverIp serverPort 的方式运行否则就报错并提示相关错误信息更新 err.hpp 的错误码 client.cc 客户端源文件 #include iostream
#include memory
#include client.hppusing namespace std;
using namespace nt_client;void Usage(const char* program)
{cout Usage: endl;cout \t program ServerIP ServerPort endl;
}int main(int argc, char* argv[])
{if (argc ! 3){// 错误的启动方式提示错误信息Usage(argv[0]);return USAGE_ERR;}std::string ip argv[1];uint16_t port stoi(argv[2]);unique_ptrUdpClient usvr(new UdpClient(ip, port));// 初始化客户端usvr-InitClient();// 启动客户端usvr-StartClient();return 0;
}err.hpp 错误码头文件 #pragma onceenum
{USAGE_ERR 1,SOCKET_ERR,BIND_ERR
};如此一来只有正确的输入 [./client ServerIP ServerPort] 才能启动程序否则不让程序运行倒逼客户端启动时提供服务器的 IP 地址 和 端口号 其实在浏览网页时输入的 url 网址在经过转换后其中也一定会包含服务器的 IP 地址 与 端口号配合请求的资源路径就能获取服务器资源了 3.7.初始化客户端
初始化客户端时同样需要创建 socket 套接字不同于服务器的是 客户端不需要自己手动绑定 IP 地址与端口号
这是因为客户端手动指明 端口号 存在隐患如果恰好有两个程序使用了同一个端口会导致其中一方的客户端直接绑定失败无法运行将绑定 端口号 这个行为交给 OS 自动执行首次传输数据时自动 bind可以避免这种冲突的出现 为什么服务器要自己手动指定端口号并进行绑定 这是因为服务器的端口不能随意改变并且这是要公布给广大客户端看的同一家公司在部署服务时会对端口号的使用情况进行管理可以直接避免端口号冲突 客户端在启动前需要先知晓服务器的 sockaddr_in 结构体信息可以利用已知的 IP 地址 和 端口号 构建
综上所述在初始化客户端时需要创建好套接字和初始化服务器的 sockaddr_in 结构体信息 client.hpp 客户端头文件 #pragma once#include iostream
#include string
#include cstring
#include cerrno
#include cstdlib
#include strings.h
#include sys/types.h
#include sys/socket.h
#include netinet/in.h
#include arpa/inet.h
#include err.hppnamespace nt_client
{class UdpClient{public:// 构造UdpClient(const std::string ip, uint16_t port):server_ip_(ip), server_port_(port){} // 析构~UdpClient() {} // 初始化客户端void InitClient() {// 1.创建套接字sock_ socket(AF_INET, SOCK_DGRAM, 0);if(sock_ -1){std::cout Create Socket Fail: strerror(errno) std::endl;exit(SOCKET_ERR);}std::cout Create Success Socket: sock_ std::endl;// 2.构建服务器的 sockaddr_in 结构体信息bzero(svr_, sizeof(svr_));svr_.sin_family AF_INET; // 设置为网络通信PF_INET 也行svr_.sin_addr.s_addr inet_addr(server_ip_.c_str()); // 绑定服务器IP地址svr_.sin_port htons(server_port_); // 绑定服务器端口号}// 启动客户端void StartClient() {}private:std::string server_ip_; // 服务器IP地址uint16_t server_port_; // 服务器端口号int sock_;struct sockaddr_in svr_; // 服务器的sockadder_in结构体信息};
}如此一来客户端就可以利用该 sockaddr_in 结构体与目标主机进行通信了
3.8.启动客户端
接下来就是客户端向服务器发送消息消息由用户主动输入使用的是 sendto 函数
发送消息步骤
用户输入消息传入缓冲区、服务器相关参数使用 sendto 函数发送消息
消息发送后客户端等待服务器回响消息
接收消息步骤
创建缓冲区接收信息判断是否接收成功处理信息
注同服务器一样客户端也需要不断运行 StartClient() 函数 — 位于 client.hpp 中的 UdpClient 类 // 启动客户端
void StartClient()
{char buff[1024];while(true){// 1.发送消息std::string msg;std::cout Input Message# ;std::getline(std::cin, msg);ssize_t n sendto(sock_, msg.c_str(), msg.size(), 0, (const struct sockaddr*)svr_, sizeof(svr_));if(n -1){std::cout Send Message Fail: strerror(errno) std::endl;continue; // 重新输入消息并发送}// 2.接收消息socklen_t len sizeof(svr_); // 创建一个变量因为接下来的参数需要传左值n recvfrom(sock_, buff, sizeof(buff) - 1, 0, (struct sockaddr*)svr_, len);if(n 0)buff[n] \0;elsecontinue;// 可以再次获取IP地址与端口号std::string ip inet_ntoa(svr_.sin_addr);uint16_t port ntohs(svr_.sin_port);printf(Client get message from [%s:%d]# %s\n,ip.c_str(), port, buff);}
}现在左手 服务器右手 客户端直接编译运行看看效果 注127.0.0.1 表示本地环回通常用于测试网络程序因为我当前的服务器和客户端都是在同一机器上运行的所以就可以使用该 IP 地址当然直接使用服务器的公网 IP 地址也是可以的
通过 netstat -nlup 指令查看端口使用情况
可以看到服务器和客户端都成功运行了OS 给客户端分配的 端口号 是 54450这是随机分配的每次重新运行后大概率都不相同 至此基于 UDP 协议编写的第一个网络程序 字符串回响 就完成了接下来对其进行改造编写第二个网络程序 4.大写转小写、远程bash
4.1.业务处理函数解耦
基于模块化处理的思想将服务器中处理消息的函数与启动服务的函数解耦由程序员传入指定的回调函数 此时业务处理函数已经变成一个模块了可以自由变换
业务处理函数A实现大写转小写业务处理函数B实现远程 bash业务处理函数C实现 xxx
服务器在启动时只需要传入对应的业务处理函数回调函数即可
修改 server.hpp 的代码如下 使用 C11 中的 function 包装器语法包装出一个符合我们业务处理需求的函数类型 server.hpp 服务器头文件 #pragma once#include iostream
#include string
#include functional
#include cstring
#include cerrno
#include cstdlib
#include strings.h
#include sys/types.h
#include sys/socket.h
#include netinet/in.h
#include arpa/inet.h
#include err.hppnamespace nt_server
{// 端口号默认值const uint16_t default_port 8888;using func_t std::functionstd::string(std::string); // 参数为string返回值同样为stringclass UdpServer{public:// 构造UdpServer(const func_t func, uint16_t port default_port):port_(port),serverHandle_(func){}// 析构~UdpServer(){} // 初始化服务器void InitServer(){// 1.创建套接字sock_ socket(AF_INET, SOCK_DGRAM, 0);if(sock_ -1){std::cout Create Socket Fail: strerror(errno) std::endl;exit(SOCKET_ERR);}// 创建成功std::cout Create Success Socket: sock_ std::endl;// 2.绑定IP地址和端口号struct sockaddr_in local;bzero(local, sizeof(local)); // 置0// 填充字段local.sin_family AF_INET; // 设置为网络通信PF_INET 也行local.sin_port htons(port_); // 主机序列转为网络序列// local.sin_addr.s_addr inet_addr(ip_.c_str()); // 点分十进制转为短整数再将主机序列转为网络序列local.sin_addr.s_addr INADDR_ANY; // 绑定任何可用IP地址// 绑定IP地址和端口号if(bind(sock_, (const sockaddr*)local, sizeof(local))){std::cout Bind IPPort Fail: strerror(errno) std::endl;exit(BIND_ERR);}// 绑定成功std::cout Bind IPPort Success std::endl;}// 启动服务器void StartServer(){// 服务器是不断运行的所以需要使用一个 while(true) 死循环char buff[1024]; // 缓冲区while(true){// 1. 接收消息struct sockaddr_in peer; // 客户端结构体socklen_t len sizeof(peer); // 客户端结构体大小// 传入 sizeof(buff) - 1 表示当前传输的是字符串预留一个位置存储 \0// 传入 0 表示当前是阻塞式读取ssize_t n recvfrom(sock_, buff, sizeof(buff) - 1, 0, (struct sockaddr*)peer, len);if(n 0)buff[n] \0;elsecontinue; // 继续读取// 2.处理数据std::string clientIp inet_ntoa(peer.sin_addr); // 获取IP地址uint16_t clientPort ntohs(peer.sin_port); // 获取端口号printf(Server get message from [%s:%d]$ %s\n,clientIp.c_str(), clientPort, buff);// 获取业务处理后的结果std::string respond serverHandle_(buff);// 3.回响给客户端n sendto(sock_, respond.c_str(), respond.size(), 0, (const struct sockaddr*)peer, sizeof(peer));if(n -1)std::cout Send Message Fail: strerror(errno) std::endl;}}private:int sock_; // 套接字uint16_t port_; // 端口号func_t serverHandle_; // 业务处理函数回调函数};
}现在只需要关注业务处理如何实现无需考虑具体的网络传输如何实现
4.2.大写转小写
现阶段实现一个将大写字符转换为小写字符的函数易如反掌只需注意一点就好了对于非大写的字符不需要进行改动
函数实现完成后将其作为参数传递给 UdpServer 类型构造出相应的对象
#include memory // 智能指针相关头文件
#include server.hppusing namespace std;
using namespace nt_server;// 大写转小写英文字母
std::string UpToLow(const std::string resquest)
{std::string ret(resquest);for(auto rc : ret){if(isupper(rc))rc 32;}return ret;
}int main()
{unique_ptrUdpServer usvr(new UdpServer(UpToLow));// 初始化服务器usvr-InitServer();// 启动服务器usvr-StartServer();return 0;
}至此只需要客户端传入一段消息如果消息中包含了大写字符我们的服务器就会将其转为小写字符然后将消息发送给客户端相当于之前单纯回响字符串的加强版 客户端仍然只需发送消息、接收消息可以直接使用之前的客户端 重新编译并运行服务器通过客户端发送信息可以看到大写字符确实都被转为小写字符了 如果想实现小写转大写或其他转换需求只需要重新编写业务处理函数将其作为参数传递给 UdpServer 类即可
注意 传递的业务处理函数在返回值、参数方面必须与类中的回调函数类型一致
4.3.远程bash
bash 指令是如何执行的
接收指令字符串对指令进行分割构成有效信息创建子进程执行进程替换子进程运行结束后父进程回收僵尸进程输入特殊指令时的处理
可以自己 模拟实现简易版 bash不过这样做太麻烦了
也可以直接使用系统提供的 popen 函数
#include stdio.hFILE *popen(const char *command, const char *type);int pclose(FILE *stream);参数解读
command 想要执行的指令type 打开文件的方式r / w / a
返回值执行成功返回最终执行结果的文件流句柄失败返回 NULL
这个函数做了这些事创建管道、创建子进程、执行指令、将执行结果以 FILE* 的形式返回 函数执行过程中可能遇到 fork 创建子进程失败或者 pipe 创建管道失败无论遇到哪种问题最终函数都会执行失败并返回 NULL 因为这里返回的是 FILE*证明其涉及了文件流相关操作在使用结束后需要使用 pclose 手动关闭文件流
编写远程 bash 的业务处理函数如下 ExecCommand() 业务处理函数 — 位于 server.cc 服务器源文件 // 远程 bash
std::string ExecCommand(const std::string request)
{// 1.安全检查// ...// 2.获取执行结果FILE* fp popen(request.c_str(), r);if(fp NULL)return Cant execute command!;// 3.将结果读取至字符串中std::string ret;char buffline[1024]; // 行缓冲区while (fgets(buffline, sizeof(buffline), fp) ! NULL){// 将每一行结果添加至 ret 中ret buffline;}// 4.关闭文件流fclose(fp);// 5.返回最终执行结果return ret;
}此时需要考虑一个问题如果别人输入的是敏感指令比如 rm -rf *怎么办
答案当然是直接拦截不让别人执行敏感操作毕竟 Linux 默认可没有回收站所以我们还需要考虑安全检查 敏感操作包含这些kill 发送信号终止进程、mv 移动文件、rm 删除文件、while :; do 死循环、shutdown 关机等等 在执行用户传入的指令前先对指令中的子串进行扫描如果发现敏感操作就直接返回不再执行后续操作 checkSafe() 安全检查函数 — 位于 server.cc 服务器源文件 // 安全检查
bool checkSafe(const std::string comm)
{// 构建安全检查组std::vectorstd::string unsafeComms{kill, mv, rm, while :; do, shutdown};// 查找 comm 中是否包含安全检查组中的字段for(auto str : unsafeComms){// 如果找到了就说明存在不安全的操作if(comm.find(str) ! std::string::npos)return false;}return true;
}将 checkSafe 安全检查函数整合进 ExecCommand 业务处理函数中同时在构建 UdpServer 对象时传入该业务处理函数对象编译并运行程序
#include string
#include vector
#include memory // 智能指针相关头文件
#include cstdio
#include server.hppusing namespace std;
using namespace nt_server;// 安全检查
bool checkSafe(const std::string comm)
{// 构建安全检查组std::vectorstd::string unsafeComms{kill, mv, rm, while :; do, shutdown};// 查找 comm 中是否包含安全检查组中的字段for(auto str : unsafeComms){// 如果找到了就说明存在不安全的操作if(comm.find(str) ! std::string::npos)return false;}return true;
}// 远程 bash
std::string ExecCommand(const std::string request)
{// 1.安全检查if(!checkSafe(request))return Non-safety instructions, refusal to execute!;// 2.获取执行结果FILE* fp popen(request.c_str(), r);if(fp NULL)return Cant execute command!;// 3.将结果读取至字符串中std::string ret;char buffline[1024]; // 行缓冲区while (fgets(buffline, sizeof(buffline), fp) ! NULL){// 将每一行结果添加至 ret 中ret buffline;}// 4.关闭文件流fclose(fp);// 5.返回最终执行结果return ret;
}int main()
{unique_ptrUdpServer usvr(new UdpServer(ExecCommand));// 初始化服务器usvr-InitServer();// 启动服务器usvr-StartServer();return 0;
}可以看到输入安全指令时可以正常获取结果如果输入的是非安全指令会直接拒绝执行 诸如 cd 这种指令称为 内建命令是需要特殊处理的所以这里才会执行失败关于如何处理可以跳转至这篇博客查看 《Linux模拟实现【简易版bash】》 平时使用的 Xshell 本质上就是这样一款网络程序我们将指令发给 Xshell 服务器它再以类似于 fopen 的方式转发给服务器获取执行结果后展示给用户 5.多人聊天室
5.1.核心功能
这是基于 UDP 协议实现的最后一个网络程序主要功能是 构建一个多人聊天室当某个用户发送消息时其他用户可以立即收到形成一个群聊
在这个程序中服务器扮演了一个接收消息和分发消息的角色将消息发送给已知的用户主机 5.2.程序结构
将服务器接收消息看作生产商品、分发消息看作消费商品这不就是一个生动形象的 「生产者消费者模型」 吗
「生产者消费者模型」 必备 321
3三组关系2两个角色1一个交易场所
其中两个角色可以分别创建两个线程一个负责接收消息放入 「生产者消费者模型」另一个则是负责从 「生产者消费者模型」 中拿去消息分发给用户主机
这里的交易场所可以选则 阻塞队列也可以选择 环形队列
关于 「生产者消费者模型」 的更多知识详见 《Linux多线程【生产者消费者模型】》 注意 并非只有客户端 A 可以向环形队列中放消息所有客户端主机的地位都是平等的允许存放消息也允许接收别人发的消息 服务器 5.3.引入环形队列
在引入 「生产者消费者模型」 后服务器头文件结构将会变成下面这个样子
启动服务器原初始化服务器、启动线程接收消息将收到的消息存入环形队列发送消息从环形队列中获取消息并派发给线程
接下来包含环形队列 RingQueue.hpp 相关头文件具体实现详见 《Linux多线程【生产者消费者模型】》中的环形队列 这里实现的是多人聊天室也就不再需要传入回调函数了 server.hpp 服务器头文件 #pragma once#include iostream
#include string
#include functional
#include cstring
#include cerrno
#include cstdlib
#include strings.h
#include sys/types.h
#include sys/socket.h
#include netinet/in.h
#include arpa/inet.h
#include err.hpp
#include RingQueue.hppnamespace nt_server
{// 端口号默认值const uint16_t default_port 8888;class UdpServer{public:// 构造UdpServer(uint16_t port default_port):port_(port){}// 析构~UdpServer(){} // 初始化服务器void StartServer(){// 1.创建套接字sock_ socket(AF_INET, SOCK_DGRAM, 0);if(sock_ -1){std::cout Create Socket Fail: strerror(errno) std::endl;exit(SOCKET_ERR);}// 创建成功std::cout Create Success Socket: sock_ std::endl;// 2.绑定IP地址和端口号struct sockaddr_in local;bzero(local, sizeof(local)); // 置0// 填充字段local.sin_family AF_INET; // 设置为网络通信PF_INET 也行local.sin_port htons(port_); // 主机序列转为网络序列// local.sin_addr.s_addr inet_addr(ip_.c_str()); // 点分十进制转为短整数再将主机序列转为网络序列local.sin_addr.s_addr INADDR_ANY; // 绑定任何可用IP地址// 绑定IP地址和端口号if(bind(sock_, (const sockaddr*)local, sizeof(local))){std::cout Bind IPPort Fail: strerror(errno) std::endl;exit(BIND_ERR);}// 绑定成功std::cout Bind IPPort Success std::endl;}// 接收消息void RecvMessage(){// 服务器是不断运行的所以需要使用一个 while(true) 死循环char buff[1024]; // 缓冲区while(true){// 1. 接收消息struct sockaddr_in peer; // 客户端结构体socklen_t len sizeof(peer); // 客户端结构体大小// 传入 sizeof(buff) - 1 表示当前传输的是字符串预留一个位置存储 \0// 传入 0 表示当前是阻塞式读取ssize_t n recvfrom(sock_, buff, sizeof(buff) - 1, 0, (struct sockaddr*)peer, len);if(n 0)buff[n] \0;elsecontinue; // 继续读取// 2.处理数据std::string clientIp inet_ntoa(peer.sin_addr); // 获取IP地址uint16_t clientPort ntohs(peer.sin_port); // 获取端口号printf(Server get message from [%s:%d]$ %s\n,clientIp.c_str(), clientPort, buff);// 3.判断是否该添加用户// TODO// 4.将消息添加至环形队列std::string msg [ clientIp : std::to_string(clientPort) ] say# buff;rq_.Push(msg);}}// 广播消息void BroadcastMessage(){while(true){// 1.从环形队列中获取消息std::string msg;rq_.Pop(msg);// 2.将消息发给用户// TODO}}private:int sock_; // 套接字uint16_t port_; // 端口号Yohifo::RingQueuestd::string rq_; // 环形队列};
}5.4.引入用户信息
在首次接收到某个用户的信息时需要将其进行标识以便后续在进行消息广播时分发给他 有点类似于用户首次发送消息就被拉入了 “群聊” 目前可以使用 IP Port 的方式标识用户确保用户的唯一性这里选取 unordered_map 这种哈希表结构方便快速判断用户是否已存在
key用户标识符value用户客户端的 sockaddr_in 结构体
注意 这里的哈希表后面会涉及多线程的访问需要加锁保护
为了方便起见直接使用了之前编写的 LockGuard.hpp 小组件具体实现详见《Linux多线程【线程互斥与同步】》 server.hpp 服务器头文件 #pragma once// ...
#include unordered_map
// ...
#include LockGuard.hppnamespace nt_server
{// 端口号默认值const uint16_t default_port 8888;class UdpServer{public:// 构造UdpServer(uint16_t port default_port):port_(port){// 初始化互斥锁pthread_mutex_init(mtx_, nullptr);}// 析构~UdpServer(){// 销毁互斥锁pthread_mutex_destroy(mtx_);} // 初始化服务器void StartServer(){// ...}// 接收消息void RecvMessage(){// 服务器是不断运行的所以需要使用一个 while(true) 死循环char buff[1024]; // 缓冲区while(true){// 1. 接收消息// ...// 2.处理数据// ...// 3.判断是否该添加用户std::string user clientIp - std::to_string(clientPort);{// 需要加锁保护LockGuard lockguard(mtx_);if(userTable_.count(user) 0)userTable_[user] peer; // 首次出现需要添加}// 4.将消息添加至环形队列// ...}}// 广播消息void BroadcastMessage(){while(true){// 1.从环形队列中获取消息// ...// 2.将消息发给用户std::vectorsockaddr_in arr;{// 从哈希表中读取信息时需要保护LockGuard lockguard(mtx_);for(auto user : userTable_)arr.push_back(user.second);}for(auto addr : arr){// 发送消息sendto(sock_, msg.c_str(), msg.size(), 0, (const sockaddr*)addr, sizeof(addr));}}}private:// ...std::unordered_mapstd::string, struct sockaddr_in userTable_; // 用户标识符, sockaddr_in 结构体pthread_mutex_t mtx_; // 互斥锁保护哈希表};
}这里的实现有一个小细节在进行广播消息时先在加锁的情况下将用户的 sockaddr_in 结构体存储在遍历发送消息
这样做的好处在于可以在一定程度上提高通信效率因为 sendto 函数涉及 IO 操作IO 本来就很慢加锁后就会更慢了先在加锁情况下将用户 sockaddr_in 结构体保存后再遍历发送消息就无需加锁了因为此时没有涉及临界资源的操作
5.5.引入多线程
最后引入 「生产者消费者」 模型中的两种角色生产者、消费者也就是两个线程原生线程库的操作有点麻烦了我们同样可以搬出之前实现的小组件 Thread.hpp更加轻松的实现线程操作具体实现详见《Linux多线程【线程互斥与同步】》 如何引入多线程 创建两个线程 A、B将接收消息作为线程 A 的回调函数广播消息作为线程 B 的回调函数当两个线程都运行后整个模型也就动起来了 为了使我们当前服务器的函数对象能成功绑定至 Thread 对象需要修改 Thread 类使用 function 包装器 Thread.hpp 线程库类 // ...// 参数、返回值为 void 的函数类型
// typedef void (*func_t)(void*);
using func_t std::functionvoid(void*); // 使用包装器设定函数类型// ...因为当前涉及了多线程相关操作在编译代码时需要指明使用 pthread 库将 Makefile 内容更新如下 Makefile .PHONY:all
all:server clientserver:server.ccg -o $ $^ -stdc11 -lpthreadclient:client.ccg -o $ $^ -stdc11 -lpthread.PHONY:clean
clean:rm -rf server clientserver.hpp 服务器头文件 #pragma once#include iostream
#include string
#include unordered_map
#include functional
#include cstring
#include cerrno
#include cstdlib
#include strings.h
#include sys/types.h
#include sys/socket.h
#include netinet/in.h
#include arpa/inet.h
#include err.hpp
#include RingQueue.hpp
#include LockGuard.hpp
#include Thread.hppnamespace nt_server
{// 端口号默认值const uint16_t default_port 8888;class UdpServer{public:// 构造UdpServer(uint16_t port default_port):port_(port){// 初始化互斥锁pthread_mutex_init(mtx_, nullptr);// 创建线程// 注意因为类内成员有隐含的 this 指针需要借助 bind 固定该参数producer_ new Thread(1, std::bind(UdpServer::RecvMessage, this));consumer_ new Thread(2, std::bind(UdpServer::BroadcastMessage, this));}// 析构~UdpServer(){// 等待线程运行结束producer_-join();consumer_-join();// 销毁互斥锁pthread_mutex_destroy(mtx_);// 释放对象delete producer_;delete consumer_;} // 初始化服务器void StartServer(){// 1.创建套接字sock_ socket(AF_INET, SOCK_DGRAM, 0);if(sock_ -1){std::cout Create Socket Fail: strerror(errno) std::endl;exit(SOCKET_ERR);}// 创建成功std::cout Create Success Socket: sock_ std::endl;// 2.绑定IP地址和端口号struct sockaddr_in local;bzero(local, sizeof(local)); // 置0// 填充字段local.sin_family AF_INET; // 设置为网络通信PF_INET 也行local.sin_port htons(port_); // 主机序列转为网络序列// local.sin_addr.s_addr inet_addr(ip_.c_str()); // 点分十进制转为短整数再将主机序列转为网络序列local.sin_addr.s_addr INADDR_ANY; // 绑定任何可用IP地址// 绑定IP地址和端口号if(bind(sock_, (const sockaddr*)local, sizeof(local))){std::cout Bind IPPort Fail: strerror(errno) std::endl;exit(BIND_ERR);}// 绑定成功std::cout Bind IPPort Success std::endl;// 启动线程producer_-run();consumer_-run();}// 接收消息void RecvMessage(){// 服务器是不断运行的所以需要使用一个 while(true) 死循环char buff[1024]; // 缓冲区while(true){// 1. 接收消息struct sockaddr_in peer; // 客户端结构体socklen_t len sizeof(peer); // 客户端结构体大小// 传入 sizeof(buff) - 1 表示当前传输的是字符串预留一个位置存储 \0// 传入 0 表示当前是阻塞式读取ssize_t n recvfrom(sock_, buff, sizeof(buff) - 1, 0, (struct sockaddr*)peer, len);if(n 0)buff[n] \0;elsecontinue; // 继续读取// 2.处理数据std::string clientIp inet_ntoa(peer.sin_addr); // 获取IP地址uint16_t clientPort ntohs(peer.sin_port); // 获取端口号printf(Server get message from [%s:%d]$ %s\n,clientIp.c_str(), clientPort, buff);// 3.判断是否该添加用户std::string user clientIp - std::to_string(clientPort);{// 需要加锁保护LockGuard lockguard(mtx_);if(userTable_.count(user) 0)userTable_[user] peer; // 首次出现需要添加}// 4.将消息添加至环形队列std::string msg [ clientIp : std::to_string(clientPort) ] say# buff;rq_.Push(msg);}}// 广播消息void BroadcastMessage(){while(true){// 1.从环形队列中获取消息std::string msg;rq_.Pop(msg);// 2.将消息发给用户std::vectorsockaddr_in arr;{// 从哈希表中读取信息时需要保护LockGuard lockguard(mtx_);for(auto user : userTable_)arr.push_back(user.second);}for(auto addr : arr){// 发送消息sendto(sock_, msg.c_str(), msg.size(), 0, (const sockaddr*)addr, sizeof(addr));}}}private:int sock_; // 套接字uint16_t port_; // 端口号Yohifo::RingQueuestd::string rq_; // 环形队列std::unordered_mapstd::string, struct sockaddr_in userTable_; // 用户标识符, sockaddr_in 结构体pthread_mutex_t mtx_; // 互斥锁保护哈希表Thread* producer_; // 生产者Thread* consumer_; // 消费者};
}以上就是 多人聊天室 中 server.hpp 服务器头文件的全部设计了至于 server.cc 服务器源文件几乎不用修改 server.cc 服务器源文件 #include string
#include vector
#include memory // 智能指针相关头文件
#include cstdio
#include server.hppusing namespace std;
using namespace nt_server;int main()
{unique_ptrUdpServer usvr(new UdpServer());// 启动服务器usvr-StartServer();return 0;
}接下来编译并运行程序可以看到此时有三个线程在运行一个 server 主线程一个生产者线程一个消费者线程 分别使用两台主机运行客户端可以看到主机 A 确实可以看到主机 B 发送的信息不过问题在于 无法实时更新消息需要自己发送消息后才能看到别人发的消息 出现这种情况的原因是 客户端只有一个线程发送消息的后才能接收消息 这就很尴尬了假设这个群聊里有十个用户那用户 A 岂不是自己至少得发送 9 条消息才能看到其他九位用户之前发送的消息 所以客户端也需要多线程化接下来就是对客户端的改造 客户端 5.6.多线程化
有了之前 server.hpp 服务器头文件多线程化的经验后改造 client.hpp 客户端头文件就很简单了同样是创建两个线程一个负责发送消息一个负责接收消息
这里同样使用 Thread.hpp 线程类 client.hpp 客户端头文件 #pragma once#include iostream
#include string
#include functional
#include cstring
#include cerrno
#include cstdlib
#include strings.h
#include sys/types.h
#include sys/socket.h
#include netinet/in.h
#include arpa/inet.h
#include err.hpp
#include Thread.hpp
#include LockGuard.hppnamespace nt_client
{class UdpClient{public:// 构造UdpClient(const std::string ip, uint16_t port):server_ip_(ip), server_port_(port){// 创建线程recv_ new Thread(1, std::bind(UdpClient::RecvMessage, this));send_ new Thread(2, std::bind(UdpClient::SendMessage, this));}// 析构~UdpClient() {// 等待线程退出recv_-join();send_-join();delete (recv_);delete (send_);} // 启动客户端void StartClient() {// 1.创建套接字sock_ socket(AF_INET, SOCK_DGRAM, 0);if(sock_ -1){std::cout Create Socket Fail: strerror(errno) std::endl;exit(SOCKET_ERR);}std::cout Create Success Socket: sock_ std::endl;// 2.构建服务器的 sockaddr_in 结构体信息bzero(svr_, sizeof(svr_));svr_.sin_family AF_INET; // 设置为网络通信PF_INET 也行svr_.sin_addr.s_addr inet_addr(server_ip_.c_str()); // 绑定服务器IP地址svr_.sin_port htons(server_port_); // 绑定服务器端口号// 启动线程recv_-run();send_-run();}// 发送消息void RecvMessage() {while(true){// 发送消息std::string msg;std::cout Input Message# ;std::getline(std::cin, msg);ssize_t n sendto(sock_, msg.c_str(), msg.size(), 0, (const struct sockaddr*)svr_, sizeof(svr_));if(n -1){std::cout Send Message Fail: strerror(errno) std::endl;continue; // 重新输入消息并发送}}}// 接收消息void SendMessage(){char buff[1024];while(true){// 2.接收消息socklen_t len sizeof(svr_); // 创建一个变量因为接下来的参数需要传左值ssize_t n recvfrom(sock_, buff, sizeof(buff) - 1, 0, (struct sockaddr*)svr_, len);if(n 0)buff[n] \0;elsecontinue;std::cout Client get message buff std::endl;}}private:std::string server_ip_; // 服务器IP地址uint16_t server_port_; // 服务器端口号int sock_;struct sockaddr_in svr_; // 服务器的sockadder_in结构体信息Thread* recv_; // 发送消息Thread* send_; // 接收消息};
}client.cc 客户端源文件 #include iostream
#include memory
#include memory
#include client.hppusing namespace std;
using namespace nt_client;void Usage(const char* program)
{cout Usage: endl;cout \t program ServerIP ServerPort endl;
}int main(int argc, char* argv[])
{if (argc ! 3){// 错误的启动方式提示错误信息Usage(argv[0]);return USAGE_ERR;}std::string ip argv[1];uint16_t port stoi(argv[2]);unique_ptrUdpClient usvr(new UdpClient(ip, port));// 启动客户端usvr-StartClient();return 0;
}
客户端改造完成后再次服务器与客户端可以看到现在已经正常了多人聊天室 构建完毕
注因为客户端发送消息、接收消息使用的是同一个文件描述符属于临界资源所以显示时出现问题很正常 关于输入、输出消息剥离的问题可以利用标准输出、标准错误 管道的方式进行区分限于篇幅原因这里不再阐述 至此基于 UDP 协议实现的多个网络程序都已经编写完成了尤其是 多人聊天室如果加上简单的图形化界面比如 EasyX、EGE就是一个简易版的 QQ 群聊 ️总结
以上就是本次关于 网络编程『socket套接字 ‖ 简易UDP网络程序』的全部内容了在本文中首先学习了一批预备知识包括 IP 地址、端口号、网络字节序等然后学习 socket 套接字编程相关接口学以致用基于 UDP 协议实现了各种网络程序小到字符串回响大到多人聊天室用到了之前系统学习的大部分知识后面还会基于 TCP 编写网络程序加深对 socket 套接字编程的理解 相关文章推荐 网络基础『发展 ‖ 协议 ‖ 传输 ‖ 地址』