前言
上一篇,我们实现了项目的部署,当部署完毕后,我们会发现云服务器的空间是有限的。本篇,我们将针对云服务器内存小、硬盘小的问题,对项目使用的存储空间进行优化,完成优化后,我们将继续开始管理员功能的开发。
内存优化
Keller 云笔记项目进行到现在,还没有到缓存和 Redis,Spring Boot、Vue.js 项目运行过程总占用的内存是比较固定的,暂时不用控制,需要注意的是 MySQL 使用的内存空间。
MySQL 索引
MySQL 占用内存的是表的索引:MySQL 在建表时如果不指定存储引擎,默认用的是 InnoDB,它的主键索引是聚集索引,其他索引是非聚集索引。
索引的数据结构使用的是 B+ 树,所有的数据均在 B+ 树的叶子节点中存储,示意图如下:
- 聚集索引:所有的数据都在 B+ 树的叶子节点存储,索引的结构即表的结构
- 非聚集索引:所有的叶子节点存储的是指向聚集索引的指针
MySQL 设置的索引页大小为 16384,也就是 16KB,每当创建一个索引,都会将索引的根节点放入内存,也就是多了 16KB 的内存开销。
查询索引页大小:
SHOW GLOBAL STATUS where Variable_name like 'Innodb_page_size';
查询结果:
查询数据表状态:
show table status from kyle_notes;
查询结果:
查询结果中 Index_length 字段表示该表索引大小,也就是索引占用内存的大小,结合数据表建表语句来看,索引建立越多的表,占用的内存越多。因此,在使用索引时要注意:
- 仅在必要的字段上创建索引
- 不必要的字段上创建索引,会浪费内存空间
- 可以创建联合索引的情况下就不创建几个单独的索引
- 使用联合索引比使用多个单独索引能节省内存空间
- 不要在高重复字段上创建索引
- 高重复字段上创建索引会降低数据表的查询效率,并且占用内存空间
单独来说,一个索引会占用 16KB 的内存,可能觉得不太多,如果在云服务器上,内存总大小可能也就 1GB,这一个个 16KB 也就显得弥足珍贵了。大家可以结合这些注意事项,来审视当前已经创建的表中,在创建索引时存在哪些问题。
磁盘空间优化
磁盘空间占用比较大的就是文件服务器,存储的是用户的头像和笔记中图片。拿笔者使用的阿里云服务器来说,磁盘空间共有 20GB,如果不断更换头像或者上传笔记中的图片,不出半天,也就将磁盘空间打爆了,因此需要对冗余的用户头像和笔记图片进行删除。
用户头像
对用户头像的优化的目标是:每个用户只会在文件服务器上存储一个头像,当上传新头像时会覆盖掉原有的头像。
为达到这个目标,对创建头像和更新头像的操作进行如优化:
- 创建头像
- 单独创建个文件夹存放所有用户的头像
- 每个用户根据用户 ID 生成一个固定的头像文件名
- 头像图片必须是固定格式的(如: PNG),如果用户上传的头像是其他格式,则将其转换为指定格式的图片
- 更新头像
- 先根据用户 ID 生成固定的文件名
- 判断用户的头像文件是否已经存在
- 如果存在,删除原头像,创建新头像
- 如果不存在,创建头像
这些操作均在服务端做改动,不涉及到 Web 端的修改,修改如下。
在 FileUtils.java 中添加方法 getPortraitName,用来生成固定的头像名称:
/**
* 获取头像文件名
* @param userId
* @return 文件名,不带后缀
*/
public static String getPortraitName(int userId){
return Md5Utils.getMd5String(PublicConstant.PORTRAIT_PREFIX + userId) ;
}
该方法会通过使用固定前缀加用户 ID 的方式生成统一的头像文件名,同时,使用 MD5 加密,隐藏文件名生成规则。
在 FileUtils.java 中添加方法 getPortraitPath,用来获取头像的保存路径:
/**
* 获取头像保存路径
* @param fileName 文件名,不带后缀
* @return 完整的头像保存路径
*/
public static String getPortraitPath(String fileName){
StringBuilder builder = new StringBuilder();
builder.append(PublicConstant.nginxPath)
.append(PublicConstant.portraitPath)
.append(PublicConstant.originImgPath);
if(mkdirs(builder.toString())){
builder.append(fileName)
.append(".")
.append(PublicConstant.PORTRAIT_TYPE);
return builder.toString();
}else {
return null;
}
}
该方法会构建一个图片保存的完整路径,同时指定头像使用固定的后缀名(PublicConstant.PORTRAIT_TYPE)。
新建 ImageUtils.java 处理图片的存储,添加方法 savePortrait,用于保存用户头像:
/**
* 保存用户头像
* 用户头像保存为固定的文件名,设置新的头像时,会覆盖掉原来的头像
* @param file
* @param userId
* @return
* @throws IOException
*/
public static String savePortrait(MultipartFile file, int userId) throws IOException{
//解析文件后缀名
String suffix = FileUtils.getSuffix(file.getOriginalFilename());
String fileName = FileUtils.getPortraitName(userId);
//保存原图
File img = new File(FileUtils.getPortraitPath(fileName));
if(img.exists()){
img.delete();
}
//保存缩略图
File thum = new File(FileUtils.getPortraitThumPath(fileName));
if(thum.exists()){
thum.delete();
}
//如果图片是 PNG 格式的,直接存储
if(PublicConstant.PORTRAIT_TYPE.equals(suffix)){
file.transferTo(img);
}else{
/*
将其他格式的图片转换成 PNG 格式
*/
Thumbnails.of(file.getInputStream()).scale(1).outputFormat(PublicConstant.PORTRAIT_TYPE).toFile(img);
}
//生成缩略图
Thumbnails.of(img).size(PublicConstant.THUM_MAX_WIDTH,PublicConstant.THUM_MAX_HEIGHT).toFile(thum);
return fileName;
}
该方法确保用户在文件服务器中只保存一个头像,并且使用固定的文件名称和后缀名。
笔记中的图片
对笔记图片的优化目标是:用户删除笔记时,从文件服务器中删除笔记中的全部图片;用户在编辑器中删除图片时,同步删除文件服务器中的图片。
为了达到这个目标,对相关操作做如下优化:
- 上传笔记中的图片
- 保存图片时,按照每个用户一个文件夹保存
- 图片名称加上笔记 ID 作为前缀,以便于确定所属的笔记
- 删除笔记
- 删除笔记时,遍历用户的文件夹,删除属于该笔记的所有图片
- 删除笔记中的图片
- 删除笔记中的图片时,根据图片名称,直接从文件服务器中删除掉该图片
- 难点在于:如何知道用户在编辑器中删除了图片
- mavon-editor 提供了删除图片的回调方法 imgDel,但只能在菜单栏上传图片按钮中触发,在编辑器中删除图片无法触发任何操作
- WangEditor 没有提供删除图片相关的回调
- 解决方法是在 Web 端出发保存笔记的操作时,通过比较修改前后的内容,判断出删除的图片
movan-editor 中可以出发 imgDel 回调的操作:
主要方法有:
在 FileUtils.java 中添加方法 getUserImgDirectory,用于获取用户文件目录。
/**
* 获取用户文件目录
* @param userId
* @return
*/
private static String getUserImgDirectory(int userId){
StringBuilder builder = new StringBuilder();
builder.append(PublicConstant.nginxPath)
.append(PublicConstant.imgPath)
.append(userId)
.append("/");
if(mkdirs(builder.toString())){
return builder.toString();
}else{
return null;
}
}
在 FileUtils.java 中添加方法 getImgPath,用于获取图片保存路径:
/**
* 获取图片保存路径
* @param fileName 文件名
* @return 完整的保存路径
*/
public static String getImgPath(String fileName,int userId){
String directory = getUserImgDirectory(userId);
if(directory.isEmpty()){
return null;
}else {
return directory + fileName;
}
}
在 ImgUtils.java 中添加方法 saveImg,用于保存图片:
/**
* 保存图片
* @param file 文件
* @param noteId 笔记ID
* @param userId 用户ID
* @return
* @throws IOException
*/
public static String saveImg (MultipartFile file,int noteId,int userId) throws IOException {
//解析文件后缀名
String suffix = FileUtils.getSuffixWithSpilt(file.getOriginalFilename());
String timeMask = DateUtils.getTimeMask();
//构建图片名称
String fileName = noteId + "-" + timeMask + suffix;
//保存图片
File img = new File(FileUtils.getImgPath(fileName,userId));
file.transferTo(img);
return fileName;
}
在 FileUtils.java 中添加方法 getUserImgs,用于获取用户的所有图片名称:
/**
* 获取用户存储的所有图片名称
* @param userId
* @return
*/
private static String[] getUserImgs (int userId){
File file = new File(getUserImgDirectory(userId));
if(file.exists() && file.isDirectory()){
return file.list();
}else{
return null;
}
}
在 FileUtils.java 中添加方法 deleteImgByNoteId,用于删除笔记中的所有图片:
/**
* 删除笔记中的图片
* @param userId
* @param noteId
*/
public static void deleteImgByNoteId(int userId,int noteId){
String noteIdStr = noteId + "";
String[] imgs = getUserImgs(userId);
if(imgs == null || imgs.length < 1){
return;
}
for(String imgName :imgs){
String[] name = imgName.split("-");
if(name.length < 2){
continue;
}
if(noteIdStr.equals(name[0])){
deleteImg(imgName,userId);
}
}
}
判断在编辑器中删除了图片
在 WangEditor 中和 mavon-editor 判断图片被删除的思路是一样的:
- 使用正则表达式匹配编辑器内容中的图片
- 分别取出修改前和修改后内容中包含的图片
- 如果有些图片在修改前有,修改后没有,说明图片被删除了
下面以判断在 mavon-editor 中删除图片的操作为例,说明实现过程。
在 data.js 中添加方法 getMarkDownImg,用以取出内容中包含的图片名称:
/**
* 从 MarkDown 格式的内容中读取出图片名称(不带路径)
* @param {Object} content
*/
getMarkDownImg(content) {
// 匹配 MarkDown 中的图片 匹配格式:![图片描述](图片地址)
var pattern = /!\[?([^\)]*)\)?/g;
// 获取匹配到的图片数组
var arr = content.match(pattern);
let imgs = []
// 遍历图片数组,解析出图片的文件名,如:abc.jpg
for (let index in arr) {
let img = arr[index];
img = img.substring(img.lastIndexOf("/") + 1, img.length - 1);
imgs.push(img);
}
//返回纯图片名的数组
return imgs;
}
说明:
因为 Keller 云笔记项目中每个用户存储图片的目录是固定的,在判断图片时,取出图片文件名即可。如果放的是网络图片,要在这里做相应的处理(如:判断图片命名格式、图片访问的域名等),以区分网络图片和项目文件服务器中存储的图片。
在 data.js 中添加方法 getMarkDownDelImgs,用于判断出删除的图片:
/**
* 获取到 MarkDown 编辑器中删除的图片
* @param {Object} oldContent 修改前的内容
* @param {Object} newContent 修改后的内容
*/
getMarkDownDelImgs(oldContent, newContent) {
let oldImgs = this.getMarkDownImg(oldContent);
//如果修改前没有图片,不用判断
if (oldImgs.length < 1) {
return [];
}
let newImgs = this.getMarkDownImg(newContent);
//如果修改后没有图片,说明图片全被删除
if (newImgs.length < 1) {
return oldImgs;
}
let del;
let delImgs = [];
for (let i in oldImgs) {
del = false;
for (let j in newImgs) {
//如果修改前的图片,修改后仍存在,说明未删除
if (oldImgs[i] === newImgs[j]) {
del = false;
break;
} else {
del = true;
}
}
if (del) {
delImgs.push(oldImgs[i]);
}
}
// 返回删除的图片
return delImgs;
}
在 Node.vue 的 handleSave 方法中添加删除图片的操作:
handleSave() {
let newText;
let newHtml;
if (this.note.type == 0) {
newText = this.$refs.wangEditor.getText();
newHtml = this.$refs.wangEditor.getHtml();
} else if (this.note.type == 1) {
newText = this.$refs.markDown.getText();
newHtml = this.$refs.markDown.getHtml();
//如果 MarkDown 笔记内容有变化,检查是否删除了图片,如果删除了图片,则请求服务器删除图片
if(this.note.text !== newText){
let delImgs = editorImgCheck.getMarkDownDelImgs(this.note.text,newText);
if(delImgs.length > 0){
for(let index in delImgs){
// 调用删除图片的接口,删除文件服务器中的图片
this.handleDelImg(delImgs[index]);
}
}
}
} else {
return;
}
... ...
}
源码地址
本篇完整的源码地址:
小结
本篇根据项目部署的实际情况,带领大家在力所能及的范围内控制项目消耗的内存、磁盘空间资源,避免因为大量冗余的数据浪费服务器存储空间。