一、服务端开发
首先,我们先对服务端进行开发,服务端这边主要的功能模块就是要实现上传、下载、页面展示的业务,并且要管理好上传的文件,所以这个过程会涉及大量的文件操作,所以最开始实现的模块是工具类,关于可能用到的文件操作先封装成接口,然后还有关于序列化的内容,然后是关于基本的配置信息,我们用配置文件去处理,设计单例模式的类进行管理,然后就是数据管理类,热点管理类以及最后的业务处理。
Gitee链接:陈恒康/云备份项目代码
1. 工具类
1.1 文件操作工具类
首先我们先实现文件操作相关的类,文件操作包括:
获取文件的基本信息(文件大小、最后一次修改时间、最后一次访问时间、文件名称、文件内容),对文件内容的操作(向文件写入数据,压缩和解压),检测是否存在该文件,关于目录遍历的操作(这个是为客户端遍历目录下所有文件,并获得文件名的操作做准备)
1.2 序列化工具类
序列化的操作我们借用Json库去完成,而Json库提供的使用方法较为繁琐麻烦,所以我们将序列化操作和反序列操作进行一个封装,接口的设计和实现如下:
那序列化举例,序列化需要先创建一个StreamWriterBuilder对象,然后用智能指针的方式,用StreamWriterBuilder对象内提供的方法去创建一个StreamWriter对象,然后用该对象内提供的方法进行序列化,同样的,反序列化同样如此。
关于对命名的理解,个人理解是因为序列化的操作是我在网络上需要对你进行信息的传递,就是说我需要向你发出某个信息,那我就是Writer(写者),而我要在网络上传输,则需要先将我的信息进行序列化(转化成字符串),而接收方作为Reader(读者),需要将信息进行反序列化才能拿到我原本想要传达的内容。
2. 配置文件类
配置文件类是用于管理配置文件内的数据,而配置文件内的数据通常是一些不涉及核心业务逻辑代码,主要是为了代码的运行提供必要的环境设置和资源定位,例如,服务器监听的端口号就是一种运行环境的设置,这些非代码逻辑相关的信息放在配置文件中,与代码解耦合,可以使得代码更加清晰,一旦需要修改则不要在所有出现过的地方都进行调整修改,而只需要改变配置文件,而且对应一些安全要求的保护,也可以对配置文件的信息进行专门的保护,以下是该项目的配置文件信息:
上面涉及到了:热点管理的时间,端口号,IP地址,下载请求的path路径前缀,压缩后缀,压缩文件存放的文件夹,备份文件存放的文件夹路径,备份文件信息的日志。
配置文件采用了Json的格式,方便我们在代码中获取时,可以使用Json的接口去读取到相关的数据,具体的文件配置类如下:
3. 数据管理模块
数据管理模块是为了将上传备份到服务器上的备份文件统一管理起来,根据“先描述,再组织”的原则,我们先选择需要哪些文件属性去更好的帮助我们在该项目中,去描述一个备份的文件
1. 判断该备份文件是否被压缩,这在客户端下载时,是否需要先解压起到关键信息的作用
2. 文件大小,最后一次访问时间,最后一次修改时间,这些基本信息在后续写热点管理模块时候,以及断点续传模块时需要用到
3. 实际的文件储存路径,压缩后的压缩包存放路径,url的下载路径,这三个信息能够让我们找到文件内容存放的位置
然后就是提供一个接口,只需要给文件名称,就可以帮我们自动获取到这些基本信息,并且完成初始化,得到一个该文件的文件属性信息
当描述好改文件信息结构体后,我们就需要将这个文件信息块给组织起来,我们要采用每个容器去管理这些信息,而我们考虑到在每次下载某个文件时,需要从大量文件中,找到我们需要的文件,所以,自然用哈希表的方式去存储是最合适的,我们通过每个文件的url下载路径去和文件属性块关联映射哈希表的方式,进行数据管理,然后还需要考虑到这个表有可能存在并发访问的问题,因为有可能多个进程同时发出下载请求,所以我们需要加上一把读写锁去保证安全,最后就是可持久化存储的考虑,我们需要将表的信息用文件的方式去存储起来,这样在服务器被意外关闭等情况下,不会将数据丢失,也就是我们需要设计成员函数,首先是保证每次表修改后,实时更新到表的备份信息文件中,然后就是需要再每次数据库重启时,将表的数据重新加载,大体如下:
4. 热点管理模块
热点管理模块的任务是将备份文件所在目录下的文件进行一个轮询,不断地检测其中的文件是否为热点文件,如果检测到非热点文件,则将文件进行压缩打包到压缩包文件存放的目录下,设计该模块的目的是为了节省服务器的空间资源,这个模块会单独用一个线程去运行
设计思路:
首先我们需要用到的成员有备份文件所在的路径,压缩包文件所在的路径,压缩后的后缀,热点时间的临界值,这些可以设置为类成员,然后主要的业务逻辑是:
1. 首先获取到备份文件目录下的所有文件名称
2. 遍历每一个文件,去根据最后一次文件访问时间判断是否属于热点文件
3. 对非热点文件进行压缩处理,压缩好后需要将原先文件进行删除,并且修改备份文件数据库里的内容,例如是否被压缩的标志位得修改。
4. 将上述操作不断循环执行,时刻检查着是否有热点文件变成非热点文件
这里附上源码辅助理解
#pragma once
#include <unistd.h>
#include "data.hpp"
extern DataManager* _data;
class HotManager
{
private:
std::string _back_dir; //备份文件所在的目录
std::string _pack_dir; //压缩文件所在的目录
std::string _pack_suffix; //压缩后缀
int _hot_time; //热点时间:当文件未被访问的时间超过该时间时,则认为该文件为非热点文件
private:
bool IsHotFile(const std::string& filename) //判断是否是热点文件
{
FileUtil fu(filename);
time_t last_atime = fu.LastATime();
time_t cur_atime = time(NULL);//当前时间
if(cur_atime - last_atime > _hot_time)
{
return false;
}
return true;
}
public:
HotManager() // 构造函数,从配置文件中获取信息
{
Config* config = Config::GetInstance();
_back_dir = config->GetBackDir();
_pack_dir = config->GetPackDir();
_pack_suffix = config->GetPackFileSuffix();
_hot_time = config->GetHotTime();
// 避免备份文件目录和压缩文件目录不存在导致错误,当不存在目录时,则创建
FileUtil fu1(_back_dir);
FileUtil fu2(_pack_dir);
fu1.CreateDirectory();
fu2.CreateDirectory();
}
bool RunModule()
{
while(1)
{
//1. 遍历备份目录,获取所有文件名
FileUtil fu(_back_dir);
std::vector<std::string> arry;
fu.ScanDirectory(&arry);
//2. 遍历文件的最后一次访问时间,判断是否是非热点文件
for(auto& a: arry)
{
if(IsHotFile(a))
{
continue;
}
// 非热点文件则进行压缩处理
BackupInfo bi;//先把原本文件属性信息提取出来
if(_data->GetOneByRealPath(a,&bi) == false)
{
bi.NewBackupInfo(a);//没有就创建
}
//压缩
FileUtil tmp(a);
tmp.Compress(bi.pack_path);
// 压缩好后,删除源文件,修改备用信息
tmp.Remove();
bi.pack_flag = true;
_data->Update(bi);
}
usleep(1000);//避免空目录死循环消耗CPU资源
}
return true;
}
~HotManager()
{}
};
5. 业务处理模块
业务处理这部分主要是服务端和客户端用http协议进行网络通信,并且服务端提供上传、页面展示、下载的核心业务,我们http协议的通信直接借用http库完成即可。
5.1 上传
我们需要提供客户端将文件上传的接口,这里面涉及到对http库的应用,http库中有个叫MultipartFormData的结构体,该结构是http协议中对文件的描述,其中成员包含:
content : 文件内容
content_type:文件类型
name:结构体标识符,这个name更多是这个结构体对象的一个标识,而不是文件
filename:文件名称
基于http协议的网络通信,文件就是用该结构进行组织传输的,在服务端,我们通过接口获取到文件信息,把文件内容拿到后,建立一个文件去将内容存到指定的目录中,这个过程就是上传。
static void Upload(const httplib::Request &req, httplib::Response &rsp) //上传
{
// 1. 先检查是否有文件被上传
auto ret = req.has_file("file");
if (ret == false)
{
rsp.status = 400;
return;
}
// 2. 通过req的接口获取上传文件的属性:文件名称 文件数据
const auto &file = req.get_file_value("file");
// 3. 将文件内容写入文件中,并且添加该文件的文件属性到数据管理模块
std::string back_dir = Config::GetInstance()->GetBackDir();
std::string realpath = back_dir + FileUtil(file.filename).FileName();
FileUtil fu(realpath);
fu.SetContent(file.content); // 创建文件并把上传的文件内容写入到备份文件中
BackupInfo info;
info.NewBackupInfo(realpath);
_data->Insert(info);
return;
}
5.2 页面展示
页面展示就是把上传到服务端的备份文件,那个进行以前端的方式,展示出来,这部分是前端的知识,所以没有过多的了解,我们重点不在这部分,所以只是网上随便找了份简陋的页面,附上文件基本信息(文件名 文件备份时间 文件大小等等),这部分不能直接做个页面,因为文件信息是动态改变的,思路很简单,就是把文件和想要展示的信息,直接拿到然后按前端的开发格式去输入即可。
static std::string TimetoStr(time_t t) // 将时间戳转换为年月日的格式
{
std::string tmp = std::ctime(&t);
return tmp;
}
static void ListShow(const httplib::Request &req, httplib::Response &rsp) //展示备份文件的列表
{
// 1. 获取到所有文件的备份信息
std::vector<BackupInfo> arry;
_data->GetAll(&arry);
// 2. 根据所有备份信息,去组织html,这部分属于前端知识
std::stringstream ss;
ss << "<html><head><title>Download</title></head>";
ss << "<body><h1>Download</h1><table>";
for (auto &a : arry)
{
ss << "<tr>";
std::string filename = FileUtil(a.real_path).FileName();
ss << "<td><a href='" << a.url << "'>" << filename << "</a></td>";
ss << "<td align='right'>" << TimetoStr(a.mtime) << "</td>";
ss << "<td align='right'>" << a.fsize / 1024 << "k</td>";
ss << "</tr>";
}
ss << "</table></body></html>";
rsp.body = ss.str();
rsp.set_header("Content-Type", "text/html");
rsp.status = 200;
return;
}
5.3 下载
下载就是把服务端的文件内容,交给客户端,我们根据客户端request的唯一路径去找到指定的文件,然后由于我们存在热点管理,所以还需要判断是否需要解压,获取到文件内容后再将文件内容设置给response即可。
其中我们还有一个断点续传的机制:
首先我们如果服务器支持断点续传的功能,我们需要在response中设置头部字段"Accept-Ranges : bytes",表示支持。然后在第一次收到下载请求时,客户端的response的头部字段中还包含着Etag字段,表示该文件的唯一标识,其中Etag可以加上最后一次修改文件的时间作为标识一部分,然后再客户端受到response后,执行下载任务,如果此时因为一些异常原因,下载中断,则当再次发出请求时,会设置一个头部字段"If-Range : Etag",所以此时我在服务端只需要检测是否存在有"If-Range"的头部字段,就可以判断该下载请求是否需要断点续传,但是还需要考虑,如果在第二次申请下载任务期间,原先需要的文件修改了,则我们认为不能进行断点续传,而是需要完整的将新的版本全部重新下载,所以一般Etag的字段中带有最后一次修改时间,就方便在第二次发送下载请求到服务端的时候,进行比较判断文件是否修改过,当满足断点续传的条件后,我们根据第二次的response下载请求中,"Range"头部字段去得到我们这次需要下载的区间信息,然后再根据该期间信息将文件内容给到客户端,状态码设置成206,由于http库中提供了断点续传的方法,也就是说,只要我们表明服务器支持断点续传,并且我们只需要做好判断是否需要且能够进行断点续传的情况,接下来获取"Range"中的区间信息,并且将文件截取区间内容返回的部分在http库内会处理,所以在代码上就会方便很多,但是其中原理需要知道。
参考代码如下:
static std::string GetETag(const BackupInfo &info) //获取唯一标识Etag
{
// 我们规定唯一标识etag的格式为: filename-fsize-mtime
FileUtil fu(info.real_path);
std::string etag = fu.FileName();
etag += "-";
etag += std::to_string(info.fsize);
etag += "-";
etag += std::to_string(info.mtime);
return etag;
}
static void Download(const httplib::Request &req, httplib::Response &rsp) //下载业务
{
// 1. 获取文件属性数据
BackupInfo info;
_data->GetOneByURL(req.path, &info);
// 2. 判断是否被压缩,若是被压缩则需要解压
if (info.pack_flag == true)
{
FileUtil fu(info.pack_path);
fu.UnCompress(info.real_path);
fu.Remove(); // 删除压缩包
info.pack_flag = false;
_data->Update(info);
}
// 3. 到这里确保可以拿到文件内容了,还需要再判断是否是断点续传的情况
bool retrans = false; // 标识是否需要进行断点续传
std::string etag = GetETag(info); // 获取唯一标识
if (req.has_header("If-Range")) // If-Range是否需要断点续传
{
// 如果存在断点续传的情况,则进一步判断上一次的etag和这次的是否一致(文件是否被修改)
if (req.get_header_value("If-Range") == etag) // 这里判断是能不能继续断点续传,如果文件已经被修改了,则不能
{
retrans = true; // 若是需要断点续传,则将标志位置为true
}
}
// 4. 最后将文件内容和各种头部字段,状态码等填入response
// 根据是否是断点续传的情况,将分类进行执行,但由于http内部会对断线续传进行处理
// 所以在代码上剩下了很多麻烦,但思路要明白
// 断点续传是通过客户端res中的"Range"字段信息,得到我们需要截取的区间信息
// 然后再body中取出区间信息响应给客户端,状态码为206
FileUtil fu(info.real_path);
if (retrans == false)
{
fu.GetContent(&rsp.body);
// 设置头部字段:Etag、Accept-Ranges、Content-Type、status
rsp.set_header("Accept-Ranges", "bytes"); // 支持断点续传
rsp.set_header("Etag", etag); // 唯一标识
rsp.set_header("Content-Type", "application/octet-stream"); // 表示该请求是下载,以二进制形式展示
rsp.status = 200;
}
else
{
fu.GetContent(&rsp.body);
// 设置头部字段:Etag、Accept-Ranges、Content-Type、status
rsp.set_header("Accept-Ranges", "bytes"); // 支持断点续传
rsp.set_header("Etag", etag); // 唯一标识
rsp.set_header("Content-Type", "application/octet-stream"); // 表示该请求是下载,以二进制形式展示
rsp.status = 206;
}
}
二、客户端开发
客户端的开放,我们选择在Windows系统下开放,主要的业务就是将目录下的文件都进行备份,并且向服务端进行http协议的通信,上传文件等等。
1. 工具类
工具类实际上也就是对于文件操作和Json序列化的操作,直接将服务端的工具类拷贝即可,当然也有地方要注意的是,在Windows系统下,文件分隔符是不一样的,在我们使用到文件分割的部分要注意,例如:
2. 数据管理类
数据管理部分主要管理的就是备份文件夹内的数据,由于客户端任务比较简单,所以我们不需要那么多文件属性,直接建立表格,将文件的真实路径和文件唯一标识建立哈希映射,文件标识中包含有文件最后一次修改的时间(这是为了方便在业务逻辑中判断该文件是否需要重新备份上传),
#pragma once
#include<unordered_map>
#include<sstream>
#include"Util.hpp"
class DataManager
{
private:
std::string _backup_file;//可持续化存储文件
std::unordered_map<std::string, std::string> _table;
public:
DataManager(std::string backup_file):_backup_file(backup_file)
{
InitLoad();
}
bool Storage()//更新备份文件信息
{
std::stringstream ss;
auto it = _table.begin();
for (; it != _table.end(); it++)
{
// 规定格式是每行一个key value,中间空格隔开
ss << it->first << " " << it->second << "\n";
}
FileUtil fu(_backup_file);
fu.SetContent(ss.str());
return true;
}
// 这里写一个字符串分割的函数
int Split(const std::string& str, const std::string& sep, std::vector<std::string>* arry)
{
int count = 0;
size_t pos = 0, idx = 0;//pos表示分隔符的位置,idx表示从什么位置开始寻找分割符
while (1)
{
pos = str.find(sep, idx);
if (pos == std::string::npos)
{
break;
}
if (pos == idx)//出现了连续的sep
{
idx = pos + sep.size();
continue;
}
std::string tmp = str.substr(idx, pos - idx);
arry->push_back(tmp);
count++;
idx = pos + sep.size();
}
if (idx < str.size())// abc nss ss,这里是把最后一段字符切割到
{
arry->push_back(str.substr(idx));
count++;
}
return count;
}
bool InitLoad()
{
FileUtil fu(_backup_file);
std::string body;
fu.GetContent(&body);
std::vector<std::string> arry;
Split(body, "\n", &arry);
for (auto& a : arry)
{
std::vector<std::string> tmp;
Split(a, " ", &tmp);
if (tmp.size() != 2)
{
continue;
}
_table[tmp[0]] = tmp[1];
}
return true;
}
bool Insert(const std::string& key, const std::string& val)
{
_table[key] = val;
Storage();
return true;
}
bool Update(const std::string& key, const std::string& val)
{
_table[key] = val;
Storage();
return true;
}
bool GetOneByKey(const std::string& key, std::string* val)
{
auto it = _table.find(key);
if (it == _table.end())
{
return false;
}
*val = it->second;
return true;
}
};
3. 业务处理类
业务处理部分的核心逻辑很简单:
- 获取到文件夹中的所有文件
- 将所有的文件进行检查,是否需要备份
- 在文件数据管理中没出现的新文件需要备份
- 被修改过的文件需要备份
- 当找到需要备份的文件,则对文件进行上传
- 添加文件数据管理信息
#pragma once
#include"data.h"
#include"httplib.h"
#include<Windows.h>
#define SERVER_ADDR "43.136.108.7"
#define SERVER_PORT 9090
class Backup
{
private:
std::string _back_dir;
DataManager* _data;
public:
Backup(const std::string& back_dir, const std::string& back_file) :_back_dir(back_dir)
{
_data = new DataManager(back_file);
}
std::string GetFileIdentifier(const std::string& filename)//获取文件的唯一标识:filename-filesize-filelastMtime
{
FileUtil fu(filename);
std::stringstream ss;
ss << fu.FileName() << "-" << fu.FileSize() << "-" << fu.LastMTime();
return ss.str();
}
bool Upload(const std::string& filename) //上传文件
{
//1. 获取文件内容
FileUtil fu(filename);
std::string body;
fu.GetContent(&body);
//2. 构造上传文件的MultipartFormData
httplib::Client client(SERVER_ADDR, SERVER_PORT);
httplib::MultipartFormData item;
item.content = body;
item.content_type = "application/octet-stream";
item.filename = filename;
item.name = "file";//这是与服务端约定好的文件上传字段
httplib::MultipartFormDataItems items;
items.push_back(item);
//3. 上传
auto res = client.Post("/upload", items);
if (!res || res->status != 200)
{
return false;
}
return true;
}
bool IsNeedUpload(const std::string& filename) //判断是否需要上传
{
std::string id;
if (_data->GetOneByKey(filename, &id) != false)
{
std::string new_id = GetFileIdentifier(filename);
if (new_id == id)
{
return false;//表示不需要再次备份
}
}
//考虑到有可能在上传一个较大的文件时,时不时文件就会被修改
//所以这里每次修改时间间隔不超过三秒的,也认为目前是正在修改中,且暂时不需要备份的文件
FileUtil fu(filename);
if (time(NULL) - fu.LastMTime() < 3)
{
return false;
}
std::cout << filename << " need upload!\n";
return true;
}
bool RunModule()
{
while (1)
{
//1.先是遍历一下整个目录的文件
FileUtil fu(_back_dir);
std::vector<std::string> arry;
fu.ScanDirectory(&arry);
//2.检查每一个文件是否需要备份
for (auto& a : arry)
{
if (IsNeedUpload(a) == false)
{
continue;
}
//3.将需要备份的文件上传并修改(添加)备份信息
if (Upload(a) == true)
{
_data->Insert(a, GetFileIdentifier(a));
std::cout << a << " upload success!\n";
}
}
Sleep(1);
}
}
};
三、项目总结
项⽬名称:云备份系统
项⽬功能:搭建云备份服务器与客⼾端,客⼾端程序运⾏在客⼾机上⾃动将指定⽬录下的⽂件备份到服务器,并且能够⽀持浏览器查看与下载,其中下载⽀持断点续传功能,并且服务器端对备份的⽂件进⾏热点管理,将⻓时间⽆访问⽂件进⾏压缩存储。开发环境: centos7.6/vim、g++、gdb、makefile 以及 windows10/vs2017
技术特点: http 客⼾端/服务器搭建, json 序列化,⽂件压缩,热点管理,断点续传,线程池, 读写锁,单例模式
项⽬模块:
1. 服务端:
a. 数据管理模块:内存中使⽤hash表存储提⾼访问效率,持久化使⽤⽂件存储管理备份数据
b. 业务处理模块:搭建 http 服务器与客⼾端进⾏通信处理客⼾端的上传,下载,查看请求,并⽀持断点续传
c. 热点管理模块:对备份的⽂件进⾏热点管理,将⻓时间⽆访问⽂件进⾏压缩存储,节省磁盘空 间。
2. 客⼾端
a. 数据管理模块:内存中使⽤hash表存储提⾼访问效率,持久化使⽤⽂件存储管理备份数据
b. ⽂件检索模块:基于 c++17 ⽂件系统库,遍历获取指定⽂件夹下所有⽂件。
c. ⽂件备份模块:搭建 http 客⼾端上传备份⽂件。
四、项目拓展
1. 给客⼾端开发⼀个好看的界⾯,让监控⽬录可以选择
2. 内存中的管理的数据也可以采⽤热点管理
3. 压缩模块也可以使⽤线程池实现
4. 实现⽤⼾管理,不同的⽤⼾分⽂件夹存储以及查看
5. 实现断点上传
6. 客⼾端限速,收费则放开