🔭个人主页:****北 海
🛜所属专栏:****Linux学习之旅、神奇的网络世界
💻操作环境:****CentOS 7.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:书同文,车同轨,直接统一标准。 这种解决方案就很彻底了,直接从根源上解决问题,也更方便
顶层设计者采用了解决方案2,
TCP/IP
协议规定:网络中传输的数据,统一采用大端存储方案,也就是网络字节序, 现在大端/小端称为 主机字节序
发送数据时,将 主机字节序 转化为 网络字节序,接收到数据后,再转回 主机字节序 就好了,完美解决不同机器中的大小端差异,可以用下面这批库函数进行转换,在发送/接收时,调用库函数进行转换即可
#include<arpa/inet.h>// 主机字节序转网络字节序uint32_thtonl(uint32_t hostlong);// l 表示32位长整数uint32_thtons(uint32_t hostshort);// s 表示16位短整数// 网络字节序转主机字节序uint32_tntohl(uint32_t netlong);// l 表示32位长整数uint32_tntohs(uint32_t netshort);// s 表示16位短整数
2.socket 套接字
2.1.socket 常见API
socket
套接字提供了下面这一批常用接口,用于实现网络通信
#include<sys/types.h>#include<sys/socket.h>// 创建socket文件描述符(TCP/UDP 服务器/客户端)intsocket(int domain,int type,int protocol);// 绑定端口号(TCP/UDP 服务器)intbind(int socket,conststructsockaddr* address, socklen_t address_len);// 开始监听socket (TCP 服务器)intlisten(int socket,int backlog);// 接收连接请求 (TCP 服务器)intaccept(int socket,structsockaddr* address, socklen_t* address_len);// 建立连接 (TCP 客户端)intconnect(int sockfd,conststructsockaddr* 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服务器头文件
#pragmaonce#include<iostream>namespace nt_server
{classUdpServer{public:// 构造UdpServer(){}// 析构~UdpServer(){}// 初始化服务器voidInitServer(){}// 启动服务器voidStartServer(){}private:// 字段};}
创建
server.cc服务器源文件
#include<memory>// 智能指针相关头文件#include"server.hpp"usingnamespace std;usingnamespace nt_server;intmain(){
unique_ptr<UdpServer>usvr(newUdpServer());// 初始化服务器
usvr->InitServer();// 启动服务器
usvr->StartServer();return0;}
创建
client.hpp客户端头文件
#pragmaonce#include<iostream>namespace nt_client
{classUdpClient{public:// 构造UdpClient(){}// 析构~UdpClient(){}// 初始化客户端voidInitClient(){}// 启动客户端voidStartClient(){}private:// 字段};}
创建
client.cc客户端源文件
#include<memory>#include"client.hpp"usingnamespace std;usingnamespace nt_client;intmain(){
unique_ptr<UdpClient>usvr(newUdpClient());// 初始化客户端
usvr->InitClient();// 启动客户端
usvr->StartClient();return0;}
为了方便后续测试,再添加一个
Makefile
文件
创建
Makefile文件
.PHONY:all
all:server client
server:server.cc
g++ -o$@ $^ -std=c++11
client:client.cc
g++ -o$@ $^ -std=c++11
.PHONY:clean
clean:
rm-rf server client
准备工作完成后,接下来着手填充代码内容
服务器设计
3.3.创建套接字
创建套接字使用
socket
函数
#include<sys/types.h>#include<sys/socket.h>// 创建套接字(TCP/UDP 服务器/客户端)intsocket(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服务器头文件
#pragmaonce#include<iostream>#include<cstring>#include<cerrno>#include<cstdlib>#include<sys/types.h>#include<sys/socket.h>namespace nt_server
{// 错误码enum{
SOCKET_ERR =1};classUdpServer{public:// 构造UdpServer(){}// 析构~UdpServer(){}// 初始化服务器voidInitServer(){// 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;}// 启动服务器voidStartServer(){}private:int sock_;// 套接字};}
文件描述符默认
0、1、2
都已经被占用了,如果再创建文件描述符,会从
3
开始,可以看到,程序运行后,创建的套接字正是
3
,证明套接字本质上就是文件描述符,不过它用于描述网络资源

3.4.绑定IP地址和端口号
注意:**我这里的服务器是云服务器,绑定
IP
地址这个操作后面需要修改**
使用
bind
函数进行绑定操作
#include<sys/types.h>#include<sys/socket.h>// 绑定IP地址和端口号(TCP/UDP 服务器)intbind(int sockfd,conststructsockaddr* addr, socklen_t addrlen);
参数解读
sockfd创建成功的套接字addr包含通信信息的sockaddr结构体地址addrlen结构体的大小
返回值:**成功返回
0
,失败返回
-1
**

参数1没啥好说的,重点在于参数2,因为我们这里是 网络通信,所以使用的是
sockaddr_in
结构体,要想使用该结构体,还得包含下面这两个头文件
#include<netinet/in.h>#include<arpa/inet.h>
sockaddr_in
结构体的构成如下
/* Structure describing an Internet socket address. */structsockaddr_in{__SOCKADDR_COMMON(sin_);
in_port_t sin_port;/* Port number. */structin_addr sin_addr;/* Internet address. *//* Pad to size of `struct sockaddr'. */unsignedchar sin_zero[sizeof(structsockaddr)-
__SOCKADDR_COMMON_SIZE -sizeof(in_port_t)-sizeof(structin_addr)];};

首先来看看 **
16
位地址类型**,转到定义可以发现它是一个宏函数,并且使用了 C语言 中一个非常少用的语法
##
(将两个字符串拼接)
/* POSIX.1g specifies this type name for the `sa_family' member. */typedefunsignedshortint sa_family_t;/* This macro is used to declare the initial common members
of 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. */typedefuint16_t in_port_t;
最后再来看看 **
IP
地址**,同样转到定义,发现
in_addr
中包含了一个
32
位无符号整数,占
4
字节,也就是 **
IP
地址** 的大小
/* Internet address. */typedefuint32_t in_addr_t;structin_addr{
in_addr_t s_addr;};
了解完
sockaddr_in
结构体中的内容后,就可以创建该结构体了,再定义该结构体后,需要清空,确保其中的字段干净可用
将变量置为
0可用使用
bzero函数
#include<cstrins>// bzero 函数的头文件structsockaddr_in local;bzero(&local,sizeof(local));
获得一个干净可用的
sockaddr_in
结构体后,可以正式绑定 **
IP
地址** 和 端口号 了
注:**作为服务器,需要确定自己的端口号,我这里设置的是
8888
**
server.hpp服务器头文件
#pragmaonce#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 nt_server
{// 退出码enum{
SOCKET_ERR =1,
BIND_ERR
};// 端口号默认值constuint16_t default_port =8888;classUdpServer{public:// 构造UdpServer(const std::string ip,constuint16_t port = default_port):port_(port),ip_(ip){}// 析构~UdpServer(){}// 初始化服务器voidInitServer(){// 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地址和端口号structsockaddr_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 IP&&Port Fail: "<<strerror(errno)<< std::endl;exit(BIND_ERR);}// 绑定成功
std::cout <<"Bind IP&&Port Success"<< std::endl;}// 启动服务器voidStartServer(){}private:int sock_;// 套接字uint16_t port_;// 端口号
std::string ip_;// IP地址(后面需要删除)};}
注意:
- 需要把主机序列转换为网络序列,可以使用
htons函数 - 需要把点分十进制的字符串,转换为无符号短整数,可以使用
inet_addr函数,这个函数在进行转换的同时,会将主机序列转换为网络序列 - 绑定IP地址和端口号这个行为并非直接绑定到当前主机中,而是在当前程序中,将创建的
socket套接字,与目标IP地址与端口号进行绑定,当程序终止后,这个绑定关系也会随之消失
server.cc服务器源文件
#include<memory>// 智能指针相关头文件#include"server.hpp"usingnamespace std;usingnamespace nt_server;intmain(){
unique_ptr<UdpServer>usvr(newUdpServer("8.134.110.68"));// 初始化服务器
usvr->InitServer();// 启动服务器
usvr->StartServer();return0;}
接下来编译并运行程序,可以发现绑定失败了,这是因为当前我使用的是云服务器,云服务器是不允许直接绑定公网
IP
的,解决方案是在绑定
IP
地址时,让其选择绑定任意可用
IP
地址

修改代码
- 云服务器中不需要明确
IP地址 - 构造时也无需传入
IP地址 - 绑定
IP地址时选择INADDR_ANY,表示绑定任何可用的IP地址
server.hpp服务器头文件
classUdpServer{public:// 构造UdpServer(constuint16_t port = default_port):port_(port){}// 初始化服务器voidInitServer(){// ...// 填充字段
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.hpp"usingnamespace std;usingnamespace nt_server;intmain(){
unique_ptr<UdpServer>usvr(newUdpServer());// 初始化服务器
usvr->InitServer();// 启动服务器
usvr->StartServer();return0;}
再次编译并运行程序,可以看到正常运行

服务器设置的端口,需要设置为开放状态,如果是本地服务器,可以使用
systemctl start firewalld.service指令开启防火墙,再使用
firewall-cmd --zone=public --add-port=Port/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,structsockaddr*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类
// 启动服务器voidStartServer(){// 服务器是不断运行的,所以需要使用一个 while(true) 死循环char buff[1024];// 缓冲区while(true){// 1. 接收消息structsockaddr_in peer;// 客户端结构体
socklen_t len =sizeof(peer);// 客户端结构体大小// 传入 sizeof(buff) - 1 表示当前传输的是字符串,预留一个位置存储 '\0'// 传入 0 表示当前是阻塞式读取
ssize_t n =recvfrom(sock_, buff,sizeof(buff)-1,0,(structsockaddr*)&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,constvoid*buf, size_t len,int flags,conststructsockaddr*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"// ...// 启动服务器voidStartServer(){// 服务器是不断运行的,所以需要使用一个 while(true) 死循环char buff[1024];// 缓冲区while(true){// ...// 3.回响给客户端
n =sendto(sock_, buff,strlen(buff),0,(conststructsockaddr*)&peer,sizeof(peer));if(n ==-1)
std::cout <<"Send Message Fail: "<<strerror(errno)<< std::endl;}}
err.hpp头文件
#pragmaonce// 错误码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客户端头文件
#pragmaonce#include<iostream>#include<string>#include"err.hpp"namespace nt_client
{classUdpClient{public:// 构造UdpClient(const std::string& ip,uint16_t port):server_ip_(ip),server_port_(port){}// 析构~UdpClient(){}// 初始化客户端voidInitClient(){}// 启动客户端voidStartClient(){}private:
std::string server_ip_;// 服务器IP地址uint16_t server_port_;// 服务器端口号};}
这两个参数由用户主动传输,这里就需要 命令行 参数相关知识了,在启动客户端时,需要以
./client serverIp serverPort
的方式运行,否则就报错,并提示相关错误信息(更新
err.hpp
的错误码)
client.cc客户端源文件
#include<iostream>#include<memory>#include"client.hpp"usingnamespace std;usingnamespace nt_client;voidUsage(constchar* program){
cout <<"Usage:"<< endl;
cout <<"\t"<< program <<" ServerIP ServerPort"<< endl;}intmain(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>usvr(newUdpClient(ip, port));// 初始化客户端
usvr->InitClient();// 启动客户端
usvr->StartClient();return0;}
err.hpp错误码头文件
#pragmaonceenum{
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客户端头文件
#pragmaonce#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.hpp"namespace nt_client
{classUdpClient{public:// 构造UdpClient(const std::string& ip,uint16_t port):server_ip_(ip),server_port_(port){}// 析构~UdpClient(){}// 初始化客户端voidInitClient(){// 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_);// 绑定服务器端口号}// 启动客户端voidStartClient(){}private:
std::string server_ip_;// 服务器IP地址uint16_t server_port_;// 服务器端口号int sock_;structsockaddr_in svr_;// 服务器的sockadder_in结构体信息};}
如此一来,客户端就可以利用该
sockaddr_in
结构体,与目标主机进行通信了
3.8.启动客户端
接下来就是客户端向服务器发送消息,消息由用户主动输入,使用的是
sendto
函数
发送消息步骤
- 用户输入消息
- 传入缓冲区、服务器相关参数,使用
sendto函数发送消息
消息发送后,客户端等待服务器回响消息
接收消息步骤:
- 创建缓冲区
- 接收信息,判断是否接收成功
- 处理信息
注:同服务器一样,客户端也需要不断运行
StartClient()函数 — 位于
client.hpp中的
UdpClient类
// 启动客户端voidStartClient(){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,(conststructsockaddr*)&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,(structsockaddr*)&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
的代码如下
使用
C++11中的
function包装器语法,包装出一个符合我们业务处理需求的函数类型
server.hpp服务器头文件
#pragmaonce#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"namespace nt_server
{// 端口号默认值constuint16_t default_port =8888;using func_t = std::function<std::string(std::string)>;// 参数为string,返回值同样为stringclassUdpServer{public:// 构造UdpServer(const func_t& func,uint16_t port = default_port):port_(port),serverHandle_(func){}// 析构~UdpServer(){}// 初始化服务器voidInitServer(){// 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地址和端口号structsockaddr_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 IP&&Port Fail: "<<strerror(errno)<< std::endl;exit(BIND_ERR);}// 绑定成功
std::cout <<"Bind IP&&Port Success"<< std::endl;}// 启动服务器voidStartServer(){// 服务器是不断运行的,所以需要使用一个 while(true) 死循环char buff[1024];// 缓冲区while(true){// 1. 接收消息structsockaddr_in peer;// 客户端结构体
socklen_t len =sizeof(peer);// 客户端结构体大小// 传入 sizeof(buff) - 1 表示当前传输的是字符串,预留一个位置存储 '\0'// 传入 0 表示当前是阻塞式读取
ssize_t n =recvfrom(sock_, buff,sizeof(buff)-1,0,(structsockaddr*)&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,(conststructsockaddr*)&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.hpp"usingnamespace std;usingnamespace nt_server;// 大写转小写(英文字母)
std::string UpToLow(const std::string& resquest){
std::string ret(resquest);for(auto&rc : ret){if(isupper(rc))
rc +=32;}return ret;}intmain(){
unique_ptr<UdpServer>usvr(newUdpServer(UpToLow));// 初始化服务器
usvr->InitServer();// 启动服务器
usvr->StartServer();return0;}
至此只需要客户端传入一段消息,如果消息中包含了大写字符,我们的服务器就会将其转为小写字符,然后将消息发送给客户端,相当于之前单纯回响字符串的加强版
客户端仍然只需发送消息、接收消息,可以直接使用之前的客户端
重新编译并运行服务器,通过客户端发送信息,可以看到大写字符确实都被转为小写字符了

如果想实现小写转大写,或其他转换需求,只需要重新编写业务处理函数,将其作为参数传递给
UdpServer
类即可
注意:传递的业务处理函数,在返回值、参数方面,必须与类中的回调函数类型一致
4.3.远程bash
bash
指令是如何执行的?
- 接收指令(字符串)
- 对指令进行分割,构成有效信息
- 创建子进程,执行进程替换
- 子进程运行结束后,父进程回收僵尸进程
- 输入特殊指令时的处理
可以自己 模拟实现简易版 bash,不过这样做太麻烦了
也可以直接使用系统提供的
popen
函数
#include<stdio.h>
FILE *popen(constchar*command,constchar*type);intpclose(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"Can't 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服务器源文件
// 安全检查boolcheckSafe(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)returnfalse;}returntrue;}
将
checkSafe
安全检查函数整合进
ExecCommand
业务处理函数中,同时在构建
UdpServer
对象时,传入该业务处理函数对象,编译并运行程序
#include<string>#include<vector>#include<memory>// 智能指针相关头文件#include<cstdio>#include"server.hpp"usingnamespace std;usingnamespace nt_server;// 安全检查boolcheckSafe(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)returnfalse;}returntrue;}// 远程 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"Can't 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;}intmain(){
unique_ptr<UdpServer>usvr(newUdpServer(ExecCommand));// 初始化服务器
usvr->InitServer();// 启动服务器
usvr->StartServer();return0;}
可以看到,输入安全指令时,可以正常获取结果,如果输入的是非安全指令,会直接拒绝执行

诸如
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服务器头文件
#pragmaonce#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 nt_server
{// 端口号默认值constuint16_t default_port =8888;classUdpServer{public:// 构造UdpServer(uint16_t port = default_port):port_(port){}// 析构~UdpServer(){}// 初始化服务器voidStartServer(){// 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地址和端口号structsockaddr_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 IP&&Port Fail: "<<strerror(errno)<< std::endl;exit(BIND_ERR);}// 绑定成功
std::cout <<"Bind IP&&Port Success"<< std::endl;}// 接收消息voidRecvMessage(){// 服务器是不断运行的,所以需要使用一个 while(true) 死循环char buff[1024];// 缓冲区while(true){// 1. 接收消息structsockaddr_in peer;// 客户端结构体
socklen_t len =sizeof(peer);// 客户端结构体大小// 传入 sizeof(buff) - 1 表示当前传输的是字符串,预留一个位置存储 '\0'// 传入 0 表示当前是阻塞式读取
ssize_t n =recvfrom(sock_, buff,sizeof(buff)-1,0,(structsockaddr*)&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);}}// 广播消息voidBroadcastMessage(){while(true){// 1.从环形队列中获取消息
std::string msg;
rq_.Pop(&msg);// 2.将消息发给用户// TODO}}private:int sock_;// 套接字uint16_t port_;// 端口号
Yohifo::RingQueue<std::string> rq_;// 环形队列};}
5.4.引入用户信息
在首次接收到某个用户的信息时,需要将其进行标识,以便后续在进行消息广播时分发给他
有点类似于用户首次发送消息,就被拉入了 “群聊”
目前可以使用 **
IP + Port
** 的方式标识用户,确保用户的唯一性,这里选取
unordered_map
这种哈希表结构,方便快速判断用户是否已存在
key:用户标识符value:用户客户端的sockaddr_in结构体
注意:这里的哈希表后面会涉及多线程的访问,需要加锁保护
为了方便起见,直接使用了之前编写的
LockGuard.hpp
小组件(具体实现详见《Linux多线程【线程互斥与同步】》)
server.hpp服务器头文件
#pragmaonce// ...#include<unordered_map>// ...#include"LockGuard.hpp"namespace nt_server
{// 端口号默认值constuint16_t default_port =8888;classUdpServer{public:// 构造UdpServer(uint16_t port = default_port):port_(port){// 初始化互斥锁pthread_mutex_init(&mtx_,nullptr);}// 析构~UdpServer(){// 销毁互斥锁pthread_mutex_destroy(&mtx_);}// 初始化服务器voidStartServer(){// ...}// 接收消息voidRecvMessage(){// 服务器是不断运行的,所以需要使用一个 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.将消息添加至环形队列// ...}}// 广播消息voidBroadcastMessage(){while(true){// 1.从环形队列中获取消息// ...// 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,(const sockaddr*)&addr,sizeof(addr));}}}private:// ...
std::unordered_map<std::string,structsockaddr_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::function<void(void*)>;// 使用包装器设定函数类型// ...
因为当前涉及了多线程相关操作,在编译代码时,需要指明使用
pthread
库,将
Makefile
内容更新如下
Makefile
.PHONY:all
all:server client
server:server.cc
g++ -o$@ $^ -std=c++11 -lpthread
client:client.cc
g++ -o$@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
rm-rf server client
server.hpp服务器头文件
#pragmaonce#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.hpp"namespace nt_server
{// 端口号默认值constuint16_t default_port =8888;classUdpServer{public:// 构造UdpServer(uint16_t port = default_port):port_(port){// 初始化互斥锁pthread_mutex_init(&mtx_,nullptr);// 创建线程// 注意:因为类内成员有隐含的 this 指针,需要借助 bind 固定该参数
producer_ =newThread(1, std::bind(&UdpServer::RecvMessage,this));
consumer_ =newThread(2, std::bind(&UdpServer::BroadcastMessage,this));}// 析构~UdpServer(){// 等待线程运行结束
producer_->join();
consumer_->join();// 销毁互斥锁pthread_mutex_destroy(&mtx_);// 释放对象delete producer_;delete consumer_;}// 初始化服务器voidStartServer(){// 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地址和端口号structsockaddr_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 IP&&Port Fail: "<<strerror(errno)<< std::endl;exit(BIND_ERR);}// 绑定成功
std::cout <<"Bind IP&&Port Success"<< std::endl;// 启动线程
producer_->run();
consumer_->run();}// 接收消息voidRecvMessage(){// 服务器是不断运行的,所以需要使用一个 while(true) 死循环char buff[1024];// 缓冲区while(true){// 1. 接收消息structsockaddr_in peer;// 客户端结构体
socklen_t len =sizeof(peer);// 客户端结构体大小// 传入 sizeof(buff) - 1 表示当前传输的是字符串,预留一个位置存储 '\0'// 传入 0 表示当前是阻塞式读取
ssize_t n =recvfrom(sock_, buff,sizeof(buff)-1,0,(structsockaddr*)&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);}}// 广播消息voidBroadcastMessage(){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,(const sockaddr*)&addr,sizeof(addr));}}}private:int sock_;// 套接字uint16_t port_;// 端口号
Yohifo::RingQueue<std::string> rq_;// 环形队列
std::unordered_map<std::string,structsockaddr_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.hpp"usingnamespace std;usingnamespace nt_server;intmain(){
unique_ptr<UdpServer>usvr(newUdpServer());// 启动服务器
usvr->StartServer();return0;}
接下来编译并运行程序,可以看到此时有三个线程在运行(一个
server
主线程,一个生产者线程,一个消费者线程)

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


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

所以客户端也需要多线程化,接下来就是对客户端的改造
客户端
5.6.多线程化
有了之前
server.hpp
服务器头文件多线程化的经验后,改造
client.hpp
客户端头文件就很简单了,同样是创建两个线程,一个负责发送消息,一个负责接收消息
这里同样使用
Thread.hpp
线程类
client.hpp客户端头文件
#pragmaonce#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.hpp"namespace nt_client
{classUdpClient{public:// 构造UdpClient(const std::string& ip,uint16_t port):server_ip_(ip),server_port_(port){// 创建线程
recv_ =newThread(1, std::bind(&UdpClient::RecvMessage,this));
send_ =newThread(2, std::bind(&UdpClient::SendMessage,this));}// 析构~UdpClient(){// 等待线程退出
recv_->join();
send_->join();delete(recv_);delete(send_);}// 启动客户端voidStartClient(){// 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();}// 发送消息voidRecvMessage(){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,(conststructsockaddr*)&svr_,sizeof(svr_));if(n ==-1){
std::cout <<"Send Message Fail: "<<strerror(errno)<< std::endl;continue;// 重新输入消息并发送}}}// 接收消息voidSendMessage(){char buff[1024];while(true){// 2.接收消息
socklen_t len =sizeof(svr_);// 创建一个变量,因为接下来的参数需要传左值
ssize_t n =recvfrom(sock_, buff,sizeof(buff)-1,0,(structsockaddr*)&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_;structsockaddr_in svr_;// 服务器的sockadder_in结构体信息
Thread* recv_;// 发送消息
Thread* send_;// 接收消息};}
client.cc客户端源文件
#include<iostream>#include<memory>#include<memory>#include"client.hpp"usingnamespace std;usingnamespace nt_client;voidUsage(constchar* program){
cout <<"Usage:"<< endl;
cout <<"\t"<< program <<" ServerIP ServerPort"<< endl;}intmain(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>usvr(newUdpClient(ip, port));// 启动客户端
usvr->StartClient();return0;}
客户端改造完成后,再次服务器与客户端,可以看到现在已经正常了,多人聊天室 构建完毕
注:因为客户端发送消息、接收消息使用的是同一个文件描述符,属于临界资源,所以显示时出现问题很正常
关于输入、输出消息剥离的问题,可以利用标准输出、标准错误 + 管道的方式进行区分,限于篇幅原因,这里不再阐述


至此基于
UDP
协议实现的多个网络程序都已经编写完成了,尤其是 多人聊天室,如果加上简单的图形化界面(比如
EasyX
、
EGE
),就是一个简易版的
QQ
群聊
🌨️总结
**以上就是本次关于 网络编程『socket套接字 ‖ 简易UDP网络程序』的全部内容了,在本文中首先学习了一批预备知识,包括
IP
地址、端口号、网络字节序等,然后学习
socket
套接字编程相关接口,学以致用,基于
UDP
协议实现了各种网络程序,小到字符串回响,大到多人聊天室,用到了之前系统学习的大部分知识,后面还会基于
TCP
编写网络程序,加深对
socket
套接字编程的理解**

相关文章推荐
网络基础『发展 ‖ 协议 ‖ 传输 ‖ 地址』
版权归原作者 北 海 所有, 如有侵权,请联系我们删除。

