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来表示一系列的数据块。这种方式可以异步地将数据块逐个发送到客户端,而不是等待整个响应的数据都准备好后再发送。这种异步处理可以提高性能并降低内存消耗。

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×