• 【网络】网络编程——带你手搓简易TCP服务端(echo服务器)+客户端(四种版本)


    在这里插入图片描述
    本篇博客由 CSDN@先搞面包再谈爱 原创,转载请标注清楚,请勿抄袭。

    前言

    本篇主要讲解套接字编程,以TCP服务端和客户端为主,提供以下版本:

    1. 单线程循环版
    2. 多进程版(两个小版本)
    3. 多线程版
    4. 线程池版

    本篇部分内容基于上篇UDP服务端和客户端的编写,屏幕前的你若对于UDP编写服务端和客户端不熟悉,建议先看我上一篇博客:【网络】网络编程入门篇——了解接口,快速上手,带你手搓简易UDP服务器和客户端(简易远端shell、简易群聊功能以及跨平台群聊),如果你已经很了解UDP相关通信的接口就不用看了。

    正式开始

    用生活中的例子来讲解TCP服务端和客户端

    就以餐馆为例。

    各位出门吃饭的时候,有些餐馆门口会有接待员。

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

    你路过这家餐馆的时候接待员会邀请你去这家餐馆吃饭。假如说此时有一个接待员张三。
    在这里插入图片描述

    当张三把一个客人引入餐馆后,其下一步要做什么?
    是换成服务员来为做顾客点餐之类的事情,假如此时有一个服务员叫做李四,这个人为新客人提供服务:
    在这里插入图片描述

    张三被替换走后,张三下来干啥?
    继续去餐馆门口揽客:
    在这里插入图片描述

    揽到客了就再重复上述步骤。

    好了,例子就讲到这,这里已经包含了TCP的思想了。

    下面通过代码来逐步分析出上述步骤。

    代码讲解

    下面代码中的部分接口我已经在上一篇UDP中详谈了,这些详细说过的代码就不再细说了,直接一带而过。

    服务端基本框架

    服务端想要通信,大致分5步。

    1. 创建套接字。
    2. bind IP + 端口号
    3. 等待对方(客户端)连接,也就是监听
    4. 获取连接,获取到发送方的IP和端口
    5. 进行通信

    等会写代码的时候再说其中各个步骤都对应上面餐馆例子中的哪一步。

    在上一篇的UDP中只有1、2、5三步,UDP就是简单一点。

    不过TCP也没有麻烦到哪里。各位细看我讲就行。

    封装一个服务器,变量IP+port,还是老四样:
    在这里插入图片描述

    最终的成品代码会在后面给出。

    其中初始化服务器负责1、2、3步。
    启动服务器负责4、5步。其实这五步放到一个函数中都行,不过我这里就这样写了。

    创建套接字 + bind

    下面图中用到的LogMessage是我自己搞的一个日志函数,代码在后面给出。
    在这里插入图片描述

    这两步中所有的接口以及各个字段的解释在我上一篇的UDP中已经讲过了,这里不再详谈,若有不懂的同学,请看我上一篇。

    LogMessage.hpp:

    #pragma once
    #include 
    #include 
    #include 
    #include 
    
    #include 
    
    #include 
    
    // 文件名
    #define _F __FILE__
    // 所在行
    #define _L __LINE__
    
    enum level
    {
        DEBUG, // 0
        NORMAL, // 1
        WARING, // 2
        ERROR, // 3
        FATAL // 4
    };
    
    std::vector<const char*> gLevelMap = {
        "DEBUG",
        "NORMAL",
        "WARING",
        "ERROR",
        "FATAL"
    };
    
    #define FILE_NAME "./log.txt"
    
    void LogMessage(int level, const char* file, int line, const char* format, ...)
    {
    #ifdef NO_DEBUG
        if(level == DEBUG)  return;
    #endif
    
        // 固定格式
        char FixBuffer[512];
        time_t tm = time(nullptr);
        // 日志级别 时间 哪一个文件 哪一行
        snprintf(FixBuffer, sizeof(FixBuffer), \
        "<%s>==[file->%s] [line->%d] ----------------------------------- time:: %s", gLevelMap[level], file, line, ctime(&tm));
    
        // 用户自定义格式
        char DefBuffer[512];
        va_list args; // 定义一个可变参数
        va_start(args, format); // 用format初始化可变参数
        vsnprintf(DefBuffer, sizeof DefBuffer, format, args); // 将可变参数格式化打印到DefBuffer中
        va_end(args); // 销毁可变参数
    
        // 往显示器打
        printf("%s\t==> %s\n\n\n", FixBuffer, DefBuffer);
        
        // 往文件中打
        // FILE* pf = fopen(FILE_NAME, "a");
        // fprintf(pf, "%s\t==> %s\n\n\n", FixBuffer, DefBuffer);
        // fclose(pf);
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    listen监听

    先来看listen函数:
    在这里插入图片描述

    这个函数作用就是将套接字设置为监听状态。

    UDP不需要做这一步,但TCP是面向连接的,当正式通信时,需要先建立连接,就像餐馆例子中的张三一样,得要在门口招揽客人,等到客人并邀请进入了餐馆就是餐馆和客人建立连接了。

    所以作为一款TCP服务器,就需要一直处于等待被连接的状态,此时才可以让对方(客户端)随时发起连接,UDP可以直接发,但是TCP不行。

    两个参数:

    • sockfd,就是刚刚第一步创建出来的套接字。
    • backlog,意思是全连接的长度,但是我前面的博客中还没有详细讲解TCP,这里的参数先不解释,等我后面博客讲了TCP协议后再详谈。用的时候给一个数字就行,不能太大也不能太小,等会直接给个20。

    返回值
    成功返回0,失败返回-1并设置errno

    代码:
    其中的gBackLog定义在前面:
    在这里插入图片描述

    在这里插入图片描述

    然后初始化工作就完成了,下面我运行起来服务端就是这个样子:
    在这里插入图片描述

    然后再来说启动服务器。

    accept接收连接

    套接字进入监听状态后就要接收连接,当发送过来连接的时候就是调用accept来获取到连接的。

    看函数:
    在这里插入图片描述

    先说参数:

    • sockfd,还是刚刚第一次创建的套接字文件描述符。
    • addr和addrlen和前面UPD中的recvfrom函数的最后两个参数一样,addr是一个输出型参数,获取发送方的IP + port信息,addrlen就是addr指向的对象的大小,一个输入输出型参数。

    返回值:

    • 我先拿出man手册中的描述:On success, these system calls return a nonnegative integer that is a descriptor for the accepted socket. On error, -1 is returned, and errno is set appropriately.
    • 意思就是当accept成功的时候会返回一个非负数的文件描述符,这个文件描述符是通过刚开始创建套接字的那个文件描述符获得的,失败时返回-1并设置错误码。

    什么意思呢?
    再回想一下我前面讲的餐馆的例子,里面有两种服务人员,一种是张三这样的接待员,一种是李四这样的服务员:
    在这里插入图片描述
    .
    张三负责揽客,而李四负责服务客人,这就是初始套接字文件描述符和这里返回的文件描述符的区别。

    • 初始的文件描述符是为了建立连接而设定的,而这里accept返回的文件描述符是为了真正通信时的IO而设定的。所以我可以把前面的_sock改一下名字,改成_listenSock,这里accept的返回值设定为serverSock。

    然后就可以写代码了:
    在这里插入图片描述

    然后这里就可以简单测试一下通不通了,虽然我没有写客户端,但是这里可以用telnet命令来进行简易的通信测试,如果没装telnet的同学照着这篇博客装:linux未找到telnet命令

    测试:
    在这里插入图片描述

    用telnet测试(telnet + IP + port就能建立连接,想要退出的话ctrl + ] (右方括号) 然后按q+回车):
    在这里插入图片描述

    成功。

    关于客户端的代码在最后再讲,这里后续代码都先用telnet来模拟客户端。

    然后就可以进行通信了,我这里就做一个最简单的服务器,一个echo服务器,就是客户端发什么就回什么。

    TCP的通信比UDP要简单,有两种通信方式:

    1. 直接调用系统中的read和write接口,这两个就是最简单的文件读写接口,我前面的博客中也讲过,不懂的同学点这里:【Linux】基础文件IO、动静态库的制作和使用,这里我就不再细讲write和read接口了。
    2. send和recv接口,这两个和UDP中的sendto还有recvfrom稍微有点不一样的就是参数:
      在这里插入图片描述
      在这里插入图片描述
      send和recv不需要传后面的两个参数了,因为前面accept的时候已经确定的对方(客户端)的IP + port了,并且已经产生了专门进行通信的文件描述符,所以这里不需要再通过recvfrom确定一下端口号。客户端和服务端通信的时候就就是通过给定的打开的文件进行通信的。

    然后这里服务端就用write和read进行通信了,等会客户端用send和recv进行通信。

    通信

    这里服务端通信时会写五个版本:

    1. 单线程版
    2. 多进程①版
    3. 多进程②版
    4. 多线程版
    5. 线程池版

    挨着说。

    单线程版

    很简单,只让服务端主线程进行接收数据和发送数据就行。
    下面的service是我自己写的接口,在这块代码下面:
    在这里插入图片描述
    在这里插入图片描述

    这样一个简单的echo服务器就写好了。

    测试:
    在这里插入图片描述

    正确的。

    多进程①版

    上面单线程有个缺陷,就是服务器一次只能接收并服务一个客户端的请求,意思就是餐馆中只有一服务人员,既要当接待员,接待了之后还得当服务员,不能继续回去执行接待工作,只能卡在发送数据和接收数据这里,因为我这写的service函数是一个死循环。

    那么这样的服务器是没法用的:
    在这里插入图片描述

    当第一个连接断开后,才会将第二个连接发过的所有数据返回来:
    在这里插入图片描述

    所以这里用起来很难受,这里可以创建一个进程来改进一下。

    让子进程执行服务工作,父进程就只管建立连接就行,建立一个连接就创建一个子进程。
    就相当于父进程进行张三接客的工作,子进程进行李四这类服务员的工作。

    代码如下:

    在这里插入图片描述

    测试:
    在这里插入图片描述

    在这里插入图片描述

    但是我有一个问题想要问问屏幕前的你:
    在这里插入图片描述
    这里连接后的文件描述符为啥都是4呢?

    很简答,因为每次父进程在创建完子进程后就会关掉serverSock文件描述符,所以会导致父进程中每次的文件描述符都是先打开4后关闭4,所以每次连接的时候就打印的都是4了。子进程最后那里exit的时候没有写close(serverSock)不影响,因为调用exit时其资源已经被自动回收了。

    这里进程版的最主要的一步就是对子进程退出信号忽略的那一步,非常重要。
    如果你不懂信号,看这篇:【Linux】三万字详解信号❤️❤️隔壁小孩看了都说学会了❤️❤️

    多进程②版

    其实这里没什么改进的,就是换一个能自动回收子进程空间的方法。

    代码:
    在这里插入图片描述

    运行:
    在这里插入图片描述

    丝毫没有问题。

    我这里让父进程最后的时候不关闭文件描述符:
    在这里插入图片描述

    就会出现这样的情况:
    在这里插入图片描述

    这种行为还是很危险的,因为内核中的文件描述符个数是有限的,不断这样积累下去迟早会用完,这样就会导致服务器永远无法通信,除非再重启。

    看:
    在这里插入图片描述

    其中open files就是文件描述符的最大个数。只有十万个,可以改但不建议,真用起来的话很快就完了。

    所以一定要避免文件描述符泄漏。

    多线程版

    上面创建进程的方法,开销稍微有点大,创建一个子进程,要创建一堆相应的内核数据结构,还有数据啥的,相比创建线程要大不少,多线程的成本更少一点。

    所以这里就来实现一下多线程的版本。
    在这里插入图片描述

    在这里插入图片描述

    运行起来服务端,连接一个客户端:
    在这里插入图片描述

    此时就会多创建一个线程。

    再创一个客户端:
    在这里插入图片描述
    这里又创建了一个线程。

    客户端发数据:
    在这里插入图片描述

    成功。

    线程池版

    创建线程和创建进程的方式都不好,因为当同一时刻有非常多客户端连接时,比如说上万个客户端,那么此时就会创建上万个线程或进程,这样的话,新创建出来的线程/进程会占用很多的内存空间,当太多就肯呢个会导致os杀掉某些任务甚至是os自身出问题,所以池化技术作用就体现出来了,给定线程/进程个数,不会出现短时间内线程/进程个数剧增的情况。相对安全一点。

    前讲多线程的博客中已经讲过线程池了,我这里就直接用当时博客中的线程池代码了。下面线程池的代码我不会再细说,简单过一下,如果你想深入了解的话,看这篇:【Linux】线程详解完结篇——信号量 + 线程池 + 单例模式 + 读写锁

    就直接用线程池中的懒汉模式了,代码直接拷贝过来:
    在这里插入图片描述

    代码:
    服务端添加一个成员:
    在这里插入图片描述

    懒汉模式初始化:
    在这里插入图片描述

    服务器启动:
    在这里插入图片描述

    其中懒汉模式的线程池中内容我稍微做了改动:
    在这里插入图片描述
    一共这么些文件,我这里就只把线程池相关文件的代码给出(LogMessage.hpp就不给了,前面有):

    • lockGuard.hpp
    #pragma once
    
    #include 
    
    class LockGuard
    {
    public:
        LockGuard(pthread_mutex_t* pmtx)
            :_pmtx(pmtx)
        {
            pthread_mutex_lock(_pmtx);
        }
    
        ~LockGuard()
        {
            pthread_mutex_unlock(_pmtx);
        }
    
    public:
        pthread_mutex_t* _pmtx;
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • Task.hpp
    #pragma once
    
    #include 
    #include 
    #include 
    
    typedef std::function<void(int, const std::string&, uint16_t, const std::string&)> func;
    //int serverSock, const std::string& IP, uint16_t port
    class Task
    {
    public:
        Task(){}
    
        Task(func fun, int sockfd, const std::string& IP, uint16_t port)
            : _fun(fun)
            , _sockfd(sockfd)
            , _IP(IP)
            , _port(port)
        {}
    
        void operator()(const std::string& name)
        {
            // 执行任务
            _fun(_sockfd, _IP, _port, name);
        }
    
    public:
        func _fun;
        int _sockfd;
        std::string _IP;
        uint16_t _port;
    };
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • Thread.hpp
    #ifndef __THREAD_HPP__
    #define __THREAD_HPP__
    
    #include 
    #include 
    
    typedef void*(*pfunc)(void*);
    
    #include 
    
    // 封装线程名称和线程回调函数的参数
    class Thread_name_and_Args
    {
    public:
        Thread_name_and_Args(const std::string& name, void* args)
            : _name(name)
            , _args(args)
        {}
    public:
        std::string _name;
        void* _args;
    };
    
    // 线程接口的封装
    class Thread
    {
    public:
        Thread(const std::string& name, pfunc func, void* args)
            : _NA(name, args)
            , _func(func)
        {}
    
        // 创建线程
        void CreateThread()
        {
            pthread_create(&_tid, nullptr, _func, &_NA);
        }
    
        // 等待线程
        void JoinThread()
        {
            pthread_join(_tid, nullptr);
        }
    
        const std::string& getName()const
        {
            return _NA._name;
        }
    
        ~Thread()
        {}
    
    private:
        pthread_t _tid; // 线程id
        Thread_name_and_Args _NA; // 线程名称和回调函数参数
        pfunc _func; // 回调函数的指针
    };
    #endif
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • ThreadPool.hpp
    #pragma once
    
    #include "Thread.hpp"
    #include "LogMessage.hpp"
    #include "lockGuard.hpp"
    #include "Task.hpp"
    
    #include 
    #include 
    
    const int DEFAULT_SIZE = 5;
    
    template<class T>
    class ThreadPool
    {
    private: // Routine专用接口
        // 获取锁地址
        pthread_mutex_t* _GetMTX()
        {
            return &_mtx;
        }
    
        // 获取生消信号量地址
        pthread_cond_t* _GetCond()
        {
            return &_cpCond;
        }
    
        // 判断任务队列中是否为空
        bool _IsEmpty()
        {
            return _taskQueue.empty();
        }
    
        T _GetTask()
        {
            T task = _taskQueue.front();
            _taskQueue.pop();
            return task;
        }
    
        // 非static函数会有this指针,这样在创建线程的时候函数指针pfunc会
        // 和非static函数不匹配,报错,所以要改为static
        static void* Routine(void* args)
        {
            // 获取到当前线程池的地址,因为Routine没有this指针,就无法拿到任务
            Thread_name_and_Args* tNA = reinterpret_cast<Thread_name_and_Args*>(args);
            ThreadPool<T>* pt = reinterpret_cast<ThreadPool<T>*>(tNA->_args);
            while(1)
            {
                // 线程执行的任务
                T task;
                {
                    // 多个消费者获取任务先上锁
                    LockGuard lg(pt->_GetMTX());
                    // 上完锁判断是否有任务,没有任务就等
                    while(pt->_IsEmpty()) pthread_cond_wait(pt->_GetCond(), pt->_GetMTX());
    
                    // 此处一定可以获取任务
                    task = pt->_GetTask();
                }
                // 仿函数执行任务
                task(tNA->_name);
            }
        }
    
    private: 
        // 构造私有
        ThreadPool(int size = DEFAULT_SIZE)
            : _size(size)
        {
            // 锁和条件变量初始化
            pthread_cond_init(&_cpCond, nullptr);
            pthread_mutex_init(&_mtx, nullptr);
    
            // 线程池中创建线程
            for(int i = 0; i < _size; ++i)
            {
                // 线程名字
                std::string name("Thread[");
                name += (std::to_string(i + 1) + ']');
    
                // 往线程池中加入线程                /*给ThreadData传this指针,不然Routine中线程拿不到任务*/
                _threadPool.push_back(new Thread(name, Routine, this));
            }
        }
    
        // 删掉拷构和赋构
        ThreadPool(const T& ref) = delete;
        const T& operator=(const T& ref) = delete;
    
    public:
        // 添加任务
        void PushTask(const T& task)
        {
            // 生消互斥,先上锁
            LockGuard lg(&_mtx);
            _taskQueue.push(task);
            // 添加好任务就发送条件信号,让消费者消费
            pthread_cond_signal(&_cpCond);
        }
    
        // 启动所有线程
        void RunAllThread()
        {
            for(int i = 0; i < _size; ++i)
            {
                _threadPool[i]->CreateThread();
                LogMessage(0, _F, _L, "%s启动成功", _threadPool[i]->getName().c_str());
            }
        }
    
        // 析构,附加等待线程
        ~ThreadPool()
        {
            for(int i = 0; i < _size; ++i)
            {
                _threadPool[i]->JoinThread();
                delete _threadPool[i];
            }
            pthread_mutex_destroy(&_mtx);
            pthread_cond_destroy(&_cpCond);
        }
    
        // 懒汉指针接口,必须定义为static的,不然没法创建对象就没法调用
        static ThreadPool<T>* GetThreadPoolPtr(int size = DEFAULT_SIZE)
        {
            if(_threadPoolPtr == nullptr)
            {
                LockGuard LG(&_MTX); // 这个封装在lockGuard.hpp中
                if(_threadPoolPtr == nullptr)
                {
                    _threadPoolPtr = new ThreadPool<T>(size);
                }
            }
            
            return _threadPoolPtr;
        }
    
    private:
        // 线程池
        std::vector<Thread*> _threadPool;
        // 线程池大小
        int _size;
        // 任务队列
        std::queue<T> _taskQueue;
        // 消消锁和生消锁
        pthread_mutex_t _mtx;
        // 生消条件变量
        pthread_cond_t _cpCond;
    
        // 懒汉模式,搞一个指针
        static ThreadPool<T>* _threadPoolPtr;
        // 专门为GetThreadPoolPtr接口提供一个锁
        static pthread_mutex_t _MTX;
    };
    
    // 初始情况下设置为nullptr,等用的时候再开空间,此即懒汉
    template<class T>
    ThreadPool<T>* ThreadPool<T>::_threadPoolPtr = nullptr;
    
    // 全局或静态的锁可以直接用PTHREAD_MUTEX_INITIALIZER进行初始化
    template<class T>
    pthread_mutex_t ThreadPool<T>::_MTX = PTHREAD_MUTEX_INITIALIZER;
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165

    运行:
    在这里插入图片描述
    这里默认线程池中线程个数是5个。

    其实这里的服务端一般不会设置成死循环的发送数据,一般情况下都是客户端发送一个请求,服务端直接返回客户端所需的数据就行了,不会再循环上去继续接收客户端请求,只有一次。

    死循环的场景很少,如果写成死循环,这里的线程池也会出现所有线程都去执行任务,此时没有空余线程去执行新来连接发送的任务。那么也就会出现第一种单线程的问题。不过正常情况下不会死循环,这里线程池也就没什么问题了。

    再来改一下service函数,其功能给字符串中的小写字母转小写:
    在这里插入图片描述

    其他简单的功能你来改改改。

    这里把上面所有服务端的代码给出来:

    #include "ThreadPool/LogMessage.hpp"
    #include "ThreadPool/ThreadPool.hpp"
    
    #include 
    #include 
    #include 
    
    #include 
    #include 
    #include 
    #include 
    
    #include 
    #include 
    #include 
    #include 
    
    
     /*多线程用*/
    // class ThreadData
    // {
    // public:
    //     ThreadData(int sockFd, const std::string IP, uint16_t port)
    //         : _sockFd(sockFd)
    //         , _IP(IP)
    //         , _port(port)
    //     {}
    
    // public:
    //     int _sockFd;
    //     std::string _IP;
    //     uint16_t _port;
    // };
    
    class Server
    {
    private:
                /*后面再详谈listen第二个参数*/
        const int gBackLog = 20;
    
        // // 客户端和服务端通信的接口
        // static void service(int serverSock, const std::string& IP, uint16_t port, const std::string& name)
        // {
        //     while(1)
        //     {
        //         // 直接用write和read通信
        //         char buff[1024];
        //         // read读取数据
        //         ssize_t readRes = read(serverSock, buff, sizeof(buff) - 1);
        //         if(readRes > 0)
        //         { // 返回值大于0,正常读取
        //             buff[readRes] = 0;
        //         }
        //         else if(readRes == 0)
        //         { // 返回值等于零,对端停止,这里服务端也停止
        //             LogMessage(NORMAL, _F, _L, "[%s:%d] =___cient disconnected___=", IP.c_str(), port);
        //             break;
        //         }
        //         else
        //         { // 返回值小于零,出错
        //             LogMessage(ERROR, _F, _L, "server read fail");
        //             break;
        //         }
        //         // 先打印一下谁发过来的数据
        //         LogMessage(NORMAL, _F, _L, "%s is servering\n\t   [%s:%d] send message ::%s", name.c_str(), IP.c_str(), port, buff);
    
        //         // echo服务器,接收到了之后,直接发回去
        //         ssize_t writeRes = write(serverSock, buff, strlen(buff));
        //         if(writeRes < 0)
        //         {
        //             LogMessage(ERROR, _F, _L, "server send fail");
        //         }
        //     }
    
        //     close(serverSock);
        // }
    
        // 客户端和服务端通信的接口
        static void service(int serverSock, const std::string& IP, uint16_t port, const std::string& name)
        {
            while(1)
            {
                // 直接用write和read通信
                char buff[1024];
                // read读取数据
                ssize_t readRes = read(serverSock, buff, sizeof(buff) - 1);
                if(readRes > 0)
                { // 返回值大于0,正常读取
                    buff[readRes] = 0;
                }
                else if(readRes == 0)
                { // 返回值等于零,对端停止,这里服务端也停止
                    LogMessage(NORMAL, _F, _L, "[%s:%d] =___cient disconnected___=", IP.c_str(), port);
                    break;
                }
                else
                { // 返回值小于零,出错
                    LogMessage(ERROR, _F, _L, "server read fail");
                    break;
                }
    
                std::string tmp = buff;
                for(int i = 0; i < readRes; ++i)
                {
                    if(buff[i] >= 'A' && buff[i] <= 'Z') buff[i] += 32;
                }
                // 先打印一下谁发过来的数据
                LogMessage(NORMAL, _F, _L, "%s is servering\n\t   [%s:%d] send message ::%s\t   [%s:%d] get message :: %s\n",\
                                                 name.c_str(), IP.c_str(), port, tmp.c_str(), IP.c_str(), port, buff);
    
                // echo服务器,接收到了之后,直接发回去
                ssize_t writeRes = write(serverSock, buff, strlen(buff));
                if(writeRes < 0)
                {
                    LogMessage(ERROR, _F, _L, "server send fail");
                }
            }
    
            close(serverSock);
        }
    
            /*多线程用*/
        // static void* ThreadRoutine(void* args)
        // {
        //     pthread_detach(pthread_self());
    
        //     ThreadData* ptd = reinterpret_cast(args);
            
        //     int sockfd = ptd->_sockFd;
        //     std::string clientIP = ptd->_IP;
        //     uint16_t clientPort = ptd->_port;
    
        //     // 这里直接调用进行服务的函数就行
        //     service(sockfd, clientIP, clientPort);
    
        //     // delete掉ThreadData对象的空间,不然内存泄漏
        //     delete ptd;
    
        //     // 线程在执行完任务后,得关闭掉文件描述符,不然会文件描述符泄漏
        //     close(sockfd);
        // }
    public:
        // 构造
        Server(uint16_t port, const std::string& IP = "", int sock = -1)
            : _port(port)
            , _IP(IP)
            , _listenSock(sock)
            , _pThreadPool(ThreadPool<Task>::GetThreadPoolPtr())
        {}
    
        // 初始化服务器
        void InitServer()
        {
            // 1. 创建套接字
                 /*先AF_INET确定网络通信*/  /*这里用的是TCP,所以用SOCK_STREAM*/
            _listenSock = socket(AF_INET, SOCK_STREAM, 0);
            if(_listenSock == -1)
            {
                LogMessage(FATAL, _F, _L, "server create socket fail");
                exit(2);
            }
            LogMessage(DEBUG, _F, _L, "server create socket success, listen sock::%d", _listenSock);
    
    
            // 2. bind 绑定IP和port
            
            sockaddr_in local; // 各个字段填充
            memset(&local, 0, sizeof(local));
                                            // 若为空字符串就绑定当前主机所有IP
            local.sin_addr.s_addr = _IP == "" ? INADDR_ANY : inet_addr(_IP.c_str());
            local.sin_port = htons(_port);
            local.sin_family = AF_INET;
                                                /*填充好了绑定*/
            if(bind(_listenSock, reinterpret_cast<sockaddr*>(&local), sizeof(local)) < 0)
            {
                LogMessage(FATAL, _F, _L, "server bind IP+port fail");
                exit(3);
            }
            LogMessage(DEBUG, _F, _L, "server bind IP+port success");
    
            // 3. listen为套接字设置监听状态
            if(listen(_listenSock, gBackLog/*后面再详谈listen第二个参数*/) < 0)
            {
                LogMessage(FATAL, _F, _L, "srever listen fail");
                exit(4);
            }
            LogMessage(NORMAL, _F, _L, "server init success");
        }
    
                 /***********************线程池版*****************************/
        // 启动服务器
        void StartServer()
        {
            _pThreadPool->RunAllThread(); // 启动线程池
            // 服务器就是个死循环,得一直跑
            while (1)
            {
                // 4.accept接收连接
                    /*客户端相关字段*/
                sockaddr_in clientMessage;
                socklen_t clientLen = sizeof(clientMessage);
                memset(&clientMessage, 0, clientLen);
                // 接收连接
                int serverSock = accept(_listenSock, reinterpret_cast<sockaddr*>(&clientMessage), &clientLen);
    
                // 对端的IP和port信息
                std::string clientIP(inet_ntoa(clientMessage.sin_addr));
                uint16_t clientPort = ntohs(clientMessage.sin_port);
    
                if(serverSock < 0)
                {
                    // 这里没连接上不能说直接退出,就像张三没有揽到某个客人餐馆就不干了,所以日志等级为ERROR
                    LogMessage(ERROR, _F, _L, "server accept connection fail");
                    continue; // 连接失败就接着找下一个客人进行连接
                }
                else
                {
                    LogMessage(NORMAL, _F, _L, "server accept connection success ::[%s:%d] server sock::%d", \
                                                                        clientIP.c_str(), clientPort,serverSock);
                }
    
                // 往线程池中push任务
                _pThreadPool->PushTask(Task(service, serverSock, clientIP, clientPort));
            }
        }
    
        //          /***********************多线程版*****************************/
        // // 启动服务器
        // void StartServer()
        // {
        //     // 服务器就是个死循环,得一直跑
        //     while (1)
        //     {
        //         // 4.accept接收连接
        //             /*客户端相关字段*/
        //         sockaddr_in clientMessage;
        //         socklen_t clientLen = sizeof(clientMessage);
        //         memset(&clientMessage, 0, clientLen);
        //         // 接收连接
        //         int serverSock = accept(_listenSock, reinterpret_cast(&clientMessage), &clientLen);
    
        //         // 对端的IP和port信息
        //         std::string clientIP(inet_ntoa(clientMessage.sin_addr));
        //         uint16_t clientPort = ntohs(clientMessage.sin_port);
    
        //         if(serverSock < 0)
        //         {
        //             // 这里没连接上不能说直接退出,就像张三没有揽到某个客人餐馆就不干了,所以日志等级为ERROR
        //             LogMessage(ERROR, _F, _L, "server accept connection fail");
        //             continue; // 连接失败就接着找下一个客人进行连接
        //         }
        //         else
        //         {
        //             LogMessage(NORMAL, _F, _L, "server accept connection success ::[%s:%d] server sock::%d", \
        //                                                                 clientIP.c_str(), clientPort,serverSock);
        //         }
    
        //         // 接到数据后创建线程执行任务            
        //         pthread_t tid;
        //         ThreadData* ptd = new ThreadData(serverSock, clientIP, clientPort);
        //         pthread_create(&tid, nullptr, ThreadRoutine, reinterpret_cast(ptd)); 
    
        //         // 主线程走到这里时不需要进行通信,但是也不需要关掉serverfd,因为这个文件描述符和线
        //         // 程共享,关闭了就会导致创建出的线程无法和对方通信
        //     }
        // }
    
        //         /***********************多进程②版*****************************/
        // // 启动服务器
        // void StartServer()
        // {
        //     // 服务器就是个死循环,得一直跑
        //     while (1)
        //     {
        //         // 4.accept接收连接
        //             /*客户端相关字段*/
        //         sockaddr_in clientMessage;
        //         socklen_t clientLen = sizeof(clientMessage);
        //         memset(&clientMessage, 0, clientLen);
        //         // 接收连接
        //         int serverSock = accept(_listenSock, reinterpret_cast(&clientMessage), &clientLen);
        //         if(serverSock < 0)
        //         {
        //             // 这里没连接上不能说直接退出,就像张三没有揽到某个客人餐馆就不干了,所以日志等级为ERROR
        //             LogMessage(ERROR, _F, _L, "server accept connection fail");
        //             continue; // 连接失败就接着找下一个客人进行连接
        //         }
        //         else
        //         {
        //             LogMessage(NORMAL, _F, _L, "server accept connection success, server sock::%d", serverSock);
        //         }
    
        //         if(fork() == 0)
        //         { // 子进程
        //             // 子进程会继承父进程中的两个文件描述符,其中listenSock没有用,是让父进程用的,所以这里要关掉
        //             close(_listenSock);
    
        //             if(fork() > 0)
        //             { // 子进程
        //                 exit(0); // 子进程直接退出
        //             }
    
        //             // 孙进程进行通信,但子进程直接退出,孙进程就变成了孤儿进程,此时会直接被os接管
        //             std::string clientIP(inet_ntoa(clientMessage.sin_addr));
        //             uint16_t clientPort = ntohs(clientMessage.sin_port);
        //             service(serverSock, clientIP, clientPort);
    
        //             exit(0);
        //         }
    
        //         // 父进程此时直接等待子进程退出,非常顺畅,因为子进程刚生下来就没了,父进程直接就等到了,可以说这一步没有时间消耗
        //         waitpid(-1, nullptr, 0); 
    
        //         // 父进程走到这里时不需要进行通信,所以直接关掉serverSock
        //         close(serverSock);
        //     }
        // }
    
        //         /***********************多进程①版*****************************/
        // // 启动服务器
        // void StartServer()
        // {
        //     // 子进程退出后,直接忽略子进程发来的信号,子进程就会自动回收其资源,效率很高
        //     signal(SIGCHLD, SIG_IGN);
        //     // 服务器就是个死循环,得一直跑
        //     while (1)
        //     {
        //         // 4.accept接收连接
        //             /*客户端相关字段*/
        //         sockaddr_in clientMessage;
        //         socklen_t clientLen = sizeof(clientMessage);
        //         memset(&clientMessage, 0, clientLen);
        //         // 接收连接
        //         int serverSock = accept(_listenSock, reinterpret_cast(&clientMessage), &clientLen);
        //         if(serverSock < 0)
        //         {
        //             // 这里没连接上不能说直接退出,就像张三没有揽到某个客人餐馆就不干了,所以日志等级为ERROR
        //             LogMessage(ERROR, _F, _L, "server accept connection fail");
        //             continue; // 连接失败就接着找下一个客人进行连接
        //         }
        //         else
        //         {
        //             LogMessage(NORMAL, _F, _L, "server accept connection success, server sock::%d", serverSock);
        //         }
    
        //         if(fork() == 0)
        //         { // 子进程
        //             // 子进程会继承父进程中的两个文件描述符,其中listenSock没有用,是让父进程用的,所以这里要关掉
        //             close(_listenSock);
    
        //             // 连接成功了就让子进程通信
        //             std::string clientIP(inet_ntoa(clientMessage.sin_addr));
        //             uint16_t clientPort = ntohs(clientMessage.sin_port);
        //             service(serverSock, clientIP, clientPort);
    
        //             // 子进程退出后会变成僵尸进程,不处理会造成内存泄露,不过让父进程wait子进程的话很麻烦
        //             exit(0);                            /*|*/
        //         }                                       /*|*/
        //                                                 /*|*/
        //         // 父进程waitpid去阻塞式等待时这里和第一版的单线程没什么区别,因为父进程会卡在这里
        //         // 但是以非阻塞方式等待时又得每次到这都要执行一下waitpid或wait函数,很麻烦
        //         // 所以开头直接忽略子进程退出时的信号,子进程退出时就会自动回收其空间
    
        //         // 父进程走到这里时不需要进行通信,所以直接关掉serverSock
        //         close(serverSock);
        //     }
        // }
    
                /************************单线程版*********************************/
        // // 启动服务器
        // void StartServer()
        // {
        //     // 服务器就是个死循环,得一直跑
        //     while (1)
        //     {
        //         // 4.accept接收连接
        //             /*客户端相关字段*/
        //         sockaddr_in clientMessage;
        //         socklen_t clientLen = sizeof(clientMessage);
        //         memset(&clientMessage, 0, clientLen);
        //         // 接收连接
        //         int serverSock = accept(_listenSock, reinterpret_cast(&clientMessage), &clientLen);
        //         if(serverSock < 0)
        //         {
        //             // 这里没连接上不能说直接退出,就像张三没有揽到某个客人餐馆就不干了,所以日志等级为ERROR
        //             LogMessage(ERROR, _F, _L, "server accept connection fail");
        //             continue; // 连接失败就接着找下一个客人进行连接
        //         }
        //         else
        //         {
        //             LogMessage(NORMAL, _F, _L, "server accept connection success, server sock::%d", serverSock);
        //         }
    
        //         // 连接成功了就进行通信
        //         std::string clientIP(inet_ntoa(clientMessage.sin_addr));
        //         uint16_t clientPort = ntohs(clientMessage.sin_port);
        //         service(serverSock, clientIP, clientPort);
    
        //         // 一定要记得通信完了之后要关闭文件描述符,
        //         close(serverSock);
        //         // 若不关,后续通信的时候这个文件描述符会一直被占用,但是却已经没用了,这种行为也被称为文件描述符泄漏
        //         // 就像在客人吃完饭之后要收拾这个客人留下来的摊子,不然别的客人没法坐了
        //     }
        // }
    
        // 析构
        ~Server()
        {}
    
    private:
        uint16_t _port; // 服务端端口号
        std::string _IP; // 服务端IP地址
        int _listenSock; // 初始套接字
    
        /*线程池*/
        std::unique_ptr<ThreadPool<Task>> _pThreadPool;
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165
    • 166
    • 167
    • 168
    • 169
    • 170
    • 171
    • 172
    • 173
    • 174
    • 175
    • 176
    • 177
    • 178
    • 179
    • 180
    • 181
    • 182
    • 183
    • 184
    • 185
    • 186
    • 187
    • 188
    • 189
    • 190
    • 191
    • 192
    • 193
    • 194
    • 195
    • 196
    • 197
    • 198
    • 199
    • 200
    • 201
    • 202
    • 203
    • 204
    • 205
    • 206
    • 207
    • 208
    • 209
    • 210
    • 211
    • 212
    • 213
    • 214
    • 215
    • 216
    • 217
    • 218
    • 219
    • 220
    • 221
    • 222
    • 223
    • 224
    • 225
    • 226
    • 227
    • 228
    • 229
    • 230
    • 231
    • 232
    • 233
    • 234
    • 235
    • 236
    • 237
    • 238
    • 239
    • 240
    • 241
    • 242
    • 243
    • 244
    • 245
    • 246
    • 247
    • 248
    • 249
    • 250
    • 251
    • 252
    • 253
    • 254
    • 255
    • 256
    • 257
    • 258
    • 259
    • 260
    • 261
    • 262
    • 263
    • 264
    • 265
    • 266
    • 267
    • 268
    • 269
    • 270
    • 271
    • 272
    • 273
    • 274
    • 275
    • 276
    • 277
    • 278
    • 279
    • 280
    • 281
    • 282
    • 283
    • 284
    • 285
    • 286
    • 287
    • 288
    • 289
    • 290
    • 291
    • 292
    • 293
    • 294
    • 295
    • 296
    • 297
    • 298
    • 299
    • 300
    • 301
    • 302
    • 303
    • 304
    • 305
    • 306
    • 307
    • 308
    • 309
    • 310
    • 311
    • 312
    • 313
    • 314
    • 315
    • 316
    • 317
    • 318
    • 319
    • 320
    • 321
    • 322
    • 323
    • 324
    • 325
    • 326
    • 327
    • 328
    • 329
    • 330
    • 331
    • 332
    • 333
    • 334
    • 335
    • 336
    • 337
    • 338
    • 339
    • 340
    • 341
    • 342
    • 343
    • 344
    • 345
    • 346
    • 347
    • 348
    • 349
    • 350
    • 351
    • 352
    • 353
    • 354
    • 355
    • 356
    • 357
    • 358
    • 359
    • 360
    • 361
    • 362
    • 363
    • 364
    • 365
    • 366
    • 367
    • 368
    • 369
    • 370
    • 371
    • 372
    • 373
    • 374
    • 375
    • 376
    • 377
    • 378
    • 379
    • 380
    • 381
    • 382
    • 383
    • 384
    • 385
    • 386
    • 387
    • 388
    • 389
    • 390
    • 391
    • 392
    • 393
    • 394
    • 395
    • 396
    • 397
    • 398
    • 399
    • 400
    • 401
    • 402
    • 403
    • 404
    • 405
    • 406
    • 407
    • 408
    • 409
    • 410
    • 411
    • 412
    • 413
    • 414
    • 415
    • 416
    • 417

    客户端

    客户端很简单,也是分几步完成

    1. 创建套接字
    2. 不用bind,这个和UDP一样,我就不讲为啥了。需要调用connect和服务端进行连接:
      在这里插入图片描述
    • 这里sockfd就是创建套接字后返回的fd。第二个参数就是服务端相关的字段,第三个参数就是整个结构体的大小。
    • 成功了返回0,失败了返回-1并设置错误码。
    1. 发送数据的时候就直接用send,收数据的时候用recv,这两个在前面都介绍了

    代码非常简单:

    #include "ThreadPool/LogMessage.hpp"
    
    #include 
    
    #include 
    #include 
    #include 
    #include 
    
    void usage(const char* args)
    {
        std::cout << "usage:\n" << args << " IP port" << std::endl;
        exit(1);
    }
    
    int main(int argc, char* argv[])
    {
        if(argc != 3)
        {
            usage(argv[0]);
        }
    
        // 1. 创建套接字
        int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    
        // 2. connect和服务器建立连接
        sockaddr_in server; // server 相关字段
        memset(&server, 0, sizeof(server));
        server.sin_family = AF_INET;
        server.sin_addr.s_addr = inet_addr(argv[1]);
        server.sin_port = htons(atoi(argv[2]));
            // connect建立连接
        if(connect(sockfd, reinterpret_cast<sockaddr*>(&server), sizeof(server)) < 0)
        {
            LogMessage(FATAL, _F, _L, "client connect fail");
            exit(2);
        }
    
        char buff[1024];
        while(1)
        {
            // 发送数据
            memset(buff, 0, sizeof(buff));
            std::cout << "请输入你要发送的信息::";
            std::cin >> buff;
            if(strcmp(buff, "quit") == 0)
            {
                std::cout << "clinet quit" << std::endl;
                break;
            }
            send(sockfd, buff, strlen(buff), 0);// 阻塞式发送
    
            // 接收数据
            recv(sockfd, (void*)buff, sizeof(buff), 0);
            std::cout << "server echo #" << buff << std::endl;
        }
    
        close(sockfd);
        return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60

    测试:
    在这里插入图片描述

    收尾

    那么到这里就差不多完了,说点收尾的知识,也是为我后面的博客稍微铺垫一点。

    TCP是面向连接的通信协议,其在接口上都做了哪些工作呢?

    1. 通信前,要进行3次握手,来进行建立连接。
        客户端发起connect,会发送一个数据给服务端来请求连接;服务端接收到数据后会给客户端回一个数据,来表明服务端接收到了请求,让客户端确定一下;客户端收到了后又会给服务端发送一个数据表明客户端知道了。这个过程中一共发了三次数据,就叫做三次挥手:
      在这里插入图片描述
      详细的细节会在后面的博客中讲。

    2. 当tcp在断开连接时,需要释放连接。
        客户端发送一个数据,让服务端知道要去连接了,服务端接到数据后,还会给客户端发送一个数据表示服务端知道要断开连接了。服务端在断开连接时也是给客户端发送一个数据,然后客户端接收到了也会给服务端发送一个数据表示客户端知道了:
      在这里插入图片描述
      也是详细的后面博客中讲。

    建立连接是单方主动,断开连接是双方都要询问对方。

    面向字节流是TCP的,面向数据报是UDP的,不过凭借本篇和上一篇UDP的代码还感觉不到啥。
    面向字节流就是发十次可以一次全部接收,面向数据报是发十次得分十次接收。得数据多的时候才能感受到。

    后面还会再写简易的服务器,不过比这里高级一点,像高性能多路转接版的后面博客就会讲。

    到此结束。。。

  • 相关阅读:
    开放式耳机怎么选择、300之内最好的耳机推荐
    机房动环监控系统有哪些告警功能,机房动环监控系统是什么?
    单例模式设计
    Visual Studio 线性表的链式存储节点输出引发异常:读取访问权限冲突
    MyBatis - 一旦执行到打印日志 Parameter 就卡死的原因探索
    【Python】保姆级万字讲解:Python中的 pip 和 conda 的理解
    51单片机DS1302时钟
    RHCE-ansible第二次实验,通过ansible远程yum安装
    2.15 这样的小红书图片内容,最容易“踩雷”!【玩赚小红书】
    淘宝客APP源码/社交电商自营商城源码/前端基于Uniapp开发
  • 原文地址:https://blog.csdn.net/m0_62782700/article/details/133715939