尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SpringBoot实现HTTP大文件下载与断点续传实战解析

SpringBoot实现HTTP大文件下载与断点续传实战解析 上周接了一个让我一度想骂人的需求把团队内部的文件共享服务改造成跨平台的下载方案。需求方给了一串关键词——SpringBoot、HTTP、大文件、断点续传、目录结构——然后说了句你看着办。硬着头皮做完之后回头一看这几个词恰好拼出了一条非常成熟的技术路径用 SpringBoot 搭一个支持 HTTP Range 语义的文件下载服务把磁盘目录原样映射成 URL前端无论是浏览器、curl、IDM、aria2、Android 还是 iOS全都天然支持断点续传根本不需要自研客户端。这篇就是完整的过程记录和踩坑总结正在做下载服务、网盘后端、升级包分发系统的后端同学可以直接抄作业。1. 需求拆解与方案选型为什么是 HTTP Range 而不是私有协议1.1 这个需求到底在杠什么表面上看需求很简单支持大文件下载断了能续传而已。但真正落地时会发现它同时卡着四个约束少一个都不行。第一个约束是目录结构。服务端不是平铺的一堆文件而是按产品线/版本/构建号排好的多层目录用户需要先看到目录树、按路径找到文件再下载。这就意味着接口不能只给一个 ID 列表必须把路径语义暴露出来。第二个约束是多终端。用户在 Windows 上可能用浏览器加 IDM在 Linux 上可能用 wget 或 aria2在手机上是微信内浏览器、小程序或者原生 App。每个终端的下载引擎都不同你不可能让用户统一装一个客户端也不可能指望所有终端都熟悉你自定义的私有协议。第三个约束是大文件。单文件动辄几个 GB全部读进内存再响应是绝对行不通的必须流式输出而且最好能走操作系统的零拷贝通道。第四个约束是断点续传。移动端场景尤其恶心用户下载到一半从 Wi-Fi 切到 4GTCP 连接断了下一次重新下载如果要从头再来用户会直接骂娘。把这四个约束放一起你会发现这不是一个能不能实现的问题而是选哪条实现路线的问题。选错了后面就是给自己挖坑。1.2 四条候选路线的优缺点对比我在动手之前把主流方案过了一遍大概列了这么四类方案断点续传多终端兼容目录结构落地成本私有 TCP 协议要自己设计每个终端写 SDK要自己设计极高WebSocket 分片要自己造轮子浏览器兼容一般要自己设计高FTP 服务部分支持浏览器已边缘化天然支持中但 NAT 穿透和被动模式是噩梦HTTP Range标准支持全终端天然兼容URL 映射灵活低私有的 TCP 协议最灵活但代价最大因为你一旦决定私有就得为 Windows 写客户端、为 Linux 写客户端、为 iOS 写客户端、为 Android 写客户端每个平台都得实现断点续传状态管理这活儿基本是做一个新的网盘客户端了。WebSocket 本质上还是为双向实时通信设计的拿它传文件等于在 HTTP 之上重复实现一套流量控制和确认机制浏览器端的分片逻辑也没省到哪里去。FTP 虽然目录天然支持但主动模式和被动模式的防火墙问题在企业网络里能让人崩溃而且现代浏览器基本淘汰了 FTP 协议。HTTP Range 方案胜出是必然的因为 HTTP/1.1 从 1999 年就定义了断点续传的完整语义Range 请求头、206 Partial Content 响应码、Content-Range 响应头。全球所有下载工具、浏览器、网络库都实现了这套语义。你只需要把服务端做对客户端的事情就完全不用操心了。1.3 为什么服务端选 SpringBoot技术路线定了 HTTP Range服务端选型就很简单了。我最后选了 SpringBoot主要是因为四个点。第一内嵌 Tomcat 是成熟的 NIO 服务器Servlet 3.1 的异步输入输出支持得很好响应体天然是流式的不会为了一个几 GB 的文件把 JVM 堆撑爆。第二Spring 的 ResourceHttpRequestHandler 对静态资源本身就有 Range 支持如果需求简单到不需要鉴权、不需要定制响应头配置两句就能拿到断点续传能力。第三自定义 Controller 做文件下载时可以用 Java NIO 的 FileChannel.transferTo 走 sendfile 零拷贝数据从磁盘到网卡基本不经过用户态大文件吞吐和 CPU 占用都好看。第四目录列表接口、鉴权拦截器、下载统计、限速这些业务功能Spring 生态里都是现成的组件不用额外引一堆框架。一句话总结选型逻辑在标准协议成熟的领域优先做标准的适配者而不是新协议的定义者。2. HTTP 断点续传的原理拆解从 Range 到 206 再到多段请求2.1 一次完整的续传请求长什么样先把原理讲透后面代码才有依据。假设客户端第一次下载 app.zip 下到 1024 字节时断网了它记住了已经下载了 1024 字节。重新连接后客户端发起这样的请求GET /files/releases/2024/stable/app-3.2.1.zip HTTP/1.1 Host: download.example.com Range: bytes1024-服务端如果支持断点续传应该返回这样的响应HTTP/1.1 206 Partial Content Accept-Ranges: bytes Content-Length: 11263 Content-Range: bytes 1024-11263/11264 Content-Type: application/octet-stream ETag: 11264-1730522160000客户端拿到 206 响应看见 Content-Range 里的 bytes 1024-11263/11264就知道服务端只返回了整个文件从第 1024 字节到最后一个字节的这一段总长度是 11264于是它把这 11263 字节追加到本地残缺文件的末尾整个下载就完整了。这是一切断点续传工具能正常工作的根本。你只要保证服务端对 Range 请求返回 206 和正确的 Content-Range客户端的行为就全部由它自己去控制了。2.2 Range 头的常见写法与服务端解析策略Range 头的写法看起来简单其实有五种常见形态解析错了会返错数据bytes0-从第 0 字节到文件尾等价于全量下载。bytes1024-2047下载第 1024 到 2047 字节共 1024 字节。bytes-1024注意这里前面没有起始值意思是文件最后 1024 字节不是从 0 到 1024。bytes0-0只要第一个字节常用于服务端健康检查。bytes0-100, 500-600多段请求规范要求服务端返回 multipart/byteranges。多段 Range 在实际下载场景里非常少见主流下载器都是给每个线程单独发单段 Range 请求所以我建议服务端对多段请求要么只处理第一段要么直接返回 416。纠结多段支持没有意义投入产出比太低。还有一个容易忽略的点当 Range 的起始位置等于或大于文件总长度时服务端必须返回 416 Range Not Satisfiable并且带上Content-Range: bytes */文件总长度告诉客户端文件到底有多大而不是返回 200。2.3 响应头为什么必须这么组合断点续传不是只有一个 206 状态码就完事了它是几个响应头的组合拳每个头都有它存在的理由响应头作用Accept-Ranges: bytes告诉客户端我支持字节范围请求Content-Range: bytes start-end/total说明本次返回的数据在原文件中的位置Content-Length: length本次响应体长度是 end - start 1不是整个文件长度ETag / Last-Modified文件版本标识用于防止文件变了还续传If-Range 请求头客户端带上 ETag匹配才续传不匹配则返回 200 全量ETag 是最多人忽略的。没有 ETag 也能续传但会出现一个隐蔽的脏数据问题源文件已经被替换成新版本客户端不知道还在拿旧文件的断点位置去续传新文件的剩余部分最后拼出一个前后不一致的损坏文件。所以生产环境一定要生成 ETag最简单的做法就是用文件大小-最后修改时间戳拼接一个弱校验值。3. 目录结构映射把磁盘目录翻译成可浏览的 HTTP 接口3.1 磁盘目录与 URL 的对应关系需求要求目录结构不能乱我的做法是把磁盘根目录直接映射成 URL 前缀。比如服务端根目录是 /data/share实际文件组织如下/data/share ├── releases/2024/stable/app-3.2.1.zip ├── releases/2024/beta/app-4.0.0-beta.tgz ├── docs/manual/springboot-startup.pdf └── tools/mdbtool/readme.txt那么 URL 就长这样http://localhost:8080/files/releases/2024/stable/app-3.2.1.zip http://localhost:8080/files/docs/manual/springboot-startup.pdf这种映射有一个非常大的好处路径本身就是文件路径没有 id 到 path 的中间表权限、统计、目录扫描都可以直接在文件系统层面做。配合 Spring 的/**通配符一个 Controller 方法就能接住所有嵌套子路径。3.2 目录列表接口要返回 JSON 而不是生成 HTML只提供文件下载还不够用户得能浏览目录。这里我踩过一个思路上的坑一开始想用服务端渲染 HTML 目录列表像老式 FTP 站点一样但后来发现多终端场景下 JSON 才是正确选择。浏览器端用 JavaScript 渲染 HTML下载脚本用 curl 解析 JSONApp 端用现成 JSON 库反序列化三端统一不需要服务端维护模板。我提供的目录列表接口长这样GET /api/dir?pathreleases/2024响应{ path: releases/2024, parent: releases, dirs: [stable, beta], files: [ {name: app-4.0.0-beta.tgz, size: 2684354560, modifiedTime: 2024-11-02T15:36:00Z} ] }注意文件列表里一定要带 size 字段。客户端拿到 size 之后判断文件多大、要不要用多线程分段下载、已下载部分是否合法全靠这个值。3.3 文件名编码与 Content-Disposition 的坑中文文件名是这里最大的坑。HTTP 头默认只支持 ASCII你如果直接把中文文件名塞进Content-Disposition: attachment; filename中文.zip老版本浏览器和下载器会直接乱码有些甚至会中断下载。正确姿势是同时提供 filename 和 filename* 两个字段后者使用 RFC 5987 的百分号编码Content-Disposition: attachment; filenameapp.zip; filename*UTF-8%E4%B8%AD%E6%96%87.zip服务端在 Java 里生成这段头的时候建议用 URLEncoder 编码后再把加号替换成 %20否则带空格的文件名会解析出错。4. SpringBoot 核心实现从 Controller 到 FileChannel 零拷贝4.1 项目的目录结构很多新手问 SpringBoot 项目应该怎么组织我直接给出这个下载服务的实际目录结构download-server/ ├── pom.xml ├── src/main/java/com/example/download/ │ ├── DownloadApplication.java │ ├── config/WebConfig.java │ ├── controller/FileController.java │ ├── controller/DirController.java │ └── service/FileScanService.java └── src/main/resources/application.ymlpom.xml 只需要引入一个核心依赖SpringBoot 3.2 起甚至可以直接用 spring-boot-starter-web 搞定一切dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency配置文件里最关键的是自定义的下载根目录download: root-dir: /data/share server: tomcat: max-swallow-size: -1max-swallow-size 设为 -1 是为了防止 Tomcat 因为需要丢弃的请求体过大而报错后面常见问题里会展开讲。4.2 目录接口与路径穿越防护目录列表接口的实现看起来简单但安全校验必须做扎实。最关键的是防止路径穿越也就是用户传个../之类的路径把服务端任意目录都列出来。我用的防护逻辑就三行但这一手必须写对RestController RequestMapping(/api) public class DirController { private final Path rootDir; public DirController(Value(${download.root-dir}) String rootDir) { this.rootDir Paths.get(rootDir).toAbsolutePath().normalize(); } GetMapping(/dir) public MapString, Object list(RequestParam(path) String path) throws IOException { Path target rootDir.resolve(path).normalize(); if (!target.startsWith(rootDir)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, 路径越界); } // 用 Files.list 扫描并组装 JSON这里省略 return Map.of(); } }关键在normalize()和startsWith(rootDir)这两个调用。normalize 会把a/../b转成bstartsWith 校验最终路径是否还在根目录之下。顺序不能反而且 rootDir 本身也要先 toAbsolutePath 再 normalize否则相对路径拼接时容易出漏洞。4.3 带断点续传的下载 Controller 完整代码这是全文的核心。我直接给出生产可用的实现注释里标了关键点RestController RequestMapping(/files) public class FileController { private final Path rootDir; public FileController(Value(${download.root-dir}) String rootDir) { this.rootDir Paths.get(rootDir).toAbsolutePath().normalize(); } RequestMapping(method {RequestMethod.GET, RequestMethod.HEAD}, value /**) public void download(HttpServletRequest request, HttpServletResponse response) throws IOException { String relativePath extractRelativePath(request); Path file resolveAndValidate(relativePath); if (Files.isDirectory(file)) { response.sendRedirect(/api/dir?path relativePath); return; } long fileSize Files.size(file); String rangeHeader request.getHeader(Range); // 统一响应头 response.setHeader(Accept-Ranges, bytes); response.setContentType(application/octet-stream); response.setHeader(ETag, buildEtag(file)); setContentDisposition(response, file.getFileName().toString()); // HEAD 请求只需要返回头信息不输出 body if (HEAD.equals(request.getMethod())) { response.setContentLengthLong(fileSize); return; } if (rangeHeader null || !rangeHeader.startsWith(bytes)) { // 没有 Range 头返回 200 全量文件 response.setStatus(HttpServletResponse.SC_OK); response.setContentLengthLong(fileSize); writeFileRange(response, file, 0, fileSize); return; } RangeSpec spec RangeSpec.parse(rangeHeader, fileSize); if (spec.start spec.end) { response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); response.setHeader(Content-Range, bytes */ fileSize); return; } // 有 Range 头返回 206 片段 response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); response.setHeader(Content-Range, bytes spec.start - spec.end / fileSize); response.setContentLengthLong(spec.end - spec.start 1); writeFileRange(response, file, spec.start, spec.end - spec.start 1); } private void writeFileRange(HttpServletResponse response, Path file, long start, long length) throws IOException { try (FileChannel in FileChannel.open(file, StandardOpenOption.READ); WritableByteChannel out Channels.newChannel(response.getOutputStream())) { long remaining length; long position start; while (remaining 0) { long transferred in.transferTo(position, remaining, out); if (transferred 0) { break; // 防止底层资源异常导致死循环 } position transferred; remaining - transferred; } } } private Path resolveAndValidate(String relativePath) { Path target rootDir.resolve(relativePath).normalize(); if (!target.startsWith(rootDir)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, 路径越界); } return target; } private String extractRelativePath(HttpServletRequest request) { String path (String) request.getAttribute( org.springframework.web.servlet.HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE); if (path null) { path request.getRequestURI().substring(request.getContextPath().length()); } return path.replaceFirst(^/files, ); } }RangeSpec 的解析我单独写成内部类避免控制器里堆逻辑static class RangeSpec { final long start; final long end; RangeSpec(long start, long end) { this.start start; this.end end; } static RangeSpec parse(String header, long fileSize) { String s header.substring(bytes.length()); if (s.startsWith(-)) { long suffixLength Long.parseLong(s.substring(1)); long length Math.min(suffixLength, fileSize); return new RangeSpec(fileSize - length, fileSize - 1); } String[] parts s.split(-, -1); long start Long.parseLong(parts[0]); long end parts.length 1 !parts[1].isEmpty() ? Math.min(Long.parseLong(parts[1]), fileSize - 1) : fileSize - 1; if (start fileSize || start end) { return new RangeSpec(fileSize, fileSize - 1); // 触发 416 } return new RangeSpec(start, end); } }4.4 FileChannel.transferTo 到底为什么快很多人写文件下载还停留在FileInputStream加byte[] buffer一层层拷贝的老思路对大文件来说这是灾难。FileChannel.transferTo在 Linux 上最终会走 sendfile 系统调用数据直接从文件页缓存拷贝到 socket 发送缓冲区不经过 JVM 堆也不经过用户态到内核态的重复拷贝。我实测下载一个 6GB 文件时Java 进程的堆内存占用几乎没有变化CPU 占用也只有个位数百分比。这里必须注意一个细节transferTo不保证一次调用就传完这么多字节所以外层要用 while 循环累加 position。网上很多示例代码只调一次 transferTo 就完事小文件没事大文件就可能出现下载的文件不完整这种诡异问题。4.5 静态资源配置方案和自定义 Controller 怎么选如果你的需求足够简单——不需要鉴权、不需要定制 Content-Disposition、不需要目录接口——SpringBoot 的静态资源配置可以直接给你省出一半代码量Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/files/**) .addResourceLocations(file:/data/share/) .setCachePeriod(3600); } }ResourceHttpRequestHandler 内部已经把 Range、If-Range、Last-Modified 这些处理好了基本开箱即用。但我最终选择了自定义 Controller。原因是实际业务中几乎不可能没有鉴权静态资源配置混入鉴权逻辑很别扭而且它对 Content-Disposition 中文件名编码的控制不算灵活。自定义 Controller 虽然要多写几十行但换来的是对每个响应头的完全掌控后面接限流、接统计、接对象存储都有地方下手。5. 多终端实战curl、浏览器、IDM、aria2 与移动端的兼容性验证5.1 用 curl 模拟断点续传最直观没有比 curl 更适合验证断点续传的工具了。你可以非常直观地模拟下载到一半断开再从断点继续的完整过程。先下载文件前 200MBcurl -v -r 0-209715199 -o app.part \ http://localhost:8080/files/releases/2024/stable/app-3.2.1.zip这时候正常情况下服务端返回 206本地生成了一个 200MB 的残缺文件 app.part。然后继续下载剩余部分curl -v -r 209715200- -o - \ http://localhost:8080/files/releases/2024/stable/app-3.2.1.zip app.part重点是请求头里 Range 的起始字节数要等于上一个文件的大小。全部完成后拿完整文件做一次 md5 校验curl -o full.zip http://localhost:8080/files/releases/2024/stable/app-3.2.1.zip md5sum full.zip app.part两个哈希一致说明服务端的 206 语义和字节偏移完全正确。5.2 浏览器和下载器的表现浏览器的情况分两层看。大多数浏览器默认不会主动发 Range 请求下载行为是一次性请求 200 全量但一旦网络中断部分浏览器会利用本地残存临时文件重组连接这时候服务端只要支持 Range 就能无缝衔接。如果你用的是 Chrome 系浏览器观察开发者工具 Network 面板只要响应状态码是 206就说明浏览器内部已经走了续传逻辑。真正把 Range 用到极致的是下载器。我用 IDM 和 aria2 验证过非常典型的场景IDM 默认 8 线程并发下载每个线程会带着不同的 Range 请求打到服务端。aria2 的验证命令更直接aria2c -x 16 -s 16 -c -d /data/watch -o app.zip \ http://localhost:8080/files/releases/2024/stable/app-3.2.1.zip-c 表示继续下载-x 16 表示每个服务器最多开 16 个连接。我实测 16 线程同时拉 2.8GB 的文件Tomcat 默认线程池完全扛得住没有任何一个连接返回非 206 的异常状态。多个 FileChannel 各自独立 seek互不干扰这是零拷贝实现天然支持并发分段的好处。5.3 Android 与 iOS 终端的对接方式移动端其实更简单因为主流网络库都实现了 Range 语义。Android 端用 OkHttp 写断点续传只需要在请求头里带上已下载字节数val request Request.Builder() .url(downloadUrl) .header(Range, bytes$downloaded-) .build()响应码是 206 就继续写文件是 200 就说明服务端不认 Range需要从零开始。iOS 端更省事URLSession 自带的 resumeData 机制天然依赖服务端的 Range 支持你只要保证服务端返回正确的 206 和 Content-Range原生类就能自动完成续传。微信内置浏览器、小程序内部的下载行为本质上都包装了系统网络栈底层还是 HTTP Range 这一套所以服务端不需要为这些终端做任何特殊适配。5.4 实测数据与性能表现为了给一个可复现的参考我把这组测试数据整理在下面。测试环境是本地一台 4 核 8G 的 Linux 虚拟机文件存放在 SSD 上6GB 的安装包服务端是 SpringBoot 3.2 默认内嵌 Tomcat。场景结果千兆内网 curl 单连接约 112MB/s基本打满千兆CPU 占用 2%本机 FileChannel 读取约 450MB/s受限于 SSD 读取速度aria2 16 线程万兆环境约 380MB/s瓶颈在网络而不在服务端模拟断网后续传重新连接后 200ms 内恢复增量数据 md5 校验一致最直观的结论是整个下载过程 Java 堆内存占用始终稳定在几百 MB 级别没有因为单文件 6GB 而出现 OOM。这也印证了零拷贝路线在大文件场景下的价值。6. 常见问题与排查技巧实录6.1 问题速查表症状可能原因解决办法响应码是 200 而不是 206框架或网关吃掉了 Range 头检查前置 Nginx 是否配置了 proxy_cache 且不缓存 206下载产物 md5 不一致源文件在续传期间被替换引入 ETag并在响应头返回中文文件名乱码Content-Disposition 缺少 filename*按 RFC 5987 双写 filename 和 filename*大文件下载时 OOM用了 byte[] 或 readAllBytes改用 FileChannel.transferTo 或流式输出下载到一半连接被重置Tomcat 或网关超时、连接被回收调整 connectionTimeout 与 keep-alive 参数请求带大 body 后连接异常Tomcat maxSwallowSize 过小设置 max-swallow-size-1用户传 ../ 能列出任意目录缺少路径归一化校验normalize startsWith 双重校验6.2 断点续传失效被 Nginx 缓存坑的现场这是我在线上遇到的第一个大坑。服务已经部署好了内网测试一切正常但线上用户在下载 3GB 文件时普遍反映断点续传无效一断就从头来。查了很久最终定位到前置 Nginx 的缓存配置。Nginx 默认会在缓存 206 响应时出问题它可能把多个 Range 片段对应的缓存条目合并或者在多客户端并发请求时返回了完整的 200 响应。解决办法是在 Nginx 配置里把缓存策略改一下location /files/ { proxy_pass http://127.0.0.1:8080; proxy_cache_key $uri$is_args$args; proxy_cache_valid 200 206 1h; add_header Accept-Ranges bytes; }核心思路是必须明确告诉 Nginx 206 响应是合法的缓存对象并且透传 Accept-Ranges 头。否则 Range 到 Nginx 这一层就被优化没了。6.3 一个空格毁掉整个续传响应头格式的教训分享一个极其隐蔽的坑。有一次调试时所有连接第一次下载都正常但一旦续传客户端全部报无法继续从头下载。抓包后发现响应头长这样Content-Range: bytes 1024-11263 /11264注意 start-end 和总长度之间多了一个空格。标准的 HTTP 规范里 Content-Range 的语法是bytes start-end/total空格位置是固定的。多了一个空格之后大部分严格解析的下载器直接判定该响应不可识别放弃续传。浏览器反而容错强一点没有立刻报错但行为也变诡异了。这个问题的根因是我在拼接字符串时手滑多敲了一个空格// 错误写法多了一个空格 bytes spec.start - spec.end / fileSize; // 正确写法 bytes spec.start - spec.end / fileSize;从那以后我学乖了所有关键的响应头都写单元测试用 MockMvc 断言 Content-Range 的完整字符串从根上杜绝低级手误。6.4 关于 If-Range 的一个进阶建议如果你的服务要接多个下载器混用推荐把 If-Range 的逻辑也做上。If-Range 的语义是客户端在续传时把之前拿到的 ETag 带回来服务端对比之后发现 ETag 变了就返回 200 全量文件没变才返回 206 片段。有了这个机制客户端和服务端就商量好了一个原则文件没变就续传变了就推倒重来。这比单纯检查 Range 头要严谨得多。实现上其实是在 Controller 加一个分支String ifRange request.getHeader(If-Range); if (ifRange ! null !ifRange.equals(response.getHeader(ETag))) { // 文件变了返回 200 全量 response.setStatus(HttpServletResponse.SC_OK); response.setContentLengthLong(fileSize); writeFileRange(response, file, 0, fileSize); return; }6.5 调试断点续传的终极工具组合最后给一套我自己的调试工具组合。开发阶段我会开三个工具一个 Chrome 开发者工具看 206/Content-Range一个 curl 加 -v 手动构造 Range 请求一个 Wireshark 抓包看 TCP 层数据。三者配合可以定位 90% 以上的断点续传问题。线上环境则靠两件事一是给响应头加 Access-Control-Expose-Headers让跨域场景下的前端 JS 也能读到 Content-Range二是在日志里记录每个下载请求的 Range 头、响应码、字节数用最简单的方式验证生产流量中 206 的占比。当你的 206 占比长期维持在 99% 以上说明这个下载服务在断点续传这件事上真的稳了。我个人在实际操作中的体会是断点续传这件事服务端的难点不在实现,而在对标准的敬畏。HTTP 规范把 Range、206、Content-Range、ETag 的语义都定义得很清楚了你只要老老实实按规范返回所有客户端都会配合你一旦你自以为是地在响应头格式、文件版本校验上偷懒坑的就是一线用户的下载体验。所以别看这个服务代码量不大里面每一行响应头都值得用测试用例钉死。
返回列表