上一章中,我们介绍了socket,以及TCP/UDP协议。这一章带大家实现几个UDP协议的网络服务。我们需要一个 服务端和一个客户端。
1.服务端实现
1.1socket函数
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
参数说明:
- domain:域,标识了这个套接字的通信类型(网络通信/本地通信),这个就是sockaddr结构体的前16个bit位。如果是本地通信就是AF_UNIX,如果是网络通信就是AF_INET。
- type:套接字提供的服务类型,常见的就是SOCK_STREAM和SOCK_DGRAM,如果我们是基于TCP协议的通信,就使用SOCK_STREAM,表示的是流式服务。如果我们是基于UDP协议通信的,就使用SOCK_DGRAM,表示的是数据包服务。
- protocol:创建套接字的类型(TCP/UDP),但是这个参数可以由前两个参数决定。所以通常设置为0
返回值:
成功会返回一个文件描述符,失败返回-1,错误码被设置
在系统的文件操作中,也会返回文件描述符,与socket返回的文件描述符不同的是,普通文件的文件缓冲区对应的是磁盘,而socket返回的文件描述符的文件缓冲区对应的是网卡。用户将数据写到缓冲区,由操作系统自动将缓冲区中的数据刷新到网卡中,网卡会负责将这个数据发送到对端主机上。
class UdpServer
{
public:
UdpServer()
{
// 创建套接字
_socket = socket(AF_INET, SOCK_DGRAM, 0);
if (_socket < 0)
{
std::cerr << "create socket error" << std::endl;
exit(1);
}
std::cout << "create socket success, socket: " << _socket << std::endl;
}
~UdpServer()
{
close(_socket);
}
private:
int _socket;
};
调用socket函数,就能创建一个套接字了,第一个参数我们填AF_INET,表示的是我们是网络通信。第二个参数我们填SOCK_DGRAM,表示我们是UDP服务(数据报)。由于一个进程启动时,默认会打开标准输入,标准输出,标准错误这三个文件描述符,而文件描述符的创建规则就是从0开始,向上找到第一个没有使用的,所以我们可以猜测以下_sock的值为3.
1.2bind函数
我们创建完套接字以后,也只是打开了一个文件。还没有将这个文件和网络关联起来,所以我们需要使用bind函数,将IP+port和文件绑定
#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数介绍:
- sockfd:socket函数的返回值。
- addr:通用结构体,包括协议家族,IP地址,端口号port
- addrlen:addr的长度
返回值介绍:
成功返回0,错误返回-1
sockaddr_in结构体
struct sockaddr_in
{
short int sin_family;
in_port_t sin_port;
struct in_addr sin_addr;
unsigned char sin_zero[8];
};
- sin_family:协议家族,表示通信类型(AF_INET网络通信,PF_INET本地通信)
- sin_port:端口号(网络字节序)
- sin_addr:IP地址。
- sin_zero:填充字段,让sizeof(sockaddr_in) = 16
既然第二步需要添加IP和端口号,所以我们要对代码修改一下,给UDP的构造函数添加port和str(IP的点分十进制表示形式)
class UdpServer
{
public:
UdpServer(const uint16_t &port, const std::string &str = "")
{
// 创建套接字
_socket = socket(AF_INET, SOCK_DGRAM, 0);
if (_socket < 0)
{
std::cerr << "create socket error" << std::endl;
exit(1);
}
std::cout << "create socket success, socket: " << _socket << std::endl;
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = inet_addr(str.c_str());
int n = bind(_socket, (sockaddr *)&addr, sizeof(addr));
if (n < 0)
{
std::cout << "bind error" << std::endl;
exit(2);
}
std::cout << "bind success " << std::endl;
}
~UdpServer()
{
close(_socket);
}
private:
int _socket;
};
需要注意的是在填写sockaddr_in的参数的时候,
- sin_family需要和socket套接字创建时的domain相同。
- 由于端口号和IP将来是要发送到网络的,而网络数据流统一采用大端的形式,所以为了代码的可移植性,我们不管自己是大端还是小端,统一调用函数hton转化成大端。
- 对于IP来说,如果直接发送的是点分十进制形式如(192.168.12.80)的形式,则需要占用过多的字节数,对网络传输无疑是一种很大的消耗,所以我们采用一个32位的整数来表示IP,在网络传输中,要将点分十进制的IP转化成整数,再进行传输。
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int inet_aton(const char *cp, struct in_addr *inp);
in_addr_t inet_addr(const char *cp); //点分十进制转整数
in_addr_t inet_network(const char *cp);
char *inet_ntoa(struct in_addr in); //整数转点分十进制
struct in_addr inet_makeaddr(int net, int host);
in_addr_t inet_lnaof(struct in_addr in);
in_addr_t inet_netof(struct in_addr in);
这里重点学习两个,inet_addr和inet_ntoa。
1.3运行服务器
首先我们要知道,服务器就是一个死循环,在启动之后永远不会退出。
#include <iostream>
#include <memory>
#include "UdpServer.hpp"
void Usage()
{
std::cout << "Please enter: ./UdpServer [ip] port" << std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 3)
{
Usage();
return 3;
}
std::string ip = argv[1];
uint16_t port = atoi(argv[argc - 1]);
std::unique_ptr<UdpServer> p(new UdpServer(port, ip));
p->Start();
return 0;
}
我们可以使用命令行参数的形式,在启动这个程序时就把IP和端口号传递过去。
1.4IP的绑定
ifconfig操作系统中用于配置和显示网络接口参数的命令行工具。它主要用于查看和设置网络接口(如以太网和 Wi-Fi)的详细信息,包括 IP 地址、子网掩码、广播地址、MAC 地址等。
这里的127.0.0.1表示的时本地环回,用于在本地进行测试,如果本地测试成功,将来网络测试出现问题,那么大概率就网络的问题。本地换回顾名思义就是只会在本地不会发送到网络当中。
当我们运行上面的程序的时候,也可以时netstat函数查看网络情况,
- -a或--all 显示所有连线中的Socket。
- -l或--listening 显示监控中的服务器的Socket。
- -n或--直接使用IP地址(数字),而不是域名。
- -p或--显示正在使用Socket的程序识别码和程序名称。
- -t或--tcp 显示TCP传输协议的连线状况。
- -u或--udp 显示UDP传输协议的连线状况。
我们可以看到确实能看到我们刚刚运行的程序成功bind了一个IP和端口号。
- 但是在实际情况下,服务器时不建议绑定一个固定IP的。
- 安全性:攻击者可能会针对这个固定的IP地址进行攻击,比如DDoS攻击或者其他类型的网络攻击。相比之下,使用动态IP或者负载均衡等技术可以更好地分散攻击,提高系统的安全性。
- 可用性:在某些情况下,固定IP可能并不总是可用的。例如,在云服务环境中,IP地址可能会因为服务器的迁移或重新部署而发生变化。如果服务端绑定到这样的固定IP,当IP地址发生变化时,服务端可能无法正常工作,导致服务中断。
- 一个服务器可能会有多个IP(多张网卡),当客户端发送数据时,每张网卡都会收到该数据,如果我们想访问指定的某一个端口8080,并且如果指定了IP,那么只能由那一个指定的IP接受数据,但是如果我们绑定的是任意IP,那么只要是发送给8080端口的,任意一个网卡接受到了都会向上交付给服务器。
addr.sin_addr.s_addr = str.empty() ? INADDR_ANY : inet_addr(str.c_str());
所以我们修改一下,如果创建服务端的时候,传了IP,那就用传的IP,如果没用,那就用我们INADDR_ANY其实就是(0.0.0.0),绑定之后,只要是发送到这台主机上,端口号为XXX(我这里是8080)的,就将数据全部交给这个进程。
1.5读取数据recvfrom
#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);
参数介绍:
- sockfd:从哪个套接字中读取
- buf:读到的数据存放的缓冲区
- len:读len个字节
- flags:读取方式,0表示阻塞读取
- src_addr:发送端的信息
- addrlen:输出型参数,表示src_addr的长度(必须初始化为sizeof(src_addr))
void Start()
{
while (true)
{
char temp[1024];
sockaddr_in addr;
socklen_t addrlen = sizeof(addr);
int n = recvfrom(_socket, temp, sizeof(temp) - 1, 0, (sockaddr *)&addr, &addrlen);
std::string ip = inet_ntoa(addr.sin_addr);
uint16_t port = ntohs(addr.sin_port);
if (n > 0)
{
printf("[%s:%d]# %s", ip.c_str(), port, temp);
}
}
}
src_addr用来保存发送端的信息,我们在前面使用bind函数的时候,也介绍过sockaddr结构,里面包含了发送端的IP和端口port,但是因为是网络序列,我们需要将他转化成主机序列,所以使用ntoh函数,ip是32位整数,我们想转化成点分十进制形式方便观看,于是可以使用inet_ntoa函数。
1.6服务端整体代码
#pragma once
#include <iostream>
#include <string>
#include <unordered_set>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
class UdpServer
{
public:
UdpServer(const uint16_t &port, const std::string &str = "")
{
// 创建套接字
_socket = socket(AF_INET, SOCK_DGRAM, 0);
if (_socket < 0)
{
std::cerr << "create socket error" << std::endl;
exit(1);
}
std::cout << "create socket success, socket: " << _socket << std::endl;
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = str.empty() ? INADDR_ANY : inet_addr(str.c_str());
int n = bind(_socket, (sockaddr *)&addr, sizeof(addr));
if (n < 0)
{
std::cout << "bind error" << std::endl;
exit(2);
}
std::cout << "bind success " << std::endl;
}
void Start()
{
while (true)
{
char temp[1024];
sockaddr_in addr;
socklen_t addrlen = sizeof(addr);
int n = recvfrom(_socket, temp, sizeof(temp) - 1, 0, (sockaddr *)&addr, &addrlen);
std::string ip = inet_ntoa(addr.sin_addr);
uint16_t port = ntohs(addr.sin_port);
if (n > 0)
{
printf("[%s:%d]# %s", ip.c_str(), port, temp);
}
}
}
~UdpServer()
{
close(_socket);
}
private:
int _socket;
};
2.客户端实现
服务端的创建和客户端类似。我们也需要创建套接字
2.1创建套接字
void Usage()
{
std::cout << "Please enter: ./Client [ip] port" << std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage();
return 3;
}
std::string ip = argv[1];
uint16_t port = atoi(argv[argc - 1]);
// 创建套接字
int clientsocket = socket(AF_INET, SOCK_DGRAM, 0);
if (clientsocket < 0)
{
std::cerr << "create socket error" << std::endl;
exit(1);
}
std::cout << "create socket success, socket: " << clientsocket << std::endl;
return 0;
}
2.1绑定问题
客户端必须绑定IP和端口,但是不用显示的bind。
这句话是什么意思呢,就是我们需要将网卡文件和IP+端口进行绑定,但是不需要我们自己手动绑定,操作系统会自动帮我们绑定一个端口号。
为什么不能显示绑定呢,首先我们要知道,一个端口只能对应一个进程,现在有两家公司,各推出了一款客户端,如果他们想让端口不冲突,那就不能让自己家的客户端绑定的端口和对方的一样,如果一样就会起冲突,但是互联网上有很多家公司,难道都要协商一下,哪个端口谁来用吗。所以我们采用了让操作系统帮我们自动分配一个没有使用的端口号。
当我们调用了类似与sendto(发送信息)的函数的时候,操作系统会帮我们自动分配一个端口号,也就是说,客户端每次启动时的端口号可能都不相同。
- 服务端的端口号为什么是固定的
服务端的端口号是众所周知的,如果每次都不一样的话,客户端就找不到服务器了
2.3发送信息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表示阻塞写入
- src_addr:对端主机的信息(包括协议家族,IP,port)
- addrlen:表示src_addr的长度
参数和recvfrom类似
// 直接给服务器send,系统会自动帮我们bind
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = inet_addr(ip.c_str());
while (true)
{
std::string buffer;
std::cout << "Please enter# ";
std::getline(std::cin, buffer);
sendto(clientsocket, buffer.c_str(), buffer.size(), 0, (sockaddr *)&addr, sizeof(addr));
}
当我们将客户端发送消息的逻辑 完成之后就可以正常开始通信了。
我们先启动服务端,再启动客户端(两个不同的进程)
命令行中提示我们输入
当我们在右边客户端输入的消息之后,就能成功发送给服务端了。当我们再次使用netstat查看网络情况
当我们再次使用netstat查看网络情况,就可以看到客户端和服务端正在运行,并且能看到各自的端口号。
这样我们就实现了一个简单的服务端客户端模型,下面是源码:
2.4源码
UdpServer.hpp
//Server.hpp
#pragma once
#include <iostream>
#include <string>
#include <unordered_set>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
class UdpServer
{
public:
UdpServer(const uint16_t &port, const std::string &str = "")
{
// 创建套接字
_socket = socket(AF_INET, SOCK_DGRAM, 0);
if (_socket < 0)
{
std::cerr << "create socket error" << std::endl;
exit(1);
}
std::cout << "create socket success, socket: " << _socket << std::endl;
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = str.empty() ? INADDR_ANY : inet_addr(str.c_str());
int n = bind(_socket, (sockaddr *)&addr, sizeof(addr));
if (n < 0)
{
std::cout << "bind error" << std::endl;
exit(2);
}
std::cout << "bind success " << std::endl;
}
void Start()
{
while (true)
{
char temp[1024];
sockaddr_in addr;
socklen_t addrlen = sizeof(addr);
int n = recvfrom(_socket, temp, sizeof(temp) - 1, 0, (sockaddr *)&addr, &addrlen);
std::string ip = inet_ntoa(addr.sin_addr);
uint16_t port = ntohs(addr.sin_port);
if (n > 0)
{
temp[n] = 0;
printf("[%s:%d]# %s\n", ip.c_str(), port, temp);
}
}
}
~UdpServer()
{
close(_socket);
}
private:
int _socket;
};
Server.cc
#include <iostream>
#include <memory>
#include "UdpServer.hpp"
void Usage()
{
std::cout << "Please enter: ./UdpServer [ip] port" << std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 3 && argc != 2)
{
Usage();
return 3;
}
std::string ip = argv[1];
uint16_t port = atoi(argv[argc - 1]);
if (argc == 2)
ip = "";
std::unique_ptr<UdpServer> p(new UdpServer(port, ip));
p->Start();
return 0;
}
Client.cc
#include <iostream>
#include <memory>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
void Usage()
{
std::cout << "Please enter: ./Client [ip] port" << std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage();
return 3;
}
std::string ip = argv[1];
uint16_t port = atoi(argv[argc - 1]);
// 创建套接字
int clientsocket = socket(AF_INET, SOCK_DGRAM, 0);
if (clientsocket < 0)
{
std::cerr << "create socket error" << std::endl;
exit(1);
}
std::cout << "create socket success, socket: " << clientsocket << std::endl;
// 直接给服务器send,系统会自动帮我们bind
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = inet_addr(ip.c_str());
while (true)
{
std::string buffer;
std::cout << "Please enter# ";
std::getline(std::cin, buffer);
sendto(clientsocket, buffer.c_str(), buffer.size(), 0, (sockaddr *)&addr, sizeof(addr));
}
return 0;
}
3.更多功能
在前面我们学习了最基础的服务端客户端编写,让客户端给服务端发消息,服务端直接显示,我们可以让服务端在拿到数据后,对数据进行特殊处理。
void ExecuteCommand()
{
while (true)
{
char temp[1024];
sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
socklen_t addrlen = sizeof(addr);
int n = recvfrom(_socket, temp, sizeof(temp) - 1, 0, (sockaddr *)&addr, &addrlen);
std::string ip = inet_ntoa(addr.sin_addr);
uint16_t port = ntohs(addr.sin_port);
if (n > 0)
{
// 接收到数据
temp[n] = '\0';
std::string buffer = temp;
// 对数据做处理
// 将数据写回
sendto(_socket, buffer.c_str(), buffer.size(), 0, (sockaddr *)&addr, sizeof(addr));
}
}
}
我们可以设计出这样一个函数,就是前面的Start,但是在拿到数据后,先将数据进行处理,再将数据发送给客户端。我们下面要完成的就是这个对数据处理的函数。
3.1回显echo
很简单,我们收到用户的数据之后,并不需要处理直接返回即可
void ExecuteCommand()
{
while (true)
{
char temp[1024];
sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
socklen_t addrlen = sizeof(addr);
int n = recvfrom(_socket, temp, sizeof(temp) - 1, 0, (sockaddr *)&addr, &addrlen);
std::string ip = inet_ntoa(addr.sin_addr);
uint16_t port = ntohs(addr.sin_port);
if (n > 0)
{
// 接收到数据
temp[n] = '\0';
std::string buffer = temp;
// 对数据做处理
EchoMessage(buffer);
// 将数据写回
sendto(_socket, buffer.c_str(), buffer.size(), 0, (sockaddr *)&addr, sizeof(addr));
}
}
}
void EchoMessage(std::string &buffer)
{
return;
}
注意,这里的EchoMessage函数虽然什么都没有做,但是,为了突出用户发的信息是被处理过的,我们还是添加了一个函数。
也是可以成功运行。
3.2大写转换
将客户端发送的小写字母转化成大写字母
void ExecuteCommand()
{
while (true)
{
char temp[1024];
sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
socklen_t addrlen = sizeof(addr);
int n = recvfrom(_socket, temp, sizeof(temp) - 1, 0, (sockaddr *)&addr, &addrlen);
std::string ip = inet_ntoa(addr.sin_addr);
uint16_t port = ntohs(addr.sin_port);
if (n > 0)
{
// 接收到数据
temp[n] = '\0';
std::string buffer = temp;
// 对数据做处理
//EchoMessage(buffer);
Transformed(buffer);
// 将数据写回
sendto(_socket, buffer.c_str(), buffer.size(), 0, (sockaddr *)&addr, sizeof(addr));
}
}
}
void Transformed(std::string &buffer)
{
for (auto &ch : buffer)
{
if ('a' <= ch && ch <= 'z')
{
ch -= 32;
}
}
return;
}
我们只需要将调用的函数替换一下,就能完成不同的业务。
3.3英译汉词典
将用户发送的英文转化成汉语
我们先创建一个哈希表,保存英语单词以及对应的意思
static std::unordered_map<std::string, std::string> Dict;
void TranslationInit()
{
Dict.insert({"apple", "苹果"});
Dict.insert({"pear", "梨子"});
Dict.insert({"banana", "香蕉"});
Dict.insert({"orange", "橘子"});
Dict.insert({"left", "左"});
Dict.insert({"right", "右"});
Dict.insert({"sun", "太阳"});
Dict.insert({"moon", "月亮"});
}
void ExecuteCommand()
{
while (true)
{
char temp[1024];
sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
socklen_t addrlen = sizeof(addr);
int n = recvfrom(_socket, temp, sizeof(temp) - 1, 0, (sockaddr *)&addr, &addrlen);
std::string ip = inet_ntoa(addr.sin_addr);
uint16_t port = ntohs(addr.sin_port);
if (n > 0)
{
// 接收到数据
temp[n] = '\0';
std::string buffer = temp;
// 对数据做处理
//EchoMessage(buffer);
//Transformed(buffer);
Translation(buffer);
// 将数据写回
sendto(_socket, buffer.c_str(), buffer.size(), 0, (sockaddr *)&addr, sizeof(addr));
}
}
}
void Translation(std::string &buffer)
{
auto it = Dict.find(buffer);
if (it == Dict.end())
{
buffer = "not find";
}
else
{
buffer = it->second;
}
}
3.4执行命令
执行用户发送的shell命令,例如pwd,ls等。
我们介绍一个函数
#include <stdio.h>
FILE *popen(const char *command, const char *type);
int pclose(FILE *stream);
popen函数会fork创建子进程,并且让子进程程序替换执行command命令,最终把结果写到一个文件当中。type就是以什么方式打开这个文件(w/r/a)。
std::unordered_set<std::string> forbid = {
"rm",
"mv",
"kill",
"cp"
};
void ExecuteCommand(std::string &buffer)
{
// 查询有无禁止命令
if (!sercharforbid(buffer))
{
buffer = "you can't do that\n";
return;
}
// 使用popen执行用户命令,并将结果写入fp中
if (buffer == "ll")
{
buffer = "ls -l --color=auto";
}
FILE *fp = popen(buffer.c_str(), "r");
if (fp == nullptr)
{
buffer = "command is unknow";
return;
}
// 读取信息
buffer.clear();
char temp[1024];
while (fgets(temp, sizeof(temp), fp) != NULL)
{
buffer += temp;
}
pclose(fp);
}
bool sercharforbid(const std::string &buffer)
{
for (auto com : forbid)
{
int pos = buffer.find(com);
if (pos != std::string::npos)
{
return false;
}
}
return true;
}
我们将一些命令保存在哈希桶中,并且不让用户执行这些命令,例如rm删除之类。
3.5网络聊天室
将来会有很多用户加入这个聊天室,一个用户发送消息,能让其他用户都看到这条消息。
我们先创建一个类,这个类对sockaddr_in进行封装。
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
class InetAddr
{
public:
InetAddr(const sockaddr_in addr)
: _addr(addr)
{
_ip = inet_ntoa(addr.sin_addr);
_port = ntohs(addr.sin_port);
}
std::string GetUser()
{
std::string temp;
temp += _ip;
temp += " : ";
temp += std::to_string(_port);
return temp;
}
std::string& GetIp()
{
return _ip;
}
uint16_t GetPort()
{
return _port;
}
sockaddr_in& GetAddr()
{
return _addr;
}
private:
std::string _ip;
uint16_t _port;
sockaddr_in _addr;
};
这个类会提取出sockaddr_in中的IP和端口并保存,GetUser函数会返回一个IP+端口号的字符串。
我们再创建一个保存用户信息的哈希桶
std::unordered_map<std::string, InetAddr> _users;
void AddUser(InetAddr &user)
{
std::string userMessage = user.GetUser();
if (_users.find(userMessage) != _users.end())
{
return;
}
_users.insert({userMessage, user});
}
每当有用户发消息时,我们根据用户的sockaddr_in就能提取出这个用户的IP+端口,如果这个用户是第一次发消息,我们就把他的信息保存起来。
void Route(size_t sock, std::string message)
{
//将message发送给每一个用户
for (auto user : _users)
{
sockaddr_in addr = user.second.GetAddr();
sendto(sock, message.c_str(), message.size(), 0, (sockaddr*)&addr, sizeof(addr));
}
}
最后再根据哈希桶中保存的用户信息,就能再将数据发送给每一个用户了。
我们就完成了一个简单的网络聊天室,但是我们通过实验会发现还存在很大的问题,由于我们的服务端是单线程阻塞式读取,所以当别人发送数据的时候,我们可能正在阻塞,并不会显示数据,只有在写之后,数据才会重新打印出来。
上面我们的测试也能反应这一点,我们的执行顺序是,从上至下从左至右,每次发一条消息,一共两次。可以看到第二次左上那个进程才收到了右上第一次发的消息,这很显然不复合实际中的网络通信。
所以我们需要将服务端改成线程池版本,服务端改成两个线程(一个读一个写)
#pragma once
#include <iostream>
#include <unordered_set>
#include <unordered_map>
#include <functional>
#include <unistd.h>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "LogMessage.hpp"
#include "ExistReason.hpp"
#include "ThreadPool.hpp"
#include "LocalGuard.hpp"
#include "Pthread.hpp"
#include "InetAddr.hpp"
using task_t = std::function<void()>;
class ChatServer
{
public:
ChatServer(const uint16_t &port, const std::string &str = "")
{
// 创建套接字
_socket = socket(AF_INET, SOCK_DGRAM, 0);
if (_socket < 0)
{
Log::LogMessage(Error, "create socket error");
exit(CREATE_SOCKET_ERROR);
}
Log::LogMessage(Debug, "create socket success, socket: %d", _socket);
// 进行bind
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = str.empty() ? INADDR_ANY : inet_addr(str.c_str());
int n = bind(_socket, (sockaddr *)&addr, sizeof(addr));
if (n < 0)
{
Log::LogMessage(Error, "bind error");
exit(BIND_ERROR);
}
Log::LogMessage(Debug, "bind success");
pthread_mutex_init(&_user_mutex, nullptr);
ThreadPool<task_t>::GetInstance()->Start();
}
~ChatServer()
{
pthread_mutex_destroy(&_user_mutex);
close(_socket);
}
void Start()
{
while (true)
{
char temp[1024];
sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
socklen_t addrlen = sizeof(addr);
int n = recvfrom(_socket, temp, sizeof(temp) - 1, 0, (sockaddr *)&addr, &addrlen);
temp[n] = 0;
InetAddr user(addr);
if (n > 0)
{
// 将用户信息保存起来
AddUser(user);
std::string message = "[";
message += user.GetUser();
message += "] ";
message += temp;
// 将任务push到队列当中
task_t task = std::bind(&ChatServer::Route, this, _socket, message);
ThreadPool<task_t>::GetInstance()->Push(task);
}
else if (n == 0)
{
// 对端关闭连接
Log::LogMessage(Debug, "close connection");
}
else
{
Log::LogMessage(Warning, "server recvfrom warning");
}
}
}
private:
void AddUser(InetAddr &user)
{
LockGuard lock(&_user_mutex);
std::string userMessage = user.GetUser();
if (_users.find(userMessage) != _users.end())
{
return;
}
_users.insert({userMessage, user});
}
void Route(size_t sock, std::string message)
{
//将message发送给每一个用户
LockGuard lock(&_user_mutex);
for (auto user : _users)
{
sockaddr_in addr = user.second.GetAddr();
sendto(sock, message.c_str(), message.size(), 0, (sockaddr*)&addr, sizeof(addr));
}
}
private:
int _socket;
std::unordered_map<std::string, InetAddr> _users;
pthread_mutex_t _user_mutex;
};
我们使用线程池,提前创建好几个线程,将来只要有一个用户发消息了,就指派一个线程去处理数据(将这个信息发送给其他用户)。
客户端
#include <iostream>
#include <memory>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "LogMessage.hpp"
#include "ExistReason.hpp"
#include "Pthread.hpp"
void Usage()
{
std::cout << "Please enter: ./Client [ip] port" << std::endl;
}
struct ThreadDate
{
ThreadDate(int sock, sockaddr_in addr)
: _sock(sock), _addr(addr)
{
}
int _sock;
sockaddr_in _addr;
};
void Sender(ThreadDate date)
{
while (true)
{
std::string buffer;
std::cout << "Please enter# ";
std::getline(std::cin, buffer);
sendto(date._sock, buffer.c_str(), buffer.size(), 0, (sockaddr *)&date._addr, sizeof(date._addr));
if (sendto <= 0)
{
std::cerr << "send error" << std::endl;
}
}
}
void Recver(ThreadDate date)
{
char mes[1024];
while (true)
{
sockaddr_in add;
socklen_t addlen = sizeof(add);
int n = recvfrom(date._sock, mes, sizeof(mes) - 1, 0, (sockaddr *)&add, &addlen);
if (n > 0)
{
mes[n] = '\0';
std::cerr << mes << std::endl;
}
}
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage();
return USE_ERROR_MANUAL;
}
std::string ip = argv[1];
uint16_t port = atoi(argv[argc - 1]);
// 创建套接字
int clientsocket = socket(AF_INET, SOCK_DGRAM, 0);
if (clientsocket < 0)
{
exit(1);
}
// 直接给服务器send,系统会自动帮我们bind
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = inet_addr(ip.c_str());
ThreadDate date(clientsocket, addr);
Thread<void, ThreadDate> sender(Sender, date);
Thread<void, ThreadDate> recver(Recver, date);
sender.Create();
recver.Create();
sender.Jion();
recver.Jion();
return 0;
}
主进程创建两个线程,一个线程进行等待,一个线程发送数据。
最终我们发现就解决了之前的问题,但是这样又有了新的问题,由于是多进程,向屏幕打印时会有出错。我们可以使用管道,注意一个细节,客户端的收消息进程在收到消息时打印使用的是cerr,我们只需要将标准错误(2号文件描述符)重定向到管道文件中,就能成功将读写分离。