一、引言
在 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 服务器框架实战完成啦。