【Linux】网络编程 —— Socket套接字 | 简易UDP网络程序

🌈欢迎来到Linux专栏 ~~ 网络编程

网络编程

一、UDP 网络程序

🎉字符串回响

🔥核心功能

分别实现客户端与服务器,客户端向服务器发送消息,服务器收到消息后,回响给客户端,有点类似于 echo 指令

该程序的核心在于 使用 socket 套接字接口,以 UDP 协议的方式实现简单网络通信

程序由 Server.hppServer.ccClient.cc 组成

🔥EchoServer.hpp 服务端

♻️常用函数介绍

初始化Init函数:

1️⃣第一步 --- 创建套接字 使用 socket 函数,本质就是打开网络文件,打开网卡

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

// 创建套接字(TCP/UDP	服务器/客户端)
int socket(int domain, int type, int protocol);

参数解析

  • domain:创建套接字用于哪种通信 (网络:AF_INET、本地:AF_UNIX 本质上都是宏)

  • type 选择数据传输类型 (流式/数据报)

  • protocol 选择协议类型 (支持根据参数2自动推导,一般设置为0

返回值 :创建成功后,返回套接字(文件描述符fd),失败返回 -1,错误码被设置

2️⃣填充网络信息ip端口号 port

其中ip地址分为两种:

  • 字符串风格的点分十进制IP地址 ,给人看的:192.168.1.1 ~ 15字符
  • 4字节IP地址

此处的IP地址与端口号都需要由用户传入

接着我们创建一个sockaddr_in结构体,

cpp 复制代码
struct sockaddr_in local;
转到定义
struct sockaddr_in
{
  __SOCKADDR_COMMON (sin_);
  in_port_t sin_port;			/* Port number.  */
  struct in_addr sin_addr;		/* Internet address.  */

  /* Pad to size of `struct sockaddr'.  */
  unsigned char sin_zero[sizeof (struct sockaddr)
	   - __SOCKADDR_COMMON_SIZE
	   - sizeof (in_port_t)
	   - sizeof (struct in_addr)];
};

其中sin_port只是用short int(无符号短整数)包装变成的,而sin_addr则是一个结构体,其只包含了32位的s_addr

cpp 复制代码
typedef uint32_t in_addr_t;
struct in_addr
{
  in_addr_t s_addr;
};

此处为什么也要有AF_INET呢?上面创建socket时候不是已经有了吗

  • 这其实是两种不同的检查,第一个socket告诉系统我要创建一个网络套接字 ,此处的AF_INET,是为了在未来的继承体系下辨别出来我要进行网络通信(多态)

接着来看看 16 位地址类型 ,转到定义可以发现它是一个宏函数 ,并且使用了 C语言 中一个非常少用的语法 ##将两个字符串拼接

cpp 复制代码
typedef unsigned short int sa_family_t; //无符号短整数,16位

#define	__SOCKADDR_COMMON(sa_prefix) \
  sa_family_t sa_prefix##family

最终会拼接成 sa_family_t sin_family 16位。此时所有的数据都对上了

了解完 sockaddr_in 结构体中的内容后,就可以创建该结构体了,再定义该结构体后,需要清空,确保其中的字段干净可用

将变量置为 0 可用使用 bzero 函数

cpp 复制代码
#include <strings.h> // bzero 函数的头文件

//2.补充网络信息
struct sockaddr_in local;
bzero(&local, sizeof(local));

local.sin_family = AF_INET;
local.sin_addr.s_addr = inet_addr(_ip.c_str()); //1.字符串风格转4字节 2.主机序列转网络序列
local.sin_port= htons(_port);

注意此时并没有 把IP地址和端口号信息设置到内核里,sockaddr_in 是一个数据类型,存在于local用户栈上的(仅仅停留在用户层)

注意:

  • 需要把主机序列转换为网络序列 ,可以使用 htons 函数
  • 需要把点分十进制的字符串,转换为无符号短整数 ,可以使用 inet_addr 函数,这个函数在进行转换的同时,会将主机序列转换为网络序列
  • 绑定IP地址和端口号这个行为并非直接绑定到当前主机中,而是在当前程序中,将创建的 socket 套接字,与目标IP地址与端口号进行绑定,当程序终止后,这个绑定关系也会随之消失
cpp 复制代码
//字符串类型转成4字节,并切换成网络序列
typedef uint32_t in_addr_t;

in_addr_t inet_addr(const char *cp); //指向字符串的指针

那如果是将4字节转换成字符串类型,并把网络序列切换成本地序列

cpp 复制代码
#include <arpa/inet.h>

char *inet_ntoa(struct in_addr in);

参数:4 字节的 IP地址的结构体

3️⃣绑定socket信息

使用 bind 函数进行绑定操作

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

// 绑定IP地址和端口号(TCP/UDP	服务器)
int bind(int sockfd, const struct sockaddr* addr, socklen_t addrlen);

参数解读

  • sockfd 创建成功的套接字
  • addr 包含通信信息的 sockaddr 结构体地址。此处要做强转(子类对象传给基类)
  • addrlen 结构体的大小

返回值 :成功返回 0,失败返回 -1

当前编写的 回响服务器 需要服务器拥有读取信息,然后回响给客户端的能力

首先服务器是一个死循环,所以Start函数直接进while

读取信息 使用 recvfrom 函数

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

// 读取信息(TCP/UDP	服务器/客户端)
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,struct sockaddr *src_addr, socklen_t *addrlen);

这个函数参数比较多,首先来看看前半部分

  • sockfd 使用哪个套接字进行读取(如果系统存在多个) ------ 一般是socket() 返回的那个描述符
  • buf 读取数据存放缓冲区,用户提供,保存收到的数据!
  • len 缓冲区的大小
  • flags 读取方式(阻塞读取0 、非阻塞读取)

前半部分主要用于读取数据,并进行存放,接下来看看后半部分

  • src_addr 输入输出型参数 ,对端主机的 sockaddr 结构体 ,包含了对端的 IP地址 和 端口号
  • addrlen 输入输出型参数 ,传入对端主机的 sockaddr 结构体大小,返回实际结构体大小(往往两个值是相等的)

这个输入输出型参数就类似于送礼时留下自己的信息,待对方还礼时可以知道还给谁,接收信息也是如此,当服务器获取客户端的 sockaddr结构体信息 后,同样可以给客户端发送信息,双方就可以愉快的进行通信了

返回值 :成功返回实际读取的字节数 ,失败返回 -1

接收消息步骤:

  • 创建缓冲区、对端 sockaddr_in 结构体
  • 接收信息,判断是否接收成功
  • 处理信息

注意: 因为 recvfrom 函数的参数 src_addr 类型为 sockaddr,需要将 sockaddr_in 类型强转后,再进行传递

既然能接受信息,那么也要可以发送信息: sendto 函数

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

// 发送信息(TCP/UDP	服务器/客户端)
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);

这个函数的参数几乎与 recvfrom 的一模一样

  • sockfd :使用哪个套接字进行发送
  • buf :发送数据存放缓冲区 ------ 发送什么数据?
  • len :缓冲区的大小
  • flags :发送方式(阻塞/非阻塞)------ 怎么发送给对方?
  • src_addr :对端主机的 sockaddr 结构体,包含了对端的 IP地址端口号 ------ 发送给谁?
  • addrlen :对端主机的 sockaddr 结构体大小

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

注意: 发送数据回去时,不需要把主机序列转成网络序列 ,因为从网络里收到的客户端的ip和端口本来就是网络序列的,不需要再转,直接用即可

cpp 复制代码
#pragma once

#include <iostream>
#include <strings.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdlib.h>

#include "Logger.hpp"

using namespace NS_LOG_MODULE;

const static int defult_fd = -1;
const static int defult_port = 8888;

enum
{
    SUCCESS = 0,
    SOCKET_ERR,
    USAGE_ERR,
    BING_ERR,
};

class UdpServer
{
public:
    UdpServer(uint16_t port)
        : _sockfd(defult_fd),
          _port(port)
    {
    }
    ~UdpServer() 
    {
        close(_sockfd);
    }
    void Init()
    {
        // 1.创建socket, 本质:打开网络文件 网卡
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0);
        if (_sockfd < 0)
        {
            // 引入日志
            LOG(LogLevel::FATAL) << "creat socket err";
            exit(SOCKET_ERR);
        }
        LOG(LogLevel::INFO) << "creat socket success, socket:" << _sockfd;

        // 2.补充网络信息
        struct sockaddr_in local;
        bzero(&local, sizeof(local));

        local.sin_family = AF_INET;
        // local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 1.字符串风格转4字节 2.主机序列转网络序列
        local.sin_port = htons(_port);
        local.sin_addr.s_addr = INADDR_ANY; //接受任何ip的信息

        // 3. bind socket信息
        int n = bind(_sockfd, (const sockaddr *)&local, sizeof(local));
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "bind socket err";
            exit(BING_ERR);
        }
        LOG(LogLevel::INFO) << "bind socket success"  << ", port: " << _port;
    }

    void Start()
    {
        // 传递的是字符串,不希望得到'\0'
        char inbuffer[1024];
        while (true)
        {
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            // 0.接受信息
            // 1.用户发来的信息
            // 2.用户的socket信息
            ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr *)&peer, &len);
            if (n > 0)
            {
                //用户的IP和Port信息是recvfrom从网络中获取的,但仍需网络序列(大端)---> 主机序列
                // peer.sin_addr;
                // peer.sin_port;
                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) + "]";

                //2.处理信息
                inbuffer[n] = 0;
                LOG(LogLevel::DEBUG) << client_address << inbuffer;
                std::string echo_string = "sever echo# ";
                echo_string += inbuffer;

                //3.发送数据回去
                //此处不需要把主机序列转成网络序列,因为从网络里收到的客户端口本来就是网络序列的,不需要再转,直接用即可
                sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0, (struct sockaddr *)&peer, len);
            }
            else
            {
                LOG(LogLevel::ERROR) << "recvfrom error";
            }
        }
    }

private:
    int _sockfd;
    // std::string _ip; //"192.168.1.1"(字符串风格的点分十进制IP地址,给人看的) 15个字节 && 4字节
    uint16_t _port;  // 用户设置好的,server port必须是固定的!防止客户端找不到
};

EchoServerMain.cc

cpp 复制代码
#include "EchoServer.hpp"
#include <memory>

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

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

    ENABLE_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_port);
    usvr->Init();
    usvr->Start();
    
    return 0;
}

🔥Client.cc 客户端

客户端在运行时,必须知道服务器的 IP 地址端口号 ,否则不知道自己该与谁进行通信,所以对于 UdpClient 类来说,ipport 者两个字段是肯定少不了的。

  • 不同于服务器的是 💥客户端不需要自己手动绑定 IP 地址与端口号(不让显示绑定)

1️⃣为什么不让client显示bind?

client port 只需要具有唯一性即可 。具体是多少,不重要。服务器并不关心客户端到底使用哪个端口。它只需要知道:请求来自192.168.1.100:50032,回复的时候发回这个地址即可。

所以客户端端口完全可以交给操作系统随机分配。

  • 显示绑定有可能会出现冲突问题! 如果恰好有两个程序使用了同一个端口,会导致其中一方的客户端直接绑定失败,无法运行。

2️⃣如何设置自己的IP和端口呢?

将绑定 端口号 这个行为交给 OS 自动执行 ,也就是 udp client 首次发送数据 的时候,OS底层会隐式自动的帮你进行获取随机端口 ,然后绑定 ~ Port + IP

服务端是一定要求显示绑定的,不能随机绑定!(好比110、119这样的紧急号码能经常变动吗??相反我们老百姓的电话只要能标识唯一性即可,是多少不重要)

第一步:创建socket,打开网卡

cpp 复制代码
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd < 0)
{
    std::cerr << "socket create fail" << std::endl;
    exit(2);
}

构建sever端socket信息

cpp 复制代码
struct sockaddr_in server;
memset(&server, 0, sizeof(server));

server.sin_family = AF_INET;
server.sin_addr.s_addr = inet_addr(server_ip.c_str()); //本地到网络,并且变成4字节
server.sin_port = htons(sever_port); //主机转网络

第二步:获取用户输入 ------ 发送给服务器 ------ 再收取网络的消息

1️⃣我们客户端怎么知道sever端的IP和端口呢?类似IP + Port是被内置到client的!!(抖音的客户端能不知道它自己的服务端吗? 都是一家的,早给你内置好了)

cpp 复制代码
while (true)
{
    std::string message;
    // 1. 获取用户输入
    std::cout << "Please Enter#: ";
    std::getline(std::cin, message);

    // 2. client 发送数据给sever端,首次发送自动绑定客户端的IP + Port
    ssize_t n = sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)&server, sizeof(server));
    if (n > 0)
    {
        // 读取消息 recvfrom
        char inbuffer[1024] = {0};
        struct sockaddr_in tmp;
        socklen_t len = sizeof(tmp);
        ssize_t m = recvfrom(sockfd, &inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&tmp, &len);
        if(m > 0)
        {
            inbuffer[m] = 0;    
            std::cout << inbuffer << std::endl;
        }
    }
}

接下来我们可以在本地测试一下这个demo,ip地址为127.0.0.1本地环回IP

如果我们绑定公网IP呢? ------ 会出现绑定错误

最佳实践: 任意地址绑定

  • 云服务器的公网IP,是禁止被显示的bind 的!!
  • 在服务器开发的时候,特别不建议用户显示bind IP地址!
  • 云服务器的公网IP,是禁止被显示的bind 不等于 云服务器的公网ip不会被使用 ------ 服务端上不给绑定,但客户端上可以公网访问服务端

使用了任意地址绑定后,客户端发三种ip地址(公网,局域网,本地环回),服务端都能接收到

源码:

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

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

// ./client sever_ip sever_port
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }

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

    // 1.创建套接字:打开网卡
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0)
    {
        std::cerr << "socket create fail" << std::endl;
        exit(2);
    }

    // 2.构建sever端socket信息
    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));

    server.sin_family = AF_INET;
    server.sin_addr.s_addr = inet_addr(server_ip.c_str()); // 本地到网络,并且变成4字节
    server.sin_port = htons(sever_port);                   // 主机转网络

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

        // 2. client 发送数据给sever端,首次发送自动绑定客户端的IP + Port
        ssize_t n = sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)&server, sizeof(server));
        if (n > 0)
        {
            // 读取消息 recvfrom
            char inbuffer[1024] = {0};
            struct sockaddr_in tmp;
            socklen_t len = sizeof(tmp);
            ssize_t m = recvfrom(sockfd, &inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&tmp, &len);
            if(m > 0)
            {
                inbuffer[m] = 0;    
                std::cout << inbuffer << std::endl;
            }
        }
    }
    return 0;
}

接下来再学习一个命令netstat查看系统中已经启动的服务

  • -a选项:查看所有的服务
  • -n选项:以数字方式查看
  • -u选项:只显示的是udp相关的内容,-t就是显示tcp内容
  • -p选项:多一列来显示清楚进程信息
    谈谈细节:
    云服务器的端口号都是被禁止打开的! 我们能用到的端口号是因为在腾讯云的后台上提前设置了。
    如果要用云服务器的端口,记得提前打开

📚汉译英网络字典

🌊业务处理函数解耦

基于模块化处理 的思想,将服务器中处理消息的函数与启动服务的函数解耦 ,由程序员传入指定的回调函数

此时业务处理函数已经变成一个模块了,可以自由变换

  • 业务处理函数A:实现大写转小写
  • 业务处理函数B:实现远程 汉译英
  • 业务处理函数C:实现 xxx

服务器在启动时,只需要传入对应的业务处理函数(回调函数) 即可

于是乎修改server.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <strings.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <functional>

#include "Logger.hpp"

using namespace NS_LOG_MODULE;

const static int defult_fd = -1;
const static int defult_port = 8888;

using callback_t = std::function<std::string (std::string)>;

enum
{
    SUCCESS = 0,
    SOCKET_ERR,
    USAGE_ERR,
    BING_ERR,
};

class UdpServer
{
public:
    UdpServer(callback_t cb, uint16_t port)
        : _sockfd(defult_fd),
          _port(port),
          _cb(cb)
    {
    }
    ~UdpServer() 
    {
        close(_sockfd);
    }
    void Init()
    {
        // 1.创建socket, 本质:打开网络文件 网卡
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0);
        if (_sockfd < 0)
        {
            // 引入日志
            LOG(LogLevel::FATAL) << "creat socket err";
            exit(SOCKET_ERR);
        }
        LOG(LogLevel::INFO) << "creat socket success, socket:" << _sockfd;

        // 2.补充网络信息
        struct sockaddr_in local;
        bzero(&local, sizeof(local));

        local.sin_family = AF_INET;
        // local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 1.字符串风格转4字节 2.主机序列转网络序列
        local.sin_port = htons(_port);
        local.sin_addr.s_addr = INADDR_ANY; //接受任何ip的信息

        // 3. bind socket信息
        int n = bind(_sockfd, (const sockaddr *)&local, sizeof(local));
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "bind socket err";
            exit(BING_ERR);
        }
        LOG(LogLevel::INFO) << "bind socket success"  << ", port: " << _port;
    }

    void Start()
    {
        // 传递的是字符串,不希望得到'\0'
        char inbuffer[1024];
        while (true)
        {
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            // 0.接受信息
            // 1.用户发来的信息
            // 2.用户的socket信息
            ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr *)&peer, &len);
            if (n > 0)
            {
                //用户的IP和Port信息是recvfrom从网络中获取的,但仍需网络序列(大端)---> 主机序列
                // peer.sin_addr;
                // peer.sin_port;
                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) + "]";

                //2.处理信息
                inbuffer[n] = 0;
                std::string result = _cb(inbuffer);


                //3.发送数据回去
                //此处不需要把主机序列转成网络序列,因为从网络里收到的客户端口本来就是网络序列的,不需要再转,直接用即可
                sendto(_sockfd, result.c_str(), result.size(), 0, (struct sockaddr *)&peer, len);
            }
            else
            {
                LOG(LogLevel::ERROR) << "recvfrom error";
            }
        }
    }

private:
    int _sockfd;
    uint16_t _port;  // 用户设置好的,server port必须是固定的!防止客户端找不到
    callback_t _cb; //用回调方式来进行数据处理
};

🌊英译汉转化

此处选择使用一个哈希表 来存储对应的数据。在构造时进行文件加载操作

文件加载过程中将每行数据进行分割存入哈希

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <fstream>
#include "Logger.hpp"
#include <unordered_map>

static const std::string default_dict = "./dixt.txt";
static const std::string sep = ": ";

using namespace NS_LOG_MODULE;

class Dict
{
public:
    Dict(const std::string &dict_path = default_dict) : _dict_path(dict_path)
    {
        LoadDict();
    }
    ~Dict()
    {
    }
    // 加载文件操作
    void LoadDict()
    {
        std::ifstream in(_dict_path);
        if (!in.is_open())
        {
            LOG(LogLevel::FATAL) << "open" << _dict_path << "error";
            exit(1);
        }
        std::string line;
        while (getline(in, line)) // 从指定文件流获取一行的内容,行分隔符'\n'
        {
            LOG(LogLevel::FATAL) << "load: " << line << " success";
            // apple: 苹果
            auto pos = line.find(sep); // 找到了冒号,截取是( ] 前开后闭
            if (pos == std::string::npos)
            {
                LOG(LogLevel::WARNING) << "Format: " << line << "error";
                continue;
            }

            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 Translate(std::string word)
    {
        auto iter = _dict.find(word);
        if(iter != _dict.end())
        {
            return _dict[word];
        }
        else
        {
            return "None";
        }
    }
private:
    std::string _dict_path;
    std::unordered_map<std::string, std::string> _dict;
};

主函数:Main.cc

cpp 复制代码
#include "EchoServer.hpp"
#include "Dict.hpp"
#include <memory>

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

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

    ENABLE_CONSOLE_LOG_STRATEGY();
    //1.构建字典
    Dict dict;
    //2.构建网络服务
    uint16_t server_port = std::stoi(argv[1]);
    //匿名函数绑定两个模块
    std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>([&dict](std::string word){
        return dict.Translate(word);
    } ,server_port);
    usvr->Init();
    usvr->Start();
    
    return 0;
}

最后进行测试:跑出来确实是可以进行转化的

接下来理顺整个demo的执行逻辑:

上述的这个英译汉还只停留在串行地发和收,可否把收到所有的客户端都统一管理起来,然后进行并发的执行呢?

🏠简单聊天室

之前我们写线程池的时候,只说过让线程帮我们处理各种任务,但是之前没说过任务是从哪里来的? ------ 任务从网络中来

在此版本我们还需把所收到的客户端给管理起来

🌈核心功能

这是基于 UDP 协议实现的最后一个网络程序,主要功能是 构建一个多人聊天室,当某个用户发送消息时,其他用户可以立即收到,形成一个群聊

在这个程序中,服务器扮演了一个接收消息和分发消息的角色,将消息发送给已知的用户主机

接下来就是按各类头文件去逐个实现

🌈InetAddr.hpp 描述客户端

InetAddr.hpp 封装了网络地址,主要功能:

  1. 两种构造方式 --- 从 struct sockaddr_in 构造(解析出 IP 和端口 ),或从 port + ip 字符串构造(填充 sockaddr_in 结构体
  2. 获取底层网络地址 --- GetNetAddress() 返回 sockaddr_in*
  3. 重载operator== IP 和端口判断地址是否相等
  4. 便捷的 Len() --- 返回地址结构体长度
cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <strings.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

// 对客户端进行先描述
class InetAddr
{
public:
    //从网络序列解析iP和端口
    InetAddr(const struct sockaddr_in &address) : _address(address), _len(sizeof(address))
    {
        _ip = inet_ntoa(_address.sin_addr);
        _port = ntohs(_address.sin_port);
    }
    // ip和端口号来填充sockaddr_in
    InetAddr(uint16_t port, const std::string &ip = "0.0.0.0") : _ip(ip), _port(port) // 给ip和端口转化成_address
    {
        bzero(&_address, sizeof(_address));
        _address.sin_family = AF_INET;
        _address.sin_addr.s_addr = inet_addr(_ip.c_str()); // 1.字符串风格转4字节 2.主机序列转网络序列
        _address.sin_port = htons(_port);
        _len = sizeof(_address);
    }
    bool operator == (const InetAddr &addr)
    {
        return (this->_ip == addr._ip) && (this->_port == addr._port);
    }

    struct sockaddr_in *GetNetAddress()
    {
        return &_address;
    }
    socklen_t Len()
    {
        return _len;
    }
    InetAddr()
    {}
    ~InetAddr()
    {}

private:
    // net address
    struct sockaddr_in _address;
    socklen_t _len;
    // host address
    std::string _ip;
    uint16_t _port;
};

上述已经完成了对客户端的描述工作了,接下来就是对其进行管理!

🌈UserManager.hpp 管理客户端

主要实现五种:增删查改 + 遍历 ,为了方便此处选择使用vector容器进行管理~

方法 功能
AddUser 添加用户(已存在则跳过)
DelUser 删除用户
SearchUser 按 InetAddr 查找用户是否存在
ModUser "修改"用户(实际是先删再增
Users 返回当前所有在线用户列表
cpp 复制代码
#pragma once

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

using namespace NS_LOG_MODULE;

// 增删查改
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
                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);
    }
    //返回其用户
    std::vector<InetAddr> &Users()
    {
        return _users;
    }

    ~UserManager()
    {}

private:
    std::vector<InetAddr> _users;
};

🌈Route.hpp 路由模块 ------ 专注于业务

此模块负责消息的路由分发,此模块要进行加锁处理

此模块重点是Broadcast函数:加锁 → 拿到在线用户列表 → 把同一条消息依次发给每个在线用户

  • 创建RAII锁对象
  • 通过调用 UserManager::Users(),获得内部 vector 容器users的引用
  • 遍历所有在线用户 ------ 逐个sendto,层层下探,最后可以拿到GetNetAddress()、和Len()。因为最后遍历的个体是InetAddr
cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <sys/socket.h>
#include "UserManager.hpp"

// 路由模块
class Route
{
public:
    Route() : _uma(std::make_unique<UserManager>())
    {}

    void CheckUser(const InetAddr &addr)
    {
        LockGuard Lockguard(_lock);
        _uma->AddUser(addr);
    }

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

    void Broadcast(int sockfd, std::string message)
    {
        LockGuard Lockguard(_lock);
        auto &users = _uma->Users(); //获取_user中的每一份
        for (auto &user : users)
        {
            //发送给
            sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)user.GetNetAddress(), user.Len());
        }
    }
    ~Route()
    {}

private:
    std::unique_ptr<UserManager> _uma; // _uma管理的是UserManager类型的对象
    Mutex _lock;
};

🌈UdpServer.hpp 纯粹的服务器

UdpServer 只负责网络 IO收数据 ),自己不处理业务逻辑。它通过 std::function 回调把控制权交还给上层(Route),上层决定怎么样去处理

UdpServer 的设计核心是将网络 I/O 与业务逻辑完全解耦,通过两个回调作为桥梁:

  • RegisterService() --- 接收上层注册的两个回调并存储
  • Start() --- 阻塞循环 recvfrom(),收到数据后依次触发两个回调
cpp 复制代码
#pragma once

#include <iostream>
#include <strings.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <functional>
#include "InetAddr.hpp"
#include "Logger.hpp"

using namespace NS_LOG_MODULE;

const static int defult_fd = -1;
const static int defult_port = 8888;

// 回调给上层 Route
using handle_addr_t = std::function<void(const InetAddr &)>;
using handle_msg_t = std::function<void(int sockfd, std::string msg)>;

enum
{
    SUCCESS = 0,
    SOCKET_ERR,
    USAGE_ERR,
    BING_ERR,
};

class UdpServer
{
public:
    UdpServer(uint16_t port)
        : _sockfd(defult_fd),
          _port(port)
    {
    }
    ~UdpServer()
    {
        close(_sockfd);
    }
    void Init()
    {
        // 1.创建socket, 本质:打开网络文件 网卡
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0);
        if (_sockfd < 0)
        {
            // 引入日志
            LOG(LogLevel::FATAL) << "creat socket err";
            exit(SOCKET_ERR);
        }
        LOG(LogLevel::INFO) << "creat socket success, socket:" << _sockfd;

        // 2.补充网络信息
        InetAddr local(_port);

        // 3. bind socket信息
        int n = bind(_sockfd, (const sockaddr *)(local.GetNetAddress()), local.Len());
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "bind socket err";
            exit(BING_ERR);
        }
        LOG(LogLevel::INFO) << "bind socket success" << ", port: " << _port;
    }

    // 注册服务
    void RegisterService(handle_addr_t handle_addr, handle_msg_t handle_msg)
    {
        _handle_addr = handle_addr;
        _handle_msg = handle_msg;
    }

    void Start()
    {
        // 传递的是字符串,不希望得到'\0'
        char inbuffer[1024];
        while (true)
        {
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            // 0.接受信息
            // 1.用户发来的信息
            // 2.用户的socket信息
            ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr *)&peer, &len);
            if (n > 0)
            {
                inbuffer[n] = 0;
                // 1.检测新用户
                InetAddr clientaddress(peer);
                _handle_addr(clientaddress);
                // 2.转发消息
                _handle_msg(_sockfd, inbuffer);
            }
            else
            {
                LOG(LogLevel::ERROR) << "recvfrom error";
            }
        }
    }

private:
    int _sockfd;
    uint16_t _port; // 用户设置好的,server port必须是固定的!防止客户端找不到
    handle_addr_t _handle_addr;
    handle_msg_t _handle_msg;
};

🌈ChatServerMain.cc 中间人

此模块就是组装层,负责把三个模块拼起来(匿名函数)

main() 做的事:创建三者 → 通过 RegisterService 把 Route 的方法绑到 UdpServer 的回调上 → 启动事件循环

cpp 复制代码
Start() 收消息 → _handle_msg 入队 → 立即返回继续收
                                     ↓
                              worker 线程取出任务 → r.Broadcast
cpp 复制代码
#include "UdpServer.hpp" //获取事件
#include "Route.hpp" //任务(包装事件后 交给执行者处理)
#include "SignalTon.hpp" //执行者, 处理动作
#include <memory>


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

using namespace NS_THREAD_POOL;

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

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

    ENABLE_CONSOLE_LOG_STRATEGY();

    //2.构建网络服务
    uint16_t server_port = std::stoi(argv[1]);

    //线程池模块
    auto Thread_Pool = ThreadPool<task_t>::Instance();

    //路由模块
    Route r;

    //网络模块
    UdpServer usvr(server_port);
    usvr.Init();

    //绑定路由  ------ 把server层检测到的任务无情转发给 路由模块
    usvr.RegisterService(
        [&r](const InetAddr &addr){
            r.CheckUser(addr);
        }, 
        [&r, Thread_Pool](int sockfd, std::string msg){
            Thread_Pool->Enqueue([&r, &sockfd, &msg](){
                r.Broadcast(sockfd, msg);
            });
        });

    usvr.Start();

    
    return 0;
}

🌈客户端优化 --- 多线程

make测试后发现,客户端之间不能及时的收到消息(甚至我发的lll,收到的是其他客户端发来的ccc)

我们的客户端不是阻塞发送吗:先发送数据再读取信息,不发送就不会进行读取

于是要改成多线程:

cpp 复制代码
#include <iostream>
#include <string>
#include <cstdlib>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include "InetAddr.hpp"
#include "Thread.hpp"

using namespace NS_Thread_Module;

int sockfd = 0; // 全局
std::string server_ip;
uint16_t server_port = 0;

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

void RecvMessage()
{
    while (true)
    {
        // 读取消息 recvfrom
        char inbuffer[1024] = {0};
        struct sockaddr_in tmp;
        socklen_t len = sizeof(tmp);
        ssize_t m = recvfrom(sockfd, &inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr *)&tmp, &len);
        if (m > 0)
        {
            inbuffer[m] = 0;
            std::cerr << inbuffer << std::endl; //往二号文件描述符打印
        }
    }
}

// 不断发送信息
void SendMessage()
{
    InetAddr serveraddr(server_port, server_ip); // 填充sockaddr_in
    while (true)
    {
        std::string message;
        // 1. 获取用户输入
        std::cout << "Please Enter#: "; //打印往标准输出
        std::getline(std::cin, message);

        // 2. client 发送数据给sever端,首次发送自动绑定客户端的IP + Port
        ssize_t n = sendto(sockfd, message.c_str(), message.size(), 0,
                           (struct sockaddr *)serveraddr.GetNetAddress(), serveraddr.Len());
        (void)n;
    }
}

// ./client sever_ip sever_port
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }

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

    // 1.创建套接字:打开网卡
    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0)
    {
        std::cerr << "socket create fail" << std::endl;
        exit(2);
    }

    // 创建线程
    Thread recver(RecvMessage);
    Thread sender(SendMessage);

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

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

    return 0;
}

继续测试:发现是可以实现两个客户端进行简单通信的 ~

此处与什么管道并没有关系,客户端里接收消息走的是 recvfrom(),收到后打印到 std::cerr2>pipe、2>pipe2 只改变了 cerr 的目的地(从终端改成管道),并不改变消息的接收方式 ------ 两个客户端依然通过同一个 UDP 服务器转发广播,所以不管你把 stderr 重定向到哪里,客户端都能正常收到消息

如果刚开始客户端不说话,是发送不了给你的。只有你启动客户端,给服务器发个消息之后,就会把你这个用户添加到在线列表里 ------ 才能收到后续消息

接着把自己的登录名字和ip与端口号加上 ------ 完善

哈哈哈全体起立 ~ 卢本伟广场

💫理顺全流程

📢写在最后

接下来是 tcp 编程