🌎Udp协议Socket编程
本次socket编程需要使用到 日志文件,此为具体日志编写过程。以及 线程池,线程池原理比较简单,看注释即可。
文章目录:
[socket 常见 API](#socket 常见 API)
[netstat 命令](#netstat 命令)
🚀端口号概念
我们上网,无非就两种行为,1、把远端的数据拉回到本地 。2、把本地数据推送到远端。而大部分的网络行为都是用户触发的。而在计算机当中,进程代表着用户。进程可能是客户端服务,或者其他服务。
而我们通过网络将数据发给目标主机,实际上最终要落实到某个APP上,也就是某个进程上,比如,我们在刷抖音时,从网络中传输的视频数据不会出现在学习通的APP上,所以:
3、把数据发送到目标主机上,不是目的,而是手段 。真正目的是为了交给主机上的某一个服务(进程)
当数据解包到最后一层时,需要将数据继续向上分用,只不过这次分用是把数据发送到对应的服务当中。而这个时候,我们就需要知道自己的数据是发送给哪一个服务的。
-
所以服务必须要有自己的唯一标识符,我们称为 端口号。
-
端口号是一个 2 字节 16 位的整数。
-
端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理。
-
IP地址用来表示网络中的唯一主机,IP 地址 + 端口号 能够 标识网络上的某一台主机的某一个进程。
-
一个端口号只能被一个进程占用
所以IP地址+端口号表示当前服务是在互联网当中的唯一的进程。那么在互联网当中,我们就能找到唯一的彼此,我们只需要这四样信息就可以找到对端主机 {src ip, src port dst ip, dst port} , 通过之前的学习,我们其实就已经了解网络通信的本质:网络通信的本质就是进程间通信。
传输层上层就是应用层了,所以在这一层会对数据帧进行解包和分用,而分用到哪个服务就不得而知了,所以在传输层,是一定需要知道每个服务的端口号的。
端口号也是有划分的,它们具体的划分如下:
- 0 - 1023 : 知名端口号, HTTP, FTP, SSH 等这些广为使用的应用层协议, 他们的端口号都是固定的。
- 1024 - 65535 : 操作系统动态分配的端口号. 客户端程序的端口号, 就是由操作系统从这个范围分配的.
🚀网络字节序
我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分, 磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分, 网络数据流同样有大端小端之分. 那么如何定义网络数据流的地址呢?
- 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出。
- 接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存 , 因此,网络数据流的地址应这样规定: 先发出的数据是低地址,后发出的数据是高地址。
- TCP/IP 协议规定, 网络数据流应采用大端字节序,即低地址高字节。
- 不管这台主机是大端机还是小端机, 都会按照这个 TCP/IP 规定的网络字节序来发送/接收数据。
- 如果当前发送主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接发送即可。
为使网络程序具有可移植性,使同样的 C 代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换。
- 这些函数名很好记,h 表示 host, n 表示 network, l 表示 32 位长整数, s 表示 16 位短整数。例如 htonl 表示将 32 位的长整数从主机字节序转换为网络字节序,例如将 IP 地址转换后准备发送。
- 如果主机是小端字节序, 这些函数将参数做相应的大小端转换然后返回。
- 如果主机是大端字节序, 这些函数不做转换,将参数原封不动地返回。
🚀Socket编程接口
✈️浅谈Tcp/Udp网络协议
TCP与UDP协议都属于传输层协议,不同的是TCP给上层提供可靠性服务,而UDP并不能提供可靠性服务。
既然TCP协议需要保证传输的可靠性,那么也就意味着,TCP实现过程是更为复杂的,接口也会更多一点。相反,udp协议不论在接口还是在实现上都会更简单。
一般tcp协议被用于对可靠性要求高的服务,比如支付,转账,网页获取等服务。而udp一般被用于视频推送,直播等。我们目前只需要了解这些即可,因为在深入我们的知识体系不足以了解。
✈️socket 常见 API
cpp
// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)
int socket(int domain, int type, int protocol);
// 绑定端口号 (TCP/UDP, 服务器)
int bind(int socket, const struct sockaddr *address,socklen_t address_len);
// 开始监听 socket (TCP, 服务器)
int listen(int socket, int backlog);
// 接收请求 (TCP, 服务器)
int accept(int socket, struct sockaddr* address,socklen_t* address_len);
// 建立连接 (TCP, 客户端)
int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
Socket编程是有不同种类的,有的是专门用来进行本地通信的,有的是用来进行跨网络通信的。有的是进行网络管理的。而这么多种种类的通信方式,实在是让人头大,所以网络设计者为了方便管理这些接口,就将接口进行统一管理:也就形成了Socketaddr结构。
- IPv4 和 IPv6 的地址格式定义在 netinet/in.h 中,IPv4 地址用 sockaddr_in 结构体表示,包括 16 位地址类型, 16 位端口号和 32 位 IP 地址。
- IPv4、IPv6 地址类型分别定义为常数 AF_INET、AF_INET6. 这样,只要取得某种 sockaddr 结构体的首地址,不需要知道具体是哪种类型的 sockaddr 结构体,就可以根据地址类型字段确定结构体中的内容。
- socket API 可以都用 struct sockaddr *类型表示, 在使用的时候需要强制转化成sockaddr_in; 这样的好处是程序的通用性, 可以接收 IPv4, IPv6, 以及 UNIX Domain Socket 各种类型的 sockaddr 结构体指针做为参数。
将来我们需要哪种种类的sockaddr,直接进行强制类型转换为对应的类型即可拿到想要的通信类型。而这种方式有没有点熟悉?没错就是多态,我们再次见识到了C语言实现多态的案例!
🚀 UDP套接字编写
✈️通信所需接口
我们先来编写最简单的udp套接字实现,创建套接字的接口:
cpp
int socket(int domain, int type, int protocol);
函数参数及返回值:
- domain参数 :表示地址族,指定使用的网络通信或其他通信协议类型。常使用 AF_INET 选项,表示网络通信。
domain可选项:
- type参数 : 套接字类型,指定套接字的通信方式。
type可选项:
最常用的两个选项:
SOCK_STREAM :面向连接的流套接字(使用TCP协议) 。
SOCK_DGRAM :无连接的数据报套接字(使用UDP协议)。
- protocol参数 :指定协议,通常为0表示系统根据type和domain选择合适的协议。
- 返回值 :文件描述符。
当我们创建完套接字,在OS层面就相当于打开了一个文件,现在需要把文件信息发送到别的主机,而 socket = IP + Port , 所以我们需要将创建的套接字与网络信息关联起来,称为网络中的唯一标识,Linux给我们提供了一个接口 bind
:
cpp
int bind(int sockfd, const struct sockaddr* addr, socklen_t addrlen);// 绑定
- sockfd参数 :文件描述符。
- addr参数 :指向 sockaddr 结构的指针,表示要绑定的地址信息。它可以是多种地址结构的指针,通常使用 sockaddr_in作为具体的地址结构体。具体结构体中会包含IP地址和端口号信息。
- addrlen参数 :指定 addr 指向的地址结构体的大小(字节数)。通常使用 sizeof(struct sockaddr_in)来获取具体的大小。这个参数是为了让 bind() 函数能够验证传入的地址结构的长度。
- 返回值 :0表示绑定成功,-1表示失败,并设置错误码。
✈️初始化UDP服务器
我们开始正式编写udp通信代码:
cpp
#pragma once
#include <iostream>
#include <cerrno>
#include <cstring>
#include <strings.h>
#include <string>
#include <arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>
#include "Log.hpp"
enum
{
SOCKET_ERROR = 1,
};
const static int defaultfd = -1;
class UdpServer
{
public:
UdpServer(uint16_t port):_sockfd(defaultfd), _port(port)
{}
void InitServer()
{}
void Start()
{}
~UdpServer()
{}
private:
int _sockfd;
uint16_t _port;// 服务器所用
};
这是一个udp通信的一个框架,要建立起网络通信,则需要先创建套接字,在未来,我们希望初始化UpdServer时,udp已经准备好了,随时可以发送,那么在初始化阶段我们就需要把准备工作做完,首先创建套接字:
cpp
void InitServer()
{
// 创建udp socket 套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd < 0)
{
LOG(FATAL, "socked error, %s, %d", strerror(errno), errno);
exit(SOCKET_ERROR);
}
}
创建完套接字,我们就需要将套接字与网络信息进行绑定,而绑定我们一定是需要bind接口来绑定的,而bind参数中有一个 sockaddr结构体指针参数,其表示待填充的网络信息。所以在bind之前,我们需要将sockaddr_in结构体进行填充。
其中,sockaddr_in结构体对象有四个待填充字段:
其中sin_family字段直接使用AF_INET字段填充,sin_port字段记录的是端口号信息,本地的端口号信息将来一定会转化为主机序列,所以我们需要对端口号进行主机序列到网络序列的转换,而端口号一般为16位,所以我们使用 htons 接口进行转换。
sin_addr需要填充的是本地的IP地址,前面我们说了IP地址是以点分十进制组成的一串数字,比如 192.168.0.1 表示本机IP,其中IP地址中最小地址为 0.0.0.0 最大为 255.255.255.255 这种表示方式是适合人观看的,并不适合计算机网络之间的通信,如果是这种字符串形式的ip那么占用字节数会很多,所以我们需要先将点分十进制的ip转换为4字节ip,再进行字段填充。
如何填充呢?我们可以自己设计一个结构体,记录四个字节的每个字节,以"."为分隔符对字符串进行扫描,可以使用stoi接口转化。但是今天我们并不需要自己手动实现,因为Linux给我们提供了对应的接口 inet_addr
:
cpp
in_addr_t inet_addr(const char *cp);// 将ip字符串转换为四字节ip
- cp参数 :待转化的字符串式IP。
sin_addr实际上还嵌套了一层结构体 s_addr:
cpp
void InitServer()
{
// 创建udp socket 套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd < 0)
{
LOG(FATAL, "socked error, %s, %d", strerror(errno), errno);
exit(SOCKET_ERROR);
}
LOG(INFO, "socket create success, sockfd: %d", sockfd);
// 填充sockaddr_in 结构
struct sockaddr_in local;// 在用户栈空间中保存
bzero(&local, sizeof(local));// 将结构体内容全部清空,以便于下面的填充,这里的bzero可以使用memset平替
local.sin_family = AF_INET;
local.sin_port = htons(_port);// port一定是要经过网络传送的,先到网络,_port:主机序列,需要转化为网络序列
local.sin_addr.s_addr = inet_addr(_ip.c_str());
}
这样,我们就将sockaddr_in结构体填充完毕,而为什么不见我们的sin_family字段填充呢?实际上:
这个字段在创建sockaddr_in对象时就已经被内置了,所以我们不需要进行填充。那么接下来就是进行绑定工作:
cpp
enum
{
SOCKET_ERROR = 1,
BIND_ERROR,
};
void InitServer()
{
// 创建udp socket 套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd < 0)
{
LOG(FATAL, "socked error, %s, %d", strerror(errno), errno);
exit(SOCKET_ERROR);
}
LOG(INFO, "socket create success, sockfd: %d", sockfd);
// 填充sockaddr_in 结构
struct sockaddr_in local;
bzero(&local, sizeof(local));// 将结构体内容全部清空,以便于下面的填充
local.sin_family = AF_INET;
local.sin_port = htons(_port);// port一定是要经过网络传送的,先到网络,_port:主机序列,需要转化为网络序列
local.sin_addr.s_addr = inet_addr(_ip.c_str());
// bind sockfd和网络信息(ip + port)
int n = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
if(n < 0)// 绑定失败
{
LOG(FATAL, "bind error, %s, %d", strerror(errno), errno);
exit(BIND_ERROR);
}
LOG(INFO, "bind success");
}
至此,我们udp通信初始化工作就做完了。
✈️netstat 命令
netstat 是一个 用来查看网络状态的重要工具。
- 语法 :netstat [选项]
- 功能 :查看网络状态
常用选项:
- n 拒绝显示别名,能显示数字的全部转化成数字
- l 仅列出有在 Listen (监听) 的服務状态
- p 显示建立相关链接的程序名
- t (tcp)仅显示 tcp 相关选项
- u (udp)仅显示 udp 相关选项
- a (all)显示所有选项,默认不显示 LISTEN 相关
以udp服务举例,我们经常使用 netstat -puan
命令来查看系统中使用udp服务的进程。
启动服务器
我们的常识告诉我们,服务器都是一直运行的,所以,启动服务服务器一定是死循环,所以我们来类成员函数添加一个标记为,记录当前服务器是否启动。
cpp
void Start()
{
_isrunning = true;
while(true)
{
sleep(1);
LOG(DEBUG, "server is running ...\n");
}
_isrunning = false;
}
我们希望在运行时,是以 ./udpserver ip port 的形式运行,所以我们需要对参数做一个判断,并将参数中的ip及port取出:
cpp
#include <iostream>
#include <memory>
#include "Log.hpp"
#include "UdpServer.hpp"
void Usage(std::string proc)
{
std::cout << "Usage:\n\t" << proc << " local_ip local_port\n" << std::endl;
}
// ./udpserver ip port
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(USAGE_ERROR);
}
EnableFile();// 向显示器打印
std::string ip = argv[1];
uint16_t port = std::stoi(argv[2]);
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(ip, port);
usvr->InitServer();
usvr->Start();
return 0;
}
这样start服务器就完成了,下图为具体运行图:
这个时候我们可以通过 netstat
命令查看网络状态:
启动服务器是一定需要接收数据与发送数据的,在网络编程中,我们 收到数据 使用 recvfrom
接口:
cpp
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
- sockfd参数 :套接字描述符,指示要接收数据的套接字。
- buf参数 :指向一个缓冲区的指针,用于存放接收到的数据。
- len参数 :指定要接收的最大字节数。这个值应该等于或大于 buf 所指向的缓冲区的大小。
- flags参数 :接收标志,通常情况下可以设置为 0。
- src_addr参数 :指向一个 sockaddr 结构的指针,用于存放发送方的地址信息。
- addrlen参数 :指向一个 socklen_t 类型的变量,该变量初始应该设置为 src_addr 所指向结构的大小。在函数调用后,它将被填充为实际发送方地址的大小。
- 返回值 :成功返回接收到的字节数,失败返回-1并设置错误码。
两台主机之间想要进行通信,那么一方接收数据就必须知道是谁给他发的,如何知道呢?通过 IP + 端口号 。所以我们就可以通过socket 得到IP和端口号,这些属于计算机的身份信息,我们在收数据的时候,src_addr 参数就已经将我们的身份信息填充完毕了,我们就可以开始收数据了:
cpp
void Start()
{
_isrunning = true;
while(true)
{
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// 要让server 先收数据
ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&peer, &len);
if(n > 0)
{
buffer[n] = 0;
LOG(DEBUG, "get message: %s\n", buffer);
// 我们要将server收到的数据,发回给对方
}
}
_isrunning = false;
}
server收到数据之后,需要将数据发送给对方主机,在计算机网络中,我们使用sendto
接口进行发送信息:
cpp
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
此函数参数及返回值含义与recvfrom接口相同,除了 addrlen这里不需要取地址。那么接收数据之后,我们就需要发送数据到对端:
cpp
void Start()
{
_isrunning = true;
while(true)
{
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// 要让server 先收数据
ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&peer, &len);
if(n > 0)
{
buffer[n] = 0;
LOG(DEBUG, "get message: %s\n", buffer);
// 我们要将server收到的数据,发回给对方
sendto(_sockfd, buffer, strlen(buffer), 0, (struct sockaddr*)&peer, len);
}
}
_isrunning = false;
}
这样,在服务器端数据的收发都已经做好了。但是我们现在缺少客户端来响应。
✈️Udp客户端
客户端需也是一个独立运行的程序,而客户端想要连接服务器端,就必须使用服务器的ip+port,所以在命令行我们依旧使用server形式:
cpp
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
void Usage(std::string proc)
{
std::cout << "Usage:\n\t" << proc << " serverip serverport\n" << std::endl;
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
return 0;
}
客户端将来是要给服务器端发送消息的,所以客户端也一定是需要进行网络通信的,那么同样也需要创建网络套接字:
cpp
// 创建socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd < 0)
{
std::cerr << "socket error" << std::endl;
}
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_port = htons(serverport);
server.sin_addr.s_addr = inet_addr(serverip.c_str());
这里我们并没有把客户端套接字与ip和port进行bind,这是因为OS已经帮助我们绑定过了。操作系统是如何绑定的呢?
客户端既然需要绑定,那么必然会和客户端的端口号绑定,但是客户端与服务器端不同的是,客户端可能不止访问你一个服务,比如手机上有各种APP不能说你今天使用了某音,以后就只能打开某音了。这是不允许的,所以客户端是不需要手动的去进行 bind,而是OS会绑定随机的port,这样就能防止client端port冲突。
所以有些人说客户端不能绑定套接字,并不是一个完整的说法,正确的说法是 客户端必须得绑定套接字,不过这一步是OS帮助我们完成的。OS在客户端第一次发数据的时候对套接字进行绑定。
后面就可以进行常规的通信操作了:
cpp
std::string message;
while(true)
{
std::cout << "Please Enter# ";
std::getline(std::cin, message);
sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&server, sizeof(server));
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
char buffer[1024];
ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&peer, &len);
if(n > 0)
{
buffer[n] = 0;
std::cout << "server echo# " << buffer << std::endl;
}
}
客户端代码简单编写完毕,这个时候客户端与服务器端就可以进行通信了:
而将来我们需要其进行网络通信,所以我们今天不绑定本地环回ip了,我们绑定我们的云服务器公网ip:
这个时候我们才发现,我们不能绑定公网ip。这是因为云服务器不能直接绑定公网ip,这里也 不推荐绑定公网ip或者任何一个确定的ip(除非本地环回ip做测试或者虚拟机)。
这是因为,如果客户端绑定了一台机器的公网ip和端口号,那么如果其他客户端想要通过内网ip访问相同端口的服务,就无法访问了,一个服务器通常不止一个ip地址,有公网ip,内网ip等ip地址,所以不能直接绑定这些ip与port。
那么我们该如何确定对端呢?我们ip地址替换为 0.0.0.0(或者0) 表示 绑定任意地址,这样,凡是发到这台机器的客户端无论使用的何种ip进行访问,只要端口号为目标端口号都可以进行绑定。
这样我们不仅可以通过本地环回ip访问,也可以通过公网ip访问了。但是这样我们并不知道到底是哪台主机给我们发的消息,所以我们可以进一步优化,封装一个带有 "主机名称的类"。
✈️确定客户端是谁
为了保证客户端不是匿名访问我们的ip,所以我们可以封装一个类对客户端进行管理,知晓到底是谁向这台主机发消息。
这个类的作用就是帮助我们获取IP与Port,我们可以这样设计这个类:
cpp
#pragma once
#include <arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <iostream>
class InetAddr
{
private:
void GetAddress(std::string *ip, uint16_t *port)
{
*port = ntohs(_addr.sin_port);
*ip = inet_ntoa(_addr.sin_addr);
}
public:
InetAddr(const struct sockaddr_in &addr): _addr(addr)
{
GetAddress(&_ip, &_port);
}
std::string IP()
{
return _ip;
}
uint16_t Port()
{
return _port;
}
~InetAddr()
{
}
private:
std::string _ip;
uint16_t _port;
struct sockaddr_in _addr;
};
由于获取地址获取的是32位uint32_t类型的地址,我们需要将其转化为字符串风格的地址,我们使用 inet_ntoa
接口来实现:
cpp
char *inet_ntoa(struct in_addr in);// 将字符串地址
typedef uint32_t in_addr_t;
struct in_addr {
in_addr_t s_addr;
};
这样,在UdpServer中添加对应的类对象,即可知道对方主机IP及占用端口号:
cpp
void Start()
{
_isrunning = true;
while(true)
{
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// 要让server 先收数据
ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&peer, &len);
if(n > 0)
{
buffer[n] = 0;
InetAddr addr(peer);
LOG(DEBUG, "get message from [%s:%d]: %s\n", addr.IP().c_str(), addr.Port(), buffer);
// 我们要将server收到的数据,发回给对方
sendto(_sockfd, buffer, strlen(buffer), 0, (struct sockaddr*)&peer, len);
}
}
_isrunning = false;
}
由此也可以看到,客户端绑定的端口号并不是固定的8888,是随机的。今天我们意识到IP地址在服务器端并不是必须得,因为我们使用0来接收任意IP,所以我们可以将服务器端代码简化,将IP定死为0,接收任意IP:
cpp
const static int defaultfd = -1;
class UdpServer
{
public:
UdpServer(uint16_t port)
:_sockfd(defaultfd)
, _port(port)
, _isrunning(false)
{}
void InitServer()
{
// 创建udp socket 套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd < 0)
{
LOG(FATAL, "socked error, %s, %d", strerror(errno), errno);
exit(SOCKET_ERROR);
}
LOG(INFO, "socket create success, sockfd: %d", _sockfd);
// 填充sockaddr_in 结构
struct sockaddr_in local;
bzero(&local, sizeof(local));// 将结构体内容全部清空,以便于下面的填充
local.sin_family = AF_INET;
local.sin_port = htons(_port);// port一定是要经过网络传送的,先到网络,_port:主机序列,需要转化为网络序列
// local.sin_addr.s_addr = inet_addr(_ip.c_str());
local.sin_addr.s_addr = INADDR_ANY;// 为主机序列,应htonl(INADDR_ANY),但是0在大小端都是一样的
// bind sockfd和网络信息(ip + port)
int n = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
if(n < 0)
{
LOG(FATAL, "bind error, %s, %d", strerror(errno), errno);
exit(BIND_ERROR);
}
LOG(INFO, "bind success");
}
void Start()
{
_isrunning = true;
while(true)
{
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// 要让server 先收数据
ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&peer, &len);
if(n > 0)
{
buffer[n] = 0;
InetAddr addr(peer);
LOG(DEBUG, "get message from [%s:%d]: %s\n", addr.IP().c_str(), addr.Port(), buffer);
// 我们要将server收到的数据,发回给对方
sendto(_sockfd, buffer, strlen(buffer), 0, (struct sockaddr*)&peer, len);
}
}
_isrunning = false;
}
~UdpServer()
{}
private:
int _sockfd;
// std::string _ip;// 不是必须的
uint16_t _port;// 服务器所用
bool _isrunning;// 是否启动服务器
};
将IP设置为0我们一般不直接将IP设置为0,我们使用 INADDR_ANY宏来表示接受任意地址,这个宏实际上就是0,并且其为主机序列,一般情况我们需要将其转化为网络序列,但是0的大小端都是0,不需要转化。
🚀UdpServer实现案例-翻译
通过以上的udp服务器端与客户端简单实现,我们已经能够让客户端与服务器端进行通信了,我们就基于此实现一个Udp服务的简单应用。
我们准备实现一个简单的翻译功能,进行中英文之间的翻译,如此,我们需要翻译数据,以及翻译部分代码。
✈️映射类
翻译文件信息:
c
apple: 苹果
banana: 香蕉
cat: 猫
dog: 狗
book: 书
pen: 笔
happy: 快乐的
sad: 悲伤的
run: 跑
jump: 跳
teacher: 老师
student: 学生
car: 汽车
bus: 公交车
love: 爱
hate: 恨
hello: 你好
goodbye: 再见
summer: 夏天
winter: 冬天
接着我们就需要建立一个类,用来处理中文与英文之间的映射,首先写出类的框架,我们把翻译文件信息单独放在一个txt文件内,所以程序每次使用时都需要将信息加载进来,这一步应该在初始化类对象时完成,也就是需要再构造函数中完成。
类内的功能只有一个就是翻译功能,我们观察翻译文件信息,左边英文中间冒号加空格,后边是中文,所以我们采用kv的方式进行映射,同样不能让冒号与空格干扰我们,所以把相同的分隔符提取出来,而kv结构则应该为类内成员,所以搭出的具体框架如下:
cpp
#pragma once
#include <iostream>
#include <unordered_map>
#include "Log.hpp"
#include <fstream>
#include <string>
namespace dict_ns
{
const std::string defaultpath = "./Dict.txt";// 配置文件默认路径
const std::string sep = ": ";// 字典分隔符
class Dict
{
private:
bool Load()
{}
public:
Dict(const std::string &path = defaultpath)
:_dict_conf_filepath(path)
{
Load();
}
std::string Translate(const std::string &word)
{}
~Dict()
{}
private:
std::unordered_map<std::string, std::string> _dict;// 映射kv
std::string _dict_conf_filepath;// 配置文件路径信息
};
} // namespace dict_ns
加载首先需要打开文件,所以需要使用到文件流,打开文件之后,我们需要对文件内容做处理,我们发现分隔符是固定的,所以每行信息在加载进来的时候,我们对每行的字符串信息进行切割,以分割符sep为界限,将中文与英文分割开,分割完毕把中文与英文放进kv结构中,这样中英文映射关系也就建立了。
cpp
bool Load()
{
std::ifstream in(_dict_conf_filepath);
if(!in.is_open())// 文件打开失败
{
LOG(FATAL, "open %s error\n", _dict_conf_filepath(path));
return false;
}
std::string line;
while(std::getline(in, line))
{
if(line.empty()) continue;
auto pos = line.find(sep);
if(pos == std::string::npos) continue;
std::string word = line.substr(0, pos);
if(word.empty()) continue;
std::string han = line.substr(pos+sep.size());
if(han.empty()) continue;
LOG(DEBUG, "Load info, %s: %s\n", word.c_str(), han.c_str());
_dict.insert(std::make_pair(word, han));
}
in.close();
LOG(DEBUG, "Load %s success\n", _dict_conf_filepath.c_str());
return true;
}
加载部分完成后,只剩下翻译工作没有做,我们在加载过程中已将将所有的单词与中文进行了kv映射,将来我们希望,调用此接口的用户传入英文单词,就会翻译出相应的汉语,所以我们对单词进行检索,如果有就返回汉语,没有就返回空串:
cpp
std::string Translate(const std::string &word)
{
auto iter = _dict.find(word);
if(iter == _dict.end())
{
return "";
}
return iter->second;
}
✈️业务模块与网络通信结合
我们已经将简单的业务模块编写完毕,接着就是将业务模块接入网络模块。我们要知道,所有的服务器本质都是要解决输入输出的问题,为了能体现网络的层状结构,所以不能让网络通信与业务进行强耦合,所以我们可以借助functional来处理业务与网络通信之间的关系。
我们在UdpServer中给服务器设置回调,用来让上层注册业务的处理方法,首先设置functional对Translate接口的参数进行绑定,以至于实现松耦合的方式实现调用:
cpp
// Dict类内
std::string Translate(const std::string &word, bool &ok)
{
ok = true;
auto iter = _dict.find(word);
if(iter == _dict.end())
{
ok = false;
return "未查询到 ...";
}
return iter->second;
}
// UdpServer类中
using func_t = std::function<std::string(const std::string&, bool &ok)>;// using关键字单独使用表示重命名与typedef 含义相同
我们服务器端负责相应用户的请求,所以在Start部分应该处理这一部分的请求,当Server端收到数据的时候,这个时候我们将用户发来的消息全部当做业务请求,而Start接口是公共部分,并没有权利来处理请求,所以这个时候应该将请求回调出去,在外部对数据进行处理,将收到的请求使用func_t进行回调外传:
cpp
void Start()
{
_isrunning = true;
while(true)
{
char request[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// 要让server 先收数据
ssize_t n = recvfrom(_sockfd, request, sizeof(request) - 1, 0, (struct sockaddr*)&peer, &len);
if(n > 0)
{
request[n] = 0;
InetAddr addr(peer);
LOG(DEBUG, "get message from [%s:%d]: %s\n", addr.IP().c_str(), addr.Port(), request);
bool ok;
std::string response = _func(request, ok);// 将请求回调出去,在外部进行数据处理
(void)ok;
// 我们要将server收到的数据,发回给对方
sendto(_sockfd, response.c_str(), strlen(response.c_str()), 0, (struct sockaddr*)&peer, len);
}
}
_isrunning = false;
}
这样,在main函数中的智能指针我们也需要进行更改,Translate接口中看似有两个参数,但是实际上却是有三个参数的,因为Translate接口是在Dict类内实现的接口,所以其第一个参数是隐藏的this指针,为了方便参数传递,我们可以将this指针进行绑定,这样我们以后每次进行回调的时候就不需要在构造一个匿名Dict对象传递了:
cpp
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(
port,std::bind(
&Dict::Translate, &dict
, std::placeholders::_1
, std::placeholders::_2
)
);
以上就完成了一个简单的基于Udp服务的网络翻译实例,下面是具体实践图:
🚀UdpServer实现案例-简单聊天室
我们可以基于UdpServer网络服务来开发一个简单的聊天室,又因为比较简洁所以我们不开前端页面了,我们直接在Linux终端进行多用户聊天室聊天,其中涉及到多用户,所以会涉及到多线程编程,虽然我们以前没有写多线程的博客,但是这个多线程理解起来并不困难。
当然,方案有许多种,我们采用的是比较简单的一种方案。在Udp服务当中,_socket是全双工的。通俗一点说就是 可以同时进行收消息和发消息。只不过在上面的代码中并没有体现,因为没有多线程的场景,而聊天室这个简单项目就会用到多线程,可以体现出_socket的全双工性质。
首先,服务器在收到消息之后,需要将消息转发给线程池,让线程池来做进一步的处理动作,所以首先我们需要先将消息转发模块完善,将其封装为一个类 MessageRoute,我们将来一定是需要处理并发场景的,所以进行通信的用户可能是多个,我们有必要对用户做管理,用户的增加和删除也在类内实现:
cpp
#pragma once
#include <iostream>
#include <vector>
#include <string>
#include <set>
#include <sys/types.h>
#include <sys/socket.h>
#include "ThreadPool.hpp"
#include "LockGuard.hpp"
#include "InetAddr.hpp"
using task_t = std::function<void()>;
class MessageRoute
{
public:
MessageRoute()
{}
void AddUser(const InetAddr& addr)// 增加用户
{}
void DelUser(const InetAddr& addr)// 删除用户
{}
void Route(int sockfd, std::string message, InetAddr who)
{}
~MessageRoute()
{
pthread_mutex_destroy(&_mutex);
}
private:
std::vector<InetAddr> _online_user;
};
以上使用vector对用户进行管理,在增加用户时则需要判断当前用户是否已经在vector内存在,才能继续添加,删除模块类似:
cpp
#pragma once
#include <iostream>
#include <vector>
#include <string>
#include <set>
#include <sys/types.h>
#include <sys/socket.h>
#include "ThreadPool.hpp"
#include "LockGuard.hpp"
#include "InetAddr.hpp"
using task_t = std::function<void()>;
class MessageRoute
{
private:
bool IsExists(const InetAddr &addr)
{
for(auto a : _online_user)
{
if(a == addr) return true;
}
return false;
}
public:
MessageRoute()
{}
void AddUser(const InetAddr& addr)// 增加用户
{
if(IsExists(addr))
return;
_online_user.push_back(addr);
}
void DelUser(const InetAddr& addr)// 删除用户
{
for(auto iter = _online_user.begin(); iter != _online_user.end(); iter++)
{
if(*iter == addr)
{
_online_user.erase(iter);
break;
}
}
}
void Route(int sockfd, std::string message, InetAddr who)
{}
~MessageRoute()
{
pthread_mutex_destroy(&_mutex);
}
private:
std::vector<InetAddr> _online_user;
};
// 因为InetAddr类是我们自定义类,之前并没有明确实现operator==, 所以我们需要在 InetAddr类内实现等于比较符:
bool operator == (const InetAddr &addr)
{
if(_ip == addr._ip && _port == addr._port)
{
return true;
}
return false;
}
当我们有了用户之后,我们就可以进行消息转发了,首先判断用户是不是新用户,如果是则添加,以及设计一个简单的退出模式,比如输入 "Q" 表示退出,退出成功就在vector内删除用户。随后就进行发送消息:
cpp
void Route(int sockfd, std::string message, InetAddr who)
{
// 我们任务,用户首次发消息,不光是发消息,还要将自己插入到在线用户当中
// 如果客户端要退出
if(message == "Q" || message == "QUIT")
{
DelUser(who);
}
// 进行消息转发
for(auto user : _online_user)
{
struct sockaddr_in addr = user.Addr();
::sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&addr, sizeof(addr));
}
}
但是这明显是一个单线程/进程的发送方式,而我们今天的聊天室是要支持多线程并发访问的,所以我们可以将发送消息的接口单独分离出来一个全新接口 RouteHelper,这个函数就是将来线程池回调的任务函数,而原本的Route接口用来处理线程池对任务的分配工作:
cpp
void RouteHelper(int sockfd, std::string message, InetAddr who)
{
AddUser(who);
// 进行消息转发
for(auto user : _online_user)
{
std::string send_message = "[" + who.IP() + ":" + std::to_string(who.Port()) + "# " + message;
struct sockaddr_in addr = user.Addr();
::sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&addr, sizeof(addr));
}
}
void Route(int sockfd, std::string message, InetAddr who)
{
// 我们任务,用户首次发消息,不光是发消息,还要将自己插入到在线用户当中
// 如果客户端要退出
if(message == "Q" || message == "QUIT")
{
DelUser(who);
}
// 构建任务对象, 让线程池进行转发
task_t t = std::bind(&MessageRoute::RouteHelper, this, sockfd, message, who);
ThreadPool<task_t>::GetInstance()->Enqueue(t);
}
那么消息转发模块基本上完成了,但是由于我们这个小项目全程在终端上进行的,所以在客户端页面上需要下点功夫,我们希望将来客户端在收到消息的时候能显示是谁发的消息,也就是要显示IP和port,原本客户端的代码很多集中在了main函数里,这里将其进行封装,增加可读性:
cpp
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Comm.hpp"
#include "thread.hpp"
using namespace ThreadModule;// 使用自定义线程
void Usage(std::string proc)
{
std::cout << "Usage:\n\t" << proc << " serverip serverport\n"
<< std::endl;
}
int InitClient(std::string &serverip, uint16_t serverport, struct sockaddr_in *server)
{
// 创建socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
std::cerr << "socket error" << std::endl;
}
// client 需要绑定,因为client也要有自己的IP和PORT,但是不能显示的使用bind函数进行绑定
// 当客户端第一次发数据的时候OS会随机的给客户端套接字绑定IP和PORT
memset(server, 0, sizeof(server));
server->sin_family = AF_INET;
server->sin_port = htons(serverport);
server->sin_addr.s_addr = inet_addr(serverip.c_str());
return sockfd;
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
struct sockaddr_in serveraddr;
int sockfd = InitClient(serverip, serverport, &serveraddr);
if (sockfd == -1)
return 1;
return 0;
}
客户端也需要两个线程,实现两个接口,一个用来发送消息,一个用来接收消息,这样消息的收发就确定了,在main函数中,将两个接口进行绑定,方便创建的两个线程可以对两个接口进行回调,随后线程进行启动,等待:
cpp
void recvmessage(int sockfd, std::string name)
{}
void sendmessage(int sockfd, struct sockaddr_in &server, std::string name)
{}
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
struct sockaddr_in serveraddr;
int sockfd = InitClient(serverip, serverport, &serveraddr);
if (sockfd == -1)
return 1;
func_t r = std::bind(&recvmessage, sockfd, std::placeholders::_1);
func_t s = std::bind(&sendmessage, sockfd, serveraddr, std::placeholders::_1);
Thread Recver(r, "recver");
Thread Sender(s, "sender");
Recver.Start();
Sender.Start();
Recver.Join();
Sender.Join();
return 0;
}
发送消息比较简单,依旧使用getline对文本进行逐行处理,最后使用sendto接口将消息发出去:
cpp
void sendmessage(int sockfd, struct sockaddr_in &server, std::string name)
{
std::string message;
while (true)
{
printf("%s | Enter# ", name.c_str());
fflush(stdout);
std::getline(std::cin, message);
sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)&server, sizeof(server));
}
}
收消息与发消息类似,使用recvfrom接口就可以收消息,但是这里最主要的是我们收到消息之后应该将消息存储到文件还是发到哪里?我们在Linux系统编程中曾经学过,文件描述符的0, 1, 2分别表示标准输入,标准输出,以及标准错误,我们可以选择将信息放在标准输出或者标准错误上,因为这两个都代表着显示器:
cpp
void recvmessage(int sockfd, std::string name)
{
while (true)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
char buffer[1024];
ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
if (n > 0)
{
buffer[n] = 0;
fprintf(stderr, "[%s] %s\n", name.c_str(), buffer);// 向标准错误中打印
}
}
}
之后我们就可以进行通信了:
最后消息转发类内的几个函数都应该保证每次只有一个线程进入,所以我们有必要对其进行加锁,下面是完整的MessageRoute类:
cpp
class MessageRoute
{
private:
bool IsExists(const InetAddr &addr)
{
for(auto a : _online_user)
{
if(a == addr) return true;
}
return false;
}
public:
MessageRoute()
{
pthread_mutex_init(&_mutex, nullptr);
}
void AddUser(const InetAddr& addr)// 增加用户
{
LockGuard lockguardd(&_mutex);
if(IsExists(addr))
return;
_online_user.push_back(addr);
}
void DelUser(const InetAddr& addr)// 删除用户
{
LockGuard lockguardd(&_mutex);
for(auto iter = _online_user.begin(); iter != _online_user.end(); iter++)
{
if(*iter == addr)
{
_online_user.erase(iter);
break;
}
}
}
void RouteHelper(int sockfd, std::string message, InetAddr who)
{
LockGuard lockguardd(&_mutex);
AddUser(who);
// 进行消息转发
for(auto user : _online_user)
{
std::string send_message = "[" + who.IP() + ":" + std::to_string(who.Port()) + "# " + message;
struct sockaddr_in addr = user.Addr();
::sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&addr, sizeof(addr));
}
}
void Route(int sockfd, std::string message, InetAddr who)
{
// 我们任务,用户首次发消息,不光是发消息,还要将自己插入到在线用户当中
// 如果客户端要退出
if(message == "Q" || message == "QUIT")
{
DelUser(who);
}
// 构建任务对象, 让线程池进行转发
task_t t = std::bind(&MessageRoute::RouteHelper, this, sockfd, message, who);
ThreadPool<task_t>::GetInstance()->Enqueue(t);
}
~MessageRoute()
{
pthread_mutex_destroy(&_mutex);
}
private:
std::vector<InetAddr> _online_user;
pthread_mutex_t _mutex;
};