从零开始实现TinyWebServer

您所在的位置:网站首页 茶叶山水情 从零开始实现TinyWebServer

从零开始实现TinyWebServer

2023-09-09 05:39| 来源: 网络整理| 查看: 265

原作者:https://zhuanlan.zhihu.com/p/364044293

 

从0到服务器开发——TinyWebServer 前言:

修改、完整注释、添加功能的项目代码:

https://github.com/white0dew/WebServer

它是个什么项目?——Linux下C++轻量级Web服务器,助力初学者快速实践网络编程,搭建属于自己的服务器。

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

项目原代码:https://github.com/qinguoyi/TinyWebServer

强无敌!这篇文章是我在学习这个项目时所写的笔记。

基础知识

要开始这个项目,需要对linux编程、网络编程有一定的了解,这方面书籍推荐《Unix网络编程》和《Linux高性能服务器编程》。

什么是web sever?

Web服务器一般指网站服务器,是指驻留于因特网上某种类型计算机的程序,可以处理浏览器等Web客户端的请求并返回相应响应——可以放置网站文件,让全世界浏览;可以放置数据文件,让全世界下载。目前最主流的三个Web服务器是Apache、 Nginx 、IIS。服务器与客户端的关系如下:

在本项目中,web请求主要是指HTTP协议,有关HTTP协议知识可以参考介绍,HTTP基于TCP/IP,进一步了解请百度。

什么是socket?

客户端与主机之间是如何通信的?——Socket

socket起源于Unix,而Unix/Linux基本哲学之一就是“一切皆文件”,都可以用“打开open –> 读写write/read –> 关闭close”模式来操作。Socket就是该模式的一个实现,socket即是一种特殊的文件,一些socket函数就是对其进行的操作(读/写IO、打开、关闭),我们以下客户端获取服务端的时间的例子,来理解socket的使用过程:

服务器端代码

 

// 《unix网络编程》的公共头文件 #include "unp.h" #include int main(int argc, char **argv) { int listenfd, connfd; struct sockaddr_in servaddr; char buff[MAXLINE]; time_t ticks; // 创建socket套接字文件描述符 listenfd = Socket(AF_INET, SOCK_STREAM, 0); bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; // 将套接字绑定到所有可用的接口 // 注htol是主机序转网络字节序,请百度了解 servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(13); // 绑定该socket和地址 Bind(listenfd, (SA *) &servaddr, sizeof(servaddr)); // 服务器开始监听这个端口上(创建监听队列) Listen(listenfd, LISTENQ); // 服务器处理代码 for ( ; ; ) { // 从监听队列中,取出一个客户端连接 connfd = Accept(listenfd, (SA *) NULL, NULL); ticks = time(NULL); snprintf(buff, sizeof(buff), "%.24s\r\n", ctime(&ticks)); Write(connfd, buff, strlen(buff)); Close(connfd); } }

 

 

客户端程序

 

// 《unix网络编程》的公共头文件 #include "unp.h" int main(int argc, char **argv) { int sockfd, n; char recvline[MAXLINE + 1]; struct sockaddr_in servaddr; if (argc != 2) err_quit("usage: a.out "); // 创建客户端socket if ( (sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) err_sys("socket error"); ​ bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_port = htons(13); /* daytime server */ if (inet_pton(AF_INET, argv[1], &servaddr.sin_addr) 0) { recvline[n] = 0; /* null terminate */ if (fputs(recvline, stdout) == EOF) err_sys("fputs error"); } if (n < 0) err_sys("read error"); exit(0); }

 

 

TCP服务器与TCP客户端的工作流程见下:

进一步了解socket可以参考。

试想,如果有多个客户端都想connect服务器,那么服务器如何对这些客户端进行处理?这就需要介绍一下IO复用。

IO复用是什么?

IO复用指的是在单个进程中通过记录跟踪每一个Socket(I/O流)的状态来同时管理多个I/O流. 发明它的原因,是尽量多的提高服务器的吞吐能力,参考链接。

如上文所说,当多个客户端与服务器连接时,这就涉及如何“同时”给每个客户端提供服务的问题。服务器的基本框架如下:

图中的逻辑单元,就是上例中“写入服务器时间”这一功能。要解决多客户端连接的问题,首先得有一个队列来对这个连接请求进行排序存放,而后需要通过并发多线程的手段对已连接的客户进行应答处理。

本项目是利用epollIO复用技术实现对监听socket(listenfd)和连接socket(客户请求连接之后的socket)的同时监听。注意I/O复用虽然可以同时监听多个文件描述符,但是它本身是阻塞的,所以为提高效率,这部分通过线程池来实现并发,为每个就绪的文件描述符分配一个逻辑单元(线程)来处理。

Unix有五种基本的IO模型:

阻塞式IO(守株待兔)非阻塞式IO(没有就返回,直到有,其实是一种轮询(polling)操作)IO复用(select、poll等,使系统阻塞在select或poll调用上,而不是真正的IO系统调用(如recvfrom),等待select返回可读才调用IO系统,其优势就在于可以等待多个描述符就位)信号驱动式IO(sigio,即利用信号处理函数来通知数据已完备且不阻塞主进程)异步IO(posix的aio_系列函数,与信号驱动的区别在于,信号驱动是内核告诉我们何时可以进行IO,而后者是内核通知何时IO操作已完成)

对于到来的IO事件(或是其他的信号/定时事件),又有两种事件处理模式:

Reactor模式:要求主线程(I/O处理单元)只负责监听文件描述符上是否有事件发生(可读、可写),若有,则立即通知工作线程,将socket可读可写事件放入请求队列,读写数据、接受新连接及处理客户请求均在工作线程中完成。(需要区别读和写事件)Proactor模式:主线程和内核负责处理读写数据、接受新连接等I/O操作,工作线程仅负责业务逻辑(给予相应的返回url),如处理客户请求。

通常使用同步I/O模型(如epoll_wait)实现Reactor,使用异步I/O(如aio_read和aio_write)实现Proactor,但是异步IO并不成熟,本项目中使用同步IO模拟proactor模式。有关这一部分的进一步介绍请参考第四章、线程池。

PS:什么是同步I/O,什么是异步I/O呢?

同步(阻塞)I/O:等待IO操作完成,才能继续进行下一步操作。这种情况称为同步IO。异步(非阻塞)I/O:当代码执行IO操作时,它只发出IO指令,并不等待IO结果,然后就去执行其他代码了。一段时间后,当IO返回结果时(内核已经完成数据拷贝),再通知CPU进行处理。(异步操作的潜台词就是你先做,我去忙其他的,你好了再叫我)

IO复用需要借助select/poll/epoll,本项目之所以采用epoll,参考问题(Why is epoll faster than select?)

对于select和poll来说,所有文件描述符都是在用户态被加入其文件描述符集合的,每次调用都需要将整个集合拷贝到内核态;epoll则将整个文件描述符集合维护在内核态,每次添加文件描述符的时候都需要执行一个系统调用。系统调用的开销是很大的,而且在有很多短期活跃连接的情况下,epoll可能会慢于select和poll由于这些大量的系统调用开销。select使用线性表描述文件描述符集合,文件描述符有上限;poll使用链表来描述;epoll底层通过红黑树来描述,并且维护一个ready list,将事件表中已经就绪的事件添加到这里,在使用epoll_wait调用时,仅观察这个list中有没有数据即可。select和poll的最大开销来自内核判断是否有文件描述符就绪这一过程:每次执行select或poll调用时,它们会采用遍历的方式,遍历整个文件描述符集合去判断各个文件描述符是否有活动;epoll则不需要去以这种方式检查,当有活动产生时,会自动触发epoll回调函数通知epoll文件描述符,然后内核将这些就绪的文件描述符放到之前提到的ready list中等待epoll_wait调用后被处理。select和poll都只能工作在相对低效的LT模式下,而epoll同时支持LT和ET模式。综上,当监测的fd数量较小,且各个fd都很活跃的情况下,建议使用select和poll;当监听的fd数量较多,且单位时间仅部分fd活跃的情况下,使用epoll会明显提升性能。

其中提到的LT与ET是什么意思?

LT是指电平触发(level trigger),当IO事件就绪时,内核会一直通知,直到该IO事件被处理;ET是指边沿触发(Edge trigger),当IO事件就绪时,内核只会通知一次,如果在这次没有及时处理,该IO事件就丢失了。 什么是多线程?

上文提到了并发多线程,在计算机中程序是作为一个进程存在的,线程是对进程的进一步划分,即在一个进程中可以有多个不同的代码执行路径。相对于进程而言,线程不需要操作系统为其分配资源,因为它的资源就在进程中,并且线程的创建和销毁相比于进程小得多,所以多线程程序效率较高。

但是在服务器项目中,如果频繁地创建/销毁线程也是不可取的,这就引入了线程池技术,即提前创建一批线程,当有任务需要执行时,就从线程池中选一个线程来进行任务的执行,任务执行完毕之后,再将该线程丢进线程池中,以等待后续的任务。

关于这部分的详细介绍可以参考:多线程与并发。

二、项目学习

完成了基础知识的了解之后,现在就来进行项目代码的学习,这就有一个问题了,究竟,怎样才算是看懂了一个开源项目?把所有代码都复现一遍?

如果真是复现一遍,性价比太小了。如果这个开源项目是工作需要,或者说就是在它的基础上进行修改,那么对其代码整体进行浏览是必不可少的。但若是只是为了学习这个项目的架构和思想,那么从整体入手,细究某一个功能,再瞄准感兴趣的代码块就可以了。

对于本文的服务器项目,笔者主要是为了学习web服务器的相关知识,不需要全部了解,但是大部分代码都得理清脉络,于是我就采用了这种方式来学习:

代码架构,每一个目录负责什么模块(这个部分可以结合开源项目的文档,可以加快对项目的理解速度)编译运行,看看有什么功能;挑某一个功能,细究其代码实现,我就先挑“用户登录注册”功能来进行研究,再考虑其他的功能;添加功能,如何在现有的框架下增加一个功能?比如上传文件、上传博客等等?添加留言板?未完......

ok,学习路线规划好了,下面就开始代码学习之旅!

代码架构

用VsCode打开项目,该项目的代码架构如下:

参考文档,该项目的代码框架如下:

编译运行

安装Mysql、创建数据库、修改代码,编译,运行:

 

sh ./build.sh ./server // 打开浏览器 localhost:9006

 

 

浏览器显示如下:

点击新用户,注册一个账号之后再登录,有一下三个功能:

分别是网页上展示一个图片/视频/微信公众号。

通过阅读代码框架和运行逻辑,先给出一个服务器运行时工作流程图如下:

所有功能我最感兴趣的还是登录注册功能,去看看如何实现的。

功能细究

关于登录功能,页面跳转逻辑如下图所示,原图来自两猿社:

上图的逻辑已经很清晰,根据HTTP请求的方法是GET还是POST,确定是获取注册/登录用户界面,还是更新用户密码跳转到登录成功界面。有关HTTP部分的介绍参考三、拔萝带泥-HTTP。

具体一点,首先需要从数据库中获取所有的用户名和密码(PS:在实际的大型项目中用户密码的传输可以参考用户登录实践),这些用户名和密码以某种数据结构(如哈希表)保存。

当浏览器请求到达时,根据其请求访问,返回对应的界面html或是错误提示。

整个过程其实是一个有限状态机。有限状态机?

有限状态机就是指系统状态从某一种状态转移到另外一种状态,表示“选择”和“更新状态”的过程。想进一步了解请参考:有限状态机?

由于该功能内部细节太多,请跳转阅读第三章、拔萝带泥-HTTP。

 

三、拔萝带泥——HTTP

这个部分是对第二章登录注册功能的详细解析。首先介绍Epoll的使用,再介绍HTTP的相关知识,而后在给出“用户登录注册”过程的细节。

Epoll

这个部分主要介绍epoll的函数调用框架,先看看epoll常用的函数。

常用函数

epoll_create

 

//创建一个指示epoll内核事件表的文件描述符 //该描述符将用作其他epoll系统调用的第一个参数 //size不起作用。 int epoll_create(int size)

 

 

epoll_ctl

 

//操作内核事件表监控的文件描述符上的事件:注册、修改、删除 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)

 

 

其中,epfd:为epoll_creat的句柄

op:表示动作,用3个宏来表示:

EPOLL_CTL_ADD (注册新的fd到epfd),EPOLL_CTL_MOD (修改已经注册的fd的监听事件),EPOLL_CTL_DEL (从epfd删除一个fd);

event:告诉内核需要监听的事件

event结构体定义如下:

 

struct epoll_event { __uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ 4};

 

 

events描述事件类型,其中epoll事件类型有以下几种

EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭)EPOLLOUT:表示对应的文件描述符可以写EPOLLPRI:表示对应的文件描述符有紧急的数据可读EPOLLERR:表示对应的文件描述符发生错误EPOLLHUP:表示对应的文件描述符被挂断;EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)而言的EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里EPOLLET: 边缘触发模式EPOLLRDHUP:表示读关闭,对端关闭,不是所有的内核版本都支持;

epoll_wait

 

//该函数用于等待所监控文件描述符上有事件的产生 //返回就绪的文件描述符个数 int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)

 

 

其中,

events:用来存内核得到事件的集合,maxevents:告之内核这个events有多大,不能大于epoll_create()时的size;timeout:是超时时间;返回值:成功返回有多少文件描述符就绪,时间到时返回0,出错返回-1; 例子

实际应用中,epoll是怎么起作用的?代码原链接。

 

//tcp server epoll并发服务器 #include #include #include #include #include #include #include #include #include #include #define MAX_LINK_NUM 128 #define SERV_PORT 8888 #define BUFF_LENGTH 320 #define MAX_EVENTS 5 ​ int count = 0; int tcp_epoll_server_init(){ //创建服务器端口的常用套路代码 int sockfd = socket(AF_INET,SOCK_STREAM,0); if(sockfd == -1){ printf("socket error!\n"); return -1; } struct sockaddr_in serv_addr; struct sockaddr_in clit_addr; socklen_t clit_len; serv_addr.sin_family = AF_INET; serv_addr.sin_port = htons(SERV_PORT); serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);// 任意本地ip int ret = bind(sockfd,(struct sockaddr*)&serv_addr,sizeof(serv_addr)); if(ret == -1){ printf("bind error!\n"); return -2; } listen(sockfd,MAX_LINK_NUM); ​ //创建epoll int epoll_fd = epoll_create(MAX_EVENTS); if(epoll_fd == -1){ printf("epoll_create error!\n"); return -3; } //向epoll注册sockfd监听事件 struct epoll_event ev; //epoll事件结构体 struct epoll_event events[MAX_EVENTS]; //事件监听队列 ev.events = EPOLLIN; ev.data.fd = sockfd; int ret2 = epoll_ctl(epoll_fd,EPOLL_CTL_ADD,sockfd,&ev); if(ret2 == -1){ printf("epoll_ctl error!\n"); return -4; } int connfd = 0; while(1){ //epoll等待事件发生 int nfds = epoll_wait(epoll_fd,events,MAX_EVENTS,-1); if(nfds == -1){ printf("epoll_wait error!\n"); return -5; } printf("nfds: %d\n",nfds); //检测 for(int i = 0;i


【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3