今天受到一个需求,需要查出文件,然后将文件打包后下载。看了下项目里默认代码有压缩功能,以此修改了下,项目使用了hutool。项目是若依项目
定义zip的数据传输对象,ossId可以是文件表的id
@Data
public class SysOssZipDTO {
/**
* 关联OSS对象存储ID
*/
})
private String ossId;
/**
* 压缩包文件内文件夹路径,例子(main/java/com/haoyu/flowForm/domain/)
*/
private String zipItemFolderPath;
}
sevice层
void downloadZip(List<SysOssZipDTO> zipDtoList, HttpServletResponse response, String taskId) throws IOException;
serviceImpl层
@Override
public void downloadZip(List<SysOssZipDTO> zipDtoList, HttpServletResponse response, String taskId) throws IOException {
if(zipDtoList == null || zipDtoList.isEmpty()){
return;
}
ZipOutputStream zip = new ZipOutputStream(response.getOutputStream());
final int bufferSize = 1024 * 1024 * 5; // 5MB
final byte[] buffer = new byte[bufferSize];
long totalFileSize = 0; // 文件总大小,估计值
// 任务进度key
final String progressKey = OssConstant.SYS_OSS_PROGRESS+taskId;
for (int i =0;i<zipDtoList.size();i++){
// 设置进度(可选,用于压缩进度查询,就是将进度存入redis里,然后设计个接口供前端查询进度)
/*SysOssProgressBO sysOssProgressBO = new SysOssProgressBO();
sysOssProgressBO.setProgressNumber(Long.valueOf(i));
sysOssProgressBO.setTotalProgress(Long.valueOf(zipDtoList.size()));
RedisUtils.setCacheObject(progressKey,sysOssProgressBO, Duration.ofMinutes(10));
*/
SysOssZipDTO zipDTO = zipDtoList.get(i);
if(StrUtil.isEmpty(zipDTO.getOssId())|| StrUtil.isEmpty(zipDTO.getZipItemFolderPath())){
continue;
}
// 通过id查找文件信息
SysOssVo sysOss = matchingUrl(SpringUtils.getAopProxy(this).getById(zipDTO.getOssId()));
if (ObjectUtil.isNull(sysOss)) {
continue;
}
// 通过文件信息获取文件流,本文用的Oss存储,如果用别的可以自己写,核心就是读取流
OssClient storage = OssFactory.instance(sysOss.getService());
try(InputStream inputStream = storage.getObjectContent(sysOss.getUrl());
){
// 这里使用文件名了,拼上前缀序号解决文件名重复问题
zip.putNextEntry(new ZipEntry(zipDTO.getZipItemFolderPath() +
"("+i+")"+sysOss.getOriginalName().replaceAll("/","_" )));
int available = inputStream.available();
totalFileSize += available;
// 防Oom,可以用下面注释的也可以用hutool的
/*int len = 0;
while((len = inputStream.read(buffer)) != -1){
zip.write(buffer, 0, len);
}*/
IoUtil.copy(inputStream, zip, bufferSize);
zip.flush();
zip.closeEntry();
}catch (Exception e) {
throw new ServiceException(e.getMessage());
}
}
IoUtil.close(zip);
// 生成zip文件
// 由于前面已经开启了response的outputstream,所以这里不能调用reset()方法,否则会导致流异常关闭
// response.reset();
response.addHeader("Access-Control-Allow-Origin", "*");
response.addHeader("Access-Control-Expose-Headers", "Content-Disposition");
response.addHeader("Content-Length", "" + totalFileSize);
response.setContentType("application/octet-stream; charset=UTF-8");
// 下载后的文件名可以由前端控制,此处由前端控制所以注释掉了
/*if(StrUtil.isNotEmpty(zipName)){
FileUtils.setAttachmentResponseHeader(response, zipName + ".zip");
}*/
}
其中的文件查询进度是可选的,可以让前端生成个任务id传到服务器
controller层
@GetMapping("/downloadZipByTrainId/{trainId}")
public void downloadZipByTrainId(HttpServletResponse response, @PathVariable String trainId) throws IOException {
// downloadZipByTrainId()方法里面调用downloadZip()
this.iTrainMaterialsService.downloadZipByTrainId(trainId,response);
}
前端代码,此处使用的是vue3
// 需要npm安装file-saver
import { saveAs } from 'file-saver'
// 点击按钮触发事件
const clickDownLoadMaterials = async (row) =>{
if(row.materialCount > 0 ){
let res = await zip(`/train/trainMaterials/downloadZipByTrainId/${row.id}?projectName=${row.trainName}`,`${row.trainName}`)
}else{
ElMessage({
message: '暂无材料可下载!',
type: 'warning',
})
}
}
const zip = (url, name) => {
isShowLoadingDownLoadMaterial.value = true
url = baseURL + url
axios({
method: 'get',
url: url,
responseType: 'blob',
headers: {
// 请求头token
'Authorization': 'Bearer ' + getToken()
}
}).then(async (res) => {
if(res.status == 200){
isShowLoadingDownLoadMaterial.value = false
}
const isLogin = await blobValidate(res.data);
if (isLogin) {
const blob = new Blob([res.data], { type: 'application/zip' })
saveAss(blob, name)
} else {
await this.printErrMsg(res.data);
}
})
}
const saveAss = (text, name, opts) =>{
saveAs(text, name, opts);
}
// 验证是否为blob格式
export async function blobValidate(data) {
try {
const text = await data.text();
JSON.parse(text);
return false;
} catch (error) {
return true;
}
}
优化
新增进度条,以及取消下载的功能
const clickDownLoadMaterials = async (row) =>{
if(row.materialCount > 0 ){
let taskId = await getZipTaskIdThenStartProgress();
progressTaskId.value = taskId;
// 使用axios.CancelToken取消请求,先赋值,后面点取消请求的时候会用到
cancelSource.value = axios.CancelToken.source();
let params = {
trainId:row.id,
taskId
}
let res = await proxy.$download.zipThenCallback(`/train/trainMaterials/downloadZipByBo`,`${row.trainName}`
,params,cancelSource.value.token,closeZipProgress)
}else{
ElMessage({
message: '暂无材料可下载!',
type: 'warning',
})
}
}
/**取消下载压缩文件 */
async function cancelZipTask (){
// 中断进度条
await closeZipProgress();
cancelSource.value?.cancel("410");
}
/**打开进度条,返回任务id */
const getZipTaskIdThenStartProgress =async () =>{
showProgress.value = true;
// 发接口获取任务id
let taskRes = await generateProgressTaskId();
let taskId = taskRes.data;
progressIntervalKey.value = setInterval(() => {
// 根据任务id获取进度
getProgressByTaskId(taskId).then(progressRes=>{
let progressObj = progressRes.data;
if(progressObj!= null && progressObj.totalProgress != 0){
let progressPercentGet = Number.parseInt((progressObj.progressNumber *100) /progressObj.totalProgress)
progressPercent.value = progressPercentGet;
if(progressPercentGet==100){
clearInterval(progressIntervalKey.value);
}
}
});
}, 1000);
return taskId;
}
/** 关闭进度条 */
async function closeZipProgress(){
// 关闭定时器
clearInterval(progressIntervalKey.value);
// 发接口删掉任务id,后端判断如果没有任务id则不再进行压缩处理
await removeProgressByTaskId(progressTaskId.value);
showProgress.value=false;
progressPercent.value=0;
}
// 下载压缩文件,带回调函数,可以中断
zipThenCallback(url, name,params,cancelToken,callback){
url = baseURL + url
axios({
method: 'get',
url: url,
responseType: 'blob',
cancelToken,
params,
headers: {
'Authorization': 'Bearer ' + getToken(),
}
}).then(async (res) => {
if(res.status == 200 && callback != undefined){
// 执行回调
callback();
}
const isLogin = await blobValidate(res.data);
if (isLogin) {
const blob = new Blob([res.data], { type: 'application/zip' })
saveAs(blob, name)
} else {
await this.printErrMsg(res.data);
}
}).catch( (error)=>{
console.log(error)
/*if(error.name!='"CanceledError"'){
ElMessage.error(error);
}*/
})
},
杂谈
这次的需求整理到了不少东西,一开始担心oom的问题,于是调试时改变了缓冲区大小,发现java使用的内存也会对应的发生变化,这是为什么呢?实际上原因很简单,final byte[] buffer = new byte[bufferSize];这个变量就是占用这么多字节的内存呀!
当缓冲区设成0的时候,会发现读的特别特别慢,这是为什么呢?这是由于读的大小太小了,java调取磁盘io的频率变多,而调取磁盘io是一个特别消耗资源的行为也特别慢,因此就慢了。设置合适的缓冲区大小,可以一次性读取对应大小的内容从而减少磁盘io的交互从而提升效率,但也要避免缓冲区过大导致变量占用内存过大而oom。
同时,java流的操作不是一次性将整个文件都读入内存中的,而是仅仅获取文件的句柄,同时读偏移量,不停的读,移动偏移量,直至文件读写结束(大佬原话)。而且系统加载文件到内存也是按块加载的,并不会一次性全读到内存中(如一个几十g的游戏,打开并不会占用几十g内存,而是用到的时候在加载,不足的时候就会根据算法按页或者按块替换)
最后,开了流一定要关流!response.getOutputStream()自带的流可以不用手动关servlet在请求结束的时候会自己关掉