深入了解linux网络—— 基于UDP实现翻译和聊天功能

前言

通过学习UDP相关接口,了解了如何使用UDP来进行网络通信;

本篇文章就基于UDP网络通信,增加一些简单的业务(翻译、聊天室)来深刻自己对UDP网络通信的理解。

翻译

首先要实现一个翻译的业务:clinet端给server发送信息,我们将该信息当做一个单词,进行翻译再返回给client端。

要实现翻译,首先就要有一个翻译的字典(english与中文的映射)。

这里就基于文件来实现该字典,在server端运行时,手动调用Load加载字典:

cpp 复制代码
//dict.hpp
#pragma once
#include <iostream>
#include <string>
#include <unordered_map>
#include <fstream>
static std::string default_dictpath = "./dict.txt";
class Dict
{
public:
    Dict() {}
    ~Dict() {}

private:
    std::unordered_map<std::string, std::string> _dict; // 字典
};

这里要实现Dict这样一个模块,来完善翻译所需要的字典。

1. 加载字典

加载字典,首先得有字典:

dict.txt

复制代码
apple : 苹果
banana : 香蕉
cat : 猫
dog : 狗
book : 书
pen : 笔
happy : 快乐的
sad : 悲伤的
run : 跑
jump : 跳
teacher : 老师
student : 学生
car : 汽车
bus : 公交车
love : 爱
hate : 恨
hello : 你好
goodbye : 再见
summer : 夏天
winter : 冬天

这里统一使用English : 中文的形式,方便解析。
要加载字典(从文件中读取,并建立映射关系)

  • 这里使用fstream流,打开当前目录下的dict.txt文件;
  • 打开文件之后,就按行读取文件中的内容,并对其进行解析,建立英语单词和中文意思的映射。
  • 在解析时,可能该行内容是无法解析的,这里就简单判断然后输出一条日志;然后继续解析下行内容。
cpp 复制代码
static std::string default_dictpath = "./dict.txt";
static std::string sep = " : ";
class Dict
{
public:
    Dict() {}
    ~Dict() {}
    void Load()
    {
        // 打开文件
        std::fstream in(default_dictpath);
        if (!in.is_open())
        {
            LOG(Level::FATAL) << "file open error";
            exit(1);
        }
        // 读取
        std::string line;
        while (std::getline(in, line))
        {
            // 处理一行信息,建立映射关系
            auto pos = line.find(sep);
            if (pos == std::string::npos)
            {
                LOG(Level::WARNING) << "load error : " << line;
                continue;
            }
            std::string english = line.substr(0, pos);
            std::string chinese = line.substr(pos + 1);
            if (english.empty() || chinese.empty())
            {
                LOG(Level::WARNING) << " unknow : " << line;
                continue;
            }
            _dict.insert(std::make_pair(english, chinese));
            LOG(Level::DEBUG) << "load : " << english << " -> " << chinese;
        }
    }

private:
    std::unordered_map<std::string, std::string> _dict; // 字典
};

这样,server端创建Dict对象,调用Load()方法加载字典;然后再创建UdpServer对象,启动服务器。

2. 翻译功能

上述实现了Dict字典记载Loadserver端现在可以创建Dict对象;

但是英文和中文的映射_dictDict类内,我们在外部是无法直接访问_dict的,所以Dict就要提供一个方法,该方法的功能就是将给定的英文单词,翻译成中文,然后返回

这个翻译功能就扣实现起来还是非常简单的,只需要通过传递进来的参数word找到对应的中文,然后返回即可。(在未来,在该方法内如果想要知道谁要进行翻译,也可以通过参数获取client端的IP地址和端口号

cpp 复制代码
    std::string Translate(std::string word)
    {
        if (_dict.count(word) == 0)
        {
            return "Unknow";
        }
        return _dict[word];
    }

到这里,实现了Dict加载功能,也实现了翻译功能;

但是,接受信息是在UdpServer内部的,对于接受到的信息,如何调用Dict类内部的Translate方法呢?

在之前所实现的Udp通信,server接受到信息之后,只是输出到显示器,然后再信息发送给client端,并没有做数据处理。

这里我们要进行数据处理(将收到的信息当做单词,翻译之后返回)。

这里就可以在Udpserver中新增一个函数对象(回调函数),处理信息只需要调用该函数,将信息传递进去,然后获取返回值即可。

对于这个函数的类型,可以根据实际情况而定

这里就简单一点:using func_t = std::function<std::string(std::string)>;

在后续中,可能想要知道client端的IP地址和端口号,就需要修改该函数类型,将client的IP地址和端口号传递给回调函数。

cpp 复制代码
//udpserver.hpp
using func_t = std::function<std::string(std::string)>;
class UdpServer
{
public:
    UdpServer(uint16_t port, func_t func) : _sockfd(-1), _port(port), _func(func)
    {
    }
    ~UdpServer() {}
    void Init()
    {
        // 1. 创建socket
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0); // AF_INET 网络通信  SOCK_DGRAM  面向数据报
        if (_sockfd < 0)
        {
            LOG(Level::DEBUG) << "socket error";
            exit(1);
        }
        LOG(Level::DEBUG) << "socket success, sockfd : " << _sockfd;
        // 2.1 构建sockaddr_in对象
        struct sockaddr_in sockin;
        bzero(&sockin, sizeof(sockin));
        sockin.sin_family = AF_INET;
        sockin.sin_addr.s_addr = INADDR_ANY;
        sockin.sin_port = htons(_port);
        // 2.2 绑定IP、端口号
        int n = bind(_sockfd, (struct sockaddr *)&sockin, sizeof(sockin));
        if (n < 0)
        {
            LOG(Level::DEBUG) << "bind error";
            exit(2);
        }
        LOG(Level::DEBUG) << "socket success";
    }
    void Start()
    {
        while (true)
        {
            char buff[256];
            struct sockaddr_in peer;
            socklen_t len;
            // 接受信息
            int n = recvfrom(_sockfd, buff, sizeof(buff), 0, (struct sockaddr *)&peer, &len);
            if (n < 0)
            {
                LOG(Level::WARNING) << "recvfrom error";
                continue;
            }
            buff[n] = '\0';
            // 调用回调函数,将读取到的信息传递进去
            std::string chinese = _func(buff);
            // 将翻译结果发送给client端
            int m = sendto(_sockfd, chinese.c_str(), chinese.size(), 0, (struct sockaddr *)&peer, len);
            if (m < 0)
            {
                LOG(Level::WARNING) << "sendto error";
                continue;
            }
        }
    }
private:
    int _sockfd;
    uint16_t _port;
    func_t _func;
};

这里,在使用UdpServer时就要由上层传递信息处理的方法。

也就是说,由上层接收到的信息如何处理;UdpServer只需要通过回调函数调用即可。

cpp 复制代码
//udpserver.cc
int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        std::cout << argv[0] << " port" << std::endl;
        return -1;
    }
    uint16_t port = std::stoi(argv[1]);
    // 1. 加载翻译字典
    Dict d;
    d.Load();
    UdpServer usvr(port, [&d](std::string word) -> std::string
                   { return d.Translate(word); });
    usvr.Init();
    usvr.Start();
    return 0;
}

到这里基于Udp实现翻译功能就基本完成了,这里通过实现翻译模块,通过回调函数让server在接收到信息之后将信息传给上层,由上层决定如何去处理数据,最后获取返回信息,将返回信息发送给client端。

扩展:封装IP和Port

在上述的操作中,都是手动创建struct sockaddr_in结构体对象;我们知道struct sockaddr_in中存在三个字段(sin_familysin_addrsin_port)。

这里就对sin_addrsin_port进行封装,在之后使用时,就可以自动化构建;(后续传参需要IPport也可以直接传递封装好的对象)。

封装实现InetAddr

cpp 复制代码
class InetAddr
{
public:
    InetAddr(){}
private:
    struct sockaddr_in _addr;
    std::string _ip;
    uint16_t _port;
};

而我们在调用bindsendtorecvfrom这些接口都需要传递struct sockaddr*的参数,这里就可以实现类内成员方法来获取struct sockaddr*

以及在后续可能需要IP地址和端口号port,这里都可以实现类内方法来获取:

cpp 复制代码
    struct sockaddr *GetInetAddr() { return (struct sockaddr *)&_addr; }
    std::string GetIP() { return _ip; }
    uint16_t GetPort() { return _port; }

此外,我们可以通过IP地址和端口号port来构建InetAddr,有时我们可以绑定IP为INADDR_ANY,就不需要IP地址,直接通过端口号就可以构建struct sockaddr结构体对象。

而我们也可能需要通过struct sockaddr_in结构体对象来获取IP和端口号,这里就通过重载构造函数来实现:

cpp 复制代码
class InetAddr
{
public:
    //通过IP地址和端口号构建
    InetAddr(std::string ip, uint16_t port)
        : _ip(ip), _port(port)
    {
        _addr.sin_family = AF_INET;
        inet_aton(_ip.c_str(), &_addr.sin_addr);
        _addr.sin_port = htons(_port);
    }
    //通过struct sockaddr_in结构体对象构建
    InetAddr(struct sockaddr_in addr) : _addr(addr)
    {
        _ip = inet_ntoa(_addr.sin_addr);
        _port = ntohs(addr.sin_port);
    }
    //通过端口号构建
    InetAddr(uint16_t port) : _ip("0"), _port(port)
    {
        _addr.sin_family = AF_INET;
        _addr.sin_addr.s_addr = INADDR_ANY;
        _addr.sin_port = htons(_port);
    }
private:
    struct sockaddr_in _addr;
    std::string _ip;
    uint16_t _port;
};

也是我们也需要传递struct sockaddr_in的长度,例如sendto

这里也通过类内函数实现,获取该长度:

cpp 复制代码
    socklen_t GetLen() { return sizeof(_addr); }

到这里就对IP地址和端口号进行了封装,就可以使用InetAddr来构建struct sockaddr对象;也可以获取IP地址和端口号。

有了对IP地址和端口号的封装,在初始化UdpServer时,就无需再自己构建struct sockaddr_in结构体对象,直接通过端口号构建InetAddr对象,通过调用成员函数获取地址和长度即可。

c 复制代码
    void Init()
    {
        // 1. 创建socket
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0); // AF_INET 网络通信  SOCK_DGRAM  面向数据报
        if (_sockfd < 0)
        {
            LOG(Level::DEBUG) << "socket error";
            exit(1);
        }
        LOG(Level::DEBUG) << "socket success, sockfd : " << _sockfd;
        InetAddr addr(_port);
        // 2.2 绑定IP、端口号
        int n = bind(_sockfd, addr.GetInetAddr(), addr.GetLen());
        if (n < 0)
        {
            LOG(Level::DEBUG) << "bind error";
            exit(2);
        }
        LOG(Level::DEBUG) << "socket success";
    }

以及client端通过命令行参数获取的IP地址和端口号也可以构建InetAddr对象;

聊天室

上述使用UDP通信,简单实现了一个翻译功能;

其中还存在很多问题:

client端是一个进程(线程)既要发送信息,也要接受信息;

server也是一个进程(线程)接受信息、处理信息、发送信息。

这里简单实现一个聊天室功能,支持群聊;并且将其设计为多线程版本:

client端:

一个线程发送信息、另外一个线程接受信息(一个w线程和一个r线程);(可以通过重定向将键盘输入和接受信息输出分离

server端:

  • 主线程从网络中接受信息之后,将该信息封装成一个任务,将该任务放入线程池任务队列中;

  • 线程池中有任务,唤醒一部分线程去执行任务。

  • 这里要实现聊天室的功能,任务很显然就是将信息分发给所有在线用户

    所以,这里就要再实现一个模块:来完成消息路由

所以,这里要实现的聊天功能抽象来说就是:

1. 信息路由

要实现聊天室,很显然就要先实现信息路由;

server端将信息封装成一个任务,要让线程去执行(将信息发送给所有在线用户),那是不是就要将所有在线用户组织管理起来;所以,在Rounte中就要存在一个在线用户信息(IP和端口号)的数组(也可以使用set等等)

cpp 复制代码
    class Rounte
    {
        Rounte() {}
        ~Rounte() {}
    private:
        std::vector<InetAddr> _online_users;
    };

server要向线程池中放任务,那这个任务(信息路由)就应该在Rounte类内实现;

参数:

  • 要发送信息,首先就要知道sockfd,而创建套接字是servermain线程执行的,要让线程池中的线程去发送信息,那就要将sockfd传递给线程(通过任务传参);
  • 此外,要发送信息,肯定也要将发送的信息传递进来吧。
  • 最后,是不是也要知道这一条信息是谁发的啊(IP地址+端口号);所以,这里就使用封装的InetAddr来传递client端的IP地址和端口号。

那该函数,该如何实现呢?

  1. 首先要维护所有在线用户,在发送信息之前,就要先判断当前用户是否在_online_users中(如果不在就新增);
  2. 然后就是,将信息发送给所有的在线用户(所有的在线用户都在_online_users中,遍历依次发送即可);
  3. 最后,**用户如何退出呢?**这里就简单一些,如果用户发送的信息是QUIT,就表示用户要退出;

用户退出,这里也显示输出一下哪个用户退出,在InetAddr中实现一个方法将IP地址和端口号转化为字符串。

要判断当前用户是否在_online_users中,那我们封装的InetAddr就要支持==判断相等。(IP地址和端口号都相等才认为InetAddr相等)

c 复制代码
//InetAddr
        bool operator==(InetAddr &addr) { return _ip == addr._ip && _port == addr._port; }
        std::string ToString() { return _ip + ":" + std::to_string(_port); }
cpp 复制代码
//Rounte
    class Rounte
    {
        bool IsExist(InetAddr &addr)
        {
            for (auto &user : _online_users)
            {
                if (user == addr)
                    return true;
            }
            return false;
        }
    public:
        Rounte() {}
        ~Rounte() {}
        void SendTask(int sockfd, const std::string &massage, InetAddr &peer)
        {
            if (IsExist(peer) == false)
            {
                _online_users.push_back(peer);
                LOG(Level::INFO) << "新增了一个在线用户";
            }
            // 发送信息
            std::string str = peer.ToString() + '#' + massage;
            for (auto &user : _online_users)
            {
                sendto(sockfd, str.c_str(), str.size(), 0, user.GetInetAddr(), user.GetLen());
            }
            if (massage == "QUIT")
            {
                LOG(Level::INFO) << peer.ToString() << "用户退出";
                auto pos = _online_users.begin();
                while (pos != _online_users.end())
                {
                    if (*pos == peer)
                        break;
                }
                _online_users.erase(pos);
            }
        }

    private:
        std::vector<InetAddr> _online_users;
    };

有了Rounte,接下来将server更改为多线程版本,这里直接复用之前实现好的线程池代码;

2. 线程池版server

首先就是接收到信息时,处理信息的函数;

上述Rounte实现的SenTask函数是void(Rounte*, int,const std::string&, InetAddr&)类型,而之前线程池中实现的任务类型是void(void)类型,如何将其连通起来呢?

我们可以在上层使用lambda表达式,将参数传递进来;而在lambda表达式内部,使用C++11中的bind,绑定参数列表;让后再将任务入队列。

cpp 复制代码
using task_t = std::function<void()>;
int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        std::cout << argv[0] << " port" << std::endl;
        return -1;
    }
    uint16_t port = std::stoi(argv[1]);
    // 消息路由
    Rounte r;
    // 线程池
    std::unique_ptr<Threadpool<task_t>> thp = std::make_unique<Threadpool<task_t>>();
    thp->Start();
    // 网络通信
    UdpServer usvr(port, [&r, &thp](int sockfd, const std::string &massage, InetAddr &addr)
                   {
        auto b = std::bind(&Rounte::SendTask,&r,sockfd,massage, addr);
        thp->Enqueue(b); });
    usvr.Init();
    usvr.Start();
    return 0;
}

这样在server接收到信息之后,只需要调用回调函数将任务入队列,唤醒线程池中线程去执行即可。

cpp 复制代码
//udpserver.hpp
using Task_t = std::function<void(int, const std::string &, InetAddr &)>;
class UdpServer
{
public:
    UdpServer(uint16_t port, Task_t func) : _sockfd(-1), _port(port), _task(func)
    {
    }
    ~UdpServer() {}
    void Init()
    {
        // 1. 创建socket
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0); // AF_INET 网络通信  SOCK_DGRAM  面向数据报
        if (_sockfd < 0)
        {
            LOG(Level::DEBUG) << "socket error";
            exit(1);
        }
        LOG(Level::DEBUG) << "socket success, sockfd : " << _sockfd;
        InetAddr addr(_port);
        // 2.2 绑定IP、端口号
        int n = bind(_sockfd, addr.GetInetAddr(), addr.GetLen());
        if (n < 0)
        {
            LOG(Level::DEBUG) << "bind error";
            exit(2);
        }
        LOG(Level::DEBUG) << "socket success";
    }
    void Start()
    {
        while (true)
        {
            char buff[256];
            struct sockaddr_in peer;
            socklen_t len;
            // 接受信息
            int n = recvfrom(_sockfd, buff, sizeof(buff), 0, (struct sockaddr *)&peer, &len);
            if (n < 0)
            {
                LOG(Level::WARNING) << "recvfrom error";
                continue;
            }
            buff[n] = '\0';
            InetAddr client(peer);
            _task(_sockfd, buff, client);//回调函数
        }
    }
private:
    int _sockfd;
    uint16_t _port;
    Task_t _task;
};

3. 多线程版client

在上述代码中,server端引入线程池,使用线程池任务向所有在线用户发送信息;

现在对于client,我们也要修改为多线程版本,一个线程写,应该线程读

这个相对比较简单了,这里将所用到的sockfdserver端IP地址和端口号定义成全局方便使用

cpp 复制代码
int sockfd;
InetAddr server;
void *Send(void *argv)
{
    while (true)
    {
        std::string massage;
        std::getline(std::cin, massage);
        // 发送信息
        sendto(sockfd, massage.c_str(), massage.size(), 0, server.GetInetAddr(), server.GetLen());
    }
}
void *recv(void *argv)
{
    while (true)
    {
        // 接受信息
        struct sockaddr_in peer;
        bzero(&peer, sizeof(peer));
        socklen_t len = sizeof(len);
        char buff[256];
        int n = recvfrom(sockfd, buff, sizeof(buff), 0, (struct sockaddr *)&peer, &len);
        if (n < 0)
        {
            std::cerr << "recvfrom error";
            continue;
        }
        buff[n] = '\0';
        std::cerr << buff << std::endl;
    }
}
int main(int agrc, char *argv[])
{
    if (agrc != 3)
    {
        std::cout << argv[0] << " serverip  serverport" << std::endl;
        return -1;
    }
    server.Set(argv[1], std::stoi(argv[2]));
    // 创建套接字
    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0)
    {
        std::cerr << "socket error" << std::endl;
        return -1;
    }
    std::cout << "socket success" << std::endl;
    pthread_t s, r;
    pthread_create(&s, nullptr, Send, nullptr);
    pthread_create(&r, nullptr, recv, nullptr);

    pthread_join(s, nullptr);
    pthread_join(r, nullptr);
    return 0;
}

当然,这里也可以将sockfdInetAddr封装成一个结构体,通过参数传递给新线程。

到这里本篇文章内容就结束了,感谢各位大佬的支持

我的博客即将同步至腾讯云开发者社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=2oul0hvapjsws

相关推荐
大聪明-PLUS2 小时前
从技术史看:Unix 从何而来
linux·嵌入式·arm·smarc
励志不掉头发的内向程序员2 小时前
【Linux系列】并发世界的基石:透彻理解 Linux 进程 — 进程概念
linux·运维·服务器·开发语言·学习
安卓开发者3 小时前
在鸿蒙NEXT中发起HTTP网络请求:从入门到精通
网络·http·harmonyos
---学无止境---3 小时前
Linux中内核堆栈跟踪函数dump_stack的实现
linux
渡我白衣3 小时前
从传输层协议到 UDP:轻量高效的传输选择
网络·网络协议·udp
早起的年轻人3 小时前
CentOS 8系统盘大文件查找方法
linux·运维·centos
心灵宝贝3 小时前
Linux CentOS 7 安装 zip-3.0-11.el7.x86_64.rpm 详细步骤(命令行教程)(附安装包)
linux·运维·centos
挺6的还3 小时前
50.Reactor反应堆模式
linux
Thexhy4 小时前
在Centos的Linux中安装Windows10系统
linux·运维·经验分享·学习·centos