Udp Socket

目录

[V1版本 - EchoServer](#V1版本 - EchoServer)

[V2版本 - DictionaryServer](#V2版本 - DictionaryServer)

[V3版本 - ChatServer](#V3版本 - ChatServer)


V1版本 - EchoServer

编写 Udp 服务端

我们将 UdpServer 的核心逻辑封装成类,以 UdpServer.hpp 文件呈现,在UdpServer.cc 使用智能指针初始化和启动 Udp 服务器

UdpServer.hpp

cpp 复制代码
class UdpServer
{
public:
    UdpServer(){}
    void Init() {}
    void Start() {}
    void Stop() {}
    ~UdpServer() {}
private:
};

UdpServer.cc

cpp 复制代码
int main()
{
    std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>();
    usvr->Init();
    usvr->Start();
    return 0;
}

网络通信的第一步是创建 socket 套接字,使用的接口如下:

cpp 复制代码
#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>
int socket(int domain, int type, int protocol);

**作用:**创建一个套接字描述符(类似于文件描述符),后续操作通过它进行网络通信。

参数:

**domain:**协议族(地址族),决定了地址格式。

常用值:AF_INET:IPv4;AF_INET6:IPv6;AF_UNIX / AF_LOCAL:Unix 域间套接字

**type:**套接字类型。常用:

SOCK_STREAM:流式套接字,基于 TCP,提供可靠、有序、面向连接的字节流。

SOCK_DGRAM:数据报套接字,基于 UDP,提供不可靠、无连接、保留消息边界的数据报。

SOCK_RAW:原始套接字,允许直接操作 IP 层(需 root 权限)。

**protocol:**具体协议,通常设为 0 表示自动选择(如 SOCK_STREAM 默认 TCP,SOCK_DGRAM 默认 UDP)

**返回值:**成功:返回一个非负整数(套接字描述符);失败:返回 -1,并设置 errno

cpp 复制代码
//初始化工作: 1. 创建socket套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd < 0)
{
    LOG(LogLevel::FATAL) << "create sockfd error";
    exit(1);
}
LOG(LogLevel::INFO) << "create sockfd success : " << _sockfd;

上述工作本质是将网卡文件打开了(Linux下一切皆文件),得到了一个文件描述符,要想进行网络通信,必须将套接字和一个本地 ip+端口号进行关联,也就是给套接字绑定 ip 和 port

cpp 复制代码
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

**作用:**将套接字与一个本地 IP 地址和端口号关联

参数:

sockfd:由 socket() 返回的套接字描述符。

addr:指向协议地址结构(如 struct sockaddr_in)的指针,需要强制转换为struct sockaddr*

addrlen:地址结构的长度(如 sizeof(struct sockaddr_in))

由于 bind 参数需要传递 sockaddr* ,因此我们需要先定义并填充好 sockaddr_in 结构体,而填充该结构体需要指定 ip 和 port,如今是我们自己写的服务器,我们启动服务器时会指定 ip 和 port,因此我们需要通过命令行参数的方式获取 ip + port,同时在 UdpServer 类中加入 _ip 和 _port 成员

UdpServer.cc

cpp 复制代码
#include "UdpServer.hpp"

// ./UdpServer localip localport
void Usage(std::string proc)
{
    std::cerr << "Usage: " << proc << " localip localport " << std::endl;
}

int main(int argc, char* argv[])
{
    if(argc != 3)
    {
        Usage(argv[0]);
        return -1; 
    }
    std::string ip = argv[1];
    uint16_t port = std::stoi(argv[2]);

    ENABLE_CONSOLE_LOG_STRATEGY();
    std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(ip, port);
    usvr->Init();
    usvr->Start();
    return 0;
}

填充 sockadd_in 结构体:

cpp 复制代码
struct sockaddr_in local; 
bzero(&local, sizeof(local)); //清空操作
local.sin_family = AF_INET; 
local.sin_port = htons(_port); // 主机转网络序列(ip+端口号将来是必定要发给对方的, 也就是要先发到网络中, 因此必须从主机序列转为网络序列)
local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 1.点分十进制字符串ip转为4字节整数ip 2.主机序列转网络序列

细节:

  1. sin_family 是 ## 将两个符号拼接成为了一个符号

  2. 服务器的 ip 和 port 将来必定是要发送给对端主机的,就需要先发送到网络中,需要调用 htons 将主机序列的端口号转为网络序列,而 ip 除了主机转网络之外,还要将 字符串 ip 转为 4 字节 ip,方便网络传输,使用 inet_addr 可以完成这两个操作

现在我们只是在用户的栈空间上定义并填充了 sockaddr_in 结构体,接下来需要调用 bind 接口 将该结构体设置进内核,实现绑定

cpp 复制代码
int n = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
if(n < 0)
{
    LOG(LogLevel::FATAL) << "bind socket error";
    exit(2);
}
LOG(LogLevel::INFO) << "bind sockfd success : " << _sockfd;

至此,服务器初始化工作完全搞定,接下来就是服务器启动逻辑,可以正常收发消息了,所有的服务器都是一个死循环,Udp 收发消息不能使用 read / write,因为 read / write 读写的是字节流,系统为 udp 收发消息提供了专门的接口

cpp 复制代码
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);

**作用:**接收 Udp 数据报,并获取发送方地址

参数:

sockfd:socket 接口返回的 套接字文件描述符

buf:接收缓冲区

len:接收缓冲区大小

flags:通常设置为0,表示阻塞式读取

src_addr:输出型参数,内核将发送方的地址填充到这里

addrlen:输入输出型参数,调用前:传入 src_addr 的大小,调用后,内核写入实际地址长度

返回值:>0,成功收到数据,具体数值表示实际接收的字节数;== 0 表示收到了一个空的 Udp 数据报;-1表示出错了,errno 被设置

cpp 复制代码
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);

**作用:**发送 Udp 数据报到指定目标地址

参数:

sockfd:socket 接口返回的 套接字文件描述符

buf:要发送的数据缓冲区

len:要发送的字节数

flags:通常设置为0,表示阻塞式发送

dest_addr:输入型参数,目标主机的地址(ip + 端口)

addrlen:dest_addr 的大小,例如 sizeof(struct sockadrr_in)

返回值:>0,成功发送数据,具体数值表示实际发送的字节数,因为是Udp数据报,因此一般都等于 len;-1表示出错了,errno 被设置

我们这个版本的Udp服务器写的是 EchoServer,也就是客户端发什么数据,服务器就收什么数据,并且原封不动的将收到的数据发送回客户端

cpp 复制代码
void Start()
{
    //所有的服务器都是死循环
    _isrunning = true;
    while(_isrunning)
    {
        //1.读数据
        char buffer[1024];
        buffer[0] = 0;
        struct sockaddr_in peer; //也要知道对方是谁
        socklen_t len = sizeof(peer); //输入输出型参数
        ssize_t n = recvfrom(_sockfd,  buffer, sizeof(buffer), 0, (struct sockaddr*)&peer, &len);
        if(n > 0)
        {
            buffer[n] = 0;
            LOG(LogLevel::DEBUG) << "client say# " << buffer;
            //2.发数据
            std::string echo_string = "server echo# ";
            echo_string += buffer;
            sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0, (struct sockaddr*)&peer, len);
        }
        
    }
    _isrunning = false;
}

UdpServer.hpp 完整代码

cpp 复制代码
#pragma once
#include <iostream>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
//下面两个头文件提供各种数据类型和各种大小端转化方法
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string>
#include <strings.h>
#include "logger.hpp"

static const int gdefaultsockfd = -1;

class UdpServer
{
public:
    UdpServer(const std::string& ip, uint16_t port) 
        :_sockfd(gdefaultsockfd), _ip(ip), _port(port), _isrunning(false)
    {}

    void Init()
    {
        //1.创建套接字, 得到套接字文件描述符
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0); //协议族/域(AF_INET表示IPV4网络通信), 套接字类型(SOCK_DGRAM表示数据报套接字), 具体协议(设置为0自动选择)
        if(_sockfd < 0)
        {
            LOG(LogLevel::FATAL) << "create sockfd error";
            exit(1);
        }
        LOG(LogLevel::INFO) << "create sockfd success : " << _sockfd;

        //2.绑定, 本质就是给套接字命名, 赋值上 ip + port, 才可以进行通信
        //2.1 填充 ip 和 端口
        struct sockaddr_in local; 
        bzero(&local, sizeof(local)); //清空操作
        local.sin_family = AF_INET; 
        local.sin_port = htons(_port); // 主机转网络序列(ip+端口号将来是必定要发给对方的, 也就是要先发到网络中)
        local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 1.点分十进制ip转4字节ip 2.主机序列转网络序列
        //2.2 绑定, 设置进内核
        int n = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
        if(n < 0)
        {
            LOG(LogLevel::FATAL) << "bind socket error";
            exit(2);
        }
        LOG(LogLevel::INFO) << "bind sockfd success : " << _sockfd;
    }

    void Start()
    {
        //所有的服务器都是死循环
        _isrunning = true;
        while(_isrunning)
        {
            //1.读数据
            char buffer[1024];
            buffer[0] = 0;
            struct sockaddr_in peer; //也要知道对方是谁
            socklen_t len = sizeof(peer); //输入输出型参数
            ssize_t n = recvfrom(_sockfd,  buffer, sizeof(buffer), 0, (struct sockaddr*)&peer, &len);
            if(n > 0)
            {
                buffer[n] = 0;
                LOG(LogLevel::DEBUG) << "client say# " << buffer;
                //2.发数据
                std::string echo_string = "server echo# ";
                echo_string += buffer;
                sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0, (struct sockaddr*)&peer, len);
            }
            
        }
        _isrunning = false;
    }

    void Stop()
    {
        _isrunning = false;
    }

    ~UdpServer() {}
private:
    int _sockfd; //文件描述符, 一切皆文件(网卡也是文件)
    uint16_t _port;
    std::string _ip;
    bool _isrunning;
};

编写 Udp 客户端

客户端我们就不封装成类了,直接写 main 函数

第一步依旧是创建 socket 套接字,接下来客户端需要像服务器一样进行绑定嘛??不需要!!!

客户端不需要显式绑定ip和端口,自己绑定容易和已有的客户端绑定的端口冲突,导致有的客户端可能启动不了,而且当前客户端端口不需要被其他程序知道,在第一次向服务端发送消息时系统会自动分配的,系统自动分配的端口绝不会和已有客户端的端口产生冲突!而服务端为啥不需要绑定端口呢?因为服务端是要被主动访问(连接)的,因此ip和端口必须是众所周知的,就需要程序员提前手动绑定好,后面才能被客户端找到!

因此不需要绑定,客户端只要创建了 socket,下来就可以直接向 服务端发送消息了,问题是服务端是谁呀??客户端必然是要知道的,在公司中,服务端的 ip 和 端口是内置到客户端中的,比如以 url 链接形式内置,因此客户端一定是知道服务端的ip和端口号的,我们自己写代码,依旧采用命令行参数的形式获取!

cpp 复制代码
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

void Usage(std::string proc)
{
    std::cerr << "Usage: " << proc << " serverip serverport " << std::endl;
}

int main(int argc, char* argv[])
{
    if(argc != 3)
    {
        Usage(argv[0]);
        return -1; 
    }
    std::string serverip = argv[1];
    uint16_t serverport = std::stoi(argv[2]);

    //创建套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if(sockfd < 0)
    {
        std::cerr << "create socket error" << std::endl;
        return 1;
    }
    
    struct sockaddr_in server;
    server.sin_family = AF_INET;
    server.sin_port = htons(serverport);
    server.sin_addr.s_addr = inet_addr(serverip.c_str());
    
    while(true)
    {
        std::cout << "Please Enter@ ";
        std::string line;
        std::getline(std::cin, line);

        //1.发消息
        sendto(sockfd, line.c_str(), line.size(), 0, (struct sockaddr*)&server, sizeof(server));
        char buffer[1024];
        struct sockaddr temp;
        socklen_t len = sizeof(temp);
        //2.收消息
        int m = recvfrom(sockfd, buffer, sizeof(buffer), 0, (struct sockaddr*)&temp, &len); //temp暂时没用到
        if(m > 0)
        {
            buffer[m] = 0;
            std::cout << buffer << std::endl;
        }
    }
    return 0;
}

上述使用的 127.0.0.1 叫做本地环回地址,意思就是用户层的数据向下交付后,经过传输层和网络层之后,不会再向下交付发送到网络中了,而是又向上交付,最后回到用户层,本地环回地址常用来做本地测试和本地通信!

云服务器是有自己的 内网 ip 地址的,也就是上图中的 inet:10.0.0.7,我们也可以绑定这个地址,同样可以完成通信!

云服务器不是有自己的公网 ip 嘛,如果绑定公网 ip 呢???会直接 bind 失败的!

结论:云服务器的公网 ip 禁止绑定!

下面我们再看一个现象,当服务端绑定内网 ip,客户端使用本地环回时,为啥客户端发消息,服务器收不到呢??这两个 ip 不是代表的是同一个主机嘛?

因为我们在 服务器中直接将 网卡(socket文件描述符) 和 固定ip 绑死了,意味着服务器接收消息只认 ip,不管是不是一台主机,只要不是同一个 ip,服务器是收不到的,而一台机器是可以有多个 ip 地址的,因此我们的服务器写的是有点小问题的,正确做法是不绑定固定 ip 地址,而是 绑定到本机所有的网络接口 IP 地址。

因此 udpServer 类中不需要 _ip 成员,在设置 sockaddr_in 时,使用 local.sin_addr.s_addr = htonl(INADDR_ANY),表示绑定到本机所有的 ip 地址

服务端通过 recvfrom 除了接收到客户端的消息,也获取到了客户端地址信息,我们也可以将该消息打印出来,就知道是哪个客户端发来的消息了!

UdpServer.hpp

cpp 复制代码
#pragma once
#include <iostream>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
//下面两个头文件提供各种数据类型和各种大小端转化方法
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string>
#include <strings.h>
#include "logger.hpp"

static const int gdefaultsockfd = -1;

class UdpServer
{
public:
    UdpServer(uint16_t port) 
        :_sockfd(gdefaultsockfd), _port(port), _isrunning(false)
    {}

    void Init()
    {
        //1.创建套接字, 得到套接字文件描述符
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0); //协议族/域(AF_INET表示IPV4网络通信), 套接字类型(SOCK_DGRAM表示数据报套接字), 具体协议(设置为0自动选择)
        if(_sockfd < 0)
        {
            LOG(LogLevel::FATAL) << "create sockfd error";
            exit(1);
        }
        LOG(LogLevel::INFO) << "create sockfd success : " << _sockfd;

        //2.绑定, 本质就是给套接字命名, 赋值上 ip + port, 才可以进行通信
        //2.1 填充 ip 和 端口
        struct sockaddr_in local; 
        bzero(&local, sizeof(local)); //清空操作
        local.sin_family = AF_INET; 
        local.sin_port = htons(_port); // 主机转网络序列(ip+端口号将来是必定要发给对方的, 也就是要先发到网络中)
        local.sin_addr.s_addr = htonl(INADDR_ANY); // 1.点分十进制ip转4字节ip 2.主机序列转网络序列
        //2.2 绑定, 设置进内核
        int n = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
        if(n < 0)
        {
            LOG(LogLevel::FATAL) << "bind socket error";
            exit(2);
        }
        LOG(LogLevel::INFO) << "bind sockfd success : " << _sockfd;
    }

    void Start()
    {
        //所有的服务器都是死循环
        _isrunning = true;
        while(_isrunning)
        {
            //1.读数据
            char buffer[1024];
            buffer[0] = 0;
            struct sockaddr_in peer; //也要知道对方是谁
            socklen_t len = sizeof(peer); //输入输出型参数
            ssize_t n = recvfrom(_sockfd,  buffer, sizeof(buffer), 0, (struct sockaddr*)&peer, &len);
            if(n > 0)
            { 
                uint16_t clientport = ntohs(peer.sin_port); //网络序列转主机序列
                std::string clientip = inet_ntoa(peer.sin_addr); //网络序列转主机序列 + 四字节ip转点分十进制ip
                buffer[n] = 0;
                LOG(LogLevel::DEBUG) << "[" << clientip << ":" << clientport << "]# " << buffer;
                //2.发数据
                std::string echo_string = "server echo# ";
                echo_string += buffer;
                sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0, (struct sockaddr*)&peer, len);
            }
            
        }
        _isrunning = false;
    }

    void Stop()
    {
        _isrunning = false;
    }

    ~UdpServer() {}
private:
    int _sockfd; //文件描述符, 一切皆文件(网卡也是文件)
    uint16_t _port;
    bool _isrunning;
};

UdpServer.cc

cpp 复制代码
#include "UdpServer.hpp"

// ./UdpServer localport
void Usage(std::string proc)
{
    std::cerr << "Usage: " << proc << " localport " << std::endl;
}

int main(int argc, char* argv[])
{
    if(argc != 2)
    {
        Usage(argv[0]);
        return -1; 
    }
    uint16_t port = std::stoi(argv[1]);
    ENABLE_CONSOLE_LOG_STRATEGY();
    std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port);
    usvr->Init();
    usvr->Start();
    return 0;
}

V2版本 - DictionaryServer

服务器要做的工作肯定不仅仅是 将消息回显以及原封不动发回客户端的,我们需要加一点业务,这一版本的服务器我们实现一个字典服务器,客户端发送一个单词,服务器返回单词的中文翻译

我们只让服务器收发数据,负责IO即可,至于业务逻辑的实现应该交给上层去做,也就是将单词翻译功能和服务器IO模块进行解耦,因此我们需要在 DictServer 类中引入 函数对象,服务器接收到单词后,直接回调得到单词翻译的结果,然后发送给客户端即可!

DictServer.hpp

cpp 复制代码
#pragma once
#include <iostream>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
//下面两个头文件提供各种数据类型和各种大小端转化方法
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string>
#include <strings.h>
#include <functional>
#include "logger.hpp"

using callback_t = std::function<std::string(const std::string& word, const std::string& whoip, uint16_t whoport)>;

static const int gdefaultsockfd = -1;

class DictServer
{
public:
    DictServer(uint16_t port, callback_t cb) 
        :_sockfd(gdefaultsockfd), _port(port), _isrunning(false), _cb(cb)
    {}

    void Init()
    {
        //1.创建套接字, 得到套接字文件描述符
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0); //协议族/域(AF_INET表示IPV4网络通信), 套接字类型(SOCK_DGRAM表示数据报套接字), 具体协议(设置为0自动选择)
        if(_sockfd < 0)
        {
            LOG(LogLevel::FATAL) << "create sockfd error";
            exit(1);
        }
        LOG(LogLevel::INFO) << "create sockfd success : " << _sockfd;

        //2.绑定, 本质就是给套接字命名, 赋值上 ip + port, 才可以进行通信
        //2.1 填充 ip 和 端口
        struct sockaddr_in local; 
        bzero(&local, sizeof(local)); //清空操作
        local.sin_family = AF_INET; 
        local.sin_port = htons(_port); // 主机转网络序列(ip+端口号将来是必定要发给对方的, 也就是要先发到网络中)
        local.sin_addr.s_addr = htonl(INADDR_ANY); // 1.点分十进制ip转4字节ip 2.主机序列转网络序列
        //2.2 绑定, 设置进内核
        int n = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
        if(n < 0)
        {
            LOG(LogLevel::FATAL) << "bind socket error";
            exit(2);
        }
        LOG(LogLevel::INFO) << "bind sockfd success : " << _sockfd;
    }

    void Start()
    {
        //所有的服务器都是死循环
        _isrunning = true;
        while(_isrunning)
        {
            //1.读数据
            char buffer[1024];
            buffer[0] = 0;
            struct sockaddr_in peer; //也要知道对方是谁
            socklen_t len = sizeof(peer); //输入输出型参数
            ssize_t n = recvfrom(_sockfd,  buffer, sizeof(buffer), 0, (struct sockaddr*)&peer, &len);
            if(n > 0)
            { 
                uint16_t clientport = ntohs(peer.sin_port); //网络序列转主机序列
                std::string clientip = inet_ntoa(peer.sin_addr); //网络序列转主机序列 + 四字节ip转点分十进制ip
                buffer[n] = 0;
                std::string word = buffer;
                std::string result = _cb(word, clientip, clientport); //回调
                //2.发数据 
                sendto(_sockfd, result.c_str(), result.size(), 0, (struct sockaddr*)&peer, len);
            }
        }
        _isrunning = false;
    }

    void Stop()
    {
        _isrunning = false;
    }

    ~DictServer() {}
private:
    int _sockfd; //文件描述符, 一切皆文件(网卡也是文件)
    uint16_t _port;
    bool _isrunning;
    callback_t _cb; //回调函数
};

要翻译需要先有一个字典,我们在当前目录下引入 dict.txt 作为测试用例,每一行的格式固定如下:

dict.txt

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

下面就是重点实现单词翻译业务逻辑,我们将单词翻译逻辑单独设计成一个 Dictionary.hpp 模块

Dictionary.hpp

cpp 复制代码
#pragma once
#include <iostream>
#include <fstream>
#include <unordered_map>
#include "logger.hpp"

static const std::string sep = ": ";

class Dictionary
{
private:
    void LoadConf()
    {
        std::ifstream in(_path);
        if(!in.is_open()) return;
        std::string line;
        while(std::getline(in, line))
        {
            LOG(LogLevel::DEBUG) << "load dict messsage: " << line;
            auto pos = line.find(sep);
            if(pos == std::string::npos)
            {
                LOG(LogLevel::WARNING) << "format error: " << line;
                continue;
            }
            std::string word = line.substr(0, pos);
            std::string value = line.substr(pos + sep.size());
            if(word.empty() || value.empty())
            {
                LOG(LogLevel::WARNING) << "format error, word or value is empty: " << line;
                continue;
            }
            _dict.insert(std::make_pair(word, value));
        }   
        LOG(LogLevel::DEBUG) << "Load dictionary success";
        in.close();
    }
public:
    Dictionary(const std::string& path)
        :_path(path)
    {
        LoadConf();
    }

    std::string Translate(const std::string word, const std::string whoip, uint16_t whoport)
    {
        auto iter = _dict.find(word);
        if(iter == _dict.end()) return "unknow";
        return iter->first + "->" + iter->second;
    }

    ~Dictionary(){}
private:
    std::string _path;
    std::unordered_map<std::string, std::string> _dict; 
};

DictServer.cc

cpp 复制代码
#include "DictServer.hpp"
#include "Dictionary.hpp"

// ./UdpServer localport
void Usage(std::string proc)
{
    std::cerr << "Usage: " << proc << " localport " << std::endl;
}


int main(int argc, char* argv[])
{
    if(argc != 2)
    {
        Usage(argv[0]);
        return -1; 
    }
    uint16_t port = std::stoi(argv[1]);
    
    ENABLE_CONSOLE_LOG_STRATEGY();
    
    Dictionary dict("./dict.txt");
    std::unique_ptr<DictServer> usvr = std::make_unique<DictServer>(port, 
        [&dict](const std::string word, const std::string whoip, uint16_t whoport)->std::string{
            return dict.Translate(word, whoip, whoport);
    });

    usvr->Init();
    usvr->Start();
    return 0;
}

V3版本 - ChatServer

这个版本我们实现一个多人群聊的功能,服务器依旧只负责IO,收到某个客户端发来的消息后,我们就可以将 sockfd,消息内容,客户端信息打包,打包成一个任务,push 到任务队列中,交给线程池去处理,线程池将某个客户端转发而来的消息广播给所有的在线用户,我们用 ip + port 标识唯一的客户端即可,因此我们需要对所有的客户端进行管理,因此我们最好将客户端的 ip + port 进行封装,并在类中提供主机地址和网络地址相互转化功能,使得代码更加优雅

InetAddr.hpp

cpp 复制代码
#pragma once
#include <iostream>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <cstring>

#define Conv(addr) ((struct sockaddr*)&addr)

//该类用于封装客户端ip+port, 并实现网络序列和主机序列的相互转化
class InetAddr
{
private:
    void NetToHost()
    {
        _port = ntohs(_addr.sin_port);
        _ip = inet_ntoa(_addr.sin_addr);
    }

    void HostToNet()
    {
        memset(&_addr, 0, sizeof(_addr));
        _addr.sin_family = AF_INET;
        _addr.sin_port = htons(_port);
        _addr.sin_addr.s_addr = inet_addr(_ip.c_str());
    }

public:
    InetAddr(struct sockaddr_in& addr)
        :_addr(addr)
    {   
        NetToHost();
    }

    InetAddr(uint16_t port, const std::string& ip = "0.0.0.0")
        :_port(port), _ip(ip)
    {
        HostToNet();
    }
    
    struct sockaddr* Addr()
    {
        return Conv(_addr);
    }

    socklen_t Length()
    {
        return sizeof(_addr);
    }

    //for debug
    std::string ToString()
    {
        return "[" + _ip + "-" + std::to_string(_port) + "]";
    }

    //用于 路由功能 中判断用户是否在用户在线列表中
    bool operator==(const InetAddr& addr)
    {
        return (_ip == addr._ip) && (_port == addr._port);
    }

    ~InetAddr(){}
private:
    struct sockaddr_in _addr; //网络序列
    //主机序列
    std::string _ip; 
    uint16_t _port;
};

ChatServer.hpp

我们修改服务器代码,使用封装的 InetAddr 类,同时约定将来服务器的回调函数:

using callback_t = std::function<void (int sockfd, std::string message, InetAddr addr)>;

功能是 将 addr 客户端发来的 message 消息 通过 sockfd 路由给所有在线用户

cpp 复制代码
#pragma once
#include <iostream>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
//下面两个头文件提供各种数据类型和各种大小端转化方法
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string>
#include <strings.h>
#include <functional>
#include "logger.hpp"
#include "InetAddr.hpp"

using callback_t = std::function<void \
    (int sockfd, std::string message, InetAddr addr)>;

static const int gdefaultsockfd = -1;

class ChatServer
{
public:
    ChatServer(uint16_t port, callback_t cb) 
        :_sockfd(gdefaultsockfd), _port(port), _isrunning(false), _cb(cb)
    {}

    void Init()
    {
        //1.创建套接字, 得到套接字文件描述符
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0); //协议族/域(AF_INET表示IPV4网络通信), 套接字类型(SOCK_DGRAM表示数据报套接字), 具体协议(设置为0自动选择)
        if(_sockfd < 0)
        {
            LOG(LogLevel::FATAL) << "create sockfd error";
            exit(1);
        }
        LOG(LogLevel::INFO) << "create sockfd success : " << _sockfd;

        //2.绑定, 本质就是给套接字命名, 赋值上 ip + port, 才可以进行通信
        //2.1 填充 ip 和 端口
        InetAddr local(_port);
        //2.2 绑定, 设置进内核
        int n = bind(_sockfd, local.Addr(), local.Length());
        if(n < 0)
        {
            LOG(LogLevel::FATAL) << "bind socket error";
            exit(2);
        }
        LOG(LogLevel::INFO) << "bind sockfd success : " << _sockfd;
    }

    void Start()
    {
        //所有的服务器都是死循环
        _isrunning = true;
        while(_isrunning)
        {
            //1.读数据
            char buffer[1024];
            buffer[0] = 0;
            struct sockaddr_in peer; //也要知道对方是谁
            socklen_t len = sizeof(peer); //输入输出型参数
            ssize_t n = recvfrom(_sockfd,  buffer, sizeof(buffer), 0, (struct sockaddr*)&peer, &len);
            if(n > 0)
            { 
                buffer[n] = 0;
                InetAddr clientAddr(peer);
                LOG(LogLevel::DEBUG) << "get a client info# " << \
                    clientAddr.ToString() << ": " << buffer;

                std::string message = buffer;
                _cb(_sockfd, message, clientAddr); //回调, 将 clientAddr 客户端发来的消息通过 _sockfd 转发出去
            }
        }
        _isrunning = false;
    }

    void Stop()
    {
        _isrunning = false;
    }

    ~ChatServer() {}
private:
    int _sockfd; //文件描述符, 一切皆文件(网卡也是文件)
    uint16_t _port;
    bool _isrunning;
    callback_t _cb; //回调函数
};

路由功能不仅仅只是广播消息,还要管理所有在线的客户端用户,当客户端是第一次发消息给服务器时,需要将该客户端添加到在线用户列表中,同时如果有客户端退出了,应该将该客户端从在线用户列表中移除,因此我们将路由单独抽取成一个类模块!

Route.hpp

cpp 复制代码
#pragma once
#include <iostream>
#include <vector>
#include "InetAddr.hpp"

class Route
{
private:
    bool IsExist(InetAddr& addr)
    {
        for(auto& user: _online_user)
            if(user == addr)
                return true;
        return false;
    }

    void AddUser(InetAddr& addr)
    {
        //判断用户是否已经存在
        if(IsExist(addr)) return;
        _online_user.push_back(addr);
    }

    void SendMessageToAll(int sockfd, std::string& message)
    {
        for(auto& user: _online_user)
        {
            LOG(LogLevel::DEBUG) << "route message to " << user.ToString();
            sendto(sockfd, message.c_str(), message.size(), 0, user.Addr(), user.Length());
        }
    }

    void DeleteUser(std::string& message, InetAddr& addr)
    {
        //约定用户发送 "QUIT" 表示退出
        if(message == "QUIT")
        {
            auto iter = _online_user.begin();
            while(iter != _online_user.end())
            {
                if(*iter == addr)
                {
                    _online_user.erase(iter);
                    break;
                }
            }    
        }
    }
public:
    Route(){}

    void RouteMessageToAll(int sockfd, std::string& message, InetAddr addr)
    {
        //1.检查当前用户是否已经在用户列表了, 如果没在, 添加到用户列表中
        AddUser(addr);
        //2.将消息路由给所有用户
        SendMessageToAll(sockfd, message);
        //3.判断用户是否要退出
        DeleteUser(message, addr);
    }

    ~Route(){}
private:
    std::vector<InetAddr> _online_user; 
};

路由功能是由线程池去调用处理的,因此我们还需要引入 ThreadPool.hpp

ThreadPool.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <queue>
#include <vector>
#include <unistd.h>
#include "Thread.hpp"
#include "Mutex.hpp"
#include "Cond.hpp"

const static int defaultThreadNum = 5;

template<class T>
class ThreadPool
{
private:
    bool QueueIsEmpty()
    {
        return _q.empty();
    }

    void Routine(const std::string& threadname)
    {
        while(true) 
        {
            T t;
            {
                LockGuard lockguard(&_lock);
                while(QueueIsEmpty() && _isrunning) //任务队列为空&&线程池还在运行, 再去休眠
                {
                    _wait_thread_num++;
                    _cond.Wait(_lock);
                    _wait_thread_num--;
                }
                if(!_isrunning && QueueIsEmpty()) //线程池要退了并且任务队列为空, 就直接退!
                {
                    LOG(LogLevel::INFO) << "线程池退出 && 任务队列为空, " << threadname << " 退出";
                    break;
                }
                //走到这里, 任务队列中一定有任务, 无论线程池退没退, 都要处理任务!
                t = _q.front();
                _q.pop(); 
                   
            }
            t(); //规定这样处理任务!
        }
    }

    ThreadPool(int threadnum = defaultThreadNum)
        :_threadnum(threadnum), _isrunning(false), _wait_thread_num(0)
    {
        for(int i = 0; i < _threadnum; i++)
        {
            std::string name = "thread-" + std::to_string(i+1);
            _threads.emplace_back([this](const std::string& threadname){this->Routine(threadname);}, name);
        }
    }

    ThreadPool<T>& operator=(const ThreadPool<T>& tp) = delete;
    ThreadPool(const ThreadPool<T>& tp) = delete;

    //for debug
    static std::string ToHex(ThreadPool<T>* addr)
    {
        char buffer[64];
        snprintf(buffer, sizeof(buffer), "%p", addr);
        return buffer;
    }
public:
    //存在线程安全问题
    static ThreadPool<T>* GetInstance()
    {
        if(!_instance) //提高效率, 避免不必要的申请释放锁!
        { 
            LockGuard lockguard(&_singleton); //避免线程安全问题
            //首次创建并初始化
            if(!_instance)
            {
                _instance = new ThreadPool<T>();
                LOG(LogLevel::INFO) << "线程池单例首次创建并初始化..., addr: " << ToHex(_instance);
                _instance->Start();
            }
        }
        return _instance;
    }

    void Start()
    {
        if(_isrunning) return;
        _isrunning = true;
        for(auto& t : _threads) t.Start();
        LOG(LogLevel::INFO) << "ThreadPool start success";
    }

    void Push(const T& t)
    {
        if(!_isrunning) return; //如果线程池已经退了, 就不要再push任务了
        {
            LockGuard lockguard(&_lock);
            _q.push(t);
            if(_wait_thread_num > 0)
                _cond.NotifyOne();
        }
    }

    void Wait()
    {
        for(auto& t : _threads) t.Join();
        LOG(LogLevel::INFO) << "ThreadPool wait success";
    }

    void Stop()
    {
        if(!_isrunning) return;
        _isrunning = false;
        if(_wait_thread_num > 0)
            _cond.NotifyAll(); //线程池都退出了, 直接唤醒所有线程处理剩余任务!
    }
    
    ~ThreadPool(){}

private:
    std::queue<T> _q; //任务队列(临界资源, 作为整体使用)
    Mutex _lock; //互斥锁(保护临界资源)
    Cond _cond; //条件变量(避免多线程频繁检测任务队列中是否有任务)
    std::vector<Thread> _threads; //管理线程
    int _threadnum; //线程数量
    bool _isrunning; //线程池是否正在运行
    int _wait_thread_num;
    static ThreadPool<T>* _instance; //单例
    static Mutex _singleton;
};

template<class T>
ThreadPool<T>* ThreadPool<T>::_instance = nullptr;

template<class T>
Mutex ThreadPool<T>::_singleton;

ServerMain.cc 中,由于线程池中的任务处理函数:t(),是这样处理任务的,没有带任何参数,因此我们不能直接将带了三个参数的路由函数(RouteMessageToAll)push 到线程池中,而是应该再定义一个 task_t 函数对象,无参数无返回值,然后 绑定 路由函数,再 push 到线程池中处理!

ServerMain.cc

cpp 复制代码
#include "ChatServer.hpp"
#include "InetAddr.hpp"
#include "Route.hpp"
#include "ThreadPool.hpp"
#include <functional>

// ./UdpServer localport
void Usage(std::string proc)
{
    std::cerr << "Usage: " << proc << " localport " << std::endl;
}

//线程池中的处理任务函数就是没有任何参数的!
using task_t = std::function<void()>;

int main(int argc, char* argv[])
{
    if(argc != 2)
    {
        Usage(argv[0]);
        return -1; 
    }
    uint16_t port = std::stoi(argv[1]);
    ENABLE_CONSOLE_LOG_STRATEGY();

    //1.创建路由类对象
    std::unique_ptr<Route> r = std::make_unique<Route>();
    //2.创建线程池对象
    auto tp = ThreadPool<task_t>::GetInstance();
    //3.启动服务器, 让线程池对象回调路由方法
    std::unique_ptr<ChatServer> usvr = std::make_unique<ChatServer>(port, 
        [&r, &tp](int sockfd, std::string message, InetAddr addr){
            task_t t = std::bind(&Route::RouteMessageToAll, r.get(), sockfd, message, addr);
            tp->Push(t);
        }); 
    usvr->Init();
    usvr->Start();
    return 0;
}

至此,服务端的逻辑全部完成,而客户端也是需要修改的,因为我们之前写的客户端是单进程/单线程的,会存在什么问题呢?客户端先调用的是 sendto 函数,需要让用户在终端中输入消息,如果当前客户端一直没输入,那就会阻塞在 sendto 处,无法收到服务端发送的来自其他客户端的消息,当我们发一条消息,就会收到一条来自其他客户端的消息,再发一条,就会再收到一条!

这不符合群聊的真实逻辑,群聊中即便当前客户端不说话,其他客户端发消息当前客户端也要能收到!因此我们需要有两个线程,分别实现收消息和发消息,我们使用C++多线程实现!

同时,我们不想让发消息和收消息的信息打印在同一个终端,看着非常混乱,我们可以将收到的消息使用 cerr 打印,在终端启动客户端时只需要将标准错误进行重定向便可以输出到另一个终端!

ChatClient.cc

cpp 复制代码
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <thread>

void Usage(std::string proc)
{
    std::cerr << "Usage: " << proc << " serverip serverport " << std::endl;
}

int sockfd = -1;
uint16_t serverport;
std::string serverip;

void InitClient()
{
    // 创建套接字
    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0)
    {
        std::cerr << "create socket error" << std::endl;
        return;
    }
}

void Send()
{
    struct sockaddr_in server;
    server.sin_family = AF_INET;
    server.sin_port = htons(serverport);
    server.sin_addr.s_addr = inet_addr(serverip.c_str());
    while (true)
    {
        std::cout << "Please Enter@ ";
        std::string line;
        std::getline(std::cin, line);

        // 发消息
        sendto(sockfd, line.c_str(), line.size(), 0, (struct sockaddr *)&server, sizeof(server));
    }
}

void Recv()
{
    while (true)
    {
        char buffer[1024];
        struct sockaddr temp;
        socklen_t len = sizeof(temp);
        // 收消息
        int m = recvfrom(sockfd, buffer, sizeof(buffer), 0, (struct sockaddr *)&temp, &len); // temp暂时没用到
        if (m > 0)
        {
            buffer[m] = 0;
            std::cerr << buffer << std::endl; //将发消息和收消息不要混在一个终端上, 而是打印在不同的终端上 
        }
    }
}

int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        return -1;
    }
    
    serverip = argv[1];
    serverport = std::stoi(argv[2]);
    InitClient();
    std::thread trecv(Recv);
    std::thread tsend(Send);
    trecv.join();
    tsend.join();
    return 0;
}

目前代码存在的问题:

我们使用的是线程池处理任务,线程池有多个线程,都去执行了路由方法,就有可能并发访问 _online_user 导致问题,因此我们需要加锁!

Route.hpp

cpp 复制代码
#pragma once
#include <iostream>
#include <vector>
#include "InetAddr.hpp"
#include "Mutex.hpp"

class Route
{
private:
    bool IsExist(InetAddr& addr)
    {
        for(auto& user: _online_user)
            if(user == addr)
                return true;
        return false;
    }

    void AddUser(InetAddr& addr)
    {
        LockGuard lockguard(&_mutex);
        //判断用户是否已经存在
        if(IsExist(addr)) return;
        _online_user.push_back(addr);
    }
    
    void SendMessageToAll(int sockfd, std::string& message, InetAddr& addr)
    {
        LockGuard lockguard(&_mutex);
        for(auto& user: _online_user)
        {
            LOG(LogLevel::DEBUG) << "route message to " << user.ToString();
            std::string info = addr.ToString();
            info += "# ";
            info += message;
            sendto(sockfd, info.c_str(), info.size(), 0, user.Addr(), user.Length());
        }
    }

    void DeleteUser(std::string& message, InetAddr& addr)
    {
        //约定用户发送 "QUIT" 表示退出
        if(message == "QUIT")
        {
            LockGuard lockguard(&_mutex);
            auto iter = _online_user.begin();
            while(iter != _online_user.end())
            {
                if(*iter == addr)
                {
                    _online_user.erase(iter);
                    break;
                }
                iter++;
            }    
        }
    }
public:
    Route(){}

    void RouteMessageToAll(int sockfd, std::string& message, InetAddr addr)
    {
        //1.检查当前用户是否已经在用户列表了, 如果没在, 添加到用户列表中
        AddUser(addr);
        //2.将消息路由给所有用户
        SendMessageToAll(sockfd, message, addr);
        //3.判断用户是否要退出
        DeleteUser(message, addr);
    }

    ~Route(){}
private:    
    //临界资源
    std::vector<InetAddr> _online_user; 
    Mutex _mutex;
};

由此也可以看出为什么要将 RouteMessageToAll 内的三个步骤封装成三个函数了,我们没有在 RouteMessageToAll 这个大函数入口直接加锁,而是把锁分别加在了 Add、Send 和 Delete 三个小函数里。这样做的好处是,如果后续在这个大函数里增加了一些不需要访问 vector 的耗时操作(比如敏感词过滤、打印日志),就不会白白阻塞其他线程,提高了并发效率!

相关推荐
编程之路,妙趣横生4 小时前
Tcp Socket
计算机网络·tcp
有毒的教程1 天前
生产环境Socket编程落地实战指南(TCP/UDP工程化方案)
网络协议·tcp/ip·udp
技术民工之路2 天前
Python Socket编程零基础实战教程(TCP/UDP通信完整版)
python·tcp/ip·udp
wang_shu_mo_ran2 天前
网络编程(二):UDP数据报在客户端与服务端之间的传输
网络·网络协议·udp
阿米亚波3 天前
【EVE-NG 实战】防火墙基础(VLAN · VRRP · NAT · ACL · 静态路由)
运维·网络·笔记·网络协议·计算机网络·网络安全·运维开发
飞花绫3 天前
嵌入式 TCP/UDP 双通道架构:为什么控制与发现要分离
tcp/ip·架构·udp
ShineWinsu3 天前
对于Linux:基于UDP实现简单聊天室功能
linux·c++·面试·udp·笔试·进程·简单聊天室
liulilittle4 天前
Exhaustive drift-fix simulation V2 — KCC on shared-bottleneck wired path.
开发语言·网络·python·tcp/ip·计算机网络·信息与通信·通信
2401_868534785 天前
案例介绍-小型网络规划及搭
python·计算机网络