【Linux】Socket编程UDP

目录

  • UDP网络编程
  • [一、V1 - 服务器](#一、V1 - 服务器)
    • [1.1 完善服务器类的`Init`接口](#1.1 完善服务器类的Init接口)
    • [1.2 完善服务器类的Start接口](#1.2 完善服务器类的Start接口)
    • [1.3 完善客户端](#1.3 完善客户端)
    • [1.4 添加客户端的IP和port信息并测试跨网络通信](#1.4 添加客户端的IP和port信息并测试跨网络通信)
    • [1.5 netstat](#1.5 netstat)
  • [二、V2 - 翻译功能服务器](#二、V2 - 翻译功能服务器)
    • [2.1 字典翻译层](#2.1 字典翻译层)
    • [2.2 添加字典类](#2.2 添加字典类)
    • [2.3 测试](#2.3 测试)
  • [三、V3 - 服务器接入线程池](#三、V3 - 服务器接入线程池)
    • [3.1 描述客户端信息](#3.1 描述客户端信息)
    • [3.2 管理用户信息](#3.2 管理用户信息)
    • [3.3 路由模块](#3.3 路由模块)
    • [3.4 完善服务器](#3.4 完善服务器)
    • [3.5 使用服务器注册服务连接线程池模块和路由模块](#3.5 使用服务器注册服务连接线程池模块和路由模块)
    • [3.6 完善客户端](#3.6 完善客户端)
    • [3.7 代码调用逻辑](#3.7 代码调用逻辑)

个人主页:矢望

个人专栏:C++Linux系统编程Linux网络编程C语言数据结构Coze-AIMySQL

UDP网络编程

一、V1 - 服务器

网络编程需要有客户端和服务端,还要有服务器类

Makefile

bash 复制代码
.PHONY:all
all:server_udp client_udp

server_udp:EchoServerMain.cc
	g++ -o $@ $^ -std=c++11
client_udp:EchoClient.cc
	g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
	rm -rf server_udp client_udp

服务器类在使用的时候,希望是有一个Init接口和Start接口,既然是服务器将来肯定是一个死循环

服务器类

cpp 复制代码
class UdpServer
{
public:
    UdpServer()
    {}

    void Init()
    {}

    void Start()
    {}

    ~UdpServer()
    {}
private:
};

1.1 完善服务器类的Init接口

第一步先要进行网络通信肯定先要创建Socket,这一步的本质就是打开网卡,属于系统特性。
创建Socket的接口是socket函数

参数 可选值(常用) 作用(一句话)
domain AF_INETIPv4AF_INET6IPv6AF_UNIX(本地进程通信) 指定通信协议族,决定IP地址的格式和长度
type SOCK_STREAM(流式) SOCK_DGRAM(数据报) SOCK_RAW(原始套接字) 指定通信语义STREAM对应TCP(可靠有序),DGRAM对应UDP(不可靠无序)
protocol 0(让系统自动匹配) IPPROTO_TCP(强制TCPIPPROTO_UDP(强制UDP 指定具体传输协议 ,通常填0由内核根据domain+type自动推导

如上图,这个函数的返回值是一个文件描述符表示创建成功,-1表示失败

将来在网络通信的时候,第一个参数通常传AF_INET,第二个参数通常是SOCK_STREAM(字节流)、SOCK_DGRAM(数据报),第三个参数通常是0。使用的时候基本只有第二个参数会变一下,其它参数基本不变。

初步完善Init接口:

cpp 复制代码
const static int defaultfd = -1;

enum
{
    SUCCESS,
    SOCKET_ERR,
};

class UdpServer
{
public:
    UdpServer()
        :_sockfd(defaultfd)
    {}

    void Init()
    {
        // 1、创建 socket, 本质是打开网卡 -- 系统特性
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0);

        if(_sockfd < 0)
        {
            // 创建失败
            LOG(LogLevel::FATAL) << "create socket fail!";
            exit(SOCKET_ERR);
        }
        LOG(LogLevel::INFO) << "create socket success, sockfd: " << _sockfd;
    }

    // ...
private:
    int _sockfd; // 文件描述符
};

如上,创建socket的代码完成了,由于要打印消息,所以把之前博客写过的日志模块引入进来了。

创建出socket之后,服务器需要有自己的ip和端口号,所以就需要这两个变量,ip为了让方便用户查看,使用string类型存储点分十进制形式的ip地址

cpp 复制代码
const static int defaultfd = -1;
const static uint16_t defaultport = 8888;

class UdpServer
{
public:
    UdpServer(const std::string &ip, uint16_t port = defaultport)
        : _sockfd(defaultfd)
        , _ip(ip)
        , _port(port)
    {}

    // ...
private:
    int _sockfd; // 文件描述符
    std::string _ip; // ip 地址
    uint16_t _port; // 端口号
};

如上,服务器端的端口号必须是固定的,否则客户端可能就找不到服务端了

编写EchoServerMain.cc测试,将来使用的时候希望用户传递ip和端口号,所以就是./server ip port

cpp 复制代码
static void Usage(const std::string &process)
{
    std::cerr << "Usage:\n\t";
    std::cerr << process << " server_ip server_port" << std::endl;
}

// ./server_udp ip port
int main(int argc, char *argv[])
{
    if(argc != 3)
    {
        Usage(argv[0]);
        exit(10);
    }

    SET_CONSOLE_LOG_STRATEGY(); // 使用日志的控制台策略

    std::string server_ip = argv[1];
    uint16_t server_port = std::stoi(argv[2]);

    std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(server_ip, server_port);
    usvr->Init();
    usvr->Start();

    return 0;
}

由于日志模块使用了C++17的内容,所以编译时就需要加-std=c++17选项。

编译运行:

创建好socket之后,第二步就是填充网络信息 ,也就是创建struct sockaddr_in结构体并填充信息。

使用struct sockaddr_in结构体,需要包含下面的三个头文件:

这个结构体专门用来存储一个网络通信端点的地址信息,即IP地址和端口号

如上图,解析完成之后就是这样:

cpp 复制代码
struct sockaddr_in {
    sa_family_t    sin_family; /* 地址族,通常是 AF_INET */
    in_port_t      sin_port;   /* 端口号,网络字节序 */
    struct in_addr sin_addr;   /* IPv4 地址 */
    unsigned char  sin_zero[8]; /* 填充字节,使结构体大小与 sockaddr 一致 */
};
cpp 复制代码
void Init()
{
    // 1、创建 socket, 本质是打开网卡 -- 系统特性
    _sockfd = socket(AF_INET, SOCK_DGRAM, 0);

    if(_sockfd < 0)
    {
        // 创建失败
        LOG(LogLevel::FATAL) << "create socket fail!";
        exit(SOCKET_ERR);
    }
    LOG(LogLevel::INFO) << "create socket success, sockfd: " << _sockfd;

    // 2、填充网络信息
    struct sockaddr_in local;
    bzero(&local, sizeof(local));
    local.sin_family = AF_INET;
    local.sin_port = htons(_port); // 端口号
    local.sin_addr.s_addr = inet_addr(_ip.c_str()); // ip
}

如上,bzero函数的作用是对指定内存区域全部清零,在<strings.h>头文件中

填充端口号时需要保证主机转网络h->n,而填充ip时需要做两件事,第一件是将字符串点分十进制风格的ip转化成四字节的ip地址,第二件是将ip由主机转网络h->n。有一个函数将ip转化的两件事都做了,就是inet_addr函数in_addr_t inet_addr(const char *cp);

如果想要手动转化,可以通过位段法和移位法实现。

虽然现在填充了网络信息,但是并没有将ip和端口号信息设置到内存中。

所以第三步绑定socket信息,即填充ipport到内核结构中

如上图,成功返回0,失败返回-1

cpp 复制代码
void Init()
{
    // 1、创建 socket, 本质是打开网卡 -- 系统特性
    _sockfd = socket(AF_INET, SOCK_DGRAM, 0);

    if(_sockfd < 0)
    {
        // 创建失败
        LOG(LogLevel::FATAL) << "create socket fail!";
        exit(SOCKET_ERR);
    }
    LOG(LogLevel::INFO) << "create socket success, sockfd: " << _sockfd;

    // 2、填充网络信息
    struct sockaddr_in local;
    bzero(&local, sizeof(local));
    local.sin_family = AF_INET;
    local.sin_port = htons(_port); // 端口号
    local.sin_addr.s_addr = inet_addr(_ip.c_str()); // ip

    // 3、绑定 socket 信息
    int n = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
    if(n < 0)
    {
        LOG(LogLevel::FATAL) << "bind socket error!";
        exit(BIND_ERR);
    }
    LOG(LogLevel::INFO) << "bind socket success, ip: " << _ip << ", port: " << _port;
}

如上,这样就初步完善了初始化Init接口。

1.2 完善服务器类的Start接口

接下来就是启动服务器服务器启动起来就是一个死循环,将来进行网络通信时,用户会给我们发消息,所以我们需要使用函数去接收它。这个接收函数叫做recvfrom

如上图,flags标志为0表示阻塞等待,标志为MSG_DONTWAIT表示非阻塞,通常设为0。后两个参数是输出型参数,用于获取对方的套接字信息,方便之后向对方发送数据。

返回值:成功返回实际接收到的字节数,失败返回 -1 并设置 errno;若连接被对方关闭返回 0

cpp 复制代码
void Start()
{
    // 假设字符串传递
    char inbuffer[1024];
    while(true)
    {
        struct sockaddr_in peer; // 定义结构体变量,存储对方的socket信息
        socklen_t len = sizeof(peer);
        // 获取用户发来的数据和socket信息
        ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer), 0, (struct sockaddr*)&peer, &len);
        if(n > 0)
        {
            inbuffer[n] = '\0';
            LOG(LogLevel::DEBUG) << "client say# " << inbuffer;
        }
    }
}

如上,这样就成功获取了用户发来的数据,但身为echo server(回声服务器),我们要把客户端发来的数据再给客户端发过去。

此时就需要另一个函数了,sendto

如上,通过fd向客户端进行数据发送。

返回值:成功返回实际发送的字节数,失败返回 -1 并设置 errno

cpp 复制代码
void Start()
{
    // 假设字符串传递
    char inbuffer[1024];
    while(true)
    {
        struct sockaddr peer; // 定义结构体变量,存储对方的socket信息
        socklen_t len = sizeof(peer);
        // 获取用户发来的数据和socket信息
        ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&peer, &len);
        if(n > 0)
        {
            inbuffer[n] = '\0';
            LOG(LogLevel::DEBUG) << "client say# " << inbuffer;
        
            std::string echo_str = "echo say# ";
            echo_str += inbuffer;
            // 向客户端发送数据
            sendto(_sockfd, echo_str.c_str(), echo_str.size(), 0, (struct sockaddr*)&peer, len);
        }
        else
        {
            LOG(LogLevel::ERROR) << "recvfrom error";
        }
    }
}

如上,为什么在Init接口中填充网络信息时需要进行主机到网络的转换,而在这个再次发送的时候不需要呢?因为通过recvfrom获取用户的socket信息时,是从网络中获取的,即它就是网络序列(大端),所以再发送到网络的时候,直接使用就可以了

析构函数:

cpp 复制代码
~UdpServer()
{
    close(_sockfd);
}

如上,服务端基本初步完善了,接下来就是客户端。

1.3 完善客户端

客户端要进行网络通信,首先也是需要创建socket

cpp 复制代码
static void Usage(const std::string &process)
{
    std::cerr << "Usage:\n\t";
    std::cerr << process << " server_ip server_port" << std::endl;
}

int main(int argc, char *argv[])
{
    if(argc != 3)
    {
        Usage(argv[0]);
        exit(3);
    }

    // 1、创建socket
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if(sockfd < 0)
    {
        std::cerr << "create socket error!" << std::endl;
        exit(2);
    }

    return 0;
}

如上图,由于客户端将来需要和服务端进行通信,所以它需要传递服务端的ipport。客户端是如何知道服务端的ipport呢?这个很简单,本来就知道,同一家公司产出的客户端一定要能够找到同一家公司的服务端,类似ipport是被内置到客户端的

创建好了socket,接下来是显示填充网络信息并绑定socket信息吗?并不是,客户端确实需要有自己的ipport信息,但是不需要且不建议自己显示填充并绑定socket信息

如果每个公司的客户端都显示绑定固定端口的话,一个手机或电脑系统上的端口是有限的(0~65535),在这有限的端口中,如果两个客户端app的固定绑定端口一样,并且它们两个是敌对公司的,那么这家公司的客户端app运行起来之后,那一家客户端app在这台手机或者电脑上就运行不起来了,因为一个端口只能标识一个进程。

所以显示绑定会出现冲突问题,客户端的port只需要具有唯一性即可,具体多少不重要。那么如何设置客户端的ipport呢?客户端一般会采用随机端口的方式,由操作系统自主选择!在客户端首次向服务端发送数据时,操作系统底层就会隐式自动帮客户端获取随机端口,然后绑定ipport

服务器的ipport必须显示绑定,且不能随便改,不显示绑定或者改变了,客户端就找不到它了

所以客户端创建好socket之后,就可以直接进行网络通信了!

cpp 复制代码
// 1、创建socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd < 0)
{
    std::cerr << "create socket error!" << std::endl;
    exit(2);
}

std::string server_ip = argv[1];
uint16_t server_port = std::stoi(argv[2]);

struct sockaddr_in server; // 服务器 socket
bzero(&server, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(server_port); // 端口
server.sin_addr.s_addr = inet_addr(server_ip.c_str()); // ip

while(true)
{
    std::string message;
    // 1、获取用户输入
    std::cout << "Please Enter# ";
    std::getline(std::cin, message);

    // 2、client 发送数据给 server,首次发送即自动绑定ip和port
    ssize_t n = sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&server, sizeof(server));
    if(n > 0)
    {
        // 发送成功,接收服务端返回的数据
        char inbuffer[1024];

        struct sockaddr_in temp;
        socklen_t len = sizeof(temp);
        ssize_t m = recvfrom(sockfd, &inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&temp, &len);
        if(m > 0)
        {
            // 接收成功
            inbuffer[m] = '\0';
            std::cout << inbuffer << std::endl;
        }
    }
}

如上,想要给服务器发消息,首先需要填充服务器端的socket信息,然后发送消息。发送成功之后,接收服务器返回回来的消息,并打印。

编译运行:

上图中的127.0.0.1是本地环回IP,绑定这个IP之后,报文不会发送到网络中,只会在自己的协议栈中跑一次,它通常用于本地通信或者网络代码的测试

但是我们不想只在当前主机测试,想要的是网络通信,接下来,我们绑定一下云服务器的公网ip试一试。

如上图,绑定出错了。

注意云服务器的公网IP是禁止被显示bind的,但这不等于云服务器的公网IP不会被使用

在服务器开发的时候,不建议服务器显示bind IP地址,一台主机上会有很多的IP地址,有无线的、有线的等等,一旦服务器显示bindIP地址,它就只能接收或发送自己bindIP的报文了,其它IP上收到的报文,服务器就接收不到了

一台主机上将来会有三种IP127.0.0.1、局域网IP、公网IP

所以最佳实践:bind任意地址,这样就可以收取所有IP上的报文了

修改服务器的bind策略,改为绑定任意地址:

cpp 复制代码
void Init()
{
    // 1、创建 socket, 本质是打开网卡 -- 系统特性
    _sockfd = socket(AF_INET, SOCK_DGRAM, 0);

    if(_sockfd < 0)
    {
        // 创建失败
        LOG(LogLevel::FATAL) << "create socket fail!";
        exit(SOCKET_ERR);
    }
    LOG(LogLevel::INFO) << "create socket success, sockfd: " << _sockfd;

    // 2、填充网络信息
    struct sockaddr_in local;
    bzero(&local, sizeof(local));
    local.sin_family = AF_INET;
    local.sin_port = htons(_port); // 端口号
    local.sin_addr.s_addr = INADDR_ANY; // 最佳实践:bind 任意地址
    // local.sin_addr.s_addr = inet_addr(_ip.c_str()); // ip

    // 3、绑定 socket 信息
    int n = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
    if(n < 0)
    {
        LOG(LogLevel::FATAL) << "bind socket error!";
        exit(BIND_ERR);
    }
    LOG(LogLevel::INFO) << "bind socket success, ip: " << _ip << ", port: " << _port;
}

首先先使用之前的方案运行测试一下:

如上图,在服务器显示bind127.0.0.1的情况下,它只能收到来自这个ip发来的报文,其它的ip收到的报文,服务器是收不到的。

再换成任意地址绑定试一试,此时就把服务器类代码涉及到的ip成员变量去掉了,不需要它,Main.cc的代码也进行了修改。

如上,报文全部都可以收到了。所以云服务器公网的IP禁止被显示bind,但是客户端可以通过公网IP向服务器通信,也就是公网IP可以被使用

如果你的云服务器主机客户端不能通过公网IP向服务端通信,请检查云服务器的安全组入方向规则有没有相关端口协议的信息,如果没有就添加上,因为云厂商的安全组默认拦截了所有入站流量。

1.4 添加客户端的IP和port信息并测试跨网络通信

添加信息很简单,因为recvfrom函数的输出型参数将客户端的socket信息带出来了,将信息提取出来就好。

port信息只需要网络转主机n->h,而ip信息需要先网络转主机n->h,然后4字节转成点分十进制信息。ip的这两步工作inet_ntoa已经做了。

cpp 复制代码
void Start()
{
    // 假设字符串传递
    char inbuffer[1024];
    while(true)
    {
        struct sockaddr_in peer; // 定义结构体变量,存储对方的socket信息
        socklen_t len = sizeof(peer);
        // 获取用户发来的数据和socket信息
        ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&peer, &len);
        if(n > 0)
        {
            uint16_t client_port = ntohs(peer.sin_port);
            std::string client_ip = inet_ntoa(peer.sin_addr);
            // 客户端信息
            std::string client_address = "[" + client_ip + ":" + std::to_string(client_port) + "]say# ";

            inbuffer[n] = '\0';
            LOG(LogLevel::DEBUG) << client_address  << inbuffer;
        
            std::string echo_str = "echo say# ";
            echo_str += inbuffer;
            // 向客户端发送数据
            sendto(_sockfd, echo_str.c_str(), echo_str.size(), 0, (struct sockaddr*)&peer, len);
        }
        else
        {
            LOG(LogLevel::ERROR) << "recvfrom error";
        }
    }
}

编译之后发Windows一份,进行WindowsLinux跨网络通信:

如上图,成功进行了WindowsLinux跨网络通信!

不止如此,如果有一个客户端向服务端发消息,那么服务端会得到一个用户的socket信息,如果有100个,服务端就会得到100socket信息,将来如果一个用户向服务端发消息,服务端可以选择向所有用户都转发一份,这样就实现了群聊的功能!如果一个用户向服务端发的消息带有目标客户端,服务器可以将消息转发给特定的客户端,这样就完成了私聊的功能!

1.5 netstat

netstatLinux 下用于查看网络连接、路由表、接口统计等信息的经典工具。

选项 作用
-a (all) 显示所有监听非监听的套接字(默认只显示非监听)
-l (listening) 仅显示正在监听的套接字(服务端常用)
-n (numeric) 数字形式显示地址和端口(不解析域名和服务名,显示更快)
-p (program) 显示进程 PID 和程序名 (需要 root 权限)
-t (tcp) 仅显示 TCP 套接字
-u (udp) 仅显示 UDP 套接字
-r (route) 显示路由表 (等同于 route -n
-i (interfaces) 显示网络接口统计信息
-s (statistics) 显示各协议(IP/TCP/UDP/ICMP)的统计信息(收发包计数、错误等)

二、V2 - 翻译功能服务器

上面我们的服务器代码写好了,但是服务器的工作就是网络通信的IO工作,服务器从网络中获取的数据应该交给上层处理,然后上层将处理完成的数据再交给服务器进行网络通信

所以在服务器类内部将来会有一个回调接口回调上层的方法,然后获取处理好的数据,服务器再进行发送

2.1 字典翻译层

将来这个服务器可以充当单词检索翻译功能,由客户端输入数据,服务器接收并交给业务处理层也就是翻译层翻译单词,并返回处理结果,由服务器将结果给客户端返回。因此回调函数的返回值和参数类型都是std::string类型

cpp 复制代码
using callback_t = std::function<std::string (std::string)>;

const static int defaultfd = -1;
const static uint16_t defaultport = 8888;

class UdpServer
{
public:
    // UdpServer(const std::string &ip, uint16_t port = defaultport)
    UdpServer(callback_t cb, uint16_t port = defaultport)
        : _sockfd(defaultfd)
        , _port(port)
        , _cb(cb)
    {}

    // ...
private:
    int _sockfd; // 文件描述符
    // std::string _ip; // ip 地址,不需要,bind 任意地址
    uint16_t _port; // 端口号
    callback_t _cb; // 回调函数,将数据交给上层处理
};

如上,给服务器类添加回调函数。

修改服务器类的数据处理逻辑:

cpp 复制代码
void Start()
{
    // 假设字符串传递
    char inbuffer[1024];
    while(true)
    {
        struct sockaddr_in peer; // 定义结构体变量,存储对方的socket信息
        socklen_t len = sizeof(peer);
        // 获取用户发来的数据和socket信息
        ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&peer, &len);
        if(n > 0)
        {
            uint16_t client_port = ntohs(peer.sin_port);
            std::string client_ip = inet_ntoa(peer.sin_addr);
            // 客户端信息
            std::string client_address = "[" + client_ip + ":" + std::to_string(client_port) + "]say# ";

            inbuffer[n] = '\0'; // 用户发来的数据
            
            std::string result = _cb(inbuffer); // 将数据交给上层处理,并存储处理结果

            // 向客户端发送处理好的数据
            sendto(_sockfd, result.c_str(), result.size(), 0, (struct sockaddr*)&peer, len);
        }
        else
        {
            LOG(LogLevel::ERROR) << "recvfrom error";
        }
    }
}

如上,将收到的数据交给上层处理,服务端将处理好的数据发送给客户端。

2.2 添加字典类

cpp 复制代码
#include <iostream>
#include <string>
#include <unordered_map>
#include <fstream>
#include "Logger.hpp"

using namespace NS_LOG_MODULE;

static const std::string default_dictpath = "./dict.txt"; // 字典文件路径
static const std::string sep = ": "; // 分隔符

class Dict
{
public:
    // 初始化字典路径,自动加载字典
    Dict(const std::string &path = default_dictpath)
        : _dict_path(path)
    {
        DictLoad(); // 加载字典库
    }

    void DictLoad() // 从文件加载字典到内存
    {
        std::ifstream in(_dict_path); // 打开字典文件

        if(!in.is_open()) // 判断文件是否打开成功
        {
            LOG(LogLevel::FATAL) << "open " << _dict_path << " error!";
            exit(2);
        }

        std::string line;
        while(std::getline(in, line)) // 逐行读取文件
        {
            auto pos = line.find(sep); // 查找分隔符位置
            if(pos == std::string::npos) // 找不到分隔符
            {
                LOG(LogLevel::WARNING) << "format " << line << " error!";
                continue;
            }
            LOG(LogLevel::INFO) << "load " << line << " success!";

            // 分隔
            std::string k = line.substr(0, pos);
            std::string v = line.substr(pos + sep.size());
            _dict.insert(std::make_pair(k, v)); // 插入哈希表
        }

        in.close(); // 关闭文件
        LOG(LogLevel::INFO) << "Load Done...";
    }

    std::string TranslateWord(std::string word) // 翻译接口
    {
        if(_dict.count(word)) return _dict[word];
        else return "Unknown";
    }
private:
    std::string _dict_path; // 记录字典文件路径
    std::unordered_map<std::string, std::string> _dict; // 字典哈希 <单词, 翻译>
};

如上就是字典类的完整实现,字典文件如下截图:

2.3 测试

cpp 复制代码
int main(int argc, char *argv[])
{
    if(argc != 2)
    {
        Usage(argv[0]);
        exit(10);
    }

    SET_CONSOLE_LOG_STRATEGY(); // 使用日志的控制台策略
    // 1、定义字典
    Dict dict;
    // 2、构建网络服务处理 IO 问题
    uint16_t server_port = std::stoi(argv[1]);
    // 3、绑定业务处理层和服务器层
    std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>([&dict](std::string word)->std::string{
        return dict.TranslateWord(word);
    } ,server_port);
    usvr->Init();
    usvr->Start();

    return 0;
}

编译运行:

如上,客户端输入单词数据,服务器接收单词数据并交给单词翻译层处理,服务器再将处理完成的数据交给客户端,上图中客户端收到了数据!

三、V3 - 服务器接入线程池

引入线程池 ,现在的代码结构是这样的:

接下来,线程池的数据将从网络中来!

计划写一个聊天服务器,服务端将客户端的socket信息管理起来,然后收到客户端发的数据之后,将数据发给线程池,由线程池负责完成将数据转发给所有客户端的任务。

3.1 描述客户端信息

我们将来要将客户端信息管理起来,所以先写一个类来描述客户端。

cpp 复制代码
class InetAddr
{
public:
    InetAddr()
    {}
    InetAddr(const struct sockaddr_in &address)
        : _address(address)
        , _len(sizeof(_address))
    {
        _ip = inet_ntoa(_address.sin_addr); // 网络转主机 && 4字节转字符串点分十进制
        _port = ntohs(_address.sin_port); // 网络主机
    }

    InetAddr(uint16_t port, const std::string &ip = "0.0.0.0")
        : _ip(ip)
        , _port(port)
    {
        bzero(&_address, sizeof(_address));
        _address.sin_family = AF_INET;
        _address.sin_addr.s_addr = inet_addr(ip.c_str()); // 主机转网络 && 字符串点分十进制转4字节
        _address.sin_port = htons(port); // 主机转网络
        _len = sizeof(_address);
    }

    struct sockaddr_in* GetNetAddr()
    {
        return &_address;
    }
    socklen_t GetLen()
    {
        return _len;
    }
    ~InetAddr()
    {}
private:
    // net address
    struct sockaddr_in _address;
    socklen_t _len;
    // host address
    std::string _ip;
    uint16_t _port;
};

如上,需要存储网络socket信息以及主机ipport。于是准备了多个构造函数。ip0.0.0.0代表bind`任意地址。
还实现了获取字节大小和套接字结构体地址的接口

有了描述网络信息的类,接下来就可以修改服务器里面的一些代码了。

在服务器的初始化Init接口当中,第二步做的工作是填充服务端的网络信息,有了描述相关信息的类,再进行这些工作的时候就不需要那么臃肿了

cpp 复制代码
void Init()
{
    // 1、创建 socket, 本质是打开网卡 -- 系统特性
    _sockfd = socket(AF_INET, SOCK_DGRAM, 0);

    if(_sockfd < 0)
    {
        // 创建失败
        LOG(LogLevel::FATAL) << "create socket fail!";
        exit(SOCKET_ERR);
    }
    LOG(LogLevel::INFO) << "create socket success, sockfd: " << _sockfd;

    // 2、填充网络信息
    InetAddr local(_port); // 填充服务器的网络信息

    // 3、绑定 socket 信息
    int n = bind(_sockfd, (struct sockaddr*)local.GetNetAddr(), local.GetLen());
    if(n < 0)
    {
        LOG(LogLevel::FATAL) << "bind socket error!";
        exit(BIND_ERR);
    }
    LOG(LogLevel::INFO) << "bind socket success, port: " << _port;
}

如上,直接使用InetAddr类的构造函数就填充好网络信息了,并且ip默认是0.0.0.0就是bind任意ip地址。在第三步绑定socket信息时,也调用类里面的接口。

3.2 管理用户信息

现在有了网络信息描述类,还需要将它们使用数据结构管理起来。我会将它再封装成一个用户管理类,这个类主要就是对用户信息进行增删查改

cpp 复制代码
class UserManager
{
public:
    UserManager()
    {}
    void AddUser(const InetAddr &addr) // 增
    {
        if(SearchUser(addr))
            return;
        
            _users.push_back(addr);
    }

    void DelUser(const InetAddr &addr) // 删
    {
        for(auto iter = _users.begin(); iter != _users.end(); iter++)
        {
            if(*iter == addr)
            {
                _users.erase(iter);
                break;
            }
        }
    }

    bool SearchUser(const InetAddr &addr) // 查
    {
        for(auto &user : _users)
        {
            if(user == addr)
            {
                return true;
            }
        }
        return false;
    }

    void ModUser(const InetAddr &addr) // 改
    {
        DelUser(addr);
        AddUser(addr);
    }
    ~UserManager()
    {}
private:
    std::vector<InetAddr> _users;
};

如上,使用vector容器组织用户信息,在实现增删查改的时候用到了InetAddr类的==比较,所以在类中重载了方法。

cpp 复制代码
bool operator==(const InetAddr &addr)
{
    return (this->_ip == addr._ip) && (this->_port == addr._port);
}

3.3 路由模块

现在可以通过服务器获取数据,并且对用户信息也进行了管理,作为这个聊天服务器,接下来需要有一个模块做数据转发给用户的工作,也就是路由模块

cpp 复制代码
class Route
{
public:
    Route()
        :_uma(std::make_unique<UserManager>())
    {}

    void User_ADD(const InetAddr &addr) // 增
    {
        LockGuard lockguard(_lock);
        _uma->AddUser(addr);
    }

    void OfflineUser(const InetAddr &addr) // 删
    {
        LockGuard lockguard(_lock);
        _uma->DelUser(addr);
    }

    void BroadCast(int sockfd, const std::string message) // 服务器广播消息给所有用户
    {
        LockGuard lockguard(_lock);
        auto users = _uma->Users();

        for(auto user : users)
        {
            sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)user.GetNetAddr(), user.GetLen());
        }
    }

    ~Route()
    {}
private:
    std::unique_ptr<UserManager> _uma;
    Mutex _lock;
};

如上,路由模块也很简单,它连接了服务器和用户,可以将服务器收到的消息广播给用户。这个类也有增删用户的接口,只是复用了UserManager类的方法。这个模块可能被多个用户同时访问,所以可能会产生并发问题,因此添加互斥锁保护

上面的广播函数的第一个参数将来是服务器的文件描述符,因为服务器收到的数据,将来也是通过服务端和客户端通信

上面由于发送消息需要遍历用户列表,所以在UserManager类中添加了获取列表的接口Users

cpp 复制代码
std::vector<InetAddr>& Users()
{
    return _users;
}

3.4 完善服务器

服务器启动之后,获取数据之后,接下来要做的就是检测当前通信的客户端是不是新用户,如果是就添加用户信息,然后进行转发信息的逻辑。
但是服务器只管IO,其余的工作交给其它层,所以服务器就需要注册其它层的服务,它需要UserManager类的新增用户的方法,需要Route类中的广播消息方法

cpp 复制代码
using handler_addr_t = std::function<void (const InetAddr&)>; // UserManager的新增用户接口
using handler_msg_t = std::function<void (int, std::string)>; // Route的广播消息接口

class UdpServer
{
public:
    // ...
    
    // 注册服务的接口
    void RegisterService(handler_addr_t handler_addr, handler_msg_t handler_msg)
    {
        _handler_addr = handler_addr;
        _handler_msg = handler_msg;
    }

    void Start()
    {
        // 假设字符串传递
        char inbuffer[1024];
        while(true)
        {
            struct sockaddr_in peer; // 定义结构体变量,存储对方的socket信息
            socklen_t len = sizeof(peer);
            // 获取用户发来的数据和socket信息
            ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&peer, &len);
            if(n > 0)
            {
                inbuffer[n] = '\0';
                // 1、检测新用户
                InetAddr clientaddr(peer);
                _handler_addr(clientaddr); // 回调UserManager函数检测注册新用户
                // 2、转发消息
                _handler_msg(_sockfd, inbuffer); // 回调Route函数广播消息
            }
            else
            {
                LOG(LogLevel::ERROR) << "recvfrom error";
            }
        }
    }

    // ...
private:
    int _sockfd; // 文件描述符
    // std::string _ip; // ip 地址,不需要,bind 任意地址
    uint16_t _port; // 端口号

    // 回调函数,将数据交给上层处理
    handler_addr_t _handler_addr; 
    handler_msg_t _handler_msg;
};

如上,服务器提供了注册服务的方法,并将需要完成的工作交给回调函数处理,这样降低了耦合度

3.5 使用服务器注册服务连接线程池模块和路由模块

cpp 复制代码
using namespace NS_THREAD_POOL_MODULE;
using task_t = std::function<void ()>; // 线程池要完成的任务类型

// ./server_udp port
int main(int argc, char *argv[])
{
    if(argc != 2)
    {
        Usage(argv[0]);
        exit(10);
    }

    SET_CONSOLE_LOG_STRATEGY(); // 使用日志的控制台策略

    // 构建网络服务处理 IO 问题
    uint16_t server_port = std::stoi(argv[1]);
    
    // 线程池模块
    auto thread_pool = ThreadPool<task_t>::Instance(); // 获取线程池单例对象

    // 路由模块
    Route r;

    // 网络模块 - 服务器
    UdpServer usvr(server_port);
    usvr.Init(); // 初始化服务器

    // 向服务器注册服务
    usvr.RegisterService(
    // ...);

    usvr.Start(); // 启动服务器

    return 0;
}

如上,向服务器注册服务的代码我没有写,因为它有几种写法。

第一种:

cpp 复制代码
usvr.RegisterService(
// 注册UserManager的检测注册新用户函数
[&r](const InetAddr &addr) -> void {
    r.User_ADD(addr);
}, 
// 注册Route的广播消息函数
[&r](int sockfd, std::string message) -> void {
    r.BroadCast(sockfd, message);
});

如上,如果这样写,它也能运行,但是它没有用到线程池模块,只用到了路由模块,相当于单进程执行服务,单进程广播

第二、三种:

cpp 复制代码
usvr.RegisterService(
// 注册UserManager的检测注册新用户函数
[&r](const InetAddr &addr) -> void {
    r.User_ADD(addr);
}, 
// 注册Route的广播消息函数
[&r, &thread_pool](int sockfd, std::string message) -> void {
    // 给线程池设置任务类型
    thread_pool->Enqueue([&r, &sockfd, &message](){
        r.BroadCast(sockfd, message);
    });
});

usvr.RegisterService(
// 注册UserManager的检测注册新用户函数
[&r](const InetAddr &addr) -> void {
    r.User_ADD(addr);
}, 
// 注册Route的广播消息函数
[&r, &thread_pool](int sockfd, std::string message) -> void {
    // 给线程池设置任务类型
    auto task = std::bind(&Route::BroadCast, &r, sockfd, message);
    thread_pool->Enqueue(task);
});

上面的两种写法都是一样的。

编译运行:

如上客户端收到了服务端的群发消息。

再试一下多个客户端的情况:

如上,两个客户端确实互相收到了对方发送的消息,但是由于客户端的收发逻辑是,每次成功发送才收一次数据,这就导致即使有很多客户端向服务端发消息,然后服务端就会向所有客户端广播消息,导致客户端的内核缓冲区积攒了很多的消息,但是由于客户端发送一次数据才接收一次数据,所以导致了读到的是之前的旧数据

3.6 完善客户端

客户端现在是只有写一次才能读一次,这个逻辑是有缺陷的,所以也要把客户端多线程化

基本框架:

cpp 复制代码
using namespace NS_THREAD_MODULE;

static void Usage(const std::string &process)
{
    std::cerr << "Usage:\n\t";
    std::cerr << process << " server_ip server_port" << std::endl;
}

int sockfd;
std::string server_ip;
uint16_t server_port;

void SendMessage() // 发送数据
{}
void RecvMessage() // 接收数据
{}

int main(int argc, char *argv[])
{
    if(argc != 3)
    {
        Usage(argv[0]);
        exit(3);
    }

    // 服务器端 ip + port
    server_ip = argv[1];
    server_port = std::stoi(argv[2]);

    // 1、创建socket
    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if(sockfd < 0)
    {
        std::cerr << "create socket error!" << std::endl;
        exit(2);
    }

    Thread Recver(RecvMessage); // 线程1:接收数据
    Thread Sender(SendMessage); // 线程2:发送数据

    Recver.Start();
    Sender.Start();

    Recver.Join();
    Sender.Join();

    return 0;
}

使用到了封装的线程类,其中一个线程执行接收数据函数,另一个线程执行发送数据函数。

完善接收和发送接口:

cpp 复制代码
static void Online(InetAddr &serveraddr) // 用户上线通知消息
{
    std::cout << "Please Set Your Nick Name# ";
    std::getline(std::cin, nickname);
    std::string online_message = nickname + "online!";
    ssize_t n = sendto(sockfd, online_message.c_str(), online_message.size(), 0, 
                       (struct sockaddr*)serveraddr.GetNetAddr(), serveraddr.GetLen());
    (void)n;
}

void SendMessage() // 发送数据
{
    // 填充服务器端网络信息
    InetAddr server(server_port, server_ip);
    Online(server); // 上线消息

    while(true)
    {
        std::string message;
        // 1、获取用户输入
        std::cout << "Please Enter# ";
        std::getline(std::cin, message);

        // 设置用户名标识
        message = nickname + "# " + message;

        // 2、client 发送数据给 server,首次发送即自动绑定ip和port
        ssize_t n = sendto(sockfd, message.c_str(), message.size(), 0, 
                           (struct sockaddr*)server.GetNetAddr(), server.GetLen());
        (void)n;
    }
}

void RecvMessage() // 接收数据
{
    while(true)
    {
        char inbuffer[1024];

        struct sockaddr_in temp;
        socklen_t len = sizeof(temp);
        ssize_t m = recvfrom(sockfd, &inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&temp, &len);
        if(m > 0)
        {
            // 接收成功
            inbuffer[m] = '\0';
            std::cerr << inbuffer << std::endl; // 信息分离,交给标准错误
        }
    }
}

上面的收到的数据,打印时打印到了标准错误中,而这样就可以信息分流了。在客户端第一次发送消息时给了用户上线消息通知

由于Online接口中使用ip:port区分用户,所以在InetAddr类中添加了ToString接口:

cpp 复制代码
std::string ToString()
{
    return "[" + _ip + ":" + std::to_string(_port) + "]";
}

用户名重名的很多,所以我们可以使用ip+port区分用户消息,客户端的SendMessage接口是向服务端发消息,所以客户端的带名称的信息最终会发给服务端,服务端同时会收到客户端的Socket信息,此时就获取到了客户端的ipport信息

客户端也可以自己获取,如果客户端自己获取之后,它获取的是自己在自己局域网的ipport,并不是在公网中的ipport,这样与服务器看到的客户端ipport不一致,所以依旧无法区分是哪个用户在发消息。

在服务器广播转发收到的消息中添加客户端的ipport信息

UdpServer

cpp 复制代码
void Start()
{
    // 假设字符串传递
    char inbuffer[1024];
    while(true)
    {
        struct sockaddr_in peer; // 定义结构体变量,存储对方的socket信息
        socklen_t len = sizeof(peer);
        // 获取用户发来的数据和socket信息
        ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&peer, &len);
        if(n > 0)
        {
            inbuffer[n] = '\0';
            // 1、检测新用户
            InetAddr clientaddr(peer);
            _handler_addr(clientaddr); // 回调UserManager函数检测注册新用户
            // 2、转发消息
            std::string tips = clientaddr.ToString(); // 新增
            std::string message = tips + inbuffer; // tips + inbuffer(nickname + 用户消息)
            _handler_msg(_sockfd, message); // 回调Route函数广播消息
        }
        else
        {
            LOG(LogLevel::ERROR) << "recvfrom error";
        }
    }
}

由于需要用到ipport信息,所以在InetAddr类中添加了ToString接口:

cpp 复制代码
std::string ToString()
{
    return "[" + _ip + ":" + std::to_string(_port) + "]";
}

编译运行:

如上,首先创建了两个管道文件,然后让其中一个客户端的标准错误信息交给fifo1,另一个客户端的标准错误信息交给fifo2,因为标准错误信息就是客户端收到的服务器广播转发的消息。
成功完成简单聊天室的编写

3.7 代码调用逻辑

整个代码的调用逻辑:

服务端:

客户端:

消息示例:

cpp 复制代码
客户端A(nickname="Alice") 发送 "hello"
  → 服务端收到:           "Alice# hello"
  → 服务端拼接地址:   "[10.0.0.1:12345]Alice# hello"
  → 广播给所有在线用户

代码调用逻辑图:

总结:
以上就是本期博客分享的全部内容啦!如果觉得文章还不错的话可以三连支持一下,你的支持就是我前进最大的动力!
技术的探索永无止境! 道阻且长,行则将至!后续我会给大家带来更多优质博客内容,欢迎关注我的CSDN账号,我们一同成长!
(~ ̄▽ ̄)~

相关推荐
Brilliantwxx6 小时前
【Linux】基础指令
linux·运维·服务器
ZhemgLee6 小时前
在Linux上使用Claude Code 并使用本地VS Code SSH远程访问的完整指南
linux·运维·服务器·ssh
CHANG_THE_WORLD6 小时前
4.串行接受多个客户端
linux
bush46 小时前
嵌入式linux学习记录十八,KGDB使用例
linux·学习
Tsuki_tl6 小时前
UDP传输协议
网络·网络协议·udp·传输层·udp校验和·tcp与udp区别·面向数据报
小则又沐风a6 小时前
库的原理:目标文件,ELF格式,程序加载
java·linux·前端
Cx330❀15 小时前
【MySQL基础】一文吃透“表的约束”:从 Null/Default 到主外键的终极安全法则
linux·服务器·数据库·c++·mysql·安全
程序员JerrySUN16 小时前
Jetson 刷机深度解析:flash.sh vs l4t_initrd_flash.sh(含安全与磁盘加密对比)
linux·网络·arm开发·安全·系统安全
六点_dn17 小时前
Linux学习笔记-printf命令
linux·运维·算法