spring mvc异步请求 sse 大文件下载 断点续传下载Range

news2025/4/7 18:59:18

学习连接

异步Servlet3.0

Spring Boot 处理异步请求(DeferredResult 基础案例、DeferredResult 超时案例、DeferredResult 扩展案例、DeferredResult 方法汇总)

spring.io mvc Asynchronous Requests 官网文档

spring.io webflux&webclient官网文档

SpringBoot+vue 大文件分片下载
SpringBoot+vue文件上传&下载&预览&大文件分片上传&文件上传进度
spring mvc异步请求 & sse & 大文件下载 & 断点续传下载

文章目录

    • 学习连接
    • springmvc异步请求
      • DeferredResult
        • 示例
      • Callable
        • 示例
      • 异步处理
        • springmvc异步处理流程
        • Exception Handling异常处理
        • Interception拦截
        • 与WebFlux相比
      • HTTP流(ResponseBodyEmitter )
        • 示例
      • sse(SseEmitter)
      • 原数据直传
        • 示例(文件下载)
          • 后端代码
          • 前端代码
        • 示例(大文件下载)
          • 后端代码
          • 前端代码1
          • 前端代码2
          • 前端代码3
        • 断点续传概念
          • 概述
          • Range
          • Content-Range
      • 响应式类型
      • 断开连接
      • 配置
        • Servlet容器
        • Spring MVC

springmvc异步请求

Spring MVC广泛接入Servlet 3.0异步请求处理:

  • DeferredResult和Callable返回值,并为单个异步返回值提供基本支持。
  • 控制器可以流式传输多个值,包括SSE和原始数据。
  • 控制器可以使用反应式客户端并返回响应处理的反应式类型。

DeferredResult

一旦在Servlet容器中启用了异步请求处理特征,控制器方法就可以用DeferredResult包装任何支持的控制器方法返回值,如下例所示:

@GetMapping("/quotes")
@ResponseBody
public DeferredResult<String> quotes() {
    DeferredResult<String> deferredResult = new DeferredResult<String>();
    // Save the deferredResult somewhere..
    return deferredResult;
}

// From some other thread...
deferredResult.setResult(result);

控制器可以从不同的线程异步地产生返回值——例如,响应外部事件(JMS消息)、定时任务或其他事件。

示例
@Slf4j
@RestController
@RequestMapping("/async")
public class TestController {

    @RequestMapping("testDeferred")
    public DeferredResult<String> testDeferred(Long timeoutValue) {
        log.info("testDeferred");

        // 1、timeoutValue为null,表示不超时.
        // 2、如果超时了,将返回这里默认值timeoutResult(不会影响给deferredResult设置值的线程)
        DeferredResult<String> deferredResult = new DeferredResult<>(timeoutValue,
                ()->{return "timeoutValue";}
        );

        new Thread(()->{
            try {
                log.info("异步处理 start");
                TimeUnit.SECONDS.sleep(5);
                // 此方法可以检测是否已超时,
                // 也就是即使超时,当前new的线程也会继续执行,下面setResult方法也会执行,只是不会把设置的值给前端,因为超时的默认值已经给了。
                log.info("是否超时: {}", deferredResult.isSetOrExpired());
            } catch (Exception e) {
                log.info("异步处理异常: {}", e);
                deferredResult.setErrorResult("error~");
                return;
            }
            deferredResult.setResult("testDeferred~");
            log.info("异步处理 end");
        }).start();

        log.info("testDeferred end");
        return deferredResult;
    }

}

Callable

控制器可以使用java.util.concurrent.Callable包装任何支持的返回值,如下例所示:

@ResponseBody
@RequestMapping("test11")
public Callable<String> processUpload() {

    return new Callable<String>() {
        public String call() throws Exception {
            log.info("call");
            return "data";
        }
    };
}

该callable会交给配置的taskExecutor执行。

示例
@RequestMapping("testCallable")
public Callable<String> testCallable(Integer timeout) {
     log.info("testCallable start");

     Callable<String> callable = new Callable<String>() {
         @Override
         public String call() throws Exception {
             TimeUnit.SECONDS.sleep(timeout);
             // task-1线程执行的
             log.info("testCallable 异步执行");
             return "call result";
         }
     };

     log.info("testCallable end");
     return callable;
 }

异步处理

springmvc异步处理流程

以下是Servlet异步请求处理的非常简洁的概述:

  • 可以通过调用request.startAsync()将ServletRequest置于异步模式。这样做的主要效果是Servlet(以及任何过滤器)可以退出,但响应保持打开状态,以便稍后完成处理。
  • 对request.startAsync()的调用返回AsyncContext,您可以使用它来进一步控制异步处理。例如,它提供了dispatch方法,类似于Servlet API的转发,只是它允许应用程序在Servlet容器线程上继续请求处理。
  • ServletRequest可以访问到对当前的DispatcherType,您可以使用它来区分处理初始请求、异步调度、转发和其他类型。

DeferredResult处理流程如下:

  • 控制器返回一个DeferredResult并将其保存在某个可以访问的内存的队列或列表中。
  • Spring MVC调用request.startAsync()。
  • 同时,DispatcherServlet和所有配置的过滤器退出请求处理线程,但响应保持打开状态。
  • 应用程序从某个线程设置DeferredResult,Spring MVC将请求分派(dispatcher)回Servlet容器。
  • 再次调用DispatcherServlet,并使用异步生成的返回值恢复处理

Callable处理流程如下:

  • 控制器返回一个Callable。
  • Spring MVC调用request.startAsync()并将Callable提交给TaskExecutor以在单独的线程中进行处理。
  • 同时,DispatcherServlet和所有过滤器退出Servlet容器线程,但响应保持打开状态。
  • 最终Callable产生一个结果,Spring MVC将请求分派回Servlet容器以完成处理。
  • 再次调用DispatcherServlet,并使用来自Callable的异步生成的返回值恢复处理。

有关进一步的背景和上下文,您还可以阅读在Spring MVC 3.2中介绍异步请求处理支持的博客文章。

(经过查看DeferredResultMethodReturnValueHandler,发现还可以返回ListenableFuture、CompletionStage类型的返回值。
源码的重点是在:WebAsyncManager的创建和使用、StandardServletAsyncWebRequest对异步请求的封装、RequestMappingHandlerAdapter#invokeHandlerMethod对分发之后的处理)

Exception Handling异常处理

当您使用DeferredResult时,您可以选择是调用setResult还是setErrorResult并带有异常。在这两种情况下,Spring MVC都会将请求分派回Servlet容器以完成处理。然后将其视为控制器方法返回给定值或产生给定异常。然后异常通过常规异常处理机制(例如,调用@ExceptionHandler方法)。

当您使用Callable时,会出现类似的处理逻辑,主要区别在于结果是从Callable返回的,或者由Callable引发异常。

Interception拦截

HandlerInterceptor实例可以是AsyncHandlerInterceptor类型,以在初始请求启动异步处时接收afterConcurrentHandlingStarted回调(而不是postHandle和afterCompletion)。

HandlerInterceptor实现还可以注册CallableProcessingInterceptor或DeferredResultProcessingInterceptor,以便更深入地与异步请求的生命周期集成(例如,处理超时事件)。AsyncHandlerInterceptor了解更多详细信息。

DeferredResult提供了onTimeout(Runnable)和onCompletion(Runnable)回调。有关详细信息,请参阅DeferredResultjavadoc。Callable可以替换为公开超时和完成回调的其他方法的WebAsyncTask。

与WebFlux相比

Servlet API最初是为Filter-Servlet链而构建的。Servlet 3.0中添加的异步请求处理允许应用程序退出Filter-Servlet链,但保留响应以供进一步处理。Spring MVC异步支持是围绕这种机制构建的。当控制器返回DeferredResult时,Filter-Servlet链就会退出,Servlet容器线程就会释放。稍后,当设置DeferredResult时,会进行ASYNC分派(到同一个URL),在此期间,控制器会再次映射,但不会调用它,而是使用DeferredResult值(就像控制器返回它一样)来恢复处理。

相比之下,Spring WebFlux既不基于Servlet API构建,也不需要这样的异步请求处理特征,因为它在设计上是异步的。异步处理内置于所有框架契约中,并在请求处理的所有阶段得到支持。

从编程模型的角度来看,Spring MVC和Spring WebFlux都支持异步和响应式类型作为控制器方法中的返回值。Spring MVC甚至支持流,包括响应式背压机制。但是,对响应的单个写入仍然是阻塞的(并且在单独的线程上执行),这与WebFlux不同,WebFlux依赖于非阻塞io,并且每次写入不需要额外的线程。

另一个根本区别是Spring MVC不支持controller方法参数中的异步或反应式类型(例如@RequestBody、@RequestPart等),也不支持将异步和反应式类型作为Model属性。而webflux支持所有。

HTTP流(ResponseBodyEmitter )

您可以对单个异步返回值使用DeferredResult和Callable。如果您想生成多个异步值并将它们写入响应怎么办?本节介绍如何执行此操作。

您可以使用ResponseBodyEmitter返回值生成对象流,其中每个对象都使用HttpMessageConverter序列化并写入响应,如下例所示:

@GetMapping("/events")
public ResponseBodyEmitter handle() {
    ResponseBodyEmitter emitter = new ResponseBodyEmitter();
    // Save the emitter somewhere..
    return emitter;
}

// In some other thread
emitter.send("Hello once");

// and again later on
emitter.send("Hello again");

// and done at some point
emitter.complete();

您还可以使用ResponseBodyEmitter作为ResponseEntity中的主体,让您自定义响应的状态和标头。

当emitter抛出IOException(例如,如果远程客户端断开连接)时,应用程序不负责清理连接,也不应调起emitter.complete或emitter.completeWithErrorError。相反,servlet容器会自动启动AsyncListener错误通知,其中Spring MVC进行completeWithError调用。该调用反过来执行对应用程序的最后一次ASYNC分发,然后Spring MVC调用配置的异常解析器并完成请求。

(查看ResponseBodyEmitterReturnValueHandler,得知ResponseBodyEmitter也是基于DeferredResult来实现的)

示例
@RequestMapping("emitter")
public ResponseEntity<ResponseBodyEmitter> responseBodyEmitter() {
    log.info("testEmitter start");

    ResponseBodyEmitter emitter = new ResponseBodyEmitter(10000L);

    emitter.onCompletion(() -> {
        log.info("testEmitter onCompletion");
    });
    emitter.onTimeout(() -> {
        log.info("testEmitter onTimeout");
    });
    emitter.onError((e) -> {
        log.info("testEmitter onError: {}", e);
    });

    new Thread(()->{
        for (int i = 0; i < 10; i++) {
            try {
                emitter.send("testEmitter~" + i);
                log.info("testEmitter~" + i);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
            try {
                TimeUnit.MILLISECONDS.sleep(500);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            if (i > 5) {
                emitter.completeWithError(new ArithmeticException("计算错误"));
            }
        }
        log.info("发送完毕~");
        emitter.complete();
    }).start();

    log.info("testEmitter end");
    HttpHeaders httpHeaders = new HttpHeaders();
    httpHeaders.add("Access-Control-Allow-Origin", "*");
    httpHeaders.add("Content-Type", "text/html");
    httpHeaders.add("Cache-Control", "no-cache");
    httpHeaders.add("Transfer-Encoding", "chunked");
    return new ResponseEntity<ResponseBodyEmitter>(emitter, httpHeaders, HttpStatus.OK);
}
<!DOCTYPE html>
<html>
<head>
    <title>分块数据实时展示</title>
</head>
<body>
    <h1>实时数据接收:</h1>
    <div id="output" style="border: 1px solid #ccc; padding: 10px; height: 300px; overflow-y: auto;"></div>

    <script>
        // 要实现浏览器逐步接收并显示分块传输的数据,可以通过以下步骤使用 Fetch API(原生支持流式响应)配合前端实时渲染。
        // 启动请求并处理流式响应
        async function fetchStreamData() {
            const outputDiv = document.getElementById('output');
            
            try {
                const response = await fetch('http://localhost:8080/async/emitter');

                // 获取可读流
                const reader = response.body.getReader();
                const decoder = new TextDecoder();

                // 持续读取数据块
                while (true) {
                    const { done, value } = await reader.read();
                    if (done) break; // 流结束

                    // 解码数据并追加到页面
                    const chunk = decoder.decode(value, { stream: true });
                    outputDiv.innerHTML += chunk;
                    outputDiv.scrollTop = outputDiv.scrollHeight; // 自动滚动到底部
                }

                console.log('数据接收完成');
            } catch (error) {
                console.error('请求失败:', error);
                outputDiv.innerHTML += '请求失败: ' + error.message;
            }
        }

        // 页面加载后自动启动
        fetchStreamData();
    </script>
</body>
</html>

sse(SseEmitter)

SseEmitter(ResponseBodyEmitter的子类)支持Server-Sent Events,其中从服务器发送的事件根据W3C SSE规范进行格式化。要从控制器生成SSE流,返回SseEmitter,如下例所示:

@GetMapping(path="/events", produces=MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter handle() {
    SseEmitter emitter = new SseEmitter();
    // Save the emitter somewhere..
    return emitter;
}

// In some other thread
emitter.send("Hello once");

// and again later on
emitter.send("Hello again");

// and done at some point
emitter.complete();

虽然SSE是流式传输到浏览器的主要选项,但请注意,IE不支持 Server-Sent Events。考虑将Spring的WebSocket与SockJS作为兜底(包括SSE)一起使用以覆盖大部分浏览器。

有关异常处理的说明,请参见HTTP流处理章节。

(使用与ResponseBodyEmitter完全一致,因为就是基于ResponseBodyEmitter。)

留意下:StreamingHttpOutputMessage 这个

原数据直传

有时,绕过消息转换机制并直接通过响应输出流(OutputStream)进行流式传输非常有用(例如实现文件下载功能)。为此,您可以将返回值的类型设为StreamingResponseBody,如下方示例所示:

@GetMapping("/download")
public StreamingResponseBody handle() {

    return new StreamingResponseBody() {
        @Override
        public void writeTo(OutputStream outputStream) throws IOException {
            // write...
        }
    };
}

您可以使用StreamingResponseBody作为ResponseEntity中的主体来自定义响应的状态和标头。

(它内部是通过WebAsyncTask包装Callbale来实现的。它相比于直接使用repsonse的outputStream写入,是异步的,不会阻塞处理请求的线程。它支持分块传输吗?这点存疑。)

示例(文件下载)
后端代码
@GetMapping("/download")
public ResponseEntity<StreamingResponseBody> handle() {
    File file = new File("D:\\Projects\\practice\\demo-boot\\src\\main\\resources\\test.png");
    StreamingResponseBody streamingResponseBody = new StreamingResponseBody() {
        @Override
        public void writeTo(OutputStream outputStream) throws IOException {
            byte[] buffer = new byte[1024];
            FileInputStream fis = new FileInputStream(file);
            int len = -1;
            while ((len = fis.read(buffer)) != -1) {
                outputStream.write(buffer, 0, len);
            }
            outputStream.flush();
            fis.close();
        }
    };
    return ResponseEntity.ok()
            .header(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "*")
            .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getName() + "\"")
            .contentType(MediaType.APPLICATION_OCTET_STREAM)
            .contentLength(file.length())
            .body(streamingResponseBody);
前端代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>File Download Example</title>
</head>
<body>
    <h1>File Download Example</h1>
    
    <button onclick="downloadWithFetch()">Download with Fetch (Streaming)</button>
    
    <script>
        async function downloadWithFetch(filename) {
            try {
                const response = await fetch(`http://127.0.0.1:8080/async/download`);
                
                console.log(response.ok);
                if (!response.ok) {
                    throw new Error('File not found');
                }
                
                // 获取文件名,可以从Content-Disposition头部解析
                let downloadFilename = 'demo.png';
                const contentDisposition = response.headers.get('Content-Disposition');
                if (contentDisposition && contentDisposition.indexOf('filename=') !== -1) {
                    downloadFilename = contentDisposition.split('filename=')[1].replace(/"/g, '');
                }
                
                // 创建Blob对象并下载
                const blob = await response.blob();
                const url = window.URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.href = url;
                a.download = downloadFilename;
                document.body.appendChild(a);
                a.click();
                
                // 清理
                window.URL.revokeObjectURL(url);
                document.body.removeChild(a);
            } catch (error) {
                console.error('Download failed:', error);
                alert('Download failed: ' + error.message);
            }
        }
    </script>
</body>
</html>
示例(大文件下载)
后端代码
@Slf4j
@RestController
@RequestMapping("/download")
public class LargeFileDownloadController {

    // 配置线程池(用于异步处理)
    // 或 Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

    // 安全下载目录
    private static final Path SAFE_BASE_DIR = Paths.get("D:\\Projects\\practice\\demo-boot\\file");

    @CrossOrigin(origins = "*",
            methods = {RequestMethod.GET, RequestMethod.HEAD, RequestMethod.POST},
            allowedHeaders = "*",
            exposedHeaders = {"Accept-Ranges", "Content-Range", "Content-Type", "Content-Length"}
    )
    @GetMapping("/{fileName}")
    public ResponseEntity<StreamingResponseBody> downloadLargeFile(
            HttpServletRequest request,
            HttpServletResponse response,
            @PathVariable String fileName,
            @RequestHeader(value = "Range", required = false) String rangeHeader) {


        // 1. 安全校验
        if (!isValidFileName(fileName)) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
        }

        Path filePath = SAFE_BASE_DIR.resolve(fileName).normalize();

        // 2. 文件存在性检查
        if (!Files.exists(filePath) || Files.isDirectory(filePath)) {
            return ResponseEntity.notFound().build();
        }

        try {
            // 3. 获取文件信息
            long fileSize = Files.size(filePath);

            if (request.getMethod().equals("HEAD")) {
                return ResponseEntity.status(HttpStatus.OK)
                        .header("Accept-Ranges", "bytes")
                        .contentLength(fileSize)
                        .body(null);
            }

            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
            headers.setContentDisposition(ContentDisposition.builder("attachment").filename(fileName).build());

            log.info("Range请求头: {}", rangeHeader);

            // 4. 处理断点续传(Range请求)
            if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
                log.info("断点续传请求");
                return handleRangeRequest(filePath, fileSize, rangeHeader);
            }

            log.info("完整文件下载请求");

            // 5. 完整文件下载
            headers.setContentLength(fileSize);

            StreamingResponseBody responseBody = output -> {
                try (InputStream is = Files.newInputStream(filePath)) {
                    byte[] buffer = new byte[64 * 1024]; // 64KB缓冲区
                    int bytesRead;
                    while ((bytesRead = is.read(buffer)) != -1) {
                        output.write(buffer, 0, bytesRead);
                        log.info("写入字节数: {}", bytesRead);
                        output.flush();
                    }
                } catch (IOException e) {
                    log.error("文件下载中断", e);
                    throw new RuntimeException(e);
                }
            };

            return ResponseEntity.ok()
                    .headers(headers)
                    .body(responseBody);

        } catch (IOException e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }

    // 处理断点续传(HTTP 206 Partial Content)
    private ResponseEntity<StreamingResponseBody> handleRangeRequest(
            Path filePath, long fullSize, String rangeHeader) throws IOException {
        
        // 解析Range头(示例简化实现)
        String[] ranges = rangeHeader.substring(6).split("-");
        long start = Long.parseLong(ranges[0]);
        long end = ranges.length > 1 ? Long.parseLong(ranges[1]) : fullSize - 1;

        long contentLength = end - start + 1;

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
        headers.setContentLength(contentLength);
        headers.set("Content-Range", String.format("bytes %d-%d/%d", start, end, fullSize));

        StreamingResponseBody responseBody = output -> {
            try (RandomAccessFile raf = new RandomAccessFile(filePath.toFile(), "r")) {
                byte[] buffer = new byte[64 * 1024];
                raf.seek(start);
                log.info("RandomAccessFile跳转到: {}", start);

                // 还需要读的剩余字节数
                long remaining = contentLength;

                while (remaining > 0) {
                    int readSize = (int) Math.min(buffer.length, remaining);
                    int bytesRead = raf.read(buffer, 0, readSize);
                    log.info("读取字节数: {}", bytesRead);
                    if (bytesRead == -1) {
                        log.info("无数据可读了");
                        break;
                    }
                    output.write(buffer, 0, bytesRead);
                    output.flush();
                    remaining -= bytesRead;
                }

                log.info("读完了: {}", remaining);
            }
        };

        return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
                .headers(headers)
                .body(responseBody);
    }

    // 校验文件名合法性(防止路径遍历)
    private boolean isValidFileName(String fileName) {
        return fileName.matches("[a-zA-Z0-9_\\-]+\\.?[a-zA-Z0-9_\\-]+");
    }
}
前端代码1

1、不占用tomcat的线程;
2、支持断点续传需要客户端支持;
3、前端能够看到进度
在这里插入图片描述

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>ajax 文件导出</title>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>

<body>
    <button type="button" onclick="downloadFile()">下载</button>

    <script type="text/javascript">
        const downloadFile = async () => {
            try {
                const response = await axios({
                    method: 'get',
                    url: `http://127.0.0.1:8080/download/test.mp4`,
                    responseType: 'blob',
                    onDownloadProgress: (progressEvent) => {
                        const percent = Math.round(
                            (progressEvent.loaded * 100) / progressEvent.total
                        );
                        console.log(`下载进度: ${percent}%`);
                    },
                });

                // 创建下载链接
                const url = window.URL.createObjectURL(new Blob([response.data]));
                const link = document.createElement('a');
                link.href = url;
                link.setAttribute('download', 'test.mp4');
                document.body.appendChild(link);
                link.click();
                link.remove();
            } catch (error) {
                console.error('下载失败:', error);
                if (error.response?.status === 404) {
                    alert('文件不存在');
                }
            }
        };
    </script>
</body>

</html>
前端代码2

在这里插入图片描述

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>断点续传下载示例</title>
    <style>
        .container {
            max-width: 600px;
            margin: 20px auto;
            padding: 20px;
            box-shadow: 0 0 10px rgba(0,0,0,0.1);
        }

        .progress-container {
            width: 100%;
            height: 20px;
            background-color: #f0f0f0;
            border-radius: 10px;
            margin: 20px 0;
        }

        .progress-bar {
            height: 100%;
            background-color: #4CAF50;
            border-radius: 10px;
            transition: width 0.3s ease;
        }

        button {
            padding: 10px 20px;
            background-color: #4CAF50;
            color: white;
            border: none;
            border-radius: 5px;
            cursor: pointer;
        }

        button:hover {
            background-color: #45a049;
        }

        #status {
            margin-top: 10px;
            color: #666;
        }

        #downloadLink {
            display: none;
            margin-top: 20px;
            color: #2196F3;
            text-decoration: none;
        }
    </style>
</head>
<body>
    <div class="container">
        <button id="controlBtn">开始下载</button>
        <div class="progress-container">
            <div id="progressBar" class="progress-bar" style="width: 0%"></div>
        </div>
        <div id="status">准备就绪</div>
        <a id="downloadLink" download>下载文件</a>
    </div>

    <script>
        const fileUrl = 'http://127.0.0.1:8080/download/test.mp4'; // 替换为实际文件URL
        let controller = null;
        let isDownloading = false;
        let receivedBytes = 0;
        let totalBytes = 0;
        let chunks = [];
        
        // 初始化IndexedDB
        const initDB = () => {
            return new Promise((resolve, reject) => {
                const request = indexedDB.open('ResumableDownloadDB', 1);
                
                request.onupgradeneeded = (event) => {
                    const db = event.target.result;
                    if (!db.objectStoreNames.contains('downloads')) {
                        db.createObjectStore('downloads', { keyPath: 'url' });
                    }
                };

                request.onsuccess = () => resolve(request.result);
                request.onerror = () => reject(request.error);
            });
        };

        // 保存下载进度
        const saveProgress = async () => {
            const db = await initDB();
            const transaction = db.transaction('downloads', 'readwrite');
            const store = transaction.objectStore('downloads');
            store.put({ url: fileUrl, receivedBytes, chunks });
        };

        // 加载下载进度
        const loadProgress = async () => {
            const db = await initDB();
            return new Promise((resolve) => {
                const transaction = db.transaction('downloads');
                const store = transaction.objectStore('downloads');
                const request = store.get(fileUrl);
                
                request.onsuccess = () => {
                    if (request.result) {
                        receivedBytes = request.result.receivedBytes;
                        chunks = request.result.chunks || [];
                    }
                    resolve();
                };
            });
        };

        // 更新进度显示
        const updateProgress = () => {
            const progress = (receivedBytes / totalBytes * 100).toFixed(1);
            document.getElementById('progressBar').style.width = `${progress}%`;
            document.getElementById('status').textContent = 
                `已下载 ${progress}% (${formatBytes(receivedBytes)} / ${formatBytes(totalBytes)})`;
        };

        // 字节单位转换
        const formatBytes = (bytes) => {
            if (bytes === 0) return '0 B';
            const units = ['B', 'KB', 'MB', 'GB'];
            const i = Math.floor(Math.log(bytes) / Math.log(1024));
            return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
        };

        // 开始/暂停下载
        const toggleDownload = async () => {
            if (isDownloading) {
                // 暂停下载
                controller.abort();
                isDownloading = false;
                await saveProgress();
                document.getElementById('controlBtn').textContent = '继续下载';
            } else {
                // 开始/继续下载
                isDownloading = true;
                document.getElementById('controlBtn').textContent = '暂停下载';
                
                try {
                    await loadProgress();
                    
                    // 获取文件大小
                    if (totalBytes === 0) {
                        const headRes = await fetch(fileUrl, { method: 'HEAD' });
                        totalBytes = parseInt(headRes.headers.get('Content-Length'), 10);
                        if (!headRes.headers.get('Accept-Ranges')) {
                            throw new Error('服务器不支持断点续传');
                        }
                    }

                    controller = new AbortController();
                    const response = await fetch(fileUrl, {
                        headers: { 'Range': `bytes=${receivedBytes}-` },
                        signal: controller.signal
                    });

                    if (response.status !== 206) {
                        throw new Error('服务器不支持范围请求');
                    }

                    const reader = response.body.getReader();
                    
                    while (true) {
                        const { done, value } = await reader.read();
                        if (done) break;

                        chunks.push(value.buffer);
                        receivedBytes += value.byteLength;
                        updateProgress();
                    }

                    // 下载完成
                    const blob = new Blob(chunks);
                    const url = URL.createObjectURL(blob);
                    
                    // 清理数据库记录
                    const db = await initDB();
                    const transaction = db.transaction('downloads', 'readwrite');
                    transaction.objectStore('downloads').delete(fileUrl);
                    
                    // 显示下载链接
                    document.getElementById('downloadLink').href = url;
                    document.getElementById('downloadLink').style.display = 'inline';
                    document.getElementById('status').textContent = '下载完成';
                } catch (err) {
                    if (err.name === 'AbortError') {
                        console.log('下载已暂停');
                    } else {
                        console.error('下载错误:', err);
                        document.getElementById('status').textContent = `错误: ${err.message}`;
                    }
                }
                isDownloading = false;
                document.getElementById('controlBtn').textContent = '开始下载';
            }
        };

        document.getElementById('controlBtn').addEventListener('click', toggleDownload);
    </script>
</body>
</html>
前端代码3

在这里插入图片描述

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>

</head>

<body>
    <button onClick="downloadChunks()">下载</button>
</body>
<script>
    function downloadChunks() {
        const chunkdownloadUrl = 'http://localhost:8080/download/test.mp4'

        // 分片下载大小 5MB
        const chunkSize = 1024 * 1024 * 20;

        // 文件总大小(需要请求后端获得)
        let fileSize = 0;

        axios
            .head(chunkdownloadUrl)
            .then(res => {

                // 定义 存储所有的分片的数组
                let chunks = [];

                // 获取文件总大小
                fileSize = res.headers['content-length']

                // 计算分片数量
                const chunksNum = Math.ceil(fileSize / chunkSize)

                // 定义下载文件分片的方法
                function downloadChunkFile(chunkIdx) {

                    if (chunkIdx >= chunksNum) {
                        alert('分片索引不可超过分片数量')
                        return
                    }

                    let start = chunkIdx * chunkSize
                    let end = Math.min(start + chunkSize - 1, fileSize - 1)
                    const range = `bytes=${start}-${end}`;

                    axios({
                        url: chunkdownloadUrl,
                        method: 'post',
                        headers: {
                            Range: range
                        },
                        responseType: 'arraybuffer'
                    }).then(response => {
                        chunks.push(response.data)
                        if (chunkIdx == chunksNum - 1) {
                            // 下载好了
                            console.log(chunks, 'chunks');
                            // 组合chunks到单个文件
                            const blob = new Blob(chunks);
                            console.log(blob, 'blob');
                            const link = document.createElement('a');
                            link.href = window.URL.createObjectURL(blob);
                            link.download = 'demo.mp4';
                            link.click();
                            return
                        } else {
                            ++chunkIdx
                            downloadChunkFile(chunkIdx)
                        }
                    })
                }

                downloadChunkFile(0)

            })


    }
</script>

</html>
断点续传概念
概述

所谓断点续传,其实只是指下载,也就是要从文件已经下载的地方开始继续下载。在以前版本的HTTP协议是不支持断点的,HTTP/1.1开始就支持了。一般断点下载时才用到Range和Content-Range实体头。HTTP协议本身不支持断点上传,需要自己实现。

Range

Range:用于客户端到服务端的请求,在请求头中,指定第一个字节的位置和最后一个字节的位置,可以通过改字段指定下载文件的某一段大小及其单位,字节偏移从0开始。典型格式:

Ranges: (unit=first byte pos)-[last byte pos]

  • Ranges: bytes=4000- 下载从第4000字节开始到文件结束部分

  • Ranges: bytes=0~N 下载第0-N字节范围的内容

  • Ranges: bytes=M-N 下载第M-N字节范围的内容

  • Ranges: bytes=-N 下载最后N字节内容

以下几点需要注意:

  • 这个数据区间是个闭合区间,起始值是0,所以“Range: bytes=0-1”这样一个请求实际上是在请求开头的2个字节。

  • “Range: bytes=-200”,它不是表示请求文件开始位置的201个字节,而是表示要请求文件结尾处的200个字节。

  • 如果last byte pos小于first byte pos,那么这个Range请求就是无效请求,server需要忽略这个Range请求,然后回应一个200,把整个文件发给client。

  • 如果last byte pos大于等于文件长度,那么这个Range请求被认为是不能满足的,server需要回应一个416,Requested range not satisfiable。

示例解释:

  • 表示头500个字节:bytes=0-499

  • 表示第二个500字节:bytes=500-999

  • 表示最后500个字节:bytes=-500

  • 表示500字节以后的范围:bytes=500-

  • 第一个和最后一个字节:bytes=0-0,-1

  • 同时指定几个范围:bytes=500-600,601-999

Content-Range

用于响应头,指定整个实体中的一部分的插入位置,他也指示了整个实体的长度。在服务器向客户返回一个部分响应,它必须描述响应覆盖的范围和整个实体长度。一般格式:
Content-Range: bytes (unit first byte pos) - [last byte pos]/[entity legth]

Header示例

GET /test.rar HTTP/1.1 
Connection: close 
Host: 116.1.219.219 
Range: bytes=0-801 //一般请求下载整个文件是bytes=0- 或不用这个头

一般正常回应

HTTP/1.1 200 OK 
Content-Length: 801      
Content-Type: application/octet-stream 
Content-Range: bytes 0-800/801 //801:文件总大小

一个最简单的断点续传实现大概如下:
1.客户端下载一个1024K的文件,已经下载了其中512K
2.网络中断,客户端请求续传,因此需要在HTTP头中申明本次需要续传的片段:Range:bytes=512000-
这个头通知服务端从文件的512K位置开始传输文件
3. 服务端收到断点续传请求,从文件的512K位置开始传输,并且在HTTP头中增加:
Content-Range:bytes 512000-/1024000
并且此时服务端返回的HTTP状态码应该是206,而不是200。

但是在实际场景中,会出现一种情况,即在终端发起续传请求时,URL对应的文件内容在服务端已经发生变化,此时续传的数据肯定是错误的。如何解决这个问题了?显然此时我们需要有一个标识文件唯一性的方法。在RFC2616中也有相应的定义,比如实现Last-Modified来标识文件的最后修改时间,这样即可判断出续传文件时是否已经发生过改动。同时RFC2616中还定义有一个ETag的头,可以使用ETag头来放置文件的唯一标识,比如文件的MD5值。

终端在发起续传请求时应该在HTTP头中申明If-Match 或者If-Modified-Since 字段,帮助服务端判别文件变化。

另外RFC2616中同时定义有一个If-Range头,终端如果在续传是使用If-Range。If-Range中的内容可以为最初收到的ETag头或者是Last-Modfied中的最后修改时候。服务端在收到续传请求时,通过If-Range中的内容进行校验,校验一致时返回206的续传回应,不一致时服务端则返回200回应,回应的内容为新的文件的全部数据。

响应式类型

Spring MVC支持在控制器中使用响应式客户端库(也可以阅读WebFlux部分中的响应式库)。这包括来自spring-webflux的WebClient和其他,例如Spring Data响应式数据存储库。在这种情况下,能够从控制器方法返回响应式类型很方便。

反应式返回值处理如下:

  • 单值promise(promise)会被自动适配,其处理方式与使用DeferredResult类似。例如:Reactor框架的Mono或RxJava的Single均支持这种适配。

  • 对于采用流式媒体类型(如 application/stream+json 或 text/event-stream)的多值流,框架会自动适配,其处理方式类似于使用 ResponseBodyEmitter 或 SseEmitter。例如:Reactor 的 Flux 或 RxJava 的 Observable 均支持此类适配。应用程序也可以直接返回 Flux<ServerSentEvent> Observable<ServerSentEvent>

  • 对于使用其他任何媒体类型(例如 application/json)的多值流,框架会将其适配为类似 DeferredResult<List<?>> 的处理方式。

Spring MVC通过spring-core的ReactiveAdapterRegistry支持React和RxJava,这使得它可以适应多个反应式库。

对于流式传输到响应,支持反应式反压机制,但对响应的写入仍然是阻塞的,并且通过配置 TaskExecutor在单独的线程上运行,以避免阻塞上游源(例如从WebClient返回的Flux)。默认情况下,SimpleAsyncTaskExecutor用于阻塞写入,但在负载下不适合。如果您计划使用反应式类型流式传输,您应该使用MVC配置来配置任务执行器。

断开连接

当远程客户端消失时,Servlet API不提供任何通知。因此,在流式传输响应时,无论是通过SseEmitter还是反应式类型,定期发送数据都很重要,因为如果客户端断开连接,写入就会失败。发送可以采取空(仅注释)SSE事件或任何其他数据的形式,对方必须将其解释为心跳并忽略。

或者,考虑使用具有内置心跳机制的Web消息传递解决方案(例如基于WebSocket的STOMP或带有SockJS的WebSocket)。

配置

必须在Servlet容器级别启用异步请求处理特征。MVC配置还公开了几个异步请求选项。

Servlet容器

过滤器和Servlet声明有一个true标志,需要设置为asyncSupported以启用异步请求处理。此外,应声明过滤器映射以处理ASYNC javax.servlet.DispatchType。

在Java配置中,当您使用AbstractAnnotationConfigDispatcherServletInitializer初始化Servlet容器时,这是自动完成的。

在web.xml配置中,您可以添加<async-supported>true</async-supported>到DispatcherServlet和Filter声明,并添加<dispatcher>ASYNC</dispatcher>到滤波器映射。

Spring MVC

MVC配置公开了以下与异步请求处理相关的选项:

  • Java配置:使用configureAsyncSupport回调/回传WebMvcConfigurer。
  • XML命名空间:使用<async-support>下的<mvc:annotation-driven>元素。

您可以配置以下内容:

  • 异步请求的默认超时值,如果未设置,则取决于底层Servlet容器。
  • AsyncTaskExecutor用于在使用反应式类型流式传输时阻止写入,以及执行从控制器方法返回的Callable实例。如果您使用反应式类型流式传输或具有返回Callable的控制器方法,我们强烈建议配置此属性,因为默认情况下,它是一个SimpleAsyncTaskExecutor。
  • DeferredResultProcessingInterceptor实现和CallableProcessingInterceptor实现。

请注意,您还可以在DeferredResult、ResponseBodyEmitter和SseEmitter上设置默认超时值SseEmitter对于Callable,您可以使用WebAsyncTask提供超时值。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2330014.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Opencv计算机视觉编程攻略-第十节 估算图像之间的投影关系

目录 1. 计算图像对的基础矩阵 2. 用RANSAC 算法匹配图像 3. 计算两幅图像之间的单应矩阵 4. 检测图像中的平面目标 图像通常是由数码相机拍摄的&#xff0c;它通过透镜投射光线成像&#xff0c;是三维场景在二维平面上的投影&#xff0c;这表明场景和它的图像之间以及同一…

14.流程自动化工具:n8n和家庭自动化工具:node-red

n8n 安装 docker方式 https://docs.n8n.io/hosting/installation/docker/ #https://hub.docker.com/r/n8nio/n8n docker pull n8nio/n8n:latest docker rm -f n8n; docker run -it \ --network macvlan --hostname n8n \ -e TZ"Asia/Shanghai" \ -e GENERIC_TIME…

图形渲染: tinyrenderer 实现笔记(Lesson 1 - 4)

目录 项目介绍环境搭建Lesson 1: Bresenham’s Line Drawing Algorithm&#xff08;画线算法&#xff09;Lesson 2: Triangle rasterization 三角形光栅化Scanline rendering 线性扫描Modern rasterization approach 现代栅格化方法back-face culling 背面剔除 Lesson 3: Hidde…

大规模硬件仿真系统的编译挑战

引言&#xff1a; 随着集成电路设计复杂度的不断提升&#xff0c;硬件仿真系统在现代芯片设计流程中扮演着越来越重要的角色。基于FPGA&#xff08;现场可编程门阵列&#xff09;的商用硬件仿真系统因其灵活性、全自动化、高性能和可重构性&#xff0c;成为验证大规模集成电路设…

记一次常规的网络安全渗透测试

目录&#xff1a; 前言 互联网突破 第一层内网 第二层内网 总结 前言 上个月根据领导安排&#xff0c;需要到本市一家电视台进行网络安全评估测试。通过对内外网进行渗透测试&#xff0c;网络和安全设备的使用和部署情况&#xff0c;以及网络安全规章流程出具安全评估报告。本…

【8】搭建k8s集群系列(二进制部署)之安装work-node节点组件(kubelet)

一、下载k8s二进制文件 下载地址&#xff1a; https://github.com/kubernetes/kubernetes/blob/master/CHANGELOG/CHANGELOG -1.20.md 注&#xff1a;打开链接你会发现里面有很多包&#xff0c;下载一个 server 包就够了&#xff0c;包含了 Master 和 Worker Node 二进制文件。…

使用 VIM 编辑器对文件进行编辑

一、VIM 的两种状态 VIM&#xff08;vimsual&#xff09;是 Linux/UNIX 系列 OS 中通用的全屏编辑器。vim 分为两种状态&#xff0c;即命令状态和编辑状态&#xff0c;在命令状态下&#xff0c;所键入的字符系统均作命令来处理&#xff1b;而编辑状态则是用来编辑文本资料&…

visual studio 2022的windows驱动开发

在visual studio2022中&#xff0c;若在单个组件中找不到Windows Driver Kit (WDK)选项&#xff0c;可通过提升vs版本解决&#xff0c;在首次选择时选择WDM 创建好项目在Source Files文件夹中创建一个test.c文件&#xff0c;并输入以下测试代码&#xff1a; #include <ntdd…

基于大数据的美团外卖数据可视化分析系统

【大数据】基于大数据的美团外卖数据可视化分析系统 &#xff08;完整系统源码开发笔记详细部署教程&#xff09;✅ 目录 一、项目简介二、项目界面展示三、项目视频展示 一、项目简介 该系统通过对海量外卖数据的深度挖掘与分析&#xff0c;能够为美团外卖平台提供运营决策支…

vue2打包部署到nginx,解决路由history模式下页面空白问题

项目使用的是vue2&#xff0c;脚手架vue-cli 4。 需求&#xff1a;之前项目路由使用的是hash&#xff0c;现在要求调整为history模式&#xff0c;但是整个过程非常坎坷&#xff0c;遇到了页面空白问题。现在就具体讲一下这个问题。 首先&#xff0c;直接讲路由模式由hash改为…

【数据结构】排序算法(中篇)·处理大数据的精妙

前引&#xff1a;在进入本篇文章之前&#xff0c;我们经常在使用某个应用时&#xff0c;会出现【商品名称、最受欢迎、购买量】等等这些榜单&#xff0c;这里面就运用了我们的排序算法&#xff0c;作为刚学习数据结构的初学者&#xff0c;小编为各位完善了以下几种排序算法&…

AI随身翻译设备:从翻译工具到智能生活伴侣

文章目录 AI随身翻译设备的核心功能1. 实时翻译2. 翻译策略3. 翻译流程4. 输出格式 二、AI随身翻译设备的扩展功能1. 语言学习助手2. 旅行助手3. 商务助手4. 教育助手5. 健康助手6. 社交助手7. 技术助手8. 生活助手9. 娱乐助手10. 应急助手 三、总结四、未来发展趋势&#xff0…

chromadb 安装和使用

简介 Chromadb 是一个开源的嵌入式向量数据库&#xff0c;专为现代人工智能和机器学习应用设计&#xff0c;旨在高效存储、检索和管理向量数据。以下是关于它的详细介绍&#xff1a; 核心特性 易于使用&#xff1a;提供了简洁直观的 API&#xff0c;即使是新手也能快速上手…

LabVIEW 在故障诊断中的算法

在故障诊断领域&#xff0c;LabVIEW 凭借其强大的图形化编程能力、丰富多样的工具包以及卓越的功能性能&#xff0c;成为工程师们进行故障诊断系统开发的得力助手。通过运用各种算法&#xff0c;能够对采集到的信号进行全面、深入的分析处理&#xff0c;从而准确地诊断出系统中…

springboot 启动方式 装配流程 自定义starter 文件加载顺序 常见设计模式

目录 springboot介绍 核心特性 快速搭建 Spring Boot 项目 方式一&#xff1a;使用 Spring Initializr 方式二&#xff1a;使用 IDE 插件 示例代码 1. 创建项目并添加依赖 2. 创建主应用类 3. 创建控制器类 4. 运行应用程序 配置文件 部署和监控 部署 监控 与其…

Android学习之Material Components

以下是 Material Design 提供的核心控件列表&#xff08;基于最新 Material Components for Android 库&#xff09;&#xff0c;按功能分类整理&#xff1a; 1. 基础按钮类 控件名称类名说明MaterialButtoncom.google.android.material.button.MaterialButton遵循 Material 规…

sentinel新手入门安装和限流,热点的使用

1 sentinel入门 1.1下载sentinel控制台 &#x1f517;sentinel管理后台官方下载地址 下载完毕以后就会得到一个jar包 1.2启动sentinel 将jar包放到任意非中文目录&#xff0c;执行命令&#xff1a; java -jar 名字.jar如果要修改Sentinel的默认端口、账户、密码&#xff…

Ubuntu 22 Linux上部署DeepSeek R1保姆式操作详解(Xinference方式)

一、安装步骤 1.基础环境安装 安装显卡驱动、cuda&#xff0c;根据自己硬件情况查找相应编号&#xff0c;本篇不介绍这部分内容&#xff0c;只给出参考指令&#xff0c;详情请读者自行查阅互联网其它参考资料。 sudo apt install nvidia-utils-565-server sudo apt install…

CTF类题目复现总结-hashcat 1

一、题目地址 https://buuoj.cn/challenges#hashcat二、复现步骤 1、下载附件&#xff0c;解压得到What kind of document is this_文件&#xff1b; 2、用010 Editor打开What kind of document is this_文件&#xff0c;发现是office文件&#xff1b; 3、将后缀名改为ppt时…

4月5日作业

需求&#xff1a; 1.按照图示的VLAN及IP地址需求&#xff0c;完成相关配置 2.要求SW 1为VLAN 2/3的主根及主网关 SW2为VLAN 20/30的主根及主网关&#xff0c;SW1和 SW2互为备份 3.可以使用super vlan 4.上层通过静态路由协议完成数据通信过程 5.AR1为企业出口路由器…