网络编程『简易TCP网络程序』

这篇具有很好参考价值的文章主要介绍了网络编程『简易TCP网络程序』。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

🔭个人主页: 北 海
🛜所属专栏: Linux学习之旅、神奇的网络世界
💻操作环境: CentOS 7.6 阿里云远程服务器

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议



🌤️前言

随着数字时代的来临,TCP网络程序已成为程序员不可或缺的技术领域。本博客将带领读者深入研究,从最基础的字符串回响开始,逐步探索至多进程、多线程服务器的高级实践。我们将详细探讨每个环节的核心功能和实现细节,致力于帮助读者深刻理解网络编程的本质。通过系统学习本博客内容,读者将获得构建稳健网络应用的重要技能,更加自信地应对日益复杂的软件开发挑战。这里将为你的编程旅程提供扎实的基础和深远的启示。


🌦️正文

TCP网络程序

接下来实现一批基于 TCP 协议的网络程序


1.字符串回响

1.1.核心功能

字符串回响程序类似于 echo 指令,客户端向服务器发送消息,服务器在收到消息后会将消息发送给客户端,该程序实现起来比较简单,同时能很好的体现 socket 套接字编程的流程

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

1.2.程序结构

这个程序我们已经基于 UDP 协议实现过了,换成 TCP 协议实现时,程序的结构是没有变化的,同样需要 server.hppserver.ccclient.hppclient.cc 这几个文件

创建 server.hpp 服务器头文件

#pragma once

#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"

namespace nt_server
{
    const uint16_t default_port = 8888; // 默认端口号

    class TcpServer
    {
    public:
        TcpServer(const uint16_t port = default_port)
            :port_(port)
        {}

        ~TcpServer()
        {}

        // 初始化服务器
        void InitServer()
        {}

        // 启动服务器
        void StartServer()
        {}

    private:
        int sock_; // 套接字(存疑)
        uint16_t port_; // 端口号
    };
}

注意:这里的 sock_ 套接字成员后面需要修改

创建 server.cc 服务器源文件

#include <memory> // 智能指针头文件
#include "server.hpp"

using namespace std;
using namespace nt_server;

int main()
{
    unique_ptr<TcpServer> usvr (new TcpServer());

    usvr->InitServer();
    usvr->StartServer();
    return 0;
}

创建 client.hpp 客户端头文件

#pragma once

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

namespace nt_client
{
    class TcpClient
    {
    public:
        TcpClient(const std::string& ip, const uint16_t port)
            :server_ip_(ip), server_port_(port)
        {}

        ~TcpClient()
        {}

        // 初始化客户端
        void InitClient()
        {}

        // 启动客户端
        void StartClient()
        {}
        
    private:
        int sock_; // 套接字
        std::string server_ip_; // 服务器IP
        uint16_t server_port_; // 服务器端口号
    };
}

创建 client.cc 客户端源文件

#include <memory>
#include "client.hpp"

using namespace std;
using namespace nt_client;

void Usage(const char *program)
{
    cout << "Usage:" << endl;
    cout << "\t" << program << " ServerIP ServerPort" << endl;
}

int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        // 错误的启动方式,提示错误信息
        Usage(argv[0]);
        return USAGE_ERR;
    }

    // 服务器IP与端口号
    string ip(argv[1]);
    uint16_t port = stoi(argv[2]);

    unique_ptr<TcpClient> usvr(new TcpClient(ip, port));

    usvr->InitClient();
    usvr->StartClient();
    return 0;
}

同时需要一个 Makefile 文件,用于快速编译和清理可执行程序

创建 Makefile 文件

.PHONY:all
all:server client

server:server.cc
	g++ -o $@ $^ -std=c++11

	
client:client.cc
	g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
	rm -rf server client

最后为了方便判断程序错误,可以增加上一篇文章中的 err.hpp 头文件,里面包含错误码与简易错误信息

创建 err.hpp 错误码头文件

#pragma once

enum
{
    USAGE_ERR = 1,
    SOCKET_ERR,
    BIND_ERR
};

接下来开始填充代码内容


服务器


1.3.初始化服务器

基于 TCP 协议实现的网络程序也需要 创建套接字、绑定 IP 和端口号

在使用 socket 函数创建套接字时,UDP 协议需要指定参数2为 SOCK_DGRAMTCP 协议则是指定参数2为 SOCK_STREAM

注:关于 socketbindsockaddr 的细节,可以看看这篇文章《网络编程『socket套接字 ‖ 简易UDP网络程序』》

InitServer() 初始化服务器函数 — 位于 server.hpp 服务器头文件中的 TcpServer

const uint16_t default_port = 8888; // 默认端口号
    
// 初始化服务器
void InitServer()
{
    // 1.创建套接字
    sock_ = socket(AF_INET, SOCK_STREAM, 0);
    if(sock_ == -1)
    {
        std::cerr << "Create Socket Fail!" << strerror(errno) << std::endl;
        exit(SOCKET_ERR);
    }

    std::cout << "Create Socket Success! " << sock_ << std::endl;

    // 2.绑定IP地址与端口号
    struct sockaddr_in local;
    memset(&local, 0, sizeof(local)); // 清零
    local.sin_family = AF_INET;
    local.sin_addr.s_addr = INADDR_ANY; // 绑定任意可用IP地址
    local.sin_port = htons(port_);
    
    if(bind(listensock_, (const sockaddr*)&local, sizeof(local)))
    {
        std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;
        exit(BIND_ERR);
    }
            
    // 3.TODO
}

注意:在绑定端口号时,一定需要把主机序列转换为网络序列

为什么在绑定端口号阶段需要手动转换为网络序列,而在发送信息阶段则不需要?
这是因为在发送信息阶段,recvfrom / sendto 等函数会自动将需要发送的信息转换为网络序列,接收信息时同样会将其转换为主机序列,所以不需要手动转换

如果使用的 UDP 协议,那么初始化服务器到此就结束了,但我们本文中使用的是 TCP 协议,这是一个 面向连接 的传输层协议,意味着在初始化服务器时,需要设置服务器为 监听 状态

使用到的函数是 listen 函数

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>

int listen(int sockfd, int backlog);

参数解读:

  • sockfd 通过该套接字进行监听
  • backlog 全连接队列最大长度

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

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

这里的参数2需要设置一个整数,通常为 16、32、64...,表示 全连接队列 的最大长度,关于 全连接队列 的详细知识放到后续博客中讲解,这里只需要直接使用

server.hpp 服务器头文件

#pragma once

#include <iostream>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"

namespace nt_server
{
    const uint16_t default_port = 8888; // 默认端口号
    const int backlog = 32; // 全连接队列的最大长度

    class TcpServer
    {
    public:
        TcpServer(const uint16_t port = default_port)
            :port_(port)
        {}

        ~TcpServer()
        {}

        // 初始化服务器
        void InitServer()
        {
            // 1.创建套接字
            sock_ = socket(AF_INET, SOCK_STREAM, 0);
            if(sock_ == -1)
            {
                std::cerr << "Create Socket Fail!" << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            std::cout << "Create Socket Success! " << sock_ << std::endl;

            // 2.绑定IP地址与端口号
            struct sockaddr_in local;
            memset(&local, 0, sizeof(local)); // 清零
            local.sin_family = AF_INET;
            local.sin_addr.s_addr = INADDR_ANY; // 绑定任意可用IP地址
            local.sin_port = htons(port_);

            if(bind(listensock_, (const sockaddr*)&local, sizeof(local)))
            {
                std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;
                exit(BIND_ERR);
            }
     
            // 3.监听
            if(listen(sock_, backlog) == -1)
            {
                std::cerr << "Listen Fail!" << strerror(errno) << std::endl;
                exit(LISTEN_ERR);
            }

            std::cout << "Listen Success!" << std::endl;
        }

        // 启动服务器
        void StartServer()
        {}

    private:
        int sock_; // 套接字(存疑)
        uint16_t port_; // 端口号
    };
}

至此基于 TCP 协议实现的初始化服务器函数就填充完成了,编译并运行服务器,显示初始化服务器成功

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

1.4.启动服务器

1.4.1.处理连接请求

TCP 是面向连接,当有客户端发起连接请求时,TCP 服务器需要正确识别并尝试进行连接,当连接成功时,与其进行通信,可使用 accept 函数进行连接

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

参数解读:

  • sockfd 服务器用于处理连接请求的 socket 套接字
  • addr 客户端的 sockaddr 结构体信息
  • addrlen 客户端的 sockaddr 结构体大写

其中 addraddrlen 是一个 输入输出型 参数,类似于 recvfrom 中的参数

返回值:连接成功返回一个用于通信的 socket 套接字(文件描述符),失败返回 -1

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

这也就意味着之前我们在 TcpServer 中创建的类内成员 sock_ 并非是用于通信,而是专注于处理连接请求,在 TCP 服务器中,这种套接字称为 监听套接字

使用 accept 函数处理连接请求

server.hpp 服务器头文件

#pragma once

#include <iostream>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"

namespace nt_server
{
    const uint16_t default_port = 8888; // 默认端口号
    const int backlog = 32; // 全连接队列的最大长度

    class TcpServer
    {
    public:
        TcpServer(const uint16_t port = default_port)
            :port_(port), quit_(false)
        {}

        ~TcpServer()
        {}

        // 初始化服务器
        void InitServer()
        {
            // 1.创建监听套接字
            listensock_ = socket(AF_INET, SOCK_STREAM, 0);
            if(listensock_ == -1)
            {
                std::cerr << "Create ListenSocket Fail!" << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            std::cout << "Create ListenSocket Success! " << listensock_ << std::endl;

            // 2.绑定IP地址与端口号
            struct sockaddr_in local;
            memset(&local, 0, sizeof(local)); // 清零
            local.sin_family = AF_INET;
            local.sin_addr.s_addr = INADDR_ANY; // 绑定任意可用IP地址
            local.sin_port = htons(port_);


            if(bind(listensock_, (const sockaddr*)&local, sizeof(local)))
            {
                std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;
                exit(BIND_ERR);
            }
     
            // 3.监听
            if(listen(listensock_, backlog) == -1)
            {
                std::cerr << "Listen Fail!" << strerror(errno) << std::endl;
                exit(LISTEN_ERR);
            }

            std::cout << "Listen Success!" << std::endl;
        }

        // 启动服务器
        void StartServer()
        {
            while(!quit_)
            {
                // 1.处理连接请求
                struct sockaddr_in client;
                socklen_t len = sizeof(client);

                int sock = accept(listensock_, (struct sockaddr*)&client, &len);

                // 2.如果连接失败,继续尝试连接
                if(sock == -1)
                {
                    std::cerr << "Accept Fail!" << strerror(errno) << std::endl;
                    continue;
                }

                // 连接成功,获取客户端信息
                std::string clientip = inet_ntoa(client.sin_addr);
                uint16_t clientport = ntohs(client.sin_port);

                std::cout << "Server accept " << clientip + "-" << clientport << " " << sock << " from " << listensock_ << " success!" << std::endl;

                // 3.根据 sock 套接字进行通信
                Service(sock, clientip, clientport);
            }
        }

    private:
        int listensock_; // 监听套接字
        uint16_t port_; // 端口号
        bool quit_; // 判断服务器是否结束运行
    };
}
1.4.2.业务处理

对于 TCP 服务器来说,它是面向字节流传输的,我们之前使用的文件相关操作也是面向字节流,凑巧的是在 Linux 中网络是以挂接在文件系统的方式实现的,种种迹象表明:可以通过文件相关接口进行通信

  • read 从文件中读取信息(接收消息)
  • write 向文件中写入信息(发送消息)

这两个系统调用的核心参数是 fd文件描述符),即服务器与客户端在连接成功后,获取到的 socket 套接字,所以接下来可以按文件操作的套路,完成业务处理

Service() 业务处理函数 — 位于 server.hpp 服务器头文件中的 TcpServer

// 业务处理
void Service(int sock, const std::string& clientip, const uint16_t& clientport)
{
    char buff[1024];
    std::string who = clientip + "-" + std::to_string(clientport);
    while (true)
    {
        ssize_t n = read(sock, buff, sizeof(buff) - 1); // 预留 '\0' 的位置
        if (n > 0)
        {
            // 读取成功
            buff[n] = '\0';
            std::cout << "Server get: " << buff << " from " << who << std::endl;

            std::string respond = func_(buff); // 实际业务处理由上层指定

            // 发送给服务器
            write(sock, buff, strlen(buff));
        }
        else if (n == 0)
        {
            // 表示当前读取到文件末尾了,结束读取
            std::cout << "Client " << who << " " << sock << " quit!" << std::endl;
            close(sock); // 关闭文件描述符
            break;
        }
        else
        {
            // 读取出问题(暂时)
            std::cerr << "Read Fail!" << strerror(errno) << std::endl;
            close(sock); // 关闭文件描述符
            break;
        }
    }
}
1.4.3.回调函数

为了更好的实现功能解耦,这里将真正的业务处理函数交给上层处理,编写完成后传给 TcpServer 对象即可,当然,在 TcpServer 类中需要添加对应的类型

这里设置回调函数的返回值为 string,参数同样为 string

server.hpp 服务器头文件

#pragma once

#include <iostream>
#include <string>
#include <functional>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"

namespace nt_server
{
    const uint16_t default_port = 8888; // 默认端口号
    const int backlog = 32;             // 全连接队列的最大长度

    using func_t = std::function<std::string(std::string)>; // 回调函数类型

    class TcpServer
    {
    public:
        TcpServer(const func_t &func, const uint16_t port = default_port)
            : func_(func), port_(port), quit_(false)
        {
        }

        ~TcpServer()
        {
        }

        // 初始化服务器
        void InitServer()
        {
            // 1.创建监听套接字
            listensock_ = socket(AF_INET, SOCK_STREAM, 0);
            if (listensock_ == -1)
            {
                std::cerr << "Create ListenSocket Fail!" << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            std::cout << "Create ListenSocket Success! " << listensock_ << std::endl;

            // 2.绑定IP地址与端口号
            struct sockaddr_in local;
            memset(&local, 0, sizeof(local)); // 清零
            local.sin_family = AF_INET;
            local.sin_addr.s_addr = INADDR_ANY; // 绑定任意可用IP地址
            local.sin_port = htons(port_);

            if (bind(listensock_, (const sockaddr *)&local, sizeof(local)))
            {
                std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;
                exit(BIND_ERR);
            }

            // 3.监听
            if (listen(listensock_, backlog) == -1)
            {
                std::cerr << "Listen Fail!" << strerror(errno) << std::endl;
                exit(LISTEN_ERR);
            }

            std::cout << "Listen Success!" << std::endl;
        }

        // 启动服务器
        void StartServer()
        {
            while (!quit_)
            {
                // 1.处理连接请求
                struct sockaddr_in client;
                socklen_t len = sizeof(client);

                int sock = accept(listensock_, (struct sockaddr *)&client, &len);

                // 2.如果连接失败,继续尝试连接
                if (sock == -1)
                {
                    std::cerr << "Accept Fail!" << strerror(errno) << std::endl;
                    continue;
                }

                // 连接成功,获取客户端信息
                std::string clientip = inet_ntoa(client.sin_addr);
                uint16_t clientport = ntohs(client.sin_port);

                std::cout << "Server accept " << clientip + "-" << clientport << " " << sock << " from " << listensock_ << " success!" << std::endl;

                // 3.根据 sock 套接字进行通信
                Service(sock, clientip, clientport);
            }
        }

        // 业务处理
        void Service(int sock, const std::string& clientip, const uint16_t& clientport)
        {
            char buff[1024];
            std::string who = clientip + "-" + std::to_string(clientport);
            while (true)
            {
                ssize_t n = read(sock, buff, sizeof(buff) - 1); // 预留 '\0' 的位置
                if (n > 0)
                {
                    // 读取成功
                    buff[n] = '\0';
                    std::cout << "Server get: " << buff << " from " << who << std::endl;

                    std::string respond = func_(buff); // 实际业务处理由上层指定

                    // 发送给服务器
                    write(sock, buff, strlen(buff));
                }
                else if (n == 0)
                {
                    // 表示当前读取到文件末尾了,结束读取
                    std::cout << "Client " << who << " " << sock << " quit!" << std::endl;
                    close(sock); // 关闭文件描述符
                    break;
                }
                else
                {
                    // 读取出问题(暂时)
                    std::cerr << "Read Fail!" << strerror(errno) << std::endl;
                    close(sock); // 关闭文件描述符
                    break;
                }
            }
        }

    private:
        int listensock_; // 监听套接字
        uint16_t port_;  // 端口号
        bool quit_;      // 判断服务器是否结束运行
        func_t func_;    // 回调函数
    };
}

服务器头文件准备完成,接下来就是填充 server.cc 服务器源文件

1.5.服务器源文件

对于当前的 TCP 网络程序(字符串回响)来说,业务处理函数逻辑非常简单,无非就是直接将客户端发送过来的消息,重新转发给客户端

server.cc 服务器源文件

#include <memory> // 智能指针头文件
#include <string>
#include "server.hpp"

using namespace std;
using namespace nt_server;

// 业务处理回调函数(字符串回响)
string echo(string request)
{
    return request;
}

int main()
{
    unique_ptr<TcpServer> usvr (new TcpServer(echo)); // 将回调函数进行传递

    usvr->InitServer();
    usvr->StartServer();
    return 0;
}

尝试编译并运行服务器,可以看到当前 bash 已经被我们的服务器程序占用了,重新打开一个终端,并通过 netstat 命令查看网络使用情况(基于 TCP 协议)

netstat -nltp

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

当前服务确实使用的是 8888 端口,并且采用的是 TCP 协议

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议


客户端


1.6.初始化客户端

对于客户端来说,服务器的 IP 地址与端口号是两个不可或缺的元素,因此在客户端类中,server_ipserver_port 这两个成员是少不了的,当然得有 socket 套接字

初始化客户端只需要干一件事:创建套接字,客户端是主动发起连接请求的一方,也就意味着它不需要使用 listen 函数设置为监听状态

注意:客户端也是需要 bind 绑定的,但不需要自己手动绑定,由操作系统帮我们自动完成

client.hpp 客户端头文件

#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"

namespace nt_client
{
    class TcpClient
    {
    public:
        TcpClient(const std::string& ip, const uint16_t port)
            :server_ip_(ip), server_port_(port)
        {}

        ~TcpClient()
        {}

        // 初始化客户端
        void InitClient()
        {
            // 创建套接字
            sock_ = socket(AF_INET, SOCK_STREAM, 0);
            if (sock_ == -1)
            {
                std::cerr << "Create Socket Fail!" << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }
            
            std::cout << "Create Sock Succeess! " << sock_ << std::endl;
        }

        // 启动客户端
        void StartClient()
        {}
        
    private:
        int sock_; // 套接字
        std::string server_ip_; // 服务器IP
        uint16_t server_port_; // 服务器端口号
    };
}

编译并运行客户端,显示 socket 套接字创建成功

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

1.7.启动客户端

1.7.1.尝试进行连接

因为 TCP 协议是面向连接的,服务器已经处于处理连接请求的状态了,客户端现在需要做的就是尝试进行连接,使用 connect 函数进行连接

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数解读:

  • sockfd 需要进行连接的套接字
  • addr 服务器的 sockaddr 结构体信息
  • addrlen 服务器的 sockaddr 结构体大小

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

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议
网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

在连接过程中,可能遇到很多问题,比如 网络传输失败、服务器未启动 等,这些问题的最终结果都是客户端连接失败,如果按照之前的逻辑(失败就退出),那么客户端的体验感会非常不好,因此在面对连接失败这种常见问题时,客户端应该尝试重连,如果重连数次后仍然失败,才考虑终止进程

注意:在进行重连时,可以使用 sleep() 等函数使程序睡眠一会,给网络恢复留出时间

StartClient() 启动客户端函数 — 位于 client.hpp 中的 TcpClient

// 启动客户端
void StartClient()
{
    // 填充服务器的 sockaddr_in 结构体信息
    struct sockaddr_in server;
    socklen_t len = sizeof(server);
    memset(&server, 0, len);
    server.sin_family = AF_INET;
    inet_aton(server_ip_.c_str(), &server.sin_addr); // 将点分十进制转化为二进制IP地址的另一种方法
    server.sin_port = htons(server_port_);

    // 尝试重连 5 次
    int n = 5;
    while(n)
    {
        int ret = connect(sock_, (const struct sockaddr*)&server, len);

        if(ret == 0)
        {
            // 连接成功,可以跳出循环
            break;
        }

        // 尝试进行重连
        std::cerr << "网络异常,正在进行重连... 剩余连接次数: " << --n << std::endl;
        sleep(1);
    }

    // 如果剩余重连次数为 0,证明连接失败
    if(n == 0)
    {
        std::cerr << "连接失败! " << strerror(errno) << std::endl;
        close(sock_);
        exit(CONNECT_ERR);
    }

    // 连接成功
    std::cout << "连接成功!" << std::endl;

    // 进行业务处理
    // Service();
}  

当然相应的错误码也得添加

err.hpp 错误码头文件

#pragma once

enum
{
    USAGE_ERR = 1,
    SOCKET_ERR,
    BIND_ERR,
    LISTEN_ERR,
    CONNECT_ERR
};

现在先不启动服务器,编译并启动客户端,模拟连接失败的情况

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

如果在数秒之后启动再服务器,可以看到重连成功

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

这种重连机制在实际中非常常见,出现这种
网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

1.7.2.业务处理

客户端在进行业务处理时,同样可以使用 readwrite 进行网络通信

Service() 业务处理函数 — 位于 client.hpp 客户端头文件中的 TcpClient

// 业务处理
void Service()
{
    char buff[1024];
    std::string who = server_ip_ + "-" + std::to_string(server_port_);
    while(true)
    {
        // 由用户输入信息
        std::string msg;
        std::cout << "Please Enter >> ";
        std::getline(std::cin, msg);

        // 发送信息给服务器
        write(sock_, msg.c_str(), msg.size());

        // 接收来自服务器的信息
        ssize_t n = read(sock_, buff, sizeof(buff) - 1);
        if(n > 0)
        {
            // 正常通信
            buff[n] = '\0';
            std::cout << "Client get: " << buff << " from " << who << std::endl;
        }
        else if(n == 0)
        {
            // 读取到文件末尾(服务器关闭了)
            std::cout << "Server " << who  << " quit!" << std::endl;
            close(sock_); // 关闭文件描述符
            break;
        }
        else
        {
            // 读取异常
            std::cerr << "Read Fail!" << strerror(errno) << std::endl;
            close(sock_); // 关闭文件描述符
            break;
        }
    }
}

至此整个 基于 TCP 协议的字符串回响程序 就完成了,下面来看看效果

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

可以看到,当客户端向服务器发起连接请求时,服务器可以识别并接受连接,双方建立连接关系后,可以正常进行通信;当客户端主动退出(断开连接),服务器也能感知到,并判断出是谁断开了连接

如果在通信过程中,服务器主动断开了连接,客户端也能感知到

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

如果我们此时立马重启服务器,会发现短期内无法再次启动服务(显示端口正在被占用),这是由于 TCP 协议断开连接时的特性导致的(正在处于 TIME_WAIT 状态),详细原因将会在后续博客中讲解

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议


2.多进程版服务器

2.1.核心功能

对于之前编写的 字符串回响程序 来说,如果只有一个客户端进行连接并通信,是没有问题的,但如果有多个客户端发起连接请求,并尝试进行通信,服务器是无法应对的

原因在于 服务器是一个单进程版本,处理连接请求 和 业务处理 是串行化执行的,如果想处理下一个连接请求,需要把当前的业务处理完成

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

具体表现为下面这种情况

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

为什么客户端B会显示当前已经连接成功?
这是因为是客户端是主动发起连接请求的一方,在请求发出后,如果出现连接错误,客户端就认为已经连接成功了,但实际上服务器还没有处理这个连接请求

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

这显然是服务器的问题,处理连接请求业务处理 应该交给两个不同的执行流完成,可以使用多进程或者多线程解决,这里先采用多进程的方案

所以当前需要实现的网络程序核心功能为:当服务器成功处理连接请求后,fork 新建一个子进程,用于进行业务处理,原来的进程专注于处理连接请求

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

2.2.创建子进程

注:当前的版本的修改只涉及 StartServer() 函数

创建子进程使用 fork() 函数,它的返回值含义如下

  • ret == 0 表示创建子进程成功,接下来执行子进程的代码
  • ret > 0 表示创建子进程成功,接下来执行父进程的代码
  • ret < 0 表示创建子进程失败

子进程创建成功后,会继承父进程的文件描述符表,能轻而易举的获取客户端的 socket 套接字,从而进行网络通信

当然不止文件描述符表,得益于 写时拷贝 机制,子进程还会共享父进程的变量,当发生修改行为时,才会自己创建

注意:当子进程取走客户端的 socket 套接字进行通信后,父进程需要将其关闭(因为它不需要了),避免文件描述符泄漏

StartServer() 服务器启动函数 — 位于 server.hppTcpServer

// 进程创建、等待所需要的头文件
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>

// 启动服务器
void StartServer()
{
    while (!quit_)
    {
        // 1.处理连接请求
        struct sockaddr_in client;
        socklen_t len = sizeof(client);

        int sock = accept(listensock_, (struct sockaddr *)&client, &len);

        // 2.如果连接失败,继续尝试连接
        if (sock == -1)
        {
            std::cerr << "Accept Fail!" << strerror(errno) << std::endl;
            continue;
        }

        // 连接成功,获取客户端信息
        std::string clientip = inet_ntoa(client.sin_addr);
        uint16_t clientport = ntohs(client.sin_port);

        std::cout << "Server accept " << clientip + "-" << clientport << " " << sock << " from " << listensock_ << " success!" << std::endl;

        // 3.创建子进程
        pid_t id = fork();

        if(id < 0)
        {
            // 创建子进程失败,暂时不与当前客户端建立通信会话
            close(sock);
            std::cerr << "Fork Fail!" << std::endl;
        }
        else if(id == 0)
        {
            // 子进程内
            close(listensock_); // 子进程不需要监听(建议关闭)

            // 执行业务处理函数
            Service(sock, clientip, clientport);

            exit(0); // 子进程退出
        }
        else
        {
            // 父进程需要等待子进程
            pid_t ret = waitpid(id, nullptr, 0); // 默认为阻塞式等待

            if(ret == id)
                std::cout << "Wait " << id << " success!";
        }

    }
}

虽然此时成功创建了子进程,但父进程(处理连接请求)仍然需要等待子进程退出后,才能继续运行,说白了就是 父进程现在处于阻塞等待状态,需要设置为 非阻塞等待

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

2.3.设置非阻塞

设置父进程为非阻塞的方式有很多,这里来一一列举

方式一:通过参数设置为非阻塞等待(不推荐)

可以直接给 waitpid() 函数的参数3传递 WNOHANG,表示当前为 非阻塞等待

详见 《Linux进程控制【创建、终止、等待】》

pid_t ret = waitpid(id, nullptr, WNOHANG); // 设置为非阻塞式等待

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议
网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

这种方法可行,但不推荐,原因如下:虽然设置成了非阻塞式等待,但父进程终究是需要通过 waitpid() 函数来尝试等待子进程,倘若父进程一直卡在 accept() 函数处,会导致子进程退出后暂时无人收尸,进而导致资源泄漏


方式二:忽略 SIGCHLD 信号(推荐使用)

这是一个子进程在结束后发出的信号,默认动作是什么都不做;父进程需要检测并回收子进程,我们可以直接忽略该信号,这里的忽略是个特例,只是父进程不对其进行处理,转而由 操作系统 对其负责,自动清理资源并进行回收,不会产生 僵尸进程

详见 《Linux进程信号【信号处理】》

直接在 StartServer() 服务器启动函数刚开始时,使用 signal() 函数设置 SIGCHLD 信号的执行动作为 忽略

忽略了该信号后,就不需要父进程等待子进程退出了(由操作系统承担)

#include <signal.h> // 信号处理相关头文件

// 启动服务器
void StartServer()
{
    // 忽略 SIGCHLD 信号
    signal(SIGCHLD, SIG_IGN);
    
    while (!quit_)
    {
       	// ...
       	
        // 3.创建子进程
        pid_t id = fork();

        if(id < 0)
        {
            // 创建子进程失败,暂时不与当前客户端建立通信会话
            close(sock);
            std::cerr << "Fork Fail!" << std::endl;
        }
        else if(id == 0)
        {
            // 子进程内
            close(listensock_); // 子进程不需要监听(建议关闭)

            // 执行业务处理函数
            Service(sock, clientip, clientport);

            exit(0); // 子进程退出
        }

    }
}

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

强烈推荐使用该方案,因为操作简单,并且没有后患之忧


方式三:设置 SIGCHLD 信号的处理动作为子进程回收(不是很推荐)

当子进程退出并发送该信号时,执行父进程回收子进程的操作

详见 《Linux进程信号【信号处理】》

设置 SIGCHLD 信号的处理动作为 回收子进程后,父进程同样不必再考虑回收子进程的问题

注意:因为现在处于 TcpServer 类中,handler() 函数需要设置为静态(避免隐含的 this 指针),避免不符合 signal() 函数中信号处理函数的参数要求

#include <signal.h> // 信号处理相关头文件

// 需要设置为静态
static void handler(int signo)
{
    printf("进程 %d 捕捉到了 %d 号信号\n", getpid(), signo);

    // 这里的 -1 表示父进程等待时,只要是已经退出了的子进程,都可以进行回收
    while (1)
    {
        pid_t ret = waitpid(-1, NULL, WNOHANG);
        if (ret > 0)
            printf("父进程: %d 已经成功回收了 %d 号进程\n", getpid(), ret);
        else
            break;
    }
    printf("子进程回收成功\n");
}

// 启动服务器
void StartServer()
{
    // 设置 SIGCHLD 信号的处理动作
    signal(SIGCHLD, handler);
    
    while (!quit_)
    {
       	// ...
       	
        // 3.创建子进程
        pid_t id = fork();

        if(id < 0)
        {
            // 创建子进程失败,暂时不与当前客户端建立通信会话
            close(sock);
            std::cerr << "Fork Fail!" << std::endl;
        }
        else if(id == 0)
        {
            // 子进程内
            close(listensock_); // 子进程不需要监听(建议关闭)

            // 执行业务处理函数
            Service(sock, clientip, clientport);

            exit(0); // 子进程退出
        }

    }
}

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议
网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

为什么不是很推荐这种方法?因为这种方法实现起来比较麻烦,不如直接忽略 SIGCHLD 信号


方式四:设置孙子进程(不是很推荐)

众所周知,父进程只需要对子进程负责,至于孙子进程交给子进程负责,如果某个子进程的父进程终止运行了,那么它就会变成 孤儿进程,父进程会变成 1 号进程,也就是由操作系统领养,回收进程的重担也交给了操作系统

可以利用该特性,在子进程内部再创建一个子进程(孙子进程),然后子进程退出,父进程可以直接回收(不必阻塞),子进程(孙子进程)的父进程变成 1 号进程

这种实现方法比较巧妙,而且与我们后面即将学到的 守护进程 有关

注意:使用这种方式时,父进程是需要等待子进程退出的

// 启动服务器
void StartServer()
{
    while (!quit_)
    {
   		// ...
   		
        // 3.创建子进程
        pid_t id = fork();

        if(id < 0)
        {
            // 创建子进程失败,暂时不与当前客户端建立通信会话
            close(sock);
            std::cerr << "Fork Fail!" << std::endl;
        }
        else if(id == 0)
        {
            // 子进程内
            close(listensock_); // 子进程不需要监听(建议关闭)

            // 再创建孙子进程
            if(fork() > 0)
                exit(0); // 子进程退出

            // 执行业务处理函数
            Service(sock, clientip, clientport);

            exit(0); // 子进程退出
        }
        else
        {
            // 父进程需要等待子进程
            pid_t ret = waitpid(id, nullptr, 0);

            if(ret == id)
                std::cout << "Wait " << id << " success!";
        }

    }
}

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议
网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

这种方法代码也很简单,但依旧不推荐,因为倘若连接请求变多,会导致孤儿进程变多,孤儿进程由操作系统接管,数量变多会给操作系统带来负担

以上就是设置 非阻塞 的四种方式,推荐使用方式二:忽略 SIGCHLD 信号

至此我们的 字符串回响程序 可以支持多客户端了

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议
网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议


细节补充当子进程取走 sock 套接字进行网络通信后,父进程就不需要使用 sock 套接字了,可以将其进行关闭,下次连接时继续使用,避免文件描述符不断增长

StartServer() 服务器启动函数 — 位于 server.hpp 服务器头文件中的 TcpServer

// 启动服务器
void StartServer()
{
    // 忽略 SIGCHLD 信号
    signal(SIGCHLD, SIG_IGN);

    while (!quit_)
    {
        // 1.处理连接请求
        // ...

        // 2.如果连接失败,继续尝试连接
        // ...

        // 连接成功,获取客户端信息
        // ...

        // 3.创建子进程
        // ...

        close(sock); // 父进程不再需要资源(建议关闭)
    }
}

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

这个补丁可以减少资源消耗,建议加上,前面是忘记加了,并且不太好修改,server.hpp 服务器头文件完整代码如下

#pragma once

#include <iostream>
#include <string>
#include <functional>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <signal.h> // 信号处理相关头文件
#include <sys/wait.h> // 进程等待时需要包含该头文件
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"

namespace nt_server
{
    const uint16_t default_port = 8888; // 默认端口号
    const int backlog = 32;             // 全连接队列的最大长度

    using func_t = std::function<std::string(std::string)>; // 回调函数类型

    class TcpServer
    {
    public:
        TcpServer(const func_t &func, const uint16_t port = default_port)
            : func_(func), port_(port), quit_(false)
        {
        }

        ~TcpServer()
        {
        }

        // 初始化服务器
        void InitServer()
        {
            // 1.创建监听套接字
            listensock_ = socket(AF_INET, SOCK_STREAM, 0);
            if (listensock_ == -1)
            {
                std::cerr << "Create ListenSocket Fail!" << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            std::cout << "Create ListenSocket Success! " << listensock_ << std::endl;

            // 2.绑定IP地址与端口号
            struct sockaddr_in local;
            memset(&local, 0, sizeof(local)); // 清零
            local.sin_family = AF_INET;
            local.sin_addr.s_addr = INADDR_ANY; // 绑定任意可用IP地址
            local.sin_port = htons(port_);

            if (bind(listensock_, (const sockaddr *)&local, sizeof(local)))
            {
                std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;
                exit(BIND_ERR);
            }

            // 3.监听
            if (listen(listensock_, backlog) == -1)
            {
                std::cerr << "Listen Fail!" << strerror(errno) << std::endl;
                exit(LISTEN_ERR);
            }

            std::cout << "Listen Success!" << std::endl;
        }

        // 启动服务器
        void StartServer()
        {
            // 忽略 SIGCHLD 信号
            signal(SIGCHLD, SIG_IGN);

            while (!quit_)
            {
                // 1.处理连接请求
                struct sockaddr_in client;
                socklen_t len = sizeof(client);

                int sock = accept(listensock_, (struct sockaddr *)&client, &len);

                // 2.如果连接失败,继续尝试连接
                if (sock == -1)
                {
                    std::cerr << "Accept Fail!" << strerror(errno) << std::endl;
                    continue;
                }

                // 连接成功,获取客户端信息
                std::string clientip = inet_ntoa(client.sin_addr);
                uint16_t clientport = ntohs(client.sin_port);

                std::cout << "Server accept " << clientip + "-" << clientport << " " << sock << " from " << listensock_ << " success!" << std::endl;

                // 3.创建子进程
                pid_t id = fork();

                if(id < 0)
                {
                    // 创建子进程失败,暂时不与当前客户端建立通信会话
                    close(sock);
                    std::cerr << "Fork Fail!" << std::endl;
                }
                else if(id == 0)
                {
                    // 子进程内
                    close(listensock_); // 子进程不需要监听(建议关闭)

                    // 执行业务处理函数
                    Service(sock, clientip, clientport);

                    exit(0); // 子进程退出
                }

                close(sock); // 父进程不再需要资源(必须关闭)
            }
        }

        // 业务处理
        void Service(int sock, const std::string& clientip, const uint16_t& clientport)
        {
            char buff[1024];
            std::string who = clientip + "-" + std::to_string(clientport);
            while (true)
            {
                ssize_t n = read(sock, buff, sizeof(buff) - 1); // 预留 '\0' 的位置
                if (n > 0)
                {
                    // 读取成功
                    buff[n] = '\0';
                    std::cout << "Server get: " << buff << " from " << who << std::endl;

                    std::string respond = func_(buff); // 实际业务处理由上层指定

                    // 发送给服务器
                    write(sock, buff, strlen(buff));
                }
                else if (n == 0)
                {
                    // 表示当前读取到文件末尾了,结束读取
                    std::cout << "Client " << who << " " << sock << " quit!" << std::endl;
                    close(sock); // 关闭文件描述符
                    break;
                }
                else
                {
                    // 读取出问题(暂时)
                    std::cerr << "Read Fail!" << strerror(errno) << std::endl;
                    close(sock); // 关闭文件描述符
                    break;
                }
            }
        }

    private:
        int listensock_; // 监听套接字
        uint16_t port_;  // 端口号
        bool quit_;      // 判断服务器是否结束运行
        func_t func_;    // 回调函数
    };
}

3.多线程版服务器

3.1.核心功能

通过多线程,实现支持多客户端同时通信的服务器

核心功能:服务器与客户端成功连接后,创建一个线程,服务于客户端的业务处理

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

这里先通过 原生线程库 模拟实现

3.2.使用原生线程库

关于 原生线程库 中对于线程的操作可以看看这篇文章《Linux多线程【线程控制】》

线程的回调函数中需要 Service() 业务处理函数中的所有参数,同时也需要具备访问 Service() 业务处理函数的能力,单凭一个 void* 的参数是无法解决的,为此可以创建一个类,里面可以包含我们所需要的参数

ThreadData 类 — 位于 server.hpp 服务器头文件中

// 包含我们所需参数的类型
class ThreadData
{
public:
    ThreadData(int sock, const std::string& ip, const uint16_t& port, TcpServer* ptr)
        :sock_(sock), clientip_(ip), clientport_(port), current_(ptr)
    {}

// 设置为公有是为了方便访问
public:
    int sock_;
    std::string clientip_;
    uint16_t clientport_;
    TcpServer* current_; // 指向 TcpServer 对象的指针
};

接下来就可以考虑如何借助多线程了

线程创建后,需要关闭不必要的 socket 套接字吗?

  • 不需要,线程之间是可以共享这些资源的,无需关闭

如何设置主线程不必等待次线程退出?

  • 可以把次线程进行分离

所以接下来我们需要在连接成功后,创建次线程,利用已有信息构建 ThreadData 对象,为次线程编写回调函数(最终目的是为了执行 Service() 业务处理函数)

注意:因为当前在类中,线程的回调函数需要使用 static 设置为静态函数

server.hpp 服务器头文件

#pragma once

#include <iostream>
#include <string>
#include <functional>
#include <cerrno>
#include <cstring>
#include <pthread.h> // 原生线程库
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"

namespace nt_server
{
    const uint16_t default_port = 8888; // 默认端口号
    const int backlog = 32;             // 全连接队列的最大长度

    using func_t = std::function<std::string(std::string)>; // 回调函数类型

    class TcpServer; // 前置声明

    // 包含我们所需参数的类型
    class ThreadData
    {
    public:
        ThreadData(int sock, const std::string& ip, const uint16_t& port, TcpServer* ptr)
            :sock_(sock), clientip_(ip), clientport_(port), current_(ptr)
        {}

    // 设置为公有是为了方便访问
    public:
        int sock_;
        std::string clientip_;
        uint16_t clientport_;
        TcpServer* current_; // 指向 TcpServer 对象的指针
    };

    class TcpServer
    {
    public:
        TcpServer(const func_t &func, const uint16_t port = default_port)
            : func_(func), port_(port), quit_(false)
        {
        }

        ~TcpServer()
        {
        }

        // 初始化服务器
        void InitServer()
        {
            // 1.创建监听套接字
            listensock_ = socket(AF_INET, SOCK_STREAM, 0);
            if (listensock_ == -1)
            {
                std::cerr << "Create ListenSocket Fail!" << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            std::cout << "Create ListenSocket Success! " << listensock_ << std::endl;

            // 2.绑定IP地址与端口号
            struct sockaddr_in local;
            memset(&local, 0, sizeof(local)); // 清零
            local.sin_family = AF_INET;
            local.sin_addr.s_addr = INADDR_ANY; // 绑定任意可用IP地址
            local.sin_port = htons(port_);

            if (bind(listensock_, (const sockaddr *)&local, sizeof(local)))
            {
                std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;
                exit(BIND_ERR);
            }

            // 3.监听
            if (listen(listensock_, backlog) == -1)
            {
                std::cerr << "Listen Fail!" << strerror(errno) << std::endl;
                exit(LISTEN_ERR);
            }

            std::cout << "Listen Success!" << std::endl;
        }

        // 启动服务器
        void StartServer()
        {
            while (!quit_)
            {
                // 1.处理连接请求
                struct sockaddr_in client;
                socklen_t len = sizeof(client);

                int sock = accept(listensock_, (struct sockaddr *)&client, &len);

                // 2.如果连接失败,继续尝试连接
                if (sock == -1)
                {
                    std::cerr << "Accept Fail!" << strerror(errno) << std::endl;
                    continue;
                }

                // 连接成功,获取客户端信息
                std::string clientip = inet_ntoa(client.sin_addr);
                uint16_t clientport = ntohs(client.sin_port);

                std::cout << "Server accept " << clientip + "-" << clientport << " " << sock << " from " << listensock_ << " success!" << std::endl;

                // 3.创建线程及所需要的线程信息类
                ThreadData* td = new ThreadData(sock, clientip, clientport, this);
                pthread_t p;
                pthread_create(&p, nullptr, Routine, td);
            }
        }

        // 线程回调函数
        static void* Routine(void* args)
        {
            // 线程分离
            pthread_detach(pthread_self());

            ThreadData* td = static_cast<ThreadData*>(args);

            // 调用业务处理函数
            td->current_->Service(td->sock_, td->clientip_, td->clientport_);

            // 销毁对象
            delete td;
        }

        // 业务处理
        void Service(int sock, const std::string& clientip, const uint16_t& clientport)
        {
            char buff[1024];
            std::string who = clientip + "-" + std::to_string(clientport);
            while (true)
            {
                ssize_t n = read(sock, buff, sizeof(buff) - 1); // 预留 '\0' 的位置
                if (n > 0)
                {
                    // 读取成功
                    buff[n] = '\0';
                    std::cout << "Server get: " << buff << " from " << who << std::endl;

                    std::string respond = func_(buff); // 实际业务处理由上层指定

                    // 发送给服务器
                    write(sock, buff, strlen(buff));
                }
                else if (n == 0)
                {
                    // 表示当前读取到文件末尾了,结束读取
                    std::cout << "Client " << who << " " << sock << " quit!" << std::endl;
                    close(sock); // 关闭文件描述符
                    break;
                }
                else
                {
                    // 读取出问题(暂时)
                    std::cerr << "Read Fail!" << strerror(errno) << std::endl;
                    close(sock); // 关闭文件描述符
                    break;
                }
            }
        }

    private:
        int listensock_; // 监听套接字
        uint16_t port_;  // 端口号
        bool quit_;      // 判断服务器是否结束运行
        func_t func_;    // 回调函数
    };
}

因为当前使用了 原生线程库,所以在编译时,需要加上 -lpthread

Makefile 文件

.PHONY:all
all:server client

server:server.cc
	g++ -o $@ $^ -std=c++11 -lpthread

	
client:client.cc
	g++ -o $@ $^ -std=c++11 -lpthread

.PHONY:clean
clean:
	rm -rf server client

接下来就是编译并运行程序,可以看到 当前只有一个进程,同时有五个线程在运行

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议
网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

使用 原生线程库 过于单薄了,并且这种方式存在问题:连接都准备好了,才创建线程,如果创建线程所需要的资源较多,会拖慢服务器整体连接效率

为此可以改用之前实现的 线程池

3.3.使用线程池

之前在 《Linux多线程【线程池】》一文中实现了多个版本的线程池,这里我们直接使用最终版,也就是 单例模式版线程池

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

部分组件不需要修改,代码如下:

ThreadPool.hpp 线程池头文件

#pragma once

#include <vector>
#include <string>
#include <memory>
#include <functional>
#include <unistd.h>
#include <pthread.h>
#include "Task.hpp"
#include "Thread.hpp"
#include "BlockingQueue.hpp" // CP模型

namespace Yohifo
{
#define THREAD_NUM 10

    template<class T>
    class ThreadPool
    {
    private:
        ThreadPool(int num = THREAD_NUM)
            :_num(num)
        {
        }

        ~ThreadPool()
        {
            // 等待线程退出
            for(auto &t : _threads)
                t.join();
        }

        // 删除拷贝构造
        ThreadPool(const ThreadPool<T> &) = delete;

    public:
        static ThreadPool<T>* getInstance()
        {
            // 双检查
            if(_inst == nullptr)
            {
                // 加锁
                LockGuard lock(&_mtx);
                if(_inst == nullptr)
                {
                    // 创建对象
                    _inst = new ThreadPool<T>();

                    // 初始化及启动服务
                    _inst->init();
                    _inst->start();
                }
            }

            return _inst;
        }

    public:
        void init()
        {
            // 创建一批线程
            for(int i = 0; i < _num; i++)
                _threads.push_back(Thread(i, threadRoutine, this));
        }

        void start()
        {
            // 启动线程
            for(auto &t : _threads)
                t.run();
        }

        // 提供给线程的回调函数(已修改返回类型为 void)
        static void threadRoutine(void *args)
        {
            // 避免等待线程,直接剥离
            pthread_detach(pthread_self());

            auto ptr = static_cast<ThreadPool<T>*>(args);

            while (true)
            {
                // 从CP模型中获取任务
                T task = ptr->popTask();

                task(); // 回调函数
            }
        }

        // 装载任务
        void pushTask(const T& task)
        {
            _blockqueue.Push(task);
        }
    
    protected:
        T popTask()
        {
            T task;
            _blockqueue.Pop(&task);

            return task;
        }

    private:
        std::vector<Thread> _threads;
        int _num; // 线程数量
        BlockQueue<T> _blockqueue; // 阻塞队列

        // 创建静态单例对象指针及互斥锁
        static ThreadPool<T> *_inst;
        static pthread_mutex_t _mtx;
    };

    // 初始化指针
    template<class T>
    ThreadPool<T>* ThreadPool<T>::_inst = nullptr;

    // 初始化互斥锁
    template<class T>
    pthread_mutex_t ThreadPool<T>::_mtx = PTHREAD_MUTEX_INITIALIZER;
}

Thread.hpp 封装实现的线程库头文件

#pragma once

#include <iostream>
#include <string>
#include <pthread.h>

enum class Status
{
    NEW = 0, // 新建
    RUNNING, // 运行中
    EXIT // 已退出
};

// 参数、返回值为 void 的函数类型
typedef void (*func_t)(void*);

class Thread
{
public:
    Thread(int num = 0, func_t func = nullptr, void* args = nullptr)
        :_tid(0), _status(Status::NEW), _func(func), _args(args)
    {
        // 根据编号写入名字
        char name[128];
        snprintf(name, sizeof name, "thread-%d", num);
        _name = name;
    }

    ~Thread()
    {}

    // 获取 ID
    pthread_t getTID() const
    {
        return _tid;
    }

    // 获取线程名
    std::string getName() const
    {
        return _name;
    }

    // 获取状态
    Status getStatus() const
    {
        return _status;
    }

    // 回调方法
    static void* runHelper(void* args)
    {
        Thread* myThis = static_cast<Thread*>(args);

        // 很简单,回调用户传进来的 func 函数即可
        myThis->_func(myThis->_args);
    }

    // 启动线程
    void run()
    {
        int ret = pthread_create(&_tid, nullptr, runHelper, this);
        if(ret != 0)
        {
            std::cerr << "create thread fail!" << std::endl;
            exit(1); // 创建线程失败,直接退出
        }
        _status =  Status::RUNNING; // 更改状态为 运行中
    }

    // 线程等待
    void join()
    {
        int ret = pthread_join(_tid, nullptr);
        if(ret != 0)
        {
            std::cerr << "thread join fail!" << std::endl;
            exit(1); // 等待失败,直接退出
        }
        _status = Status::EXIT; // 更改状态为 退出
    }

private:
    pthread_t _tid; // 线程 ID
    std::string _name; // 线程名
    Status _status; // 线程状态
    func_t _func; // 线程回调函数
    void* _args; // 传递给回调函数的参数
};

BlockingQueue.hpp 生产者消费者模型头文件

#pragma once

#include <queue>
#include <mutex>
#include <pthread.h>
#include "LockGuard.hpp"

// 命名空间,避免冲突
namespace Yohifo
{
#define DEF_SIZE 10

    template<class T>
    class BlockQueue
    {
    public:
        BlockQueue(size_t cap = DEF_SIZE)
            :_cap(cap)
        {
            // 初始化锁与条件变量
            pthread_mutex_init(&_mtx, nullptr);
            pthread_cond_init(&_pro_cond, nullptr);
            pthread_cond_init(&_con_cond, nullptr);
        }

        ~BlockQueue()
        {
            // 销毁锁与条件变量
            pthread_mutex_destroy(&_mtx);
            pthread_cond_destroy(&_pro_cond);
            pthread_cond_destroy(&_con_cond);
        }

        // 生产数据(入队)
        void Push(const T& inData)
        {
            // 加锁(RAII风格)
            LockGuard lock(&_mtx);

            // 循环判断条件是否满足
            while(IsFull())
            {
                pthread_cond_wait(&_pro_cond, &_mtx);
            }

            _queue.push(inData);

            // 可以加策略唤醒,比如生产一半才唤醒消费者
            pthread_cond_signal(&_con_cond);

            // 自动解锁

        }

        // 消费数据(出队)
        void Pop(T* outData)
        {
            // 加锁(RAII 风格)
            LockGuard lock(&_mtx);

            // 循环判读条件是否满足
            while(IsEmpty())
            {
                pthread_cond_wait(&_con_cond, &_mtx);
            }

            *outData = _queue.front();
            _queue.pop();

            // 可以加策略唤醒,比如消费完后才唤醒生产者
            pthread_cond_signal(&_pro_cond);

            // 自动解锁
        }

    private:
        // 判断是否为满
        bool IsFull()
        {
            return _queue.size() == _cap;
        }
        
        // 判断是否为空
        bool IsEmpty()
        {
            return _queue.empty();
        }

    private:
        std::queue<T> _queue;
        size_t _cap; // 阻塞队列的容量
        pthread_mutex_t _mtx; // 互斥锁
        pthread_cond_t _pro_cond; // 生产者条件变量
        pthread_cond_t _con_cond; // 消费者条件变量
    };
}

LockGuard.hpp 自动化锁头文件

#pragma once

#include <pthread.h>

class LockGuard
{
public:
    LockGuard(pthread_mutex_t*pmtx)
        :_pmtx(pmtx)
    {
        // 加锁
        pthread_mutex_lock(_pmtx);
    }

    ~LockGuard()
    {
        // 解锁
        pthread_mutex_unlock(_pmtx);
    }

private:
    pthread_mutex_t* _pmtx;
};

现在需要修改 Task.hpp 任务头文件中的 Task 任务类,将其修改为一个服务于 网络通信中业务处理 的任务类(也就是 Service() 业务处理函数)

Service() 业务处理函数中,需要包含 socket 套接字、客户端 IP、客户端端口号 等必备信息,除此之外,我们还可以将 可调用对象(Service() 业务处理函数) 作为参数传递给 Task 对象

Task.hpp 任务类

#pragma once

#include <string>
#include <functional>

namespace Yohifo
{
    // Service() 业务处理函数的类型
    using cb_t = std::function<void(int, std::string, uint16_t)>;

    class Task
    {
    public:
        // 可以再提供一个默认构造(防止部分场景中构建对象失败)
        Task()
        {}

        Task(int sock, const std::string& ip, const uint16_t& port, const cb_t& cb)
            :sock_(sock), ip_(ip), port_(port), cb_(cb)
        {}

        // 重载运算操作,用于回调 [业务处理函数]
        void operator()()
        {
            // 直接回调 cb [业务处理函数] 即可
            cb_(sock_, ip_, port_);
        }

    private:
        int sock_;
        std::string ip_;
        uint16_t port_;
        cb_t cb_; // 回调函数
    };
}

准备工作完成后,接下来就是往 server.hpp 服务器头文件中添加组件了

注意:

  • 在构建 Task 对象时,需要使用 bind 绑定类内函数,避免参数不匹配
  • 当前的线程池是单例模式,在 Task 任务对象构建后,通过线程池操作句柄 push 对象即可

其实也就是在 StartServer.hpp 中增加了这两句代码

// 3.构建任务对象 注意:使用 bind 绑定 this 指针
Yohifo::Task t(sock, clientip, clientport, std::bind(&TcpServer::Service, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3));

// 4.通过线程池操作句柄,将任务对象 push 进线程池中处理
Yohifo::ThreadPool<Yohifo::Task>::getInstance()->pushTask(t);

完整的服务器代码如下

server.hpp 服务器头文件

#pragma once

#include <iostream>
#include <string>
#include <functional>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"
#include "ThreadPool.hpp" // 线程池
#include "Task.hpp" // 任务类

namespace nt_server
{
    const uint16_t default_port = 8888; // 默认端口号
    const int backlog = 32;             // 全连接队列的最大长度

    using func_t = std::function<std::string(std::string)>; // 回调函数类型

    class TcpServer; // 前置声明

    // 包含我们所需参数的类型
    class ThreadData
    {
    public:
        ThreadData(int sock, const std::string& ip, const uint16_t& port, TcpServer* ptr)
            :sock_(sock), clientip_(ip), clientport_(port), current_(ptr)
        {}

    // 设置为公有是为了方便访问
    public:
        int sock_;
        std::string clientip_;
        uint16_t clientport_;
        TcpServer* current_; // 指向 TcpServer 对象的指针
    };

    class TcpServer
    {
    public:
        TcpServer(const func_t &func, const uint16_t port = default_port)
            : func_(func), port_(port), quit_(false)
        {
        }

        ~TcpServer()
        {
        }

        // 初始化服务器
        void InitServer()
        {
            // 1.创建监听套接字
            listensock_ = socket(AF_INET, SOCK_STREAM, 0);
            if (listensock_ == -1)
            {
                std::cerr << "Create ListenSocket Fail!" << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            std::cout << "Create ListenSocket Success! " << listensock_ << std::endl;

            // 2.绑定IP地址与端口号
            struct sockaddr_in local;
            memset(&local, 0, sizeof(local)); // 清零
            local.sin_family = AF_INET;
            local.sin_addr.s_addr = INADDR_ANY; // 绑定任意可用IP地址
            local.sin_port = htons(port_);

            if (bind(listensock_, (const sockaddr *)&local, sizeof(local)))
            {
                std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;
                exit(BIND_ERR);
            }

            // 3.监听
            if (listen(listensock_, backlog) == -1)
            {
                std::cerr << "Listen Fail!" << strerror(errno) << std::endl;
                exit(LISTEN_ERR);
            }

            std::cout << "Listen Success!" << std::endl;
        }

        // 启动服务器
        void StartServer()
        {
            while (!quit_)
            {
                // 1.处理连接请求
                struct sockaddr_in client;
                socklen_t len = sizeof(client);

                int sock = accept(listensock_, (struct sockaddr *)&client, &len);

                // 2.如果连接失败,继续尝试连接
                if (sock == -1)
                {
                    std::cerr << "Accept Fail!" << strerror(errno) << std::endl;
                    continue;
                }

                // 连接成功,获取客户端信息
                std::string clientip = inet_ntoa(client.sin_addr);
                uint16_t clientport = ntohs(client.sin_port);

                std::cout << "Server accept " << clientip + "-" << clientport << " " << sock << " from " << listensock_ << " success!" << std::endl;

                // 3.构建任务对象 注意:使用 bind 绑定 this 指针
                Yohifo::Task t(sock, clientip, clientport, std::bind(&TcpServer::Service, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3));

                // 4.通过线程池操作句柄,将任务对象 push 进线程池中处理
                Yohifo::ThreadPool<Yohifo::Task>::getInstance()->pushTask(t);
            }
        }

        // 业务处理
        void Service(int sock, const std::string& clientip, const uint16_t& clientport)
        {
            char buff[1024];
            std::string who = clientip + "-" + std::to_string(clientport);
            while (true)
            {
                ssize_t n = read(sock, buff, sizeof(buff) - 1); // 预留 '\0' 的位置
                if (n > 0)
                {
                    // 读取成功
                    buff[n] = '\0';
                    std::cout << "Server get: " << buff << " from " << who << std::endl;

                    std::string respond = func_(buff); // 实际业务处理由上层指定

                    // 发送给服务器
                    write(sock, buff, strlen(buff));
                }
                else if (n == 0)
                {
                    // 表示当前读取到文件末尾了,结束读取
                    std::cout << "Client " << who << " " << sock << " quit!" << std::endl;
                    close(sock); // 关闭文件描述符
                    break;
                }
                else
                {
                    // 读取出问题(暂时)
                    std::cerr << "Read Fail!" << strerror(errno) << std::endl;
                    close(sock); // 关闭文件描述符
                    break;
                }
            }
        }

    private:
        int listensock_; // 监听套接字
        uint16_t port_;  // 端口号
        bool quit_;      // 判断服务器是否结束运行
        func_t func_;    // 回调函数
    };
}

接下来编译并运行程序,当服务器启动后(此时无客户端连接),只有一个线程,这是因为我们当前的 线程池 是基于 懒汉模式 实现的,只有当第一次使用时,才会创建线程

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

接下来启动客户端,可以看到确实创建了一批次线程(十个)

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

当然可以支持多客户端同时通信

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

看似程序已经很完善了,其实隐含着一个大问题:当前线程池中的线程,本质上是在回调一个 while(true) 死循环函数,当连接的客户端大于线程池中的最大线程数时,会导致所有线程始终处于满负载状态,直接影响就是连接成功后,无法再创建通信会话(倘若客户端不断开连接,线程池中的线程就无力处理其他客户端的会话)

说白了就是 线程池 比较适合用于处理短任务,对于当前的场景来说,线程池 不适合建立持久通信会话,应该将其用于处理 read 读取、write 写入 任务

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

如果想解决这个问题,有两个方向:Service() 函数中支持一次 [收 / 发],或者多线程+线程池,多线程用于构建通信会话,线程池则用于处理 [收 / 发] 任务

前者实现起来比较简单,无非就是把 Service() 业务处理函数中的 while(true) 循环去掉

Service() 业务处理函数

// 业务处理
void Service(int sock, const std::string &clientip, const uint16_t &clientport)
{
    char buff[1024];
    std::string who = clientip + "-" + std::to_string(clientport);
    ssize_t n = read(sock, buff, sizeof(buff) - 1); // 预留 '\0' 的位置
    if (n > 0)
    {
        // 读取成功
        buff[n] = '\0';
        std::cout << "Server get: " << buff << " from " << who << std::endl;

        std::string respond = func_(buff); // 实际业务处理由上层指定

        // 发送给服务器
        write(sock, buff, strlen(buff));
    }
    else if (n == 0)
    {
        // 表示当前读取到文件末尾了,结束读取
        std::cout << "Client " << who << " " << sock << " quit!" << std::endl;
        close(sock); // 关闭文件描述符
    }
    else
    {
        // 读取出问题(暂时)
        std::cerr << "Read Fail!" << strerror(errno) << std::endl;
        close(sock); // 关闭文件描述符
    }
}

至于后者就比较麻烦了,需要结合 高级IO 相关知识,这里不再阐述


4.日志输出

4.1.日志的重要性

在之前的编程经历中,如果我们的程序运行出现了问题,都是通过 标准输出标准错误错误信息 直接输出到屏幕上,debug 阶段这样使用没啥问题,但如果出错的是一个不断在运行中的服务,那问题就大了,因为服务器是不间断运行中,直接将 错误信息 输出到屏幕上,会导致错误排查变得极为困难

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

将各种 错误信息 组织管理,就形成了日志,日志有属于自己的格式(包括时间、文件名及行号、错误等级等),利于排查问题

所以接下来我们将会实现一个简易版日志器,用于定向输出我们的日志信息

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

4.2.可变参数

日志需要我们指定格式并输出,依赖于可变参数

在编写简易版日志器之前,需要先认识一下 C语言 中有关可变参数的使用,主要包括这几个

#include <stdarg.h>

va_list	// 指向可变参数列表的指针

va_start()	// 将指针指向起始地址

va_arg()	// 根据类型,提取可变参数列表中的参数

va_end()	// 将指针置为空 

关于 可变参数 更多知识详见 《【C语言】可变参数列表》

比如我们可以通过 可变参数 实现参数遍历

#include <stdio.h>
#include <stdarg.h>

void foreach(int format, ...)
{
    va_list p;
    va_start(p, format);

    // 接下来就是获取其中的每一个参数
    for(int i = 0; i < format; i++)
        printf("%d ", va_arg(p, int));
    printf("\n");

    // 置空
    va_end(p);
}

int main()
{
    foreach(5, 1,2,3,4,5);
    return 0;
}

这种依靠自己动手的方法比较麻烦,我们也可以借助标准库提供的 vsnprintf() 函数进行参数解析

4.3.日志器实现

日志是有等级的,一般分为五级:

  1. Debug 用于调试
  2. Info 提示信息
  3. Warning 警告
  4. Errorr 错误
  5. Fatal 致命错误

错误等级越高,代表影响越大

当然难免有不明确的错误,可以再添加一级:UnKnow 未知错误

// 日志等级
enum
{
    Debug = 0,
    Info,
    Warning,
    Error,
    Fatal
};

string getLevel(int level)
{
    vector<string> vs = {"<Debug>", "<Info>", "<Warning>", "<Error>", "<Fatal>", "<Unknown>"};
    
    //避免非法情况
    if(level < 0 || level >= vs.size() - 1)
        return vs[vs.size() - 1];
    
    return vs[level];
}

接下来是获取时间信息,可以通过 time() 函数获取当前时间戳,然后再利用 localtime() 函数构建 struct tm 结构体对象,这个对象会将时间戳解析成 年月日 时分秒 等详细信息,直接获取即可

strcut tm 结构体的信息如下,细节:年份已经 -1900 了,使用时需要加上 1900;月份从 0 开始,使用时需要 +1

/* Used by other time functions.  */
struct tm
{
  int tm_sec;			/* Seconds.	[0-60] (1 leap second) */
  int tm_min;			/* Minutes.	[0-59] */
  int tm_hour;			/* Hours.	[0-23] */
  int tm_mday;			/* Day.		[1-31] */
  int tm_mon;			/* Month.	[0-11] */
  int tm_year;			/* Year	- 1900.  */
  int tm_wday;			/* Day of week.	[0-6] */
  int tm_yday;			/* Days in year.[0-365]	*/
  int tm_isdst;			/* DST.		[-1/0/1]*/

# ifdef	__USE_BSD
  long int tm_gmtoff;		/* Seconds east of UTC.  */
  const char *tm_zone;		/* Timezone abbreviation.  */
# else
  long int __tm_gmtoff;		/* Seconds east of UTC.  */
  const char *__tm_zone;	/* Timezone abbreviation.  */
# endif
};

可以这样获取当前时间

// 获取当前时间
string getTime()
{
    time_t t = time(nullptr);   //获取时间戳
    struct tm *st = localtime(&t);    //获取时间相关的结构体

    char buff[128];
    snprintf(buff, sizeof(buff), "%d-%d-%d %d:%d:%d", st->tm_year + 1900, st->tm_mon + 1, st->tm_mday, st->tm_hour, st->tm_min, st->tm_sec);

    return buff;
}

接下来就是获取进程 PID,这个简单,直接使用 getpid() 函数获取即可,最后是解析参数,需要用到 vsnprintf() 函数,只要传入缓冲区和 va_list 指针,该函数就可以自动解析出参数,并存入缓冲区中

void logMessage(int level, const char* format, ...)
{
    //截获主体消息
    char msgbuff[1024];
    va_list p;
    va_start(p, format);    //将 p 定位至 format 的起始位置
    vsnprintf(msgbuff, sizeof(msgbuff), format, p); //自动根据格式进行读取
    va_end(p);
}

接下来就是将 日志等级 时间 PID参数 进行拼接,形成日志

log.hpp 日志头文件

#pragma once

#include <iostream>
#include <string>
#include <vector>
#include <cstdio>
#include <time.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdarg.h>

using namespace std;

enum
{
    Debug = 0,
    Info,
    Warning,
    Error,
    Fatal
};

string getLevel(int level)
{
    vector<string> vs = {"<Debug>", "<Info>", "<Warning>", "<Error>", "<Fatal>", "<Unknown>"};
    
    //避免非法情况
    if(level < 0 || level >= vs.size() - 1)
        return vs[vs.size() - 1];
    
    return vs[level];
}

string getTime()
{
    time_t t = time(nullptr);   //获取时间戳
    struct tm *st = localtime(&t);    //获取时间相关的结构体

    char buff[128];
    snprintf(buff, sizeof(buff), "%d-%d-%d %d:%d:%d", st->tm_year + 1900, st->tm_mon + 1, st->tm_mday, st->tm_hour, st->tm_min, st->tm_sec);

    return buff;
}

//处理信息
void logMessage(int level, const char* format, ...)
{
    //日志格式:<日志等级> [时间] [PID] {消息体}
    string logmsg = getLevel(level);    //获取日志等级
    logmsg += " " + getTime();  //获取时间
    logmsg += " [" + to_string(getpid()) + "]";    //获取进程PID

    //截获主体消息
    char msgbuff[1024];
    va_list p;
    va_start(p, format);    //将 p 定位至 format 的起始位置
    vsnprintf(msgbuff, sizeof(msgbuff), format, p); //自动根据格式进行读取
    va_end(p);

    logmsg += " {" + string(msgbuff) + "}";    //获取主体消息

    printf("%s\n", logmsg);

} 

为什么日志消息最后还是向屏幕输出?这样组织日志消息的好处是什么?
因为现在还在测试阶段,等测试完成后,可以将日志消息存入文件中,做到持久化存储;至于统一组织的好处不言而喻,能够确保每条日志消息都包含必要信息,便于排查错误

简单测试的效果如下

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

4.4.应用于程序中

接下来可以包含 log.hpp 这个日志器头文件,并进行日志输出了,比如先将 client.hpp 客户端头文件中的错误信息日志化(代码少一些,比较好改)

client.hpp 客户端头文件

#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"
#include "log.hpp"

namespace nt_client
{
    class TcpClient
    {
    public:
        TcpClient(const std::string& ip, const uint16_t port)
            :server_ip_(ip), server_port_(port)
        {}

        ~TcpClient()
        {}

        // 初始化客户端
        void InitClient()
        {
            // 创建套接字
            sock_ = socket(AF_INET, SOCK_STREAM, 0);
            if (sock_ == -1)
            {
                logMessage(Fatal, "Create Socket Fail! %s", strerror(errno));
                exit(SOCKET_ERR);
            }

            logMessage(Debug, "Create Sock Succeess! %d", sock_);
        }

        // 启动客户端
        void StartClient()
        {
            // 填充服务器的 sockaddr_in 结构体信息
            struct sockaddr_in server;
            socklen_t len = sizeof(server);
            memset(&server, 0, len);
            server.sin_family = AF_INET;
            inet_aton(server_ip_.c_str(), &server.sin_addr); // 将点分十进制转化为二进制IP地址的另一种方法
            server.sin_port = htons(server_port_);

            // 尝试重连 5 次
            int n = 5;
            while(n)
            {
                int ret = connect(sock_, (const struct sockaddr*)&server, len);

                if(ret == 0)
                {
                    // 连接成功,可以跳出循环
                    break;
                }

                // 尝试进行重连
                logMessage(Warning, "网络异常,正在进行重连... 剩余连接次数: %d", --n);
                sleep(1);
            }

            // 如果剩余重连次数为 0,证明连接失败
            if(n == 0)
            {
                logMessage(Fatal, "连接失败! %s", strerror(errno));
                close(sock_);
                exit(CONNECT_ERR);
            }

            // 连接成功
            logMessage(Info, "连接成功!");

            // 进行业务处理
            Service();
        }

        // 业务处理
        void Service()
        {
            char buff[1024];
            std::string who = server_ip_ + "-" + std::to_string(server_port_);
            while(true)
            {
                // 由用户输入信息
                std::string msg;
                std::cout << "Please Enter >> ";
                std::getline(std::cin, msg);

                // 发送信息给服务器
                write(sock_, msg.c_str(), msg.size());

                // 接收来自服务器的信息
                ssize_t n = read(sock_, buff, sizeof(buff) - 1);
                if(n > 0)
                {
                    // 正常通信
                    buff[n] = '\0';
                    std::cout << "Client get: " << buff << " from " << who << std::endl;
                }
                else if(n == 0)
                {
                    // 读取到文件末尾(服务器关闭了)
                    logMessage(Error, "Server %s quit! %s", who.c_str(), strerror(errno));
                    close(sock_); // 关闭文件描述符
                    break;
                }
                else
                {
                    // 读取异常
                    logMessage(Error, "Read Fail! %s", strerror(errno));
                    close(sock_); // 关闭文件描述符
                    break;
                }
            }
        }
        
    private:
        int sock_; // 套接字
        std::string server_ip_; // 服务器IP
        uint16_t server_port_; // 服务器端口号
    };
}

效果就是这个样子,至于代码中其他输出错误的地方,都可以采用 简易版日志器 进行统一输出

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

改造完成的程序长这个样子

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

4.5.持久化存储

所谓持久化存储就是将日志消息输出至文件中,修改 log.hpp 中的代码即可

  • 指定日志文件存放路径
  • 打开文件,将日志消息追加至文件中

注意:当前的改动中并未涉及目录创建,所以需要手动创建相关目录

log.hpp 日志头文件

#pragma once

#include <iostream>
#include <string>
#include <vector>
#include <cstdio>
#include <time.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdarg.h>

using namespace std;

enum
{
    Debug = 0,
    Info,
    Warning,
    Error,
    Fatal
};

static const string file_name = "log/TcpServer.log";

string getLevel(int level)
{
    vector<string> vs = {"<Debug>", "<Info>", "<Warning>", "<Error>", "<Fatal>", "<Unknown>"};
    
    //避免非法情况
    if(level < 0 || level >= vs.size() - 1)
        return vs[vs.size() - 1];
    
    return vs[level];
}

string getTime()
{
    time_t t = time(nullptr);   //获取时间戳
    struct tm *st = localtime(&t);    //获取时间相关的结构体

    char buff[128];
    snprintf(buff, sizeof(buff), "%d-%d-%d %d:%d:%d", st->tm_year + 1900, st->tm_mon + 1, st->tm_mday, st->tm_hour, st->tm_min, st->tm_sec);

    return buff;
}

//处理信息
void logMessage(int level, const char* format, ...)
{
    //日志格式:<日志等级> [时间] [PID] {消息体}
    string logmsg = getLevel(level);    //获取日志等级
    logmsg += " " + getTime();  //获取时间
    logmsg += " [" + to_string(getpid()) + "]";    //获取进程PID

    //截获主体消息
    char msgbuff[1024];
    va_list p;
    va_start(p, format);    //将 p 定位至 format 的起始位置
    vsnprintf(msgbuff, sizeof(msgbuff), format, p); //自动根据格式进行读取
    va_end(p);

    logmsg += " {" + string(msgbuff) + "}";    //获取主体消息

    //持久化。写入文件中
    FILE* fp = fopen(file_name.c_str(), "a");   //以追加的方式写入
    if(fp == nullptr) return;   //不太可能出错

    fprintf(fp, "%s\n", logmsg.c_str());
    fflush(fp); //手动刷新一下
    fclose(fp);
    fp = nullptr;
} 

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议
网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议


5.守护进程

5.1.会话、进程组、进程

接下来进入本文中的最后一个小节: 守护进程

守护进程 的意思就是让进程不间断的在后台运行,即便是 bash 关闭了,也能照旧运行。守护进程 就是现实生活中的服务器,因为服务器是需要 24H 不间断运行的

当前我们的程序在启动后属于 前台进程前台进程 是由 bash 进程替换而来的,因此会导致 bash 暂时无法使用

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

如果在启动程序时,带上 & 符号,程序就会变成 后台进程后台进程 并不会与 bash 进程冲突,bash 仍然可以使用

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

后台进程 也可以实现服务器不间断运行,但问题在于 如果当前 bash 关闭了,那么运行中的后台进程也会被关闭,最好的解决方案是使用 守护进程

在正式学习 守护进程 之前,需要先了解一组概念:会话、进程组、进程

分别运行一批 前台、后台进程,并通过指令查看进程运行情况

sleep 1000 | sleep 2000 | sleep 3000 &

sleep 100 | sleep 200 | sleep 300

ps -ajx | head -1 && ps -ajx | grep sleep | grep -v grep

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

其中 会话 <-> SID、进程组 <-> PGID、进程 <-> PID,显然,sleep 1000、2000、3000 处于同一个管道中(有血缘关系),属于同一个 进程组,所以他们的 PGID 都是一样的,都是 4261;至于 sleep 100、200、300 属于另一个 进程组PGID4308;再仔细观察可以发现 每一组的进程组 PGID 都与当前组中第一个被创建的进程 PID 一致,这个进程被称为 组长进程

会话 >= 进程组 >= 进程

无论是 后台进程 还是 前台进程,都是从同一个 bash 中启动的,所以它们处于同一个 会话 中,SID 都是 1939,并且关联的 终端文件 TTY 都是 pts/1

Linux 中一切皆文件,终端文件也是如此,这里的终端其实就是当前 bash 输出结果时使用的文件(也就是屏幕),终端文件位于 dev/pts 目录下,如果向指定终端文件中写入数据,那么对方也可以直接收到
(关联终端文件说白了就是打开了文件,一方写,一方读,不就是管道吗)

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

根据当前的 会话 SID 查找目标进程,发现这玩意就是 bash 进程,bash 进程本质上就是一个不断运行中的 前台进程,并且自成 进程组

在同一个 bash 中启动前台、后台进程,它们的 SID 都是一样的,属于同一个 会话,关联了同一个 终端SID 其实就是 bashPID

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

我们使用 XShell 等工具登录 Linux 服务器时,会在服务器中创建一个 会话bash),可以在该会话内创建 进程,当 进程 间有关系时,构成一个 进程组组长 进程的 PID 就是该 进程组PGID

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

Linux 中的登录操作实际上就是创建了一个会话,Windows 中也是如此,当你的 Windows 变卡时,可以使用 [注销] 按钮结束整个会话,重新登录,电脑就会流畅如初

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议


在同一个会话中,只允许一个前台进程在运行,默认是 bash,如果其他进程运行了,bash 就会变成后台进程(暂时无法使用),让出前台进程这个位置(后台进程与前台进程之前是可以进程切换)

如何将一个 后台进程 变成 前台进程

首先通过指令查看当前 会话 中正在运行的 后台进程,获取 任务号

jobs

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

接下来通过 任务号后台进程 变成 前台进程,此时 bash 就无法使用了

fg 1

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

那如何将 前台进程 变成 后台进程

首先是通过 ctrl + z 发送 19SIGSTOP 信号,暂停正在运行中的 前台进程

键盘输入 ctrl + z

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

然后通过 任务号,可以把暂停中的进程变成 后台进程

bg 1

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

5.2.守护进程化

一般网络服务器为了不受到用户登录重启的影响,会以 守护进程 的形式运行,有了上面那一批前置知识后,就可以很好的理解 守护进程 的本质了

守护进程:进程单独成一个会话,并且以后台进程的形式运行

说白了就是让服务器不间断运行,可以直接使用 daemon() 函数完成 守护进程化

#include <unistd.h>

int daemon(int nochdir, int noclose);

参数解读:

  1. nochdir 改变进程的工作路径
  2. noclose 重定向标准输入、标准输出、标准错误

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

一般情况下,daemon() 函数的两个参数都只需要传递 0默认工作在 / 路径下,默认重定向至 /dev/null

/dev/null 就像是一个 黑洞,可以把所有数据都丢入其中,相当于丢弃数据

使用 damon() 函数使之前的server.cc 守护进程化

server.cc 服务器源文件

#include <memory> // 智能指针头文件
#include <string>
#include <unistd.h>
#include "server.hpp"

using namespace std;
using namespace nt_server;

// 业务处理回调函数(字符串回响)
string echo(string request)
{
    return request;
}

int main()
{
    // 直接守护进程化
    daemon(0, 0);
    
    unique_ptr<TcpServer> usvr (new TcpServer(echo)); // 将回调函数进行传递

    usvr->InitServer();
    usvr->StartServer();
    return 0;
}

现在服务器启动后,会自动变成 后台进程,并且自成一个 新会话,归操作系统管(守护进程 本质上是一种比较坚强的 孤儿进程

注意:现在标准输出、标准错误都被重定向至 /dev/null 中了,之前向屏幕输出的数据,现在都会直接被丢弃,如果想保存数据,可以选择使用日志

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

如果想终止 守护进程,需要通过 kill pid 杀死目标进程

使用系统提供的接口一键 守护进程化 固然方便,不过大多数程序员都会选择手动 守护进程化(可以根据自己的需求定制操作)

原理是 使用 setsid() 函数新设一个会话,谁调用,会话 SID 就是谁的,成为一个新的会话后,不会被之前的会话影响

#include <unistd.h>

pid_t setsid(void);

返回值:成功返回该进程的 pid,失败返回 -1

注意:调用该函数的进程,不能是组长进程,需要创建子进程后调用

手动实现守护进程时需要注意以下几点:

  1. 忽略异常信号
  2. 0、1、2 要做特殊处理(文件描述符)
  3. 进程的工作路径可能要改变(从用户目录中脱离至根目录)

具体实现步骤如下:

1、忽略常见的异常信号:SIGPIPESIGCHLD

2、如何保证自己不是组长? 创建子进程 ,成功后父进程退出,子进程变成守护进程

3、新建会话,自己成为会话的 话首进程

4、(可选)更改守护进程的工作路径:chdir

5、处理后续对于 0、1、2 的问题

对于 标准输入、标准输出、标准错误 的处理方式有两种

暴力处理:直接关闭 fd

优雅处理:fd 重定向至 /dev/null,也就是 daemon() 函数的做法

这里我们选择后者,守护进程 的函数实现如下

Daemon.hpp 守护进程头文件

#pragma once

#include <iostream>
#include <cstring>
#include <cerrno>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include "err.hpp"
#include "log.hpp"

static const char *path = "/home/Yohifo";

void Daemon()
{
    // 1、忽略常见信号
    signal(SIGPIPE, SIG_IGN);
    signal(SIGCHLD, SIG_IGN);

    // 2、创建子进程,自己退休
    pid_t id = fork();
    if (id > 0)
        exit(0);
    else if (id < 0)
    {
        // 子进程创建失败
        logMessage(Error, "Fork Fail: %s", strerror(errno));
        exit(FORK_ERR);
    }

    // 3、新建会话,使自己成为一个单独的组
    pid_t ret = setsid();
    if (ret == -1)
    {
        // 守护化失败
        logMessage(Error, "Setsid Fail: %s", strerror(errno));
        exit(SETSID_ERR);
    }

    // 4、更改工作路径
    int n = chdir(path);
    if (n == -1)
    {
        // 更改路径失败
        logMessage(Error, "Chdir Fail: %s", strerror(errno));
        exit(CHDIR_ERR);
    }

    // 5、重定向标准输入输出错误
    int fd = open("/dev/null", O_RDWR);
    if (fd == -1)
    {
        // 文件打开失败
        logMessage(Error, "Open Fail: %s", strerror(errno));
        exit(OPEN_ERR);
    }

	// 重定向标准输入、标准输出、标准错误
    dup2(fd, 0);
    dup2(fd, 1);
    dup2(fd, 2);

    close(fd);
}

当然相应的错误码也需要更新

err.hpp 错误码头文件

#pragma once

enum
{
    USAGE_ERR = 1,
    SOCKET_ERR,
    BIND_ERR,
    LISTEN_ERR,
    CONNECT_ERR,
    FORK_ERR,
    SETSID_ERR,
    CHDIR_ERR,
    OPEN_ERR
};

接下来就是在服务启动成功后,将其 守护进程化

StartServer() 服务器启动函数 — 位于 server.hpp 服务器头文件中的 TcpServer

#include "myDaemon.hpp"

// 启动服务器
void StartServer()
{
    // 守护进程化
    Daemon();
	
	// ...
}

现在服务器在启动后,会自动新建会话,以 守护进程 的形式运行

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

关于 inet_ntoa 函数的返回值(该函数的作用是将四字节的 IP 地址转化为点分十进制的 IP 地址)
inet_ntoa 返回值为 char*,转化后的 IP 地址存储在静态区,二次调用会覆盖上一次的结果,多线程场景中不是线程安全的

  • 不过在 CentOS 7 及更高版本中,接口进行了更新,新增了互斥锁,多线程场景中测试没问题

6.完整代码

下面是不同版本服务器的完整代码

「朴素版,支持单客户端连接」

「多进程版,支持多客户端连接」

「多线程版(原生线程库),支持多客户端连接」

「多线程版(线程池),支持多客户端连接」

「日志版,支持简易日志输出」

「守护进程版,支持服务部署」

网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议


🌨️总结

以上是关于『简易TCP网络程序』的全部内容,作为上一篇博客的延伸,本文重新实现了字符串回响网络程序,基于TCP协议逐步改造并引入多进程、多线程、线程池、日志输出、守护进程等技术。这使得网络程序更为成熟,为后续网络和高级IO的学习提供了有力支持。同时,对套接字编程的重要性也得到了充分体现。希望本文能为读者在网络编程领域的深入学习提供实质性帮助。


网络编程『简易TCP网络程序』,Linux学习之旅,神奇的网络世界,网络,tcp/ip,网络协议

文章来源地址https://www.toymoban.com/news/detail-761478.html

相关文章推荐

网络编程『socket套接字 ‖ 简易UDP网络程序』

网络基础『发展 ‖ 协议 ‖ 传输 ‖ 地址』

到了这里,关于网络编程『简易TCP网络程序』的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • JavaEE & UDP简易翻译服务器 & 网络编程示例2 & TCP回显服务器,回显客户端

    禁止白嫖 T T 点点赞呗 这个翻译器主要是在上一章的回显服务器和回显客户端上进行修改 修改了计算响应的过程, 即process方法 1.1 重写方法 重写方法是Java中的一种重要手段 指在一个类的子类里,对父类的一个方法进行重新定义! 而父类的权限级别要大于等于子类~ 【除了

    2023年04月16日
    浏览(60)
  • 【网络编程】网络编程套接字(三)TCP网络程序

    与前边的UDP网络程序相同,创建套接字的接口都是socket,下边对socket接口进行介绍: 协议家族选择AF_INET,因为我们要进行网络通信。 而第二个参数,为服务类型,传入SOCK_STREAM,我们编写TCP程序,所以要选择流式的服务。 第三个参数默认传入0,由前两个参数就可以推出这是

    2024年02月16日
    浏览(62)
  • unix网络编程-简易服务器与客户端程序解析

    a -- address f -- file        eg: fputs() -- file put stream fd -- file descriptor h - host(主机) in/inet -- internet        eg: sockaddr_in; inet_aton n -- network(网络字节序)/numeric(数值) p -- protocol(协议)/presentation(表达/呈现形式) s -- socket        eg: sin -- socket internet t -- type,用于指定某种

    2024年01月16日
    浏览(69)
  • 【Linux网络编程】网络编程套接字(TCP服务器)

    作者:爱写代码的刚子 时间:2024.4.4 前言:本篇博客主要介绍TCP及其服务器编码 只介绍基于IPv4的socket网络编程,sockaddr_in中的成员struct in_addr sin_addr表示32位 的IP地址 但是我们通常用点分十进制的字符串表示IP地址,以下函数可以在字符串表示和in_addr表示之间转换 字符串转in

    2024年04月14日
    浏览(79)
  • 【Linux网络】网络编程套接字(TCP)

    目录 地址转换函数 字符串IP转整数IP 整数IP转字符串IP 关于inet_ntoa 简单的单执行流TCP网络程序 TCP socket API 详解及封装TCP socket  服务端创建套接字  服务端绑定  服务端监听  服务端获取连接  服务端处理请求 客户端创建套接字 客户端连接服务器 客户端发起请求 服务器测试

    2024年03月21日
    浏览(71)
  • golang网络编程学习-TCP

    golang网络编程学习-TCP 网络编程主要的内容是: 1.TCP网络编程 2.http服务 3.rpc服务 4.websocket服务   golang网络编程学习-TCP 一、TCP/IP TCP/IP是一种协议簇,它是网络通信的基础,是互联网的核心协议,负责数据在网络中的传输。它包括TCP和IP两个协议,TCP提供数据报传输的可靠性,

    2024年02月16日
    浏览(44)
  • Linux网络编程——tcp套接字

    本章Gitee仓库:tcp套接字 客户端: 客户端: 关于构造和初始化,可以直接在构造的时候,将服务器初始化,那为什么还要写到 init 初始化函数里面呢? 构造尽量简单一点,不要做一些“有风险”的操作。 tcp 是面向连接的,通信之前要建立连接,服务器处于等待连接到来的

    2024年02月20日
    浏览(58)
  • Linux网络编程(TCP状态转换关系)

    本篇文章来讲解一下TCP的状态转换关系,学习这个状态转换关系对于我们深入了解网络编程是非常有必要的。 客户端状态转换: 1.CLOSED - SYN-SENT:当客户端尝试与服务器建立连接时,客户端从CLOSED状态转换到SYN-SEND状态。此时客户端发送SYN(同步)包,表示请求建立连接,并

    2024年02月07日
    浏览(44)
  • Linux网络编程之TCP文件传输

    1. 要求 在Linux环境下,编程实现文件的上传和下载,即客户端可以发送文件给服务器,服务器将文件写到服务器端文件系统中;客户端请求下载文件时服务器读取文件内容,发送给客户端,客户端接收内容并写入本地文件。要求 (1)源代码格式化良好并适当注释; (2)除上述核心功

    2024年02月08日
    浏览(43)
  • 【Linux】网络---->套接字编程(TCP)

    TCP的编程流程:大致可以分为五个过程,分别是准备过程、连接建立过程、获取新连接过程、消息收发过程和断开过程。 1.准备过程:服务端和客户端需要创建各自的套接字,除此之外服务端还需要绑定自己的地址信息和进行监听。注意:服务端调用listen函数后,处理监听状

    2024年02月04日
    浏览(67)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包