1.socket
1.1.什么是socket
Socket 的中文翻译过来就是"套接字"。
套接字是什么,我们先来看看它的英文含义:插座。
Socket 就像一个电话插座,负责连通两端的电话,进行点对点通信,让电话可以进行通信,端口就像插座上的孔,端口不能同时被其他进程占用。而我们建立连接就像把插头插在这个插座上,创建一个 Socket 实例开始监听后,这个电话插座就时刻监听着消息的传入,谁拨通我这个"IP 地址和端口",我就接通谁。
事实上,
**Socket本身有"插座"的意思,在Linux环境下,用于表示进程间网络通信的特殊文件类型。**本质为内核借助缓冲区形成的伪文件。
换句话说,Socket就是一种特殊的文件,服务器和客户端各自维护一个"Socket文件",在建立连接打开后,可以向文件写入内容供对方读取或者读取对方内容,通讯结束时关闭文件。
在Linux操作系统中,所有对象都被视作文件,这使得文件描述符成为管理这些对象的核心工具。文件描述符是一个用于标识已打开文件的整数索引,通过它,可以进行各种I/O操作。**Socket作为一种特殊类型的文件,也遵循这一模式。****在创建Socket时,系统会为其分配一个文件描述符,从而允许进程像操作文件一样读写网络数据。**这种设计极大地简化了网络编程的复杂性,使得开发者可以专注于应用逻辑而非底层细节。
与管道类似的,Linux系统将其封装成文件的目的是为了统一接口,使得读写套接字和读写文件的操作一致。
套接字和管道的区别是:
- 管道主要应用于本地进程间通信;
- 套接字多应用于网络进程间数据的传递;
- 套接字的内核实现较为复杂,不宜在学习初期深入学习。
在Linux系统中,我们之前是使用PID来标识进程的,用文件描述符来标识文件的。但是在socket这里,就都换了。
在TCP/IP协议中,
- **"IP地址+TCP或UDP端口号"**唯一标识网络通讯中的一个进程。
- "IP地址+端口号"就对应一个socket。
=欲建立连接的两个进程必须各自有一个socket来标识,那么这两个socket组成的socket pair就唯一标识一个连接。刚好像插头和插座的连接一样,必须两者一一对应。因此可以用Socket来描述网络连接的一对一关系。
套接字通信原理如下图所示:
在网络通信中,套接字一定是成对出现的**。**一端的发送缓冲区对应对端的接收缓冲区。我们使用同一个文件描述符来描述"发送缓冲区"和"接收缓冲区"。
实际上,我们口中的Socket有多层意思,一层意思是特殊的文件,一层意思是表示一种特有的通信模式,还有一层意思是在应用层和传输层之间的一个抽象层,它把 TCP/IP 层复杂的操作抽象为几个简单的接口,供应用层调用实现进程在网络中的通信。
就像下面这样子
TCP/IP协议最早在BSD UNIX上实现,为TCP/IP协议设计的应用层编程接口称为socket API。
2.socket API
首先我们来看看
socket
套接字提供了下面这一批常用接口,用于实现网络通信
cpp
#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,这是个什么东西?
首先我们要明白,
**网络通信时,数据发送方、数据接收方需要明确对方的网络地址,**而网络的地址的三大要素就是下面这3点:
- 协议
- ip
- 端口
在socket里面这三个参数用一个结构体 sockaddr 来表示。
先说结论,sockaddr是统一的接口,只用一个接口完成不同套接字(比如IPV4,IPV6)之间的通信问题。
socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、IPv6,以及后面要讲的UNIX Domain Socket。**然而,各种网络协议的地址格式并不相同。**在C语言中如果直接处理就要多出重复的接口,设计成统一的目的是为了设计尽量少的接口,实现面向对象中的静态多态------函数重载。
2.1.sockaddr 和sockaddr_in结构体
socket 这套网络通信标准隶属于POSIX 通信标准 ,该标准的设计初衷就是为了实现 可移植性,程序可以直接在使用该标准的不同机器中运行,但有的机器使用的是网络通信,有的则是使用本地通信,socket 套接字为了能同时兼顾这两种通信方式,提供了 sockaddr 结构体
由 sockaddr 结构体衍生出了两个不同的结构体:sockaddr_in 网络套接字、sockaddr_un 域间套接字,前者用于网络通信,后者用于本地通信
我们今天不讨论socketaddr_un这个,因为他不是用来网络通信的。
我们来看看sockaddr和sockaddr_in这两个
sockaddr在头文件#include <sys/socket.h>中定义,sockaddr的缺陷是 :sa_data把目标地址和端口信息混在一起了,如下
cppstruct sockaddr { sa_family_t sin_family;//地址族 char sa_data[14]; //14字节,包含套接字中的目标地址和端口信息 };
sockaddr_in在头文件#include<netinet/in.h>或#include <arpa/inet.h>中定义,
该结构体解决了sockaddr的缺陷,把port和addr 分开储存在两个变量中,如下:
cppstruct sockaddr_in { short sin_family; // 2 字节 ,地址族,e.g. AF_INET, AF_INET6 unsigned short sin_port; // 2 字节 ,16位TCP/UDP 端口号 e.g. htons(3490), struct in_addr sin_addr; // 4 字节 ,32位IP地址 char sin_zero[8]; // 8 字节 ,不使用 }; struct in_addr { unsigned long s_addr; // 32位IPV4地址打印的时候可以调用inet_ntoa()函数将其转换为char *类型. };
sin_port和sin_addr都必须是网络字节序(NBO),一般可视化的数字都是主机字节序(HBO)。
注释中标明了属性的含义及其字节大小,这两个结构体一样大,都是16个字节,而且都有family属性,不同的是:
- sockaddr用其余14个字节来表示sa_data
- 而sockaddr_in把14个字节拆分成sin_port, sin_addr和sin_zero分别表示端口、ip地址。sin_zero用来填充字节使sockaddr_in和sockaddr保持一样大小。
**我们上面提到过网络通信时,数据发送方、数据接收方需要明确对方的网络地址,**而网络的地址的三大要素就是下面这3点:
- 协议
- ip
- 端口
大家看看sockaddr和sockaddr_in里面也没有这3样东西呢?
事实上,sockaddr和sockaddr_in包含的数据都是一样的,但他们在使用上有区别:
- 程序员不应操作sockaddr,sockaddr是给操作系统用的
- 程序员应使用sockaddr_in来表示地址,sockaddr_in区分了地址和端口,使用更方便。
此外, 二者大小一样,都是16个字节,即占用的内存大小是一致的,因此可以互相转化。二者是并列结构,指向sockaddr_in结构的指针也可以指向sockaddr。
所以在网络编程中我们会对sockaddr_in结构体进行操作,使用sockaddr_in来建立所需的信息,先把sockaddr_in变量赋值后,强制类型转换后传入用sockaddr做参数的函数。也就是说sockaddr_in用于socket定义和赋值;sockaddr用于函数参数。
一般的用法为:
程序员把类型、ip地址、端口填充sockaddr_in结构体,然后强制转换成sockaddr,作为参数传递给系统调用函数
3.UDP网络通信程序
接下来接下来实现一批基于 UDP 协议的网络程序,本节只介绍基于IPv4的socket网络编程
3.1.核心功能
分别实现客户端与服务器,客户端向服务器发送消息,服务器收到消息后,回响给客户端,有点类似于 echo
指令
该程序的核心在于 使用 socket
套接字接口,以 UDP
协议的方式实现简单网络通信
3.2.程序结构
程序由server.hpp server.cc client.hpp client.cc 组成,大体框架如下
创建
server.hpp
服务器头文件
cpp
#pragma once
#include <iostream>
namespace nt_server
{
class UdpServer
{
public:
// 构造
UdpServer()
{}
// 析构
~UdpServer()
{}
// 初始化服务器
void InitServer()
{}
// 启动服务器
void StartServer()
{}
private:
// 字段
};
}
创建
server.cc
服务器源文件
cpp
#include <memory> // 智能指针相关头文件
#include "server.hpp"
using namespace std;
using namespace nt_server;
int main()
{
unique_ptr<UdpServer> usvr(new UdpServer());//使用智能指针创建了一个UdeServer对象
// 初始化服务器
usvr->InitServer();
// 启动服务器
usvr->StartServer();
return 0;
}
创建
client.hpp
客户端头文件
cpp
#pragma once
#include <iostream>
namespace nt_client
{
class UdpClient
{
public:
// 构造
UdpClient()
{}
// 析构
~UdpClient()
{}
// 初始化客户端
void InitClient()
{}
// 启动客户端
void StartClient()
{}
private:
// 字段
};
}
创建
client.cc
客户端源文件
cpp
#include <memory>
#include "client.hpp"
using namespace std;
using namespace nt_client;
int main()
{
unique_ptr<UdpClient> usvr(new UdpClient());
// 初始化客户端
usvr->InitClient();
// 启动客户端
usvr->StartClient();
return 0;
}
为了方便后续测试,再添加一个 Makefile
文件
创建
Makefile
文件
cpp
.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.服务端设计
3.3.1.创建套接字------socket函数
创建套接字使用 socket
系统调用接口
socket函数对应于普通文件的打开操作 。**普通文件的打开操作返回一个文件描述字,而socket()用于创建一个socket描述符(socket descriptor),它唯一标识一个socket。**这个socket描述字跟文件描述符一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。
正如可以给open的传入不同参数值,以打开不同的文件。创建socket的时候,也可以指定不同的参数创建不同的socket描述符,socket函数的三个参数分别为:
- domain:这个是协议域(协议簇),决定了socket的地址类型。
常用的有下面
- AF_INET:用来产生IPV4 - socket 的协议,使用TCP或UDP来传输,用IPV4的地址
- AF_INET6:和上面的差不多,这个是IPV6的
- AF_UNIX:本地协议,用在Unix和Linux系统上,一般都是服务端和客户端在同一台机器上时使用。 (要用一个绝对路径名作为地址)。
我们一般使用IPV4的AF_INET
- type:指socket类型,有面向连接的套接字(SOCK_STREAM)和面向消息的套接字(SOCK_DGRAM)。
我们看看它的参数
- SOCK_STREAM:这个协议是按照顺序的、可靠的、数据完整的基于字节流的连接。这是一个使用最多的socket类型,是用TCP协议来传输的。
- SOCK_DGRAM:这个协议是无连接的,固定长度的连接调用。该协议是不可靠的,使用UDP来进行它的连接。
- SOCK_SEQPACKET:这个协议是双线路的、可靠的连接,发送固定长度的数据包进行传输。(注(1))必须把整个包完整的接收才能够进行读取。
- SOCK_RAW:这个socket类型提供单一的网络访问
其中
- 面向连接的套接字可以理解成TCP协议,数据稳定、按序传输,不存在数据边界,且收发数据在套接字内部有缓冲,所以服务器和客户端进行I/O操作时并不会马上调用,可能分多次调用;
- 面向消息的套接字可以看做UDP,特点:快速传输、有数据边界、数据可能丢失、传输数据大小受限。
- protocol:指计算机间通信中使用的协议信息。
- protocol一般设置为0,默认协议
一般都可以为0(当protocol为0时,会自动选择type类型对应的默认协议。),如果同一协议簇中存在多个数据传输方式相同的协议,则才用第三个参数。
常用的协议有,IPPROTO_TCP、IPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等。type和protocol并不是可以随意组合的,如SOCK_STREAM不可以跟IPPROTO_UDP组合。
- 返回值
socket返回的值是一个文件描述符,SOCKET类型本身也是定义为int的,既然是文件描述符,那么在系统中都当作是文件来对待的,0,1,2分别表示标准输入、标准输出、标准错误。所以其他打开的文件描述符都会大于2, 错误时就返回 -1. 这里INVALID_SOCKET 也被定义为 -1 。
socket函数打开一个网络通讯端口,如果成功的话就像open一样返回一个文件描述符,应用程序可以像读写文件一样read/write在网络上收发数据。
好了socket函数学完了,接下来在**server.hpp
** 的 InitServer()
函数中创建套接字,并对创建成功/失败后的结果做打印
server.hpp
cpp
#pragma once
#include <iostream>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <sys/types.h>
#include <sys/socket.h>
namespace 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_; // 套接字
};
}
因为这里是使用 UDP 协议实现的 网络通信,参数1 domain 选择 AF_INET(基于 IPv4 标准),参数2 type 选择 SOCK_DGRAM(数据报传输),参数3设置为 0,可以根据 SOCK_DGRAM 自动推导出使用 UDP 协议
我们运行一下
文件描述符默认 0、1、2
都已经被占用了,如果再创建文件描述符,会从 3
开始,可以看到,程序运行后,创建的套接字正是 3
,证明套接字本质上就是文件描述符,不过它用于描述网络资源
3.3.2.绑定IP地址和端口号------bind函数
bind的英文意思就是捆绑
服务端用于将把用于通信的地址和端口绑定到socket 上。所以可以猜出,这个函数的参数应该包含:用于通信的 socket 和服务端的 IP 地址和端口号。ip地址和端口号是放在 socketaddr_in 结构体里面的。
参数:
- - sockfd : 通过socket函数得到的文件描述符
- - addr : 需要绑定的socket地址,这个地址封装了ip和端口号的信息
- - addrlen : 第二个参数结构体占的内存大小
参数1没啥好说的,重点在于参数2,因为我们这里是 网络通信 ,所以使用的是 sockaddr_in
结构体,要想使用该结构体,还得包含下面这两个头文件
cpp
#include <netinet/in.h>
#include <arpa/inet.h>
有的人可能又好奇了
- 这个第2个参数不是const struct sockaddr*吗?为啥要使用sockaddr_in呢?
不记得的朋友去上面看看啊!
我们进行网络通信的时候一般的做法是
程序员把类型、ip地址、端口填充sockaddr_in结构体,然后强制转换成sockaddr,作为参数传递给系统调用函数
我们需要详细了解一下这个sockaddr_in结构体
cpp
struct sockaddr_in {
short sin_family; // 2 字节 ,地址族,e.g. AF_INET, AF_INET6
unsigned short sin_port; // 2 字节 ,16位TCP/UDP 端口号 e.g. htons(3490),
struct in_addr sin_addr; // 4 字节 ,32位IP地址
char sin_zero[8]; // 8 字节 ,不使用
};
struct in_addr {
unsigned long s_addr; // 32位IPV4地址打印的时候可以调用inet_ntoa()函数将其转换为char *类型.
};
sin_port和sin_addr都必须是网络字节序(NBO),一般可视化的数字都是主机字节序(HBO)。
我们需要一个sockaddr_in结构体,再创建1个short类型,一个unsigned short,一个struct in_addr传进去,这样子会不会很清楚?
了解完 sockaddr_in
结构体中的内容后,就可以创建该结构体了,再定义该结构体后,需要清空,确保其中的字段干净可用
将变量置为
0
可用使用bzero
函数
cpp#include <cstrins> // bzero 函数的头文件 struct sockaddr_in local; bzero(&local, sizeof(local));
获得一个干净可用的 sockaddr_in 结构体后,可以正式绑定 IP 地址 和 端口号 了
server.hpp 服务器头文件
cpp
#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 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地址和端口号
int n = bind(sock_, (const sockaddr*)&local, sizeof(local));
if(n<0)
{
std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;
exit(BIND_ERR);
}
// 绑定成功
std::cout << "Bind IP&&Port Success" << std::endl;
}
// 启动服务器
void StartServer()
{}
private:
int sock_; // 套接字
uint16_t port_; // 端口号
std::string ip_; // IP地址(后面需要删除)
};
}
注:作为服务器,需要确定自己的端口号,我这里设置的是 8888,这个端口号需要来回发送的,这个端口号必须是网络字节序,可以使用 htons 函数
有几点要说明了
- 端口号会在网络里互相转发,需要把主机序列转换为网络序列,可以使用 htons 函数
- 需要把点分十进制的字符串,转换为无符号短整数,可以使用 inet_addr 函数,这个函数在进行转换的同时,会将主机序列转换为网络序列(因为IP地址需要在网络里面发送)
- 绑定IP地址和端口号这个行为并非直接绑定到当前主机中,而是在当前程序中,将创建的 socket 套接字,与目标IP地址与端口号进行绑定,当程序终止后,这个绑定关系也会随之消失
我们这里先插一个小知识
3.3.2.1.地址转换函数------字符串和struct in_addr互相转换
- 我们这里为什么要使用字符串来表示IP地址?
首先大部分用户习惯使用的IP是点分十进制的字符串,就像下面这个这样
cpp128.11.3.31
基于IPv4的socket网络编程,sockaddr_in中的成员struct in_addr sin_addr表示32位 的IP 地址,也就是说事实上我们的IP地址就是下面的第3个成员。
cppstruct sockaddr_in { short sin_family; // 2 字节 ,地址族,e.g. AF_INET, AF_INET6 unsigned short sin_port; // 2 字节 ,16位TCP/UDP 端口号 e.g. htons(3490), struct in_addr sin_addr; // 4 字节 ,32位IP地址 char sin_zero[8]; // 8 字节 ,不使用 }; struct in_addr { unsigned long s_addr; // 32位IPV4地址打印的时候可以调用inet_ntoa()函数将其转换为char *类型. };
点分十进制的IP地址不好输入,我们往往先用更好输入的字符串来存储IP地址,然后将字符串版的IP地址转换为struct in_addr版的IP地址(也就是点分十进制版的)
事实上,在网络编程中,经常需要进行点分十进制字符串表示的IP地址和
in_addr
结构体表示的IP地址之间的转换。以下是一些常用的地址转换函数,它们存放在<arpa/inet.h>头文件中。
- 1. 字符串转 in_addr 结构体
有很多函数都能做到,这里就举两个,第一个函数就是inet_addr
cpp#include <arpa/inet.h> in_addr_t inet_addr(const char *cp);
该函数将点分十进制的字符串表示的IPv4地址转换为网络字节序的32位整数。返回的是in_addr_t类型,通常用于填充sin_addr.s_addr字段。
示例
cppconst char *ipString = "192.168.1.1"; in_addr_t ipAddress = inet_addr(ipString);
第二个函数就是inet_pton
cpp#include <arpa/inet.h> int inet_pton(int af, const char *src, void *dst);
**这个函数是更通用的函数,支持IPv4和IPv6地址的转换。**第一个参数 af 表示地址族,常用的是 AF_INET(IPv4)和 AF_INET6(IPv6)。第二个参数 src 是输入的字符串表示的IP地址,第三个参数 dst 是输出的二进制表示的IP地址。
示例
cpp#include <arpa/inet.h> struct in_addr ipv4Address; const char *ipString = "192.168.1.1"; inet_pton(AF_INET, ipString, &(ipv4Address.s_addr));
- 2. in_addr 结构体转字符串
有很多函数都能做到,这里就举两个,第一个函数是inet_ntoa函数
cpp#include <arpa/inet.h> char *inet_ntoa(struct in_addr in);
该函数将in_addr结构体中的IPv4地址转换为点分十进制的字符串表示。需要注意的是,返回的是指向静态缓冲区的指针,inet_ntoa把结果放到自己内部的一个静态存储区, 这样第二次调用时的结果会覆盖掉上一次的结果。因此不宜多次调用,不能用来多线程。
- 如果有多个线程调用 inet_ntoa, 是否会出现异常情况呢?
- 在APUE中, 明确提出inet_ntoa不是线程安全的函数;
- 在多线程环境下, 推荐使用inet_ntop, 这个函数由调用者提供一个缓冲区保存结果, 可以规避线程安全问 题;
示例:
cpp#include <arpa/inet.h> struct in_addr ipv4Address; ipv4Address.s_addr = inet_addr("192.168.1.1"); char *ipString = inet_ntoa(ipv4Address);
第二个函数就是inet_ntop
cpp#include <arpa/inet.h> const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
**这是一个更通用的函数,支持IPv4和IPv6地址的转换。**第一个参数 af 表示地址族,常用的是 AF_INET(IPv4)和 AF_INET6(IPv6)。第二个参数 src 是输入的二进制表示的IP地址。第三个参数 dst 是输出的字符串表示的IP地址的缓冲区。第四个参数 size 是缓冲区的大小。
示例
cpp#include <arpa/inet.h> struct in_addr ipv4Address; ipv4Address.s_addr = inet_addr("192.168.1.1"); char ipString[INET_ADDRSTRLEN]; inet_ntop(AF_INET, &(ipv4Address.s_addr), ipString, INET_ADDRSTRLEN);
这些地址转换函数是在网络编程中非常实用的工具,它们使得在不同表示之间进行转换变得简单而高效。通过合理使用这些函数,你可以轻松地在字符串表示和二进制表示之间转换IP地址,从而更方便地进行网络编程。
server.cc
服务器源文件
cpp
#include <memory> // 智能指针相关头文件
#include "server.hpp"
using namespace std;
using namespace nt_server;
int main()
{
unique_ptr<UdpServer> usvr(new UdpServer("8.134.110.68"));
// 初始化服务器
usvr->InitServer();
// 启动服务器
usvr->StartServer();
return 0;
}
接下来编译并运行程序
可以发现运行错了
- 如果运行环境是虚拟机,这个是可以运行起来的,但是我们今天的运行环境是云服务器,云服务器禁止绑定公网IP,因为这个是虚拟化了的,跟我们的机器对不上。
- 此外,一台机器可能有多张网卡,有多个IP地址,这样子如果只绑定了一个IP,那么只能收到这个IP发来的。
所以解决方案是在绑定 IP 地址时,让其选择绑定任意可用 IP 地址
这样子有两种方法
第一种方法是服务器端只需要作下面这些改动
- 不需要为 IP 地址而创建srring类型
- 构造时也无需传入 IP 地址
- 绑定 IP 地址时选择 INADDR_ANY,表示绑定任何可用的 IP 地址
server.hpp
服务器头文件
cpp
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_; // 删除
};
此外还有一种方法,我们可以不删除这个string,我们让IP绑定到0.0.0.0,0.0.0.0 表示任意IP地址
cpp#pragma once //..... namespace nt_server { // 退出码 enum { SOCKET_ERR = 1, BIND_ERR }; // 端口号默认值 const uint16_t default_port = 8888; const std::string="0.0.0.0";//注意这里 class UdpServer { public: // 构造 UdpServer(const std::string ip=defaultip, const uint16_t port = default_port) :port_(port), ip_(ip) {} // 析构 ~UdpServer() {} // 初始化服务器 void InitServer() { //。。。。 // 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()); // 点分十进制转为短整数,再将主机序列转为网络序列 //。。。。 } // 启动服务器 void StartServer() {} private: int sock_; // 套接字 uint16_t port_; // 端口号 std::string ip_; // IP地址 }; }
这样子就好了
server.cc
服务器源文件
cpp
#include <memory> // 智能指针相关头文件
#include "server.hpp"
using namespace std;
using namespace nt_server;
int main()
{
unique_ptr<UdpServer> usvr(new UdpServer());
// 初始化服务器
usvr->InitServer();
// 启动服务器
usvr->StartServer();
return 0;
}
再次编译并运行程序,可以看到正常运行
到目前为止,我们的UDP网络通信程序已经完成了最基本的环境搭建,接下来就是发信息,读消息那些了。
3.3.3.读取信息------recvfrom函数
读取信息使用recvfrom函数
recvfrom()
函数是一个系统调用,用于从套接字接收数据。
该函数通常与无连接的数据报服务(如 UDP)一起使用,但也可以与其他类型的套接字使用。
与简单的 recv()
函数不同,recvfrom()
可以返回数据来源的地址信息。
我们来看看它的参数
-
sockfd:一个已打开的套接字的描述符。
-
buf:一个指针,指向用于存放接收到的数据的缓冲区。
-
len:缓冲区的大小(以字节为单位)。
-
flags:控制接收行为的标志,读取方式(阻塞/非阻塞)。通常可以设置为0,但以下是一些可用的标志:
- MSG_WAITALL:尝试接收全部请求的数据。函数可能会阻塞,直到收到所有数据。
- MSG_PEEK:查看即将接收的数据,但不从套接字缓冲区中删除它【1】。
- 其他一些标志还可以影响函数的行为,但在大多数常规应用中很少使用。
前半部分主要用于读取数据,并进行存放,接下来看看后半部分
-
src_addr:一个指针,指向一个 sockaddr 结构,用于保存发送数据的源地址。
-
addrlen:一个值-结果参数。开始时,它应该设置为 src_addr 缓冲区的大小。当 recvfrom() 返回时,该值会被修改为实际地址的长度(以字节为单位)。
后面都是用来保存对方的地址信息的!
返回值:
- 在成功的情况下,
recvfrom()
返回接收到的字节数。 - 如果没有数据可读或套接字已经关闭,那么返回值为0。
- 出错时,返回
-1
,并设置全局变量errno
以指示错误类型。
使用示例
cpp
struct sockaddr_in sender;
socklen_t sender_len = sizeof(sender);
char buffer[1024];
int bytes_received = recvfrom(sockfd, buffer, sizeof(buffer), 0,
(struct sockaddr*)&sender, &sender_len);
if (bytes_received < 0) {
perror("recvfrom failed");
// handle error
}
注意: 因为
recvfrom
函数的参数src_addr
类型为sockaddr
,需要将sockaddr_in
类型强转后,再进行传递
server.hpp
服务器头文件
cpp
namespace nt_server
{
// 退出码
enum
{
SOCKET_ERR = 1,
BIND_ERR
};
// 端口号默认值
const uint16_t default_port = 8888;
class 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';
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.回响给客户端
// ...
}
}
//.....
};
}
到这里也算是完成一小步
3.3.4.发送消息------sendto函数
发送信息使用 sendto
函数
sendto()
函数是一个系统调用,用于发送数据到一个指定的地址。
它经常与无连接的数据报协议,如UDP,一起使用。
不像 send()
函数只能发送数据到一个预先建立连接的远端,sendto()
允许在每次发送操作时指定目的地址。
参数解释:
-
sockfd:一个已打开的套接字的描述符。
-
buf:一个指针,指向要发送的数据的缓冲区。
-
len:要发送的数据的大小(以字节为单位)。
-
flags:控制发送行为的标志,也就是发送方式(阻塞/非阻塞)。通常可以设置为0。一些可用的标志包括:
- MSG_CONFIRM:在数据报协议下告诉网络层该数据已经被确认。
- MSG_DONTROUTE:不查找路由,数据报将只发送到本地网络。
- 其他标志可以影响函数的行为,但在大多数常规应用中很少使用。
-
dest_addr:指向 sockaddr 结构的指针,该结构包含目标地址和端口信息。
-
addrlen:dest_addr 缓冲区的大小(以字节为单位)。
返回值:
- 成功时,
sendto()
返回实际发送的字节数。 - 出错时,返回
-1
并设置全局变量errno
以指示错误类型。
例子:
cpp
struct sockaddr_in receiver;
receiver.sin_family = AF_INET;
receiver.sin_port = htons(12345); // Some port number
inet_pton(AF_INET, "192.168.1.1", &receiver.sin_addr); // Some IP address
char message[] = "Hello, World!";
ssize_t bytes_sent = sendto(sockfd, message, sizeof(message), 0,
(struct sockaddr*)&receiver, sizeof(receiver));
if (bytes_sent < 0) {
perror("sendto failed");
// handle error
}
在这个例子中,我们使用 sendto()
发送一个字符串到指定的IP地址和端口号。如果发送失败,我们打印一个错误消息。
server.hpp
服务器头文件
cpp
//。。。
namespace nt_server
{
// 退出码
enum
{
SOCKET_ERR = 1,
BIND_ERR
};
// 端口号默认值
const uint16_t default_port = 8888;
class 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';
else
continue; // 继续读取
// 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.回响给客户端
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;
}
}
//.....
};
}
万事具备后,就可以启动服务器了,可以看到服务器启动后,处于阻塞等待状态,这是因为还没有客户端给我的服务器发信息,所以它就会暂时阻塞
- 如何证明服务器端正在运行?
可以通过
Linux
中查看网络状态的指令,因为我们这里使用的是UDP
协议,所以只需要输入下面这条指令,就可以查看有哪些程序正在运行
cppnetstat -nlup
现在服务已经跑起来了,并且如期占用了
8888
端口,接下来就是编写客户端相关代码注意:0.0.0.0 表示任意IP地址
这个时候我们修改代码
cpp
#include <memory> // 智能指针相关头文件
#include "server.hpp"
using namespace std;
using namespace nt_server;
int main()
{
unique_ptr<UdpServer> usvr(new UdpServer(80));//使用智能指针创建了一个UdeServer对象
// 初始化服务器
usvr->InitServer();
// 启动服务器
usvr->StartServer();
return 0;
}
运行一下
我们去监控面板看看
不让我们绑定啊
事实上对于端口号的绑定:[0,1023]这个区间的端口号都不要去绑定,这些是系统内定的端口号,一般都有固定的应用层协议使用,比如http:80,https:443...... 这个就像110就代表警察,120是急救,你的电话号码不能是这些吧。此外如果真的想要绑定,那就使用sudo吧!!!
3.3.5.命令行参数改装服务端
上面的代码中,我们的端口号都是在代码里面指定了的,但是我们不能每次使用的时候都去修改代码吧,我们其实通过命令行参数来指定端口号
server.hpp
cpp
//....
// 退出码
enum
{
SOCKET_ERR = 1,
BIND_ERR,
USAGE_ERR
};
//.....
cpp
#include <memory> // 智能指针相关头文件
#include "server.hpp"
using namespace std;
using namespace nt_server;
void Usage(const char* program)
{
cout << "Usage:" << endl;
cout << "\t" << program << " ServerPort" << endl;
}
int main(int argc, char* argv[])
{
if (argc != 2)
{
// 错误的启动方式,提示错误信息
Usage(argv[0]);
return USAGE_ERR;
}
//命令行参数都是字符串,我们需要将其转换成对应的类型
uint16_t port = stoi(argv[1]);//将字符串转换成端口号
unique_ptr<UdpServer> usvr(new UdpServer(port));//使用智能指针创建了一个UdeServer对象
// 初始化服务器
usvr->InitServer();
// 启动服务器
usvr->StartServer();
return 0;
}
运行一下
很酷吧
3.4.客户端设计
3.4.1.使用命令行参数指定IP地址和端口号
和服务端不同,客户端在运行时,必须知道服务器的 IP 地址 和 端口号,否则不知道自己该与谁进行通信,所以对于 UdpClient
类来说,ip
和 port
者两个字段是肯定少不了的
client.hpp
客户端头文件
cpp
#pragma once
#include <iostream>
#include <string>
namespace nt_client
{
// 退出码
enum
{
USAGE_ERR=3
};
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 的方式运行,否则就报错,并提示相关错误信息
client.cc
客户端源文件
cpp
#include <iostream>
#include <memory>
#include "client.hpp"
using 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_ptr<UdpClient> usvr(new UdpClient(ip, port));
// 初始化客户端
usvr->InitClient();
// 启动客户端
usvr->StartClient();
return 0;
}
如此一来,只有正确的输入 [./client ServerIP ServerPort] 才能启动程序,否则不让程序运行,倒逼客户端启动时,提供服务器的 IP
地址 和 端口号
3.4.2.初始化客户端
初始化客户端时,同样需要创建 socket 套接字,不同于服务器的是 客户端不需要自己手动绑定(bind) IP 地址与端口号
这是因为客户端手动指明(bind) 端口号 存在隐患:如果恰好有两个程序使用了同一个端口,会导致其中一方的客户端直接绑定失败,无法运行,将绑定 端口号 这个行为交给 OS 自动执行(首次传输数据时自动 bind),可以避免这种冲突的出现
- 为什么服务器要自己手动指定端口号,并进行绑定(bind)?
这是因为服务器的端口不能随意改变,并且这是要公布给广大客户端看的,同一家公司在部署服务时,会对端口号的使用情况进行管理,可以直接避免端口号冲突
客户端在启动前,需要先知晓服务器的 sockaddr_in 结构体信息,可以利用已知的 IP 地址 和 端口号 构建 ,这个就像,顾客必须得知道哪里会提供服务吧!!!
综上所述,在初始化客户端时,需要创建好套接字和初始化服务器的 sockaddr_in 结构体信息
也就是说
- 客户端需要bind吗?需要,只不过不需要用户显示的bind!一般有os自主随机选择,
- 一个端口号只能被1个进程bind,对server是如此,对client也是如此。
- 其实clinent的port是多少不重要,只要能保证主机上的唯一性就可以。
- 系统什么时候给我bind呢?首次发送数据的时候。
client.hpp
客户端头文件
cpp
#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.hpp"
namespace nt_client
{
// 退出码
enum
{
SOCKET_ERR = 1,
BIND_ERR,
USAGE_ERR
};
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_); // 绑定服务器端口号
//注意这里就不要自己去手动绑定了,os会在我们第一次发送消息的时候自动绑定
}
// 启动客户端
void StartClient()
{}
private:
std::string server_ip_; // 服务器IP地址
uint16_t server_port_; // 服务器端口号
int sock_;
struct sockaddr_in svr_; // 服务器的sockadder_in结构体信息
};
}
如此一来,客户端就可以利用该 sockaddr_in
结构体,与目标主机进行通信了
3.4.2.通信
接下来就是客户端向服务器发送消息,消息由用户主动输入,使用的是 sendto 函数
发送消息步骤
- 用户输入消息
- 传入缓冲区、服务器相关参数,使用 sendto 函数发送消息
- 消息发送后,客户端等待服务器回响消息
接收消息步骤:
- 创建缓冲区
- 接收信息,判断是否接收成功
- 处理信息
注:同服务器一样,客户端也需要不断运行
client.hpp
cpp
// 启动客户端
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';
else
continue;
// 可以再次获取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);
}
}
到现在已经大功告成了,我们马上去运行
3.5.运行的一些问题
服务端必须先启动
我们去另外一个机器下面运行发现
我们发现已经建立成功了
我们现在来启动用户端
那么问题来了,我们用户端怎么知道服务端的IP呢?
大家知道网络呢吧!!我们去上网搜索一下百度的官网,
这个网址的本质就是IP地址,也就是说,服务端提供自己的服务的时候,会把IP也一并公布出来
所以客户端是知道服务端的IP地址的,我们直接填自己的公网IP地址即可
注:
127.0.0.1
表示本地环回(通常用于测试网络程序),因为我当前的服务器和客户端都是在同一机器上运行的,所以就可以使用该IP
地址,当然直接使用服务器的公网IP
地址也是可以的
我们运行一下
我们发现都没有反应啊
这其实不是代码的问题,这是环境的问题,我的云服务器(我的环境)的udp的端口没有开放
3.5.1.防火墙开放端口
打开防火墙
cppsudo systemctl start firewalld.service
关闭防火墙
cppsudo systemctl stop firewalld.service
查看防火墙状态
cppsudo firewall-cmd --state
开放TCP端口
cppsudo firewall-cmd --zone=public --add-port=80/tcp --permanent # 开放TCP 80端口 sudo firewall-cmd --zone=public --add-port=443/tcp --permanent # 开放TCP 443端口 sudo firewall-cmd --zone=public --add-port=3306/tcp --permanent # 开放TCP 3306端口 sudo firewall-cmd --zone=public --add-port=6379/tcp --permanent # 开放TCP 6379端口
关闭TCP端口
cppsudo firewall-cmd --zone=public --remove-port=80/tcp --permanent #关闭TCP 5672端口 sudo firewall-cmd --zone=public --remove-port=443/tcp --permanent #关闭TCP 443端口 sudo firewall-cmd --zone=public --remove-port=3306/tcp --permanent #关闭TCP 3306端口 sudo firewall-cmd --zone=public --remove-port=6379/tcp --permanent #关闭TCP 6379端口
开放udp端口
cppsudo firewall-cmd --zone=public --add-port=9595/udp --permanent # 开放UDP 9595端口
关闭UDP端口
cppsudo firewall-cmd --zone=public --remove-port=9595/udp--permanent #关闭UDP 9595端口
查看监听的TCP端口
cppnetstat -ntlp
查看监听的UDP端口
cppnetstat -nulp
配置生效
cppsudo firewall-cmd --reload
查看所有开放的端口
cppsudo firewall-cmd --list-port
检测UDP的特定端口是否开放
cppsudo firewall-cmd --query-port=8877/udp 检测8877端口是否开放
检测TCP的特定端口是否开放
cppsudo firewall-cmd --query-port=8877/tcp 检测8877端口是否开放
注意上面部分操作是要sudo权限的
- (1)TCP和UDP的端口号范围都是0~65535。
- (2)0~1023的端口号被预留给一些特定的服务和应用程序使用,例如HTTP服务使用的端口号是80,HTTPS服务使用的端口号是443,FTP服务使用的端口号是21等等。这些端口号被称为"知名端口"或"系统端口"。
- (3)1024~49151的端口号被称为"注册端口"或"用户端口",这些端口号可以被一些应用程序使用,但是不会与系统端口冲突。
- (4)49152~65535的端口号被称为"动态端口"或"私有端口",这些端口号可以被应用程序动态地分配使用。
好了我们现在来开放我们的8877窗口
我们去看看它有没有开放
没有啊,这是因为我们还没有让我们的配置生效
好了,目前我们已经把8877端口开放了,接下来就是使用这个开放的端口了。
我们看看8877没有被占用
我们直接绑定8877
绑定成功了啊
我们输入信息看看
还是不通过!!!!!
虽然通过CentOs 7系统的的「防火墙」开放了对应的端口号,任然无法访问端口号对应的应用程序,后面了解到原来还需要设置云服务器的「安全组规则」,开放相应的端口权限,服务端的接口才能真正开放。
3.5.2.设置云服务器安全组
华为云服务器开放端口的具体步骤:
步骤1:登录华为云官网
步骤2:点击主页右上角的控制台
步骤3:进去之后点击安全组
步骤4,进去之后点击我们的实例
注意是点击我们的云服务器配置的那个实例
步骤5,点击入方向规则
步骤 6:点击添加规则
步骤7:按照下面这样子填
注意:
- 0.0.0.0/0表示任意IP地址。
- 如果我们想开放8000-10000等其他区间也是可以的。
我们发现已经成功了
3.5.3.启动
到现在我们总算是完成所有步骤了
完美啊!!!!!
这个时候网络通信已经完成了,我们可以保持服务端一直开启,然后多台云服务器启动client程序,然后就能发给服务端了,服务器这个时候就像是一个多人聊天室了
3.6.源代码
server.hpp
cpp
#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 nt_server
{
// 退出码
enum
{
SOCKET_ERR = 1,
BIND_ERR,
USAGE_ERR
};
// 端口号默认值
const uint16_t default_port = 8888;
class UdpServer
{
public:
// 构造
UdpServer(const uint16_t port = default_port) : port_(port)
{
}
// 析构
~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地址和端口号
int n = bind(sock_, (const sockaddr *)&local, sizeof(local));
if (n < 0)
{
std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;
exit(BIND_ERR);
}
// 绑定成功
std::cout << "Bind IP&&Port 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';
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.回响给客户端
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;
}
}
private:
int sock_; // 套接字
uint16_t port_; // 端口号
};
}
cpp
#include <memory> // 智能指针相关头文件
#include "server.hpp"
using namespace std;
using namespace nt_server;
void Usage(const char* program)
{
cout << "Usage:" << endl;
cout << "\t" << program << " ServerPort" << endl;
}
int main(int argc, char* argv[])
{
if (argc != 2)
{
// 错误的启动方式,提示错误信息
Usage(argv[0]);
return USAGE_ERR;
}
//命令行参数都是字符串,我们需要将其转换成对应的类型
uint16_t port = stoi(argv[1]);//将字符串转换成端口号
unique_ptr<UdpServer> usvr(new UdpServer(port));//使用智能指针创建了一个UdeServer对象
// 初始化服务器
usvr->InitServer();
// 启动服务器
usvr->StartServer();
return 0;
}
client.hpp
cpp
#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 nt_client
{
// 退出码
enum
{
SOCKET_ERR = 1,
BIND_ERR,
USAGE_ERR
};
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()
{
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';
else
continue;
// 可以再次获取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);
}
}
private:
std::string server_ip_; // 服务器IP地址
uint16_t server_port_; // 服务器端口号
int sock_;
struct sockaddr_in svr_; // 服务器的sockadder_in结构体信息
};
}
cpp
#include <iostream>
#include <memory>
#include "client.hpp"
using 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_ptr<UdpClient> usvr(new UdpClient(ip, port));
// 初始化客户端
usvr->InitClient();
// 启动客户端
usvr->StartClient();
return 0;
}
makefile
cpp
.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
怎么样?收获很多吧!!
4.代码解耦
我们发现,这个网络环境搭建和我们消息处理的代码 (即使我们上面没有提供消息处理业务)的耦合度太高了,我们能不能将这个网络环境搭建和我们这个聊天服务的代码进行解耦呢?
**基于模块化处理的思想,将服务器中处理消息的函数与启动服务的函数解耦,由程序员传入指定的回调函数,**服务器在启动时,只需要传入对应的业务处理函数(回调函数)即可
这个时候就得使用C++11的function包装器了
4.1.C++function包装器
我来带大家现学现用,function定义在functional头文件中
function包装器是一种函数包装器,也叫做适配器。它可以对可调用对象进行包装,C++中的function本质就是一个类模板。
我们看些例子
cpp
int f(int a, int b)
{
return a + b;
}
struct Functor
{
public:
int operator()(int a, int b)
{
return a + b;
}
};
class Plus
{
public:
static int plusi(int a, int b)
{
return a + b;
}
double plusd(double a, double b)
{
return a + b;
}
};
int main()
{
// 1、包装函数指针(函数名)
function<int(int, int)> func1 = f;
cout << func1(1, 2) << endl;
// 2、包装仿函数(函数对象)
function<int(int, int)> func2 = Functor();
cout << func2(1, 2) << endl;
// 3、包装lambda表达式
function<int(int, int)> func3 = [](int a, int b) {return a + b; };
cout << func3(1, 2) << endl;
// 4、包装静态成员函数
function<int(int, int)> func4 = &Plus::plusi; // &可省略
cout << func4(1, 2) << endl;
// 5、包装类的非静态成员函数
function<double(Plus, double, double)> func5 = &Plus::plusd; // &不可省略
cout << func5(Plus(), 1.1, 2.2) << endl;
return 0;
}
- 包装时指明返回值类型和各形参类型,然后可调用对象赋值给function包装器即可,包装后function对象就可以像普通函数一样使用了。
- 取静态成员函数的地址可以不用取地址运算符 & ,但取非静态成员函数地址使用 & 。
- 包装费静态的成员函数需要注意,非静态成员函数的第一个参数是隐藏this指针,因此在包装时需要指明第一个形参的类型为类的类型。
好了,相信大家会用了 ,如果想详细了解的话可以去:http://t.csdnimg.cn/SRoDV
4.2.分离网络通信和消息处理业务
server.hpp
服务器头文件
cpp
#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>
namespace nt_server
{
// 退出码
enum
{
SOCKET_ERR = 1,
BIND_ERR,
USAGE_ERR
};
// 端口号默认值
const uint16_t default_port = 8888;
using func_t = std::function<std::string(std::string)>;
// 可以简单的理解为func_t是一个参数为string,返回值同样为string的函数的类型
class UdpServer
{
public:
// 构造
UdpServer(const func_t& func, uint16_t port = default_port)//注意这里的func_t
:port_(port)
,serverHandle_(func)
//注意serverHandle_的类型已经是一个func_t,就是一个一个参数为string,返回值同样为string的函数的类型
{}
// 析构
~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 IP&&Port Fail: " << strerror(errno) << std::endl;
exit(BIND_ERR);
}
// 绑定成功
std::cout << "Bind IP&&Port 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';
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);
// 获取业务处理后的结果
std::string respond = serverHandle_(buff);
//特别注意这里,业务处理的代码已经放到了这个serverHandle_这个函数了,
//而这个serverHandle_的函数是不在这个类里面,是在类外面了,
//是在创建这个class UdpServer时就指定好了的
// 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_; // 业务处理函数(回调函数)
};
}
我们得特别注意下面这几处地方
cpp
//...
#include <functional>//注意这个
//...
namespace nt_server
{
//..
using func_t = std::function<std::string(std::string)>;
// 可以简单的理解为func_t是一个参数为string,返回值同样为string的函数的类型
class UdpServer
{
public:
// 构造
UdpServer(const func_t& func, uint16_t port = default_port)//注意这里的func_t
:port_(port)
,serverHandle_(func)
//注意serverHandle_的类型已经是一个func_t,就是一个一个参数为string,返回值同样为string的函数的类型
{}
// 析构
~UdpServer()
{}
// 初始化服务器
void InitServer()
{
//...
}
// 启动服务器
void StartServer()
{
//...
// 获取业务处理后的结果
std::string respond = serverHandle_(buff);
//特别注意这里,业务处理的代码已经放到了这个serverHandle_这个函数了,
//而这个serverHandle_的函数是不在这个类里面,是在类外面了,
//是在创建这个class UdpServer时就指定好了的
}
}
private:
//...
func_t serverHandle_; // 业务处理函数(回调函数)
};
}
我们把消息处理业务分离了出来。
cpp
#include <memory> // 智能指针相关头文件
#include "server.hpp"
using namespace std;
using namespace nt_server;
void Usage(const char* program)
{
cout << "Usage:" << endl;
cout << "\t" << program << " ServerPort" << endl;
}
//消息处理业务
// 大写转小写(英文字母)
std::string UpToLow(const std::string& resquest)
{
std::string ret(resquest);
for(auto &rc : ret)
{
if(isupper(rc))
rc += 32;
}
return ret;
}
int main(int argc, char* argv[])
{
if (argc != 2)
{
// 错误的启动方式,提示错误信息
Usage(argv[0]);
return USAGE_ERR;
}
//命令行参数都是字符串,我们需要将其转换成对应的类型
uint16_t port = stoi(argv[1]);//将字符串转换成端口号
unique_ptr<UdpServer> usvr(new UdpServer(UpToLow,port));
// 初始化服务器
usvr->InitServer();
// 启动服务器
usvr->StartServer();
return 0;
}
我们将消息处理业务设定为将消息从大写转小写。
我们运行一下看看
解耦很成功!!!
好,这个只是进行了字符串的转换处理
5.远程bash
服务端提供的服务,可以千变万化,例如我们可以提供命令服务,实现一个远程的bash
5.1.popen和pclose
bash
指令是如何执行的?
- 接收指令(字符串)
- 对指令进行分割,构成有效信息
- 创建子进程,执行进程替换
- 子进程运行结束后,父进程回收僵尸进程
- 输入特殊指令时的处理
这样子做太复杂了
其实Linux系统专门有这样一个系统调用接口------popen
函数
功能:
popen()函数通过先创建一个管道,然后调用 fork 产生一个子进程,让子进程执行shell中的command命令。popen()建立的管道会连到子进程的标准输出设备(stdin)或标准输入设备(stdout),然后返回一个文件指针。随后进程便可利用此文件指针来读取子进程的输出设备或是写入到子进程的标准输入设备中。
这个函数做了这些事:创建管道、创建子进程、执行指令、将执行结果以 FILE 的形式返回*
参数:
1. command:要执行的命令。
2. type:
- 如果 type 为r,则将子进程的标准输出(stdout)连接到返回的的文件指针。
- 如果 type 为 w,则将子进程的标准输入(stdin)连接到返回的的文件指针。
返回值:
- 调用成功就返回一个文件指针,如果此函数内部在调用 fork() 或 pipe() 失败,或者不能分配内存将返回NULL。
此外有打开就有关闭------pclose函数
-
功能 :关闭
popen()
函数打开的文件, -
参数:文件指针
-
返回值:调用成功就返回0,否则返回非0。
我们可以看一个例子
在下面的例子中,我们使用 popen()
函数打开一个进程并执行 ls -l
命令,然后将其输出作为文本流读取并打印到屏幕上。最后,我们使用 pclose()
函数关闭进程和文件指针。
cpp
#include <stdio.h>
int main()
{
FILE* fp = popen("ls -l", "r");//将执行结果以 FILE* 的形式返回
if (!fp)
{
perror("popen fail: ");
}
char buf[1024];
while (fgets(buf, sizeof(buf), fp) != NULL)//将执行结果存到了buf里面
{
printf("%s", buf);
}
pclose(fp);
return 0;
}
5.2.实现远程bash
ExecCommand()
业务处理函数 --- 位于server.cc
服务器源文件
cpp
// 远程 bash
std::string ExecCommand(const std::string& request)
{
// 1.安全检查
// ...
// 2.获取执行结果
FILE* fp = popen(request.c_str(), "r");//将执行结果以 FILE* 的形式返回
if(fp == NULL)
return "Can't execute command!";
// 3.将结果读取至字符串中
std::string ret;
char buffline[1024]; // 行缓冲区
while (fgets(buffline, sizeof(buffline), fp) != NULL)//将执行结果存到buffline里面
{
// 将每一行结果,添加至 ret 中
ret += buffline;
}
// 4.关闭文件流
fclose(fp);
// 5.返回最终执行结果
return ret;
}
此时需要考虑一个问题:如果别人输入的是敏感指令(比如 rm -rf *)怎么办?
- 敏感操作包含这些:kill 发送信号终止进程、mv 移动文件、rm 删除文件、while :; do 死循环、shutdown 关机等等
答案当然是直接拦截,不让别人执行敏感操作,毕竟 Linux 默认可没有回收站,所以我们还需要考虑安全检查
在执行用户传入的指令前,先对指令中的子串进行扫描,如果发现敏感操作,就直接返回,不再执行后续操作
cpp
// 安全检查
bool checkSafe(const std::string& comm)
{
// 构建安全检查组
std::vector<std::string> unsafeComms{"kill", "mv", "rm", "while :; do", "shutdown"};
// 查找 comm 中是否包含安全检查组中的字段
for(auto &str : unsafeComms)
{
// 如果找到了,就说明存在不安全的操作
if(comm.find(str) != std::string::npos)
return false;
}
return true;
}
将 checkSafe 安全检查函数整合进 ExecCommand 业务处理函数中,同时在构建 UdpServer 对象时,传入该业务处理函数对象,编译并运行程序
cpp
#include <string>
#include <vector>
#include <memory> // 智能指针相关头文件
#include <cstdio>
#include "server.hpp"
using namespace std;
using namespace nt_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 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 "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;
}
void Usage(const char* program)
{
cout << "Usage:" << endl;
cout << "\t" << program << " ServerPort" << endl;
}
int main(int argc, char* argv[])
{
if (argc != 2)
{
// 错误的启动方式,提示错误信息
Usage(argv[0]);
return USAGE_ERR;
}
//命令行参数都是字符串,我们需要将其转换成对应的类型
uint16_t port = stoi(argv[1]);//将字符串转换成端口号
unique_ptr<UdpServer> usvr(new UdpServer(ExecCommand,port));
// 初始化服务器
usvr->InitServer();
// 启动服务器
usvr->StartServer();
return 0;
}
可以看到,输入安全指令时,可以正常获取结果,如果输入的是非安全指令,会直接拒绝执行
诸如
cd
这种指令称为 内建命令,是需要特殊处理的,所以这里才会执行失败
这样子就完成一个简易的远程bash啦!!!!!
这里其实就是想告诉大家,我们在Xshell和云服务器的工作原理是:我们的Xshell就相当于我们的client端,我们的云服务器相当于我们的server端,我们的云服务器开放一个端口让我们去访问,我们在Xshell输入指令,但是指令的运行是在很远的另外一台服务器,服务器通过网络来将结果打印到我们的Xshell里面而已
我们可以去看看这个开放的端口
就是上面那个323
5.3.windows和Linux联动
我们说过每款操作系统是不一样的,但是每款操作系统的网络协议栈必须是一样的,所以Linux的套接字和Windows的套接字是一样的!! !也就是说,我们可以在windows环境下面基本可以运行这代码,只不过windows里的数据类型和Linux有点区别
接下来我要简单的修改一下代码,让我们的Linux端充当一个服务器,让windows下充当一个客户端
我们打开vs2022
由于Linux端的代码和windows端的代码有点不一样,我们需要稍微做一点修改
client.hpp
cpp
#pragma once
#pragma warning (disable:4996)
#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <string.h>
#include <sys/types.h>
#include<winsock2.h>
#pragma comment(lib,"ws2_32.lib")
namespace nt_client
{
// 退出码
enum
{
SOCKET_ERR = 1,
BIND_ERR,
USAGE_ERR
};
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 结构体信息
memset(&svr_,0, 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()
{
char buff[1024];
while (true)
{
// 1.发送消息
std::string msg;
std::cout << "Input Message# ";
std::getline(std::cin, msg);
int 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.接收消息
int len = sizeof(svr_); // 创建一个变量,因为接下来的参数需要传左值
n = recvfrom(sock_, buff, sizeof(buff) - 1, 0, (struct sockaddr*)&svr_, &len);
if (n > 0)
buff[n] = '\0';
else
continue;
// 可以再次获取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);
}
}
private:
std::string server_ip_; // 服务器IP地址
uint16_t server_port_; // 服务器端口号
SOCKET sock_;
struct sockaddr_in svr_; // 服务器的sockadder_in结构体信息
};
}
client.cpp
cpp
#define _CRT_SECURE_NO_WARNINGS
/*客户端*/
#include<memory>
#include"udp_client.h"
using namespace std;
using namespace nt_client;
#define SERVER_IP "127.0.0.1" // 服务器IP地址:指要连接的服务器的地址
#define SERVER_PORT 8877 // 服务器端口号
int main() {
// 初始化Winsock库
WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {
printf("WSAStartup failed!\n");
return -1;
}
unique_ptr<UdpClient> usvr(new UdpClient(SERVER_IP, SERVER_PORT));
// 初始化客户端
usvr->InitClient();
// 启动客户端
usvr->StartClient();
// 清理Winsock库
WSACleanup();
return 0;
}
我们打开浏览器
完美通信了
这里我只是想说明我们的windows和Linux的网络协议栈是一样的
6.多人聊天室
6.1.单线程版本
这是基于 UDP
协议实现的最后一个网络程序,主要功能是 构建一个多人聊天室,当某个用户发送消息时,其他用户可以立即收到,形成一个群聊
在这个程序中,服务器扮演了一个接收消息和分发消息的中间角色,将消息发送给已知的用户主机
首先我们需要明确一些问题,我们服务端怎么知道客户端的位置?
- 其实服务端在接受客户端的信息的时候就已经有它的信息了
但是我们的用户不只有1个,我们必须得把它们的信息管理起来,我们必须弄一张用户列表来记录我们的在线用户信息,这里我们使用unordered_map,不知道的可以去http://t.csdnimg.cn/SJH7p
server.hpp
cpp
#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>
namespace nt_server
{
// 退出码
enum
{
SOCKET_ERR = 1,
BIND_ERR,
USAGE_ERR
};
// 端口号默认值
const uint16_t default_port = 8888;
using func_t = std::function<std::string(std::string)>;
// 可以简单的理解为func_t是一个参数为string,返回值同样为string的函数的类型
class UdpServer
{
public:
// 构造
UdpServer(const func_t& func, uint16_t port = default_port)//注意这里的func_t
:port_(port)
,serverHandle_(func)
//注意serverHandle_的类型已经是一个func_t,就是一个一个参数为string,返回值同样为string的函数的类型
{}
// 析构
~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 IP&&Port Fail: " << strerror(errno) << std::endl;
exit(BIND_ERR);
}
// 绑定成功
std::cout << "Bind IP&&Port Success" << std::endl;
}
//检测是不是新用户
void CheckUser(const struct sockaddr_in & client,const std::string clientIp_,uint16_t clientPort_)
{
auto iter= online_user_.find(clientIp_);
if(iter==online_user_.end())
{
online_user_.insert({clientIp_,client});
std::cout<<"["<<clientIp_<<":"<<clientPort_<<"] add to oniline user."<<std::endl;
}
}
//广播给所有人
void Broadcast(const std::string& respond,const std::string clientIp_,uint16_t clientPort_)
{
for(const auto&usr :online_user_)
{
std::string message="[";
message+=clientIp_;
message+=" : ";
message+=std::to_string(clientPort_);
message+="]#";
message+=respond;
int z = sendto(sock_, respond.c_str(), respond.size(), 0, (const sockaddr*)&usr.second, sizeof(usr.second));
if(z == -1)
std::cout << "Send Message Fail: " << strerror(errno) << std::endl;
}
}
// 启动服务器
void StartServer()
{
// 服务器是不断运行的,所以需要使用一个 while(true) 死循环
char buff[1024]; // 缓冲区
while(true)
{
// 1. 接收消息
struct sockaddr_in client; // 客户端结构体
socklen_t len = sizeof(client); // 客户端结构体大小
// 传入 sizeof(buff) - 1 表示当前传输的是字符串,预留一个位置存储 '\0'
// 传入 0 表示当前是阻塞式读取
ssize_t n = recvfrom(sock_, buff, sizeof(buff) - 1, 0, (struct sockaddr*)&client, &len);
if(n > 0)
buff[n] = '\0';
else
continue; // 继续读取
// 2.处理数据
std::string clientIp = inet_ntoa(client.sin_addr); // 获取用户的IP地址
uint16_t clientPort = ntohs(client.sin_port); // 获取端口号
//2.1.判断是不是新用户,如果是就加入,如果不是就什么也不做
CheckUser(client,clientIp,clientPort);
//2.2 对数据进行业务处理,并获取业务处理后的结果
std::string respond = serverHandle_(buff);
//特别注意这里,业务处理的代码已经放到了这个serverHandle_这个函数了,
// 3.回响给所有在线客户端
Broadcast(respond,clientIp,clientPort);
}
}
private:
int sock_; // 套接字
uint16_t port_; // 端口号
func_t serverHandle_; // 业务处理函数(回调函数)
std::unordered_map<std::string,struct sockaddr_in> online_user_;//在线用户列表
};
}
其他都没有变化
cpp
#include <string>
#include <vector>
#include <memory> // 智能指针相关头文件
#include <cstdio>
#include "server.hpp"
using namespace std;
using namespace nt_server;
//业务处理函数
std::string ExecCommand(const std::string& request)
{
return request;
}
void Usage(const char* program)
{
cout << "Usage:" << endl;
cout << "\t" << program << " ServerPort" << endl;
}
int main(int argc, char* argv[])
{
if (argc != 2)
{
// 错误的启动方式,提示错误信息
Usage(argv[0]);
return USAGE_ERR;
}
//命令行参数都是字符串,我们需要将其转换成对应的类型
uint16_t port = stoi(argv[1]);//将字符串转换成端口号
unique_ptr<UdpServer> usvr(new UdpServer(ExecCommand,port));
// 初始化服务器
usvr->InitServer();
// 启动服务器
usvr->StartServer();
return 0;
}
我们运行一下
到这里我们还是感觉挺正常的,我们接着往下看
我们再运行一下我们在windows上面写的client程序
有没有发现,我们第二个客户发消息过去,第二个客户只收到了它自己的消息,第一个客户也没有反应
但是我们继续让第一个客户发消息,我们却惊奇的发现这次收到的消息居然是第二个用户第一次发的消息!!!!
这是为什么呢?
这是因为我们仔细观察一下我们的client端代码,
cpp
// 启动客户端
void StartClient()
{
char buff[1024];
while(true)
{
// 1.发送消息
std::string msg;
std::cout << "Input Message# ";
std::getline(std::cin, msg);
......
n = recvfrom(sock_, buff, sizeof(buff) - 1, 0, (struct sockaddr*)&svr_, &len);
.....
}
}
每获取完一次消息,就立马要去输入,如果我们不输入,那么这个getline函数输入会阻塞这个进程的运行啊。
那么就有2种解决方法, 客户端的代码
- 用其他不阻塞的函数替换getline,比如getch()和kbhit()函数
- **将客户端的代码从单线程版改成多线程版本,**一个线程输入,一个线程获取信息
注意: 我们创建的是UDP套接字,它是全双工的,也就是说它是可以同时读和写的。
那么话不多说我们就用多线程来修改一下
6.2.多线程版本
6.1.1.C++11类内使用多线程
有很多时候,我们希望可以在C++类里面对那些比较耗时的函数使用多线程技术,但是熟悉C++对象语法的人应该知道,C++类的成员函数的函数指针不能直接做为参数传到pthread_create,主要因为是C++成员函数指针带有类命名空间,同时成员函数末尾是会被C++编译器加上可以接收对象地址的this指针参数。
因此需要将成员函数做一定的转化,将其转化为不被编译器加上this指针,而由我们自己来为该函数维护"this"指针即可。
只需将类内的线程函数变化为static函数,但是static函数就不能直接访问到我们类内的私有数据,不要忘了这个线程函数的void*参数,我们可以把this指针作为参数传给它,然后就能通过这个this指针来访问到我们的类内的私有数据
client.hpp
cpp
#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 <pthread.h>
#include <unistd.h>
#include <arpa/inet.h>
#include<functional>
namespace nt_client
{
// 退出码
enum
{
SOCKET_ERR = 1,
BIND_ERR,
USAGE_ERR
};
class UdpClient
{
public:
// 构造
UdpClient(const std::string &ip, uint16_t port)
: server_ip_(ip), server_port_(port)
{
}
// 析构
~UdpClient()
{
}
static void *send_message(void *argc)//传进来的是this指针
{
UdpClient*_this =(UdpClient*)argc;//强制转换为类指针
char buff[1024];
while (1)
{
// 1.发送消息
std::string msg;
std::cout << "Input Message# ";
std::getline(std::cin, msg);
ssize_t n = sendto(_this->sock_, msg.c_str(), msg.size(), 0, (const struct sockaddr *)&_this->svr_, sizeof(_this->svr_));
if (n == -1)
{
std::cout << "Send Message Fail: " << strerror(errno) << std::endl;
continue; // 重新输入消息并发送
}
}
return (void*)0;
}
static void *recv_message(void *argc)//传进来的是this指针
{
UdpClient*_this =(UdpClient*)argc;
char buff[1024];
while (1)
{
// 2.接收消息
socklen_t len = sizeof(_this->svr_); // 创建一个变量,因为接下来的参数需要传左值
int n = recvfrom(_this->sock_, buff, sizeof(buff) - 1, 0, (struct sockaddr *)&_this->svr_, &len);
if (n > 0)
buff[n] = '\0';
else
continue;
// 可以再次获取IP地址与端口号
std::string ip = inet_ntoa(_this->svr_.sin_addr);
uint16_t port = ntohs(_this->svr_.sin_port);
printf("Client get message from [%s:%d]# %s\n", ip.c_str(), port, buff);
}
return (void*)0;
}
// 初始化客户端
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()
{
pthread_t recv, sender;
//只需将类内的线程函数变化为static函数,但是static函数就不能直接访问到我们类内的私有数据,
//不要忘了这个线程函数的void*参数,我们可以把this指针作为参数传给它,
//然后就能通过这个this指针来访问到我们的类内的私有数据
pthread_create(&recv, nullptr, recv_message, (void*)this);
pthread_create(&sender, nullptr, send_message, (void*)this);
pthread_join(recv,nullptr);
pthread_join(sender,nullptr);
}
private:
std::string server_ip_; // 服务器IP地址
uint16_t server_port_; // 服务器端口号
int sock_;//套接字描述符
struct sockaddr_in svr_; // 服务器的sockadder_in结构体信息
};
}
client.cc保持不变
makefile
cpp
.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
6.1.2.C++11多线程功能
这里需要简单的使用一下C++11的多线程功能
大家跟我简单学一下就行
cpp
#include <iostream>
#include <thread> //必须包含<thread>头文件
void threadFunctionA()
{
std::cout << "Run New thread: 1" << std::endl;
}
void threadFunctionB(int n)
{
std::cout << "Run New thread: "<< n << std::endl;
}
int main()
{
std::cout << "Run Main Thread" << std::endl;
std::thread newThread1(threadFunctionA);
std::thread newThread2(threadFunctionB,2);
newThread1.join();
newThread2.join();
return 0;
}
cpp
//result
Run Main Thread
Run New thread: 1
Run New thread: 2
上述示例中,我们创建了两个线程newThread1 和newThread2 ,使用函数threadFunctionA()
和threadFunctionB()
作为线程的执行函数,并使用join()
函数等待线程执行完成。
windows端client.h代码
cpp
#pragma once
#pragma warning (disable:4996)
#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <string.h>
#include <sys/types.h>
#include<winsock2.h>
#include <thread>
#pragma comment(lib,"ws2_32.lib")
namespace nt_client
{
// 退出码
enum
{
SOCKET_ERR = 1,
BIND_ERR,
USAGE_ERR
};
class UdpClient
{
public:
// 构造
UdpClient(const std::string& ip, uint16_t port)
: server_ip_(ip), server_port_(port)
{
}
// 析构
~UdpClient()
{
}
static void* send_message(void* argc)//传进来的是this指针
{
UdpClient* _this = (UdpClient*)argc;//强制转换为类指针
char buff[1024];
while (1)
{
// 1.发送消息
std::string msg;
std::cout << "Input Message# ";
std::getline(std::cin, msg);
int n = sendto(_this->sock_, msg.c_str(), msg.size(), 0, (const struct sockaddr*)&_this->svr_, sizeof(_this->svr_));
if (n == -1)
{
std::cout << "Send Message Fail: " << strerror(errno) << std::endl;
continue; // 重新输入消息并发送
}
}
return (void*)0;
}
static void* recv_message(void* argc)//传进来的是this指针
{
UdpClient* _this = (UdpClient*)argc;
char buff[1024];
while (1)
{
// 2.接收消息
int len = sizeof(_this->svr_); // 创建一个变量,因为接下来的参数需要传左值
int n = recvfrom(_this->sock_, buff, sizeof(buff) - 1, 0, (struct sockaddr*)&_this->svr_, &len);
if (n > 0)
buff[n] = '\0';
else
continue;
// 可以再次获取IP地址与端口号
std::string ip = inet_ntoa(_this->svr_.sin_addr);
uint16_t port = ntohs(_this->svr_.sin_port);
printf("Client get message from [%s:%d]# %s\n", ip.c_str(), port, buff);
}
return (void*)0;
}
// 初始化客户端
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 结构体信息
memset(&svr_,0, 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()
{
//只需将类内的线程函数变化为static函数,但是static函数就不能直接访问到我们类内的私有数据,
//不要忘了这个线程函数的void*参数,我们可以把this指针作为参数传给它,
//然后就能通过这个this指针来访问到我们的类内的私有数据
std::thread rec(recv_message, (void*)this);
std::thread sen(send_message, (void*)this);
rec.join();
sen.join();
}
private:
std::string server_ip_; // 服务器IP地址
uint16_t server_port_; // 服务器端口号
int sock_;//套接字描述符
struct sockaddr_in svr_; // 服务器的sockadder_in结构体信息
};
}
我们运行起来看看
这样子客户端就能进行一个群聊了。
这回就完全没有什么问题了,但是它这个输入和输出混在一起了,客户体验感不好。
6.3.分离输入输出
我们知道Linux下一切皆是文件,那么我们的终端也是文件,我们一个账号可以被多个终端同时登录,就像下面这样子
那Linux是怎么区分的呢?
其实在/dev/pts这个目录里面就记录了登录这个账号的终端
我们可以做下面这些测试
我们发现往0立马写没有反应,往1写是左边那个终端,往2写是右边这个终端 ,我们可以让它当作我们的消息的输出区,我们可以使用open来打开这个文件然后往里面写入
我们写一个测试代码
cpp
#include<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
std::string termial="/dev/pts/1";
int main()
{
int fd=open(termial.c_str(),O_WRONLY);
if(fd<0)
{
std::cout<<"open error"<<std::endl;
return 1;
}
dup2(fd,1);//重定向
std::cout<<"hello world"<<std::endl;
close(fd);
}
我们发现在右边的终端运行这个程序,真的打印到左边这个终端来了。
client.hpp
cpp
.....
static void *recv_message(void *argc)//传进来的是this指针
{
.......
std::cerr<<"Client get message from ["<<ip.c_str()<<":"<<port<<"]#"<<buff<<std::endl;
//注意这个cerr!!!
}
return (void*)0;
}
我们运行看看
很好啊!!!输入和输出就分离了
6.5.上线通知在线用户
我们聊天要看看对方在不在线!!!
client.hpp
cpp
static void *send_message(void *argc)//传进来的是this指针
{
UdpClient*_this =(UdpClient*)argc;//强制转换为类指针
std::string msg1=自己的IP;
msg1+="comming....";
sendto(_this->sock_, msg1.c_str(), msg1.size(), 0, (const struct sockaddr *)&_this->svr_, sizeof(_this->svr_));
char buff[1024];
while (1)
{
//。。。
}
return (void*)0;
}
这样子你一上线,就会通知大家!!!
至此基于 UDP 协议实现的多个网络程序都已经编写完成了,尤其是多人聊天室,如果加上简单的图形化界面(比如 EasyX、EGE),就是一个简易版的 QQ 群聊