服务器介绍
本项目大部分参考社长的
TinyWebServer
。首先认识一下什么是服务器。
服务器就是一个
服务器软件
(程序),其主要功能是通过
HTTP协议
与客户端(通常是浏览器browser)进行通信,并对其请求做出HTTP响应,返回客户端所请求的内容(文件,网页等)。
通常用户使用Web浏览器与相应服务器进行通信。在浏览器中键入
“域名”
或
“IP地址:端口号”
,浏览器则先将你的域名解析成相应的IP地址或者直接根据你的IP地址向对应的Web服务器发送一个
HTTP请求
。这一过程首先要通过TCP协议的三次握手建立与目标Web服务器的连接,然后HTTP协议生成针对目标Web服务器的
HTTP请求报文
,通过TCP、IP等协议发送到目标Web服务器上。
服务器端整体运行流程
webserver的初始化
首先我们在服务器端通过
mysql
建立一个名叫“yourdb”的数据库,登录数据库的用户名和密码默认为
root,123456
。并且在“yourdb”中建立一张包含
user
和
password
两个字段的名叫user的表。
当我们在命令行运行服务器程序,我们可以自定义设置
端口号,线程池数量,日志写入方式,反应堆模型
等等。
因为我们程序将运行config.parse_arg()进行命令行解析,获取我们的自定义设置,当然也可以使用默认设置。
void Config::parse_arg(int argc, char*argv[]){
int opt;
//getopt()方法是用来分析命令行参数
//argc:通常由 main 函数直接传入,表示参数的数量
//argv:通常也由 main 函数直接传入,表示参数的字符串变量数组
//*str用于参数的解析。例如 “abc:”,其中 -a,-b 就表示两个普通选项,
//-c 表示一个必须有参数的选项,因为它后面有一个冒号。
//全局变量optarg:如果某个选项有参数,这包含当前选项的参数字符串
const char *str = "p:l:m:o:s:t:c:a:";
while ((opt = getopt(argc, argv, str)) != -1)
{
switch (opt)
{
case 'p':
{
PORT = atoi(optarg);
break;
}
case 'l':
{
LOGWrite = atoi(optarg);
break;
}
case 'm':
{
TRIGMode = atoi(optarg);
break;
}
case 'o':
{
OPT_LINGER = atoi(optarg);
break;
}
case 's':
{
sql_num = atoi(optarg);
break;
}
case 't':
{
thread_num = atoi(optarg);
break;
}
case 'c':
{
close_log = atoi(optarg);
break;
}
case 'a':
{
actor_model = atoi(optarg);
break;
}
default:
break;
}
}
}
接着调用默认构造函数初始化webserver对象,即
创建HTTP对象数组和资源文件夹路径初始化
。最后就可以运行webserver.init()进行初始化。
void WebServer::init(int port, string user, string passWord, string databaseName, int log_write,
int opt_linger, int trigmode, int sql_num, int thread_num, int close_log, int actor_model)
{
//初始端口号
m_port = port;
//初始登录名
m_user = user;
//初始登录密码
m_passWord = passWord;
//初始化数据库名
m_databaseName = databaseName;
//初始化数据库连接池数量
m_sql_num = sql_num;
//初始化线程池内的线程数量
m_thread_num = thread_num;
//初始化日志写入方式
m_log_write = log_write;
//初始优雅关闭连接
m_OPT_LINGER = opt_linger;
//初始化触发组合模式
m_TRIGMode = trigmode;
//关闭日志,默认0不关闭
m_close_log = close_log;
//初始化并发模式,默认proactor
m_actormodel = actor_model;
}
日志、数据库、线程池和触发模式
日志和数据库均只有一个实例对象,均通过
局部静态成员变量的懒汉模式实现单例模式
。优点:延迟实例化,节约资源;线程安全;性能提高;不存在内存泄漏。
日志的初始化需要设置日志文件名,日志缓冲区大小,日志最大行数。如果是
异步方式
写入日志,我们还需要设置
阻塞队列
的大小,本项目借鉴生产者消费者模型,采用循环数组结构实现阻塞队列。
为什么要创建连接池?
从一般流程中可以看出,若系统需要频繁访问数据库,则需要频繁
创建和断开数据库连接
,而创建数据库连接是一个
很耗时
的操作,也容易对数据库造成安全隐患。
在程序初始化的时候,集中创建多个数据库连接,并把他们集中管理,供程序使用,可以保证较快的数据库读写速度,更加安全可靠。将数据库连接的获取与释放通过
RAII机制封装
(也称为**“资源获取就是初始化”**,是c++等编程语言常用的管理资源、
避免内存泄露
的方法),避免手动释放。
数据库连接池
内部通过
链表list
存储mysql连接。并且使用
信号量
机制进行同步。
整个项目采用
半同步\半反应堆
的并发模式工作,线程池的内部构造类似数据库连接池,同样采样链表将HTTP对象存储起来形成
请求队列
,同时使用信号量机制和互斥锁进行同步。初始化线程池需要设置线程池数量(默认为8)、并发模式和能接受的
最大请求队列长度
。
需要设置用于监听的套接字(listenfd)和用于通信的套接字(connfd)的触发模式,默认是同步I/O模拟的proactor模式。
主线程监听连接
使用socket()创建一个用于监听连接的套接字,并绑定默认的
网卡
和默认的
端口(9006)
,我设置了端口复用,能复用处于TIME_WAIT的socket。
创建epoll实例,将listenfd加入epoll树并注册其读事件。
创建一对套接字进行
管道通信
,管道写端将定时信号发送给管道读端。管道读端的可读事件加入epoll实例中,即
统一事件源
。
统一事件源,是指将信号事件与其他事件一样被处理。
最后设置
时钟信号
和
终止信号
的的处理动作(即管道写端将信号发送给管道读端),至此就完成了准备工作。
主线程处理监控文件描述符上的事件
void WebServer::eventLoop()
{
bool timeout = false;
bool stop_server = false;
while (!stop_server)
{
//等待所监控文件描述符上有事件的产生
//检测发生事件的文件描述符
int number = epoll_wait(m_epollfd, events, MAX_EVENT_NUMBER, -1);
if (number < 0 && errno != EINTR) //被中断的系统调用返回EINTR
{
LOG_ERROR("%s", "epoll failure");
break;
}
//轮询文件描述符
for (int i = 0; i < number; i++)
{
int sockfd = events[i].data.fd;
//处理新到的客户连接
if (sockfd == m_listenfd)
{
bool flag = dealclinetdata();
if (false == flag)
continue;
} //对端断开连接|套接字意外关闭?|异常连接
else if (events[i].events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR))
{
//服务器端关闭连接,移除对应的定时器
util_timer *timer = users_timer[sockfd].timer;
deal_timer(timer, sockfd);
}
//处理信号
//管道读端对应的文件描述符发生读事件
else if ((sockfd == m_pipefd[0]) && (events[i].events & EPOLLIN))
{
bool flag = dealwithsignal(timeout, stop_server);
if (false == flag)
LOG_ERROR("%s", "dealclientdata failure");
}
//处理客户端发送数据的读事件
else if (events[i].events & EPOLLIN)
{
dealwithread(sockfd);
}
//处理向客户端发送数据的写事件
else if (events[i].events & EPOLLOUT)
{
dealwithwrite(sockfd);
}
}
if (timeout)
{
//处理定时任务,并重新定时以不断触发SIGALRM信号
utils.timer_handler();
LOG_INFO("%s", "timer tick");
timeout = false;
}
}
}
从以上**dealwithread()
和
dealwithwrite()**内部实现可以看出两种并发模式(
reactor
和
proactor
)的区别。简单来说:
reactor模式中,
主线程
(I/O处理单元)只负责监听文件描述符上是否有事件发生,有的话立即通知
工作线程
(逻辑单元 ),读写数据、接受新连接及处理客户请求均在工作线程中完成。通常由
同步I/O
实现。
proactor模式中,主线程和内核负责处理读写数据、接受新连接等I/O操作,工作线程仅负责业务逻辑,如处理客户请求。
同步I/O模拟proactor模式
的工作流程如下(epoll_wait为例):
- 主线程往epoll内核事件表注册socket上的读就绪事件。
- 主线程调用epoll_wait等待socket上有数据可读。
-
当socket上有数据可读,epoll_wait通知主线程,
主线程
从socket
循环读取数据
,直到没有更多数据可读,然后将读取到的数据封装成一个
请求对象
并插入
请求队列
。 -
睡眠在请求队列上某个
工作线程
被唤醒,它获得请求对象并
处理客户请求
,然后往epoll内核事件表中注册该socket上的写就绪事件。 - 主线程调用epoll_wait等待socket可写。
-
当socket上有数据可写,epoll_wait通知主线程。
主线程
往socket上写入服务器处理客户请求的结果。