【计算机网络】socket网络编程 --- 实现一些简易UDP网络程序

在这里插入图片描述

👦个人主页:Weraphael
✍🏻作者简介:目前正在学习c++和算法
✈️专栏:Linux
🐋 希望大家多多支持,咱一起进步!😁
如果文章有啥瑕疵,希望大佬指点一二
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注😍


前言

在上篇博客中,我们简单认识了socket套接字的相关API等其它内容。这篇博客我将带领大家如何使用socket套接字,来从零实现一个简单的UDP网络程序 — 字符串回响。即接收到的数据内容要原样返回给客户端。里面会掺杂点格外知识,希望大家耐心看完 ~

说明:建议大家先看完上篇博客,再来学习本篇博客的相关知识。 👉点击跳转

一、封装服务端

1.1 创建套接字

我们把服务端封装成一个类,当我们定义出一个服务器对象后需要马上初始化服务器,而初始化服务器需要做的第一件事就是创建套接字

首先在Udpserver.cc内搭出大体的框架

#include <iostream>
#include "UdpServer.hpp"

using namespace std;

int main()
{
    // 创建udp服务器对象
    unique_ptr<UdpServer> svr(new UdpServer());

    // 初始化服务器
    svr->Init();

    // 启动服务器
    svr->Run();

    return 0;
}

创建套接字所需要用到的是socket函数

#include <sys/types.h>
#include <sys/socket.h>

int socket(int domain, int type, int protocol);

说明:

  • domain:指定协议家族。常用的值有:

    • AF_INET:用于IPv4网络。注意:PF_INETAF_INET等价。
    • AF_INET6:用于IPv6网络。
    • AF_UNIX / AF_LOCAL:用于本地进程间通信。(这不是本章的重点)
  • type:指定套接字类型。常用的值有:

    • SOCK_STREAM:面向连接的套接字,通常用于TCP
    • SOCK_DGRAM:无连接的套接字,通常用于UDP。(dgrm有数据报的意思)
    • SOCK_RAW:原始套接字,用于访问更底层的协议。
  • protocol:指定协议。设置成0 表示使用默认协议。但该字段一般直接设置为0就可以了,此时会根据传入的前两个参数自动推导出你最终需要使用的是哪种协议。

  • 返回值:成功时返回一个套接字描述符(本质就是文件描述符)。失败时返回 -1,并设置 errno 以指示错误原因。

socket函数属于什么类型的接口?

在这里插入图片描述

网络协议栈是分层的,按照TCP/IP四层模型来说,自顶向下依次是应用层、传输层、网络层和数据链路层。

而我们现在所写的代码都叫做用户级代码,也就是说我们是在应用层编写代码,因此,我们调用的实际是下三层的接口。其中传输层和网络层是操作系统的一部分,并由内核负责管理,也就意味着我们在应用层调用的接口都叫做系统调用接口

说明:socket套接字通常用于传输层接口。尽管socket也可以在网络层(如原始套接字)进行操作,但大多数常见的应用程序使用的是传输层的接口,如TCPUDP

socket函数的返回值(回顾文件:点击跳转

socket函数是被进程所调用的,当进程运行起来,操作系统就要为该进程创建PCB。当进程打开文件时,操作系统会在内核中创建数据结构来描述这个已打开的文件对象(和PCB类似)。这个数据结构通常被称为file或其他类似的名字,该结构体里包含比如文件的属性信息、操作方法以及文件缓冲区等。其中文件对应的属性在内核当中是由struct inode结构体来维护的,而文件对应的操作方法实际就是一堆的函数指针(比如read*write*)在内核当中就是由struct file_operations结构体来维护的。

而进程可以打开多个文件,那进程PCB结构体对象就要存储哪些文件是由哪一个进程打开的。因此,每个进程PCB对象都要和打开的文件建立关系!所以进程PCB对象其实有一个指针struct files_struct* files,这个指针指向结构体files_struct,而这个结构体包含一个指针数组struct file* fd_array[],这个数组我们可以称之为文件描述符表。数组中的每个元素都是指向当前进程所打开文件的指针(地址)

所以,本质上文件描述符就是指针数组的下标(索引)。所以,只要拿着文件描述符,就可以找到对应的文件。其中数组中的012下标依次对应的就是标准输入、标准输出以及标准错误。


当调用socket函数创建套接字时,因为在Linux中一切皆文件,实际相当于我们打开了一个网卡文件。打开后在内核层面上就形成了一个对应的struct file结构体,同时该结构体被连入到了该进程对应的文件描述符表,并将该结构体的首地址填入到了fd_array数组当中下标为3的位置,此时fd_array数组中下标为3的指针就指向了这个打开的网络文件,最后3号文件描述符作为socket函数的返回值返回给了用户。(后面会向大家验证)

因此,未来我们用户想要对网卡文件进行网络通信(对网卡读写),就必须使用该函数的返回值。

对于一般的普通文件来说,当用户通过文件描述符将数据写到文件缓冲区,然后再把数据刷到磁盘上就完成了数据的写入操作。而对于现在socket函数打开的网络文件来说,当用户将数据写到文件缓冲区后,操作系统会定期将数据刷到网卡里面,而网卡则是负责数据发送的,因此数据最终就发送到了网络当中。

在这里插入图片描述

UdpServer.hpp

  • 因为我们要进行的是网络通信,创建套接字时我们第一个需要填入的参数就是AF_INETAF_INET6也是可以的。
  • 我们需要的服务类型就是SOCK_DGRAM,因为我们现在编写的UDP服务器
  • 第三个参数之间设置为0即可
#pragma once

#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include "log.hpp"

log lg;

enum
{
    SOCKET_ERR = 1
};

class UdpServer
{
public:
    UdpServer()
    	: _socketfd(-1)
    {}

    ~UdpServer()
    {
        if (_socketfd >= 0)
        {
            close(_socketfd);
        }
    }

    // 初始化UDP服务器
    void Init()
    {
		// 创建套接字
        _socketfd = socket(AF_INET, SOCK_DGRAM, 0);
        if (_socketfd < 0) // 套接字创建失败
        {
            lg.logmessage(Fatal, "socket create error, socketfd: %d", _socketfd);
            exit(SOCKET_ERR);
        }
        lg.logmessage(Info, "socket create success, socketfd: %d", _socketfd);
    }

private:
    int _socketfd; // 网络文件描述符
};

说明:

  • 以上代码我引入了往期博客写的一个日志小插件,方便打印出我们想要的信息。
  • 以上析构函数写不写都没什么问题。因为文件的生命周期通常与进程的生命周期相关联。在 Unix/Linux系统中,文件描述符(如用于网络套接字的文件描述符)在进程的生命周期内有效。一旦进程结束,这些文件描述符会自动被释放。

UdpServer.cc服务器源文件。这里我们可以做一个简单的测试,看看套接字是否创建成功。

#include <iostream>
#include "UdpServer.hpp"
#include <memory>

using namespace std;

int main()
{
    // 创建udp服务器对象
    unique_ptr<UdpServer> svr(new UdpServer());

    // 初始化服务器
    svr->Init();
	
	// 运行服务器
	// svr->Run();

    return 0;
}

【程序结果】

在这里插入图片描述

运行程序后可以看到套接字是创建成功的,对应获取到的文件描述符就是3。这也很好理解,因为012默认被标准输入流、标准输出流和标准错误流占用了,此时最小的、未被利用的文件描述符就是3

1.2 绑定套接字

现在套接字已经创建成功了,但作为一款服务器来讲,如果只是把套接字创建好了,那我们也只是在系统层面上打开了一个文件,操作系统将来并不知道是要将数据写入到磁盘还是写到网卡,此时该文件还没有与网络关联起来。

而我们现在编写的是不面向连接的UDP服务器,所以创建完套接字后的第二件事就是绑定套接字即将套接字绑定到一个特定的IP地址和端口port上,让套接字能够在网络中接收数据

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

说明:

  • sockfd:套接字的文件描述符,通常是由 socket 函数返回的。

  • addrsockaddr结构体是一个套接字的通用结构体,实际我们在进行网络通信时,还是要定义sockaddr_insockaddr_un这样的结构体,只不过在传参时需要将该结构体的地址类型进行强转为sockaddr*

    • sockaddr_in结构体:用于跨网络通信(更详细细节下面说)
    • sockaddr_un结构体:是用于本地通信
    • 注意:使用以上结构体需要加上头文件<netinet/in.h>
  • addrlenaddr 结构体的大小,以字节为单位。

  • 返回值:成功返回 0;失败返回 -1,并设置errno以指示错误原因。

在这里插入图片描述

首先对于bind函数,第一个参数和第三个参数没的说。主要是第二个参数,我们可以先对struct sockaddr_in结构体进行一个清空初始化处理。可以先使用 bzero函数,它的作用是一个用于将内存区域的字节设置为零的函数。

#include <strings.h>

void bzero(void *s, size_t len);

当然你也可以使用memset来实现。memset函数的原型定义在<string.h>头文件中,其用法如下:

#include <string.h>
void *memset(void *s, int c, size_t len);
// s是指向要填充的内存区域的指针
// c是要填充的值
// len是要填充的字节数

// -------------------------
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr)); // 将 addr 结构体的所有字节设置为零

初始化之后,我们需要设置struct sockaddr_in成员变量,比如端口号和IP地址之类的。首先可以在vscode中查看sockaddr_in结构体的相关成员:

在这里插入图片描述

  • sin_family:表示协议家族。必须使用与socket创建时相同的协议家族。例如,如果你使用AF_INET 创建了套接字,bind时也应使用AF_INET的地址。
  • sin_port:表示端口号,是一个16位的整数。注意:端口号需要转换为网络字节序。因为只要进行网络通信,端口号一定是双方来回传输的数据,因此为了保证双方能够正常解析数据,需要将其转换为网络字节序。可以使用htons函数
  • sin_addr:其中sin_addr的类型是struct in_addr,实际该结构体当中就只有一个成员,该成员就是一个32位的整数,就是IP地址。

但是我们用户最直观的就是输入类似于192.168.1.1这种字符串形式的IP地址,可是这里sin_addr的类型是32位无符号整数,那么我们要把字符串转化为32位无符号整数

并且在网络通信中,IP地址和端口号一样,也是双方来回传输的数据,也要保证双方能够正常解析数据

因此,这里可以使用 函数inet_addr 将字符串形式的IP地址转换为32位的无符号整数,并且返回的整数已经是网络字节序。

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

in_addr_t inet_addr(const char *cp);
  • 剩下的那一个字段不用管,这也就是为什么一开始我们要将结构体struct sockaddr_in的所有成员清空的原因。

注意:以上设置端口号和IP的行为都是在用户层面上的,最后我们还是需要使用bind函数,将指定的IP地址和端口号绑定到套接字上,最终由操作系统内核负责管理。这意味着内核会确保指定的地址和端口号与套接字关联,并在网络通信中处理数据的接收和发送。

补充Init接口

#pragma once

#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include "log.hpp"
#include <netinet/in.h>
#include <strings.h>
#include <arpa/inet.h>
#include <cstring>

log lg;

#define DEFAULT_IP "0.0.0.0" // 默认IP地址
#define DEFAULT_PORT 8080    // 默认端口号

enum
{
    SOCKET_ERR = 1,
    BIND_ERR
};

class UdpServer
{
public:
    UdpServer(const std::string &ip = DEFAULT_IP, const uint16_t &port = DEFAULT_PORT)
        : _socketfd(-1), _ip(ip), _port(port)
    {
    }

    ~UdpServer()
    {
        if (_socketfd >= 0)
        {
            close(_socketfd);
        }
    }

    // 初始化UDP服务器
    void Init()
    {
		// 创建套接字
        _socketfd = socket(AF_INET, SOCK_DGRAM, 0);
        if (_socketfd < 0) // 套接字创建失败
        {
            lg.logmessage(Fatal, "socket create error, socketfd: %d", _socketfd);
            exit(SOCKET_ERR);
        }
        lg.logmessage(Info, "socket create success, socketfd: %d", _socketfd);

		// 绑定套接字
        struct sockaddr_in local;
        
        // 初始化struct sockaddr_in对象(清空)
        bzero(&local, sizeof(local));
        // 填充struct sockaddr_in对象字段
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);                  // 细节
        local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 细节
        
        // 绑定
        int n = bind(_socketfd, (const struct sockaddr *)&local, sizeof(local));
        if (n == -1) // 绑定失败
        {
            lg.logmessage(Fatal, "bind error, errno: %d, describe: %s", errno, strerror(errno));
            exit(BIND_ERR);
        }
        lg.logmessage(Info, "bind success, errno: %d, describe: %s", errno, strerror(errno));
    }

private:
    int _socketfd;   // 网络文件描述符
    uint16_t _port;  // UDP服务器的端口号
    std::string _ip; // UDP服务器的IP地址
};

【运行结果】

在这里插入图片描述

1.3 启动服务端操作

UDP服务器的初始化就只需要创建套接字和绑定就行了,当服务器初始化完毕后我们就可以启动服务器了。

服务器实际上就是在周而复始的为我们提供某种服务,比方说你无论什么时候你都可以刷抖音。服务器之所以称为服务器,是因为服务器运行起来后就不会退出。因此,服务器实际执行的是一个死循环代码

注意:UDP服务器是不面向连接的,因此只要UDP服务器启动后,就可以直接接收客户端发来的数据。注意:服务器是被动的,只有数据发来才会做事。

recvfrom函数

recvfrom是一个用于接收数据的网络编程函数,通常用于接收来自网络套接字的数据。

#include <sys/types.h>
#include <sys/socket.h>

ssize_t recvfrom(int sockfd, void *buf, size_t len 
				, int flags, struct sockaddr *src_addr
				, socklen_t *addrlen);

说明:

  • sockfd: 套接字的文件描述符,通常由 socket 函数创建。
  • buf: 指向接收缓冲区的指针。数据将被读入这个缓冲区。
  • len: 缓冲区的大小,以字节为单位。
  • flags: 读取的方式。一般设置为0,表示阻塞读取。

收到消息后,服务器需要知道是谁发来的,为了后序是否做回响做准备。

  • src_addr: 发送方相关的属性信息,包括协议家族、IP地址、端口号等。
  • addrlen: 调用时传入期望读取的src_addr结构体的长度,返回时代表实际读取到的src_addr结构体的长度,这是一个输入输出型参数。
  • 返回值:读取成功返回实际读取到的字节数,读取失败返回-1,同时错误码会被设置。
#pragma once

#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include "log.hpp"
#include <netinet/in.h>
#include <strings.h>
#include <arpa/inet.h>
#include <cstring>

log lg;

#define DEFAULT_IP "0.0.0.0" // 默认IP地址
#define DEFAULT_PORT 8080    // 默认端口号

enum
{
    SOCKET_ERR = 1,
    BIND_ERR
};

class UdpServer
{
public:
    UdpServer(const std::string &ip = DEFAULT_IP, const uint16_t &port = DEFAULT_PORT)
        : _socketfd(-1), _ip(ip), _port(port), _isRun(false)
    {
    }

    ~UdpServer()
    {
        if (_socketfd >= 0)
        {
            close(_socketfd);
        }
    }

    // 初始化UDP服务器
    void Init()
    {
        // ...
    }

    // 运行服务端服务器
    void Run()
    {
        _isRun = true;
        char buffer[1024];
        while (true)
        {
            struct sockaddr_in client;
            socklen_t len = sizeof(client);
            // -1是为了去掉'\n'
            ssize_t n = recvfrom(_socketfd, buffer, sizeof(buffer) - 1
            					, 0, (struct sockaddr *)&client, &len);

            if (n < 0) // 接收失败
            {
                // UDP是不可靠的,接收失败不是什么很大的问题,日志级别给个警告就好
                lg.logmessage(Warning, "recvfrom error, errno: %d, describe: %s", errno, strerror(errno));
                continue; // 继续运行即可
            }
            // 来到这,发送方的数据都在buffer里了
            buffer[n] = 0; // 将buffer当做字符串

            // 加工:服务端个性化回显
            std::string message = buffer;
            std::string echo_string = "server echo# " + message;
            std::cout << echo_string << std::endl;

            // 服务端发送回给客户端(未完成) ???
        }
    }

private:
    int _socketfd;   // 网络文件描述符
    uint16_t _port;  // UDP服务器的端口号
    std::string _ip; // UDP服务器的IP地址
    bool _isRun;     // 服务器是否在运行
};

1.4 回响操作

客户端发送数据后,希望确认服务器是否成功接收了这些数据。服务器的回响消息(回显)可以作为接收确认,告诉客户端数据已经到达。

sendto函数

#include <sys/types.h>
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags
			, const struct sockaddr *dest_addr, socklen_t addrlen);

说明:

  • sockfd: 套接字的文件描述符,通常由 socket 函数创建。
  • buf: 指向要发送数据的缓冲区的指针。
  • len: 要发送的数据长度,以字节为单位。
  • flags: 控制发送操作的标志。和recvfrom函数一样,填0即可。
  • dest_addr: 指向 sockaddr 结构体的指针,指定数据的目标地址(在无连接协议中,目标地址是必需的)。
  • addrlen: 目标地址结构的大小。通常设置为 dest_addr 对象的大小。
void Run()
{
   	// ....

    // 服务端发送回给客户端(未完成) 
    sendto(_socketfd, echo_string.c_str(), echo_string.size(), 0, (const sockaddr *)&client, len);  
}

二、一个关于ip的问题 — INADDR_ANY

客户端的代码我们已经写完了,那如何知道服务器已经运行情况呢?

我们可以通过以下命令

netstat -naup
  • -n: 显示数字格式的地址和端口号,而不是解析为主机名或服务名。
  • -a: 显示所有连接和监听的套接字。
  • -u: 显示UDP连接。
  • -p: 显示哪个进程在使用每个套接字

在这里插入图片描述

注意:以上能启动纯属巧合!!!

以上服务端使用的IP地址默认是0.0.0.0,端口号也是默认的8080。那如果我的端口号不变,将IP地址改为我的云服务器的IP地址175.178.46.38

#include <iostream>
#include "UdpServer.hpp"
#include <memory>

using namespace std;

int main()
{
    // 创建udp服务器对象
    unique_ptr<UdpServer> svr(new UdpServer("175.178.46.38", 8080));

    // 初始化服务器
    svr->Init();

    // 启动服务器
    svr->Run();

    return 0;
}

【运行结果】

在这里插入图片描述

我们发现,绑定竟然失败?错误的原因是无法分配请求的地址?地址错误?为什么?

现在我来解释一下,如果你是虚拟机,运行以上代码就不会发生错误;如果你是云服务器,禁止直接绑定公网ip,那为什么不允许我们绑定呢?

如果你在服务器上绑定特定的公网IP地址,那么只有在这个公网IP地址上访问的请求才会被接受,也就是说,只有通过这个特定 IP 地址发出的请求会被接受,其他 IP 地址的请求会被忽略。但服务器一般是可以接受任意的IP地址的。比方说,任何一个人都可以访问抖音的服务器刷抖音。

因此,如果需要让任意IP地址访问,直接将IP绑定为0,即0.0.0.0。系统也提供宏INADDR_ANY,它对应的值就是0

绑定INADDR_ANY的好处

一台服务器底层可能会有多个网卡设备,此时这台服务器就可能会有多个IP地址,但一台服务器上比如端口号为8080的应用服务只有一个。

因此,这台服务器在接收数据时,这里的多张网卡在底层实际都收到了数据。如果这些数据也都想访问端口号为8081的服务。而此时如果服务端在绑定的时候是指明绑定的某一个IP地址,那么此时服务端在接收数据的时候就只能从绑定IP对应的网卡接收数据。

在这里插入图片描述

而如果服务端绑定的是INADDR_ANY,那么只要是发送给端口号为8080的服务的数据,系统都会可以将数据自底向上交给该服务端进行响应。

在这里插入图片描述

因此,服务端绑定INADDR_ANY这种方案也是强烈推荐的方案,所有的服务器具体在操作的时候用的也就是这种方案

只需修改Init函数即可

// 初始化UDP服务器
void Init()
{
    // 创建套接字
    // 略 ...
    
    // 绑定套接字
    struct sockaddr_in local;
    // 初始化struct sockaddr_in对象(清空)
    bzero(&local, sizeof(local));
    // 填充struct sockaddr_in对象字段
    local.sin_family = AF_INET;
    local.sin_port = htons(_port); // 细节
    // local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 细节
    local.sin_addr.s_addr = INADDR_ANY; // 动态绑定INADDR_ANY
    // 绑定
    int n = bind(_socketfd, (const struct sockaddr *)&local, sizeof(local));
    if (n == -1) // 绑定失败
    {
        lg.logmessage(Fatal, "bind error, errno: %d, describe: %s", errno, strerror(errno));
        exit(BIND_ERR);
    }
    lg.logmessage(Info, "bind success, errno: %d, describe: %s", errno, strerror(errno));
}

【运行结果】

在这里插入图片描述

此时当我们再重新编译运行服务器时就不会绑定失败了,并且此时当我们再用netstat命令查看时会发现,该服务器的本地IP地址变成了0.0.0.0,这就意味着该UDP服务器可以在本地读取任何一张网卡里面的数据。

但是,服务器的IP地址也可以指定绑定为127.0.0.1,这是本地回环地址,也称为localhost,这个地址用于指代计算机本身。它常用于网络应用程序的测试,即不连接到外部网络的情况下,测试程序的网络功能。即只能进行本地网络通信。这个会在【本地测试】中为大家演示

三、一个关于端口号的问题

如果我将原本的端口号8080改为80又会是什么结果呢?

#include <iostream>
#include "UdpServer.hpp"
#include <memory>

using namespace std;

int main()
{
    // 创建udp服务器对象
    unique_ptr<UdpServer> svr(new UdpServer("175.178.46.38", 80));

    // 初始化服务器
    svr->Init();

    // 启动服务器
    svr->Run();

    return 0;
}

在这里插入图片描述

我们发现还是绑定的问题,而且是权限问题?什么?绑定也有权限问题?

那我们提升权限来试试看

在这里插入图片描述

我们发现提升权限后就绑定成功了。

这里我想说的是,端口号范围[0, 1023]被称为知名端口,这些端口号是由互联网分配号码管理局IANA注册并分配给特定的应用层协议和服务的。常见的知名端口号包括:

  • HTTP:80
  • HTTPS:443
  • FTP:21
  • SSH:22
  • SMTP:25
  • ...

这些端口通常被操作系统和网络设备保留用于特定的服务或应用。系统和网络管理员通常避免在这些端口上运行其他服务,以避免与已知服务发生冲突。因此,我们绑定端口号建议选择[1024,正无穷)

四、引入命令行参数

未来服务端启动我们想以这样的形式./xxx 端口号

#include <iostream>
#include "UdpServer.hpp"
#include <memory>
#include <string>
using namespace std;

void Usage(string proc)
{
    cout << "\n\tUsage: " << proc << " 端口号1024+" << endl
         << endl;
}

int main(int argc, char *argv[])
{
    // 如果命令行参数的个数不是2,那么就要提示用户
    if (argc != 2)
    {
        Usage(argv[0]);
        exit(1);
    }

    uint16_t port = stoi(argv[1]);

    // 创建udp服务器对象
    unique_ptr<UdpServer> svr(new UdpServer("1.1.1.1", port));

    // 初始化服务器
    svr->Init();

    // 启动服务器
    svr->Run();

    return 0;
}

在这里插入图片描述

五、编写客户端

关于客户端的绑定bind问题

对于服务器来说,服务器就是为了给别人提供服务的,因此服务器必须要让别人知道自己的IP地址和端口号,这些绑定操作确保服务端能够在指定的网络接口和端口上监听和接收数据。具体来说:

  • IP 地址:动态绑定INADDR_ANY,即设置为0.0.0.0
  • 端口号:服务端必须绑定到一个特定的端口号,以便客户端能够通过该端口号连接到服务端。

客户端同样需要绑定IP地址和端口号,因为服务端也要能够正确地将数据回传给客户端。只不过客户端通常不需要我们显式地绑定IP地址和端口号。大多数情况下,客户端的操作系统会自动处理这一过程

  • IP地址:客户端一般不需要指定IP地址。操作系统会自动选择合适的本地网络接口IP地址来发起连接请求。

  • 端口号:客户端也不需要显式地绑定到一个特定的端口号。操作系统会为客户端应用程序随机分配一个可用的端口号,以便建立与服务端的连接。

那么操作系统为什么要自己处理呢?

如果客户端绑定了某个端口号,那么以后这个端口号就只能给这一个客户端(进程)使用,即使这个客户端没有启动,这个端口号也无法分配给别人,并且如果这个端口号被别人使用了,那么这个客户端就无法启动了。所以客户端的端口只要保证唯一性就行了,因此客户端端口可以动态的进行设置,并且客户端的端口号不需要我们来设置,当我们调用类似于sendto这样的接口时,操作系统会自动给当前客户端获取一个唯一的端口号。

客户端这里就不为大家进行封装了,直接一个Udpclient.cc文件,即能跑起来就行

注意:作为一个客户端,它必须知道它要访问的服务端的IP地址和端口号,我们这里使用命令行参数来实现就行了

#include <cstdlib>
#include <unistd.h>
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <strings.h>
using namespace std;

void Usage(string proc)
{
    cout << "\n\tUsage: " << proc << " serverip serverport" << endl
         << endl;
}

int main(int argc, char *argv[])
{
    // 如果命令行参数的个数不是3,那么就要提示用户
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }

    string server_ip = argv[1];           // 服务端IP地址
    uint16_t server_port = stoi(argv[2]); // 服务端端口号
	
	// 服务端IP地址和端口
    struct sockaddr_in server;
    bzero(&server, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(server_port);
    server.sin_addr.s_addr = inet_addr(server_ip.c_str());
    socklen_t len = sizeof(server);

    // 创建套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0)
    {
        cout << "client socket create error" << endl;
        return 1;
    }

    string message;
    char buffer[1024];
    while (true)
    {
        cout << "Please Enter@ ";
        getline(cin, message);
		
		// 发送消息给服务端
        sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)&server, len);

        struct sockaddr_in temp;
        socklen_t len = sizeof(temp);
       	// 接收服务端的消息
        ssize_t s = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&temp, &len);
        
        if (s > 0)
    	{
        	buffer[s] = 0;
        	cout << buffer << endl;
    	}
    }

    close(sockfd);
    return 0;
}

六、本地测试

在这里插入图片描述

注意:

  • 如果以上代码在你本地上试的时候没有回显,那么可能你就要打开你的云服务器的特定端口(开放端口行为),因为我们云服务器为了保证自己的安全,很多的端口默认都是禁掉的,不准任何一个人访问,包括你。
  • 我将服务器的IP地址绑定成了127.0.0.1,这是本地环回地址,也称为localhost。它用于在计算机上测试网络程序,确保数据在同一台机器上发送和接收,不经过网络接口。只能进行本地通信。

七、代码优化

7.1 解耦

UdpServer.hpp

在这里插入图片描述

在以上代码中,服务端接收数据和处理数据之间的耦合度高。我们可以将其做一个适当的解耦。由程序员传入指定的回调函数来处理数据。

UdpServer.hpp

// 相当于函数指针。参数是string,返回值也是string
using func_t = std::function<std::string(const std::string &)>;

void Run(func_t func)
{
    _isRun = true;
     char buffer[1024];
     while (_isRun)
    {
       struct sockaddr_in client;
       socklen_t len = sizeof(client);

       ssize_t n = recvfrom(_socketfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&client, &len);

       if (n < 0) // 发送失败
       {
           // UDP是不可靠的,发送失败不是什么很大的问题,日志级别给个警告就好
           lg.logmessage(Warning, "recvfrom error, errno: %d, describe: %s", errno, strerror(errno));
           continue;
       }

       // 来到这,发送方的数据都在buffer里了
       buffer[n] = 0;

       // 加工:服务端个性化回显
       std::string message = buffer;

       // std::string echo_string = "server echo# " + message;
       // std::cout << echo_string << std::endl;

       // 意思就是我接收到了数据,但我不在该函数内部处理数据,而是在外部
       std::string echo_string = func(message);
       std::cout << echo_string << std::endl;

       // 发送回给对方
       sendto(_socketfd, echo_string.c_str(), echo_string.size(), 0, (const sockaddr *)&client, len);
   }
}

Udpserver.cc

#include <iostream>
#include "UdpServer.hpp"
#include <memory>
#include <string>
using namespace std;

void Usage(string proc)
{
    cout << "\n\tUsage: " << proc << " 端口号1024+" << endl
         << endl;
}

string Handler(const string &str)
{
    string res = "Server get a message: ";
    res += str;
    return res;
}

int main(int argc, char *argv[])
{
    // 如果命令行参数的个数不是2,那么就要提示用户
    if (argc != 2)
    {
        Usage(argv[0]);
        exit(1);
    }

    uint16_t port = stoi(argv[1]);

    // 创建udp服务器对象
    unique_ptr<UdpServer> svr(new UdpServer(port, "1.1.1.1"));

    // 初始化服务器
    svr->Init();

    // 启动服务器
    svr->Run(Handler);

    return 0;
}

【运行结果】

在这里插入图片描述

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

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

在这里插入图片描述

7.2 实现大写转小写

Udpserver.cc

// 大写转小写
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[])
{
    // 如果命令行参数的个数不是2,那么就要提示用户
    if (argc != 2)
    {
        Usage(argv[0]);
        exit(1);
    }

    uint16_t port = stoi(argv[1]);

    // 创建udp服务器对象
    unique_ptr<UdpServer> svr(new UdpServer(port, "127.0.0.1"));

    // 初始化服务器
    svr->Init();

    // 启动服务器
    svr->Run(UpToLow);

    return 0;
}

【运行结果】

在这里插入图片描述

1.3 实现远程bash

bash指令是如何执行的?

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

可以自己模拟一个bash。参考博客:点击跳转

这里推荐使用系统提供的 popen函数。该函数是一个用于创建管道和处理子进程的函数。这个函数会做以下这些事:

  • 创建管道

  • 创建子进程

  • 处理子进程

  • 将执行结果以FILE*的形式返回

#include <stdio.h>
FILE *popen(const char *command, const char *mode);

说明:

  • command:是要执行的命令字符串。

  • mode:指定了打开管道的方式,可以是"r"读取管道输出,或者"w"写入管道输入。

  • 返回值:如果成功,它指向一个打开的管道,允许你读取或写入数据。

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

Udpserver.cc

string Bash(const string &str)
{
    FILE *fp = popen(str.c_str(), "r");
    if (fp == nullptr)
    {
        perror("popen");
        return "error";
    }
    // 读取结果
    char buffer[4096];
    string res;
    while (true)
    {
        char *ans = fgets(buffer, sizeof(buffer), fp);
        if (ans == nullptr)
        {
            break;
        }
        res += buffer;
    }
    pclose(fp);
    return res;
}

int main(int argc, char *argv[])
{
    // 如果命令行参数的个数不是2,那么就要提示用户
    if (argc != 2)
    {
        Usage(argv[0]);
        exit(1);
    }

    uint16_t port = stoi(argv[1]);

    // 创建udp服务器对象
    unique_ptr<UdpServer> svr(new UdpServer(port, "127.0.0.1"));

    // 初始化服务器
    svr->Init();

    // 启动服务器
    svr->Run(Bash);

    return 0;
}

【运行结果】

在这里插入图片描述

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

答案当然是直接拦截,不让别人执行敏感操作。所以我们还需要考虑安全检查!

敏感操作包含这些:kill发送信号终止进程、mv移动文件、rm删除文件、while :; do死循环、shutdown关机等等。因此,在执行用户传入的指令前,先对指令中的字符串进行扫描,如果发现敏感操作,就直接返回,不再执行后续操作。

Udpserver.cc

bool SafeCheck(const string &str)
{
    bool safe = true;

    vector<string> key_word = {
        "rm",
        "mv",
        "cp",
        "kill",
        "sudo",
        "unlink",
        "yum",
        "uninstall",
        "top"};

    for (auto &e : key_word)
    {
        auto pos = str.find(e);
        if (pos != string::npos) // 找到了
        {
            safe = false;
        }
    }
    return safe;
}

string Bash(const string &str)
{
    // 命令安全检查
    if (SafeCheck(str) == false)
    {
        return "Banning";
    }

    FILE *fp = popen(str.c_str(), "r");
    if (fp == nullptr)
    {
        perror("popen");
        return "error";
    }
    // 读取结果
    char buffer[4096];
    string res;
    while (true)
    {
        char *ans = fgets(buffer, sizeof(buffer), fp);
        if (ans == nullptr)
        {
            break;
        }
        res += buffer;
    }
    pclose(fp);
    return res;
}

int main(int argc, char *argv[])
{
    // 如果命令行参数的个数不是2,那么就要提示用户
    if (argc != 2)
    {
        Usage(argv[0]);
        exit(1);
    }

    uint16_t port = stoi(argv[1]);

    // 创建udp服务器对象
    unique_ptr<UdpServer> svr(new UdpServer(port, "127.0.0.1"));

    // 初始化服务器
    svr->Init();

    // 启动服务器
    svr->Run(Bash);

    return 0;
}

【运行结果】

在这里插入图片描述

可以看到,输入安全指令时,可以正常获取结果,如果输入的是非安全指令,会直接拒绝执行。注意:诸如cd这种指令称为内建命令,只有子进程还能运行成功;还有一些别名的命令,比如ll等。都是需要特殊处理的。

平时使用的Xshell软件本质上就是这样一款网络程序,用户通过SSH等协议连接到远程服务器。它的工作原理是将用户输入的指令发送到远程服务器,然后将服务器的响应结果返回并显示给用户。

八、实现Linux操作系统和Windows操作系统之间的交互

虽然不同操作系统的网络协议栈可能有些许实现差异,但它们遵循相同的网络协议标准(如 TCP/IP网络协议栈),以确保网络通信的兼容性。因此,LinuxWindows系统之间的网络套接字通常可以互操作,只是在应用层的API和数据类型上可能会有一些差异。例如,Windows 使用SOCKET类型,而 Linux使用int类型来表示套接字。这些差异主要影响到如何编写和处理代码,但协议的核心通信机制是统一的。

接下来我们以Windows充当客户端,Linux充当服务器,来实现不同操作系统之间的网络通信。至于Windows下的接口风格,大家可以自行百度。

VS2019

#define _CRT_SECURE_NO_WARNINGS 1

#include <WinSock2.h>
#include <winsock.h>
#include <iostream>
#include<string>
#include <cstring>
using namespace std;

/*
是一个 Microsoft Visual C++ 特有的指令,用于在编译时自动链接到 ws2_32.lib 库。
这个库提供了 Winsock 2 的支持,用于网络编程。通过使用这个指令,你不需要在项目设置
中手动添加库,编译器会自动处理链接过程
*/
#pragma comment(lib, "ws2_32.lib")

/*

#pragma warning(disable:4996) 是一个 Microsoft Visual C++ 特有的指令,用于禁用编译器的特定警告,
警告代码 4996 通常涉及使用不安全的函数,比如 strcpy、sprintf和inet_addr等。
这个指令可以帮助你避免这些警告,但需要注意,禁用警告可能会掩盖潜在的安全问题。
*/
#pragma warning(disable:4996) 


string server_ip = "175.178.46.38"; 
uint16_t server_port = 8080;
int main()
{
	cout << "Hello client" << endl;

    // 在Windows中,进行任何网络操作(如创建套接字、发送和接收数据)前的必要准备
    // 是要初始化Winsock 库

    // 初始化 Winsock 库。
	WSADATA wsd;
    // 指定需要使用的 Winsock 版本(2.2),并将 wsd 传递给它以获取初始化信息。如果调用成功,Winsock 将被初始化并准备好进行网络操作
	WSAStartup(MAKEWORD(2, 2), &wsd);

	// 直接复制 客户端代码
    struct sockaddr_in server;
    memset(&server,0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(server_port);
    server.sin_addr.s_addr = inet_addr(server_ip.c_str());
    

    // 创建套接字
    SOCKET sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd == SOCKET_ERROR)
    {
        cout << "client socket create error" << endl;
        return 1;
    }

    string message;
    char buffer[1024];
    while (true)
    {
        cout << "Please Enter@ ";
        getline(cin, message);

        sendto(sockfd, message.c_str(), (int)message.size(), 0, (struct sockaddr*)&server, (int)sizeof(server));

        struct sockaddr_in temp;
        int len = sizeof(temp);
        int s = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&temp, &len);

        if (s > 0)
        {
            buffer[s] = 0;
            cout << buffer << endl;
        }
    }
    // 关闭套接字文件
    closesocket(sockfd);
    
    // 在完成所有网络操作后被调用,以释放 WSAStartup 初始化时所分配的资源和清理 Winsock 库的状态。
    WSACleanup();

	return 0;
}

【运行结果】

在这里插入图片描述

九、网络测试

我们可以将生成的客户端的可执行程序发送给你的其他朋友,进行网络级别的测试。注:在你朋友执行可执行程序之前,你需要先将服务端运行起来。还要保证你的朋友使用的端口号是你服务器上的端口号。

操作略…

十、相关代码

Gitee链接: 👉点击跳转

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值