【Linux网络】网络通信实战:UDP & TCP Socket编程实现Echo Server

目录

  • [1 ~> Echo Server简单说明](#1 ~> Echo Server简单说明)
  • [2 ~> UDP Echo Server](#2 ~> UDP Echo Server)
    • [UDP 通信流程:](#UDP 通信流程:)
    • [2.1 服务端](#2.1 服务端)
      • 服务端框架如下:
      • [2.1.1 初始化](#2.1.1 初始化)
      • [2.1.2 运行服务端](#2.1.2 运行服务端)
      • [2.1.3 服务端外壳](#2.1.3 服务端外壳)
    • [2.2 客户端](#2.2 客户端)
  • [3 ~> TCP Echo Server](#3 ~> TCP Echo Server)
    • [TCP 通信流程](#TCP 通信流程)
    • [3.1 IPv4 地址结构体类](#3.1 IPv4 地址结构体类)
    • [3.2 服务端](#3.2 服务端)
      • 服务器框架:
      • [3.2.1 初始化](#3.2.1 初始化)
      • [3.2.2 启动服务端服务](#3.2.2 启动服务端服务)
      • [3.2.3 已连接套接字服务](#3.2.3 已连接套接字服务)
    • [3.3 客户端](#3.3 客户端)
    • [3.4 TCP Echo Server实现高并发](#3.4 TCP Echo Server实现高并发)
      • [3.4.1 多进程版本](#3.4.1 多进程版本)
      • [3.4.2 多线程版本](#3.4.2 多线程版本)
      • [3.4.3 线程池版本](#3.4.3 线程池版本)
  • [4 ~> 总结](#4 ~> 总结)

1 ~> Echo Server简单说明

Echo Server是一个简单的回显服务器和客户端数据的代码。目的是让我们进一步熟悉Socket API,以及UDPTCP通信的流程。

Echo Server主要由两部分构成:服务端代码和客户端代码。

同时,我们还会引入前面的日志,方便Debug和观察效果!

服务端代码我们进行封装,客户端代码简单写。这与UDP和TCP通信特点有关系,下面我们具体分析!

2 ~> UDP Echo Server

UDP 通信流程:

2.1 服务端

服务端框架如下:

cpp 复制代码
class UdpServer {
public:
    UdpServer() {}
    void Init()
    {
        // 1.创建套接字
        // 2.绑定
    }
    void Start() {}
    ~UdpServer() {}
private:
    int _sockfd;
    std::string _ip; // 127.0.0.1
    uint16_t _port;  // 端口号

    bool _isrunning; // 状态
    std::function<std::string(std::string)> _func;    // 消息处理
};

服务端负责:创建套接字,绑定,接收并处理客户端消息,然后发送给客户端。

消息处理采用回调函数的方式。


2.1.1 初始化

UDP只需要进行创建套接字和绑定IP + Port

cpp 复制代码
void Init()
{
    // 1.创建套接字:IPv4 + UDP
    _sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (_sockfd < 0) exit(1);
    
    // 2.绑定
    // 初始化
    struct sockaddr_in local;     // 地址结构体对象
    bzero(&local, sizeof(local)); // 清空addr的内容
    local.sin_family = AF_INET;   // 初始化协议家族为IPv4
    local.sin_addr.s_addr = INADDR_ANY; // INADDR_ANY就是0,大小端一样
    local.sin_port = htons(_port); // 将端口号转化为网络序列交给local
    // 绑定
    int n = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
    if (n < 0) exit(2);
}
  • 细节一:bind时绑定指定IP和绑定INADDR_ANY(0.0.0.0)有什么区别?
  • 首先服务端只能绑定本机IP,绑定指定IP后,即告诉操作系统只接收通过特定网卡到达的数据包,如果其他客户端尝试向本机的其他IP地址发送请求,系统会拒绝该连接或直接忽略。
  • 绑定INADDR_ANY,无论客户端的数据包是从哪张网卡到达本机特定端口,只要端口匹配,操作系统都会把该数据交给该程序处理。同时监听 127.0.0.1(回环)、192.168.x.x(内网)、公网 IP 。

通常我们直接绑定INADDR_ANY

  • 细节二:绑定IP和端口号时,必须先转化为网络序列(大端)。
  • 细节三:必须将bind的第二个参数强转为sockaddr_in类型。

2.1.2 运行服务端

从客户端接收数据包,处理后发送给客户端!

cpp 复制代码
void Start()
{
    _isrunning = true;
    // 服务端就是一个死循环一直等待客户端数据
    while (_isrunning)
    {
        // 1.从客户端接收消息
        char buffer[1024];
        struct sockaddr_in peer; // 输出型参数,获取客户端IP+端口号
        socklen_t len = sizeof(peer);
        // 阻塞式等待消息
        ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&peer, &len);
        if (n < 0) exit(3);
        buffer[n] = 0; // 手动添加'\0'
        std::string ip = inet_ntoa(peer.sin_addr); // 将IP转化为点分十进制
        uint16_t port = ntohs(peer.sin_port);        // 将端口号转化为主机序列
        std::cout << "Client [" << ip << "][" << port << "]Say@ " << buffer;
        
        // 2.处理客户端数据
        std::string result = _func(buffer);
        // 3.反馈给客户端
        ssize_t s = sendto(_sockfd, result.c_str(), result.size(), 0, (struct sockaddr*)&peer, sizeof(peer));
        (void)s;
    }
}

接收数据:recvfrom

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

通过文件描述符从套接字对象的接收缓冲区读数据,放到指定缓冲区。同时,通过输出型参数src_addr获取客户端属性(IP + Port)。

flags参数:设置为0,默认阻塞模式,如果没有数据,进程挂起直到有数据到达。

发送数据:sendto

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

通过文件描述符将发送的数据写入发送缓冲区,并通过参数dest_addr告诉客户端服务端属性。

flags参数:设置为0,默认阻塞模式:如果发送缓冲区满了,进程挂起直到有空间。

为什么说UDP通信是全双工?

在套接字(Socket)对应的内核数据结构中,操作系统为每一个 UDP 文件描述符(fd)都分配了两块完全独立的内存区域:

  • 发送缓冲区: 专门用来暂存应用程序准备发往网络的数据。

  • 接收缓冲区: 专门用来暂存网卡接收到的、等待应用程序读取的数据。

由于这两块缓冲区在物理内存和内核数据结构上是完全分开的,应用程序在执行 sendto()(往发送缓冲区写数据)的同时,完全可以并发地执行 recvfrom()(从接收缓冲区读数据),两者在内核层没有任何资源竞争。

2.1.3 服务端外壳

cpp 复制代码
// 简单数据处理方法:回调处理客户端数据
std::string Strategy(const std::string& message)
{
    std::string str = "hello, ";
    str += message;
    return str;
}

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

    uint16_t port = std::stoi(argv[1]);
    // 服务端
    std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port, Strategy);
    usvr->Init();
    usvr->Start();
    return 0;
}

2.2 客户端

cpp 复制代码
// ./udpclient server_ip server_port
int main(int argc, char* argv[])
{
    if (argc != 3)
    {
        std::cerr << "Should Enter: " << argv[0] << " ip port" << std::endl;
        return 1;
    }
    std::string server_ip = argv[1];
    uint16_t server_port = std::stoi(argv[2]);
    // 1.创建套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) return 2;

    // 2.初始化服务器信息
    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;                     // IPV4协议家族
    server.sin_addr.s_addr = inet_addr(server_ip.c_str()); // 点分十进制 -> 网络序列
    server.sin_port = htons(server_port);            // 转化为网络序列(大端)
    socklen_t len = sizeof(server);

    while (true)
    {
        // 2.向服务器发送请求
        std::string message;
        std::cout << "Please Enter@ ";
        getline(std::cin, message);

        // 阻塞式发送
        ssize_t n = sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&server, len);

        // 3.接收服务器的反馈
        char buffer[1024];
        struct sockaddr_in peer;  // 输出型参数,获取服务端信息
        socklen_t lens = sizeof(peer);
        ssize_t m = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&peer, &lens);
        if (m < 0) return 3;
        buffer[m] = 0;
        std::cout << "Echo Server@ " << buffer << std::endl;
    }
    return 0;
}
  • 细节:客户端不需要绑定,操作系统自己绑定(随机分配端口)

客户端非常多,由用户绑定可能会造成端口冲突。

3 ~> TCP Echo Server

TCP 通信流程

3.1 IPv4 地址结构体类

由于在TCP/UDP通信时,我们经常遇到初始化地址结构体对象,通过地址结构体对象获取IP和端口号,以及求结构体对象大小等等操作。

所以,我们将地址结构体单独封装为一个类,对外提供接口,这样就方便多了!

cpp 复制代码
class InetAddr {
public:
    // 构造函数重载:
    InetAddr() {}

    InetAddr(sockaddr_in addr)
        : _addr(addr)
    {
        // 网络序列 -> 主机序列
        // _ip = inet_ntoa(_addr.sin_addr); // 方法一:
        // 方法二:
        char buff[128];
        inet_ntop(AF_INET, &_addr.sin_addr, buff, sizeof(_addr));
        _ip = buff;

        _port = ntohs(_addr.sin_port);
    }

    // 传参构造(IP + Port):适合客户端
    InetAddr(std::string ip, uint16_t port)
        : _port(port),
        _ip(ip)
    {
        // 初始化_addr
        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);
    }

    // 传参构造(Port):适合服务端(服务端IP通常绑定0)
    InetAddr(uint16_t port)
        : _port(port),
        _ip()
    {
        // 初始化_addr
        memset(&_addr, 0, sizeof(_addr));
        _addr.sin_family = AF_INET;
        _addr.sin_addr.s_addr = INADDR_ANY; // 接收所有网卡数据
        _addr.sin_port = htons(_port);
    }

    // 赋值重载
    void operator=(InetAddr& peer)
    {
        peer._addr = _addr;
        char buff[128];
        inet_ntop(AF_INET, &_addr.sin_addr, buff, sizeof(_addr));
        peer._ip = buff;
        peer._port = ntohs(_addr.sin_port);
    }

    // 获取地址结构体对象
    sockaddr_in& Addr() { return _addr; }
    // 获取主机序列的IP(点分十进制)
    std::string Ip() { return _ip; }
    // 获取主机序列的port
    uint16_t Port() { return _port; }
    // 获取的地址结构体对象大小
    socklen_t GetSocklen() { return sizeof(_addr); }

    // 重载==
    bool operator==(InetAddr& a)
    {
        return (_ip == a._ip && _port == a._port);
    }

    // 主机进程标识:IP + Port
    std::string StringAddr()
    {
        std::string addr = _ip + " " + std::to_string(_port);
        return addr;
    }
    
    ~InetAddr() {}
private:
    sockaddr_in _addr; // 地址结构体对象
    std::string _ip;   // IP(点分十进制)
    uint16_t _port;    // 端口号
};

3.2 服务端

服务器框架:

cpp 复制代码
const int Defaultfd = -1;
class TCPserver {
public:
    TCPserver(uint16_t port)
        : _listen_sockfd(Defaultfd),
        _port(port),
        _isrunning(false)
    {}
    // 初始化模块
    void Init()
    {
        // 1.创建套接字
        // 2.绑定
        // 3.listen:将网络套接字设置为监听状态
    }
    // 服务模块
    std::string Service(int sockfd, InetAddr& peer) {}
    // 启动模块
    void Start()
    {
        // 获取连接
    }
    ~TCPserver() {}
private:
    int _listen_sockfd; // 监听套接字,只负责监听客户端连接
    uint16_t _port;     // 端口号
    bool _isrunning;    // 服务器状态
};

3.2.1 初始化

TCP 通信服务端初始化需要完成:

  • 创建套接字
  • 绑定IP + Port
  • 设置套接字为监听状态
cpp 复制代码
#define CAST(addr) ((sockaddr*)&(addr))  // 强转 -> sockaddr_in*
void Init()
{
    // 1.创建套接字
    _listen_sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (_listen_sockfd < 0) exit(1);

    // 2.绑定
    InetAddr server_addr(_port); // IPv4地址结构体对象
    int n = bind(_listen_sockfd, CAST(server_addr.Addr()), server_addr.GetSocklen());
    if (n < 0) exit(2);

    // 3.将网络套接字设置为监听状态
    int m = listen(_listen_sockfd, 128);
    if (m < 0) exit(3);
}

3.2.2 启动服务端服务

服务端accept 获取连接,如果客户端不连接就一直阻塞;当客户端发起connect,服务端获取到连接后,返回一个已连接套接字为该客户端提供服务。

同时peer获取到客户端信息(IP + Port)。

cpp 复制代码
void Start() {
    _isrunning = true;
    while (_isrunning)
    {
        // 1.阻塞式获取连接
        sockaddr_in peer;
        socklen_t len = sizeof(peer);
        // 返回一个新的文件描述符:负责为客户端提供服务
        int connected_sockfd = accept(_listen_sockfd, CAST(peer), &len);
        if (connected_sockfd < 0) continue;

        InetAddr addr(peer);
        Service(sockfd, addr);
    }
    _running = false;
}

3.2.3 已连接套接字服务

accept获取到连接后,返回已连接套接字(文件描述符),此时服务端进程通过对该套接字进行read / write,获取客户端请求,出来后恢复给客户端。

cpp 复制代码
std::string Service(int sockfd, InetAddr& peer)
{
    while (true)
    {
        // 1. 从接收缓冲区读
        char buff[1024];
        ssize_t n = read(sockfd, buff, sizeof(buff) - 1);
        if (n > 0)
        {
            buff[n] = 0;
            std::cout << "client say# " << buff << std::endl;

            // 2. 简单处理
            std::string echo_string = "hello, ";
            echo_string += buff;
            // 3. 写回发送缓冲区
            write(sockfd, echo_string.c_str(), echo_string.size());
        }
        else if (n == 0)
        {
            close(sockfd);
            break;
        }
        else
        {
            std::cout << peer.StringAddr() << " 异常..." << endl;
            close(sockfd);
            break;
        }
    }
    return "NONE";
}

3.3 客户端

客户端只需要创建套接字,然后发起连接,等服务端成功获取到连接后就可以通过接收缓冲区和发送缓冲区收发数据了。

cpp 复制代码
void Usage(std::string name)
{
    std::cout << "Please Enter# " << name << " IP port" << std::endl;
    exit(3);
}

// ./tcpclient 127.0.0.1 8080
int main(int args, char* argv[])
{
    if (args != 3) Usage(argv[0]);

    // 1.创建套接字
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) exit(1);

    // 2.系统内部绑定
    // 3.客户端发起连接
    std::string ip = argv[1];
    uint16_t port = std::stoi(argv[2]);
    InetAddr local(ip, port);
    int n = connect(sockfd, CAST(local.Addr()), local.GetSocklen());
    if (n < 0) exit(2);

    while (true)
    {
        std::string line;
        std::cout << "Please Enter# ";
        getline(std::cin, line);
        if (line == "QUIT") break;

        // 发送请求
        write(sockfd, line.c_str(), line.size());

        // 接收回复
        char buff[1024];
        ssize_t m = read(sockfd, buff, sizeof(buff) - 1);
        if (m > 0)
        {
            buff[m] = 0;
            std::cout << "server Echo# " << buff << std::endl;
        }
    }
    return 0;
}

3.4 TCP Echo Server实现高并发

当前我们的服务器在接收到客户端请求后,直接在本进程完成对该请求的处理(Service),这并不实用。

所以,我们在服务器接收到请求后,可以额外创建进程或者线程,将请求交给他们,也可以采用我们之前的线程池。

我们只需要在服务端Start中修改代码即可!!!

3.4.1 多进程版本

多进程就是fork一个子进程处理请求,但是这样处理,就需要父进程等待回收子进程,不然子进程就会变成僵尸进程,导致内存泄漏

而父进程需要不停地获取客户端连接,如果等待子进程结束,就会使得效率低下。

所以,我们让子进程继续fork一个孙子进程,然后子进程直接退出,父进程回收子进程,此时,孙子进程就变成孤儿进程了,由操作系统领养,处理完请求后由操作系统释放。

cpp 复制代码
void Start() {
    while (true)
    {
        // 获取连接
        // ...

        int pid = fork();
        if (pid < 0)
        {
            exit(1);
        }
        else if (pid == 0)
        {
            // child: 创建完孙子进程后直接退出
            // 防止父进程因等待子进程退出而阻塞,父进程还要负责获取连接
            int ppid = fork();
            if (ppid < 0)
            {
                exit(2);
            }
            else if (ppid == 0)
            {
                // 孙子进程:被OS领养(孤儿进程),执行service,进程退出后,OS自动释放
                Service(sockfd, addr);
                exit(0);
            }
            else
                exit(OK); // 子进程直接退出
        }
        waitpid(pid, nullptr, 0); // 父进程回收子进程
    }
}

3.4.2 多线程版本

cpp 复制代码
class PthreadData
{
public:
    PthreadData(int sockfd, InetAddr& addr, TCPserver* tsvr)
        : _sockfd(sockfd),
        _addr(addr),
        _tsvr(tsvr)
    {}
    ~PthreadData() {}

    int _sockfd;
    InetAddr _addr;
    TCPserver* _tsvr; // 方便在静态成员函数Routine中调用Service
};

static void* Routine(void* args)
{
    // 主动分离
    pthread_detach(pthread_self());
    PthreadData* data = static_cast<PthreadData*>(args);
    // 数据处理
    data->_tsvr->Service(data->_sockfd, data->_addr);
    delete data;
    return nullptr;
}


void Start()
{
    _isrunning = true;
    while (_isrunning)
    {
        // 获取连接
        // ...

        // 多线程
        pthread_t tid;
        PthreadData *data = new PthreadData(sockfd, addr, this);
        int n = pthread_create(&tid, nullptr, Routine, data);
    }
    _isrunning = false;
}

借助类内对象将需要的参数传入到Routine方法!!!

3.4.3 线程池版本

线程池完整代码:戳这里

cpp 复制代码
void Start()
{
    _isrunning = true;
    while (_isrunning)
    {
        // 获取连接
        // ...

        // 线程池:将任务派发给线程池(回调)
        PthreadPool<func_t>::GetInstance()->Equeue([this, sockfd, &addr]() {
            this->Service(sockfd, addr);
            });
    }
    _isrunning = false;
}

4 ~> 总结

Echo Server(回显服务器):最基础的网络通信模型。

通过对Echo Server的编程,我们对 Socket API 做了测试与练习,也掌握了UDPTCP的通信流程。

相关推荐
派葛穆1 小时前
s7-200smart-tcp/ip通讯
网络协议·tcp/ip
同勉共进11 小时前
记一例 vibe coding + gcc bug 导致的线程池死锁问题
c++·线程池·gcc·死锁·vibe coding
一叶龙洲12 小时前
wslg打开Ubuntu24.04默认打开图形界面
linux·服务器·数据库·ubuntu
星恒讯工业路由器14 小时前
5G FWA技术演进与趋势
网络·5g·信息与通信·4g·5g fwa·3gpp规范·fwa趋势
敖行客 Allthinker14 小时前
Parallels Ubuntu虚拟机项目如何让手机访问?完整解决方案
linux·运维·ubuntu
凌云C14 小时前
5G 协议体系与交互流程
网络协议
keyipatience15 小时前
线程栈与TLS和线程互斥
java·linux·服务器·开发语言·ubuntu
j7~15 小时前
【Linux】二十二.《Linux 信号机制完全指南:表示、捕捉与处理》
linux·信号处理·volatile·可入重函数·保存信号
观山岳五楼16 小时前
Ubuntu 24 怎么使用Ubuntu 20 的镜像源
linux·运维·ubuntu