跳到主要内容

WebFlux实现文件打包压缩下载

1. 前言

基于传统的SpringMVC框架下,实现一个这样的功能并不难,但是在响应式编程中,使用webflux实现时却大不一样,需要做到无阻塞,符合响应式规范。来一起看看吧

2. 从入口开始,前端部分

我们假设一种常见的场景,用户需要一个批量导出的功能,所以会勾选表单中的多个,然后拿到对应的id,传给后端,这里我传递的直接是文件名

 		/**
* 批量导出
*/
function batchExport(){

// 获取表单
const form = document.getElementById("fileForm");

// 获取所有选中的复选框
const checkedBoxes = [];
const checkboxes = form.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach((checkbox) => {
if (checkbox.checked) {
checkedBoxes.push($(checkbox).attr("id"));
}
});

//异步请求
fetch(contextPath + "share/batchDownloadFile", {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
fileNames: checkedBoxes,
})
})
.then(response => {

const disposition = response.headers.get('Content-Disposition');
const filename = disposition ? disposition.split('filename=')[1].replaceAll("\"","") : 'compressedFiles.zip';
return response.blob().then(blob => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
});
})
.catch(error => {console.error('Error:', error);});
}

因为发送的是POST请求,所以拿到后端返回的是直接的字节数组,需要对返回的数据再进行一次转换,提供一个虚拟的a标签触发浏览器的下载。

const disposition = response.headers.get('Content-Disposition');
const filename = disposition ? disposition.split('filename=')[1].replaceAll("\"","") : 'compressedFiles.zip';
return response.blob().then(blob => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
});

接着我们看后段部分

3. 数据处理,后端部分

直接使用Mono<String> requestBody接收前端的请求参数,这样的操作需要主要的是,不要直接像传统MVC框架一样,在方法第一行就直接

log.info("batchDownloadFile received payload:{}", requestBody);

这样的操作是会有问题的,因为requestBody 并不是实际的请求参数,而是Mono对象。

完整代码如下

/**
* 批量下载文件
*
* @param requestBody 请求正文
* @return {@link Mono}<{@link ResponseEntity}<{@link Flux}<{@link DataBuffer}>>>
*/
@PostMapping(value = "/batchDownloadFile", produces = "application/zip")
public Mono<ResponseEntity<Flux<DataBuffer>>> batchDownloadFile(@RequestBody Mono<String> requestBody) {
return requestBody.doOnNext(it -> log.info("batchDownloadFile received payload:{}", it))
.map(JSON::parseObject)
.doOnError(it -> {
it.printStackTrace();
log.error("batchDownloadFile failed");
})
.map(it -> {
//获取入参中所有的文件名,进行遍历,并增加到一个zip文件中
final Stream<String> fileNames = Optional.ofNullable(it.getJSONArray("fileNames"))
.orElse(new JSONArray())
.stream()
.map(fileName -> exportPath + File.separator + fileName);
//压缩多个文件并转为字节数组
return zipFileToByteArray(fileNames);
})
.flatMap(data -> {
//上一个map传到这的已经是字节数组。我们直接对字节数组进行返回就行

//设置响应headers
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setContentDispositionFormData("Content-Disposition", "download.zip");

final ResponseEntity<Flux<DataBuffer>> body = ResponseEntity.ok()
.headers(httpHeaders)
.contentType(MediaType.APPLICATION_OCTET_STREAM)
//最后ResponseEntity中的body,则是由new DefaultDataBufferFactory().wrap包裹响应出去
.body(Flux.just(data).map(b -> new DefaultDataBufferFactory().wrap(b)));
return Mono.just(body);
});
}

/**
* 压缩多个文件并转为字节数组
*
* @param filepathList 文件路径列表
* @return {@link byte[]}
*/
@SneakyThrows
private static byte[] zipFileToByteArray(Stream<String> filepathList) {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream);
filepathList.forEach(file -> addToZipFile(file, zipOutputStream));
zipOutputStream.close();
return byteArrayOutputStream.toByteArray();
}

/**
* 压缩某一个文件到返回的zip文件中
*
* @param fileName 文件名
* @param zos 佐斯
*/
@SneakyThrows
private static void addToZipFile(String fileName, ZipOutputStream zos){
File file = new File(fileName);
try (FileInputStream fis = new FileInputStream(file);){
//这里的fileName可以控制生成的压缩包当中的文件的嵌套层级关系
ZipEntry zipEntry = new ZipEntry(FilenameUtils.getName(fileName));
zos.putNextEntry(zipEntry);

byte[] bytes = new byte[1024];
int length;
while ((length = fis.read(bytes)) >= 0) {
zos.write(bytes, 0, length);
}

zos.closeEntry();
}

}

3.1 响应的对象为什么是Mono<ResponseEntity<Flux<DataBuffer>>>

可能会有人有疑问,有必要嵌套这么多层吗? 其实如果只有ResponseEntity<Flux<DataBuffer>> 也是可以的,但是如果你这么写那么就和传统的MVC写法一样了,我们都知道在响应式编程中,只有subscribes 的时候才会真正出发整个流的执行,然后生成元素,所以我们需要返回的是Mono<ResponseEntity> 而不是ResponseEntity<T>

接着ResponseEntity<Flux<DataBuffer>> ,有必要再包裹一层吗?

使用ResponseEntity<Flux<DataBuffer>>来包裹Flux<DataBuffer>。这是因为在处理流式数据时,通常使用Flux来表示一系列的数据块。这种方式可以异步地将数据块逐个发送到客户端,而不是等待整个响应的数据都准备好后再发送。这种异步处理可以提高性能并降低内存消耗。