【网络】套接字(socket)编程——UDP版
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
套接字提供了下面这一批常用接口,用于实现网络通信
#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把目标地址和端口信息混在一起了,如下
struct sockaddr { sa_family_t sin_family;//地址族char sa_data[14]; //14字节,包含套接字中的目标地址和端口信息 };
sockaddr_in在头文件#include<netinet/in.h>或#include <arpa/inet.h>中定义,
该结构体解决了sockaddr的缺陷,把port和addr 分开储存在两个变量中,如下:
struct sockaddr_in {short sin_family; // 2 字节 ,地址族,e.g. AF_INET, AF_INET6unsigned 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
服务器头文件
#pragma once#include <iostream>namespace nt_server
{class UdpServer{public:// 构造UdpServer(){} // 析构~UdpServer(){} // 初始化服务器void InitServer(){}// 启动服务器void StartServer(){}private:// 字段};
}
创建
server.cc
服务器源文件
#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
客户端头文件
#pragma once#include <iostream>namespace nt_client
{class UdpClient{public:// 构造UdpClient() {} // 析构~UdpClient() {} // 初始化客户端void InitClient() {}// 启动客户端void StartClient() {}private:// 字段};
}
创建
client.cc
客户端源文件
#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
文件
.PHONY:all
all:server clientserver:server.ccg++ -o $@ $^ -std=c++11client:client.ccg++ -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
#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
结构体,要想使用该结构体,还得包含下面这两个头文件
#include <netinet/in.h>
#include <arpa/inet.h>
有的人可能又好奇了
- 这个第2个参数不是const struct sockaddr*吗?为啥要使用sockaddr_in呢?
不记得的朋友去上面看看啊!
我们进行网络通信的时候一般的做法是
程序员把类型、ip地址、端口填充sockaddr_in结构体,然后强制转换成sockaddr,作为参数传递给系统调用函数
我们需要详细了解一下这个sockaddr_in结构体
struct sockaddr_in {short sin_family; // 2 字节 ,地址族,e.g. AF_INET, AF_INET6unsigned 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
函数#include <cstrins> // bzero 函数的头文件struct sockaddr_in local; bzero(&local, sizeof(local));
获得一个干净可用的 sockaddr_in 结构体后,可以正式绑定 IP 地址 和 端口号 了
server.hpp 服务器头文件
#pragma once#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.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是点分十进制的字符串,就像下面这个这样
128.11.3.31
基于IPv4的socket网络编程,sockaddr_in中的成员struct in_addr sin_addr表示32位 的IP 地址,也就是说事实上我们的IP地址就是下面的第3个成员。
struct sockaddr_in {short sin_family; // 2 字节 ,地址族,e.g. AF_INET, AF_INET6unsigned 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
#include <arpa/inet.h>in_addr_t inet_addr(const char *cp);
该函数将点分十进制的字符串表示的IPv4地址转换为网络字节序的32位整数。返回的是in_addr_t类型,通常用于填充sin_addr.s_addr字段。
示例
const char *ipString = "192.168.1.1"; in_addr_t ipAddress = inet_addr(ipString);
第二个函数就是inet_pton
#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地址。
示例
#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函数
#include <arpa/inet.h>char *inet_ntoa(struct in_addr in);
该函数将in_addr结构体中的IPv4地址转换为点分十进制的字符串表示。需要注意的是,返回的是指向静态缓冲区的指针,inet_ntoa把结果放到自己内部的一个静态存储区, 这样第二次调用时的结果会覆盖掉上一次的结果。因此不宜多次调用,不能用来多线程。
- 如果有多个线程调用 inet_ntoa, 是否会出现异常情况呢?
- 在APUE中, 明确提出inet_ntoa不是线程安全的函数;
- 在多线程环境下, 推荐使用inet_ntop, 这个函数由调用者提供一个缓冲区保存结果, 可以规避线程安全问 题;
示例:
#include <arpa/inet.h>struct in_addr ipv4Address; ipv4Address.s_addr = inet_addr("192.168.1.1"); char *ipString = inet_ntoa(ipv4Address);
第二个函数就是inet_ntop
#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 是缓冲区的大小。
示例
#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
服务器源文件
#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
服务器头文件
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地址#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
服务器源文件
#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()
可以返回数据来源的地址信息。
我们来看看它的参数
1. sockfd:一个已打开的套接字的描述符。
2. buf:一个指针,指向用于存放接收到的数据的缓冲区。
3. len:缓冲区的大小(以字节为单位)。
4. flags:控制接收行为的标志,读取方式(阻塞/非阻塞)。通常可以设置为0,但以下是一些可用的标志:
- MSG_WAITALL:尝试接收全部请求的数据。函数可能会阻塞,直到收到所有数据。
- MSG_PEEK:查看即将接收的数据,但不从套接字缓冲区中删除它【1】。
- 其他一些标志还可以影响函数的行为,但在大多数常规应用中很少使用。
前半部分主要用于读取数据,并进行存放,接下来看看后半部分
5. src_addr:一个指针,指向一个 sockaddr 结构,用于保存发送数据的源地址。
6. addrlen:一个值-结果参数。开始时,它应该设置为 src_addr 缓冲区的大小。当 recvfrom() 返回时,该值会被修改为实际地址的长度(以字节为单位)。
后面都是用来保存对方的地址信息的!
返回值:
- 在成功的情况下,
recvfrom()
返回接收到的字节数。 - 如果没有数据可读或套接字已经关闭,那么返回值为0。
- 出错时,返回
-1
,并设置全局变量errno
以指示错误类型。
使用示例
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
服务器头文件
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';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.回响给客户端// ...}}//.....};
}
到这里也算是完成一小步
3.3.4.发送消息——sendto函数
发送信息使用 sendto
函数
sendto()
函数是一个系统调用,用于发送数据到一个指定的地址。
它经常与无连接的数据报协议,如UDP,一起使用。
不像 send()
函数只能发送数据到一个预先建立连接的远端,sendto()
允许在每次发送操作时指定目的地址。
参数解释:
1. sockfd:一个已打开的套接字的描述符。
2. buf:一个指针,指向要发送的数据的缓冲区。
3. len:要发送的数据的大小(以字节为单位)。
4. flags:控制发送行为的标志,也就是发送方式(阻塞/非阻塞)。通常可以设置为0。一些可用的标志包括:
- MSG_CONFIRM:在数据报协议下告诉网络层该数据已经被确认。
- MSG_DONTROUTE:不查找路由,数据报将只发送到本地网络。
- 其他标志可以影响函数的行为,但在大多数常规应用中很少使用。
5. dest_addr:指向 sockaddr 结构的指针,该结构包含目标地址和端口信息。
6. addrlen:dest_addr 缓冲区的大小(以字节为单位)。
返回值:
- 成功时,
sendto()
返回实际发送的字节数。 - 出错时,返回
-1
并设置全局变量errno
以指示错误类型。
例子:
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 addresschar 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
服务器头文件
//。。。
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';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.回响给客户端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
协议,所以只需要输入下面这条指令,就可以查看有哪些程序正在运行netstat -nlup
现在服务已经跑起来了,并且如期占用了
8888
端口,接下来就是编写客户端相关代码注意:
0.0.0.0
表示任意IP地址
这个时候我们修改代码
sever.cc
#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
//....// 退出码enum{SOCKET_ERR = 1,BIND_ERR,USAGE_ERR};
//.....
server.cc
#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
客户端头文件
#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
客户端源文件
#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
客户端头文件
#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
// 启动客户端
void StartClient()
{char buff[1024];while(true){// 1.发送消息std::string msg;std::cout << "Input Message# ";std::getline(std::cin, msg);ssize_t n = sendto(sock_, msg.c_str(), msg.size(), 0, (const struct sockaddr*)&svr_, sizeof(svr_));if(n == -1){std::cout << "Send Message Fail: " << strerror(errno) << std::endl;continue; // 重新输入消息并发送}// 2.接收消息socklen_t len = sizeof(svr_); // 创建一个变量,因为接下来的参数需要传左值n = recvfrom(sock_, buff, sizeof(buff) - 1, 0, (struct sockaddr*)&svr_, &len);if(n > 0)buff[n] = '\0';elsecontinue;// 可以再次获取IP地址与端口号std::string ip = inet_ntoa(svr_.sin_addr);uint16_t port = ntohs(svr_.sin_port);printf("Client get message from [%s:%d]# %s\n",ip.c_str(), port, buff);}
}
到现在已经大功告成了,我们马上去运行
3.5.运行的一些问题
服务端必须先启动
我们去另外一个机器下面运行发现
我们发现已经建立成功了
我们现在来启动用户端
那么问题来了,我们用户端怎么知道服务端的IP呢?
大家知道网络呢吧!!我们去上网搜索一下百度的官网,
这个网址的本质就是IP地址,也就是说,服务端提供自己的服务的时候,会把IP也一并公布出来
所以客户端是知道服务端的IP地址的,我们直接填自己的公网IP地址即可
注:
127.0.0.1
表示本地环回(通常用于测试网络程序),因为我当前的服务器和客户端都是在同一机器上运行的,所以就可以使用该IP
地址,当然直接使用服务器的公网IP
地址也是可以的
我们运行一下
我们发现都没有反应啊
这其实不是代码的问题,这是环境的问题,我的云服务器(我的环境)的udp的端口没有开放
3.5.1.防火墙开放端口
打开防火墙
sudo systemctl start firewalld.service
关闭防火墙
sudo systemctl stop firewalld.service
查看防火墙状态
sudo firewall-cmd --state
开放TCP端口
sudo 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端口
sudo 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端口
sudo firewall-cmd --zone=public --add-port=9595/udp --permanent # 开放UDP 9595端口
关闭UDP端口
sudo firewall-cmd --zone=public --remove-port=9595/udp--permanent #关闭UDP 9595端口
查看监听的TCP端口
netstat -ntlp
查看监听的UDP端口
netstat -nulp
配置生效
sudo firewall-cmd --reload
查看所有开放的端口
sudo firewall-cmd --list-port
检测UDP的特定端口是否开放
sudo firewall-cmd --query-port=8877/udp 检测8877端口是否开放
检测TCP的特定端口是否开放
sudo 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
#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';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.回响给客户端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_; // 端口号};
}
server.cc
#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
#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';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);}
}private:std::string server_ip_; // 服务器IP地址uint16_t server_port_; // 服务器端口号int sock_;struct sockaddr_in svr_; // 服务器的sockadder_in结构体信息};
}
client.cc
#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
.PHONY:all
all:server clientserver:server.ccg++ -o $@ $^ -std=c++11client:client.ccg++ -o $@ $^ -std=c++11.PHONY:clean
clean:rm -rf server client
怎么样?收获很多吧!!
4.代码解耦
我们发现,这个网络环境搭建和我们消息处理的代码(即使我们上面没有提供消息处理业务)的耦合度太高了,我们能不能将这个网络环境搭建和我们这个聊天服务的代码进行解耦呢?
基于模块化处理的思想,将服务器中处理消息的函数与启动服务的函数解耦,由程序员传入指定的回调函数,服务器在启动时,只需要传入对应的业务处理函数(回调函数)即可
这个时候就得使用C++11的function包装器了
4.1.C++function包装器
我来带大家现学现用,function定义在functional头文件中
function包装器是一种函数包装器,也叫做适配器。它可以对可调用对象进行包装,C++中的function本质就是一个类模板。
我们看些例子
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
服务器头文件
#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';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);//特别注意这里,业务处理的代码已经放到了这个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_; // 业务处理函数(回调函数)};
}
我们得特别注意下面这几处地方
//...
#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_; // 业务处理函数(回调函数)};
}
我们把消息处理业务分离了出来。
server.cc
#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()
函数关闭进程和文件指针。
#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
服务器源文件
// 远程 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 默认可没有回收站,所以我们还需要考虑安全检查
在执行用户传入的指令前,先对指令中的子串进行扫描,如果发现敏感操作,就直接返回,不再执行后续操作
// 安全检查
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 对象时,传入该业务处理函数对象,编译并运行程序
server.cc
#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
#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';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);}}private:std::string server_ip_; // 服务器IP地址uint16_t server_port_; // 服务器端口号SOCKET sock_;struct sockaddr_in svr_; // 服务器的sockadder_in结构体信息};
}
client.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
#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';elsecontinue; // 继续读取// 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_;//在线用户列表};
}
其他都没有变化
server.cc
#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端代码,
// 启动客户端
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
#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';elsecontinue;// 可以再次获取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
.PHONY:all
all:server clientserver:server.ccg++ -o $@ $^ -std=c++11 -lpthreadclient:client.ccg++ -o $@ $^ -std=c++11 -lpthread.PHONY:clean
clean:rm -rf server client
6.1.2.C++11多线程功能
这里需要简单的使用一下C++11的多线程功能
大家跟我简单学一下就行
#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;
}
//result
Run Main Thread
Run New thread: 1
Run New thread: 2
上述示例中,我们创建了两个线程newThread1和newThread2,使用函数threadFunctionA()
和threadFunctionB()
作为线程的执行函数,并使用join()
函数等待线程执行完成。
windows端client.h代码
#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';elsecontinue;// 可以再次获取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来打开这个文件然后往里面写入
我们写一个测试代码
#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
.....
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
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 群聊
相关文章:
【网络】套接字(socket)编程——UDP版
1.socket 1.1.什么是socket Socket 的中文翻译过来就是“套接字”。 套接字是什么,我们先来看看它的英文含义:插座。 Socket 就像一个电话插座,负责连通两端的电话,进行点对点通信,让电话可以进行通信,端…...
一篇文章让你彻底掌握 Shell
大家好,这里是 Lucifer三思而后行,专注于提升数据库运维效率。 文章目录 一篇文章让你彻底掌握 Shell简介什么是 shell什么是 shell 脚本Shell 环境指定脚本解释器 模式交互模式非交互模式 基本语法解释器注释echoprintfprintf 的转义符 变量变量命名原则…...
Java中的Collection集合:深入理解与应用
在Java中,Collection接口是Java集合框架(Java Collections Framework)的基石之一,它定义了一系列操作对象集合的方法,如添加、删除、遍历等。Collection接口是List、Set和Queue等具体集合类型的父接口,为Ja…...
Kubernetes-K8S
Kubernetes由于单词太长,省略掉中间8个字母简称为K8S。它介于应用服务和服务器之间。能够通过策略协调和管理多个服务,只需要一个YAML文件配置。定义应用的部署顺序等信息,自动部署应用到各个服务器,还可以自动扩容缩容。 架构原理…...
简化文本处理流程,通用文字识别助力提升信息采集效率
随着信息技术的发展、移动设备使用的普及和全球化的商业需求,非结构化数据转换为结构化数据的需求日益增长,数字化成为信息存储和管理的主流趋势。在此背景下,OCR技术应运而生,该技术可以将图像中文本信息转化为计算机等设备可以使…...
【网络】TCP协议通信的重要策略——滑动窗口,快重传,流量控制,拥塞控制,延时应答
目录 MSS值 滑动窗口 滑动窗口与重发机制 快重传机制 滑动窗口与流量控制 滑动窗口与拥塞控制 延时应答 个人主页:东洛的克莱斯韦克-CSDN博客 相关文章 【网络】传输层TCP协议的报头和传输机制-CSDN博客 【网络】详解TCP协议通信时客户/服务端的状态-CSDN博…...
极狐GitLab CI/CD 如何构建镜像并推送到 azure 镜像仓库?
极狐GitLab 是 GitLab 在中国的发行版,专门面向中国程序员和企业提供企业级一体化 DevOps 平台,用来帮助用户实现需求管理、源代码托管、CI/CD、安全合规,而且所有的操作都是在一个平台上进行,省事省心省钱。可以一键安装极狐GitL…...
Leetcode—1143. 最长公共子序列【中等】
2024每日刷题(155) Leetcode—1143. 最长公共子序列 实现代码 class Solution { public:int longestCommonSubsequence(string text1, string text2) {int m text1.length();int n text2.length();vector<vector<int>> dp(m 1, vector&…...
ZBrush笔刷介绍
根据使用的方法和效果,ZBrush的笔刷可以分类如下: 基本功能笔刷 这些笔刷用于常规的雕刻、移动和平滑操作。 纹理应用笔刷 一般需要自己额外购买的是这三种笔刷 Alpha Brushes:使用灰度图(alpha)来定义笔刷形状和纹…...
React+AntDesign做一个日历,展示节假日,节气,并且在某几个时间上添加活动备注
直接贴效果图😄 首先日历是用的AntDesign提供的Calendar组件,这个组件还是蛮强大的,可以自定义头部时间下拉;渲染每个时间段,或者重置时间段内容,玩的空间是很大的 直接贴代码,结尾最后我会将…...
排序算法之梳排序
title: 梳排序 date: 2024-7-30 14:46:27 0800 categories: 排序算法 tags:排序算法梳排序 description: 梳排序(Comb Sort)是一种由弗拉基米尔多博舍维奇(Wlodzimierz Dobosiewicz)于1980年所发明的不稳定排序算法,并…...
ESP8266 创建TCP连接
TCP Client 使用WiFiClient类可以实现TCP Client 基本方法 连接Server,connect WiFiClient client; client.connect(host, port) 检测客户端是否存在数据流 client.available() 读取客户端的一个字符 client.read(); 检查连接状态 client.connected() 使用…...
OceanBase内存管理小窍门
本文来自OceanBase热心用户的实践分享。 本文主要是对OceanBase内存管理的实用技巧分享,而并非直接深入OceanBase的代码层面进行阐述。 阅读本文章你将了解: 重载运算符new 与malloc在返回值上区别?在ceph 双向链表新用法&am…...
【问题解决】git status中文文件名乱码
问题复现 解决办法 在git bash中直接执行如下命令 git config --global core.quotepath false原因 通过 git config --help 可以查看到以下内容: core.quotePath Commands that output paths (e.g. ls-files, diff), will quote “unusual” characters in the p…...
探索数据结构:AVL树的分析与实现
✨✨ 欢迎大家来到贝蒂大讲堂✨✨ 🎈🎈养成好习惯,先赞后看哦~🎈🎈 所属专栏:数据结构与算法 贝蒂的主页:Betty’s blog 1. AVL树的介绍 在前面我们学习二叉搜索树时知道,在数据有序…...
使用 C++ 实现简单的插件系统
使用 C 实现简单的插件系统 在现代软件开发中,插件系统是一种常见的架构模式,它允许开发者在不修改主程序的情况下,扩展应用程序的功能。通过插件,用户可以根据需要添加或移除功能模块,从而提高软件的灵活性和可维护性…...
使用Python创建省份城市地图选择器
在这篇博客中,我们将探讨如何使用Python创建一个简单而实用的省份城市地图选择器。这个项目不仅能帮助我们学习Python的基础知识,还能让我们了解如何处理JSON数据和集成网页浏览器到桌面应用程序中。 C:\pythoncode\new\geographicgooglemap.py 全部代码…...
【Java 数据结构】Stack和Queue介绍
Stack和Queue StackStack是什么Stack的使用构造方法常用方法 栈的模拟实现初始化和基本方法入栈出栈查看栈顶 栈的应用链栈的简单介绍 QueueQueue是什么Queue的使用队列的模拟实现初始化入队出队查看队头元素 循环队列循环队列的定义及其注意点循环队列的实现初始化和基本方法获…...
Docker基本语法
提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档 文章目录 前言一、更新yum镜像仓库(一)查看本地yum镜像源地址(二)设置docker的镜像仓库(1)安装必要工具…...
uniapp 对于scroll-view滑动和页面滑动的联动处理
需求 遇到一个需求 解决方案 这个时候可以做一个内页面滑动判断 <!-- scroll-y 做true或者false的判断是否滑动 --> <view class"u-menu-wrap" style"background-color: #fff;"><scroll-view :scroll-y"data.isGo" scroll-wit…...
opencv基础的图像操作
1.读取图像,显示图像,保存图像 #图像读取、显示与保存 import numpy as np import cv2 imgcv2.imread(./src/1.jpg) #读取 cv2.imshow("img",img) #显示 cv2.imwrite("./src/2.jpg",img) #保存 cv2.waitKey(0) #让程序进入主循环(让…...
Java | Leetcode Java题解之第337题打家劫舍III
题目: 题解: class Solution {public int rob(TreeNode root) {int[] rootStatus dfs(root);return Math.max(rootStatus[0], rootStatus[1]);}public int[] dfs(TreeNode node) {if (node null) {return new int[]{0, 0};}int[] l dfs(node.left);i…...
本地查看的Git远程仓库分支与远程仓库分支数量不一致
说明:一次,在IDEA中想切换到某分支,但是查看Remote没有找到要切换的分支,但是打开GitLab,查看远程仓库,是有这个分支的。 解决:1)在IDEA的Git中,点下面Fatch获取一下远程…...
opencv-python实战项目九:基于拉普拉斯金字塔的图像融合
文章目录 一,简介:二,拉普拉斯金字塔介绍:三,算法实现步骤3.1 构建融合拉普拉斯金字塔3.2 融合后的拉普拉斯金字塔复原: 四,整体代码实现:五,效果: 一&#x…...
浅谈JDK
JDK(Java Development Kit) JDK是Java开发工具包,是Java编程语言的核心软件开发工具。 JDK包含了一系列用于开发、编译和运行Java应用程序的工具和资源。其中包括: 1.Java编译器(javac):用于将Java源代码编译成字节…...
爬虫案例3——爬取彩票双色球数据
简介:个人学习分享,如有错误,欢迎批评指正 任务:从500彩票网中爬取双色球数据 目标网页地址:https://datachart.500.com/ssq/ 一、思路和过程 目标网页具体内容如下: 我们的任务是将上图中…...
C++ | Leetcode C++题解之第337题打家劫舍III
题目: 题解: struct SubtreeStatus {int selected;int notSelected; };class Solution { public:SubtreeStatus dfs(TreeNode* node) {if (!node) {return {0, 0};}auto l dfs(node->left);auto r dfs(node->right);int selected node->val…...
软件架构设计师-UML知识导图
软件架构设计师-UML知识导图,包含如下内容: 结构化设计,包含结构化设计的概念、结构化设计的主要内容、概要设计、详细设计及模块设计原则;UML是什么:介绍UML是什么;UML的结构:构造块、公共机制…...
在使用transformers和pytorch时出现的版本冲突的问题
在使用transformers和torch库的时候,出现了以下问题: 1、OSError: [WinError 126] 找不到指定的模块。 Error loading "D:\Program Files\anaconda3\envs\testenv\Lib\site-packages\torch\lib\fbgemm.dll" or one of its dependencies. 2、…...
uniapp粘贴板地址识别
1: 插件安装 主要是依靠 address-parse 这个插件: 官网 收货地址自动识别 支持pc、h5、微信小程序 - DCloud 插件市场 // 首先需要引入插件 npm install address-parse --save 2:html部分 <view class""><view class&quo…...
长宁网站建设/虎门今日头条新闻
Spark运行模式 Spark 有很多种模式,最简单就是单机本地模式,还有单机伪分布式模式,复杂的则运行在集群中,目前能很好的运行在 Yarn和 Mesos 中,当然 Spark 还有自带的 Standalone 模式,对于大多数情况 Stan…...
广州外贸营销型网站/网址缩短在线生成器
实现效果:实现功能:viewpagerfragment实现加载界面sqlite数据获取并显示到listview上listview的item监听并携带数据跳转到其他界面使用sharedpreference存储部分测试数据实现过程:各方法和变量的作用请详见代码注释。listview的数据显示请见a…...
动态效果酷炫的网站/搜索引擎营销的英文缩写是
嗯,今天好不容易把鸽了好久的缩点给弄完了……感觉好像……很简单? 算法的目的,其实就是在有向图上,把一个强连通分量缩成一个点……然后我们再对此搞搞事情,\(over\) 哦对,时间复杂度很显然是\(\Theta(n)\…...
游戏网站app/石家庄网站建设方案推广
解决方法如下: 1>、进入超级用户模式。也就是输入"su -",系统会让你输入超级用户密码,输入密码后就进入了超级用户模式。 2>、添加文件的写权限。也就是输入命令"chmod uw /etc/sudoers"。 3>、编辑/etc/sudoers文件…...
wordpress视频略缩图/域名解析查询工具
这篇文章主要为大家详细介绍了python小程序: 剪子,石头,布实例代码,具有一定的参考价值,感兴趣的小伙伴们可以参考一下 闲来无事,写一个小程序,练习一下python,哈哈: #!/usr/bin/env python #co…...
如何将vs做的网站备份出来6/软文之家
我做了一個傻事,要在Server上新增一張網卡,可是因為一直無法啟動, 所以很自然的以為CentOS 6又多了其他的設定要求, 因此查了兩天的資料,也試過很多方式,但都沒有效用。 今天早上心血來潮,想說用…...