暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

微服务专栏(十九):文件下载功能实现

修电脑的杂货店 2021-12-08
1596

 人只有献身于社会,才能找出那短暂而有风险的生命的意义。——爱因斯坦


1. 准备工作

第一步: 创建相关工程及类文件

netdisk-web-perpc
|-- com.micro.controller
| |-- FileDownloadController.java//Controller
|-- com.micro.properties
| |-- DownloadProperties.java//下载信息相关的配置类

netdisk-service-api
|-- com.micro.disk.service
| |-- FileService.java //文件接口
| |-- FilePreviewService.java //文件预览接口
|-- com.micro.disk.bean
| |-- DownloadBean.java//下载实体

netdisk-service-provider
|-- com.micro.service.impl
| |-- FileServiceImpl.java
| |-- FilePreviewServiceImpl.java

  • FilePreviewService.java
    文件预览接口,主要目的是根据filemd5
    获取切块记录、根据storepath
    获取切块byte[]
    ,主要用于文件下载、文件在线预览和编辑等等。

  • FileService.java
    文件接口,主要是查询文件信息等

  • 思考:为什么需要独立一个FileDownloadController.java
    类专门做文件下载呢?
    其实,看需求的复杂度,如果业务不复杂,那么文件的相关操作(比如:文件上传、下载、删除等)可以统一一个类即可;如果业务比较复杂,则建议拆分,这样比较清晰。

第二步: 根据上节的分析,我们需要定义三个接口给前端工程调用。

①文件大小计算和阀值判断接口

@PostMapping("/getDownloadInfo")
public Result getDownloadInfo(String idjson) {

}

  • 作用:下载之前计算下载文件的大小,是否超过定义的阀值

  • 参数:idjson的格式"1,2,3",就是前端勾选多个文件,把它们以逗号隔开拼接起来。

  • 返回值:

    {
    code:0,
    msg:"查询成功",
    data:{
    isbig:0, /*0表示小于阀值,1表示大于阀值*/
    totalsize:"100M", /*文件总大小*/
    filenum:2, /*下载的文件数量*/
    foldernum:1 /*下载的文件夹数量*/
    }
    }

②合并切块和压缩文件接口

@PostMapping("/mergeFiles")
public Result mergeFiles(String downloadname, String downloadsuffix, String idjson){

}

  • 作用:根据切块分别合并完整的文件到服务器的临时目录,并且把这些文件最终压缩成一个独立压缩包

  • 参数:

    • downloadname,压缩包名称,用户自定义下载压缩包的名称

    • downloadsuffix,压缩包格式(比如:zip,rar等)

    • idjson同上

  • 返回参数:

    {
    code:0,
    msg:"压缩成功",
    data:"http://ip:port//netdisk-web-perpc/disk/filedownload/downloadZip?filename=test.zip&path=/temp/test.zip"
    }

③下载压缩包接口

@GetMapping("/downloadZip")
public void downloadZip(String filename, String path, HttpServletRequest request, HttpServletResponse response) {

}

  • 作用:根据path去临时目录找到压缩包,并且获取其字节流写回浏览器

  • 参数:

    • filename,压缩包的名称

    • path,压缩包的存储位置

    • filename和path是第二个接口
      返回给浏览器的

  • 返回值:返回字节流,然后浏览器直接下载文件


2. Service接口实现


2.1 Service接口定义

public interface FilePreviewService {
/**
* 下载文件的信息查询
* userid 用户ID
* fileids 文件ID集合
*/

public DownloadBean getDownloadInfo(String userid,List<String> fileids);

/*
* 根据文件md5获取切块的存储路径集合(并且按切块序号排序)
* filemd5 文件MD5
*/

public List<String> getChunksByFilemd5(String filemd5);

/*
* 根据切块存储地址,到文件系统获取字节流
* storepath 切块存储地址
*/

public byte[] getBytesByUrl(String storepath);
}

  • 说明:该接口,主要用于文件的下载、文件的预览、文件在线编辑

public interface FileService {
/*
* 根据文件ID查询其基本信息
* id 文件ID
*/

public FileBean findOne(String id);

/*
* 获取某个文件夹下的子文件(夹)
* userid 用户ID
* pid 文件夹ID
*/

public List<FileBean> findChildrenFiles(String userid,String pid);
}

  • 说明:该类是文件的核心类,定义的接口很多,大家可以自己去看

@Data
public class DownloadBean implements Serializable{
private Integer filenum; //文件数量
private Integer foldernum; //文件夹数量
private Long totalsize; //所有文件总大小
private Integer isbig; //0小于阀值;1大于阀值
}

  • 说明:这个是实体类,主要用于FilePreviewService.getDownloadInfo

@Data
public class FileBean implements Serializable{
//字段太多,省略了;它其实就是disk_file表的字段
}

  • 说明:这个是实体类

以上就是整个下载功能需要用到的实体和接口情况,比较简单。


2.2 Service接口实现

FileServiceImpl.java、FilePreviewServiceImpl.java 两个类中涉及以上的接口实现都非常的简单,自己去看源码即可。这里需要特殊讲解的是getDownloadInfo
接口的实现。

@Override
public DownloadBean getDownloadInfo(String userid,List<String> fileids) {
if(CollectionUtils.isEmpty(fileids)){
throw new RuntimeException("请选择下载记录");
}
//1.定义一个实体
DownloadBean dd=new DownloadBean();
//2.遍历文件ID集合
for(String fileid:fileids){
//2.1 根据文件ID查询文件的基本信息
DiskFile file=diskFileDao.findOne(fileid);
//2.2 累加计算下载文件的总大小
dd.setTotalsize(dd.getTotalsize()+file.getFilesize());
//2.3 累加计算下载文件的数量(文件数量、文件夹数量)
if(file.getFiletype()==1){ //文件
dd.setFilenum(dd.getFilenum()+1);

}else if(file.getFiletype()==0){ //文件夹
dd.setFoldernum(dd.getFoldernum()+1);
//2.4 如果当前是文件夹则递归查找
dgGetDownloadInfo(userid,file.getId(), dd);
}
}
return dd;
}

//递归方法
public void dgGetDownloadInfo(String userid,String pid,DownloadBean dd){
//1.根据文件ID查找子记录
List<DiskFile> files=diskFileDao.findListByPid(userid,pid);
//2.如果不为空则处理【如果为空则递归结束了】
if(!CollectionUtils.isEmpty(files)){
//2.1 遍历集合
for(DiskFile file:files){
//2.1.1 累加计算下载文件的总大小
dd.setTotalsize(dd.getTotalsize()+file.getFilesize());
//2.1.2 累加计算下载文件的数量(文件数量、文件夹数量)
if(file.getFiletype()==1){//文件
dd.setFilenum(dd.getFilenum()+1);

}else if(file.getFiletype()==0){//文件夹
dd.setFoldernum(dd.getFoldernum()+1);
//2.1.3 如果当前是文件夹则递归查找
dgGetDownloadInfo(userid,file.getId(), dd);
}
}
}
}

  • 说明:该方法主要使用递归的思想进行处理,主要目的是递归累加计算文件的总大小、文件的总数量、文件夹的总数量。


3. Controller接口实现


3.1 计算下载文件接口

@PostMapping("/getDownloadInfo")
public Result getDownloadInfo(String idjson,HttpServletRequest request) {
try {
//1.处理idjson
List<String> fileids = new ArrayList<String>();
String[] ids = idjson.split(",");
for (String id : ids) {
fileids.add(id);
}
//2.获取用户信息【前面提高的MVC封装】
SessionUserBean user=UserInfoUtils.getBean(request);
//3.调用Service接口获取文件信息
DownloadBean bean = filePreviewService.getDownloadInfo(user.getId(),fileids);
//4.判断文件大小是否大于200M【可以把该值提到Nacos进行管理】
if (bean.getTotalsize() <= 200 * 1024 * 2014) {
bean.setIsbig(0);
} else {
bean.setIsbig(1);
}
return ResultUtils.success("查询成功", bean);
} catch (Exception e) {
return ResultUtils.error(e.getMessage());
}
}


3.2 合并和压缩接口


3.2.1 配置类绑定

第一步:配置文件,application.properties

#临时目录(合并和压缩文件临时存放的目录)
download.temp.path=/usr/local/files/temp
#服务器信息
download.temp.host=192.168.1.8:8012

第二步:定义一个配置类

  • 主要目的是把配置类和配置文件进行关联,当然你也可以使用 @Value("${xxx}") 来代替

//该类是对应application.properties的配置类
@Data
@ConfigurationProperties(prefix="download.temp")
@Component
public class DownloadProperties {
private String path; //合并切块;压缩文件的临时存储目录
private String host; //临时目录,所在服务器IP地址(下面会分析)
}

或者

@Data
@Component
public class DownLoadProperties {
@Value("${download.temp.path}")
private String path;

@Value("${download.temp.host}")
private String host;
}

提示:可以把这些信息放到 Nacos 来统一管理


3.2.2 代码实现

@Autowire
private DownloadProperties downloadProperties;

@Reference(check = false)
private FileService fileService;

@Reference(check = false)
private FilePreviewService fps;

/*
* downloadname:压缩包名称
* downloadsuffix:压缩包格式
* idjson:下载文件id拼接
*/

@PostMapping("/mergeFiles")
public Result mergeFiles(String downloadname, String downloadsuffix, String idjson, HttpServletRequest request,HttpServletResponse response) {
try {
// 1.校验
ValidateUtils.validate(downloadname, "下载文件名称");
ValidateUtils.validate(downloadsuffix, "下载文件格式");
ValidateUtils.validate(idjson, "下载记录");

// 2.获取文件ID数组
String[] fileids = idjson.split(",");

// 3.创建本地临时存放目录
SessionUserBean user = UserInfoUtils.getBean(request);
String temp=downloadProperties.getPath();//临时目录
String userid=user.getId();
String path =temp+ "/" + userid+"/"+downloadname;
File file = new File(path);
file.mkdirs();//创建临时目录

// 4.遍历文件ID数组,递归实现文件的合并
for (String fileid : fileids) {
FileBean bean = fileService.findOne(fileid);
if (bean.getFiletype() == 0) { //表示文件夹

// 本地创建文件夹
File folderFile = new File(path + "/" + bean.getFilename());
folderFile.mkdirs();

// 继续递归
dgDownload(userid,path +"/"+ bean.getFilename(), bean.getId());

} else if(bean.getFiletype() == 1){ //表示文件
String filePath = path + "/" + bean.getFilename();
FileOutputStream out = new FileOutputStream(filePath);

//根据文件MD5查询切块存储路径集合
List<String> urls = fps.getChunksByFilemd5(bean.getFilemd5());
for (String url : urls) {
//根据每一个路径获取相应的字节流
byte[] bytes = fps.getBytesByUrl(url);
//往临时目录输出文件,自动合并切块
out.write(bytes);
out.flush();
}
out.close();
}
}

// 把多个文件压缩成压缩包
String zipPath = path+"/"+downloadname + "." + downloadsuffix;
FileZipUtils.fileToZip(path, zipPath);

// 返回压缩包信息【核心说明】
Map map=new HashMap();
map.put("host",downloadProperties.getHost());
map.put("path",zipPath);

return ResultUtils.success("压缩成功", map);
} catch (Exception e) {
return ResultUtils.error(e.getMessage());
}
}

递归处理:

private void dgDownload(String userid,String path, String pid){
//1.获取pid下的子文件
List<FileBean> beans = fileService.findChildrenFiles(userid,pid);
if (!CollectionUtils.isEmpty(beans)) {
for (FileBean bean : beans) {
if (bean.getFiletype() == 0) {//表示文件夹
// 本地创建文件夹
File file = new File(path + "/" + bean.getFilename());
file.mkdirs();

// 继续递归
dgDownload(userid,path + "/" + bean.getFilename(), bean.getId());

} else if(bean.getFiletype() == 1){ //表示文件

String filename = path + "/" + bean.getFilename();
FileOutputStream out = new FileOutputStream(filename);
List<String> urls = fps.getChunksByFilemd5(bean.getFilemd5());
for (String url : urls) {
byte[] bytes = fps.getBytesByUrl(url);
out.write(bytes);
out.flush();
}
out.close();
}
}
}
}

上面的代码,其实大家不要研究每一行代码的意思,只需要掌握核心思路即可

  • 第一步:在服务器本地生成一个临时目录
    专门存放合并的文件
    生成的压缩包
    ,为了达到不互相干扰,主要通过创建子目录 userid 目录和 downloadname 目录去隔离。

  • 第二步:文件合并,根据文件ID
    获取完整的文件记录,并且做判断

    • 如果是文件,再根据文件md5
      获取切块记录集合,并且按顺序输出到临时目录,自动合并成完整的文件

    • 如果是文件夹,先在临时目录下创建对应文件夹,并且递归查询其下面的子文件/文件夹,然后做处理

  • 第三步:压缩文件(夹),这里可能有点同学不懂压缩
    如何去实现,下面会讲解。

  • 第四步:把压缩包的下载路径拼接好并返回给客户端。


3.2.3 思考:为什么需要返回完整压缩包信息

思考:为什么不是把压缩包的存储路径
返回客户端就得了呢?还要拼接host等信息呢?

回答:主要是集群模式的问题

如果mergeFiles
接口返回数据格式

{
code:0,
msg:"合并且压缩成功",
data:"/usr/temp/test.zip"
}

  • 集群模式下,文件合并请求
    压缩包下载请求
    可能对应两台不同的服务器进行处理。如果是这样的话,那么压缩包下载请求则在其本地无法找到对应的压缩包,导致下载失败。

如果mergeFiles
接口返回数据格式

{
code:0,
msg:"合并且压缩成功",
data:{
host:"192.168.1.8"
path:"/usr/temp/test.zip"
}
}

  • 如果后端返回的格式加上host
    字段,那么前端工程发起压缩包下载的时候,可以直接拼接 IP 地址

伪代码示例如下所示:

function mergeFiles(downloadname,downloadsuffix,idjson){
var url=baseUrl+"/disk/filecommon/mergeFiles";
var args={
"downloadname":downloadname,
"downloadsuffix":downloadsuffix,
"idjson":idjson
};
$.post(url,args,function(data){
if(data.code==0){
//第一步:获取接口返回信息
var path=data.data.path;
var host=data.data.host;
//第二步:发起下载压缩包请求【注意的是使用baseUrl】
location.href="http://"+host+"/disk/filecommon/downloadZip?path="+path;
}else{
alert("合并失败");
}
});
}

思考:前端工程访问后端接口是通过 Nginx 转发的,并且后端接口是部署内网,前端是无法直接访问的,那么该怎么办呢?

解决:只能在 Nginx 配置 ip_hash 的测试模式,只要是同一个 ip 地址访问,转发到同一台服务器进行处理

特殊问题说明如下

①如果 Nginx 做转发,那么其实后台返回host
字段是没有任何意义的,建议配置ip_hash

②通过InetAddress
类获取项目运行服务器的外网IP地址
有点问题(无法获取阿里云正确的外网 IP 地址),因此只能手工在Nacos
配置外网 IP,如果 netdisk-web-perc 工程集群部署,则一个工程对应一份 Nacos 配置文件,不同的配置文件配置不同的外网 IP。


3.2.4 文件合并解析

单个文件的合并思路

  1. 根据filemd5
    获取切块集合(按切块顺序从小到大排序)

  2. 遍历集合,根据storepath
    去 FastDFS 分布式文件系统获取字节数组(byte[]),并且写到本地磁盘

  3. 所有的切块共用一个输出流
    ,并且按顺序输出byte[]
    ,自动合并成一个完整的文件

多个文件(含文件夹)的合并思路

  1. 前端传递过来的文件 ID 数组,遍历

  2. 遍历得到的文件 ID 去数据库查询其完整记录,判断其是文件还是文件夹

  3. 如果是文件,则按照上面单个文件合并的思路
    执行

  4. 如果是文件夹,则采用递归的思想去处理

  • 先在本地磁盘创建对应的文件夹

  • 去数据库查询其下面的文件(夹)记录集合,并且遍历,判断文件类型,如果是文件则按照上面单个文件合并的思路
    执行;如果是文件夹则继续递归。


3.2.5 文件压缩解析

具体的压缩代码大家可以去下面看具体的源码实现。

utils-common
|-- com.micro.common.file
| |-- FileZipUtils.java

  • 这里不讲解 FileZipUtils.java 的源码,自己可以去看源码

以下主要讲解几种不同的压缩细节处理最终性能不一样,具体如下所示:

方案一:使用 FileInputStream 读取文件,不带缓存

  • 测试压缩 20M 的文件,时间 30s

public static void compress() {
//1.File
File zipFile = new File("E:/test.zip");
//2.包装成ZipOutputStream输出流
ZipOutputStream zipOut = new ZipOutputStream(new FileOutputStream(zipFile));
//3.获取切块文件数组
File[] files=new File("E:/cutFiles").listFiles();
//4.遍历切块文件
for (File f:files) {
//4.1 获取单个切块输入流
InputStream input = new FileInputStream(f);
//4.2 创建压缩的Enrty,可以认度为是每个文知件一个Entry
zipOut.putNextEntry(new ZipEntry(f.getName()));
//4.3 定义一个临时数组,长度是切块的大小
byte[] bytes=new byte[input.available()];
//4.4 往临时数组写入数据
input.read(bytes);
//4.5 把临时数组写出到压缩输出流当中
zipOut.write(bytes);
//4.6 关闭切块输入流
input.close();
}
//5.关闭输出流
zipOut.close();
}

方案二: 使用 BufferedInputStream 输入流,带缓存

  • 测试压缩 20M的 文件,时间 2s

  • 主要是使用了带缓冲的输入、输出流(Buffered开头的)

  • IO 采用的是静态代理(包装)设计模式

public static void compress() {
//1.File
File zipFile = new File("E:/test.zip");
//2.包装成ZipOutputStream输出流
ZipOutputStream zipOut = new ZipOutputStream(new FileOutputStream(zipFile));
//3.带缓冲的输出流【IO其实采用的就是保障设计模式,层层包装】
BufferedOutputStream bout = new BufferedOutputStream(zipOut);
//4.获取切块文件数组
File[] files=new File("E:/cutFiles").listFiles();
//5.遍历切块文件
for (File f:files) {
//5.1 包装模式,有缓存功能,效率更高
BufferedInputStream binput = new BufferedInputStream(new FileInputStream(f));
//5.2 创建压缩的Enrty,可以认度为是每个文知件一个Entry
zipOut.putNextEntry(new ZipEntry(f.getName()));
//5.3 定义一个临时数组,长度是切块的大小
byte[] bytes=new byte[input.available()];
//5.4 往临时数组写入数据
input.read(bytes);
//5.5 把临时数组写出到压缩输出流当中
zipOut.write(bytes);
//5.6 关闭切块输入流
binput.close();
}
//6 关闭输出流
bout.close();
}

方案三:使用 NIO 的直接缓冲区来读取和写出文件

  • 测试压缩 20M 的文件,时间 1s

  • 说明:传统 IO 是面向
    编程、NIO 是面向通道
    编程

public static void compress() {
//1.File
File zipFile = new File(""E:/test.zip"");
//2.包装成ZipOutputStream输出流
ZipOutputStream zipOut = new ZipOutputStream(new FileOutputStream(zipFile);
//3.创建通道
WritableByteChannel wchannel = Channels.newChannel(zipOut);

//4.获取切块文件数组
File[] files=new File("E:/cutFiles").listFiles();
//5.遍历切块文件
for (File f:files) {
//5.1 输入流通道
FileChannel fc = new FileInputStream(f).getChannel();
//5.2 创建压缩的Enrty,可以认度为是每个文知件一个Entry
zipOut.putNextEntry(new ZipEntry(f.getName()));
//5.3 往目标通道写数据
fc.transferTo(0, fc.size(), wchannel);
}
}

提示:以上代码核心是一层目录压缩,如果想实现多级目录压缩,只需要递归即可。


3.3 压缩包下载接口

@GetMapping("/downloadZip")
public void downloadZip(String filename, String path, HttpServletRequest request, HttpServletResponse response) {

//1.防止文件名称是中文导致乱码
String userAgent = request.getHeader("User-Agent");
if (userAgent.contains("MSIE") || userAgent.contains("Trident")) {
// 针对IE或者以IE为内核的浏览器:
filename = java.net.URLEncoder.encode(filename, "UTF-8");
} else {
// 非IE浏览器的处理:
filename = new String(filename.getBytes("UTF-8"), "ISO-8859-1");
}

//2.设置响应头,attachment表示附件下载
response.setHeader("content-disposition", "attachment;filename=" + filename);
//3.获取输出流
OutputStream out = response.getOutputStream();
//4.读取压缩包并且写到网络中
FileInputStream input = new FileInputStream(path);
byte[] bytes = new byte[input.available()];
out.write(bytes);
//5.关闭流
input.close();
out.close();
}

  1. 根据前端传递过来的path
    (压缩包所在路径),获取字节数组

  2. 把字节数组写到输出流里面

  3. 设置响应头,让浏览器识别并下载文件,response.setHeader("content-disposition", "attachment;filename=" + filename);
    ,其中attachement表示附件下载的意思。


4. 小结

本章内容主要讲解文件下载的功能实现,并且核心讲解一些特殊地方,比如:递归思想、文件的合并与压缩、为什么返回压缩包的完整信息、还有几种压缩文件的方式比较、响应头的设置等。

纸上得来终觉浅,绝知此事要coding...

文章转载自修电脑的杂货店,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论