前言:
迷你网盘是一个小项目,本质上是一个http服务器,在这个http服务器汇总实现文件上传,下载,展示功能。在下载功能中使用http协议拓展出断点续传输、分块传输功能。最终实现出一个迷你网盘。首先要搭建好一个TCP服务器,然后在服务器内创建一个线程池来处理服务器接收到的请求,当收到请求时,首先就是要把请求的内容用一个类保存起来,这个类中有一个成员变量是stat结构体,用来存放请求文件的文件信息。让后分析这个请求的内容,辨别这是一个什么类型的请求。请求一共有三类:文件列表请求,文件下载请求和文件上传请求。
文件上传请求:客户端请求上传文件,客户端的请求是GET方式并且查询字符串不为空,或者请求方式是POST就代表这是一个文件上传请求,此时利用CGI技术启动一个进程来处理这个请求,这样即使上传失败也不会影响服务器的运行。将首行和请求头通过环境变量的方式传递给子进程,而正文数据则通过管道传递给子进程。
文件列表请求:列出该目录下的所有文件,利用stat结构体中的st_mode判断这个请求的文件是一个目录,就运用dirent结构体和scandir函数把该目录下的所有文件信息都放到一个dirent数组中,然后根据服务器设置的方法需要排好序,把dirent数组里面存放的是该目录下的每一个文件的信息,再将这些文件信息打包成一个个小包发送给客户端。
文件下载请求:客户端请求下载该文件。当请求的文件是一个文件时,则说明该请求是一个文件下载请求,首先判断是不是一个断点续传,如果是的话就执行断点续传的流程,如果不是则执行普通文件下载的流程。
目录
整体框架:
- 建立tcp服务端程序;
- 是为新的客户端建立线程池任务并添加到线程池中,因此我们还要创建一个线程池;
- 线程池中的线程获取任务;
- 接收http请求数据;
- 解析http请求数据;
- 判断请求类型;
1.HTTP服务端设计
- 创建socket
- 绑定地址信息
- 开始监听
- 获取已连接成功的客户端socket
- 服务端程序的业务处理--->创建线程任务,将任务添加到线程池中
HTTP服务端最重要的是搭建TCP服务器,至于我为什么要使用TCP协议而不是其他的协议的理由想必不需要多说了吧,TCP是现在使用最广泛的协议,我使用TCP不仅能够搭建一个效率可观,可靠的服务器,还能达到学习的目的。
搭建TCP服务器的时候有几个需要注意的地方:
- 创建套接字的时候,要地址复用,服务器挂掉之后需要立即重启,此时就需要地址复用,调用setsockopt可以达到这个功能,一般的调用方式:
int opt = 1;
setsockopt(_server_sock, SOL_SOCKET, SO_REUSEADDR, (char*)&opt, sizeof(opt));
- 绑定ip地址的时候,可以选择绑定这个局域网内所有的ip地址,因为有的服务器不止一张网卡,就有不同的ip地址
lst_addr.sin_addr.s_addr = htonl(INADDR_ANY);
- 让后开始监听,此时需要注意函数的第二个参数,全链接队列的个数,实际全连接个数=此参数+1
listen(_server_sock, 5);//此时全连接队列的个数就是6
- 还有一点,在服务器启动的时候,要忽略SIG_PIPE信号,至于为何如此,是因为项目中使用了CGI技术
signal(SIGPIPE, SIG_IGN);
此时启动HTTP服务器,就开始accept新连接,收到新连接时就将该连接放到线程池内,让线程池中的某个线程去处理这个请求。所以此处要设计一个线程池。
在服务器启动之后,头两件事情就是初始化HTTP,并且创建一个线程池。
2.线程池设计
- 创建线程
- 创建线程安全的任务队列--->任务入队,处理完毕后任务出队
- 线程池终止
2.1线程池的作用:
- 线程的创建和销毁都是有开销的,线程池的可以减少这些开销
- 线程池控制线程池的并发数可以有效的避免大量的线程池争夺CPU资源而造成堵塞
- 线程池可以提供定时、定期、单线程、并发数控制等功能
线程池设计中还应设计一个类,这个类叫做任务类,这个类就是到达的任务。成员变量只有两个,一个是accept到的套接字,另一个就是任务处理函数。
要实现线程安全的线程池,就需要使用互斥锁和条件变量,线程池里面的数量有上限,自己设置;线程创建出来之后要分离,创建线程池的时候同时要初始化好互斥锁和条件变量;析构函数要删除互斥锁和条件变量。
线程创建之后要去检测是否有任务,没有任务就去沉睡,当有任务来临时,就唤醒一个线程去执行任务。,因此线程池内还应该维护一条任务队列,用于存放到达的任务。
任务入队要上锁,
出队不需要上锁,因为出队是在线程接口中调用,但是线程接口在出队之前就会加锁。
3.HTTP请求处理
有任务(连接)来了,创建一个任务类,把这个任务任到任务队列中,添加到任务对列中后,记得要唤醒一个线程!!!
线程醒来之后去处理这个任务,处理任务是有一个专门的函数来处理,处理流程如下图所示:
3.1接收HTTP头部
设计出一个类(RequestInfo)来专门放接收到的HTTP数据,
http请求头格式:
- 首行
- 头部
- 空行
- 正文
要读取到头部,就要把首行和头部全部读取出来,此时我的做法是:把数据从缓存区中全部预先读取出来,放到事先定义好的数组tmp里面:
//预先读取,不从缓存区中把数据拿出来
int ret = recv(_cli_sock, tmp, MAX_HTTPHDR, MSG_PEEK);
再通过下面的函数,找到空行的位置:
char* ptr = strstr(tmp, "\r\n\r\n");
ptr-tmp就是头部+首行的长度:
通过下面的函数把头部+首行的内容放到RequestInfo的一个成员变量里面:
_http_header.assign(tmp, ptr - tmp);
接收HTTP数据之后,就要通过这个类来分析这是一个什么请求,根据不同的请求来做出不同的响应。
再通过下面的函数把正文放到tmp数组里面,此时会覆盖掉之前tmp里面的内容。
recv(_cli_sock, tmp, hdr_len + 4, 0);
3.2解析HTTP头部
上面接收的HTTP头部都放在了一个RequestInfo类的一个成员变量里面,解析起来还不简单嘛?
首行+头部信息都是以行为单位陈列的,故只需在出现“\r\n”的地方将其分割开就好了,第一个是首行,后面的全部是HTTP头部
首行:请求方法 URL 协议版本
首行是以空格分割的。
请求方法只有三种:GET 、POST 、 HEAD
URL:虚拟资源路径?查询字符串。所以此处要分两步解析,看有没有“?”
协议版本只有三种:HTTP/0.9 、HTTP/1.0 、 HTTP/1.1
URL中的虚拟路径的根目录是相对的,要将它转换成绝对的路径,需要调用函数:
realpath()
头部
头部的信息以 key: value 的形式存在,可以设计一个unordered_map来存放。
文件信息
调用stat函数将文件信息存到一个结构体中,第一个参数是文件路径,第二个参数是结构体。ls -l会调用stat函数
int stat(const char *file_name, struct stat *buf);
struct stat {
dev_t st_dev; //文件的设备编号
ino_t st_ino; //节点
mode_t st_mode; //文件的类型和存取的权限
nlink_t st_nlink; //连到该文件的硬连接数目,刚建立的文件值为1
uid_t st_uid; //用户ID
gid_t st_gid; //组ID
dev_t st_rdev; //(设备类型)若此文件为设备文件,则为其设备编号
off_t st_size; //文件字节数(文件大小)
unsigned long st_blksize; //块大小(文件系统的I/O 缓冲区大小)
unsigned long st_blocks; //块数
time_t st_atime; //最后一次访问时间
time_t st_mtime; //最后一次修改时间
time_t st_ctime; //最后一次改变时间(指属性)
};
请求类型有三种:首先判断是不是CGI,再来判断是不是文件列表。
- 文件列表请求 ,请求的是一个目录
- 文件下载请求, 请求的不是一个目录
- 文件上传请求(CGI),①请求方法是GET并且查询字符串不为空、②POST
因此响应也要有三种。
4.HTTP响应处理
4.1文件上传的CGI请求处理
如果请求是一个文件上传的请求,服务器的处理方法是创建一个子进程去处理。
在fork之前就要创建两个匿名管道,一个用来向子进程传递数据,另一个用来从子进程获取处理结果。在fork之前是因为子进程要共享父进程的数据。
父进程做的事
- 通过in管道传递正文数据给子进程
- 通过out管道读取子进程的处理结果直到返回0
- 将数据处理结果组织http数据,响应给客户端
子进程做的事
利用setenv函数给子进程设置环境变量把
- 请求方法
- 协议版本
- 资源路径
- 查询字符串
这四个数据传给子进程。这四者也就是首行解析出来的内容。再用同样的方式把请求头传给子进程。
再将那两个管道重定向,使子进程的标准输入变成父进程向子进程传递数据的管道,标准输出变成子进程向父进程传递数据的管道。如果子进程想打印数据的话,可以打印在标准错误上面。
最后就是进行程序替换了,使子进程去执行文件上传的程序代码。
文件上传程序
POST /upload HTTP/1.1
Host: 192.168.122.135:8080
Connection: keep-alive
Content-Length: 202
Cache-Control: max-age=0
Origin: http://192.168.122.135:8080
Upgrade-Insecure-Requests: 1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryBJP7FhV4yjQ3wgVo
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.109 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Referer: http://192.168.122.135:8080/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9------WebKitFormBoundaryBJP7FhV4yjQ3wgVo
Content-Disposition: form-data; name="fileupload"; filename="hello.txt"
Content-Type: text/plainhello world!!
------WebKitFormBoundaryBJP7FhV4yjQ3wgVo
Content-Disposition: form-data; name="anniu";
Content-Type: text/plain上传文件
------WebKitFormBoundaryBJP7FhV4yjQ3wgVo--
上述是服务器收到的请求。
文件上传程序我命名为upload。首行和请求报头的内容都在环境变量中放着。需要上传的文件数据从标准输入中获得(重定向)
利用getenv函数从环境变量中获取“Content-Length”和“Content-Type”字段,从而知道你所需要上传的文件大小和文件类型。
文件的boundary在Content-Type中,寻找“boundary=”字段,然后构造好下列三个boundary:
- _f_boundary = "--" + boundary;
- _m_boundary = "\r\n" + _f_boundry + "\r\n";
- _l_boundary = "\r\n" + _f_boundry + "--";
此时就可以从在管道里读取的正文中匹配boundary,如果有boundary的话,首先匹配到的是_f_boundary,
如果上传的不止一个文件,还会有_m_boundary,最后是_l_boundary。
检测到_f_boundary之后,就去后面匹配文件名,匹配到了就根据这个文件名创建文件,匹配不到则说明出错了。
如果检测到_m_boundary,同_f_boundary的操作一样。
直到遇到_l_boundary,则说明文件上传完毕。
4.2文件列表请求
判断是不是文件列表很简单,只需要一行代码就可搞定:
if(info._st.st_mode & S_IFDIR)
{
....
}
st_mode是stat结构体中的成员,表示文件的类型和存取的权限,如果上述if判断为真,就表示该文件为目录文件,就可说明该请求是文件列表请求。
文件列表请求的响应就把该目录下的文件信息展示出来。而且一访问这个服务器就是文件列表展示功能,所以这里还要写好网页的html,包括网页标题,html页面中的元信息,form表单等等。
这里再介绍一个结构体:dirent。为了获取某文件夹目录内容,所使用的结构体。起着一个索引的作用
#include <dirent.h> struct dirent { long d_ino; /* inode number 索引节点号 */ off_t d_off; /* offset to this dirent 在目录文件中的偏移 */ unsigned short d_reclen; /* length of this d_name 文件名长 */ unsigned char d_type; /* the type of d_name 文件类型 */ char d_name [NAME_MAX+1]; /* file name (null-terminated) 文件名,最长256字符 */ }
相关函数:
opendir(),readdir(),closedir();
通过readdir函数读取到的文件名存储在结构体dirent的d_name成员中,而函数int stat(const char *file_name, struct stat *buf);的作用就是获取文件名为d_name的文件的详细信息,存储在stat结构体中。stat函数在上面就有说明了。
还有一个关于文件操作的函数:scandir()
头文件:#include <dirent.h>
int scandir(const char *dir, struct dirent **namelist, nt (*select) (const struct dirent *), nt (*compar) (const struct dirent **, const struct dirent**));
函数说明:scandir()会扫描参数dir指定的目录文件,经由参数select指定的函数来挑选目录结构至参数namelist数组中,最后再调用参数compar指定的函数来排序namelist数组中的目录数据。每次从目录文件中读取一个目录结构后便将此结构传给参数select所指的函数, select函数若不想要将此目录结构复制到namelist数组就返回0,若select为空指针则代表选择所有的目录结构。scandir()会调用qsort()来排序数据,参数compar则为qsort()的参数,若是要排列目录名称字母则可使用alphasort(). 结构dirent定义请参考readdir()。
返回值 :成功则返回复制到namelist数组中的数据结构数目,有错误发生则返回-1。实际调用:
struct dirent** p_dirent = NULL; int num = scandir(info._path_phys.c_str(), &p_dirent, NULL, alphasort);
函数扫描info._path_phys.c_str()目录,将该目录下所有的目录结构复制到p_dirent数组中,调用alphasort函数来排序p_dirent数组中的目录数据(依字母顺序来排序)。
p_dirent数组里面存放的是该目录下的每一个文件的信息,再将这些文件信息打包成一个个小包发送给客户端。把所有文件的信息都发过去之后,
4.3文件下载请求
发送文件:while循环调用read函数,返回值大于0就继续,
判断了不是CGI请求,也不是文件列表请求之后,那么就只有文件下载请求了。
文件下载请求里面还有点花样呢,首先判断是不是断点续传,如果是的话就走断点续传的路子,如果不是就走普通文件下载的路子。
首先判断是不是断点续传,判断的方式是在接收到的HTTP头部中查找“If-Range”,如果没有这个字段肯定就不是断点续传了;如果找到了这个字段,还有进行后续的判断:查看最后一次修改的时间和是否被修改标志,如果在中途被修改了就要重新传输,如果If-Range字段值若是跟ETag值或者更新的日期时间匹配一致,那么就作为范围请求处理,也就是断点续传。
随后就是查找“Range”,没有range字段也不是断点续传。
断点续传
range.erase(range.begin(), range.begin() + 6);去掉“bytes=”
size_t pos = range.find("-");寻找“-”
Range: bytes=-500 这种情况代表只需传输最后500字节,pos = 0;
Range: bytes=500- 这种情况代表是500以后到文件最后,pos = range.size()-1;
Range: byte=200-500 这种情况代表传输从200-500的字节
找到你说需要响应的是哪些字节的数据,就可以着手构建响应报头了,
- 此时响应报头的这状态码不再是200 OK,而是206(断点续传) 协议版本 206 PARTIAL CONTENT
- 并且响应的头信息中需要有 Content-Range: bytes start-end/len,这个len就是你要传的字节数。
把响应头构建好并且发送过去之后,就要发送客户端要下载的文件数据了
首先以只读的方式打开这个文件,然后要调用一个函数lseek
off_t lseek(int fd, off_t offset, int whence);
lseek()函数会重新定位被打开文件的位移量,根据参数offset以及whence的组合来决定:
offset
为正则向文件末尾移动(向前移),为负数则向文件头部(向后移)。SEEK_SET:
从文件头部开始偏移offset个字节。
SEEK_CUR:
从文件当前读写的指针位置开始,增加offset个字节的偏移量。
SEEK_END:
文件偏移量设置为文件的大小加上偏移量字节。
发送数据的时候要注意一些细节:
假如:已经发送的数据量+你刚刚读取的数据量>需要发送的数据量
那么你就只需发送:需要发送的数据量-已经发送的数据量。其余情况就是你读了多少就发多少。
文件数据发送完毕之后要记得关闭文件描述符。
普通文件下载
普通文件下载要比断点续传简单,首先构建响应报头,发送完响应报头之后就以只读的方式打开一个文件,发送数据,发送完了关闭文件。
项目缺点:
- 分块传输这里,使用多线程传输可以速度更快。
- 客户端下载文件、上传文件优化:以多线程+http的分块传输实现。
- 压力测试:大概四五十客户端的时候下载文件速度达到二三百kb左右,硬件性能上不行,可以使用分布式。
- 传输大文件:我采用的是循环式的读取文件内容,每次读取都放到一个缓冲的buff当中,直到把文件读取完。这里的传输大文件是指文件太大,把服务器的磁盘写满,我们可以在每次服务器起来的时候,检测一下磁盘的剩余空间,每次上传文件之后都修改这个数据,可以设置一个阈值,当磁盘剩余空间已经少于这个阈值时,返回302状态码,提醒客户端请求其他的服务器。