【网络编程】二、UDP网络套接字编程详解

发布于:2025-05-09 ⋅ 阅读:(19) ⋅ 点赞:(0)


在这里插入图片描述

前言

UDP 相当于 TCP 来说细节少了很多,所以我们先从简单的 UDP 套接字编程下手,因为其涉及到的细节比较少,也很直接,就是面向报文,服务端拿到信息就直接丢给客户端,丢了也不管,无需我们关系太多细节,等我们把 UDP 套接字编程稍微掌握了,其实 TCP 的那套接口也是类似的,但是我们学 TCP 的时候,需要去了解它的传输细节。

​ 下面我们就从 UDP 套接字编程开始,分为服务端和客户端,我们先讲服务端,客户端也就顺其自然了解了!

Ⅰ. UDP服务端

一、服务器创建流程

  1. 创建套接字(socket
  2. 绑定端口号和 IP 地址(一般来说端口号是固定的,而 IP 地址就作为服务器来说,我们设置为 0.0.0.0 或者 INADDR_ANY,表示任意 IP 都能访问)
  3. 接收(recvfrom)、发送(sendto)数据,包括对数据的业务处理

二、创建套接字 – socket

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>

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

参数说明:

  • domain:创建套接字的域或者叫做协议家族,也就是创建套接字的类型。该参数就相当于 struct sockaddr 结构的前 16 位。

    • 如果是本地通信就设置为**AF_UNIX**,如果是网络通信就设置为 AF_INETIPv4)或 AF_INET6IPv6)。
  • type:创建套接字时所需的服务类型。

    • 其中最常见的服务类型是 SOCK_STREAMSOCK_DGRAM,如果是基于 UDP 的网络通信,我们采用的就是**SOCK_DGRAM**,叫做用户数据报服务,如果是基于 TCP 的网络通信,我们采用的就是 SOCK_STREAM,叫做流式套接字,提供的是流式服务。
  • protocol:创建套接字的协议类别。

    • 你可以指明为 TCPUDP,但该字段 一般直接设置为 0 就可以了,设置为 0 表示的就是默认,此时会根据传入的前两个参数自动推导出你最终需要使用的是哪种协议。

返回值说明:

  • 套接字创建 成功返回一个文件描述符,创建失败返回 -1,同时错误码会被设置。

socket 属于什么类型的接口❓❓❓

​ 网络协议栈是分层的,按照 TCP/IP 四层模型来说,自顶向下依次是应用层、传输层、网络层和数据链路层。而我们现在所写的代码都叫做 用户级代码,也就是说我们是在应用层编写代码,因此我们调用的实际是下三层的接口,而传输层和网络层都是在操作系统内完成的,也就意味着我们在应用层调用的接口都叫做 系统调用接口

socket 是被谁调用的❓❓❓

socket 这个函数是被程序调用的,但并不是被程序在编码上直接调用的,而是程序编码形成的可执行程序运行起来变成进程,当这个进程被 CPU 调度执行到 socket 函数时,然后才会执行创建套接字的代码,也就是说 socket 函数是被进程所调用的

socket 底层做了什么❓❓❓和其函数返回值有没有什么关系❓❓❓

socket 函数是被进程所调用的,而每一个进程在系统层面上都有一个进程地址空间 PCBtask_struct文件描述符表(files_struct 以及对应打开的各种文件。而文件描述符表里面包含了一个数组 fd_array,其中数组中的 0、1、2 下标依次对应的就是 标准输入标准输出 以及 标准错误

在这里插入图片描述

当我们调用 socket 函数创建套接字时,实际相当于我们打开了一个“网络文件”,打开后在内核层面上就形成了一个对应的 struct file 结构体,同时该结构体被连入到了该进程对应的文件双链表,并将该结构体的首地址填入到了 fd_array 数组当中下标为 3 的位置(除非此时先打开了其它文件描述符),此时 fd_array 数组中下标为 3 的指针就指向了这个打开的“网络文件”,最后该文件描述符作为 socket 函数的返回值返回给了用户
在这里插入图片描述

​ 其中每一个 struct file 结构体中包含的就是对应打开文件各种信息,比如文件的属性信息、操作方法以及文件缓冲区等。其中文件对应的属性在内核当中是由 struct inode 结构体来维护的,而文件对应的操作方法实际就是一堆的函数指针(比如 read*write*),它们在内核当中就是由 struct file_operations 结构体来维护的。而文件缓冲区对于打开的普通文件来说对应的一般是磁盘,但对于现在打开的 “网络文件” 来说,这里的文件缓冲区对应的就是网卡

在这里插入图片描述

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

三、绑定对应端口号、IP地址到套接字 – bind

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

​ 所以我们要绑定对应的端口号、IP 地址到套接字文件中,此时就可以改变网络文件当中文件操作函数的指向,将对应的操作函数改为对应网卡的操作方法,此时读数据和写数据对应的操作对象就是网卡了,所以绑定实际上就是将文件和网络关联起来。而绑定使用的函数就是 bind() 函数!

​ 该函数的函数原型如下:

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>

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

参数说明:

  • sockfd:套接字描述符,即要绑定的套接字。
  • addr:一个指向 sockaddr 结构体的指针,该结构体包括协议家族、IP地址、端口号等。
    • sin_family:表示协议家族。
    • sin_port:表示端口号,是一个 16 位的无符号整数。
    • sin_addr:表示 IP 地址,是一个 32 位的无符号整数。
      • 其中 sin_addr 的类型是 struct in_addr,实际该结构体当中就只有一个成员,该成员就是一个 32 位的无符号整数,IP 地址实际就是存储在这个整数当中的。
  • addrlen:传入的 sockaddr 结构体的大小。

返回值说明:

  • 绑定成功返回 0,绑定失败返回 -1,同时错误码会被设置。

对于上面的参数 addr 来说,一般需要借助到我们之前讲的几个函数来转换格式:

  • 一开始最好将该结构体初始化一下,也就是清空数据。
  • sin_port 需要转化为网络字节格式也就是大端格式,又因为它是 16 位的,那么就得使用 htons() 函数;
  • sin_addr 中的 s_addr 其实一般我们是给一个点分十进制字符串,然后通过 inet_addr() 函数将其转化为网络字节序的 32 位无符号整型,但是对于服务器来说没必要这么麻烦,因为 IP 地址直接设为 0.0.0.0 即可,表示任意 IP 都能访问,所以直接用头文件中给的宏 INADDR_ANY

​ 总结起来就是这样子:

struct sockaddr_in local;
bzero(&local, sizeof local);    // 先将一段local的内存清零,即将其中的每个字节都设置为0

local.sin_family = AF_INET;
local.sin_port = htons(_port);  // 因为端口号是多个字节组成,所以要保证先转化为大段序列
local.sin_addr.s_addr = INADDR_ANY;

在这里插入图片描述

​ 关于 struct sockaddr_in 结构体,我们之前有谈过,我们可以用 grep 命令在 /usr/include 目录下查找该结构,此时就可以找到定义该结构的文件。在该文件中就可以找到 struct sockaddr_in 结构的定义,需要注意的是,struct sockaddr_in 属于系统级的概念,不同的平台接口设计可能会有点差别

/* Structure describing an Internet socket address.  */
struct sockaddr_in
{
 __SOCKADDR_COMMON (sin_);
 in_port_t sin_port;				/* Port number.  */
 struct in_addr sin_addr;		/* Internet address.  */

 /* Pad to size of `struct sockaddr'.  */
 unsigned char sin_zero[sizeof (struct sockaddr) -
			   __SOCKADDR_COMMON_SIZE -
			   sizeof (in_port_t) -
			   sizeof (struct in_addr)];
};

/* Internet address.  */
typedef uint32_t in_addr_t;
struct in_addr
{
 in_addr_t s_addr;
};

四、数据的发送和接收 – sendto && recvfrom

UDP 套接字是无连接协议,必须使用 recvfrom 函数接收数据,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:表示要发送数据的套接字文件描述符。
    • buf:指向要发送的数据缓冲区。
    • len:表示要发送的数据长度。
    • flags:表示发送数据的选项,一般我们 设为 0 就行了!常用的有 MSG_DONTWAIT 表示非阻塞发送。
    • dest_addr:(输入参数)指向目标地址的 sockaddr 结构体指针,包括目标 IP 地址和端口号等信息。
    • addrlen:(输入参数)目标地址结构体的大小。
  • 返回值:
    • 如果发送成功,返回发送的字节数
    • 如果发送失败,返回 -1,并设置 errno 错误码。

​ 需要注意的是,sendto() 函数是阻塞型函数,如果要进行非阻塞型发送,可以设置 MSG_DONTWAIT 标志或使用 select() 函数等。同时,sendto() 函数在发送数据时不保证数据一定会被对方接收,如果需要保证数据可靠性,应该使用 TCP 协议。


#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);
  • 作用:从已连接的 socket 中接收数据,并将数据存储到指定的缓冲区中。

  • 参数:

    • sockfd:已经建立好连接的 socket

    • buf:指向接收数据存放的缓冲区。

    • len:缓冲区长度。

    • flags:读取方式,一般设为 0,阻塞式读取。

    • src_addr:(输出型参数)返回发送方的地址信息。

    • addrlen:(输入输出型参数)地址信息的长度。

  • 返回值:

    • 成功接收到数据时,它会 返回接收到的字节数
    • 失败返回值:发生错误,则返回 -1,并设置 errno 变量以指示错误类型。

五、搭建服务器框架

​ 搭建服务器的细节都在注释中!

① 服务器类实现:udpserver.hpp

#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include "onlineUser.hpp"
using namespace std;

namespace Server
{
    static const string defaultIP = "0.0.0.0"; // 默认设为0,表示接收任意IP
    static const int MAXSIZE = 1024; 		   // 接收到的数据最大值

    enum { USAGE_ERR = 1, BIND_ERR, SOCKET_ERR, CLOSE_ERR, OPEN_ERR, NOTFOUND_ERR, SEND_ERR }; // 错误码

    using func_t = function<void(int, string, uint16_t, string)>; // 业务处理函数的类型

    class udpServer
    {
    private:
        uint16_t _port;   // 当前服务端进程的端口号
        string _ip;       // 当前服务端的ip,但是作为一个服务器,一般都是将ip设为全0,代表任意ip都能访问
        int _socketfd;    // 套接字文件的文件描述符
        func_t _callback; // 服务端要完成的业务
        
    public:
        udpServer(func_t callback, const uint16_t port, const string& ip = defaultIP)
            :_port(port), _ip(ip), _socketfd(-1), _callback(callback)
        {}

        // 初始化服务端
        void initServer()
        {
            // 1. 创建套接字(本质是创建文件)
            _socketfd = socket(AF_INET, SOCK_DGRAM, 0);
            if(_socketfd == -1)
            {
                cerr << "socket error: " << errno << " : " << strerror(errno) << endl; 
                exit(SOCKET_ERR);
            }
            cout << "socket success: " << _socketfd << endl;

            // 2. 绑定端口号和ip地址到当前的套接字文件
            struct sockaddr_in local;
            bzero(&local, sizeof local);    // 先将一段local的内存清零,即将其中的每个字节都设置为0,更推荐用memset函数!
            
            local.sin_family = AF_INET;
            local.sin_port = htons(_port);  // 因为端口号是多个字节组成,所以要保证先转化为大段序列

            // inet_addr函数帮我们将格式化字符串转化为in_addr_t类型,并且调整成大段序列
            // local.sin_addr.s_addr = inet_addr(_ip.c_str()); 

            // 但是一般我们将作为服务器的ip设为全0,所以不需要做上述工作,直接利用一个值为全0的宏赋值就行
            local.sin_addr.s_addr = INADDR_ANY;

            int n = bind(_socketfd, (struct sockaddr*)&local, sizeof local);
            if(n == -1)
            {
                cerr << "bind error: " << errno << " : " << strerror(errno) << endl; 
                exit(BIND_ERR);
            }
        }

        // 启动服务端
        void start()
        {
            // 服务器的本质就是一个死循环,称为常驻内存的进程
            char buffer[MAXSIZE];
            while(true)
            {
                struct sockaddr_in src;
                socklen_t srclen = sizeof(src); // 这是因为操作系统不知道我们传过去的是哪个sockaddr的哪个结构体,所以我们要传大小过去

                // 这里要传sizeof(buffer)-1是因为腾出一个位置给\0
                ssize_t n = recvfrom(_socketfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&src, &srclen);
                if(n > 0)
                {
                    // 将接收到的客户端的信息保存起来
                    uint16_t src_port = ntohs(src.sin_port); // 要考虑大端接收问题,所以要转化一下
                    string src_ip = inet_ntoa(src.sin_addr); // 考虑到原本是个uint32_t类型,且还要转化为点分法,所以我们借用inet_ntoa函数帮我们完成
                    
                    buffer[n] = '\0';	// 此时因为n就是数据的长度,相当于可以定位到有效数据末尾,记得要将其置为'\0'
                    string recvmessage = buffer;

                    // 打印收集到的信息,并且执行业务处理
                    cout << "[" << src_ip << ", " << src_port << "]: " <<  recvmessage << endl;
                    _callback(_socketfd, src_ip, src_port, recvmessage);
                }
            }
        }

        ~udpServer()
        {
            int n = close(_socketfd);
            if(n != 0)
            {
                cout << "close error: " << errno << " : " << strerror(errno) << endl;
                exit(CLOSE_ERR); 
            }
        }
    };
}

② 服务器主函数:udpserver.cpp

​ 鉴于构造服务器时需要传入 IP 地址和端口号,我们这里可以引入命令行参数。此时当我们运行服务器时在后面跟上对应的 IP 地址和端口号即可!

​ 由于使用云服务器的原因,后面实际不需要传入 IP 地址,因此在运行服务器的时候我们只需要传入端口号即可,目前我们就手动将 IP 地址设置为127.0.0.1IP 地址为 127.0.0.1 实际上等价于 localhost 表示本地主机,我们将它称之为本地环回,相当于我们一会先在本地测试一下能否正常通信,然后再进行网络通信的测试。

#include "udpServer.hpp"
#include <memory>
#include <stdio.h>
using namespace std;
using namespace Server;

// 提示用法函数 -- 设为static是因为防止与客户端的用法函数冲突了!
static void Usage(string proc)
{
    cout << "\nUsage:\n\t" << proc << " local_port\n\n";
}

void handler(int sockfd, string clientip, uint16_t clientport, string message)
{
    // 业务处理,后面会填充
}

int main(int argc, char* argv[])
{
    if(argc != 2) // 如果参数传递不为两个,则提醒使用者,并且退出程序
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }
    uint16_t port = atoi(argv[1]); // 将端口号类型转换为uint16_t

    unique_ptr<udpServer> udper(new udpServer(handlerMessage, port)); // 使用智能指针创建服务端对象
    udper->initServer();	// 初始化服务器
    udper->start();			// 启动服务器
    return 0;
}

​ 需要注意的是,agrv 数组里面存储的是字符串,而端口号是一个整数,因此需要使用 atoi 函数将字符串转换成整数。然后我们就可以用这个 IP 地址和端口号来构造服务器了,服务器构造完成并初始化后就可以调用 start 函数启动服务器了。

​ 此时带上端口号运行程序就可以看到套接字创建成功、绑定成功,现在服务器就在等待客户端向它发送数据。

在这里插入图片描述

​ 虽然现在客户端代码还没有编写,但是我们可以通过 netstat 命令来查看当前网络的状态,这里我们可以选择携带 -nlup 选项。

在这里插入图片描述

​ 其中 Proto 表示协议的类型,Recv-Q 表示网络接收队列,Send-Q 表示网络发送队列,Local Address 表示本地地址,Foreign Address 表示外部地址,State 表示当前的状态,PID 表示该进程的进程ID,Program name 表示该进程的程序名称。

​ 而 Foreign Address 写成 0.0.0.0:* 表示任意 IP 地址、任意端口号的程序都可以访问当前进程

绑定INADDR_ANY的好处

​ 当一个服务器的带宽足够大时,一台机器接收数据的能力就约束了这台机器的 IO 效率,因此一台服务器底层可能装有多张网卡,此时这台服务器就可能会有多个 IP 地址,但一台服务器上端口号为 8081 的服务只有一个。这台服务器在接收数据时,这里的多张网卡在底层实际都收到了数据,如果这些数据也都想访问端口号为 8081 的服务。此时如果服务端在绑定的时候是指明绑定的某一个 IP 地址,那么此时服务端在接收数据的时候就只能从绑定 IP 对应的网卡接收数据。而如果服务端绑定的是 INADDR_ANY,那么只要是发送给端口号为 8081 的服务的数据,系统都会可以将数据自底向上交给该服务端。

在这里插入图片描述

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

​ 当然,如果你既想让外网访问你的服务器,但你又指向绑定某一个 IP 地址,那么就不能用云服务器,此时可以选择使用虚拟机或者你自定义安装的 Linux 操作系统,那个 IP 地址就是支持你绑定的,而云服务器是不支持的。

Ⅱ. UDP客户端

一、客户端创建流程

  1. 创建套接字(socket
  2. 绑定 IP 地址(一般来说 端口号不需要我们自己去绑定,操作系统在底层会判断当前有无空闲的端口号进行绑定,防止端口号冲突)
  3. 发送(sendto)、接收(recvfrom)数据,包括对数据进行处理

二、创建套接字

​ 同样的,我们把客户端也封装成一个类,当我们定义出一个客户端对象后也是需要对其进行初始化,而客户端在初始化时也需要创建套接字,之后客户端发送数据或接收数据也就是对这个套接字进行操作。

​ 客户端创建套接字时选择的协议家族也是 AF_INET,需要的服务类型也是 SOCK_DGRAM,当客户端被析构时也可以选择关闭对应的套接字。与服务端不同的是,客户端在初始化时只需要创建套接字就行了,而 不需要进行绑定操作

三、关于客户端的绑定问题

​ 首先,由于是网络通信,通信双方都需要找到对方,因此服务端和客户端都需要有各自的 IP 地址和端口号,只不过 服务端需要进行端口号的绑定,而 客户端不需要

​ 因为服务器就是为了给别人提供服务的,因此服务器必须要让别人知道自己的 IP 地址和端口号,IP 地址一般对应的就是域名,而端口号一般没有显示指明过,因此服务端的端口号一定要是一个众所周知的端口号,并且选定后不能轻易改变,否则客户端是无法知道服务端的端口号的,这就是服务端要进行绑定的原因,只有绑定之后这个端口号才真正属于自己,因为一个端口只能被一个进程所绑定,服务器绑定一个端口就是为了独占这个端口。

​ 而客户端在通信时虽然也需要端口号,但客户端一般是不进行手动绑定的,客户端在访问服务端的时候,端口号只要是唯一的就行了,不需要和特定客户端进程强相关。

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

​ 也就是说,客户端每次启动时使用的端口号可能是变化的,此时只要我们的端口号没有被耗尽,客户端就永远可以启动。

四、搭建客户端框架

① 客户端类单进程版实现(读写会互相阻塞):udpclient.hpp

#pragma once
#include <iostream>
#include <string>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <pthread.h>
using namespace std;

namespace Client
{
    enum { USAGE_ERR = 1, SEND_ERR, SOCKET_ERR, CLOSE_ERR };

    class udpClient
    {
    private:
        uint16_t _serverPort; // 服务器端口
        string _serverIP;	  // 服务器ip地址
        int _socketfd; 		  // 文件描述符
        bool _quit;			  // 判断是否推出的标志
        
    public:
        udpClient(const string& ip, const uint16_t port)
            : _serverPort(port), _serverIP(ip), _socketfd(-1), _quit(false)
        {}

        void initClient()
        {
            // 1. 创建套接字文件
            _socketfd = socket(AF_INET, SOCK_DGRAM, 0);
            if(_socketfd == -1)
            {
                cerr << "socket error: " << errno << " : " << strerror(errno) << endl; 
                exit(SOCKET_ERR);
            }
            cout << "socket success: " << _socketfd << endl;

            // 2. 绑定当前客户端的ip和端口号到套接字,而ip和端口号其实不需要明确绑定,
            // 因为套接字会自动绑定到系统分配的本地IP地址和端口号上,所以一般我们可以不显式绑定!       
        }

        void run()
        {   
            // 将要发送到目的端的信息填上
            struct sockaddr_in destination;
            memset(&destination, 0, sizeof destination);
            destination.sin_family = AF_INET;
            destination.sin_addr.s_addr = inet_addr(_serverIP.c_str());
            destination.sin_port = htons(_serverPort);

            string message; // 要发送的信息
            while(!_quit)
            {
                // 发送信息,建议还是统一使用C语言的形式使用读写操作
                cout << "Please enter the message you want to send: ";
                char line[1024];
                fgets(line, sizeof(line), stdin);
                message = line;

                ssize_t n = sendto(_socketfd, message.c_str(), message.size(), 0, (struct sockaddr*)&destination, sizeof(destination));
                if(n == -1)
                {
                    cerr << "send error: " << errno << " : " << strerror(errno) << endl; 
                    exit(SEND_ERR);
                }

                // 接收信息,但是后面这里我们会改成多线程,因为上面的输入导致了阻塞
                char buffer[1024];
                struct sockaddr_in tmp;
                socklen_t tmplen = sizeof(tmp);
                ssize_t s = recvfrom(_socketfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&tmp, &tmplen);
                if(s > 0)
                {
                    buffer[s] = '\0';
                	cout << buffer << endl;
                }
            }
        }
    };
}

💥② 客户端类多线程版实现(读写分离):udpclient.hpp

​ 因为上面的客户端是读写不分离的,所以会互相影响,那我们就搞个多线程的版本,其实也就是多开一个线程,给读写消息自己开一个空间去完成任务!主要和上面区分开的点和注意的点如下:

  1. 在类内 多线程函数需要加一个 static 保证其不会接收到一个 this 指针!
  2. 因为我们可以直接在 run() 启动函数中进行消息的发送,所以我们只需要多开一个线程去完成消息的接收即可,不需要开两个线程!并且==记得接收信息的线程要进行线程分离==,让它们撇清关系!
  3. 下面在发送信息的时候,因为我们下面会给出几个业务处理的小样例,其中包括简单的聊天室,那么我们就可以简单的通过管道来进行输入框和显示框的分离达到聊天软件那种效果!但是因为如果我们都是通过 stdout 来重定向到管道文件中的话,就做不到输入框也能显示内容了,因为全都被重定向到管道文件中去了!
    • 为了解决这个问题,我们就用 stderr 来充当输入框的内容显示!因为与 stdout 不同的是,stderr 通常用于输出不应被重定向的信息,以便及时地将错误和警告信息显示给用户,它也是属于输出流!并且它们还是独立的,互不影响!
    • stderr 输出流的内容没有被重定向到管道文件中的原因:重定向操作符 >>> 默认只会重定向标准输出 stdout,而不会重定向标准错误 stderr
  4. 要注意使用 fgets 函数来接收键盘输入的时候,回车键也是会被接收的,所以如果需要输入打印出来不显示多一个回车的话,那么就要将其置为 \0
#pragma once
#include <iostream>
#include <string>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <pthread.h>
using namespace std;

namespace Client
{
    enum { USAGE_ERR = 1, SEND_ERR, SOCKET_ERR, CLOSE_ERR };

    class udpClient
    {
    private:
        uint16_t _serverPort; // 服务器端口
        string _serverIP;	  // 服务器ip地址
        int _socketfd; 		  // 文件描述符
        bool _quit;			  // 判断是否推出的标志

        pthread_t _reader;	  // 线程标识符
        
    public:
        udpClient(const string& ip, const uint16_t port)
            : _serverPort(port), _serverIP(ip), _socketfd(-1), _quit(false)
        {}

        void initClient()
        {
            // 1. 创建套接字文件
            _socketfd = socket(AF_INET, SOCK_DGRAM, 0);
            if(_socketfd == -1)
            {
                cerr << "socket error: " << errno << " : " << strerror(errno) << endl; 
                exit(SOCKET_ERR);
            }
            cout << "socket success: " << _socketfd << endl;

            // 2. 绑定当前客户端的ip和端口号到套接字,而ip和端口号其实不需要明确绑定,
            // 因为套接字会自动绑定到系统分配的本地IP地址和端口号上,所以一般我们可以不显式绑定!       
        }
		
        // 读信息线程
        static void* recv_routine(void* args)
        {
            // 读取服务端信息
            pthread_detach(pthread_self()); // 分离线程
            int socketfd = *(static_cast<int*>(args));
            char buffer[1024];
            while(true)
            {
                struct sockaddr_in tmp;
                socklen_t tmplen = sizeof(tmp);
                ssize_t s = recvfrom(socketfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&tmp, &tmplen);
                if(s > 0)
                {
                    buffer[s] = '\0';
                	cout << buffer << endl;
                }
            }
            return nullptr;
        }

        void run()
        {   
            // 创建一个读线程,负责读取服务端发来的信息,是为了防止下面的写造成了堵塞而读取的效果不佳
            pthread_create(&_reader, nullptr, recv_routine, (void*)&_socketfd);

            // 将要发送到目的端的信息填上
            struct sockaddr_in destination;
            memset(&destination, 0, sizeof destination);
            destination.sin_family = AF_INET;
            destination.sin_addr.s_addr = inet_addr(_serverIP.c_str());
            destination.sin_port = htons(_serverPort);

            string message; // 要发送的信息
            char line[1024];
            while(!_quit)
            {
                // 负责发送信息
                
                // 这里用stderr和stdout来区分开打印,是为了配合后面聊天室功能的输入框和显示框的分离
                // 其中我们让stderr负责的是输入框显示输入内容,而stdout负责的是显示框部分的显示接收消息的内容
                // 而stderr的内容没有被重定向到管道文件中的原因是,重定向操作符>和>>默认只会重定向标准输出(stdout),而不会重定向标准错误(stderr)
                // 具体还是结合后面业务处理功能函数一起看效果会更清楚一些!
                
                fprintf(stderr, "Enter# ");       // 输入框提示内容写到stderr上
                fflush(stderr);                   // 直接刷新stderr的话,与stdout并不冲突,它们是独立的,所以会立刻显示到stderr
                
                fgets(line, sizeof(line), stdin); // 建议还是统一使用C语言的形式使用读写操作
                line[strlen(line) - 1] = '\0';    // 注意这里回车也会被放到字符串中,所以要将回车变成0
                message = line;

                ssize_t n = sendto(_socketfd, message.c_str(), message.size(), 0, (struct sockaddr*)&destination, sizeof(destination));
                if(n == -1)
                {
                    cerr << "send error: " << errno << " : " << strerror(errno) << endl; 
                    exit(SEND_ERR);
                }
            }
        }
    };
}

③ 客户端主函数:udpclient.cpp

​ 客户端的主函数的主要任务就是启动客户端去发送信息给服务端即可!

#include "udpClient.hpp"
#include <memory>
using namespace Client;

static void Usage(string proc)
{
    // 这里用cerr是为了配合聊天室的输入框和显示框的分离,stderr负责的是输入框,所以我们要在提示框进行用法提醒
    cerr << "\nUsage:\n\t" << proc << " destination_ip destination_port\n\n"; 
}

int main(int argc, char* argv[])
{
    if(argc != 3) // 如果参数传递不为两个,则提醒使用者,并且退出程序
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }
    string destination_ip = argv[1];
    uint16_t destination_port = atoi(argv[2]);
    
    unique_ptr<udpClient> udper(new udpClient(destination_ip, destination_port)); // 使用智能指针管理客户端对象
    udper->initClient();
    udper->run();
    return 0;
}

Ⅲ. 服务端加入业务处理

​ 下面给出三个常见的例子,这部分是为了完善服务端内部的业务处理,因为服务端收到客户端的消息,肯定是为了做业务处理才存在的,而不是单单接收一个消息!

一、简单的中英文翻译

#include "udpServer.hpp"
#include <memory>
#include <unordered_map>
#include <fstream>
#include <signal.h>
#include <stdio.h>
using namespace std;
using namespace Server;

static void Usage(string proc)
{
    cout << "\nUsage:\n\t" << proc << " local_port\n\n";
}

static const string dictpath = "./dict.txt"; // 单词文件的路径
static unordered_map<string, string> dict;   // 存放单词的容器

// 初始化字典的函数 -- 也就是将文件中的数据拿出来放到哈希表中
static void initdict(char Separator)
{
    ifstream in(dictpath, ios::binary);
    if(!in.is_open()) 
    {
        cerr << "open file " << dictpath << " error" << endl; 
        exit(OPEN_ERR);
    }

    // 按行获取文件中的单词,比如apple:苹果,注意下面使用的substr是左闭右开的
    string line;
    while(getline(in, line))
    {
        size_t pos = line.find(Separator);
        if(pos == string::npos)
        {
            cerr << "未找到对应单词的翻译" << endl;
            exit(NOTFOUND_ERR);
        }
        dict[line.substr(0, pos)] = line.substr(pos + 1); // 找到了就写到字典中
    }
    in.close();

    cout << "load dict success" << endl;
}

// 测试单词是否成功读取的测试函数
static void debugPrint()
{
    for(auto &e : dict)
    {
        cout << e.first << " : " << e.second << endl;
    }
}

// demo1 -- 简单的中英文翻译
void handlerMessage1(int sockfd, string clientip, uint16_t clientport, string message)
{
    // 这样子做就可以对message进行特定的业务处理,而不关心message怎么来的 ---- server通信和业务逻辑解耦!
    // 这是相对婴儿版的业务逻辑
    string response_message;
    auto iter = dict.find(message);
    if(iter == dict.end())
        response_message = "unknown!";
    else
        response_message = iter->second;

    // 发回给客户端 -- 这部分与下面的其它demo都是一样的
    struct sockaddr_in client;
    bzero(&client, 0);
    client.sin_family = AF_INET;
    client.sin_port = htons(clientport);
    client.sin_addr.s_addr = inet_addr(clientip.c_str());

    ssize_t n = sendto(sockfd, response_message.c_str(), response_message.size(), 0, (struct sockaddr*)&client, sizeof client);
    if(n == -1)
    {
        cerr << "send error: " << errno << " : " << strerror(errno) << endl; 
        exit(SEND_ERR);
    }
}

// 自定义信号,达到热加载目的
static void reload(int signo)
{
    (void)signo;
    initdict(':');
}

int main(int argc, char* argv[])
{
    if(argc != 2) // 如果参数传递不为两个,则提醒使用者,并且退出程序
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }
    uint16_t port = atoi(argv[1]); // 将端口号类型转换为uint16_t
    
    // 热加载功能,也就是我们不需要退出程序进行字典的更新
    // 只需要捕捉2号信号,让它帮助我们去重新调用一次initdict()函数即可!
    signal(2, reload); 
    initdict(':');
    debugPrint();

    unique_ptr<udpServer> udper(new udpServer(handlerMessage1, port)); // 创建服务端对象
    udper->initServer();
    udper->start();

    return 0;
}

​ 下面我们创建一个字典文档,填几个单词翻译进去:

apple:苹果
liren:利刃
banana:香蕉
tcp:传输控制协议

​ 然后运行起来看看效果:

在这里插入图片描述

​ 因为我们有热加载功能,所以当我们需要往字典中添加新的单词翻译的时候,只需要往文档中直接加入,然后通过 ctrl+c 来捕捉 2 号信号,在该信号中又会去调用一次加载字典的函数,达到热加载的功能!

​ 比如我们现在往字典中添加 nihao:你好呀,然后在服务端通过键盘输入热键:
在这里插入图片描述

​ 这样子我们就不用退出服务器重新加载了,非常方便!

二、简单的远程命令行指令解析

​ 这里用到的函数就是我们在套接字接口那介绍的 popen 函数了,非常的好用,就是 pipe + fork + exec* 的组合函数!另外为了防止有人在远程命令行捣乱,用一些不合法的指令,所以我们可以适当的排除一些风险的指令的执行!

​ 并且其实我们 只需要改动业务处理部分,对于回传信息给客户端的内容,其实和上面是一模一样的,所以有兴趣的话是可以包装成一个接口去调用的!

#include "udpServer.hpp"
#include <memory>
#include <unordered_map>
#include <fstream>
#include <signal.h>
#include <stdio.h>
using namespace std;
using namespace Server;
static void Usage(string proc)
{
    cout << "\nUsage:\n\t" << proc << " local_port\n\n";
}

// demo2 -- 远程命令行指令解析
void handlerMessage2(int sockfd, string clientip, uint16_t clientport, string cmd)
{
    // 先排除一些有风险的指令
    if(cmd.find("rm") != string::npos
        || cmd.find("mv") != string::npos
        || cmd.find("cp") != string::npos
        || cmd.find("rmdir") != string::npos
        || cmd.find("while") != string::npos)
    {
        cerr << clientip << ": " << clientport << " 正在做一个非法的操作: " << cmd << endl;
        return;
    }

    // 使用popen函数,其相当于pipe + fork + exec*
    FILE* stream = popen(cmd.c_str(), "r");
    string response;
    if(stream == nullptr)
        response = cmd + " exec failed";

    char line[1024];
    while(fgets(line, sizeof(line), stream))
    {
        response += line; // 按行读取
    }
	
    ///
    // 发回给客户端(这部分都是一致的,无需修改)
    struct sockaddr_in client;
    bzero(&client, 0);
    client.sin_family = AF_INET;
    client.sin_port = htons(clientport);
    client.sin_addr.s_addr = inet_addr(clientip.c_str());

    ssize_t n = sendto(sockfd, response.c_str(), response.size(), 0, (struct sockaddr*)&client, sizeof client);
    if(n == -1)
    {
        cerr << "send error: " << errno << " : " << strerror(errno) << endl; 
        exit(SEND_ERR);
    }

    pclose(stream); // 使用这个而不是fclose来关闭
}

int main(int argc, char* argv[])
{
    if(argc != 2) // 如果参数传递不为两个,则提醒使用者,并且退出程序
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }
    uint16_t port = atoi(argv[1]); // 将端口号类型转换为uint16_t

    unique_ptr<udpServer> udper(new udpServer(handlerMessage1, port)); // 创建服务端对象
    udper->initServer();
    udper->start();

    return 0;
}

在这里插入图片描述

三、简易的小聊天室

​ 首先我们得先有描述一个成员的类以及管理聊天室里面成员的类,所以我们先创建一个 onlineUser.hpp 来实现这两个类:

#pragma once
#include <iostream>
#include <string>
#include <unordered_map>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
using namespace std;

class User
{
public:
    User(const string& ip, uint16_t port) : _ip(ip), _port(port) {}
    string& getip() { return _ip; }
    uint16_t getport() { return _port; }
private:
    string _ip;
    uint16_t _port;
};

class onlineUser
{
private:
    unordered_map<string, User> _users; // 通过哈希表来管理用户
    
public:
    void addOnlineUser(const string& ip, uint16_t port) // 添加用户函数
    {
        string id = ip + "-" + to_string(port);
        _users.insert(make_pair(id, User(ip, port)));
    }
    void delOnlineUser(const string& ip, uint16_t port) // 删除用户函数
    {
        string id = ip + "-" + to_string(port);
        _users.erase(id);
    }
    bool isOnlineUser(const string& ip, uint16_t port)  // 判断用户是否在线函数
    {
        string id = ip + "-" + to_string(port);
        return _users.find(id) == _users.end() ? false : true;
    }
    void broadcastMessage(int sockfd, const string& ip, uint16_t port, const string& message) // 向聊天室的成员进行广播函数
    {
        for(auto& user: _users)
        {
            // 这里广播的消息就是发消息这个用户的【ip + 端口 + 消息】
            string id = ip + "-" + to_string(port) + "# " + message;

            struct sockaddr_in client;
            bzero(&client, sizeof client);
            client.sin_family = AF_INET;
            client.sin_port = htons(user.second.getport());
            client.sin_addr.s_addr = inet_addr(user.second.getip().c_str());
            sendto(sockfd, id.c_str(), id.size(), 0, (struct sockaddr*)&client, sizeof(client));
        }
    }
};

​ 接着就是继续在 udpServer.cc 中实现这部分的业务处理逻辑代码:

#include "udpServer.hpp"
#include <memory>
#include <unordered_map>
#include <fstream>
#include <signal.h>
#include <stdio.h>
using namespace std;
using namespace Server;
static void Usage(string proc)
{
    cout << "\nUsage:\n\t" << proc << " local_port\n\n";
}

// demo3 -- 一个简易的小聊天室
onlineUser users;

void handlerMessage3(int sockfd, string clientip, uint16_t clientport, string cmd)
{
    // 判断是否为上下线请求
    if(cmd == "online")
        users.addOnlineUser(clientip, clientport);
    else if(cmd == "offline")
        users.delOnlineUser(clientip, clientport);

    // 判断是否在线,是的话则进行信息的广播,不是的话则提示请登录
    if(users.isOnlineUser(clientip, clientport) == true)
    {
        // 在线的话,进行消息的广播
        users.broadcastMessage(sockfd, clientip, clientport, cmd);
    }
    else
    {
        // 不在线的话,单独提示需要上线
        struct sockaddr_in client;
        bzero(&client, sizeof(client));
        client.sin_family = AF_INET;
        client.sin_port = htons(clientport);
        client.sin_addr.s_addr = inet_addr(clientip.c_str());

        string response = "你还没有上线,请先上线,运行: online";
        sendto(sockfd, response.c_str(), response.size(), 0, (struct sockaddr*)&client, sizeof client);
    }
}

int main(int argc, char* argv[])
{
    if(argc != 2) // 如果参数传递不为两个,则提醒使用者,并且退出程序
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }
    uint16_t port = atoi(argv[1]); // 将端口号类型转换为uint16_t
    unique_ptr<udpServer> udper(new udpServer(handlerMessage3, port)); // 创建服务端对象
    udper->initServer();
    udper->start();

    return 0;
}

​ 为了有输入框和显示框的分离效果,这里我们 创建一个命名管道,将我们输入的内容显示在输入框中,而输出的内容则通过管道显示在另一个正在查看命名管道的终端下,达到分离效果,如下所示:

在这里插入图片描述

Ⅳ. windows系统的客户端

​ 其实我们写的那一套代码,在其它的操作系统上同样是能够访问的,比如 windows,下面我们就拿这个操作系统来做演示!

​ 具体的一些接口这里就不介绍了,主要是为了展现一下在不同操作系统之间同样是能够互通的,说明的是网络编程其实底层用的都是同一套,但是可能不同平台的接口封装不太一样,就像 c/c++linux 上的接口,很原滋原味的!

​ 而 windows 系统的网络接口其实也差不多,只是有些函数要需要依赖其它的一些组件罢了!

#define  _WINSOCK_DEPRECATED_NO_WARNINGS
#include <iostream>
#include <string>
#include <cstring>
#include <WinSock2.h>
#pragma comment(lib, "ws2_32.lib")
using namespace std;

uint16_t server_port = 8080;	   // 服务器端口号
string server_ip = "81.71.97.127"; // 服务器ip

int main() 
{
	WSADATA wsaData;
	// 初始化套接字环境WSAStartup   
	if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
	{
		cout << "WSAStartup failed with error: " << WSAGetLastError() << endl;
		return 1;
	}
	else
	{
		cout << "WSAStartup Success" << endl;
	}

	// 创建套接字,初始化地址和端口
	SOCKET csock = socket(AF_INET, SOCK_DGRAM, 0);
	if (csock == SOCKET_ERROR)
	{
		cout << "socket failed with error: " << WSAGetLastError() << endl;
		closesocket(csock);
		WSACleanup();
		return 1;
	}
	else
	{
		cout << "socket success" << endl;
	}
	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());

	char inbuffer[1024];
	string send_message;
	while (true)
	{
		// 发送信息
		cout << "Please Enter# ";
		getline(cin, send_message);
		int s = sendto(csock, send_message.c_str(), (int)send_message.size(), 0, (struct sockaddr*)&server, (int)sizeof(server));
		if (s == -1)
		{
			cout << "send failed" << endl;
			break;
		}

		// 接收信息
		struct sockaddr_in peer;
		int peerlen = sizeof peer;
		inbuffer[0] = 0; // C语言风格的清零
		int n = recvfrom(csock, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&peer, &peerlen);
		if (n > 0)
		{
			inbuffer[n] = 0;
			cout << "Server返回的信息是# " << inbuffer << endl;
		}
		else
			break;
	}

	closesocket(csock);
	WSACleanup();
	return 0;
}

​ 服务端的话我们就用简单的业务处理,进行返回即可:

#include "udpServer.hpp"
#include <memory>
#include <unordered_map>
#include <fstream>
#include <signal.h>
#include <stdio.h>
using namespace std;
using namespace Server;
static void Usage(string proc)
{
    cout << "\nUsage:\n\t" << proc << " local_port\n\n";
}
void handlerMessage(int sockfd, string clientip, uint16_t clientport, string message)
{
    string response_message;
    response_message += " [server echo]";

    // 发回给客户端
    struct sockaddr_in client;
    bzero(&client, 0);
    client.sin_family = AF_INET;
    client.sin_port = htons(clientport);
    client.sin_addr.s_addr = inet_addr(clientip.c_str());

    ssize_t n = sendto(sockfd, response_message.c_str(), response_message.size(), 0, (struct sockaddr*)&client, sizeof client);
    if(n == -1)
    {
        cerr << "send error: " << errno << " : " << strerror(errno) << endl; 
        exit(SEND_ERR);
    }
}
int main(int argc, char* argv[])
{
    if(argc != 2) // 如果参数传递不为两个,则提醒使用者,并且退出程序
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }
    uint16_t port = atoi(argv[1]); // 将端口号类型转换为uint16_t
    unique_ptr<udpServer> udper(new udpServer(handlerMessage, port)); // 创建服务端对象
    udper->initServer();
    udper->start();
    return 0;
}

在这里插入图片描述

在这里插入图片描述


网站公告

今日签到

点亮在社区的每一天
去签到