【Linux】C++ 实现可扩展 TCP 服务器|回响、字典、远程命令

一、引言

在 Linux 网络编程学习中,很多人写服务端项目时都是从零编写套接字代码,做回响、字典、计算器功能要重复造轮子。其实我们可以封装一套通用 Tcpserver 框架,利用回调函数把底层网络逻辑和上层业务拆分开。**同一套服务器代码,只要替换业务处理函数,就能快速实现回响服务、在线字典查询、远程计算器三种不同功能。**本文就带大家感受通用 TCP 服务封装的思路,理解代码复用和解耦设计的好处。

二、Common.hpp

它作为项目公共头文件,整合各类系统头文件;定义程序退出错误码,替换硬编码数字,方便定位程序出错位置。

其中Nocopy 工具类用来禁用子类的拷贝与赋值,防止套接字资源因为对象拷贝发生错乱。

CONV 宏用来做结构体强制转换。socket 接口要求传入通用地址结构体指针,我们每次不用手写长长的强制转换,用宏直接简化代码。

例如:CONV(peer) 等价于 (struct sockaddr*)&peer,少写重复模板代码。

cpp 复制代码
#pragma once 
#include <iostream>
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <memory>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>

enum Exitcode
{
    OK=0,
    USAGE_ERR,
    SOCKET_ERR,
    BIND_ERR,
    LISTEN_ERR,
    ACCEPT_ERR,
    CONNECT_ERR,
    FORK_ERR

};

//让继承它的子类,不能被复制、不能被赋值!
class Nocopy
{
public:
    Nocopy() {}
    ~Nocopy() {}
    Nocopy(const Nocopy &) = delete;
    const Nocopy &operator=(const Nocopy &) = delete;
};

#define CONV(addr) ((struct sockaddr*)&addr)

三、Tcpserver.hpp

首先我们先封装可复用的通用Tcpserver框架,整合了套接字创建、绑定、监听、连接接收等基础网络逻辑。框架通过回调函数解耦网络层与业务层更换回调逻辑即可快速实现回响、在线字典、远程计算器等功能 。代码还预留了单进程、多进程、多线程、线程池四种并发实现方案,方便对比不同服务器并发模型的差异。

2.1核心流程

1.Init( ):完成 TCP 服务初始化,执行 socket 创建监听套接字、bind绑定端口、listen开启监听。

2.Run():服务主循环,不停调用 accept 等待客户端接入。

3.每连上一个客户端,就创建新线程,执行 Service() 函数负责和客户端收发数据。(多进程版本)

4.**Service()**循环读取客户端发来的数据,交给回调函数 _func 处理业务逻辑,再把处理结果发回客户端。

2.2版本

version0:单进程,一次只能服务一个客户端,几乎不用

version1:多进程方案(fork 创建子进程)

version2:来一个客户端,临时开一条线程,通信结束这条线程就没了,频繁创建销毁比较耗性能

version3:线程池方案(可开启复用线程,减少频繁创建销毁线程开销)

2.3注意

1.func_t _func 回调:业务逻辑从外面传进来!框架本身不用关心具体是做回响、查字典,还是运行计算器。

2.封装 ThreadData 结构体:在线程函数之间传

递套接字、客户端地址和服务器对象这几组数据。

3.继承 Nocopy:不让服务器对象被拷贝,防止出现资源错乱的问题。

注:Nocopy 是一个工具基类。TcpServer 管理着监听套接字这种独占资源,一旦对象被拷贝,很容易出现重复释放资源的 bug 。让 TcpServer 继承 Nocopy,就能直接禁用拷贝构造和赋值,代码编译时就拦截非法拷贝操作,避免后续资源管理乱套。

cpp 复制代码
#pragma once
class Nocopy
{
protected:
    Nocopy() = default;
    ~Nocopy() = default;
    // 删除拷贝构造
    Nocopy(const Nocopy&) = delete;
    // 删除赋值重载
    Nocopy& operator=(const Nocopy&) = delete;
};

2.4代码

cpp 复制代码
#pragma once
#include "Common.hpp"
#include "Log.hpp"
#include "InetAddr.hpp"
#include <sys/wait.h>
#include <signal.h>
#include <pthread.h>
#include "ThreadPool.hpp"
#include <functional>

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

using namespace ThreadPoolModule;
using namespace LogModule;
#define backlog 10
const int defaultsockfd=-1;
class TcpServer:public Nocopy
{

    public:
    TcpServer(uint16_t port,func_t func)
    :_port(port)
    ,_listensockfd(defaultsockfd)
    ,_isrunning(false)
    ,_func(func)
    {}

    void Init()
    {
        // signal(SIGCHLD,SIG_IGN);

        //1.创建套接字
        _listensockfd=socket(AF_INET,SOCK_STREAM,0);
        if(_listensockfd<0)
        {
            LOG(Loglevel::FATAL)<<"socket error";
            exit(SOCKET_ERR);
        }
        LOG(Loglevel::INFO)<<"socket success"<<_listensockfd;

        //2.绑定
        InetAddr localaddr(_port);
        int n=bind(_listensockfd,localaddr.NetAddptr(),localaddr.Netaddlen());
        if(n<0)
        {
            LOG(Loglevel::FATAL)<<"bind error";
            exit(BIND_ERR);
        }
        LOG(Loglevel::INFO)<<"bind success"<<_listensockfd;

        //3.监听
        n=listen(_listensockfd,backlog);
        if(n<0)
        {
            LOG(Loglevel::FATAL)<<"listen error";
            exit(LISTEN_ERR);
        }
        LOG(Loglevel::INFO)<<"listen success"<<_listensockfd;


    }


    class ThreadData
    {

        public:
        ThreadData(int fd,InetAddr& ar,TcpServer* s)
        :sockfd(fd)
        ,addr(ar)
        ,tsvr(s)
        {}

        public:
        int sockfd;
        InetAddr addr;
        TcpServer *tsvr;
    };

    void Service(int sockfd,InetAddr &peer)
    {
        char buffer[1024];
        while(true)
        {
            ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
            if(n>0)
            {
                buffer[n]=0;//设置为字符串
                LOG(Loglevel::DEBUG)<<peer.StringAddr()<<" #"<<buffer;

                //执行字典业务、远程控制业务
                std::string echo_string=_func(buffer,peer);

                //写回数据
                // std::string echo_string="echo_say#";
                // echo_string+=buffer;

                write(sockfd,echo_string.c_str(),echo_string.size());
            }
            else if(n==0)
            {
                LOG(Loglevel::DEBUG)<<peer.StringAddr()<<"退出了...";
                close(sockfd);
                break;
            }
            else
            {
                LOG(Loglevel::DEBUG) << peer.StringAddr() << "异常了...";
                close(sockfd);
                break;
            }
        }

    }

    static void* Routine(void* args)
    {
        pthread_detach(pthread_self());
        ThreadData* td=static_cast<ThreadData*>(args);
        td->tsvr->Service(td->sockfd,td->addr);
        delete td;
        return nullptr;
    }

    void Run()
    {
        _isrunning=true;
        while(_isrunning)
        {
            //获取链接
            struct sockaddr_in peer;//一个空盒子,准备装客户端地址
            socklen_t len=sizeof(peer);
            int sockfd=accept(_listensockfd,CONV(peer),&len);//干活的 传数据的套接字可以有多个
            if(sockfd<0)
            {
                LOG(Loglevel::FATAL) << "accept error";
                exit(ACCEPT_ERR);
            }
            InetAddr addr(peer);// 1. 用 peer 构造出一个 InetAddr 对象,名字叫 addr
            LOG(Loglevel::INFO) << "accept success,peer addr:" << addr.StringAddr();


            //version 0 测试  --单进程版本
            // Service(sockfd,addr);



            //version 1 多进程版本
            // pid_t id=fork();
            // if(id<0)
            // {
            //     LOG(Loglevel::FATAL)<<"fork error";
            //     exit(FORK_ERR);
            // }
            // else if(id==0)
            // {
            //     //子进程
            //     //子进程是来服务客户端的,它不需要监听新连接
            //     close(_listensockfd);
            //     //创建孙子进程来执行
            //     if(fork()>0)
            //         exit(OK);

            //     //孙子进程来执行
            //     Service(sockfd, addr);
            // }
            // else
            // {
            //     //父进程
            //     //循环 accept 接新客户端  ,根本不跟客户端通信,所以不需要sockfd
            //     close(sockfd);

            //     pid_t rid=waitpid(id,nullptr,0);//如果这样会阻塞  可以创建孙子进程来执行 也可以执行signal
            //     (void)rid;

            // }

            


            //version2 多线程版本
            ThreadData* td=new ThreadData(sockfd,addr,this);//td 是这个盒子的地址。
            

            pthread_t tid;
            pthread_create(&tid,NULL,Routine,td);


            //version3 线程池版本
            // ThreadPool<task_t>::Getinstance()->Equeue([this,sockfd,&addr](){
            //     this->Service(sockfd,addr);

            // });



        }
        _isrunning=false;

    }

    ~TcpServer()
    {}
    private:
    uint16_t _port;
    int _listensockfd;//监听  只负责获取链接的套接字
    bool _isrunning;

    func_t _func;//设置回调函数
};

四、翻译模块

Dict类是字典翻译业务模块,专门给 TCP 服务器提供查单词功能 ,属于上层业务代码,和网络代码完全解耦。程序启动的时候,把本地 dictionary.txt 单词文件加载到内存哈希表;客户端发来英文单词,服务器调用这个类就能查到对应的中文释义。

4.1Loaddict()---------加载字典文件

程序启动之后,这个函数就会运行。它打开本地的 dictionary.txt 字典文件,然后逐行读取里面的内容。

文件里每一行都要遵守 sad: 悲伤的 这种格式。代码会找到分隔符 : ,把一行文字切开,前面一部分是英文单词,后面就是对应的中文意思,最后把这一组数据存到哈希表里面。

如果读到格式错乱、没法分割的脏数据,不会直接让服务器崩掉,只是跳过这一行,并且输出一条警告日志,方便我们后期排查文件哪里写错了。

4.2Translate()---------翻译查询函数

接收客户端传来的单词,去哈希表里查找。

找到单词:返回中文释义,同时打印日志,记录哪个 IP 端口查了什么单词

找不到单词:返回字符串 "None"

cpp 复制代码
#include <iostream>
#include <fstream>
#include <string>
#include <unordered_map>
#include "log.hpp"
#include "InetAddr.hpp"
using namespace LogModule;

const std::string defaultdict="./dictionary.txt";
const std::string sep1=": ";//log里也有这个 所以可能存在冲突 要改一下名字

class Dict
{
public:
    Dict(const std::string &dict_path = defaultdict)
        : _dict_path(dict_path)
    {
    }

    bool Loaddict()
    {
        //打开文件
        std::ifstream in(_dict_path);
        if(!in.is_open())
        {
            LOG(Loglevel::DEBUG)<<"打开字典"<<_dict_path<<"失败";
            return false;
        }
        std::string line;
        while(std::getline(in,line)) // 只要能读到行,就继续循环
        {
            // sad: 悲伤的
            auto pos=line.find(sep1);
            if(pos==std::string::npos)
            {
                LOG(Loglevel::WARNING)<<"解析"<<line<<"失败";
                continue;

            }
            std::string english=line.substr(0,pos);
            std::string chinese=line.substr(pos+sep1.size());

            if(english.empty()||chinese.empty())
            {
                LOG(Loglevel::WARNING)<<"没有有效内容"<<line;
                continue;
            }

            _dict.insert(std::make_pair(english,chinese));
            LOG(Loglevel::DEBUG)<<"加载"<<line;
        }
        in.close();
        return true;
    }

    std::string Translate(const std::string&word,InetAddr& client)
    {
        auto iter=_dict.find(word);
        if(iter==_dict.end())
        {
            return "None";
        }

        LOG(Loglevel::INFO)<<"进入翻译模块 ["<<client.Ip()<<":"<<client.Port()<<"]"<<word<<"->"<<iter->second;

        return iter->second;

        
    }

    ~Dict()
    {
    }

private:
    std::string _dict_path;
    std::unordered_map<std::string, std::string> _dict;
};

五、远程执行模块

Command类的作用就是让 TCP 服务器拥有远程执行 Linux 指令的能力。

初始化的时候我们维护了一份命令白名单,只有名单里写好的命令才能够被运行。当客户端发送一条指令过来,Excute函数首先调用Issafecommand检查这条命令是否合法。要是客户端发来的命令不在白名单里,服务器直接就不给它跑,拒绝请求。

校验通过以后,就调用 popen 执行这条 Linux 命令。**(让 Linux 系统帮你执行一条命令,然后把命令的输出变成一个 "文件" 给你读)**然后一行一行把命令打印出来的内容全部读出来,拼成一整段结果。最后把客户端的地址和命令运行后的内容一块打包,发回给客户端。

注:popen 是 Linux 下一个库函数,作用:在程序内部执行一条 shell 命令,并且能拿到命令打印出来的结果。

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <set>
#include "Common.hpp"
#include "InetAddr.hpp"

class Command
{
    public:
    Command()
    {
        _whitelistcommands.insert("ls");
        _whitelistcommands.insert("pwd");
        _whitelistcommands.insert("ls -l");
        _whitelistcommands.insert("ll");
        _whitelistcommands.insert("touch");
        _whitelistcommands.insert("who");
        _whitelistcommands.insert("whoami");
    }

    bool Issafecommand(const std::string& cmd)
    {
        auto iter=_whitelistcommands.find(cmd);
        return iter!=_whitelistcommands.end();

    }

    std::string Excute(const std::string& cmd,InetAddr& addr)
    {
        //1.判断命令是否在白名单
        if(!Issafecommand(cmd))
        {
            return std::string("坏人");
        }

        std::string who=addr.StringAddr();
        //2.执行命令
        FILE* fp=popen(cmd.c_str(),"r");//让 Linux 系统帮你执行一条命令,然后把命令的输出变成一个 "文件" 给你读
        if(fp==nullptr)
        {
            return std::string("你输入的命令不存在:")+cmd;
        }

        //把命令执行完的所有结果,全部读出来,拼成一个大字符串
        std::string result;
        char line[1024];
        while(fgets(line,sizeof(line),fp))//循环读
        {
            result+=line;
        }

        return who+"excute done,result is:\n"+result;
    }

    ~Command()
    {}

    private:
    std::set<std::string> _whitelistcommands;
};

六、Tcpclient.cc

这就是我们的 TCP 客户端程序 。运行时填上服务器 IP 和端口,就能主动去连接 TCP 服务端。客户端不需要绑定端口、开启监听,操作系统会自动分配临时端口。连接成功之后进入循环,我们在控制台输入内容发送给服务器,等待服务器处理完毕后接收返回结果并打印出来。回响、字典翻译、远程命令这三套业务,都可以用这同一个客户端去测试。

cpp 复制代码
#include <iostream>
#include "Common.hpp"
#include "InetAddr.hpp"

void Usage(std::string proc)
{
    std::cerr<<"Usage: "<<proc<<"port"<<std::endl;
}

//./tcpclient server_ip server_port
int main(int argc,char* argv[])
{
    if(argc!=3)
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }
    std::string serverip=argv[1];
    uint16_t serverport=std::stoi(argv[2]);

    //1.创建套接字
    int sockfd=socket(AF_INET,SOCK_STREAM,0);
    if(sockfd<0)
    {
        std::cerr<<"socket erro"<<std::endl;
        exit(SOCKET_ERR);
    }

    //2.不需要显示的绑定,随机选择端口号,也不需要listen、accept
    //3.进行连接
    InetAddr serveraddr(serverip,serverport);
    int n=connect(sockfd,serveraddr.NetAddptr(),serveraddr.Netaddlen());
    if(n<0)
    {
        std::cerr<<"connect erro"<<std::endl;
        exit(CONNECT_ERR);
    }
    

    while(true)
    {
        std::string line;
        std::cout<<"Please Enter@";
        std::getline(std::cin,line);

        write(sockfd,line.c_str(),line.size());
        char buffer[1024];
        ssize_t size=read(sockfd,buffer,sizeof(buffer)-1);
        if(size>0)
        {
            buffer[size]=0;
            std::cout<<"server echo#"<<buffer<<std::endl;
        }
    }

    close(sockfd);

    return 0;
}

七、Tcpserver.cc

这是 TCP 服务器程序的入口文件。得益于我们之前设计的通用服务器框架,网络收发逻辑已经被封装好了,我们只需要在这里选择对应的业务回调传给服务器,就能切换不同功能

defaulthandler 就是我们的回响测试回调 。客户端发来的数据存到 word 里面,函数把一段固定文字和客户端发送的内容拼在一起,最后返回给服务器,再由服务器发回客户端。客户端输入什么内容,最后就能在收到的结果里看到自己刚才发送的数据,以此验证服务器‑客户端通信是否正常。

用 lambda 把 (远程命令业务/字典业务/回响业务)打包成回调函数,传给 TcpServer。

注意:lambda 的形参名字不要和捕获进来的外部变量名字一模一样。一旦重名,lambda 内部的参数就会 "盖住" 外部捕获的变量,编译器就分不清我们到底想用哪一个,容易引发编译错误。

cpp 复制代码
#include "TcpSever.hpp"
#include "Common.hpp"
#include "Dict.hpp"
#include "Command.hpp"

std::string defaulthandler(const std::string&word,InetAddr& peer)
{
    LOG(Loglevel::INFO)<<"回到了默认的回调函数";
    std::string s="zihanhh";
    s+=word;
    return s;
}
void Usage(std::string proc)
{
    std::cerr<<"Usage: "<<proc<<"port"<<std::endl;
}

//./tcpclient port
int main(int argc,char* argv[])
{

    if(argc!=2)
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }
    uint16_t port=std::stoi(argv[1]);
    
    Enable_Console_LogStrategy();

    //创建字典模块
    // Dict d;
    // d.Loaddict();

    //执行远程执行模块
    Command cmd;     //捕获变量 和 形参不能重名
    std::unique_ptr<TcpServer> tsvr=std::make_unique<TcpServer>(port,[&cmd](const std::string& command
        ,InetAddr& addr){
        return cmd.Excute(command,addr);    
    });
    

    // std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(port, [&d](const std::string &word, InetAddr &addr)
    //                                                               { return d.Translate(word, addr); });

    // std::unique_ptr<TcpServer> tsvr=std::make_unique<TcpServer>(port,defaulthandler);
    tsvr->Init();
    tsvr->Run();
    return 0;
}

八、案例演示

8.1回响案例

8.2字典案例

8.3远程执行案例

好啦,以上就是本文全部内容啦,这三种业务无缝切换,通用 TCP 服务器框架实战完成啦。

相关推荐
新兴AI民工17 分钟前
# 【Linux内核三十六】进程管理模块:CFS负载均衡(一):sched_domain与sched_group层级构建
linux·运维·负载均衡
pride.li21 分钟前
WSL教程-WSL安装与配置Ubuntu
linux
luj_176822 分钟前
毒素驱动的生物自毁机制探析
服务器·c语言·开发语言·经验分享·算法
weixin_3077791324 分钟前
Databricks里用PySpark统计指定表和字段中各字段的空值、空字符串或零值比例
运维·数据仓库·python·spark·云计算
躺不平的理查德29 分钟前
Nginx与Ajax 备忘录
linux·运维·服务器
多多鼠31 分钟前
Tool Calling的信任边界:从协议校验到执行沙箱的完整链路设计
运维·开发语言·网络·人工智能·python·langchain
谢亮_vipxieliang35 分钟前
ValidX vs Apache Commons Validator:功能与性能对比
java·服务器·spring boot·后端·spring cloud·apache·hibernate
惜离殇37 分钟前
从零开始的敲代码生活--Linux应用软件(文件操作基础2)
linux·文件io·目录io
Neighbor_OldY44 分钟前
【实战复盘】RDP暴力破解与内网横向移动溯源:Windows全套排查命令与事件ID硬核解析
运维·windows·web安全