目录
需求场景:
接到一个需求,总结下来的核心要点是,要求下载一个zip文件,经过一些列业务分类之后,这个下载下来的zip包里面可能还有zip包,但是服务器上的原始文件都是word和excel这样的文件,也就是需要在代码段进行一个嵌套或者说递归打包的过程。
实际上就是需要一个类似这样的目录层级:
附件.zip
-第一个内部附件.zip
-工作记录.xls
前言:
评论区有小伙伴发现我第一次的思路有错误,这里保留了第一次的有问题的思路,给大家分享一下思考的过程,如果有小伙伴想直接看结果,可以直接跳过第一段直接查看新的思路
有问题的思路:
第一次问题分析:
1、首先常规的把全部附件打到一个压缩包,或者是带文件夹层级的都很常见,但是想嵌套成zip包,这个没有尝试过。
2、百度之后也无果,都是复制粘贴后最简单的压缩和解压缩,也就是单层的zip包压缩,并没有找到什么有效的方法,那没办法,得自己想
3、先分析常规的打包是怎么做的,我这边是http下载请求,就是通过ZipOutputStream把文件流输出到http的response中的输出流中去,而ZipOutputStream又是通过一个个ZipEntry对象和文件的inputStream流去写入数据
4、有了上面一层的理解就很简单了,其实只需要把内部打包的ZipOutputStream直接往外部的ZipOutputStream里面输出就可以了,我们只需要在外面把文件名定义好就可以了
有问题的代码:
void testExport(HttpServletResponse response){
//下载的文件名
String fileName="附件.zip";
try {
//根据自己前端以及浏览器设置文件名编码
fileName = URLEncoder.encode(fileName,"UTF-8");
} catch (Exception e) {
log.error(e.getMessage(),e);
}
//设置响应头编码
response.setContentType("application/octet-stream;charset=UTF-8");
response.setCharacterEncoding("utf-8");
//设置文件名
response.setHeader("Content-Disposition","attachment;filename="+fileName);
//把Content-Disposition这个字段开放给前端可见,这样前端就可以取到后端生成的文件名
response.setHeader("Access-Control-Expose-Headers","Content-Disposition");
//真正的下载,这里都是用的jdk7的写法,所有的流都不用我手动关闭了
try (
//这里面为了防止内存溢出等问题,使用了BufferedOutputStream缓冲流,可以看到这里是把流输出到了response里面
ZipOutputStream zos1=new ZipOutputStream(new BufferedOutputStream(response.getOutputStream()), StandardCharsets.UTF_8);
){
zos1.setMethod(ZipOutputStream.DEFLATED);
zos1.putNextEntry(new ZipEntry("第一个内部压缩包.zip"));
try (
//这里才是嵌套压缩的重点,可以看到这里zos2里面的输出流是zos1,这样,在内部处理的时候只需要处理zos2就行了,输出的操作会由zos2自己往zos里面输出
ZipOutputStream zos2=new ZipOutputStream(new BufferedOutputStream(zos1), StandardCharsets.UTF_8);
){
//这里面内层压缩包的附件
zos2.setMethod(ZipOutputStream.DEFLATED);
zos2.putNextEntry(new ZipEntry("工作记录.xls"));
try (InputStream is = new FileInputStream("data/工作记录.xls")){
int b;
byte[] buffer = new byte[1024];
while ((b=is.read(buffer))!=-1){
zos2.write(buffer,0,b);
}
zos2.flush();
}catch (Exception e){
log.error("文件输出异常",e);
}
}catch (Exception e){
log.error("内部打包异常",e);
}
zos1.flush();
}catch (Exception e){
log.error("文件下载失败",e);
}
}
思考过程:
1、其实一开始想错了,总在想一个output没办法往另一个output里面去写,甚至考虑过把output转为input,再写入到最外层的zipoutputstram中去,但是因为这个业务场景文件后期是会不断增多的,把全部的流放到内存中去大概率是会出问题的。
2、然后就想利用服务器做缓存,先把打包好的文件保存到服务器中去,然后再去直接读文件拿到输入流,但是这样一个是我觉得一个是增加io的压力,临时文件也得去清理;还有一个考虑到这样就失去了边压缩边下载的速度了。
3、后面才研究一下这个打包的过程,发现并不难实现,只是网上没有类似的内容,还是得去好好学习io的知识才可以
新的的思路:
重新分析问题
1、上面的思路,评论区有小伙伴提出是有问题的,zos2会把zos1给close,导致二级无法打包多个压缩包,确实是存在这样的问题,我后面又去研究了一下,最终找到了一个新的方案。
2、上述问题其实如果继续用之前的思路,其实是没办法解决的,因为只要内部的流被关闭了一次,外层的流都会被关掉,所以我们得换一个思路,既然使用递归流不行,那我们还是只能考虑将文件存在内存中,对内存造成的负荷是没有办法避免的。
3、所以这里我用到了一个新的类,ByteArrayOutputStream,虽然名字是输出流,但它实际上是一个内存流,简单的理解为字节数组就行,甚至不需要去关闭它。所以这样我们就可以使用这个类,把内部打包完的压缩包暂时的存到这个ByteArrayOutputStream里面去,就相当于一个ByteArrayOutputStream对象就代表内部的一个zip文件,然后再把每个文件通过response的output输出出去。
代码案例:
这段代码是一个demo,这里是用写死的方式打包了两个压缩包,方便更好的理解ByteArrayOutputStream是如何使用的
public void test(HttpServletResponse response){
//下载的文件名
String fileName="附件.zip";
try {
//根据自己前端以及浏览器设置文件名编码
fileName = URLEncoder.encode(fileName,"UTF-8");
} catch (Exception e) {
log.error(e.getMessage(),e);
}
//设置响应头编码
response.setContentType("application/octet-stream;charset=UTF-8");
response.setCharacterEncoding("utf-8");
//设置文件名
response.setHeader("Content-Disposition","attachment;filename="+fileName);
//把Content-Disposition这个字段开放给前端可见,这样前端就可以取到后端生成的文件名
response.setHeader("Access-Control-Expose-Headers","Content-Disposition");
//真正的下载,这里都是用的jdk7的写法,所有的流都不用我手动关闭了
try (
//最外层的压缩包,还是往response的output里面输出
ZipOutputStream zos1=new ZipOutputStream(response.getOutputStream(), StandardCharsets.UTF_8);
){
zos1.setMethod(ZipOutputStream.DEFLATED);
zos1.putNextEntry(new ZipEntry("第一个内部压缩包.zip"));
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
try (
//这里就是把第一个内部的压缩包放到byteArrayOutputStream中去了
ZipOutputStream zos2=new ZipOutputStream(byteArrayOutputStream, StandardCharsets.UTF_8);
){
//这里面内层压缩包的附件
zos2.setMethod(ZipOutputStream.DEFLATED);
zos2.putNextEntry(new ZipEntry("测试文件.txt"));
try (InputStream is = new FileInputStream("/data/测试文件.txt")){
int b;
byte[] buffer = new byte[1024];
while ((b=is.read(buffer))!=-1){
zos2.write(buffer,0,b);
}
zos2.flush();
}catch (Exception e){
log.error("文件输出异常",e);
}
}catch (Exception e){
log.error("内部打包异常",e);
}
//往最外部的压缩包里面写入
zos1.write(byteArrayOutputStream.toByteArray());
zos1.flush();
//重置这个类,这样不用每次都new一个新的对象造成内存负担
byteArrayOutputStream.reset();
zos1.setMethod(ZipOutputStream.DEFLATED);
zos1.putNextEntry(new ZipEntry("第二个内部压缩包.zip"));
try (
//和第一个压缩包一样
ZipOutputStream zos3=new ZipOutputStream(byteArrayOutputStream, StandardCharsets.UTF_8);
){
zos3.setMethod(ZipOutputStream.DEFLATED);
zos3.putNextEntry(new ZipEntry("测试文件.xlsx"));
try (InputStream is = new FileInputStream("/data/测试文件.xlsx")){
int b;
byte[] buffer = new byte[1024];
while ((b=is.read(buffer))!=-1){
zos3.write(buffer,0,b);
}
zos3.flush();
}catch (Exception e){
log.error("文件输出异常",e);
}
}catch (Exception e){
log.error("内部打包异常",e);
}
zos1.write(byteArrayOutputStream.toByteArray());
zos1.flush();
}catch (Exception e){
log.error("文件下载失败",e);
}
}
封装工具类:
由上面的demo,封装了一个工具类,这样就这样就可以实现递归打包,并且不限制个数了
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.CollectionUtils;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.InputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@Slf4j
public class FileUtils {
public static void recursionExportZip(HttpServletResponse response,String fileName,List<ZipPo> fileList){
//下载的文件名
try {
//根据自己前端以及浏览器设置文件名编码
fileName = URLEncoder.encode(fileName,"UTF-8");
} catch (Exception e) {
log.error(e.getMessage(),e);
}
//设置响应头编码
response.setContentType("application/octet-stream;charset=UTF-8");
response.setCharacterEncoding("utf-8");
//设置文件名
response.setHeader("Content-Disposition","attachment;filename="+fileName);
//把Content-Disposition这个字段开放给前端可见,这样前端就可以取到后端生成的文件名
response.setHeader("Access-Control-Expose-Headers","Content-Disposition");
try (
//把流输出到response里面
ZipOutputStream zos1=new ZipOutputStream(new BufferedOutputStream(response.getOutputStream()), StandardCharsets.UTF_8);
){
recursionPackage(zos1,fileList);
}catch (Exception e){
log.error("文件下载失败",e);
}
}
private static void recursionPackage(ZipOutputStream zos, List<ZipPo> fileList) {
//先初始化一个,每次使用的时候reset,防止内存溢出
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
for (ZipPo zipPo : fileList) {
zos.setMethod(ZipOutputStream.DEFLATED);
try {
zos.putNextEntry(new ZipEntry(zipPo.getName()));
} catch (Exception e) {
log.error(e.getMessage(),e);
}
//判断对象是附件还是压缩包
if(CollectionUtils.isEmpty(zipPo.getFileList())){ //附件
//获取附件并写到压缩包中
try (InputStream is = new FileInputStream(zipPo.getPath())){
int b;
byte[] buffer = new byte[1024];
while ((b=is.read(buffer))!=-1){
zos.write(buffer,0,b);
}
zos.flush();
}catch (Exception e){
log.error("文件输出异常",e);
}
}else {//压缩包
//重置一下内存流
byteArrayOutputStream.reset();
try (
//把压缩包,往byteArrayOutputStream里面输出
ZipOutputStream zos2=new ZipOutputStream(byteArrayOutputStream, StandardCharsets.UTF_8);
){
//递归压缩
recursionPackage(zos2,zipPo.getFileList());
}catch (Exception e){
log.error("内部打包异常",e);
}
//把byteArrayOutputStream写到压缩包中,这个地方需要特别注意一下,不能把这两个写在上面的try里面,因为zos2关闭的时候会影响到zos,导致流的关闭顺序出错,压缩包文件被损坏,感谢评论区大佬的指正
try {
zos.write(byteArrayOutputStream.toByteArray());
zos.flush();
} catch (IOException e) {
log.error("内部打包异常",e);
}
}
}
}
@Data
public static class ZipPo{
/**
* 文件名/压缩包名
*/
private String name;
/**
* 附件路径,为压缩包时该字段为null
*/
private String path;
/**
* 递归的文件,当为文件时改字段为null
*/
private List<ZipPo> fileList;
public ZipPo() {
}
public ZipPo(String name, String path) {
this.name = name;
this.path = path;
}
public ZipPo(String name, List<ZipPo> fileList) {
this.name = name;
this.fileList = fileList;
}
}
}
测试工具类:
public void download(HttpServletResponse response){
//单文件
List<FileUtils.ZipPo> list = new ArrayList<>();
list.add(new FileUtils.ZipPo("测试文件.txt","/data/测试文件.txt"));
//第一个压缩包里面两个文件
List<FileUtils.ZipPo> list1_1=new ArrayList<>();
list1_1.add(new FileUtils.ZipPo("测试文件.xlsx","/data/测试文件.xlsx"));
list1_1.add(new FileUtils.ZipPo("测试文件.docx","/data/测试文件.docx"));
list.add(new FileUtils.ZipPo("第一个内部压缩包.zip",list1_1));
//第二个压缩包
List<FileUtils.ZipPo> list1_2=new ArrayList<>();
list1_2.add(new FileUtils.ZipPo("测试文件.bmp","/data/测试文件.bmp"));
//嵌套一个压缩包
List<FileUtils.ZipPo> list2_1=new ArrayList<>();
list2_1.add(new FileUtils.ZipPo("测试文件.pptx","/data/测试文件.pptx"));
list1_2.add(new FileUtils.ZipPo("第二个内部压缩包里面的第一个压缩包.zip",list2_1));
list.add(new FileUtils.ZipPo("第二个内部压缩包.zip",list1_2));
FileUtils.recursionExportZip(response,"附件.zip",list);
}
原附件:
下载后的压缩包: