原理

首先需要判断目标服务器是否支持断点续传
方法是在Header中添加Range字段,值格式为:bytes={开始下标}-{结束下标}(头尾包含),如 Range: bytes=10-20 表示获取第10字节到第20字节。

当 Range字段合法时服务器若返回206状态码,表示支持断点续传。

Range: bytes=0- 表示获取全部字节,我们需要先获取全部字节来得到文件的总长度,以及判断状态码是否是206。

然后使用线程池,让每个线程请求不同的Range分段,并用一个字节数组保存请求到的字节数据。当所有分段下载完成后把该字节数组写出到文件。

本例使用比较简单的实现方法,文件下载过程中数据全部保存在内存中,所以并不是真正的断点续传(重启程序就会丢失),且不适用于过大的文件。

依赖

<!-- https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp -->
        <dependency>
            <groupId>com.squareup.okhttp3</groupId>
            <artifactId>okhttp</artifactId>
            <version>4.8.1</version>
        </dependency>

基础方法

/**
     * 一次尝试连接
     *
     * @param url   url
     * @param start 文件开头
     * @param end   文件结尾
     * @return 响应对象
     * @throws IOException
     */
    public static CloseableHttpResponse getResponse(String url, Integer start, Integer end) throws IOException {
        CloseableHttpClient client = getCloseableHttpClient();
        HttpGet get = new HttpGet(url);
        int endIndex = url.indexOf("/", url.indexOf("//") + 2);
        get.addHeader("Referer", url.substring(0, endIndex));
        get.addHeader("Range", "bytes=" + (start != null ? start : 0) + "-" + (end != null ? end : ""));
        CloseableHttpResponse execute = client.execute(get);
        return execute;
    }

    /**
     * 生成http客户端
     *
     * @return http客户端
     */
    private static CloseableHttpClient getCloseableHttpClient() {
        int connectionRequestTimeout = 30 * 1000;
        RequestConfig config = RequestConfig.custom()
                .setConnectionRequestTimeout(connectionRequestTimeout)
                .setConnectTimeout(connectionRequestTimeout)
                .setSocketTimeout(connectionRequestTimeout).build();

        return HttpClients.custom()
                .setDefaultRequestConfig(config).build();
    }

    /**
     * 创建线程池
     *
     * @param name     线程池名称
     * @param coreSize 核心线程池大小
     * @return 线程池
     */
    public static ThreadPoolTaskExecutor getExecutor(String name, Integer coreSize) {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        //核心线程池大小
        executor.setCorePoolSize(coreSize);
        //最大线程数
        executor.setMaxPoolSize(coreSize);
        //队列容量
        executor.setQueueCapacity(1000);
        //活跃时间
        executor.setKeepAliveSeconds(300);
        //线程名字前缀
        executor.setThreadNamePrefix(name);

        // setRejectedExecutionHandler:当pool已经达到max size的时候,如何处理新任务
        // CallerRunsPolicy:不在新线程中执行任务,而是由调用者所在的线程来执行
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        // 等待所有任务结束后再关闭线程池
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.initialize();
        return executor;
    }

核心方法

private static void PoolDownload(String url, String filePath) throws IOException {
        //开始时间
        long start = System.currentTimeMillis();

        File file = new File(filePath);
        if (file.exists()) {
            file.delete();
        }
        File parentFile = file.getParentFile();
        if (!parentFile.exists()) {
            parentFile.mkdirs();
        }
        //第一次尝试连接 获取文件大小 以及是否支持断点续传
        CloseableHttpResponse response = getResponse(url, null, null);
        //状态码
        int statusCode = response.getStatusLine().getStatusCode();
        //文件总大小 这里直接转换为int比较方便计算 因为我们的目的是下载小文件 int足够使用
        int contentLength = Math.toIntExact(response.getEntity().getContentLength());
        //字节数组 用来存储下载到的数据 下载完成后写入到文件
        byte[] bytesFile = new byte[contentLength];

        //状态码 = 206 时表示支持断点续传
        if (statusCode == HttpStatus.SC_PARTIAL_CONTENT) {
            //创建线程池
            ThreadPoolTaskExecutor downloadExecutor = getExecutor("d", 10);
            int k = 1024;
            //分块大小 这里选择80k
            int step = 40 * k;
            //用来分配任务的数组下标
            int index = 0;
            while (index < contentLength) {
                int finalIndex = index;
                //提交任务
                downloadExecutor.execute(() -> {
                    //循环到成功
                    while (true) {
                        try {
                            //请求一个分块的数据
                            CloseableHttpResponse res = getResponse(url, finalIndex, finalIndex + step - 1);
                            HttpEntity entity = res.getEntity();
                            InputStream inputStream = entity.getContent();
                            //缓冲字节数组 大小4k
                            byte[] buffer = new byte[4 * k];
                            //读取到的字节数组长度
                            int readLength;
                            //分块内已读取到的位置下标
                            int totalRead = 0;
                            while ((readLength = inputStream.read(buffer)) > 0) {
                                //把读取到的字节数组复制到总的字节数组的对应位置
                                System.arraycopy(buffer, 0, bytesFile, finalIndex + totalRead, readLength);
                                //下标移动
                                totalRead += readLength;
                            }
                            EntityUtils.consume(entity);
                            //分段下载成功 结束任务
                            return;
                        } catch (IOException e) {
                            //分段下载失败 重新开始
                            log.warn(e.getMessage());
                        }
                    }

                });
                index += step;
            }
            //等待任务结束 这里用了一个比较粗糙的方法
            log.info("等待任务结束");
            do {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            } while (downloadExecutor.getActiveCount() > 0);
            downloadExecutor.shutdown();

            //把总字节数组写入到文件;
            FileOutputStream fos = new FileOutputStream(file);
            fos.write(bytesFile, 0, bytesFile.length);
            fos.flush();
            fos.close();


            long end = System.currentTimeMillis();

            log.info("{} 下载完毕 用时 {}毫秒 总速度:{}KB/s", filePath.substring(filePath.lastIndexOf("/") + 1), (end - start), contentLength * 1000 / 1024 / (end - start));

        }
    }