【网络】套接字(socket)编程——简易UDP网络程序1

1.socket

1.1.什么是socket

Socket 的中文翻译过来就是“套接字”。

套接字是什么,我们先来看看它的英文含义:插座。

        Socket 就像一个电话插座,负责连通两端的电话,进行点对点通信,让电话可以进行通信,端口就像插座上的孔,端口不能同时被其他进程占用。而我们建立连接就像把插头插在这个插座上,创建一个 Socket 实例开始监听后,这个电话插座就时刻监听着消息的传入,谁拨通我这个“IP 地址和端口”,我就接通谁。

事实上, 

        Socket本身有“插座”的意思,在Linux环境下,用于表示进程间网络通信的特殊文件类型。本质为内核借助缓冲区形成的伪文件。

        换句话说,Socket就是一种特殊的文件,服务器和客户端各自维护一个“Socket文件”,在建立连接打开后,可以向文件写入内容供对方读取或者读取对方内容,通讯结束时关闭文件。

        在Linux操作系统中,所有对象都被视作文件,这使得文件描述符成为管理这些对象的核心工具。文件描述符是一个用于标识已打开文件的整数索引,通过它,可以进行各种I/O操作。Socket作为一种特殊类型的文件,也遵循这一模式。在创建Socket时,系统会为其分配一个文件描述符,从而允许进程像操作文件一样读写网络数据。这种设计极大地简化了网络编程的复杂性,使得开发者可以专注于应用逻辑而非底层细节。

与管道类似的,Linux系统将其封装成文件的目的是为了统一接口,使得读写套接字和读写文件的操作一致。

        套接字和管道的区别是:

  1. 管道主要应用于本地进程间通信;
  2. 套接字多应用于网络进程间数据的传递;
  3. 套接字的内核实现较为复杂,不宜在学习初期深入学习。

在Linux系统中,我们之前是使用PID来标识进程的,用文件描述符来标识文件的。但是在socket这里,就都换了。 

在TCP/IP协议中,

  1. “IP地址+TCP或UDP端口号”唯一标识网络通讯中的一个进程。
  2. “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点:

  1. 协议
  2. ip
  3. 端口

在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_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属性,不同的是:

  1. sockaddr用其余14个字节来表示sa_data
  2. 而sockaddr_in把14个字节拆分成sin_port, sin_addr和sin_zero分别表示端口、ip地址。sin_zero用来填充字节使sockaddr_in和sockaddr保持一样大小。

我们上面提到过网络通信时,数据发送方、数据接收方需要明确对方的网络地址,而网络的地址的三大要素就是下面这3点:

  1. 协议
  2. ip
  3. 端口

大家看看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 协议的网络程序

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 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的地址类型。

常用的有下面

  1. AF_INET:用来产生IPV4 - socket 的协议,使用TCP或UDP来传输,用IPV4的地址
  2. AF_INET6:和上面的差不多,这个是IPV6的
  3. AF_UNIX:本地协议,用在Unix和Linux系统上,一般都是服务端和客户端在同一台机器上时使用。 (要用一个绝对路径名作为地址)。

我们一般使用IPV4的AF_INET 

  • type:指socket类型,有面向连接的套接字(SOCK_STREAM)和面向消息的套接字(SOCK_DGRAM)。

我们看看它的参数

  1. SOCK_STREAM:这个协议是按照顺序的、可靠的、数据完整的基于字节流的连接。这是一个使用最多的socket类型,是用TCP协议来传输的。
  2. SOCK_DGRAM:这个协议是无连接的,固定长度的连接调用。该协议是不可靠的,使用UDP来进行它的连接。
  3. SOCK_SEQPACKET:这个协议是双线路的、可靠的连接,发送固定长度的数据包进行传输。(注(1))必须把整个包完整的接收才能够进行读取。
  4. 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 结构体里面的。

 参数: 

  1. - sockfd : 通过socket函数得到的文件描述符 
  2. - addr : 需要绑定的socket地址,这个地址封装了ip和端口号的信息 
  3.  - 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_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 函数

#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 函数

有几点要说明了 

  1. 端口号会在网络里互相转发,需要把主机序列转换为网络序列,可以使用 htons 函数
  2. 需要把点分十进制的字符串,转换为无符号短整数,可以使用 inet_addr 函数,这个函数在进行转换的同时,会将主机序列转换为网络序列(因为IP地址需要在网络里面发送)
  3. 绑定IP地址和端口号这个行为并非直接绑定到当前主机中,而是在当前程序中,将创建的 socket 套接字,与目标IP地址与端口号进行绑定,当程序终止后,这个绑定关系也会随之消失
  • 我们这里为什么要使用字符串来表示IP地址?

首先大部分用户习惯使用的IP是点分十进制的字符串,就像下面这个这样

128.11.3.31

那么我们用字符串就能很好的存储这个点分十进制的字符串,然后需要把点分十进制的字符串IP,转换为无符号短整数IP,可以使用 inet_addr 函数,这个函数在进行转换的同时,会将主机序列转换为网络序列

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';
                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() 允许在每次发送操作时指定目的地址。

参数解释:

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 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 服务器头文件 

//。。。
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 协议,所以只需要输入下面这条指令,就可以查看有哪些程序正在运行

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 结构体信息

也就是说

  1. 客户端需要bind吗?需要,只不过不需要用户显示的bind!一般有os自主随机选择,
  2. 一个端口号只能被1个进程bind,对server是如此,对client也是如此。
  3. 其实clinent的port是多少不重要,只要能保证主机上的唯一性就可以。
  4. 系统什么时候给我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 函数

发送消息步骤

  1. 用户输入消息
  2. 传入缓冲区、服务器相关参数,使用 sendto 函数发送消息
  3. 消息发送后,客户端等待服务器回响消息

接收消息步骤:

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

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

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';
        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地址即可

我们运行一下

 

我们发现都没有反应啊

这其实不是代码的问题,这是环境的问题,我的云服务器(我的环境)的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';
                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_; // 端口号
    };
}

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';
        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结构体信息
    };
}

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 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本质就是一个类模板。

我们看些例子

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;
}


  1. 包装时指明返回值类型和各形参类型,然后可调用对象赋值给function包装器即可,包装后function对象就可以像普通函数一样使用了。
  2. 取静态成员函数的地址可以不用取地址运算符 & ,但取非静态成员函数地址使用 & 。
  3. 包装费静态的成员函数需要注意,非静态成员函数的第一个参数是隐藏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';
                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_; // 业务处理函数(回调函数)
    };
}

我们得特别注意下面这几处地方

//...
#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 指令是如何执行的?

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

这样子做太复杂了

其实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啦!!!!! 

  • 23
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 12
    评论
评论 12
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值