我们直接从一个简单的测试入手
一、准备工作
我们先把代码放出来,然后对着代码进行分析
1、用户类
cpp
class InetAddr
{
public:
InetAddr(struct sockaddr_in&addr):_addr(addr)
{
//网络转主机
_port=ntohs(_addr.sin_port);//从网络中拿到网络序列
char ipbuffer[64];
inet_ntop(AF_INET,&_addr.sin_addr,ipbuffer,sizeof(_addr));
_ip=ipbuffer;
}
InetAddr(const std::string &ip,uint16_t port):_ip(ip),_port(port)
{
//主机转网络
memset(&_addr,0,sizeof(_addr));
_addr.sin_family=AF_INET;
inet_pton(AF_INET,_ip.c_str(),&_addr.sin_addr);
_addr.sin_port=htons(_port);
}
InetAddr(uint16_t port):_port(port),_ip("0")
{
//主机转网络
memset(&_addr,0,sizeof(_addr));
_addr.sin_family=AF_INET;
_addr.sin_addr.s_addr=INADDR_ANY;
_addr.sin_port=htons(_port);
}
//返回_addr
struct sockaddr_in& Netaddr()
{
return _addr;
}
//返回_addr的地址
const struct sockaddr *NetAddrPtr()
{
return CONV(_addr);
}
//返回长度
socklen_t NetAddrLen()
{
return sizeof(_addr);
}
bool operator==(InetAddr&peer)
{
return _ip==peer._ip&&_port==peer._port;
}
std::string Ip()
{
return _ip;
}
uint16_t Port()
{
return _port;
}
std::string StringAddr()
{
return _ip+":"+std::to_string(_port);
}
~InetAddr(){}
private:
struct sockaddr_in _addr;
std::string _ip;
uint16_t _port;
};

我们先定一个标准,但凡在操作中遇到网络序列转本地序列,或者本地序列转网络序列的,我们尽量用固定的函数:
ntohs:

作用是将16位的网络字节序转化为主机字节序
inet_ntop:

作用是将网络二进制格式的IP地址转化为人类可读的字符串IP
需要注意的是dst是一个输出缓冲区,用来存储点分字符串,而size是_addr的长度
htons:

作用是将主机字节序的16位端口号转化为网络字节序
inet_pton:

作用是将字符串IP转化为二进制格式的IP地址
需要注意src是我们要转化的点分字符串ip,而dst是输出,存放二进制ip的,IPv4需要传入&struct in_addr。
对这个类进行分析:
作用:把原生 C 语言的 struct sockaddr_in 做面向对象封装,统一管理 IP、端口、网络地址结构体,屏蔽字节序转换、IP 字符串 / 二进制转换的重复代码。
第一个构造函数的使用场景:需要查看是谁进入了监听范围,此时需要人类能够读懂的ip和port。
第二个构造函数的使用场景:上传人类可读的二进制ip和16位的端口号,转化为可用于connect和bind的网络地址。
第三个构造函数的使用场景:服务器监听时,需要bind本机所有网卡,只需要传入端口号即可。
Netaddr的作用:返回封装好的原生 sockaddr_in 结构体(网络字节序)
NetAddrPtr的作用:将_addr的地址返回,需要注意的是,这里使用了一个宏,CONV(addr)等同于(struct sockaddr*)&_addr。
StringAddr的作用:返回人类能够看懂的字符串ip和port。
2、日志打印
cpp
#ifndef __LOG_HPP__
#define __LOG_HPP__
#include <iostream>
#include <cstdio>
#include <string>
#include <filesystem> //C++17
#include <sstream>
#include <fstream>
#include <memory>
#include <ctime>
#include <unistd.h>
#include "Mutex.hpp"
namespace LogModule
{
using namespace MutexModule;
const std::string gsep = "\r\n";
// 策略模式,C++多态特性
// 2. 刷新策略 a: 显示器打印 b:向指定的文件写入
// 刷新策略基类
class LogStrategy
{
public:
~LogStrategy() = default;
virtual void SyncLog(const std::string &message) = 0;
};
// 显示器打印日志的策略 : 子类
class ConsoleLogStrategy : public LogStrategy
{
public:
ConsoleLogStrategy()
{
}
void SyncLog(const std::string &message) override
{
LockGuard lockguard(_mutex);
std::cout << message << gsep;
}
~ConsoleLogStrategy()
{
}
private:
Mutex _mutex;
};
// 文件打印日志的策略 : 子类
const std::string defaultpath = "./log";
const std::string defaultfile = "my.log";
class FileLogStrategy : public LogStrategy
{
public:
FileLogStrategy(const std::string &path = defaultpath, const std::string &file = defaultfile)
: _path(path),
_file(file)
{
LockGuard lockguard(_mutex);
if (std::filesystem::exists(_path))
{
return;
}
try
{
std::filesystem::create_directories(_path);
}
catch (const std::filesystem::filesystem_error &e)
{
std::cerr << e.what() << '\n';
}
}
void SyncLog(const std::string &message) override
{
LockGuard lockguard(_mutex);
std::string filename = _path + (_path.back() == '/' ? "" : "/") + _file; // "./log/" + "my.log"
std::ofstream out(filename, std::ios::app); // 追加写入的 方式打开
if (!out.is_open())
{
return;
}
out << message << gsep;
out.close();
}
~FileLogStrategy()
{
}
private:
std::string _path; // 日志文件所在路径
std::string _file; // 日志文件本身
Mutex _mutex;
};
// 形成一条完整的日志&&根据上面的策略,选择不同的刷新方式
// 1. 形成日志等级
enum class LogLevel
{
DEBUG,
INFO,
WARNING,
ERROR,
FATAL
};
std::string Level2Str(LogLevel level)
{
switch (level)
{
case LogLevel::DEBUG:
return "DEBUG";
case LogLevel::INFO:
return "INFO";
case LogLevel::WARNING:
return "WARNING";
case LogLevel::ERROR:
return "ERROR";
case LogLevel::FATAL:
return "FATAL";
default:
return "UNKNOWN";
}
}
std::string GetTimeStamp()
{
time_t curr = time(nullptr);
struct tm curr_tm;
localtime_r(&curr, &curr_tm);
char timebuffer[128];
snprintf(timebuffer, sizeof(timebuffer),"%4d-%02d-%02d %02d:%02d:%02d",
curr_tm.tm_year+1900,
curr_tm.tm_mon+1,
curr_tm.tm_mday,
curr_tm.tm_hour,
curr_tm.tm_min,
curr_tm.tm_sec
);
return timebuffer;
}
// 1. 形成日志 && 2. 根据不同的策略,完成刷新
class Logger
{
public:
Logger()
{
EnableConsoleLogStrategy();
}
void EnableFileLogStrategy()
{
_fflush_strategy = std::make_unique<FileLogStrategy>();
}
void EnableConsoleLogStrategy()
{
_fflush_strategy = std::make_unique<ConsoleLogStrategy>();
}
// 表示的是未来的一条日志
class LogMessage
{
public:
LogMessage(LogLevel &level, std::string &src_name, int line_number, Logger &logger)
: _curr_time(GetTimeStamp()),
_level(level),
_pid(getpid()),
_src_name(src_name),
_line_number(line_number),
_logger(logger)
{
// 日志的左边部分,合并起来
std::stringstream ss;
ss << "[" << _curr_time << "] "
<< "[" << Level2Str(_level) << "] "
<< "[" << _pid << "] "
<< "[" << _src_name << "] "
<< "[" << _line_number << "] "
<< "- ";
_loginfo = ss.str();
}
// LogMessage() << "hell world" << "XXXX" << 3.14 << 1234
template <typename T>
LogMessage &operator<<(const T &info)
{
// a = b = c =d;
// 日志的右半部分,可变的
std::stringstream ss;
ss << info;
_loginfo += ss.str();
return *this;
}
~LogMessage()
{
if (_logger._fflush_strategy)
{
_logger._fflush_strategy->SyncLog(_loginfo);
}
}
private:
std::string _curr_time;
LogLevel _level;
pid_t _pid;
std::string _src_name;
int _line_number;
std::string _loginfo; // 合并之后,一条完整的信息
Logger &_logger;
};
// 这里故意写成返回临时对象
LogMessage operator()(LogLevel level, std::string name, int line)
{
return LogMessage(level, name, line, *this);
}
~Logger()
{
}
private:
std::unique_ptr<LogStrategy> _fflush_strategy;
};
// 全局日志对象
Logger logger;
// 使用宏,简化用户操作,获取文件名和行号
#define LOG(level) logger(level, __FILE__, __LINE__)
#define Enable_Console_Log_Strategy() logger.EnableConsoleLogStrategy()
#define Enable_File_Log_Strategy() logger.EnableFileLogStrategy()
}
#endif
3、锁
cpp
#pragma once
#include <iostream>
#include <pthread.h>
namespace MutexModule
{
class Mutex
{
public:
Mutex()
{
pthread_mutex_init(&_mutex, nullptr);
}
void Lock()
{
int n = pthread_mutex_lock(&_mutex);
(void)n;
}
void Unlock()
{
int n = pthread_mutex_unlock(&_mutex);
(void)n;
}
~Mutex()
{
pthread_mutex_destroy(&_mutex);
}
pthread_mutex_t *Get()
{
return &_mutex;
}
private:
pthread_mutex_t _mutex;
};
class LockGuard
{
public:
LockGuard(Mutex &mutex):_mutex(mutex)
{
_mutex.Lock();
}
~LockGuard()
{
_mutex.Unlock();
}
private:
Mutex &_mutex;
};
}
4、Common.hpp
cpp
#pragma once
#include<iostream>
enum ExitCode
{
OK=0,
USAGE_ERR,
BIND_ERR,
SOCK_ERR,
LISTEN_ERR
};
//防止拷贝
class NoCopy
{
public:
NoCopy(){}
~NoCopy(){}
NoCopy(const NoCopy&)=delete;
const NoCopy&operator=(const NoCopy&)=delete;
};
#define CONV(addr) ((struct sockaddr*)&addr)
在这里面我们统一了退出码,方便查找错误,分析程序崩溃的原因
禁止拷贝类,是为了让派生类防止拷贝,因为fd属于独占资源,如果允许拷贝对象,那么两个对象持有同一个fd,析构时会先后两次close(fd)
二、服务端测试版本
在开始之前呢,我想先给大家讲一个故事:

有一个好再来鱼庄,鱼庄安排了张三在街上招人,碰到人就说:要不要来我们这里来吃鱼啊,假如说招到了,就安排李四等服务员来接客,而张三再回门口继续吆喝,假如说没有招到,那就找寻下一个目标,其实在这过程当中,好再来鱼庄就是服务端,而马路就是操作系统,张三就是_listensockfd,只负责监听,而accept的返回值,就是为我们提供服务的sockfd。
函数介绍:

accept的作用就是获取顾客,从监听那里获取连接,其实就是服务员从张三那里获得来吃饭的客人。
初始化接口:
cpp
void Init()
{
//创建套接字
_listensockfd=socket(AF_INET,SOCK_STREAM,0);//SOCK_STREAM意味着面向字节流
if(_listensockfd<0)
{
LOG(LogLevel::FATAL)<<"socket error";
exit(SOCK_ERR);
}
LOG(LogLevel::INFO)<<"socket success";
//bind固定端口
InetAddr local(_port);
int n=bind(_listensockfd,(const sockaddr*)local.NetAddrPtr(),local.NetAddrLen());
//bind失败:
if(n<0)
{
LOG(LogLevel::FATAL)<<"bind error";
exit(BIND_ERR);
}
LOG(LogLevel::INFO)<<"bind success:"<<_listensockfd;
//_listen
n=listen(_listensockfd,backlog);//backlog代表底层设置的权限队列长度
if(n<0)
{
LOG(LogLevel::FATAL)<<"listen error";
exit(LISTEN_ERR);
}
LOG(LogLevel::INFO)<<"listen success"<<_listensockfd;
}
首先第一步就是创建套接字,其实就是雇佣张三,第二步就是绑定端口,绑定就是将文件描述符_listensockfd与本地IP+port进行绑定,类似于给张三分配特定的职务,开始监听,其实就相当于拉拢客人。
运行接口:
cpp
void Run()
{
_isrunning=true;
while(_isrunning)
{
struct sockaddr_in peer;
socklen_t len=sizeof(peer);
//获取连接,由_listensockfd来拉拢客人
int sockfd=accept(_listensockfd,CONV(peer),&len);
if(sockfd<0)
{
LOG(LogLevel::WARNING)<<"accept error";
continue;
}
InetAddr addr(peer);
//网络转主机,打印消息
LOG(LogLevel::INFO)<<"accept success,peer addr:"<<addr.StringAddr();
Server(sockfd,addr);
}
_isrunning=false;
}
获取拉拢过来的客人的的信息peer,然后获取连接,其实就是服务员从张三那里获得来吃饭的客人,然后获得主机消息,让用户知道是谁来了!!
需要注意的是这里的accept被调用成功之后,操作系统会自动填充好peer中的客户端ip地址和端口,peer其实就相当于一个容器,用来存放客户端的地址的。
服务接口:
cpp
void Server(int sockfd, InetAddr &peer)
{
char buffer[1024];
// TCP的读取工作类似于读文件一样!!
// 先读取数据
while (true)
{
ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
LOG(LogLevel::INFO) << peer.StringAddr() << "say#" << buffer;
// 再写回数据
std::string echo_server = "server say#";
echo_server += buffer;
write(sockfd, echo_server.c_str(), echo_server.size());
}
else if (n == 0)
{
LOG(LogLevel::DEBUG) << peer.StringAddr() << " 退出了...";
close(sockfd);
break;
}
else
{
LOG(LogLevel::DEBUG) << peer.StringAddr() << " 异常...";
close(sockfd);
break;
}
}
}
read:

服务端在运行时首先会读取数据,用read读取,从sockfd(与客户端通信的通道)客户端读取数据,再把数据写回给客户端。
读取数据有三种情况:
n>0:读取成功
n<0:读取失败
n==0:对端把连接关了,相当于服务器读到了文件的结尾 --- 与pipe类似。
我们用telnet来进行测试,telnet单纯充当一个TCP的收发工具,是一个通用的TCP客户端工具。

可以看到测试成功,当我关闭客户端时:

三、单进程正规版本
缺点就是一次只能处理一个请求:
cpp
#include<iostream>
#include"Common.hpp"
#include"InetAddr.hpp"
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include<cstring>
#include<memory>
#include<unistd.h>
//tcpclient server_ip server_port
void Usage(const char*process)
{
std::cout<<"Usage:"<<process<<"ip"<<"port"<<std::endl;
}
int main(int argc,char*argv[])
{
if(argc!=3)
{
Usage(argv[0]);
}
//获取服务端的ip和端口号
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 error"<<std::endl;
exit(SOCK_ERR);
}
//套接字创建成功之后就需要发起连接请求了,用connect
InetAddr peer(serverip,serverport);
int n=connect(sockfd,CONV(peer),sizeof(peer));
if(n<0)
{
std::cerr<<"connect error"<<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());
//接受服务端发来的消息,用read去读
char buffer[1024];
ssize_t size=read(sockfd,buffer,sizeof(buffer)-1);
if(size>0)
{
buffer[size]=0;
std::cout<<"server#"<<buffer<<std::endl;
}
}
close(sockfd);
//2、客户端不需要显示的绑定,会自动选择本地当前的ip和端口号为我们创建客户端,随机方式选择端口号
return 0;
}
客户端代码

四、多进程正规版本
多进程正规版本就是一次可以处理多个请求
cpp
void Run()
{
_isrunning = true;
while (_isrunning)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// 获取连接,由_listensockfd来拉拢客人
int sockfd = accept(_listensockfd, CONV(peer), &len);
if (sockfd < 0)
{
LOG(LogLevel::WARNING) << "accept error";
continue;
}
InetAddr addr(peer);
// 网络转主机,打印消息
LOG(LogLevel::INFO) << "accept success,peer addr:" << addr.StringAddr();
//多进程版本
pid_t id=fork();
if(id<0)
{
LOG(LogLevel::FATAL)<<"fork error";
exit(FORK_ERR);
}
else if(id==0)
{
//子进程不能看到父进程的listensockfd
close(_listensockfd);
Server(sockfd,addr);
exit(OK);
}
else
{
//父进程运行
close(sockfd);
//等待子进程,要不然就僵尸了
pid_t rid=waitpid(id,nullptr,0);
}
}
_isrunning = false;
}
我们先有一个雏形,再抛出问题,这里我们创建了多进程,我们不想让子进程看到父进程的_listensockfd,所以我们在子进程中关闭了这个文件描述符,我们不想让父进程看到子进程的sockfd,所以我们在父进程中关闭了这个文件描述符,然后子进程去执行server的任务,而父进程去执行accept获取连接的任务,但是父进程要等待子进程完成之后进行回收啊,那这里不就是阻塞了嘛?阻塞跟单进程有什么区别?最简单的方法就是用signal,让父进程忽略信号,直接并行执行。

直接在初始化时设置忽略信号。
但是这样太简单了,我们竟然要写多进程,那么我们就得把多进程优雅的设计给搞进来!!
来看看改变之后的代码:

我们就加了
,为何会让代码有了质的提升呢?我们来好好分析一下!!
我们的子进程在这里其实又创建了一个子进程,为了形象点,我们在这里叫做孙子进程,当fork()>0时,其实就是子进程执行的代码,直接退出了,此时我们的父进程执行完代码后压根就不需要去阻塞等待了,直接回收,而Server的任务交给了我们的孙子进程,但是孙子进程的父进程直接退出了,自己就变成了孤儿进程,孤儿进程是被系统所回收的,所以成功解决了难题!!
客户端和服务端需要一直维持那个链接,当其他人的进程加进来的时候,我们可以写脚本发现,只要是加进来的程序,一定会一直被受理。

可以看到孤儿进程越来越多了。

ctrl+c退出之后,孤儿进程就变少了。
五、多线程版本
我们先回答两个问题!
1、如果进程打开了一个文件,得到了一个fd,这个fd线程能看到吗?
答案:能够看到,一个进程的文件描述符是被所有线程共享的
2、线程敢不敢关闭自己不需要的fd?
答案:肯定不敢


我们来分析一下一些注意事项,因为ThreadData是一个内部类,所以我们可以在Run中直接调用,而我们又把Routine定义在了类中,我们需要把它设置静态的,因为有this指针,不能不传参数,但是呢定义为了静态的之后,就无法调用内部成员函数了,所以我们需要在ThreadData内部定义一个TcpServer的对象,然后在Routine内部创建的进程就可以去调用这个TcpServer对象,然后调用Server执行任务了!!
值得注意的是这里的Server我们不退,那么服务就一直进行,我们称这种服务为长服务,长服务适合多线程和多进程,短服务就是连接完,应答完,立马关掉连接;
六、线程池版本
cpp
using namespace LogModule;
using namespace ThreadPoolModule;
using task_t = std::function<void()>;
// 服务器往往是禁止拷贝的,所以我们继承一下NoCopy,想要完成整个服务器的拷贝,就得完成NoCopy的拷贝,所以我们就能禁止服务器拷贝了
const static int defaultsockfd = -1;
const static int backlog = 8;
class TcpServer : public NoCopy
{
public:
TcpServer(uint16_t port) : _port(port), _listensockfd(defaultsockfd), _isrunning(false)
{
}
void Init()
{
//signal(SIGCHLD,SIG_IGN);//忽略SIG_IGN信号,意味着父进程再也不用等子进程了,非常简单
// 创建套接字
_listensockfd = socket(AF_INET, SOCK_STREAM, 0); // SOCK_STREAM意味着面向字节流
if (_listensockfd < 0)
{
LOG(LogLevel::FATAL) << "socket error";
exit(SOCK_ERR);
}
LOG(LogLevel::INFO) << "socket success:"<<_listensockfd;
// bind固定端口
InetAddr local(_port);
int n = bind(_listensockfd, (const sockaddr *)local.NetAddrPtr(), local.NetAddrLen());
// bind失败:
if (n < 0)
{
LOG(LogLevel::FATAL) << "bind error";
exit(BIND_ERR);
}
LOG(LogLevel::INFO) << "bind success:" << _listensockfd;
//_listen
n = listen(_listensockfd, backlog); // backlog代表底层设置的权限队列长度
if (n < 0)
{
LOG(LogLevel::FATAL) << "listen error";
exit(LISTEN_ERR);
}
LOG(LogLevel::INFO) << "listen success:" << _listensockfd;
}
void Server(int sockfd, InetAddr &peer)
{
char buffer[1024];
// TCP的读取工作类似于读文件一样!!
// 先读取数据
while (true)
{
ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
LOG(LogLevel::INFO) << peer.StringAddr() << " say#" << buffer;
// 再写回数据
std::string echo_server = "server say#";
echo_server += buffer;
write(sockfd, echo_server.c_str(), echo_server.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->Server(td->sf,td->addr);
delete td;
return nullptr;
}
struct ThreadData
{
ThreadData(int sockfd,InetAddr&ar,TcpServer*s):sf(sockfd),addr(ar),tsvr(s)
{}
int sf;
InetAddr addr;
TcpServer *tsvr;
};
void Run()
{
_isrunning = true;
while (_isrunning)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// 获取连接,由_listensockfd来拉拢客人
int sockfd = accept(_listensockfd, CONV(peer), &len);
if (sockfd < 0)
{
LOG(LogLevel::WARNING) << "accept error";
continue;
}
InetAddr addr(peer);
// 网络转主机,打印消息
LOG(LogLevel::INFO) << "accept success,peer addr:" << addr.StringAddr();
//线程池版本,线程池一般比较适合处理短服务
//将新链接和客户端构建一个新的任务,push到线程池中,让线程池来处理
ThreadPool<task_t>::GetInstance()->Enqueue([this,sockfd,&addr](){
this->Server(sockfd,addr);
});
}
_isrunning = false;
}
~TcpServer()
{
}
private:
uint16_t _port;
int _listensockfd; // 监听套接字
bool _isrunning;
};
直接定义了一个任务,是一个function容器,然后将lambda表达式这个处理方法保存到这个容器里,然后当我们执行任务时,直接进行回调,完成这个任务。
大家也可以尝试做一下我们之前的业务版本,比如说翻译啊!