首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >【Linux网络】Socket编程:UDP网络编程实现ChatServer

【Linux网络】Socket编程:UDP网络编程实现ChatServer

作者头像
Ronin305
发布2025-12-22 13:35:46
发布2025-12-22 13:35:46
720
举报
文章被收录于专栏:我的博客我的博客

上篇文章我们实现了英译汉的网络字典,客服端向服务端发送英文,服务端接收数据后回调处理,将翻译后的中文再转发给客户端,这其实和EchoSever一样都是一对一的网络通信。我们也可以实现多个客户端之间进行网络通信,通过服务端将一个客户端发送的消息转发给所有客户端,这样大家都能够看到你发的消息,以此来达到一个简易聊天室的效果。所以我们服务端在处理数据时就不再是简单的进行翻译了,而是要实现一个可以路由转发的功能。

1. 封装路由转发功能的类

现在我们就不再是将数据进行翻译了,而是进行路由转发

框架如下:

代码语言:javascript
复制
#pragma once

#include <iostream>
#include <string>
#include <vector>
#include "Log.hpp"
#include "InetAddr.hpp"

using namespace LogModule;

class Route
{
public:
    Route()
    {}

    // 路由转发信息
    void MessageRoute(int sockfd, const std::string& message, InetAddr& peer)
    {

    }

    ~Route() {}
private:
    std::vector<InetAddr> _online_user; // 管理在线用户
};

我们要转发给所有在线用户,要怎么发呢?先描述再组织,通过数组来管理我们的在线用户(当然也可以使用其他数据结构,这里采用数组),而在线用户我们可以使用ip和端口号来标识,同时这里我们默认为第一次发送消息就等同于用户登录。

注意:参数中要有文件描述符,因为我们转发给每个在线用户时,是使用sendto系统调用,该函数参数中是需要文件描述符的

那么我们在转发前需要判断用户存不存在,不存在就要把用户添加到数组中

代码语言:javascript
复制
	bool IsExist(InetAddr& peer)
    {
        for(auto& user : _online_user)
        {
            if(user == peer)
            {
                return true;
            }
        }
        return false;
    }

我们直接将 InetAddr 类的对象比较是不可以的,因为我们没有在 InetAddr 类中重载比较运算符,所以我们可以先重载一个比较运算符

代码语言:javascript
复制
	bool operator==(const InetAddr& addr)
    {
        return _ip == addr._ip && _port == addr._port;
    }

添加新用户

代码语言:javascript
复制
	void AddUser(InetAddr& peer)
    {
        LOG(LogLevel::INFO) << "新增一个在线用户: " << peer.StringAddr();
        _online_user.push_back(peer);
    }

同样 ,这里为方便展示每个用户的信息,我们也还可以在 InetAddr 类中实现一个函数,直接返回用户信息的字符串

代码语言:javascript
复制
	std::string StringAddr()
    {
        return _ip + ":" + std::to_string(_port);
    }

如果用户想要退出呢?我们可以让用户通过输入 “QUIT” 来确认用户是否需要退出

代码语言:javascript
复制
	// 路由转发信息
    void MessageRoute(int sockfd, const std::string& message, InetAddr& peer)
    {
        if(!IsExist(peer))
        {
            AddUser(peer);
        }

        std::string send_message = peer.StringAddr() + "# " + message;

        // 转发
        for(auto& user : _online_user)
        {
            sendto(sockfd, send_message.c_str(), send_message.size(), 0, (const struct sockaddr*)&user.NetAddr(), sizeof(user.NetAddr()));
        }

        if(message == "QUIT")
        {
            LOG(LogLevel::INFO) << "在线用户: " << peer.StringAddr() << " 退出聊天";
            DeleteUser(peer);
        }
    }

注意:我们转发消息的时候,发给每个在线用户是需要每个用户的网络地址的,所以我们可以在实现一个获取用户网络地址的函数(InetAddr中实现)

代码语言:javascript
复制
	struct sockaddr_in& NetAddr()
    {
        return _addr;
    }

转发消息也需要转发给自己,因为我自己也需要看到我发出的消息

最后我们这里还需要实现一个删除用户函数

代码语言:javascript
复制
	void DeleteUser(InetAddr& peer)
    {
        for(auto iter = _online_user.begin(); iter != _online_user.end(); iter++)
        {
            if(*iter == peer)
            {
                LOG(LogLevel::INFO) << "删除一个在线用户: " << peer.StringAddr() << " 成功";
                _online_user.erase(iter);
                break;
            }
        }
    }

2. UdpServer.hpp——服务端通信

这里我们服务端需要修改一下,我们现在接收客户端发来的消息后,并不是要将该消息处理好之后再次转发回原来的客户端,而是需要回调进行路由转发,将消息转发给所有在线用户,所以我们回调不需要得到返回结果,并且参数还要多增加一个文件描述符,因为路由转发需要用到文件描述符

那么包装器function需要修改

代码语言:javascript
复制
using func_t = std::function<void(int, const std::string&, InetAddr&)>;

同时,我们也不需要再给原来的客户端再次转发了,因为我们路由转发时,已经给包括原来客户端在内的所有在线用户都转发过了

完整代码:

代码语言:javascript
复制
#pragma once

#include <iostream>
#include <string>
#include <functional>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Log.hpp"
#include "InetAddr.hpp"


using namespace LogModule;

using func_t = std::function<void(int, const std::string&, InetAddr&)>;

class UdpServer
{
public:
    UdpServer(uint16_t port, func_t func)
        :_socketfd(-1), _port(port), _isrunning(false), _func(func)
    {}

    void Init()
    {
        _socketfd = socket(AF_INET, SOCK_DGRAM, 0);
        if(_socketfd < 0)
        {
            LOG(LogLevel::FATAL) << "socket error";
            exit(1);
        }
        LOG(LogLevel::INFO) << "socket success, socketfd: " << _socketfd;

        // 填充sockaddr_in结构体
        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;

        // 绑定IPv4地址结构
        int n = bind(_socketfd, (struct sockaddr*)&local, sizeof(local));
        if(n < 0)
        {
            LOG(LogLevel::FATAL) << "bind error";
            exit(2);
        }
        LOG(LogLevel::INFO) << "bind success, sockfd : " << _socketfd;
    }

    void Start()
    {
        _isrunning = true;
        while(_isrunning)
        {
            char buffer[1024];
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            ssize_t n = recvfrom(_socketfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);
            if(n > 0)
            {
                // 服务端需要知道客户端的ip和端口号
                InetAddr client(peer);

                buffer[n] = 0;

                // 回调进行路由转发、
                _func(_socketfd, buffer, client);
            }
        }
    }

    ~UdpServer() {}
private:
    int _socketfd;
    uint16_t _port; // 端口号
    bool _isrunning;

    func_t _func;
};

3. UdpServer.cc——服务端主程序

这里我们要实例化出一个路由转发的类,由它提供路由转发的服务

代码语言:javascript
复制
#include <memory>
#include "UdpServer.hpp"
#include "Route.hpp"

// ./udpserver port
int main(int argc, char* argv[])
{
    if(argc != 2)
    {
        std::cerr << "Usage: " << argv[0] << " port" << std::endl;
        return 1;
    }

    uint16_t port = std::stoi(argv[1]);

    Enable_Console_Log_Strategy();

    // 路由服务
    Route route;


    // 网络服务器对象提供网络通信功能
    std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port, [&route](int sockfs, const std::string& message, InetAddr& client){
        route.MessageRoute(sockfs, message, client);
    });
    usvr->Init();
    usvr->Start();
    return 0;
}

4. UdpServer.cc——客户端主程序

我们先来看一下客户端主程序的代码

代码语言:javascript
复制
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Log.hpp"

using namespace LogModule;

// ./udpclient server_ip server_port
int main(int argc, char* argv[])
{
    // 客户端需要绑定服务器的ip和port
    if(argc != 3)
    {
        std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;
        return 1;
    }

    Enable_Console_Log_Strategy();

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

    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if(sockfd < 0)
    {
        LOG(LogLevel::FATAL) << "socket error";
        return 2;
    }

    // 不需要手动绑定ip和端口,操作系统会分配一个临时端口与ip进行绑定

    // 填写服务器信息
    struct sockaddr_in server;
    memset(&server, 0, sizeof(server)); // 这里使用memset
    server.sin_family = AF_INET;
    server.sin_port = htons(server_port); // 转成网络字节序
    server.sin_addr.s_addr = inet_addr(server_ip.c_str()); // 字符串->网络字节序
    while(true)
    {
        // 从键盘获取要发送的数据
        std::string input;
        std::cout << "Client Enter# ";
        std::getline(std::cin, input);

        // 发送数据给服务器
        ssize_t n = sendto(sockfd, input.c_str(), input.size(), 0, (struct sockaddr*)&server, sizeof(server));
        if(n < 0)
        {
            LOG(LogLevel::FATAL) << "sendto error";
            return 3;
        }

        // 接收服务器转发回来的数据并回显在控制台上
        char buffer[1024];
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);
        ssize_t m = recvfrom(sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);
        if(m > 0)
        {
            buffer[m] = 0;
            std::cout << buffer << std::endl;
        }
    }

    return 0;
}

这里我们如果不发送消息就会阻塞在这,但是我们现实中,一般在群里聊天时,尽管我自己不发送消息,但是我也能看到别人发的消息啊,但是目前我们客户端的代码如果不发送消息就会一直阻塞,收不到任何其他客户端发送的消息,那要怎么做呢?

仔细想想,前面我们在学习多线程的时候,我们其实可以让一个线程发,一个线程收,并行处理,这样哪怕我今天不说话,被阻塞在这,但是不影响另一个线程收消息。

那么这里我们可以引入之前封装的线程

代码语言:javascript
复制
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Log.hpp"
#include "Thread.hpp"

using namespace LogModule;
using namespace ThreadModlue;

int sockfd = 0;
std::string server_ip;
uint16_t server_port = 0;
pthread_t id;

void Recv()
{
    while(true)
    {
        // 接收服务器转发回来的数据并回显在控制台上
        char buffer[1024];
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);
        ssize_t m = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
        if (m > 0)
        {
            buffer[m] = 0;
            std::cout << buffer << std::endl;
        }
    }
}

void Send()
{
    // 填写服务器信息
    struct sockaddr_in server;
    memset(&server, 0, sizeof(server)); // 这里使用memset
    server.sin_family = AF_INET;
    server.sin_port = htons(server_port); // 转成网络字节序
    server.sin_addr.s_addr = inet_addr(server_ip.c_str()); // 字符串->网络字节序
    while (true)
    {
        // 从键盘获取要发送的数据
        std::string input;
        std::cout << "Client Enter# ";
        std::getline(std::cin, input);

        // 发送数据给服务器
        ssize_t n = sendto(sockfd, input.c_str(), input.size(), 0, (struct sockaddr *)&server, sizeof(server));
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "sendto error";
            exit(3);
        }
        if(input == "QUIT")
        {
            pthread_cancel(id);
            break;
        }
    }
}

// ./udpclient server_ip server_port
int main(int argc, char *argv[])
{
    // 客户端需要绑定服务器的ip和port
    if (argc != 3)
    {
        std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;
        return 1;
    }

    Enable_Console_Log_Strategy();

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

    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0)
    {
        LOG(LogLevel::FATAL) << "socket error";
        return 2;
    }

    // 不需要手动绑定ip和端口,操作系统会分配一个临时端口与ip进行绑定

    Thread recver(Recv);
    Thread sender(Send);

    recver.Start();
    sender.Start();


    id = recver.Id();

    recver.Join();
    sender.Join();


   
    return 0;
}

由于我们创建两个线程之后的执行函数,我们定义在了全局,所以我们需要用到的一些变量也需要改成全局才能在线程中使用

运行测试一下:

在这里插入图片描述
在这里插入图片描述

我们可以看到输出混在了一起,由于我们只是实现一个简单的聊天室,不去做前端的一些相关处理,但是这里我们可以使用重定向,可以将客户自己的输入,输出到标准输出中,所有客户发送的消息则输出到标准错误中,那么我们就可以再打开一个服务器,将标准错误重定向到该服务器的终端,这样就能解决输入输出都混在一起的情况。或者也可以使用命名管道打印在另一个服务器的终端,这里我们就不做演示了


5. 引入线程池

我们客户端可以使用多线程,服务端其实也可以,服务端只需要接收消息,然后将消息作为一个任务,后面会有多个客户端给服务端发送消息,那么就会有多个任务,把任务交给准备好的线程池去执行这个任务,也就是进行路由转发

那不就相当于我们服务端是一个生产者,线程池就是一个消费者,那这不就是我们前面说过的生产者消费者模型嘛

在这里插入图片描述
在这里插入图片描述

那么在服务端主程序中,我们就不应该直接让Route类型的对象去完成路由转发,而是交给线程池,让线程池中的线程去通过Route来完成路由转发这个任务。

代码语言:javascript
复制
#include <memory>
#include "UdpServer.hpp"
#include "Route.hpp"
#include "ThreadPool.hpp"

using namespace ThreadPoolModule;

using task_t = std::function<void()>;

// ./udpserver port
int main(int argc, char* argv[])
{
    if(argc != 2)
    {
        std::cerr << "Usage: " << argv[0] << " port" << std::endl;
        return 1;
    }

    uint16_t port = std::stoi(argv[1]);

    Enable_Console_Log_Strategy();

    // 路由服务
    Route route;

    // 线程池
    auto tp = ThreadPool<task_t>::GetInstance();

    // 网络服务器对象提供网络通信功能
    std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port, [&route, &tp](int sockfd, const std::string& message, InetAddr& client){
        task_t t = std::bind(&Route::MessageRoute, &route, sockfd, message, client);
        tp->Enqueue(t);
    });
    usvr->Init();
    usvr->Start();
    return 0;
}

这里我们使用了bind包装器,在【C++】专栏中我们有专门介绍过包装器

运行测试:

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

其实在引入线程池之后,我们这里就会出现一些新的问题,还记得在我们线程池中,执行任务不是互斥的,虽然取任务是在互斥量中进行的,但是执行任务在临界区外,所以我们管理在线用户的vector就是一个全局资源,没有互斥锁的保护,我们线程池在访问全局资源时就会产生并发问题

那我们就不仅可以使用一个数组管理在线用户,还可以使用一个数组(其他数据结构也可以)来管理删除的用户,将任务拆分出来,今天我们就是一股脑的就是一个任务,其实可以拆分成新增用户的任务,删除用户的任务,路由转发的任务等等,我们就可以在执行不同任务时加锁,但是这些需要配合协议来完成,但是我们今天还只是对协议有一个朴素的理解,所以我们今天做不了,但是我们可以做。

但是我们也可以直接简单粗暴的在使用消息路由转发函数时加锁

代码语言:javascript
复制
	// 路由转发信息
    void MessageRoute(int sockfd, const std::string& message, InetAddr& peer)
    {
        LockGuard lockguard();
        if(!IsExist(peer))
        {
            AddUser(peer);
        }

        std::string send_message = peer.StringAddr() + "# " + message;

        // 转发
        for(auto& user : _online_user)
        {
            sendto(sockfd, send_message.c_str(), send_message.size(), 0, (struct sockaddr*)&user.NetAddr(), sizeof(user.NetAddr()));
        }

        if(message == "QUIT")
        {
            LOG(LogLevel::INFO) << "在线用户: " << peer.StringAddr() << " 退出聊天";
            DeleteUser(peer);
        }
    }

6. 补充拓展

6.1 关于inet_ntoa

代码语言:javascript
复制
#include <arpa/inet.h>

char *inet_ntoa(struct in_addr in);

inet_ntoa 这个函数返回了一个char*,很显然是这个函数自己在内部为我们申请了一块内存来保存ip的结果。那么是否需要调用者手动释放呢?

man手册上说,inet_ntoa 函数,是把这个返回结果放到了静态存储区。这个时候不需要我们手动进行释放.

在这里插入图片描述
在这里插入图片描述

那么问题来了,如果我们调用多次这个函数,会有什么样的效果呢?

在这里插入图片描述
在这里插入图片描述

运行结果如下:

在这里插入图片描述
在这里插入图片描述

因为 inet_ntoa 把结果放到自己内部的一个静态存储区,这样第二次调用时的结果会覆盖掉上一次的结果.

同时,inet_ntoa 是非线程安全的,在多线程环境中可能产生竞争条件。因此,在多线程环境下,推荐使用 inet_ntop。这个函数由调用者提供一个缓冲区保存结果,可以规避线程安全问题;


6.2 inet_ntop - 二进制到字符串转换

功能 将网络字节序的二进制 IP 地址转换为点分十进制字符串格式。

代码语言:javascript
复制
#include <arpa/inet.h>
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

参数说明

  • af:地址族,如 AF_INET (IPv4) 或 AF_INET6 (IPv6)
  • src:指向网络地址结构的指针
  • dst:目标缓冲区,用于存储转换后的字符串
  • size:目标缓冲区的大小

返回值

  • 成功:指向 dst 的指针
  • 失败:NULL,设置 errno

所以我们下面可以将 InetAddr 类中的网络字节序转换为点分十进制字符串格式的函数修改一下

代码语言:javascript
复制
	InetAddr(struct sockaddr_in &addr)
        : _addr(addr)
    {
        _port = ntohs(_addr.sin_port);   // 从网络中拿到的数据
        //_ip = inet_ntoa(_addr.sin_addr); // 网络字节序转点分十进制
        char ipbuffer[64];
        inet_ntop(AF_INET, &_addr.sin_addr, ipbuffer, sizeof(_addr));
        _ip = ipbuffer;
    }

6.3 inet_pton - 字符串到二进制转换

介绍完 inet_ntop ,那就不得不再介绍一下 inet_pton

功能 将点分十进制字符串格式的 IP 地址转换为网络字节序的二进制格式。

代码语言:javascript
复制
#include <arpa/inet.h>

// 字符串 -> 二进制 (Presentation to Network)
int inet_pton(int af, const char *src, void *dst);

参数

  • af:地址族 - AF_INET (IPv4) 或 AF_INET6 (IPv6)
  • src:源字符串(点分十进制 IP 地址)
  • dst:目标缓冲区,存储二进制结果

返回值

  • 1:成功
  • 0:输入不是有效的 IP 地址格式
  • -1:错误(地址族不支持等),设置 errno

我们这里还可以增加一个主机转网络构造函数

代码语言:javascript
复制
	InetAddr(const std::string &ip, uint16_t port)
        :_ip(ip), _port(port)
    {
        // 主机转网络
        memset(&_addr, 0, sizeof(_addr));
        _addr.sin_family = AF_INET;
        inet_pton(AF_INET, _ip.c_str(), &_addr.sin_addr);
        _addr.sin_port = htons(_port);
    }

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2025-10-10,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1. 封装路由转发功能的类
  • 2. UdpServer.hpp——服务端通信
  • 3. UdpServer.cc——服务端主程序
  • 4. UdpServer.cc——客户端主程序
  • 5. 引入线程池
  • 6. 补充拓展
    • 6.1 关于inet_ntoa
    • 6.2 inet_ntop - 二进制到字符串转换
    • 6.3 inet_pton - 字符串到二进制转换
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档