如何通过HTTP Range请求分段获取OSS资源(下载篇)

阿里巴巴OSS讲解 https://help.aliyun.com/zh/oss/how-to-obtain-oss-resources-by-segmenting-http-range-requests

一、先讲最重要的:InputStream 的本质是什么(脱离代码)

1、一句话定义(非常重要)

InputStream 不是"文件",而是:
👉 一个"按顺序读取字节的抽象通道"

它只关心三件事:

  • 有没有数据
  • 下一批数据在哪
  • 什么时候读完(返回 -1)

2、InputStream 的三大"本质特性"

1️⃣ 它是「顺序模型」,不是「随机访问」

int b = inputStream.read();

你永远只能:读"下一个字节"

你不能:跳到任意位置(那是 RandomAccessFile 的事)

所以 InputStream 天然适合:

  • 网络
  • 管道
  • 大文件
  • 流式处理

2️⃣ 它不关心数据来源

下面这些 本质上完全一样:

java 复制代码
new FileInputStream(...)
new SocketInputStream(...)
new GZIPInputStream(...)
new ByteArrayInputStream(...)

因为: InputStream 只是一个"水龙头接口"

至于水是井水、矿泉水、雨水,它不关心

3️⃣ read() = "向前消费"

int read(byte[] buf)

这意味着:

读过的数据:就"消失了"不能倒退

非常适合:大文件,实时数据

二、下面实现一个分片下载的FileInputStream

FileInputStream 在"逻辑上"做了什么?

一句话总览 把一个远程大文件 拆成多个 10MB 分片 后台并发下载 前台按

InputStream 顺序一个字节一个字节读

们把这个类拆成 5 层理解

🧱 第 1 层:文件分片模型

private static final long PART_SIZE = 10 * 1024 * 1024;

1、每个分片 10MB, 文件被拆成:

filePartCount = ceil(fileSize / PART_SIZE)

2、 Future = "未来会出现的文件"

List<Future > filePathList;

含义是: index 含义

0 第 0 个分片文件(还没下载)

1 第 1 个分片文件 ... ...

每一个 Future 表示:
"这个分片会在未来被下载到某个 Path"

3、线程池 = 后台下载引擎

Executors.newFixedThreadPool(Math.min(filePartCount, 5))

最多 5 个并发下载

控制带宽、内存、文件句柄

4、getInputStream() = 核心魔法

Path path = pathFuture.get();

return Files.newInputStream(path);

这里发生了什么?

如果这个分片没下载: 提交下载任务

阻塞等待当前分片完成

打开本地临时文件

返回一个 真正的文件 InputStream

👉 这一步是"假流 → 真文件流"的转换点

5、read() = 把多个流拼成一个

关键逻辑:

java 复制代码
int byteRead = currentInputStream.read(...);
if (byteRead == -1) {
    // 当前分片读完
    close
    deleteCurrentFile
    切换下一个分片
}

也就是说:

分片0 InputStream\] → EOF ↓ 自动切换 ↓ \[分片1 InputStream\] → EOF ↓ 自动切换

对调用方来说:

感觉就像一个连续的大文件

完全感知不到分片存在

6、这个 FileInputStream "欺骗"了谁?

它成功地"骗"了:

GZIPInputStream

InputStreamReader

BufferedReader

因为:

它完美遵守了 InputStream 的契约

👉 read() / -1 / 顺序读取

7、为什么这种设计非常高级?

1️⃣ 极度省内存

不下载完整文件

不缓存整个 gzip

只保留当前分片

2️⃣ IO 与计算并行

后台:下载

前台:解压 + 解析

3️⃣ 完美适配 Java IO 生态

任何接受 InputStream 的地方都能用

GZIP / JSON / CSV / XML 全通吃

8、InputStream 的"哲学总结"

InputStream 不是"文件读取工具",

而是 Java 世界里最重要的一种"数据抽象协议"

你这份代码,本质是在说:

"不管数据从哪里来,只要我能按顺序吐字节,

整个 Java IO 世界都会配合我工作。"

java 复制代码
@Getter
@Slf4j
public class FileInputStream extends InputStream {
    private static final long PART_SIZE = 10 * 1024 * 1024;

    private final String fileName;
    private final Long fileSize;
    private final String fileUrl;

    private final int filePartCount;
    private final List<Future<Path>> filePathList;
    private final ExecutorService threadPool;

    private int currentPartIndex;
    private InputStream currentInputStream;

    public FileInputStream(String fileName, Long fileSize, String fileUrl) {
        this.fileName = fileName;
        this.fileSize = fileSize;
        this.fileUrl = fileUrl;

        this.filePartCount = filePartCount(this.fileSize);
        this.filePathList = Lists.newArrayListWithCapacity(filePartCount);
        this.threadPool = Executors.newFixedThreadPool(Math.min(filePartCount, 5));

        this.currentPartIndex = 0;
        for (int i = 0; i < this.filePartCount; i++) {
            this.filePathList.add(null);
        }
    }

    private static int filePartCount(long totalSize) {
        return (int) Math.ceil((double) totalSize / PART_SIZE);
    }

    @Override
    public int read(@NotNull byte[] b, int off, int len) throws IOException {
        try {
            if (currentInputStream == null) {
                if (currentPartIndex >= filePartCount) {
                    return -1;
                }

                currentInputStream = getInputStream();
            }

            int byteRead = currentInputStream.read(b, off, len);
            if (byteRead == -1 || byteRead < len) {
                // 当前 InputStream 读取完毕,立即关闭
                currentInputStream.close();
                deleteCurrentFile();

                if (currentPartIndex + 1 < filePartCount) {
                    // 切换到下一个 InputStream
                    currentPartIndex++;
                    currentInputStream = getInputStream();

                    int remainBytes = len - Math.max(byteRead, 0);
                    byteRead += currentInputStream.read(b, byteRead, remainBytes);
                } else {
                    // 所有 InputStream 读取完毕
                    currentInputStream = null;
                }
            }
            return byteRead;
        } catch (Exception e) {
            throw new IOException(e);
        }
    }

    public int read() throws IOException {
        try {
            if (currentInputStream == null) {
                if (currentPartIndex >= filePartCount) {
                    return -1;
                }

                currentInputStream = getInputStream();
            }

            int byteRead = currentInputStream.read();
            if (byteRead == -1) {
                // 当前 InputStream 读取完毕,立即关闭
                currentInputStream.close();
                deleteCurrentFile();

                if (currentPartIndex + 1 < filePartCount) {
                    // 切换到下一个 InputStream
                    currentPartIndex++;
                    currentInputStream = getInputStream();
                    byteRead = currentInputStream.read();
                } else {
                    // 所有 InputStream 读取完毕
                    currentInputStream = null;
                }
            }
            return byteRead;
        } catch (Exception e) {
            throw new IOException(e);
        }
    }

    @Override
    public void close() throws IOException {
        if (currentInputStream != null) {
            currentInputStream.close();
            deleteCurrentFile();
        }
        if (threadPool != null) {
            threadPool.shutdownNow();
        }
        if (CollectionUtils.isNotEmpty(filePathList)) {
            filePathList.clear();
        }
        deleteTempDir();
    }

    private void deleteCurrentFile() throws IOException {
        try {
            Future<Path> pathFuture = filePathList.get(currentPartIndex);
            if (pathFuture == null) {
                return;
            }
            Path path = pathFuture.get();
            if (path == null) {
                return;
            }

            File file = path.toFile();
            if (file.isFile() && file.exists()) {
                if (!file.delete()) {
                    file.deleteOnExit();
                }
                log.info("[DM] file deleted " + file.getPath());
            }
        } catch (Exception e) {
            throw new IOException(e);
        }
    }

    private void deleteTempDir() throws IOException {
        Path path = getTempPath(this.fileName);
        if (Files.notExists(path)) {
            return;
        }

        deleteDirectory(path.toFile());
    }

    private void deleteDirectory(File dic) {
        File[] files = dic.listFiles();
        if (files != null) {
            for (File file : files) {
                deleteDirectory(file);
            }
        }
        dic.delete();
    }

    private InputStream getInputStream() throws Exception {
        if (currentPartIndex >= this.filePathList.size()) {
            throw new IOException("超出文件分片总数");
        }

        Future<Path> pathFuture = this.filePathList.get(currentPartIndex);
        if (pathFuture == null) {
            pathFuture = this.threadPool.submit(buildDownloadTask(currentPartIndex));
            this.filePathList.set(currentPartIndex, pathFuture);
        }
        if (currentPartIndex + 1 < filePartCount) {
            // 下载下一个文件
            filePathList.set(currentPartIndex + 1, threadPool.submit(buildDownloadTask(currentPartIndex + 1)));
        }

        // 开始解析
        Path path = pathFuture.get();
        return Files.newInputStream(path);
    }

    private DownloadTask buildDownloadTask(int partIndex) {
        long startPos = partIndex * PART_SIZE;
        long endPos = startPos + PART_SIZE - 1;
        if (partIndex == filePartCount - 1) {
            endPos = fileSize;
        }
        return new DownloadTask(startPos, endPos, partIndex);
    }

    private class DownloadTask implements Callable<Path> {
        private static final int MAX_RETRY_TIMES = 5;
        private static final int RETRY_SLEEP_MILLIS = 1000;

        private final long startPos;
        private final long endPos;
        private final int serialNum;

        public DownloadTask(long startPos, long endPos, int serialNum) {
            this.startPos = startPos;
            this.endPos = endPos;
            this.serialNum = serialNum;
        }

        @Override
        public Path call() throws Exception {
            int retryTimes = 0;

            do {
                try {
                    return doCall();
                } catch (Exception e) {
                    // 所有异常都重试
                    log.error("下载文件 {} 分片 {} 失败", fileName, serialNum, e);
                    if (!shouldRetry(retryTimes)) {
                        break;
                    }
                }
            } while (retryTimes++ < MAX_RETRY_TIMES);

            log.error("下载文件 {} 分片 {} 重试达到最大次数 {}", fileName, serialNum, MAX_RETRY_TIMES);
            throw new Exception("下载文件异常,超出重试次数");
        }

        private boolean shouldRetry(int retryTimes) {
            if (retryTimes + 1 > MAX_RETRY_TIMES) {
                //最后一次重试失败后,直接抛出异常,不再等待
                return false;
            }

            int sleepMillis = RETRY_SLEEP_MILLIS * (1 << retryTimes);
            try {
                log.info("下载文件 {} 分片 {} 失败,将在 {} ms 后重试(第{}次)", fileName, serialNum, sleepMillis, retryTimes + 1);
                Thread.sleep(sleepMillis);
            } catch (InterruptedException e1) {
                Thread.currentThread().interrupt();
            }
            return true;
        }

        public Path doCall() throws Exception {
            String rangeStr = "bytes=" + startPos + "-" + endPos;
            log.info("begin download file {} serialNum is {} range is {}", fileName, serialNum, rangeStr);

            CloseableHttpClient httpClient = FileHttpHelper.getClient(fileUrl);
            HttpGet httpGet = new HttpGet(fileUrl);
            httpGet.addHeader("Range", rangeStr);
            httpGet.setConfig(RequestConfig.custom()
                    // 建立连接的时间
                    .setConnectTimeout(30 * 1000)
                    // 读取数据的时间,一个分片大小10M,给30分钟的时间足够了
                    .setSocketTimeout(30 * 1000 * 60)
                    .build());

            try (CloseableHttpResponse response = httpClient.execute(httpGet)) {
                int code = response.getStatusLine().getStatusCode();
                boolean success = code >= 200 && code < 300;
                if (!success) {
                    throw new Exception("Failed to download file. Server returned HTTP status code: " + code);
                }

                InputStream inputStream = response.getEntity().getContent();

                Path tmpPath = getTempPath(fileName);
                if (Files.notExists(tmpPath)) {
                    log.info("create temp directory:{}", tmpPath);
                    Files.createDirectories(tmpPath);
                }
                Path tempFilePath = tmpPath.resolve(String.valueOf(serialNum));

                // 将下载的内容写到临时文件,每个任务写一个临时文件
                log.info("begin write to temp file:{}", tempFilePath);
                long byteSize = Files.copy(inputStream, tempFilePath, StandardCopyOption.REPLACE_EXISTING);
                if (byteSize != PART_SIZE && startPos + byteSize != endPos) {
                    log.info("write to temp file:{} size:{} error", tempFilePath, byteSize);
                    throw new Exception("byteSizeError");
                }
                log.info("write to temp file:{} finished", tempFilePath);

                inputStream.close();
                return tempFilePath;
            }
        }
    }


    private static Path getTempPath(String fileName) {
        String tmpDirPath = System.getProperty("java.io.tmpdir");
        return Paths.get(tmpDirPath, "dmDownload", fileName);
    }

}

三、流式解析FileInputStream.

如下代码:

用通俗易懂的语言讲一下

1、InputStream = 水龙头(出水口)调用 read() = 接水,你只管"有没有水流出来"

2、GZIPInputStream 本身不是"水源", 它是一个"装在水龙头前面的过滤器 / 转换器"。 换句话说: 它既是一个InputStream, 又"消费"另一个 InputStream。

3、FileInputStream(分片下载实现)= 水源 + 水管 new FileInputStream(fileName, fileSize, fileUrl)

水源:远程 gzip 文件

水管:分片下载 + 本地缓存 + 顺序拼接

对外:稳定、连续地出"压缩过的水"

4、GZIPInputStream 要"包"一个 InputStream?因为它遵守一个非常牛的设计模式:

👉 装饰器模式(Decorator)

特点:

接口不变:还是 InputStream

功能增强:多了"解压"

可无限叠加

5、完整"水流路径"示意图(重点) 远程 gzip 文件

远程 gzip 文件

↓ [你的 FileInputStream] (分片下载 + 拼接)

↓ 压缩字节 [GZIPInputStream] (解压)

↓ 原始字节 [InputStreamReader] (字节 → 字符)

↓ [BufferedReader] (按行读)

6、InputStreamReader 是什么?和BufferedReader区别是什么

本质定义

InputStreamReader extends Reader

它是"字符流世界"与"字节流世界"的桥梁

3️⃣ 用水流模型理解

字节流(byte)

↓ UTF-8 / GBK / ISO-8859-1

InputStreamReader

↓ 字符流(char)

和BufferedReader 区别是什么?

BufferedReader extends Reader

它是一个**"字符流的缓存增强器"**

7、 没有 BufferedReader 会怎样?

reader.read(); // 每次一个字符

每次 read:都可能触发一次底层 IO 性能极差

BufferedReader 干了什么? char[] buffer = new char[8192];

一次性读一大块存到内存

上层:

一个字符一个字符地用

👉 典型的 空间换时间

BufferedReader 的"杀手级能力" ⭐ readLine() String line =

bufferedReader.readLine();

自动识别:

\n

\r\n

非常适合:

日志

CSV

JSON

文本文件

一句话区分:

解决的问题 装饰的是什么
InputStreamReader 字节 → 字符 数据"语义"
BufferedReader 读得快、读得巧 读取"方式"
java 复制代码
@SneakyThrows 
@Override protected BufferedReader initReader() { 
	String fileName = RandomUtils.generateUUIDString(); 
	String fileUrl = fileInfo.getUrl(); 
	InputStream fis = new GZIPInputStream(new FileInputStream(fileName, 		fileInfo.getFileSize(), fileUrl)); 
	return new BufferedReader(new InputStreamReader(fis, StandardCharsets.UTF_8)); 
}
java 复制代码
    @Override
    public void start() {
        // 使用示例
        this.bufferedReader = initReader();
        // 这里使用新的CsvReader Builder方式
        CsvReader<CsvRecord> csvReader = CsvReader.builder()
                .quoteCharacter('`')
                .ofCsvRecord(bufferedReader);
        csvRecords = csvReader.iterator();
        // 解析表结构
        readHead();
        parseDictEncode();
        // 跳过表头
        csvRecords.next();
        // 初始化读取数据迭代器
        initDataIterator();
    }
相关推荐
AI视觉网奇2 小时前
ue http 请求学习笔记
网络·网络协议·http
迷途的小子2 小时前
go-gin binding 标签详解
java·golang·gin
机灵猫2 小时前
守卫系统的最后一道防线:深入 Sentinel 限流降级与熔断机制(对比 Hystrix)
java·hystrix·sentinel
liulanba2 小时前
AI Agent技术完整指南 第二部分:开发框架
网络·数据库·oracle
教练、我想打篮球2 小时前
124 记一次 大模型无限输出 “--“ 导致的短时间频繁 ygc
java·flow·ygc
while(1){yan}2 小时前
Spring日志
java·后端·spring
集智飞行2 小时前
mavros udp url
网络·网络协议·udp
小肖爱笑不爱笑2 小时前
Maven
java·log4j·maven
FreeBuf_2 小时前
攻击者伪造Jackson JSON库入侵Maven中央仓库
java·json·maven