深入了解linux网络—— 基于UDP实现翻译和聊天功能
前言
通过学习UDP
相关接口,了解了如何使用UDP
来进行网络通信;
本篇文章就基于UDP
网络通信,增加一些简单的业务(翻译、聊天室)来深刻自己对UDP
网络通信的理解。
翻译
首先要实现一个翻译的业务:clinet
端给server
发送信息,我们将该信息当做一个单词,进行翻译再返回给client
端。
要实现翻译,首先就要有一个翻译的字典(english
与中文的映射)。
这里就基于文件来实现该字典,在server
端运行时,手动调用Load
加载字典:
//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
文件;- 打开文件之后,就按行读取文件中的内容,并对其进行解析,建立英语单词和中文意思的映射。
- 在解析时,可能该行内容是无法解析的,这里就简单判断然后输出一条日志;然后继续解析下行内容。
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
字典记载Load
,server
端现在可以创建Dict
对象;
但是英文和中文的映射_dict
在Dict
类内,我们在外部是无法直接访问_dict
的,所以Dict
就要提供一个方法,该方法的功能就是将给定的英文单词,翻译成中文,然后返回
这个翻译功能就扣实现起来还是非常简单的,只需要通过传递进来的参数word
找到对应的中文,然后返回即可。(在未来,在该方法内如果想要知道谁要进行翻译,也可以通过参数获取client
端的IP地址和端口号)
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地址和端口号传递给回调函数。
//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
只需要通过回调函数调用即可。
//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_family
、sin_addr
和sin_port
)。
这里就对sin_addr
和sin_port
进行封装,在之后使用时,就可以自动化构建;(后续传参需要IP
和port
也可以直接传递封装好的对象)。
封装实现InetAddr
:
class InetAddr
{
public:InetAddr(){}
private:struct sockaddr_in _addr;std::string _ip;uint16_t _port;
};
而我们在调用bind
、sendto
、recvfrom
这些接口都需要传递struct sockaddr*
的参数,这里就可以实现类内成员方法来获取struct sockaddr*
;
以及在后续可能需要IP
地址和端口号port
,这里都可以实现类内方法来获取:
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和端口号,这里就通过重载构造函数来实现:
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
;
这里也通过类内函数实现,获取该长度:
socklen_t GetLen() { return sizeof(_addr); }
到这里就对IP地址和端口号进行了封装,就可以使用
InetAddr
来构建struct sockaddr
对象;也可以获取IP
地址和端口号。
有了对IP地址和端口号的封装,在初始化UdpServer
时,就无需再自己构建struct sockaddr_in
结构体对象,直接通过端口号构建InetAddr
对象,通过调用成员函数获取地址和长度即可。
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
等等)
class Rounte{Rounte() {}~Rounte() {}private:std::vector<InetAddr> _online_users;};
server
要向线程池中放任务,那这个任务(信息路由)就应该在Rounte
类内实现;
参数:
- 要发送信息,首先就要知道
sockfd
,而创建套接字是server
端main
线程执行的,要让线程池中的线程去发送信息,那就要将sockfd
传递给线程(通过任务传参);- 此外,要发送信息,肯定也要将发送的信息传递进来吧。
- 最后,是不是也要知道这一条信息是谁发的啊(IP地址+端口号);所以,这里就使用封装的
InetAddr
来传递client
端的IP地址和端口号。
那该函数,该如何实现呢?
- 首先要维护所有在线用户,在发送信息之前,就要先判断当前用户是否在
_online_users
中(如果不在就新增);- 然后就是,将信息发送给所有的在线用户(所有的在线用户都在
_online_users
中,遍历依次发送即可);- 最后,**用户如何退出呢?**这里就简单一些,如果用户发送的信息是
QUIT
,就表示用户要退出;用户退出,这里也显示输出一下哪个用户退出,在
InetAddr
中实现一个方法将IP地址和端口号转化为字符串。
要判断当前用户是否在_online_users
中,那我们封装的InetAddr
就要支持==
判断相等。(IP地址和端口号都相等才认为InetAddr
相等)
//InetAddrbool operator==(InetAddr &addr) { return _ip == addr._ip && _port == addr._port; }std::string ToString() { return _ip + ":" + std::to_string(_port); }
//Rounteclass 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
,绑定参数列表;让后再将任务入队列。
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
接收到信息之后,只需要调用回调函数将任务入队列,唤醒线程池中线程去执行即可。
//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
,我们也要修改为多线程版本,一个线程写,应该线程读
这个相对比较简单了,这里将所用到的sockfd
、server
端IP地址和端口号定义成全局方便使用
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;
}
当然,这里也可以将
sockfd
、InetAddr
封装成一个结构体,通过参数传递给新线程。
到这里本篇文章内容就结束了,感谢各位大佬的支持
我的博客即将同步至腾讯云开发者社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=2oul0hvapjsws