从0到服务器开发——TinyWebServer(下)

本文涉及的产品
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
日志服务 SLS,月写入数据量 50GB 1个月
简介: 从0到服务器开发——TinyWebServer

从0到服务器开发——TinyWebServer(中):https://developer.aliyun.com/article/1508285

返回响应报文

在完成请求报文的解析之后,明确用户想要登录/注册,需要跳转到对应的界面、添加用户名、验证用户等等,并将相应的数据写入相应报文,返回给浏览器,流程图如下:

这个在process_read()中完成请求报文的解析之后,状态机会调用do_request()函数,该函数是处理功能逻辑的。该函数将网站根目录和url文件拼接,然后通过stat判断该文件属性。url,可以将其抽象成ip:port/xxx,xxx通过html文件的action属性(即请求报文)进行设置。m_url为请求报文中解析出的请求资源,以/开头,也就是x,项目中解析后的m_url有8种情况,见do_request()函数,部分代码如下:

 //功能逻辑单元
http_conn::HTTP_CODE http_conn::do_request()
{
    strcpy(m_real_file, doc_root);
    int len = strlen(doc_root);
    //printf("m_url:%s\n", m_url);
    const char *p = strrchr(m_url, '/');
    //处理cgi
    if (cgi == 1 && (*(p + 1) == '2' || *(p + 1) == '3'))
    {
        //根据标志判断是登录检测还是注册检测
        char flag = m_url[1];
        char *m_url_real = (char *)malloc(sizeof(char) * 200);
        strcpy(m_url_real, "/");
        strcat(m_url_real, m_url + 2);
        strncpy(m_real_file + len, m_url_real, FILENAME_LEN - len - 1);
        free(m_url_real);

        //将用户名和密码提取出来
        //user=123&passwd=123
        char name[100], password[100];
        int i;
        for (i = 5; m_string[i] != '&'; ++i)
            name[i - 5] = m_string[i];
        name[i - 5] = '\0';

        int j = 0;
        for (i = i + 10; m_string[i] != '\0'; ++i, ++j)
            password[j] = m_string[i];
        password[j] = '\0';

        if (*(p + 1) == '3')
        {
            //如果是注册,先检测数据库中是否有重名的
            //没有重名的,进行增加数据
            ......
            if (users.find(name) == users.end())
            {
                m_lock.lock();
                int res = mysql_query(mysql, sql_insert);
                users.insert(pair<string, string>(name, password));
                m_lock.unlock();

                if (!res)
                    strcpy(m_url, "/log.html");
                else
                    strcpy(m_url, "/registerError.html");
            }
            else
                strcpy(m_url, "/registerError.html");
        }
        ......
} 

其中,stat函数用于获取文件的类型、大小等信息;mmap用于将文件等映射到内存,提高访问速度,详见mmap原理;iovec定义向量元素,通常,这个结构用作一个多元素的数组,详见社长微信;writev为聚集写,详见链接;


执行do_request()函数之后,子线程调用process_write()进行响应报文(add_status_line、add_headers等函数)的生成。在生成响应报文的过程中主要调用add_reponse()函数更新m_write_idx和m_write_buf。


值得注意的是,响应报文分为两种,一种是请求文件的存在,通过io向量机制iovec,声明两个iovec,第一个指向m_write_buf,第二个指向mmap的地址m_file_address ;另一种是请求出错,这时候只申请一个iovec,指向m_write_buf 。


其实往响应报文里写的就是服务器中html的文件数据,浏览器端对其进行解析、渲染并显示在浏览器页面上。


另外,用户登录注册的验证逻辑代码在do_request()中,通过对Mysql数据库进行查询或插入,验证、添加用户。


以上就是对注册/登录模块的详细介绍,之后分模块对该项目的线程池、日志、定时器等进行细节探究。

四、线程池

这个部分着重介绍该项目的线程池实现。整体框架如下:

定义

线程池其定义如下:

template <typename T>
class threadpool
{
public:
    /*thread_number是线程池中线程的数量,max_requests是请求队列中最多允许的、等待处理的请求的数量*/
    threadpool(int actor_model, connection_pool *connPool, int thread_number = 8, int max_request = 10000);
    ~threadpool();
    bool append(T *request, int state);
    bool append_p(T *request);

private:
    /*工作线程运行的函数,它不断从工作队列中取出任务并执行之*/
    static void *worker(void *arg);//为什么要用静态成员函数呢-----class specific
    void run();

private:
    int m_thread_number;        //线程池中的线程数
    int m_max_requests;         //请求队列中允许的最大请求数
    pthread_t *m_threads;       //描述线程池的数组,其大小为m_thread_number
    std::list<T *> m_workqueue; //请求队列
    locker m_queuelocker;       //保护请求队列的互斥锁
    sem m_queuestat;            //是否有任务需要处理
    connection_pool *m_connPool;  //数据库
    int m_actor_model;          //模型切换(这个切换是指Reactor/Proactor)
};

注意到该线程池采用模板编程,这是为了增强其拓展性:各种任务种类都可支持。

线程池需要预先创建一定的线程,其中最重要的API为:

#include <pthread.h>
//返回新生成的线程的id
int pthread_create 
(pthread_t *thread_tid,//新生成的线程的id         
const pthread_attr_t *attr, //指向线程属性的指针,通常设置为NULL      
void * (*start_routine) (void *), //处理线程函数的地址  
void *arg);  //start_routine()中的参数

函数原型中的第三个参数,为函数指针,指向处理线程函数的地址。该函数,要求为静态函数。如果处理线程函数为类成员函数时,需要将其设置为静态成员函数(因为类的非静态成员函数有this指针,就跟void*不匹配)。进一步了解请看

线程池创建

项目中线程池的创建:

threadpool<T>::threadpool( int actor_model, connection_pool *connPool, int thread_number, int max_requests) : m_actor_model(actor_model),m_thread_number(thread_number), m_max_requests(max_requests), m_threads(NULL),m_connPool(connPool)
{
    if (thread_number <= 0 || max_requests <= 0)
        throw std::exception();
    m_threads = new pthread_t[m_thread_number];     //pthread_t是长整型
    if (!m_threads)
        throw std::exception();
    for (int i = 0; i < thread_number; ++i)
    {
        //创建成功应该返回0,如果线程池在线程创建阶段就失败,那就应该关闭线程池了
        if (pthread_create(m_threads + i, NULL, worker, this) != 0)
        {
            delete[] m_threads;
            throw std::exception();
        }
        //主要是将线程属性更改为unjoinable,便于资源的释放,详见PS
        if (pthread_detach(m_threads[i]))
        {
            delete[] m_threads;
            throw std::exception();
        }
    }
}

PS:注意到创建一个线程之后需要调用pthread_detech(),原因在于: linux线程有两种状态joinable状态和unjoinable状态。


如果线程是joinable状态,当线程函数自己退出都不会释放线程所占用堆栈和线程描述符(总计8K多)。只有当调用了pthread_join,主线程阻塞等待子线程结束,然后回收子线程资源。


而unjoinable属性可以在pthread_create时指定,或在线程创建后在线程中pthread_detach(pthread_detach()即主线程与子线程分离,子线程结束后,资源自动回收), 如:pthread_detach(pthread_self()),将状态改为unjoinable状态,确保资源的释放。其实简单的说就是在线程函数头加上 pthread_detach(pthread_self())的话,线程状态改变,在函数尾部直接 pthread_exit线程就会自动退出。省去了给线程擦屁股的麻烦。

加入请求队列

当epoll检测到端口有事件激活时,即将该事件放入请求队列中(注意互斥),等待工作线程处理:

//proactor模式下的请求入队
bool threadpool<T>::append_p(T *request)
{
    m_queuelocker.lock();
    if (m_workqueue.size() >= m_max_requests)
    {
        m_queuelocker.unlock();
        return false;
    }
    m_workqueue.push_back(request);
    m_queuelocker.unlock();
    m_queuestat.post();
    return true;
}

上面是Proactor模式下的任务请求入队,不知道Reactor和Proactor模式的请回到第一章、IO复用。本项目所实现的是一个基于半同步/半反应堆式的并发结构,以Proactor模式为例的工作流程如下:

  • 主线程充当异步线程,负责监听所有socket上的事件
  • 若有新请求到来,主线程接收之以得到新的连接socket,然后往epoll内核事件表中注册该socket上的读写事件
  • 如果连接socket上有读写事件发生,主线程从socket上接收数据,并将数据封装成请求对象插入到请求队列中
  • 所有工作线程睡眠在请求队列上,当有任务到来时,通过竞争(如互斥锁)获得任务的接管权

即是如下原理:(图片来自


线程处理

在建立线程池时,调用pthread_create指向了worker()静态成员函数,而worker()内部调用run()。

//工作线程:pthread_create时就调用了它
template <typename T>
void *threadpool<T>::worker(void *arg)
{
    //调用时 *arg是this!
    //所以该操作其实是获取threadpool对象地址
    threadpool *pool = (threadpool *)arg;
    //线程池中每一个线程创建时都会调用run(),睡眠在队列中
    pool->run();
    return pool;
}

run()函数其实也可以看做是一个回环事件,一直等待m_queuestat()信号变量post,即新任务进入请求队列,这时请求队列中取出一个任务进行处理:

//线程池中的所有线程都睡眠,等待请求队列中新增任务
void threadpool<T>::run()
{
    while (true)
    {
        m_queuestat.wait();
        m_queuelocker.lock();
        if (m_workqueue.empty())
        {
            m_queuelocker.unlock();
            continue;
        }
        T *request = m_workqueue.front();
        m_workqueue.pop_front();
        m_queuelocker.unlock();
        if (!request)
            continue;
//        ......线程开始进行任务处理
    }
}

**注:**每调用一次pthread_create就会调用一次run(),因为每个线程是相互独立的,都睡眠在工作队列上,仅当信号变量更新才会唤醒进行任务的竞争

五、定时器

原理解析

如果一个客户端与服务器长时间连接,并且不进行数据的交互,这个连接就没有存在的意义还占据了服务器的资源。在这种情况下,服务器就需要一种手段检测无意义的连接,并对这些连接进行处理。


除了处理非活跃的连接之外,服务器还有一些定时事件,比如关闭文件描述符等。


为实现这些功能,服务器就需要为各事件分配一个定时器。

该项目使用SIGALRM信号来实现定时器,首先每一个定时事件都处于一个升序链表上,通过alarm()函数周期性触发SIGALRM信号,而后信号回调函数利用管道通知主循环,主循环接收到信号之后对升序链表上的定时器进行处理:若一定时间内无数据交换则关闭连接。

有关这一部分的底层API解析,建议直接阅读我所添加的源码注释或者参考社长的文章

代码与框图

由于定时器部分在源代码中调用比较复杂,可以结合该框图进行理解:


文字性叙述:

服务器首先创建定时器容器链表,然后用统一事件源将异常事件,读写事件和信号事件统一处理,根据不同事件的对应逻辑使用定时器。

具体的,浏览器与服务器连接时,创建该连接对应的定时器,并将该定时器添加到定时器容器链表上;


处理异常事件时,执行定时事件,服务器关闭连接,从链表上移除对应定时器;


处理定时信号时,将定时标志设置为true,以便执行定时器处理函数;


处理读/写事件时,若某连接上发生读事件或某连接给浏览器发送数据,将对应定时器向后移动,否则,执行定时事件。

六、日志系统

为了记录服务器的运行状态,错误信息,访问数据的文件等,需要建立一个日志系统。本项目中,使用单例模式创建日志系统。该部分的框图如下(原图来自社长):


由上图可知,该系统同步和异步两种写入方式。

其中异步写入方式,生产者-消费者模型封装为阻塞队列,创建一个写线程,工作线程将要写的内容push进队列写线程从队列中取出内容,写入日志文件。对于同步写入方式,直接格式化输出内容,将信息写入日志文件。

该系统可以实现按天分类,超行分类功能。

这个部分建议直接结合源码,从log.h入手进行阅读,先查看同步写入的方式,在进行异步写入日志以及阻塞队列的阅读。

或是参考社长的:日志系统

七、其他

数据库连接池

该项目在处理用户连接时,采用的是:每一个HTTP连接获取一个数据库连接,获取其中的用户账号密码进行对比(有点损耗资源,实际场景下肯定不是这么做的),而后再释放该数据库连接。

那为什么要创建数据库连接池呢?

据库访问的一般流程为:当系统需要访问数据库时,先系统创建数据库连接,完成数据库操作,然后系统断开数据库连接。——从中可以看出,若系统需要频繁访问数据库,则需要频繁创建和断开数据库连接,而创建数据库连接是一个很耗时的操作,也容易对数据库造成安全隐患。


在程序初始化的时候,集中创建多个数据库连接,并把他们集中管理,供程序使用,可以保证较快的数据库读写速度,更加安全可靠。


其实数据库连接池跟线程池的思想基本是一致的。


在该项目中不仅实现了数据库连接池,还将数据库连接的获取与释放通过RAII机制封装,避免手动释放。


这一部分比较易懂,建议直接阅读源码。

封装同步类

为便于实现同步类的RAII机制,该项目在pthread库的基础上进行了封装,实现了类似于C++11的mutex、condition_variable。

可以阅读文件夹lock中的源码进行这方面的学习。

参考资料

(主要资料)社长本人的文章:

https://github.com/qinguoyi/TinyWebServer#%E5%BA%96%E4%B8%81%E8%A7%A3%E7%89%9B

(力荐)一文读懂TinyWebServer:


https://huixxi.github.io/2020/06/02/%E5%B0%8F%E7%99%BD%E8%A7%86%E8%A7%92%EF%BC%9A%E4%B8%80%E6%96%87%E8%AF%BB%E6%87%82%E7%A4%BE%E9%95%BF%E7%9A%84TinyWebServer/#more


https://book.douban.com/subject/24722611/


https://baike.baidu.com/item/WEB%E6%9C%8D%E5%8A%A1%E5%99%A8/8390210?fr=aladdin


主流服务器对比:


https://www.cnblogs.com/sammyliu/articles/4392269.html


https://blog.csdn.net/u010066903/article/details/52827297/


项目地址:


https://github.com/qinguoyi/TinyWebServer

相关文章
|
5月前
|
安全 Linux Windows
小试跨平台局域网文件传输工具NitroShare,几点感想
小试跨平台局域网文件传输工具NitroShare,几点感想
|
9月前
|
网络协议 Java Unix
从0到服务器开发——TinyWebServer(上)
从0到服务器开发——TinyWebServer
387 1
|
9月前
|
缓存 移动开发 网络协议
从0到服务器开发——TinyWebServer(中)
从0到服务器开发——TinyWebServer
310 0
|
9月前
|
网络协议 Unix Linux
基础的网络服务器开发
基础的网络服务器开发
|
消息中间件 负载均衡 算法
即时通讯技术文集(第21期):后端架构设计基础入门系列 [共15篇]
为了更好地分类阅读 52im.net 总计1000多篇精编文章,我将在每周三推送新的一期技术文集,本次是第21 期。
99 0
|
Oracle Java 关系型数据库
从0开始搭建一台服务器开发环境(上)
这套文章将教您如何一步一步搭建一台服务器,通过这套文章你将搭建的服务,以及配置如下:
1007 1
从0开始搭建一台服务器开发环境(上)
|
NoSQL 网络协议 Java
从0开始搭建一台服务器开发环境(中)
4,安装maven 5,安装tomcat 6,安装redis 8,安装mysql 9,安装svn
216 0
从0开始搭建一台服务器开发环境(中)
|
域名解析 负载均衡 网络协议
即时通讯技术文集(第4期):不为人知的网络编程 [共14篇]
为了更好地分类阅读52im.net 总计1000多篇精编文章,我将在每周三推送新的一期技术文集,本次是第4 期。
121 0
即时通讯技术文集(第4期):不为人知的网络编程 [共14篇]
|
网络协议 架构师 Java
即时通讯技术文集(第3期):高性能网络编程系列 [共14篇]
为了更好地分类阅读52im.net 总计1000多篇精编文章,我将在每周三推送新的一期技术文集,本次是第3 期。
236 0
即时通讯技术文集(第3期):高性能网络编程系列 [共14篇]
|
网络协议 Java 程序员
即时通讯技术文集(第2期):脑残式网络编程系列 [共12篇]
为了更好地分类阅读52im.net 总计1000多篇精编文章,我将在每周三推送新的一期技术周刊,本次是第2 期。
120 0
即时通讯技术文集(第2期):脑残式网络编程系列 [共12篇]