SpringBoot + MongoDB GridFS

随着web 3.0的兴起,数据的形式不局限于文字,还有语音、视频、图片等。高效存储与检索二进制数据也成为web 3.0必须要考虑的问题。然而这种二进制数据是不适合存储在普通关系型数据库(MySQL、Oracle)中的,关系型数据库更多的是存储图片的访问路径。因此二进制数据可以使用MongoDB的内置模块GridFS进行检索与存储,也是一种比较好的解决方案。

1、MongoDB GridFS

1.1、GridFS概述

Grid中文释义为网格,FS(File System)释义为文件系统,合起来就是网格式的文件存储规范或系统。GridFSMongoDB的一个子模块,用于存储和检索超过16M(BSON)的文件,如果文件大小没有超过16M可以将数据保存常规的BSON中。

在实际系统开发中,上传的图片或者文件可能尺寸会很大,此时我们可以借用 GridFS 来辅助管理这些文件。注意:GridFS不是MongoDB自身特性,只是一种将大型文件存储在 MongoDB 的文件规范。这种规范也不是将一个大文件存储在一条文档中,而是自动将文件分成块,每一块作为一条文档单独存储(GridFS使用的块容量默认是256k)。

在这里插入图片描述

1.2、GridFS存储原理

GridFS使用两个集合(collection)存储文件:

  1. chunks:用于存储文件内容的二进制数据
  2. files:用于存储文件的元数据(meta数据包括filename、content_type、用户自定义属性等)

GridFS会将两个集合放在一个普通的buket中,并且这两个集合使用buket的名字作为前缀。MongoDBGridFS默认使用fs命名的buket存放两个文件集合。因此存储文件的两个集合分别会命名为集合fs.files和集合fs.chunks

当然也可以自定义不同的buket名字,可以在一个数据库中定义多个bukets,但所有的集合的名字都不得超过MongoDB命名空间的限制(MongoDB 集合的命名包括了数据库名字与集合名字,会将数据库名与集合名通过"."分隔。而且命名的最大长度不得超过120bytes)。

使用GridFS存储文件时,如果文件大于 256K ,会先将文件分割成多个块,最终将 chunk 块的信息存储在fs.chunks集合的多个文档中。然后将文件信息存储在fs.files集合的唯一一份文档中。对于同一个大文件,fs.chunks集合中多个文档中的file_id字段对应fs.files集中某一个文档的_id字段。

除此之外files集合中的文档就是BSON格式存储的,可以使用MongoDB的索引的一系列特性,因此可以更快的遍历与操作文件。

1.3、GridFS应用场景

1、应用系统有需要上传图片(用户上传或者系统本身的文件发布等)

2、文件分布式存储与读取(与其他分布式文件存储系统没啥区别)

3、文件的量级处于飞速增长,有可能达到操作系统自己的文件系统的查询性能瓶颈(甚至超过系统硬盘的扩容范围)

4、文件的备份(不使用GridFS这种方式也可以做,但是更加方面)

5、文件系统访问的故障转移和修复(GridFS可以避免用于存储用户上传内容的文件系统出现的某些问题)

6、文件检索支持索引检索,存储除文件本身以外还需要关联更多的元数据信息(文件的发布式作者、发布时间、文件tag属性等等自定义信息),有了索引后可以更快的检索出文件元数据和文件本身。

7、对文件的分类模糊(文件夹分类关系混乱或者无法分类)

8、文件尺寸较小,而且众多,且文件经常有可能被迁移、删除、修改元数据等

2、文件上传、下载、预览功能实现

2.1、在SpringBoot中集成MongoDB

具体集成细节就不重复说了,请参照这篇文章:

文章地址:https://blog.csdn.net/m0_46357847/article/details/123803461

2.2、JPA与MySQL集成

1、为了方便展示文件属性,我们可以设计一个专门存放图片元数据的数据表(TB_FILE),下载和预览的时候就只需要传入主键即可。例如(JPA + MySQL):

/**
 * @description: 文件实体
 * @author: laizhenghua
 * @date: 2022/6/5 15:28
 */
@Entity
@Table(name = "TB_FILE", schema = "TEST")
@JsonIgnoreProperties(value = {"hibernateLazyInitializer", "handler"})
public class FileEntity implements Serializable {
    private static final long serialVersionUID = -6850526733071376712L;
    private Integer id;
    private Date addDate;
    private String guid;
    private String fullPath;
    private String fileName;
    private String size;
    private String extension;
    private String state;
    private Integer sortNo;

    @Id
    @Column(name = "ID", unique = true, length = 32)
    @GeneratedValue(strategy = GenerationType.IDENTITY) // 自增
    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    @Column(name = "ADDDATE")
    public Date getAddDate() {
        return addDate;
    }

    public void setAddDate(Date addDate) {
        this.addDate = addDate;
    }

    @Column(name = "GUID")
    public String getGuid() {
        return guid;
    }

    public void setGuid(String guid) {
        this.guid = guid;
    }

    @Column(name = "FULLPATH")
    public String getFullPath() {
        return fullPath;
    }

    public void setFullPath(String fullPath) {
        this.fullPath = fullPath;
    }

    @Column(name = "FILENAME")
    public String getFileName() {
        return fileName;
    }

    public void setFileName(String fileName) {
        this.fileName = fileName;
    }

    @Column(name = "SIZE")
    public String getSize() {
        return size;
    }

    public void setSize(String size) {
        this.size = size;
    }

    @Column(name = "EXTENSION")
    public String getExtension() {
        return extension;
    }

    public void setExtension(String extension) {
        this.extension = extension;
    }

    @Column(name = "STATE")
    public String getState() {
        return state;
    }

    public void setState(String state) {
        this.state = state;
    }

    @Column(name = "SORTNO")
    public Integer getSortNo() {
        return sortNo;
    }

    public void setSortNo(Integer sortNo) {
        this.sortNo = sortNo;
    }
}

建表SQL:

CREATE TABLE `TEST`.`TB_FILE`  (
  `ID` int(32) NOT NULL AUTO_INCREMENT,
  `ADDDATE` datetime NULL DEFAULT NULL,
  `GUID` varchar(64) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL,
  `FULLPATH` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL,
  `FILENAME` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL,
  `SIZE` varchar(100) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL,
  `EXTENSION` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL,
  `STATE` varchar(10) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL,
  `SORTNO` int(30) NULL DEFAULT NULL,
  PRIMARY KEY (`ID`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 12 CHARACTER SET = utf8 COLLATE = utf8_bin ROW_FORMAT = Dynamic;

2、Dao层编写

/**
 * @description: FileDao
 * @author: laizhenghua
 * @date: 2022/6/5 15:44
 */
public interface FileDao extends JpaRepository<FileEntity, Integer> {
	// 引入相关GAV坐标后,我们只需继承JAP提供的 JpaRepository 接口就能完成大部分增删改查功能
}

3、编写获取文件实体信息的接口,测试JAP与MySQL集成是否正常。

controller

/**
 * @description:
 * @author: laizhenghua
 * @date: 2022/6/5 15:40
 */
@RestController
@RequestMapping(value = "/file")
public class FileController {
    @Autowired
    private FileService fileService;

    @RequestMapping(value = "/getList", method = {RequestMethod.GET, RequestMethod.POST})
    public R getFileList(@RequestParam(value = "pageNo") Integer pageNo, @RequestParam(value = "pageSize") Integer pageSize) {
        Map<String, Object> fileMap = fileService.getList(pageNo, pageSize);
        return R.ok().put("data", fileMap);
    }
}

service

@Service(value = "fileService")
public class FileServiceImpl implements FileService {
    private final org.apache.logging.log4j.Logger log = Logger.getLogger(FileServiceImpl.class);

    @Autowired
    private FileDao fileDao;

    @Autowired
    private GridFsOperations gridFsOperations;

    @Autowired
    private GridFsTemplate gridFsTemplate;

    @Override
    public Map<String, Object> getList(Integer pageNo, Integer pageSize) {
        Pageable pageable = PageRequest.of(pageNo - 1, pageSize, Sort.Direction.ASC, "id");
        Page<FileEntity> pageResult = fileDao.findAll(pageable);
        if (pageResult == null) {
            return null;
        }
        List<FileEntity> fileEntityList = pageResult.getContent();
        if (CollectionUtils.isEmpty(fileEntityList)) {
            return null;
        }
        Map<String, Object> resultMap = new HashMap<>();
        resultMap.put("total", pageResult.getTotalElements());
        resultMap.put("list", fileEntityList);
        return resultMap;
    }
}

4、随便插入几条数据进行测试,例如

在这里插入图片描述

2.3、文件上传实现

设计好实体后,我们采用Spring封装好的GridFsOperations进行文件二进制数据的保存也就很简单了,例如:

controller

@RestController
@RequestMapping(value = "/file")
public class FileController {
    @Autowired
    private FileService fileService;
    
    @RequestMapping(value = "/upload", method = RequestMethod.POST)
    public R upload(@RequestParam(value = "file") MultipartFile file) {
        FileEntity fileEntity = fileService.upload(file);
        if (fileEntity == null) {
            return R.error(500, "文件上传失败");
        }
        return R.ok().put("data", fileEntity);
    }
}

service层,主要看下uoload方法即可。

/**
 * @description:
 * @author: laizhenghua
 * @date: 2022/6/5 15:42
 */
@Service(value = "fileService")
public class FileServiceImpl implements FileService {
    private final org.apache.logging.log4j.Logger log = Logger.getLogger(FileServiceImpl.class);

    @Autowired
    private FileDao fileDao;

    @Autowired
    private GridFsOperations gridFsOperations;

    @Autowired
    private GridFsTemplate gridFsTemplate;

    @Override
    public Map<String, Object> getList(Integer pageNo, Integer pageSize) {
        Pageable pageable = PageRequest.of(pageNo - 1, pageSize, Sort.Direction.DESC, "id");
        Page<FileEntity> pageResult = fileDao.findAll(pageable);
        if (pageResult == null) {
            return null;
        }
        List<FileEntity> fileEntityList = pageResult.getContent();
        if (CollectionUtils.isEmpty(fileEntityList)) {
            return null;
        }
        Map<String, Object> resultMap = new HashMap<>();
        resultMap.put("total", pageResult.getTotalElements());
        resultMap.put("list", fileEntityList);
        return resultMap;
    }

    @Override
    public FileEntity upload(MultipartFile file) {
        InputStream inputStream = null;
        ObjectId _id = null;
        try {
            inputStream = file.getInputStream();
            _id = gridFsOperations.store(inputStream, file.getOriginalFilename(), file.getContentType());
            // 这里推荐使用如下方式上传(会自动创建索引非常方便)
            /*
            MongoDatabase mongoDatabase = mongoDatabaseFactory.getMongoDatabase();
	        String bucketName = "Materials";
	        GridFSBucket gridFSBucket = GridFSBuckets.create(mongoDatabase, bucketName);
	        _id = gridFSBucket.uploadFromStream(file.getOriginalFilename(), inputStream);
            */
        } catch (IOException e) {
            log.error(e);
        } finally {
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    log.error(e);
                }
            }
        }
        if (_id == null) {
            return null;
        }
        // 保存文件实体对象
        FileEntity entity = saveFileEntity(_id, file);
        return entity;
    }
    public FileEntity saveFileEntity(ObjectId _id, MultipartFile file) {
        FileEntity entity = new FileEntity();
        entity.setGuid(_id.toString());
        entity.setAddDate(new Date());
        entity.setExtension(getExtensionName(file.getOriginalFilename()));
        entity.setFullPath("/file/" + _id + "." + getExtensionName(file.getOriginalFilename()));
        entity.setFileName(file.getOriginalFilename());
        entity.setSize(getFileSize(file.getSize(), 2));
        entity.setSortNo(999);
        entity.setState("0");
        return fileDao.save(entity);
    }
    // 文件后缀获取
    public String getExtensionName(String fileName) {
        if (fileName == null || fileName.length() == 0) {
            return null;
        }
        int index = fileName.lastIndexOf(".");
        if (index < 0 || index > fileName.length() - 1) {
            return null;
        }
        return fileName.substring(index + 1);
    }
    // 获取文件大小KB
    public String getFileSize(long size, int round) {
        BigDecimal decimal = new BigDecimal(size / 1024);
        return decimal.setScale(round, BigDecimal.ROUND_HALF_UP).doubleValue() + "KB";
    }

    @Override
    public String getGridFSDatabaseName() {
        Class<? extends GridFsTemplate> clazz = gridFsTemplate.getClass();
        MongoDatabaseFactory dbFactory = null;
        try {
            Field dbFactoryField = clazz.getDeclaredField("dbFactory");
            dbFactoryField.setAccessible(true);
            dbFactory = (MongoDatabaseFactory) dbFactoryField.get(gridFsOperations);
        } catch (Exception e) {
            log.error(e);
            return null;
        }
        if (dbFactory == null) {
            return null;
        }
        MongoDatabase mongoDatabase = dbFactory.getMongoDatabase();
        if (mongoDatabase == null) {
            return null;
        }
        return mongoDatabase.getName();
    }
}

最后我们可以使用VUEelement-ui简单搭建一个前端页面进行文件上传、下载、预览功能测试。VUE在国内还是比较重要的,推荐大家还是自行搭建下前端页面。例如:

在这里插入图片描述
那么如上图所示,[点击上传]按钮如何如何配合后端接口实现文件上传呢?

1、前端可以声明一个隐藏起来的input标签

<input type="file" style="display: none" ref="file" @change="fileUpload($event)"/>
<div class="upload">
    <el-button size="small" type="primary" @click="upload">点击上传</el-button>
</div>

2、给[点击上传]按钮绑定点击事件(不借助任何上传文件插件)事件回调函数如下

upload() {
	// 主动触发点击事件
    this.$refs.file.dispatchEvent(new MouseEvent("click"));
}
// 点击[点击上传]按钮后会弹出文件上传窗口。

3、最后就是选择文件后的回调函数@change写法,也是在这个回调函数中完成文件上传接口的调用

fileUpload(event) {
    // 通过event.target.files获取文件内容并封装到formData中
    let file = event.target.files[0];
    let formData = new FormData();
    formData.append("file", file);

    // 文件上传
    let _ctx = this;
    request.upload(formData).then(function (resp) {
        if (resp.code === 500) {
            _ctx.$message.error(resp.msg);
        }
        _ctx.$message({message: '文件上传成功', type: 'success'});
        _ctx.getFileList(); // 刷新table数据
    });
}

4、测试与验证文件元数据与二进制数据在MongoDB中保存的位置

选择文件上传
在这里插入图片描述

查看表格数据
在这里插入图片描述
我们可以在表格中可以看到,文件的GUID=62a4048b97ba1849b54df139也就是MongoDB中主键,那么就可以通过这个主键找到此文件的元数据与二进制数据,例如:

// fs.files 集合保存元数据
db.getCollection("fs.files").find({_id: ObjectId("62a4048b97ba1849b54df139")});

// fs.chunks 集合保存二进制数据
db.getCollection("fs.chunks").find({files_id: ObjectId("62a4048b97ba1849b54df139")});

在这里插入图片描述
在这里插入图片描述

2.4、文件预览实现

对于文件的预览也很简单,在浏览器中预览需要有承载文件的标签页,也需要告诉浏览器文件以什么方式处理文件也就是使用Content-Type控制,例如Content-Type: image/png。老样子我们还是先写后端接口。备注:预览和下载我们可以通过一个接口实现。

controller

@RestController
@RequestMapping(value = "/file")
public class FileController {
    @Autowired
    private FileService fileService;

    @RequestMapping(value = "/download/{id}", method = RequestMethod.GET)
    public void download(@PathVariable("id") Integer fileId, @RequestParam(value = "type", required = false) String type, HttpServletResponse response) {
        // preview/预览 download/下载
        type = type == null ? "download" : type;
        fileService.download(fileId, type, response);
    }
}

service

/**
 * @description:
 * @author: laizhenghua
 * @date: 2022/6/5 15:42
 */
@Service(value = "fileService")
public class FileServiceImpl implements FileService {
    private final org.apache.logging.log4j.Logger log = Logger.getLogger(FileServiceImpl.class);

    @Autowired
    private FileDao fileDao;

    @Autowired
    private GridFsOperations gridFsOperations;

    @Override
    public void download(Integer fileId, String type, HttpServletResponse response) {
        FileEntity entity = fileDao.getById(fileId);
        if (entity == null) {
            return;
        }
        Query query = Query.query(Criteria.where("_id").is(new ObjectId(entity.getGuid())));
        GridFSFile gridFSFile = gridFsOperations.findOne(query);
        if (gridFSFile == null) {
            return;
        }
        GridFsResource resource = gridFsOperations.getResource(gridFSFile);
        InputStream inputStream = null;
        OutputStream outputStream = null;
        try {
            inputStream = resource.getInputStream();
            outputStream = response.getOutputStream();
            response.setContentType(resource.getContentType());
            if ("download".equals(type)) {
                String fileName = URLEncoder.encode(resource.getFilename(), "utf-8");
                response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
            }
            write(inputStream, outputStream);
        } catch (IOException e) {
            log.error(e);
        } finally {
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    log.error(e);
                }
            }
            if (outputStream != null) {
                try {
                    outputStream.close();
                } catch (IOException e) {
                    log.error(e);
                }
            }
        }
    }
    
    public void write(InputStream inputStream, OutputStream outputStream) {
        byte[] buffer = new byte[4096];
        try {
            int count = inputStream.read(buffer, 0, buffer.length);
            while (count != -1) {
                outputStream.write(buffer, 0, count);
                count = inputStream.read(buffer, 0, buffer.length);
            }
        } catch (RuntimeException e) {
            throw e;
        } catch (Exception e) {
            throw new RuntimeException(e.getMessage(), e);
        }
    }
}

OK后端接口搞定,接下来就是前端按钮功。前面我们也说要想实现文件预览需要有两个东西一个标签页另外一个是Content-Type。实际开发中都需要借助第三方插件去承载文件,还可以对文件进行放大与缩小等操作。但是在这里为了方便我们直接以浏览器标签页去承载文件即可。

1、前端按钮代码

<el-table-column label="操作" width="150" align="center">
	<template slot-scope="scope">
	    <el-button type="primary" size="small" @click="preview(scope.row)">预览</el-button>
	    <el-button type="success" size="small" @click="download(scope.row)">下载</el-button>
	</template>
</el-table-column>

2、preview点击事件回调函数

preview(row) {
	// 重新打打开一个标签页即可
    window.open("http://127.0.0.1:8080/file/download/" + row.id + "?type=preview");
}

3、测试

点击预览按钮即可

在这里插入图片描述

2.5、文件下载实现

前面我们也说了预览和下载是共用一个接口,只需修改参数即可(至于后端下载接口详见预览后端接口)。因此我们只需书写前端代码即可,例如:

download(row) {
    let url = "http://127.0.0.1:8080/file/download/" + row.id;
    // 手动创建一个form标签
    let form = document.createElement("form");
    form.method = "get";
    form.action = url; // url即为下载接口
    document.body.append(form);
    form.submit();
}

测试

在这里插入图片描述

END

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐