前言
通过学习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
字典记载Load
,server
端现在可以创建Dict
对象;
但是英文和中文的映射_dict
在Dict
类内,我们在外部是无法直接访问_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_family
、sin_addr
和sin_port
)。
这里就对sin_addr
和sin_port
进行封装,在之后使用时,就可以自动化构建;(后续传参需要IP
和port
也可以直接传递封装好的对象)。
封装实现InetAddr
:
cpp
class InetAddr
{
public:
InetAddr(){}
private:
struct sockaddr_in _addr;
std::string _ip;
uint16_t _port;
};
而我们在调用bind
、sendto
、recvfrom
这些接口都需要传递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
,而创建套接字是server
端main
线程执行的,要让线程池中的线程去发送信息,那就要将sockfd
传递给线程(通过任务传参);- 此外,要发送信息,肯定也要将发送的信息传递进来吧。
- 最后,是不是也要知道这一条信息是谁发的啊(IP地址+端口号);所以,这里就使用封装的
InetAddr
来传递client
端的IP地址和端口号。
那该函数,该如何实现呢?
- 首先要维护所有在线用户,在发送信息之前,就要先判断当前用户是否在
_online_users
中(如果不在就新增);- 然后就是,将信息发送给所有的在线用户(所有的在线用户都在
_online_users
中,遍历依次发送即可);- 最后,**用户如何退出呢?**这里就简单一些,如果用户发送的信息是
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
,我们也要修改为多线程版本,一个线程写,应该线程读
这个相对比较简单了,这里将所用到的sockfd
、server
端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;
}
当然,这里也可以将
sockfd
、InetAddr
封装成一个结构体,通过参数传递给新线程。
到这里本篇文章内容就结束了,感谢各位大佬的支持
我的博客即将同步至腾讯云开发者社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=2oul0hvapjsws