Linux网络编程——基于UDP协议的简易聊天室

news/2024/5/18 14:00:42 标签: udp, 网络, tcp/ip

0.关注博主有更多知识

操作系统入门知识合集

目录

1.UDP服务端

1.1消息转发的实现

2.UDP客户端

3.效果展示

1.UDP服务端

使用C、C++混编的方式在Linux环境下实现一个简单的UDP服务端。那么我们先看代码,然后逐步分析:

// udpServer.hpp
#pragma once

#include <string>
#include <iostream>
#include <strings.h>
#include <functional>
/*网络必要的头文件*/
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>

/*其他操作系统接口*/
#include <unistd.h>

namespace server
{
    using namespace std;
    enum {SOCKET_ERROR = 1,START_ERROR,BIND_ERROR};
    typedef function<void(int,struct sockaddr_in &,string &)> func_t;
    class udpServer
    {
    public:
        /*服务器不需要绑定任何IP!
         *绑定默认的0.0.0.0即可
         *绑定了一个指定的IP之后,只能接收到指定IP的客户端的数据*/
        udpServer(const func_t &func,const uint16_t &port,const string &ip = defaultIp)
            :_func(func),_port(port),_ip(ip),_socketFd(-1)
        {
            /*以IP协议、数据报的形式打开文件
             *也就是以UDP协议打开网络文件
             *如果打开失败,说明无法进行网络通信,程序退出*/
            _socketFd = socket(AF_INET,SOCK_DGRAM,0);
            if(_socketFd == -1)
            {
                cerr << "socket fail" << endl;
                exit(SOCKET_ERROR);
            }
            cout << "socket success: " << _socketFd << endl; 

            /*以后对网络的I/O的操作就是对网络文件操作
             *但是还没有将该文件绑定IP和端口号,也就是还没有绑定套接字
             *所以现在要绑定套接字*/
            struct sockaddr_in local = getSockaddr_in();
            int n = bind(_socketFd,(struct sockaddr *)&local,sizeof(local));
            if(n == -1)
            {
                cerr << "bind fail" << endl;
                exit(BIND_ERROR);
            }
            /*至此套接字创建工作完成*/
        }
        ~udpServer()
        {}

        void start()
        {
            while(true)
            {
                /*读取客户端发送的数据
                 *并打印出来,相当于服务器后台日志*/
                char buffer[buffer_num];

                struct sockaddr_in client;
                socklen_t len = sizeof(client);
                int n = recvfrom(_socketFd,buffer,sizeof(buffer)-1,0,(struct sockaddr *)&client,&len);
                if(n > 0)
                {
                    buffer[n] = 0;
                    string message = buffer;
                    string clinetIp = inet_ntoa(client.sin_addr);
                    uint16_t clinetPort = ntohs(client.sin_port);

                    cout << clinetIp << "[" << clinetPort << "]# " << message << endl;

                    /*回调*/
                    _func(_socketFd,client,message);
                }
            }
        }
    private:
        /*参与网络通信需要端口号和ip地址
         *端口号是一个2字节的整数
         *ip地址用点分十进制的字符串表示*/
        uint16_t _port;
        string _ip;

        /*以文件的形式打开一个网络文件
         *即使用socket()打开一个网络文件*/
        int _socketFd;

        /*所有对象共享一个默认的IP*/
        static const string defaultIp;
        static const int buffer_num = 1024;

        /*回调函数:服务器要处理的业务逻辑*/
        func_t _func;

        /*获取struct sockaddr结构体*/
        struct sockaddr_in getSockaddr_in()
        {
             struct sockaddr_in local;
            /*初始化sockaddr_in对象
             *并且将端口号、IP地址设置进去
             *我们使用的IP地址是字符串,所以需要转换成整数
             *使用inet_addr()接口,里面自动转换成整数,并且自动大小端字节序*/
            bzero((void *)&local,sizeof(local));
            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;
            return local;
        }
    };
    const string udpServer::defaultIp = "0.0.0.0";
} /*namespace server ends here*/

首先我们介绍一下scoket()接口

  第一个名为"domain"的参数代表域,即我们想要使用网络套接字、原始套接字还是Unix域间套接字,很显然,这里我们应该选择网络套接字,所以该参数填AF_INET,它是一个宏,代表以IPV4网络协议

  第二个名为"type"的参数代表网络通信的种类。我们前面介绍过UDP协议是面向数据报传输的、TCP协议是面向字节流传输的,那么我们现在要实现UDP服务器,所以该参数填SOCK_DGRAM,它也是一个宏,代表数据报

  第三个名为"prorocal"的参数代表使用的网络协议,但是因为我们前两个参数已经确定了我们使用UDP协议,所以该参数填0

那么socket()的作用是什么?我们观察它的返回值:

  可以发现socket()的返回值是一个文件描述符,这就说明了socket()会打开一个文件,并且我们不难猜测出打开的文件是一个网络文件,这个网络文件就是我们收发数据的入口

那么在udpServer类的构造函数当中,调用了socket()之后,我们又调用了一个名为getScokaddr_in()的成员函数,该函数返回一个类型为strcut sockaddr_in的结构体,前面我们介绍过,该结构体用于网络通信,我们在getScokaddr_in()函数内部设定好结构体的属性,然后返回,最后在外部接收。需要注意的是,我们需要设置struct sockaddr_in结构体的协议家族、端口号和IP地址,同时也需要大小端的问题,所以我们使用一些大小端转换的接口进行字节序转换,最重要的是,我们的IP地址设置为了"0.0.0.0"或者使用宏INADDR_ANY,这是因为服务器不需要绑定任何IP,如果服务器绑定了一个指定的IP,那么这就说明该服务器只能接收到该IP地址对应的数据。INADDR_ANY是一个宏

  这里就会有一个奇怪的问题,如果我们不用INADDR_ANY设置属性,而是使用我们默认提供的"0.0.0.0"字符串,可以看到注释部分使用了一个名为inet_addr()的接口:

  这是因为我们指定的IP是一个点分十进制表示的字符串,但是操作系统使用的IP地址是一个整数:

  所以我们必须将字符串转化为整数,当然,这个工作不需要程序员来做,因为我们无法得知我们机器的大小端字节序究竟是什么。

接下来我们就调用了bind()接口,即绑定套接字。那么何为bind()?或者说bind()的作用是什么?bind()的作用就是绑定套接字到使用socket()打开的网络文件当中,套接字就是我们之前所说的IP地址和端口号,将套接字绑定到了网络文件之后,才可以收发网络消息(不然别人怎么知道我在哪?)。那么它的声明如下:

  参数的目的非常明显,就是让我们将设置好属性的结构体绑定到socket()打开的网络文件当中。

此时构造函数的功能全部完成,目的就是让udpServer类在实例化对象的时候就初始化好服务器,然后只需调用start()成员即可启动服务器。那么在start()成员函数当中,使用recvfrom()接口阻塞式的读取从网络文件当中得到的数据,它的后两个参数是输入输出型参数,目的是知道是哪个客户端给服务器发送的数据,方便进行后续的工作。注意我们有一个名为_func的成员,它的类型是一个包装器,它能够回调外部的函数,也就是说可以处理其他的业务逻辑。聊天室的功能就在该回调函数当中实现。

1.1消息转发的实现

当服务端接收到来自客户端的消息时,通过_func()回调到外部逻辑,然后实现一次对所有链接到服务端的客户端的消息转发,达到群聊的效果。那么为了方便管理用户,可以单独定义一个类,并提供诸多成员方法:

// onlineUsers.hpp
#pragma once

#include <iostream>
#include <string>
#include <strings.h>
#include <unordered_map>
using namespace std;

/*网络必要头文件*/
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>

/*用户具有的属性*/
class Users
{
public:
    Users(const string &ip, const uint16_t &port)
        : _ip(ip), _port(port)
    {}

public:
    string _ip;
    uint16_t _port;
};

/*管理用户的类*/
class onlineUsers
{
public:
    /*添加用户,即将ip+port作为key,Users对象作为value*/
    void addUser(const string &ip, const uint16_t &port)
    {
        string id = ip + to_string(port);
        _users.insert(make_pair(id, Users(ip, port)));
    }

    /*删除用户,通过id作为key删除用户*/
    void delUser(const string &ip, const uint16_t &port)
    {
        string id = ip + to_string(port);
        _users.erase(id);
    }

    /*判断是否在线*/
    bool isOnline(const string &ip, const uint16_t &port)
    {
        string id = ip + to_string(port);
        return _users.find(id) == _users.end() ? false : true;
    }

    /*向在线的所有用户转发消息*/
    void routingMessage(int _socketFd,const string &ip,const uint16_t &port,string &message)
    {
        for(auto &user : _users)
        {
            struct sockaddr_in client;
            bzero((void *)&client,sizeof(client));
            client.sin_family = AF_INET;
            client.sin_port = htons(user.second._port);
            client.sin_addr.s_addr = inet_addr(user.second._ip.c_str());
            
            string s = ip + "-" + to_string(port) + "# " + message;
            sendto(_socketFd,s.c_str(),s.size(),0,(struct sockaddr *)&client,sizeof(client));
        }
    }

private:
    /*哈希表存储用户*/
    unordered_map<string, Users> _users;
};

那么服务端的业务逻辑就是这样的:

#include "udpServer.hpp"
#include <iostream>
#include <string>
#include <memory>
#include <signal.h>
using namespace std;
using namespace server;

#include "onlineUsers.hpp"

onlineUsers ous;
void chatDemo(int _socketFd,struct sockaddr_in &client,string &message)
{
    string ip = inet_ntoa(client.sin_addr);
    uint16_t port = ntohs(client.sin_port);
    if(message == "online") ous.addUser(ip,port);
    if(message == "offline") ous.delUser(ip,port);

    if(ous.isOnline(ip,port))
    {
        ous.routingMessage(_socketFd,ip,port,message);/*路由消息(转发消息)*/
    }
    else 
    {
        string response = "please online...";
        sendto(_socketFd,response.c_str(),response.size(),0,(struct sockaddr *)&client,sizeof(client));
    }
}

void Usage(char *command)
{
    cout << "\nUsage:\n\t" << command << "\t" << "server_port\n" << endl; 
}

/*服务器启动时只需要绑定端口号*/
int main(int argc,char *argv[])
{
    if(argc != 2)
    {
        Usage(argv[0]);
        exit(START_ERROR);
    }
    uint16_t port = atoi(argv[1]);
    
    unique_ptr<udpServer> upus(new udpServer(chatDemo,port));
    upus->start();
    return 0;
}

至此,服务端的工作完成。

2.UDP客户端

客户端的代码与服务端的代码十分类似,我们先看上层的调用逻辑:

// udpClient.cpp

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

void Usage(char *command)
{
    cout << "\nUsage:\n\t" << command << "\t" << "server_ip\t" << "server_port\n" << endl; 
}

/*客户端启动时必须绑定两个选项
 *意在指明与哪个服务器建立通信*/
int main(int argc,char *argv[])
{
    if(argc != 3)
    {
        Usage(argv[0]);
        exit(START_ERROR);
    }
    string serverIp = argv[1];
    uint16_t serverPort = atoi(argv[2]);
    unique_ptr<udpClinet> upuc(new udpClinet(serverIp,serverPort));
    upuc->start();
    return 0;
}

可以发现客户端与服务端的区别。服务端启动时只需要绑定端口号,目的就是为了确定唯一的进程,方便建立通信。但是客户端在启动的时候需要IP地址和端口号,也就是说启动时需要套接字,那么这个套接字是谁的?客户端启动时需要的套接字不是客户端的套接字,而是服务端的套接字。因为客户端要与服务端建立通信,那么它就需要知道服务端在哪,并且我们在编写客户端时,我们不需要关心客户端自己的套接字,因为这个部分的工作由操作系统帮忙完成,具体什么意思,我们看客户端的实现:

// udpClient.hpp

#pragma once

#include <string>
#include <iostream>
#include <strings.h>
/*网络必要的头文件*/
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>

/*其他操作系统接口*/
#include <unistd.h>
#include <pthread.h>
#include "blockQueue.hpp"
#include <vector>
using namespace blockqueue;

namespace client
{
    using namespace std;
    enum
    {
        SOCKET_ERROR = 1,
        START_ERROR
    };
    class udpClinet
    {
    public:
        udpClinet(const string &serverIp, const uint16_t &serverPort)
            : _socketFd(-1), _serverIp(serverIp), _serverPort(serverPort)
        {
            _socketFd = socket(AF_INET, SOCK_DGRAM, 0);
            if (_socketFd == -1)
            {
                cerr << "socket fail" << endl;
                exit(SOCKET_ERROR);
            }
            cout << "socket success: " << _socketFd << endl;
            /*客户端不关心自己的IP和端口号,所以不需要我们自己动手bind
             *但这并不代表客户端不需要bind,只是这个工作操作系统给做了*/
        }

        static void *productorDemo(void *args)
        {
            pthread_detach(pthread_self());
            udpClinet *_this = static_cast<udpClinet *>(args);
            blockQueue<string> *bq = _this->bq;
            while (true)
            {
                int socketFd = (static_cast<udpClinet *>(args))->_socketFd;
                char buffer[1024];
                struct sockaddr_in server;
                socklen_t len = sizeof(server);
                size_t n = recvfrom(socketFd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&server, &len);
                if(n > 0) buffer[n] = 0;
                string message = buffer;
                bq->push(message);
            }
        }

        void start()
        {
            /*主线程充当消费者,新线程充当生产者
             *生产者从网络当中获取消息,并将该消息写到阻塞队列当中
             *消费者直接从阻塞队列当中获取消息*/
            pthread_t consumer, productor;
            pthread_create(&productor, nullptr, productorDemo, this);

            while (true)
            {
                string message;
                cerr << "Please Say[回车刷新聊天记录]# ";
                getline(cin, message);
                struct sockaddr_in server = getSockaddr_in();
                sendto(_socketFd, message.c_str(), message.size(), 0, (struct sockaddr *)&server, sizeof(server));
                
                /*消费者从阻塞队列当中获取消息,一次全部拿完
                 *强制让主线程休眠一小会,目的是起到每次输入完后,强制调度生产者线程*/
                usleep(1234);
                while(!bq->isEmpty())
                {
                    string ret;
                    bq->pop(&ret);
                    cout << ret << endl;
                }
            }
        }

    private:
        /*网络通信三个条件:
         *网络文件、IP、端口号
         *作为客户端不需要关心自己的IP和端口号
         *要关心服务器的IP和端口号*/
        int _socketFd;
        string _serverIp;
        uint16_t _serverPort;

        static blockQueue<string> *bq;/*阻塞队列*/

        /*获取struct sockaddr结构体*/
        struct sockaddr_in getSockaddr_in()
        {
            struct sockaddr_in server;
            /*初始化sockaddr_in对象
             *并且将端口号、IP地址设置进去
             *我们使用的IP地址是字符串,所以需要转换成整数
             *使用inet_addr()接口,里面自动转换成整数,并且自动大小端字节序*/
            bzero((void *)&server, sizeof(server));
            server.sin_family = AF_INET;
            server.sin_port = htons(_serverPort);
            server.sin_addr.s_addr = inet_addr(_serverIp.c_str());
            return server;
        }
    };
    blockQueue<string> *udpClinet::bq = new blockQueue<string>();
} /*namespace client ends here*/

可以发现,在构造函数当中,我们并没有调用bind()绑定套接字,在注释当中也说到了该任务由操作系统完成。原因还是上面说过的老话,客户端最关心的就是服务端的IP地址和端口号,客户端本身的IP地址和端口号的最大功能就是让服务端知道是哪个客户端发送的数据,所以说这个工作不需要我们来完成。所以我们在getSockaddr_in()成员函数中,将结构体的IP地址和端口号,都设置成了服务端的IP地址和端口号。

那么start()接口就是客户端的启动入口了,为了方便网络通信,我们新创建了一个线程,这个线程充当生产者,它从网络当中获取消息并将消息放入阻塞队列当中;那么主线程就相当于消费者,每次完成输入之后都会从阻塞队列当中获取消息并打印出来。注意中间使用了一条usleep(1234)语句,无论它休眠多久,我们的目的就是在每次输入完成后,强行调度生产者线程从网络当中获取最近的消息,主线程醒来后再从阻塞队列当中获取消息并输出。

当然,我们没有显式地使用bind()绑定的一个重要原因就是,bind()会将固定的套接字绑定到socket当中,并且因为客户端的套接字通常都是固定的(服务器的套接字通常是固定的),并且由于客户端是一个经常加载、退出的程序,所以程序启动时都会绑定一个固定的套接字,这就会造成一个偶然错误,即万一程序启动时该套接字被其他进程占用了,那么该客户端就起不来了。所以我们bind()的任务交给操作系统,操作系统会选择空闲的端口号来绑定,具体绑定的过程就发生在recvfrom()或者sendto()接口当中,操作系统会检测当前进程有没有绑定套接字,如果没有操作系统就会自动完成这个任务。客户端的绑定套接字的作用就是让服务端知道是哪个客户端发来的数据,将来发送的数据要到哪个客户端去

在这里,附上阻塞队列的生产消费模型的代码:

#pragma once

#include <queue>
#include <pthread.h>
namespace blockqueue
{
    using namespace std;

    template <class T>
    class blockQueue
    {
    private:
#define MAXCAP 100 /*缓冲区上限大小,随时可变*/
    public:
        blockQueue(const size_t &cap = MAXCAP) : _cap(cap)
        {
            pthread_mutex_init(&_mutex, nullptr);
            pthread_cond_init(&_productorCond, nullptr);
            pthread_cond_init(&_consumerCond, nullptr);
        }
        ~blockQueue()
        {
            pthread_mutex_destroy(&_mutex);
            pthread_cond_destroy(&_productorCond);
            pthread_cond_destroy(&_consumerCond);
        }

        /*向阻塞队列(缓冲区)生产数据*/
        void push(const T &in)
        {
            pthread_mutex_lock(&_mutex);

            /*如果阻塞队列为满,生产者不能生产,进入自己的条件变量等待*/
            while (isFull())
            { /*这里必须使用while而不是if*/
                /*进入条件变量相当于发生一次线程切换
                 *然是要将锁释放,让其他线程拥有锁
                 *这就是为什么需要传入锁的原因*/
                pthread_cond_wait(&_productorCond, &_mutex);
            }
            _q.push(in);

            /*生产者生产一个,说明阻塞队列就多一个数据
             *所以此时可以唤醒消费者消费*/
            pthread_cond_signal(&_consumerCond);
            pthread_mutex_unlock(&_mutex);
        }

        /*从阻塞队列(缓冲区)拿数据
         *使用输出型参数*/
        void pop(T *out)
        {
            pthread_mutex_lock(&_mutex);
            while (isEmpty())
            {
                pthread_cond_wait(&_consumerCond, &_mutex);
            }
            *out = _q.front();
            _q.pop();
            pthread_cond_signal(&_productorCond);
            pthread_mutex_unlock(&_mutex);
        }

        bool isFull()
        {
            return _q.size() == _cap;
        }
        bool isEmpty()
        {
            return _q.size() == 0;
        }

    private:
        /*以一个队列作为缓冲区*/
        queue<T> _q;

        /*生产者与消费者之间存在互斥与同步关系
         *故定义一把锁、生产者条件变量、消费者条件变量*/
        pthread_mutex_t _mutex;
        pthread_cond_t _productorCond;
        pthread_cond_t _consumerCond;

        /*缓冲区的大小*/
        size_t _cap;
    };
} /*namespace blockqueue ends here*/

3.效果展示

服务器启动时,需要绑定端口号(云服务器下):

客户端启动时,需要绑定服务器的IP地址和服务器进程的端口号(虚拟机下):

再在本地环境中启动一个客户端,以检测群发功能是否正常(云服务器下):

现在两个客户端登陆,并且先让虚拟机客户端先发送消息,测试云服务器上的客户端能否收到消息:

云服务器客户端发送消息,测试虚拟机客户端能否收到消息:


http://www.niftyadmin.cn/n/338664.html

相关文章

无人值守的IDC机房动环综合运维方案

企业数字化转型以及5G、物联网、云计算、人工智能等新业态带动了数据中心的发展&#xff0c;在国家一体化大数据中心及“东数西算”节点布局的推动下&#xff0c;数据中心机房已成为各大企事业单位维持业务正常运营的重要组成部分&#xff0c;网络设备、系统、业务应用数量与日…

Android应用-开发框架设计

目录 1. &#x1f4c2; 简介 1.1 背景 1.2 专业术语 2. &#x1f531; 总体设计思想 2.1 分层&#xff1a;组件化设计框架 2.2 分类&#xff1a;应用开发架构图 3. ⚛️ 框架详细设计 3.1 组件化框架外形 3.2 业务模块化 3.3 代码编程框架 4. &#x1f4a0; 框架其他…

InnoDB数据页结构

什么是页&#xff1f;什么是数据页&#xff1f; 页是InnoDB管理存储空间的基本单元&#xff0c;一个页的大小一般是16k。 InnoDB有许多不同的页&#xff0c;有存放表空间头部信息的页&#xff0c;INODE信息的页&#xff0c;当然还有存放我们记录信息的页&#xff0c;这个页叫…

20230518 美国知乎 Quora 旗下 Poe.com 上可以免费Claude试用 7 天。

&#x1f680; 美国知乎 Quora 旗下 Poe.com 上可以免费Claude试用 7 天。 最强竞品 Claude 最近实现了史诗升级&#xff0c;支持十万 token 上下文&#xff0c;并且可以处理英文书籍&#xff0c;但申请使用需要付费。 而在美国知乎 Quora 旗下 Poe.com 上可以免费试用 7 天。…

linux系统SSL证书部署https单/多站点

以下教程为linux系统申请SSL证书&#xff0c;部署单/多站点https方法。如果对技术不熟悉&#xff0c;建议l联系服务商。 另需先申请下载SSL证书&#xff0c;如还没有&#xff0c;请先申请ssl证书。 一、linux系统单/多站点https部署方法&#xff08;安装默认wdcp环境&#xf…

gpt接口新增配额控制

工作内容,不对外开放 场景: 用户使用gpt时会消耗token,我们要求能够在某个地方配置gpt限额,gpt限额有全局限额也有个人配置的限额, 先配置一个默认的全局的限额(所有用户gpt3.5每个月不能超过1000,每天不能超过500,每个小时不能超过100), 用户可以配置用户的限额(该…

人工智能专栏第五讲——策略树

本篇文章我们将介绍机器学习中非常重要的一个概念——决策树,同时讲解其基本算法和特性,以及在实际应用中的一些注意事项。 1. 什么是决策树? 决策树是一种常用的监督学习算法,用于解决分类和回归问题。它的基本思想是把输入数据分类或回归成离散或连续的输出值,构成一棵…

本地git仓库(gitea)与openssh-server的冲突(connection reset by ip port 22)

前提 之前在本地的windows电脑上安装了一个gitea供项目组成员使用。 期间为了在windows电脑上使用scp拷贝文件&#xff0c;离线安装过一个openssh。 冲突 发现无法pull/clone gitea上的仓库了&#xff0c;提示 connection reset by ip port 22 fatal: Could not read from r…