Webserver项目

一. 介绍和部署

1. 介绍

Webserver是经典的C++后端服务项目,主要提供网页的客户端和服务器端数据交互
借此用于理解

  1. 网络编程,包括HTTP请求和响应结构,如何处理GET、POST, Socket编程、TCP/IP协议栈(连接,传输,终止)
  2. 多线程编程,包括线程管理、线程同步和设计模式(生产者-消费者模型)
  3. 数据库编程,包括SQL和NoSQLSQL,即如何使用关系数据库(MySQL)和非关系型数据库(MongoDB)进行交互
  4. 性能优化,包括内存的分配和释放、缓存优化
  5. 部署和运维,包括服务器的配置,Docker容器使用以及日志管理

掌握以下技术栈

  • 线程池 + 非阻塞socket + epoll(ET和LT均实现) + 事件处理(Reactor和模拟Proactor均实现) 的并发模型
  • 使用状态机解析HTTP请求报文,支持解析GET和POST请求
  • 访问服务器数据库实现web端用户注册、登录功能,可以请求服务器图片和视频文件
  • 实现同步/异步日志系统,记录服务器运行状态
  • 经Webbench压力测试可以实现上万的并发连接数据交换

2. Linux服务器部署

首先安装ubuntu系统
版本为18.04,可以选择在虚拟机上安装,并使用vscode的ssh远程连接(具体步骤搜索vscode远程连接本地虚拟机)
虚拟机安装ssh apt-get install openssh-server ,使用远程连接可能还涉及开启和开机自启,一般来说都是默认启动,对于后面的mysql也是同样

sudo systemctl start ssh   #启动
sudo systemctl enable ssh   #开机自启动
sudo systemctl status ssh    #检查状态

apt-get 是一个命令行工具,同样是包管理工具,用于安装和更新对应软件
进行更新sudo apt-get update
安装git apt-get install git
安装g++环境 apt-get install build-essential
安装vim apt-get install vim

代码拉取
原始版本git clone https://github.com/qinguoyi/TinyWebServer.git
中文注释版本git clone https://github.com/white0dew/WebServer.git

安装配置mysql
安装mysql sudo apt-get install mysql-server
进行配置sudo mysql_secure_installation
mysql配置包括不启用验证密码插件,设置数据库root用户密码,不删除匿名用户,禁用root用户远程登录,不删除test数据库,重新加载表权限
检测数据库状态systemctl status mysql.service 包括是否启动,以及是否开机自启,进程号等
进入数据库sudo mysql -uroot -p

初始化需要的库

create database yourdb;  --创建数据库
USE yourdb;   --进入数据库
CREATE TABLE user(  --创建表
    username char(50) NULL,
    passwd char(50) NULL
)ENGINE=InnoDB;
INSERT INTO user(username, passwd) VALUES('name', 'passwd'); --插入信息

其它指令

SHOW DATABASES; --显示所有数据库
USE yourdb;   --进入数据库(进入系统数据库mysql可以通过user表查询用户信息)
SHOW TABLES; --显示所有表
select *from user; --查看对应表
EXIT;   --退出数据库

确保main.cpp配置和mysql数据库配置相同,这里是默认的用户

cd /etc/mysql
sudo vim debian.cnf

自定义数据库用户配置和权限

CREATE USER 'webuser'@'localhost' IDENTIFIED BY 'webpassword'; --在本地创建新用户以及对应密码
GRANT ALL PRIVILEGES ON webdatabase.* TO 'webuser'@'localhost'; --给予其相应数据库的权限
FLUSH PRIVILEGES;

执行程序
安装链接库apt-get install libmysqlclient-dev
make进行编译 sh ./build.sh
运行 ./server

具体参数
./server [-p port] [-l LOGWrite] [-m TRIGMode] [-o OPT_LINGER] [-s sql_num] [-t thread_num] [-c close_log] [-a actor_model]
-p,自定义端口号,默认9006
-l,选择日志写入方式,默认同步写入
* 0,同步写入
* 1,异步写入

-m,listenfd和connfd的模式组合,默认使用LT + LT
* 0,表示使用LT + LT
* 1,表示使用LT + ET
* 2,表示使用ET + LT
* 3,表示使用ET + ET

-o,优雅关闭连接,默认不使用
* 0,不使用
* 1,使用

-s,数据库连接数量,默认为8
-t,线程数量,默认为8
-c,关闭日志,默认打开
* 0,打开日志
* 1,关闭日志

-a,选择反应堆模型,默认Proactor
* 0,Proactor模型
* 1,Reactor模型

输入ip:9006进行登录注册

二. 各模块功能

1. 参数解析器

首先是main函数参数传入int main(int argc, char *argv[])
使用自定义Config类进行命令行参数解析,分别表示端口号,日志写入方式,触发组合模式,优雅关闭链,数据库连接池数量,线程池线程数量,是否关闭日志,并发模型选择
./server [-p port] [-l LOGWrite] [-m TRIGMode] [-o OPT_LINGER] [-s sql_num] [-t thread_num] [-c close_log] [-a actor_model]
具体解析方式采用,同时在Config类里面初始化默认参数

const char *str = "p:l:m:o:s:t:c:a:";
while ((opt = getopt(argc, argv, str)) != -1)  //每次获取一个opt和一个optarg,用于判断和赋值

类似的arg使用方法,比如定义一个处理变长参数的函数

变长参数求和
#include <cstdarg> 
// 定义一个处理变长参数的函数,计算所有传递参数的总和
int sum(int count, ...) {
    va_list args; // 定义一个 va_list 变量,用于存储变长参数信息
    va_start(args, count); // 初始化 va_list,使其指向变长参数列表的第一个参数

    int total = 0;
    for (int i = 0; i < count; ++i) {
        total += va_arg(args, int); // 获取下一个参数,并将其累加到 total 中
    }

    va_end(args); // 清理 va_list 变量
    return total;
}

同时还可以用来格式化输入,比如输入日志的时候,以及输出响应报文的时候

2. 日志写入

包括四种级别的日志,调试、信息、警告、错误
根据不同的函数调用去执行写,然后共用一个写的方法
首先要初始化日志,包括缓冲区等信息,异步写还要初始化一个阻塞线程,以及自定义写日志任务队列
要进行日志信息的准备,包括时间日期,同时拼接写入的信息
根据文件名或者当前日志行数决定是否新建日志文件
由于只有一个日志实例,上面的一些共享资源需要互斥使用,比如在缓冲区准备写入数据
同步模式直接刷新写入日志文件,异步模式把写入信息提交给阻塞队列

同步模式
所有过程其实都要互斥处理,包括在应用层缓冲准备数据,因为共用一个实例,还有从缓冲写入硬盘的过程

异步模式
应用层缓冲准备数据需要处理互斥,将处理好的数据传给阻塞队列后,由日志线程统一写入硬盘

3. 数据库

初始化建立一个连接池,使用list实现的一个双向阻塞队列,避免连接的重复建立和释放
同时初始化使用一个连接获取用户信息,放入缓存(一个map)用于快速验证
线程通过自定义http实体访问数据库,从该连接池互斥取出连接,这里通过实时建立一个类,也就是访问结束后自动析构会放回,使用该连接进行一个格式化的插入数据操作
数据库写入的时候需要互斥,因为要避免账户相同,但可以同时查询(虽然也没用到)

4. 线程池

初始化建立一个线程池数组,同时初始化一个list双向阻塞任务队列,直接全部启动调用
根据条件变量进行阻塞和执行,多个线程竞争该任务队列,线程池主要执行解析输入请求报文和生成输出响应报文
该任务连接的Reactor模式和proactor模式还会决定是由主线程还是线程池子线程来进行socket系统内核数据的传输
通过线程池实现与连接任务的解耦,避免线程的重复建立和释放,也避免太多线程占据资源

请求报文的解析通过状态机,分别解析请求行、请求头和请求体,记录响应的参数,这里主要是GET还是POST请求,对应的action或者请求的资源地址
Connection是长连接与否keep-alive,HTTP版本是否对应,获取消息体的长度Content-length,获取Host
由于到达的数据不一定完整,所以采用状态机这种逻辑清晰的流解析方式,当解析完整,接着生成响应报文,这里可以同时处理业务逻辑,然后将响应报文格式和响应资源聚合
线程池的读写主要是读应用层缓冲,解析缓冲,进行业务逻辑处理,输出到应用层缓冲,准备完毕即可注册监听对应端口文件描述符的EPOLLOUT事件,交给主线程判断,避免阻塞等待

5. 触发模式

LT ET
连接的监听和连接的数据传输(读取)都有两种模式,对于监听是持续的模式,连接是oneshot避免事件频繁触发,以及方便业务逻辑,避免多个线程处理一个连接
同时,多线程这些文件描述符都设置为非阻塞模式,即不会分别等待事件到来,而是通过epoll事件触发
监听的LT是每次处理一个连接,ET将所有监听到的连接全部处理完毕,也就是为连接建立文件描述符并加入epoll,同时设置感兴趣事件
连接数据读取的LT也是进行一次数据处理,不要求从socket将数据全部取出,ET将数据全部读取出来,其实这里就是减少事件的触发和线程切换
无论读取有没有完成,都是由后续解析判断请求报文是否完整,否则还需要继续监听,当请求报文完整,就可以do_request生成响应报文放到应用层缓冲,同时开始监听socket写缓冲

6. 监听运行

原理是在操作系统内核文件描述符表注册相应文件描述符和事件,由操作系统就绪事件队列来进行通知
主要框架是通过epoll机制监听socket的事件,避免等待外部I/O,同时通过信号、定时器、管道统一在主线程处理所有状态变化和事件触发
分别监听是否有新的连接、监听异常事件、监听已有的连接读写事件、监听定时器信号,同时跳转对应的处理逻辑,最后才统一处理定时器避免响应过慢

三. 项目梳理

1. 简单web

实际上从简单的服务访问来看,就是浏览器访问服务器网址获取页面和服务
对于服务器最简单的就是循环等待请求,然后根据请求的网络地址,将页面进行返回,
不同语言都有封装好的tcp/ip解析以及http请求解析库

2. 很多访问

当有大量的请求的时候,这个时候就出现了问题
程序逐个请求去解析以及返回,会浪费cpu资源,因为很多时候都在等待I/O的输入输出
为了解决这个问题,可以设置很多个线程,在其它线程I/O的时候,可以切换线程进行其它逻辑的处理

3. 多线程

多线程会引入很多问题
首先线程什么时候创立,如果对每一个连接建立一个新线程开销太大
最好是连接和线程分离,当有任务的时候,再让线程去处理任务,实现任务和线程的解耦
这里就引入了线程池和任务队列,同时需要实现线程对于任务的调度,这样也避免了重复建立和释放线程带来的开销

接着,线程和连接解耦了,谁去监听对应连接的文件描述符
这里统一使用一个线程对所有事件进行监听,采用epoll机制,后续统一实现对所有事件包括信号和计时器的监听
整个程序的框架就成了事件驱动,所以这个监听可以放到主线程,由它实现整个逻辑的分发和处理,同时对应文件描述符都采用非阻塞方式

然后,多线程会涉及到资源的互斥和共享,这里就要实现线程信号传输功能,比如多个线程对任务队列的访问需要上锁,以及所有的连接都是oneshot
同时子线程和主线程之间的同步,子线程之间的互斥,根据具体资源和需求涉及到信号量、条件变量、互斥量的使用

对应的静态连接可以设置超时机制用于释放,避免连接过多,这里引入定时器机制,采用哈希+队列方式,进行淘汰

4. 请求解析和返回

由于是自己实现的页面以及相应请求
这里需要对HTTP协议请求进行解析,包括GET和POST以及对应消息体

5. 数据库+缓存

由于要实现用户的登录和注册,同时存储用户信息
当存在大量用户时,不能在本地内存或者硬盘上读写,专门由数据库进行管理,比如mysql
这就涉及到服务器与数据库的交互,信息查询、检索和存储,以及安全查询,比如预编译语句
想要更快的访问数据,根据时间局部性原理和空间局部性原理,可以增加缓存加速查询,比如redis

6. 日志写入(同步|异步)

为了方便调试和查找问题,服务器还需要提供日志写入功能
同步写日志为主线程去写,保证实时性,但会因为慢速的I/O耽误cpu效率,导致阻塞
异步日志写入则由子线程去写,这部分专门由一个日志类执行
由于日志写入对于逻辑没有影响,属于额外操作,异步比较好实现

7. 连接和数据传输的两种选择(LT|ET)

对于连接的监听和数据传输的监听都可以采用LT或者ET模式
对于高并发请求,ET模式可以减少系统调用,减少事件的通知,但它需要非阻塞I/O一起使用,同时确保一次性读取写入数据

四. 项目参数和问题

参数
默认端口 9006
线程池线程数 8
数据库连接池数 8
最大文件描述符 65536
最大时间数 10000
最小超时时间 5s
连接读取文件上限 200
连接读缓存大小 2048
连接写缓存大小 1024

日志缓冲问题:目前一个日志实例缓冲准备写入数据,会导致竞争,可以将这个缓冲也区分开来
数据库写入问题:虽然有多个连接,但写入还是互斥,因为要避免互斥判断缓存,前者判断过于频繁,这里进行了双重检定,但这里实现不了异步,因为要等待写入结果
数据库问题:在本地直接缓存涉及到大量数据不行,采用redis进行缓存
数据库安全问题:直接将消息体的账户密码拼接成查询或者插入语句,不安全,采用预编译语句。缓存和数据库信息明文存储也不安全
线程池与任务匹配问题:目前是线程池线程无序竞争释放的任务,没有一定的调度和匹配,比如对任务划分优先级
session和cookie:没有解析请求报文的session id,同时响应报文也没有设置cookie,这里可以再建立一个这样的会话实体进行判断,直接返回登陆后的界面
定时器机制:实现用来处理超时连接,目前的LRU模式,用双向队列复杂度较低,如果加入解析连接的时间和次数,要改成小根堆的模式去调整

五. 参考文章项目

原始版本
原始版本部署
中文注释版本
中文注释版本讲解

posted @ 2024-06-07 10:42  失控D大白兔  阅读(129)  评论(0编辑  收藏  举报