<网络> 网络Socket 编程基于UDP协议模拟简易网络通信

目录

前言:

一、预备知识

(一)IP地址

(二)端口号

(三)端口号与进程PID

(四)传输层协议

(五)网络字节序

二、socket 套接字

(一)socket 常见API

(二)sockaddr 结构体

三、基于UDP模拟网络通信

(一)核心功能

(二)服务端客户端程序结构

(三)服务端设计

1. 创建套接字

2. 绑定IP地址和端口号

3. 启动服务器

(四)客户端设计

1. 指定IP地址和端口号

2. 初始化客户端

3. 启动客户端

四、大写转小写、远程bash

(一)业务处理函数解耦

(二)大写转小写

(三)远程bash

五、多人聊天室

(一)业务处理功能

(二)程序结构

(三)引入环形队列

(四)入用户信息

(五)引入多线程

(六)客户端多线程化


前言:

网络编程(Network Programming)是指编写程序来实现计算机网络之间的通信。这通常涉及到使用套接字(sockets)来建立连接、发送和接收数据。网络编程可以应用于各种场景,如开发网站、构建分布式系统、实现网络服务等。

一、预备知识

(一)IP地址

我们知道 IP 是全球网络的基础使用 IP 地址来标识公网环境下主机的唯一性,我们可以根据 目的IP地址 进行跨路由器的远端通信(将信息从主机 A 发送至主机 Z )。

仅仅使用 IP 只能定位到目标主机,并且目标主机不是最终目的地,要想定位到最终目的地,需要依靠 端口号。

目标主机中存在很多进程,网络通信实际是不同主机中的进程在进行通信,并非主机与主机直接通信。

(二)端口号

端口号 是一个用于标识网络进程唯一性的标识符,是一个 2 字节(16位)的整数,取值范围为 [0, 65535],可以通过 端口号 定位主机中的目标进程。其中,0至1023之间的端口号是系统保留的,只能由系统进程使用,而剩下的端口号则可以用于用户程序和服务。

抛开网络其他知识,将信息从主机 A 中的进程 A 发送至主机 B 中的 进程 B,这不就是 进程间通信 吗?之前学习的 进程间通信 是通过 匿名管道、命名管道、共享内存 等方式实现,而如今的 进程间通信 则是通过 网络传输 的方式实现。

需要进行网络通信的进程有很多,为了方便进行管理,就诞生了 端口号 这个概念,同进程的 PID 一样,端口号 也可以用于标识进程。

(三)端口号与进程PID

端口号 用于标识进程,进程 PID 也是用于标识进程,为什么在网络中,不直接使用进程 PID 呢?

  • 进程 PID 隶属于操作系统中的进程管理,如果在网络中使用 PID,会导致网络标准中被迫引入进程管理相关概念(进程管理与网络强耦合)。
  • 进程管理 属于 OS 内部中的功能,OS 可以有很多标准,但网络标准只能有一套,在网络中直接使用 PID 无法确保网络标准的统一性。
  • 并不是所有的进程都需要进行网络通信,如果端口号、PID 都使用同一个解决方案,无疑会影响网络管理的效。

所以综上所述,网络中的 端口号 需要通过一种全新的方式实现,也就是一个2字节的整数 port,进程 A 运行后,可以给它绑定 端口号 N,在进行网络通信时,根据 端口号 N 来确定信息是交给主机Z的进程 A 的:

所以将之前的结论再具体一点:IP + Port 可以标识公网环境下,唯一的网络进程

网络传输中的必备信息组 [目的IP 源 IP && 目的 Port 源 Port]

  • 目的 IP需要把信息发送到哪一台主机
  • 源 IP信息从哪台主机中发出
  • 目的 Port将信息交给哪一个进程
  • 源 Port信息从哪一个进程中发出

注意: 端口号与进程 PID 并不是同一个概念

进程 PID 就好比你的身份证号,端口号 相当于学号,这两个信息都可以标识唯一的你,但对于学校来说,使用学号更方便进行管理。


一个进程可以绑定多个 端口号 吗?一个 端口号 可以被多个进程绑定吗?

端口号 的作用是配合 IP 地址标识网络世界中进程的唯一性,如果一个进程绑定多个 端口号,依然可以保证唯一性(因为无论使用哪个 端口号,信息始终只会交给一个进程);但如果一个 端口号 被多个进程绑定了,在信息递达时,是无法分辨该信息的最终目的进程的,存在二义性。

所以一个进程可以绑定多个端口号,一个 端口号 不允许被多个进程绑定,如果被绑定了,可以通过 端口号 顺藤摸瓜,找到占用该 端口号 的进程。

如果某个端口号被使用了,其他进程再继续绑定是会报错的,提示 该端口已被占用


主机(操作系统)是如何根据 端口号 定位具体进程的?

这个实现起来比较简单,创建一张哈希表,维护 <端口号, 进程 PID> 之间的映射关系,当信息通过网络传输到目标主机时,操作系统可以根据其中的 [目的 Port],直接定位到具体的进程 PID,然后进行通信。

(四)传输层协议

主流的传输层协议有两个:TCP 和 UDP

两个协议各有优缺点,可以采用不同的协议,实现截然不同的网络程序,关于 TCP 和 UDP的详细信息将会放到后面的文章中详谈,先来看看简单这两种协议的特点:

TCP 协议:传输控制协议

  • 传输层协议
  • 有连接
  • 可靠传输
  • 面向字节流

字节流就像水龙头,用户可以根据自己的需求获取水流量

UDP协议:用户数据协议

  • 传输层协议
  • 无连接
  • 不可靠传输
  • 面向数据报

数据报则是相当于包裹,用户每次获取的都是一个或多个完整的包裹

关于 可靠性
TCP 的可靠传输并不意味着它可以将数据百分百递达,而是说它在数据传输过程中,如果发生了传输失败的情况,它会通过自己独特的机制,重新发送数据,确保对端百分百能收到数据;至于 UDP 就不一样,数据发出后,如果失败了,也不会进行重传,好在 UDP 面向数据报,并且没有很多复杂的机制,所以传输速度很快。

总结起来就是:TCP 用于对数据传输要求较高的领域,比如金融交易、网页请求、文件传输等,至于 UDP 可以用于短视频、直播、即时通讯等对传输速度要求较高的领域。

如果不知道该使用哪种协议,优先考虑 TCP,如果对传输速度有要求,可以选择 UDP

(五)网络字节序

预备知识

  • 数据拥有高权值位和低权值位,比如在 32 位操作系统中,十六进制数 0x11223344,其中的 11 称为 最高权值位44 称为 最低权值位
  • 内存有高地址和低地址之分

如果将数据的高权值存放在内存的低地址处,低权值存放在高地址处,此时就称为 大端字节序,反之则称为 小端字节序,这两种字节序没有好坏之分,只是系统设计者的使用习惯问题,比如我当前的电脑在存储数据时,采用的就是 小端字节序 方案:

通过内存单元可以看到,使用 小端字节序 时数据是倒着放的,大端字节序 就是正着存放了

大小端字节序就有点像吃香蕉时的方式,有的人是从头部开始剥皮,有的人是从尾部开始剥皮,两种方式都能吃到香蕉,纯属习惯问题。

在网络出现之前,使用大端或小端存储都没有问题,网络出现之后,就需要考虑使用同一种存储方案了,因为网络通信时,两台主机存储方案可能不同,会出现无法解读对方数据的问题。

如果你是网络标准的设计者,你会如何解决?
解决方案1:数据发送前,给报文中添加大小端的标记字段,待数据递达后,对端在根据标志位进行解读,再进行转换。 这个方案实现起来不太方便,并且给每一个报文都添加标记字段这个行为比较浪费。
解决方案2:书同文,车同轨,直接统一标准。 这种解决方案就很彻底了,直接从根源上解决问题,也更方便。

顶层设计者采用了解决方案2,TCP/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位短整数

二、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 地址、端口号 等网络通信必备项,本地通信只需要提供一个路径名,通过文件读写的方式进行通信(类似于命名管道)

socket 提供的接口参数为 sockaddr*,我们既可以传入 &sockaddr_in 进行网络通信,也可以传入 &sockaddr_un 进行本地通信传参时将参数进行强制类型转换即可,这是使用 C语言 实现 多态 的典型做法,确保该标准的通用性。

为什么不将参数设置为 void* ?
因为在该标准设计时,C语言还不支持 void* 这种类型,为了确保向前兼容性,即便后续支持后也不能进行修改了。

三、基于UDP模拟网络通信

(一)核心功能

分别实现客户端与服务器,客户端向服务器发送消息,服务器收到消息后,回响给客户端,有点类似于 echo 指令

该程序的核心在于 使用 socket 套接字接口,以 UDP 协议的方式实现简单网络通信

(二)服务端客户端程序结构

程序由 server.hppserver.ccclient.hppclient.cc 组成,大体框架如下:

创建 server.hpp 服务器头文件

#pragma once#include <iostream>namespace ns_server
{class UdpServer{public:// 构造UdpServer(){}// 析构~UdpServer(){}// 初始化void Init(){}// 启动void Start(){}private:// ...    };
}

创建 server.cc 服务器源文件

#include <memory>#include "server.hpp"using namespace std;
using namespace ns_server;int main()
{unqiue_ptr<UdpServer> us(new UdpServer());us->Init();us->Start();return 0;
}

创建 client.hpp 客户端头文件

#pragma once#include <iostream>namespace ns_client
{class UdpClient{public:// 构造UdpClient(){}// 析构~UdpClient(){}// 初始化void Init(){}// 启动void Start(){}private:// ...      };
}

创建 client.cc 客户端源文件

#include <memory>#include "client.hpp"using namespace std;
using namespace ns_client;int main()
{unqiue_ptr<UdpClient> us(new UdpClient());us->Init();us->Start();return 0;
}

makefile 文件

.PHONY:all
all:udp_client udp_serverudp_client:udp_client.ccg++ -o $@ $^ -std=c++11udp_server:udp_server.ccg++ -o $@ $^ -std=c++11.PHONY:clean
clean:rm -f udp_client udp_server

准备工作完成后,接下来着手填充代码内容.

(三)服务端设计

1. 创建套接字

创建套接字使用 socket 函数

#include <sys/types.h>
#include <sys/socket.h>// 创建套接字(TCP/UDP	服务器/客户端)
int socket(int domain, int type, int protocol);

  • 参数1:domain 创建套接字用于哪种通信(网络/本地)
  • 参数2:type 选择数据传输类型(流式/数据报)
  • 参数3:protocol 选择协议类型(支持根据参数2自动推导)

返回值:创建成功后,返回套接字(文件描述符),失败返回 -1

因为这里是使用 UDP 协议实现的 网络通信,参数2 domain 选择 AF_INET(基于 IPv4 标准),参数2 type 选择 SOCK_DGRAM数据报传输),参数3设置为 0,可以根据 SOCK_DGRAM 自动推导出使用 UDP 协议。

AF_INET6 为基于 IPv6 标准

在 server.hpp 的 Init() 函数中创建套接字,并对创建成功/失败后的结果做打印:

#pragma once#include <iostream>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <sys/types.h>
#include <sys/socket.h>namespace ns_server
{// 错误码enum{SOCKET_ERR = 1 };class UdpServer{public:// ...// 初始化void Init(){// 1. 创建套接字sock_ = socket(AF_INET, SOCK_DGRAM, 0);if(sock_ < 0){std::cerr << "create socket error: " << strerror(errno) << std::endl;exit(SOCKET_ERR);}std::cout << "create socket success: " << sock_ << std::endl;}private:int sock_; // 套接字};
}

文件描述符默认 0、1、2 都已经被占用了,如果再创建文件描述符,会从 3 开始,可以看到,程序运行后,创建的套接字正是 3,证明套接字本质上就是文件描述符,不过它用于描述网络资源:

2. 绑定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);

  • 参数1:sockfd 创建成功的套接字
  • 参数2:addr 包含通信信息的 sockaddr 结构体地址
  • 参数3:addrlen  结构体的大小

返回值:成功返回 0,失败返回 -1

参数1没啥好说的,重点在于参数2,因为我们这里是 网络通信,所以使用的是 sockaddr_in 结构体,要想使用该结构体,还得包含下面这两个头文件

#include <netinet/in.h>
#include <arpa/inet.h>

sockaddr_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 地址 和 端口号 了

注:作为服务器,需要确定自己的端口号,我这里设置的是 8080

#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>namespace ns_server
{// 错误码enum{SOCKET_ERR = 1, BIND_ERR};// 端口号默认值const static uint16_t default_port = 8080;class UdpServer{public:// 构造UdpServer(std::string ip, uint16_t port = default_port): ip_(ip), port_(port){std::cout << "server addr: " << ip << " : " << port_ << std::endl;}// 析构~UdpServer(){}// 初始化void Init(){// 1. 创建套接字sock_ = socket(AF_INET, SOCK_DGRAM, 0);if(sock_ < 0){std::cerr << "create socket error: " << strerror(errno) << std::endl;exit(SOCKET_ERR);}std::cout << "create socket success: " << 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_, (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(){}private:int sock_;         // 套接字uint16_t port_;    // 端口号std::string ip_;   // IP地址(后面需删除)};
}

注意:

  • 需要把主机序列转换为网络序列,可以使用 htons 函数
  • 需要把点分十进制的字符串,转换为无符号短整数,可以使用 inet_addr 函数,这个函数在进行转换的同时,会将主机序列转换为网络序列。
  • 绑定IP地址和端口号这个行为并非直接绑定到当前主机中,而是在当前程序中,将创建的 socket 套接字,与目标IP地址和端口号进行绑定,当程序终止后,这个绑定关系也会随之消失。

接下来编译并运行程序

#include <memory>#include "server.hpp"using namespace std;
using namespace ns_server;int main()
{unique_ptr<UdpServer> us(new UdpServer("43.139.55.38"));us->Init();us->Start();return 0;
}

可以发现绑定失败了,这是因为当前我使用的是云服务器,云服务器是不允许直接绑定公网 IP 的,解决方案是在绑定 IP 地址时,让其选择绑定任意可用 IP 地址

修改代码

  • 云服务器中不需要明确 IP 地址
  • 构造时也无需传入 IP 地址
  • 绑定 IP 地址时选择 INADDR_ANY,表示绑定任何可用的 IP 地址
    class UdpServer{public:// 构造UdpServer(uint16_t port = default_port):port_(port){std::cout << "server addr: " << port_ << std::endl;}// 初始化void Init(){// ...// 填充字段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_;   // IP地址(后面需删除)};
#include <memory>#include "server.hpp"using namespace std;
using namespace ns_server;int main()
{unique_ptr<UdpServer> us(new UdpServer());us->Init();us->Start();return 0;
}

 可以看到绑定成功,套接口的描述符为3:

服务器设置的端口,需要设置为开放状态,如果是本地服务器,可以使用 systemctl start firewalld.service 指令开启防火墙,再使用 firewall-cmd --zone=public --add-port=Port/tcp --permanent 开启指定的端口号。
如果是云服务器,就需要通过 控制台,开放对应的端口。

3. 启动服务器

当前编写的 回响服务器 需要服务器拥有接收读取信息,然后回响给客户端的能力。

从指定的网络接收数据,并将其存储到指定的缓冲区中,使用 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

接收消息步骤:

  1. 创建缓冲区、接收端 sockaddr_in 结构体
  2. 接收信息,判断是否接收成功
  3. 处理信息

所以接下来编写接收消息的逻辑

注意: 因为 recvfrom 函数的参数 src_addr 类型为 sockaddr,需要将 sockaddr_in 类型强转后,再进行传递

void Start()
{// 服务器是不断运行的,所以可以使用一个死循环来一直运行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';else continue; // 继续读取// 2. 处理数据std::string clientIP = inet_ntoa(peer.sin_addr); // 获取IP地址uint16_t clientPort = ntohs(peer.sin_port);      // 获取端口号printf("Server got messages 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 源文件(注意包含头文件)

#pragma once// 错误码
enum
{SOCKET_ERR = 1, BIND_ERR
};
// 3. 回响给客户端
n = sendto(sock_, buff, strlen(buff), 0, (struct sockaddr*)&peer, sizeof(peer));
if(n == -1)
{std::cout << "Send messages fali: " << strerror(errno) << std::ednl;
}

万事具备后,就可以启动服务器了,可以看到服务器启动后,处于阻塞等待状态,这是因为还没有客户端给我的服务器发信息,所以它就会暂时阻塞:

如何证明服务器正在运行?

可以通过 Linux 中查看网络状态的指令,因为我们这里使用的是 UDP 协议,所以只需要输入下面这条指令,就可以查看有哪些程序正在运行:

netstat -nlup

现在服务已经跑起来了,并且如期占用了 8080 端口,接下来就是编写客户端相关代码

0.0.0.0 表示任意IP地址

(四)客户端设计

1. 指定IP地址和端口号

客户端在运行时,必须知道服务器的 IP 地址 和 端口号,否则不知道自己该与谁进行通信,所以对于 UdpClient 类来说,ip 和 port 者两个字段是肯定少不了的:

#pragma once#include <iostream>
#include <string>
#include "err.hpp"namespace ns_client
{class UdpClient{public:// 构造UdpClient(std::string &ip, uint16_t port):server_ip_(ip), server_port_(port){}// 析构~UdpClient(){}// 初始化void Init(){}// 启动void Start(){}private:std::string server_ip_; // 服务端IP地址uint16_t server_port_;  // 服务端端口号};
}

这两个参数由用户主动输入,这里就需要 命令行 参数相关知识了,在启动客户端时,需要以 ./udp_client serverIp serverPort 的方式运行,否则就报错,并提示相关错误信息(更新 err.hpp 的错误码)

#include <memory>#include "client.hpp"using namespace std;
using namespace ns_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_ptr<UdpClient> us(new UdpClient(ip, port));us->Init();us->Start();return 0;
}
#pragma onceenum
{USAGE_ERR = 1,SOCKET_ERR,BIND_ERR
};

如此一来,只有正确的输入 [./udp_client ServerIP ServerPort] 才能启动程序,否则不让程序运行,倒逼客户端启动时,提供服务器的 IP 地址 和 端口号:

其实在浏览网页时输入的 url 网址,在经过转换后,其中也一定会包含服务器的 IP 地址 与 端口号,配合请求的资源路径,就能获取服务器资源了。

2. 初始化客户端

初始化客户端时,同样需要创建 socket 套接字,不同于服务器的是 客户端不需要自己手动绑定 IP 地址与端口号。

这是因为客户端手动指明 端口号 存在隐患:如果恰好有两个程序使用了同一个端口,会导致其中一方的客户端直接绑定失败,无法运行,将绑定 端口号 这个行为交给 OS 自动执行(首次传输数据时自动 bind),可以避免这种冲突的出现

那为什么服务器要自己手动指定端口号,并进行绑定?
这是因为服务器的端口不能随意改变,并且这是要公布给广大客户端看的,同一家公司在部署服务时,会对端口号的使用情况进行管理,可以直接避免端口号冲突。

客户端在启动前,需要先知晓服务器的 sockaddr_in 结构体信息,可以利用已知的 IP 地址 和 端口号 构建。

综上所述,在初始化客户端时,需要创建好套接字和初始化服务器的 sockaddr_in 结构体信息

#pragma once#include <iostream>
#include <string>
#include "err.hpp"namespace ns_client
{class UdpClient{public:// 构造UdpClient(std::string &ip, uint16_t port):server_ip_(ip), server_port_(port){}// 析构~UdpClient(){}// 初始化void Init(){// 1. 创建套接字sock_ = socket(AF_INET, SOCK_DGRAM, 0);if(sock_ < 0){std::cerr << "create socket error: " << strerror(errno) << std::endl;exit(SOCKET_ERR);}std::cout << "create socket success: " << 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 Start(){}private:std::string server_ip_;  // 服务端IP地址uint16_t server_port_;   // 服务端端口号int sock_;struct sockaddr_in svr_; // 服务端的sockadder_in结构体信息};
}

如此一来,客户端就可以利用该 sockaddr_in 结构体,与目标主机进行通信了

3. 启动客户端

接下来就是客户端向服务器发送消息,消息由用户主动输入,使用的是 sendto 函数

发送消息步骤

  1. 用户输入消息
  2. 传入缓冲区、服务器相关参数,使用 sendto 函数发送消息

消息发送后,客户端等待服务器回响消息

接收消息步骤:

  1. 创建缓冲区
  2. 接收信息,判断是否接收成功
  3. 处理信息

注:同服务器一样,客户端也需要不断运行

// 启动
void Start()
{char buffer[1024];while(true){// 1. 发送消息std::string msg;std::cout << "please input message# ";std::getline(std::cin, msg);ssize_t n = sendto(sock_, msg.c_str(), msg.size(), 0, (struct sockaddr*)&svr_, sizeof(svr_));if(n == -1){std::cout << "Send messages fail: " << strerror(errno) << std::endl;continue; // 重新输入消息并发送}// 2. 接收消息socklen_t len = sizeof(svr_); // 创建一个变量,因为接下来的参数需要传左值n = recvfrom(sock_, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&svr_, &len);if(n > 0) buffer[n] = '\0';else continue;// 可以再次获取IP地址和端口号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);}
}

现在左手 服务器,右手 客户端,直接编译运行,看看效果:

注:127.0.0.1 表示本地环回(通常用于测试网络程序),因为我当前的服务器和客户端都是在同一机器上运行的,所以就可以使用该 IP 地址,当然直接使用服务器的公网 IP 地址也是可以的。

通过 netstat -nlup 指令查看端口使用情况:

可以看到,服务器和客户端都成功运行了,OS 给客户端分配的 端口号 是 47283,这是随机分配的,每次重新运行后,大概率都不相同

至此基于 UDP 协议编写的第一个网络程序 字符串回响 就完成了,接下来对其进行改造,编写第二个网络程序。

四、大写转小写、远程bash

(一)业务处理函数解耦

基于模块化处理的思想,将服务器中处理消息的函数与启动服务的函数解耦,由程序员传入指定的回调函数

此时业务处理函数已经变成一个模块了,可以自由变换

  • 业务处理函数A:实现大写转小写
  • 业务处理函数B:实现远程 bash
  • 业务处理函数C:实现 xxx

服务器在启动时,只需要传入对应的业务处理函数(回调函数)即可

修改 server.hpp 的代码如下

使用 C++11 中的 function 包装器语法,包装出一个符合我们业务处理需求的函数类型

#pragma once#include <iostream>
#include <string>
#include <cstring>
#include <functional>
#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"namespace ns_server
{// 端口号默认值const static uint16_t default_port = 8080;// 参数为string,返回值同样为stringusing 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(){// 1. 创建套接字sock_ = socket(AF_INET, SOCK_DGRAM, 0);if(sock_ < 0){std::cerr << "create socket error: " << strerror(errno) << std::endl;exit(SOCKET_ERR);}std::cout << "create socket success: " << 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_, (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){// 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';else continue; // 继续读取// 2. 处理数据std::string clientIP = inet_ntoa(peer.sin_addr); // 获取IP地址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);// 3. 回响给客户端n = sendto(sock_, respond.c_str(), respond.size(), 0, (struct sockaddr*)&peer, sizeof(peer));if(n == -1){std::cout << "Send messages fali: " << strerror(errno) << std::endl;}}}private:int sock_;         // 套接字uint16_t port_;    // 端口号// std::string ip_;   // IP地址(后面需删除)func_t service_; // 业务处理函数};
}

现在只需要关注业务处理如何实现,无需考虑具体的网络传输如何实现

(二)大写转小写

现阶段实现一个将大写字符转换为小写字符的函数易如反掌,只需注意一点就好了:对于非大写的字符,不需要进行改动。

函数实现完成后,将其作为参数传递给 UdpServer 类型,构造出相应的对象

#include <memory>
#include "server.hpp"using namespace std;
using namespace ns_server;std::string transactionString(const std::string& resquest)
{std::string result(resquest);for(auto &r : result){if(isupper(r))r += 32;}return result;
}int main()
{unique_ptr<UdpServer> us(new UdpServer(transactionString));us->Init();us->Start();return 0;
}

至此只需要客户端传入一段消息,如果消息中包含了大写字符,我们的服务器就会将其转为小写字符,然后将消息发送给客户端,相当于之前单纯回响字符串的加强版。

客户端仍然只需发送消息、接收消息,可以直接使用之前的客户端。

重新编译并运行服务器,通过客户端发送信息,可以看到大写字符确实都被转为小写字符了:

如果想实现小写转大写,或其他转换需求,只需要重新编写业务处理函数,将其作为参数传递给 UdpServer 类即可。

注意: 传递的业务处理函数,在返回值、参数方面,必须与类中的回调函数类型一致。

(三)远程bash

bash 指令是如何执行的?

  1. 接收指令(字符串)
  2. 对指令进行分割,构成有效信息
  3. 创建子进程,执行进程替换
  4. 子进程运行结束后,父进程回收僵尸进程
  5. 输入特殊指令时的处理

我们可以直接使用系统提供的 popen 函数

#include <stdio.h>FILE *popen(const char *command, const char *type);int pclose(FILE *stream);

参数解读

  • command 想要执行的指令
  • type 打开文件的方式(r / w / a

返回值:执行成功返回最终执行结果的文件流句柄,失败返回 NULL。

这个函数做了这些事:创建管道、创建子进程、执行指令、将执行结果以 FILE* 的形式返回

函数执行过程中,可能遇到 fork 创建子进程失败,或者 pipe 创建管道失败,无论遇到哪种问题,最终函数都会执行失败,并返回 NULL

因为这里返回的是 FILE*,证明其涉及了文件流相关操作,在使用结束后,需要使用 pclose 手动关闭文件流。

编写远程 bash 的业务处理函数如下

std::string ExecuteCommand(const std::string& request)
{// 1. 安全检查// ... // 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;
}

此时需要考虑一个问题:如果别人输入的是敏感指令(比如 rm -rf *)怎么办?

答案当然是直接拦截,不让别人执行敏感操作,毕竟 Linux 默认可没有回收站,所以我们还需要考虑安全检查。

敏感操作包含这些:kill 发送信号终止进程、mv 移动文件、rm 删除文件、while :; do 死循环、shutdown 关机等等。

在执行用户传入的指令前,先对指令中的子串进行扫描,如果发现敏感操作,就直接返回,不再执行后续操作:

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 fasle;}return true;
}

将 checkSafe 安全检查函数整合进 ExecuteCommand业务处理函数中,同时在构建 UdpServer 对象时,传入该业务处理函数对象,编译并运行程序

#include <memory>
#include "server.hpp"using namespace std;
using namespace ns_server;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 fasle;}return true;
}std::string ExecuteCommand(const std::string& request)
{// 1. 安全检查// ... // 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()
{unique_ptr<UdpServer> us(new UdpServer(transactionString));us->Init();us->Start();return 0;
}

可以看到,输入安全指令时,可以正常获取结果,如果输入的是非安全指令,会直接拒绝执行:

平时使用的 Xshell 本质上就是这样一款网络程序,我们将指令发给 Xshell 服务器,它再以类似于 fopen 的方式转发给服务器,获取执行结果后展示给用户:

五、多人聊天室

(一)业务处理功能

这是基于 UDP 协议实现的最后一个网络程序,主要功能是 构建一个多人聊天室,当某个用户发送消息时,其他用户可以立即收到,形成一个群聊。

在这个程序中,服务器扮演了一个接收消息和分发消息的角色,将消息发送给已知的用户主机

(二)程序结构

将服务器接收消息看作生产商品、分发消息看作消费商品,这不就是一个生动形象的 生产者消费者模型 吗?

生产者消费者模型 必备 321 原则

  • 3三组关系
  • 2两个角色
  • 1一个交易场所

其中两个角色可以分别创建两个线程,一个负责接收消息,放入 生产者消费者模型,另一个则是负责从 生产者消费者模型 中拿去消息,分发给用户主机。

这里的交易场所可以选则 阻塞队列,也可以选择 环形队列

注意: 并非只有客户端 A 可以向环形队列中放消息,所有客户端主机的地位都是平等的,允许存放消息,也允许接收别人发的消息。

(三)引入环形队列

在引入 生产者消费者模型 后,服务器头文件结构将会变成下面这个样子:

  • 启动服务器,原初始化服务器、启动线程
  • 接收消息,将收到的消息存入环形队列
  • 发送消息,从环形队列中获取消息,并派发给线程
#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.hpp"namespace ns_server
{// 端口号默认值const static uint16_t default_port = 8080;class UdpServer{// 构造UdpServer(uint16_t port = default_port):port_(port){}// 析构~UdpServer(){}// 初始化服务器void start(){// 1.创建套接字sock_ = socket(AF_INET, SOCK_DGRAM, 0);if(sock_ < 0){std::cerr << "create socker fail: " << strerror(errno) << std::endl;exit(SOCKET_ERR);}std::cout << "create socket success: " << sock_ << std::endl;// 2.绑定IP地址和端口号struct sockaddr_in local;bzero(&local, sizeof(local));// 填充字段local.sin_family = AF_INET; // 设置为网络通信 (PF_INET 也行)local.sin_port = htons(port_); // 主机序列转为网络序列local.sin_addr.s_addr = INADDR_ANY; // 绑定任何可用IP地址// 绑定IP地址和端口号if(bind(sock_, (struct sockaddr*)&local, sizeof(local))){std::cerr << "bind socket error: " << strerror(errno) << std::endl;exit(BIND_ERR); }std::cout << "bind socket success" << sock_ << std::endl;}// 接收消息void RecvMessage(){// 服务器是不断运行的,所以可以使用一个死循环来一直运行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';else continue; // 继续读取// 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 BroadCast(){while(true){// 1.从环形队列中获取消息std::string msg;rq_.pop(&msg);// 2.将消息发送给客户// TODO}}private:int sock_; // 套接字uint16_t port_; // 端口号RingQueue<std::string> rq_;};
}

(四)入用户信息

在首次接收到某个用户的信息时,需要将其进行标识,以便后续在进行消息广播时分发给他

有点类似于用户首次发送消息,就被拉入了 “群聊”

目前可以使用 IP + Port 的方式标识用户,确保用户的唯一性,这里选取 unordered_map 哈希表结构,方便快速判断用户是否已存在:

  • key用户标识符
  • value用户客户端的 sockaddr_in 结构体

注意: 这里的哈希表后面会涉及多线程的访问,需要加锁保护

所以我们可以使用之前实现的LockGuard.hpp:线程同步与互斥 三(五)小节:

#pragma once// ...
#include <unordered_map>
#include "err.hpp"
#include "RingQueue.hpp"
#include "LockGuard.hpp"namespace ns_server
{// 端口号默认值const static uint16_t default_port = 8080;class UdpServer{// 构造UdpServer(uint16_t port = default_port):port_(port){pthread_mutex_init(&mtx_, nullptr);}// 析构~UdpServer(){pthread_mutex_destroy(&mtx_);}// 初始化服务器void start(){// ...}// 接收消息void RecvMessage(){// 服务器是不断运行的,所以可以使用一个死循环来一直运行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 BroadCast(){while(true){// 1.从环形队列中获取消息std::string msg;rq_.pop(&msg);// 2.将消息发送给客户std::vector<sockaddr_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, (struct sockaddr*)&addr, sizeof(addr));}}}private:// ...std::unordered_map<std::string, struct sockaddr_in> userTable;pthread_mutex_t mtx_;};
}

这里的实现有一个小细节:在进行广播消息时,先在加锁的情况下,将用户的 sockaddr_in 结构体存储,再遍历发送消息。

这样做的好处在于可以在一定程度上提高通信效率,因为 sendto 函数涉及 IO 操作,IO 本来就很慢,加锁后就会更慢了,先在加锁情况下将用户 sockaddr_in 结构体保存后,再遍历发送消息就无需加锁了(因为此时没有涉及临界资源的操作)。

(五)引入多线程

最后引入 生产者消费者 模型中的两种角色:生产者、消费者,也就是两个线程,原生线程库的操作有点麻烦了,我们同样可以搬出之前实现的小组件 Thread.hpp,更加轻松的实现线程操作:

如何引入多线程?
创建两个线程 AB,将接收消息作为线程 A 的回调函数,广播消息作为线程 B 的回调函数,当两个线程都运行后,整个模型也就动起来了。

为了使我们当前服务器的函数对象能成功绑定至 Thread 对象,需要修改 Thread 类(使用 function 包装器)

// ...// 参数、返回值为 void 的函数类型
// typedef void (*func_t)(void*);
using func_t = std::function<void(void*)>;  // 使用包装器设定函数类型// ...

因为当前涉及了多线程相关操作,在编译代码时,需要指明使用 pthread 库,将 Makefile 内容更新如下

.PHONY:all
all:udp_client udp_serverudp_client:udp_client.ccg++ -o $@ $^ -std=c++11 -lpthreadudp_server:udp_server_thread.ccg++ -o $@ $^ -std=c++11 -lpthread.PHONY:clean
clean:rm -f udp_client udp_server
#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 <unordered_map>
#include "err.hpp"
#include "RingQueue.hpp"
#include "LockGuard.hpp"
#include "Thread.hpp"namespace ns_server
{// 端口号默认值const static uint16_t default_port = 8080;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::BroadCast, this));}// 析构~UdpServer(){// 等待线程执行结束producer_->join();consumer_->join();pthread_mutex_destroy(&mtx_);// 释放线程delete producer_;delete consumer_;}// 初始化服务器void start(){// 1.创建套接字sock_ = socket(AF_INET, SOCK_DGRAM, 0);if(sock_ < 0){std::cerr << "create socker fail: " << strerror(errno) << std::endl;exit(SOCKET_ERR);}std::cout << "create socket success: " << 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 = INADDR_ANY; // 绑定任何可用IP地址// 绑定IP地址和端口号if(bind(sock_, (struct sockaddr*)&local, sizeof(local))){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){// 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';else continue; // 继续读取// 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 BroadCast(){while(true){// 1.从环形队列中获取消息std::string msg;rq_.pop(&msg);// 2.将消息发送给客户std::vector<sockaddr_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, (struct sockaddr*)&addr, sizeof(addr));}}}private:int sock_; // 套接字uint16_t port_; // 端口号RingQueue<std::string> rq_;// <用户标识符, sockaddr_in 结构体>std::unordered_map<std::string, struct sockaddr_in> userTable;pthread_mutex_t mtx_;Thread *producer_; // 生产者Thread *consumer_;  // 消费者};
}

以上就是 多人聊天室 中 server_thread.hpp 服务器头文件的全部设计了,至于 server.cc 服务器源文件,几乎不用修改

#include <memory>
#include <vector>
#include <cstdio>
#include "server_thread.hpp"using namespace std;
using namespace ns_server;int main()
{unique_ptr<UdpServer> us(new UdpServer());us->start();return 0;
}

接下来编译并运行程序,可以看到此时有三个线程在运行(一个 server 主线程,一个生产者线程,一个消费者线程

可以看到两个不同的端口号即是两台不同的主机,分别使用两台主机运行客户端,可以看到主机 A 确实可以看到主机 B 发送的信息,不过问题在 无法实时更新消息,需要自己发送消息后,才能看到别人发的消息

出现这种情况的原因是 客户端发送消息后,才能接收消息, 这就很尴尬了,假设这个群聊里有十个用户,那用户 A 岂不是自己至少得发送 9 条消息,才能看到其他九位用户之前发送的消息 。

所以客户端也需要多线程化,接下来就是对客户端的改造 。

(六)客户端多线程化

有了之前 server_thread.hpp 服务器头文件多线程化的经验后,改造 client.hpp 客户端头文件就很简单了,同样是创建两个线程,一个负责发送消息,一个负责接收消息

#pragma once#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <functional>
#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.hpp"namespace ns_client
{class UdpClient{public:// 构造UdpClient(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 Start(){// 1. 创建套接字sock_ = socket(AF_INET, SOCK_DGRAM, 0);if(sock_ < 0){std::cerr << "create socket error: " << strerror(errno) << std::endl;exit(SOCKET_ERR);}std::cout << "create socket success: " << 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 << "Please Input Message# ";std::getline(std::cin, msg);ssize_t n = sendto(sock_, msg.c_str(), msg.size(), 0, (struct sockaddr*)&svr_, sizeof(svr_));if(n == -1){std::cout << "Send Message Fail: " << strerror(errno) << std::endl;continue; // 重新输入消息并发送}}}// 接收消息void SendMessage(){char buffer[1024];while(true){// 2. 接收消息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_;  // 服务端IP地址uint16_t server_port_;   // 服务端端口号int sock_;struct sockaddr_in svr_; // 服务端的sockadder_in结构体信息Thread *recv_; // 发送消息Thread *send_; // 接收消息};
}
#include <memory>#include "client.hpp"using namespace std;
using namespace ns_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_ptr<UdpClient> us(new UdpClient(ip, port));us->Start();return 0;
}

客户端改造完成后,再次服务器与客户端,可以看到现在已经正常了,多人聊天室 构建完毕。

注:因为客户端发送消息、接收消息使用的是同一个文件描述符,属于临界资源,所以显示时出现问题很正常。

关于输入、输出消息剥离的问题,可以利用标准输出、标准错误 + 管道的方式进行区分,限于篇幅原因,这里不再阐述:

至此基于 UDP 协议实现的多个网络程序都已经编写完成了,尤其是 多人聊天室,如果加上简单的图形化界面(比如 EasyXEGE),就是一个简易版的 QQ 群聊。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/789133.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Java基础知识总结(第八篇):集合:Collection(List、Set)、Map、Collections 工具类

声明: 1. 本文根据韩顺平老师教学视频自行整理&#xff0c;以便记忆 2. 若有错误不当之处, 请指出 系列文章目录 Java基础知识总结&#xff08;第一篇&#xff09;&#xff1a;基础语法 Java基础知识总结&#xff08;第二篇&#xff09;&#x…

Kaggle:收入分类

先看一下数据的统计信息 import pandas as pd # 加载数据&#xff08;保留原路径&#xff0c;但在实际应用中建议使用相对路径或环境变量&#xff09; data pd.read_csv(r"C:\Users\11794\Desktop\收入分类\training.csv", encodingutf-8, encoding_errorsrepl…

STM32-02基于HAL库(CubeMX+MDK+Proteus)GPIO输出案例(LED流水灯)

文章目录 一、功能需求分析二、Proteus绘制电路原理图三、STMCubeMX 配置引脚及模式&#xff0c;生成代码四、MDK打开生成项目&#xff0c;编写HAL库的GPIO输出代码五、运行仿真程序&#xff0c;调试代码 一、功能需求分析 在完成开发环境搭建之后&#xff0c;开始使用STM32GP…

玩机进阶教程-----高通9008线刷XML脚本修改备份 檫除的操作步骤解析

在高通9008官方固件中我们可以看到刷写需要的脚本rawprogram0.xml和辅助脚本patch0.xml&#xff0c;脚本的作用在于将固件内各个分区对应写入手机内。根据分区地址段。然后判断脚本中那些分区不写入。以下步骤将分析emmc字库为例来讲解如何将默认刷入脚本修改为备份 檫除脚本。…

2013年认证杯SPSSPRO杯数学建模B题(第二阶段)流行音乐发展简史全过程文档及程序

2013年认证杯SPSSPRO杯数学建模 B题 流行音乐发展简史 原题再现&#xff1a; 随着互联网的发展&#xff0c;流行音乐的主要传播媒介从传统的电台和唱片逐渐过渡到网络下载和网络电台等。网络电台需要根据收听者的已知喜好&#xff0c;自动推荐并播放其它音乐。由于每个人喜好…

乐校园二手书交易管理系统的设计与实现|Springboot+ Mysql+Java+ B/S结构(可运行源码+数据库+设计文档)大学生闲置二手书在线销售

本项目包含可运行源码数据库LW&#xff0c;文末可获取本项目的所有资料。 推荐阅读300套最新项目持续更新中..... 最新ssmjava项目文档视频演示可运行源码分享 最新jspjava项目文档视频演示可运行源码分享 最新Spring Boot项目文档视频演示可运行源码分享 2024年56套包含ja…

蓝桥杯相关算法学习(Python)

一、排序 排序算法是指将一组数据按照某种规则重新排列&#xff0c;使得数据呈现出递增或递减的顺序。常见的排序算法包括冒泡排序、选择排序、插入排序、快速排序、归并排序、堆排序等。 1.冒泡排序 解释&#xff1a; 冒泡排序通过不断交换相邻两个元素的位置&#xff0c;使…

Redis从入门到精通(四)Redis实战:短信登录

文章目录 前言第4章 Redis实战&#xff1a;短信登录4.1 基于session实现短信登录4.1.1 短信登录逻辑梳理4.1.2 创建测试项目4.1.3 实现发送短信验证码功能4.1.4 实现用户登录功能4.1.5 实现登录拦截功能4.1.6 session共享问题 4.2 基于Redis实现短信登录4.2.1 Key-Value的结构设…

mysql语句学习

SQL Select语句完整的执行顺序&#xff1a; 1、from子句组装来自不同数据源的数据&#xff1b; &#xff08;先join在on&#xff09; 2、where子句基于指定的条件对记录行进行筛选&#xff1b; 3、group by子句将数据划分为多个分组&#xff1b; 4、使用聚集函数进行计算&a…

取证之内存取证工具Volatility学习

一、简介 Volatility是一款开源的内存取证分析工具&#xff0c;支持Windows&#xff0c;Linux&#xff0c;MaC&#xff0c;Android等多类型操作系统系统的内存取证方式。该工具是由python开发的&#xff0c;目前支持python2、python3环境。 二、安装 1、下载地址 GitHub - …

搭建 Qt 开发环境

&#x1f40c;博主主页&#xff1a;&#x1f40c;​倔强的大蜗牛&#x1f40c;​ &#x1f4da;专栏分类&#xff1a;QT❤️感谢大家点赞&#x1f44d;收藏⭐评论✍️ 目录 一、QT SDK 的下载和安装 1.QT SDK 的下载 二、QT SDK的安装 1、找到下载的文件并双击 2、双击之…

Gatekeep AI:文本转视频教学工具,开启智能学习新纪元

在当今的数字时代,技术的进步不断改变着我们学习和理解知识的方式。 Gatekeep AI 就是这样一款令人兴奋的工具,它专注于将数学和物理问题通过文本提示转化为生动的视频。 特点与优势: 直观的可视化:将复杂的数学和物理概念以直观的视频形式呈现。快速生成:根据用户提供的…

IT公司管理者日常工作思考

一、前言 作为IT公司的管理者,我们应该一切从实际出发,理论和实际相结合,以终为始,带领公司(组织)不断前进。当然前进包括稳重求进,稳步前进,积极扩张,厚积薄发。等等。大多数公司追求的都是稳中求进,没有稳的进都是在冒比较大的风险。积极扩张,又容易出现较大的风…

世优科技上榜2024年度《中国虚拟数字人影响力指数报告》

日前&#xff0c;第三期《中国虚拟数字人影响力指数报告》在中国网络视听大会上正式发布。本期《报告》由中国传媒大学媒体融合与传播国家重点实验室&#xff08;以下简称“国重实验室”&#xff09;、中国传媒大学数字人研究院编制&#xff0c;中国网络视听协会、人民日报智慧…

数据库加载驱动问题(java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver)

java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver 遇到此问题&#xff0c;首先检查IDEA外部库中是否有mysql数据库驱动。如下所示&#xff1a; 如果发现外部库中存有mysql数据库驱动&#xff0c;需要在数据库配置文件中查看是否设置有时区mysql8.0以上版本需要设…

SAD法(附python实现)和Siamese神经网络计算图像的视差图

1 视差图 视差图&#xff1a;以左视图视差图为例&#xff0c;在像素位置p的视差值等于该像素在右图上的匹配点的列坐标减去其在左图上的列坐标 视差图和深度图&#xff1a; z f b d z \frac{fb}{d} zdfb​ 其中 d d d 是视差&#xff0c; f f f 是焦距&#xff0c; b b…

【漏洞复现】用友NC-Cloud系统queryRuleByDeptId存在SQL注入漏洞

“ 如棠安全的技术文章仅供参考&#xff0c;此文所提供的信息只为网络安全人员对自己所负责的网站、服务器等&#xff08;包括但不限于&#xff09;进行检测或维护参考&#xff0c;未经授权请勿利用文章中的技术资料对任何计算机系统进行入侵操作。利用此文所提供的信息而造成的…

vue 实现的h5 页面,如何设置页面中的 title

修改页面中的title 公共修改方式在App.vue 中&#xff1a; created() {document.title "测试标题"; },单个页面修改&#xff0c;就在单个页面编写就ok

提高 API 性能的小技巧

引言 随着数字时代的到来&#xff0c;API&#xff08;应用程序接口&#xff09;已经成为连接不同服务和应用的桥梁&#xff0c;其意义远超技术工具本身。随着大数据、云服务和 5G 技术等领域的进步&#xff0c;API 的作用变得更加重要&#xff0c;它不仅促进了数字转型的发展&…

supersqli-攻防世界

题目 加个报错 1 and 11 #没报错判断为单引号字符注入 爆显位 1 order by 2#回显正常 1 order by 3#报错 说明列数是2 尝试联合查询 -1 union select 1,2# 被过滤了 return preg_match("/select|update|delete|drop|insert|where|\./i",$inject); select|update|d…