最近接了个挺有代表性的需求公司要把一台文件服务器上的几百G资源目录直接做成HTTP访问用户用手机、电脑、平板都能打开网页按目录翻文件、点击下载而且下载到一半断网后再次点下载应该接着上次的位置继续不能从头再来。技术栈限死是Spring Boot不许引入太复杂的组件。乍一看这需求有两块目录浏览和断点续传但拆开之后核心其实都是HTTP协议的标准能力。这篇文章我会把整个落地方案讲透包括Range头原理、Spring Boot里三种实现方式、目录映射与安全校验、多终端适配、以及我在生产环境踩过的坑。适合正在做文件管理系统、私有云盘、资源站后端或者对接大文件下载场景的Java同学。内容全部基于可复现的代码不需要牛皮硬件一台普通Linux服务器加Spring Boot就够。1. 动手前先理清方案这个需求到底在做什么1.1 “断点续传”不等于“分片上传”很多人一听到断点续传就想到前端把文件切块、后端合并这是典型的上传场景和下载场景完全不是一回事。下载断点续传建立在HTTP Range机制上客户端告诉服务端“我已经有0到999字节了剩下的从第1000字节开始发”服务端响应206 Partial Content输出剩余部分就行。上传断点续传则涉及分片编号、临时目录、合并校验、任务会话等复杂度高得多。我要做的资源下载服务属于前者。如果你将来的需求是上传大文件建议单独设计上传接口不要和下载的Range逻辑混在一起两者的数据语义差别很大。这也是我一开始给产品讲清楚的地方避免需求越做越歪。1.2 为什么选HTTP而不是FTP或WebDAV既然是多终端客户端范围很杂浏览器、安卓、iOS、Windows下载器、甚至电视盒子。HTTP是唯一所有终端都原生支持的协议不用装客户端、不用处理防火墙、不用写端上SDK。浏览器里一个a标签就能触发下载video标签天然会发Range请求做视频拖动播放移动端OkHttp、NSURLSession对HTTP Range支持也都很成熟。FTP的优势在于服务端实现简单但公网穿透麻烦弱网断线后续传逻辑要自己写WebDAV能力强但移动端支持参差。HTTP这套方案对所有终端一视同仁下载器用HTTP浏览器用HTTP后端就只按HTTP规则办事省掉大量端上适配。1.3 “目录结构”这个关键词有三层含义第一层是最容易让新手误解的Spring Boot工程本身的目录结构。第二层是服务器文件实际存放的目录结构。第三层是URL里呈现的虚拟目录结构。先说第一层。如果文件直接丢进src/main/resources/static打包后几G资源全塞进jar启动慢、发布慢、分目录管理全是泪。正确做法是资源目录外置到file:/data/files/这类路径Spring Boot工程只提供接口文件系统路径从配置文件读。我推荐在这个需求里至少拆出这几个模块src/main/java/com/example/fileserver/ config/WebConfig.java # 静态资源映射/拦截器注册 controller/FileController.java # 目录列表、下载、文件下载入口 service/FileService.java # 路径拼接、索引查询、元数据组装 util/PathSecurityUtil.java # 路径规范化和目录穿越校验 src/main/resources/ application.yml # 配置存储根目录、临时目录等第三层虚拟目录结构才是同用户打交道的/data/files/2025/04/esp32_demo.zip映射成URL里的/file/2025/04/esp32_demo.zip。不要让前端直接拼服务器物理路径物理路径属于后端实现细节随时可以迁移数据盘而URL结构一旦稳定下来就不要随便改不然别人的收藏夹、下载历史全挂。2. 断点续传的核心原理HTTP Range到底在谈什么2.1 一次完整的Range交互长什么样服务端在返回任何可续传的文件时响应头里必须带Accept-Ranges: bytes告诉客户端我支持按字节区间下载。客户端拿到这个信号后下次断线重连时就可以发这样一个请求GET /file/2025/04/esp32_demo.zip HTTP/1.1 Host: 192.168.1.10:8080 Range: bytes1048576-bytes1048576-意思是我已经下载了1048576字节你从第1048576字节开始把剩余部分全发给我。服务端如果同意会返回HTTP/1.1 206 Partial Content Accept-Ranges: bytes Content-Range: bytes 1048576-5242880/5242881 Content-Length: 4194305 Content-Type: application/octet-stream注意Content-Range的格式是bytes 开始-结束/总大小这里区间是闭区间所以结束位置总大小-1。最后面的总大小是完整文件大小不是剩余大小。客户端要用这个总大小来判断文件是否下完。如果客户端请求的区间完全不在文件范围内比如bytes99999999-服务端要返回416 Range Not Satisfiable并带上Content-Range: bytes */5242880。这同样很重要下载器判断文件状态需要依赖它。2.2 六种常见Range格式和边界计算我实测下来不同终端发出的Range头五花八门后端解析时要把下面这些情况都覆盖到Range头含义服务端响应示例bytes0-99下载前100字节Content-Range: bytes 0-99/5242880bytes1024-2048下载第1025到2049字节Content-Range: bytes 1024-2048/5242880bytes1024-从第1025字节到文件末尾Content-Range: bytes 1024-5242879/5242880bytes-1024下载末尾1024字节Content-Range: bytes 5241856-5242879/5242880bytes0-99,200-299多段Range服务端可忽略或返回multipart/byterangesbytes100-0无效区间返回416计算边界时要特别小心闭区间总大小如果算错了客户端会把CRC校验算错。举个例子文件总大小5242881字节客户端请求bytes0-5242880这其实等价于请求全文件。HttpRange类内部会把结束位置夹到total-1但如果你手写解析很容易在end total时忘记截断导致长度多出一截。2.3 服务端不处理Range头会怎样如果服务端忽略Range头直接返回200和整个文件下载器并不会自动检测并修复而是认为服务器不支持断点续传下一次断线还是从头再来。对于几百兆甚至几个G的文件弱网下这种体验基本不可用。所以判断一个Spring Boot服务是否支持断点续传最简单的方法就是看响应头里有没有Accept-Ranges: bytes。我在测试环境里用一条命令就能验证curl -I http://localhost:8080/file/2025/04/esp32_demo.zip如果返回头里有Accept-Ranges: bytes再发一条curl -H Range: bytes0-99 -o /tmp/part1 http://localhost:8080/file/2025/04/esp32_demo.zip用wc -c /tmp/part1检查是不是正好100字节如果返回200而不是206说明Range没有生效。3. Spring Boot落地方案三种实现姿势都给你3.1 姿势一静态资源映射让框架替你处理Range如果只是想把磁盘目录开放成HTTP下载不需要做权限控制、不需要定制目录列表那直接用Spring Boot的addResourceHandlers就够这是我个人最推荐的起步姿势。Configuration public class WebConfig implements WebMvcConfigurer { Value(${file.root-path}) private String rootPath; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/file/**) .addResourceLocations(file: rootPath /); } }关键点有两个第一file:后面必须跟着绝对路径Linux下就是file:/data/files/Windows下要写成file:///D:/data/files/第二结尾斜杠不能省它决定了/file/**之后的路径怎么拼接到物理路径后面。这种情况下Spring MVC的ResourceHttpRequestHandler会自动处理Range请求返回206、Accept-Ranges、Content-Range都不用自己写。浏览器直接访问http://localhost:8080/file/2025/04/esp32_demo.zip就能下载视频标签拖动播放也能正常工作。它的短板也很明显目录列表不会自动生成权限控制基本要靠拦截器硬切一旦想按用户维度动态拼接路径就显得别扭。所以这姿势适合内网工具站、快速验证的原型场景。3.2 姿势二自定义Controller加ResourceRegion精确控制一切当需要动态权限、不同终端不同文件、目录结构要存储在数据库里的时候静态资源映射就不够用了。我常用的方案是自定义Controller借助Spring的HttpRange和ResourceRegion。RestController RequestMapping(/file) public class FileController { Value(${file.root-path}) private String rootPath; GetMapping(/**) public ResponseEntityResourceRegion download( RequestHeader(value HttpHeaders.RANGE, required false) String rangeHeader, HttpServletRequest request) throws IOException { String relativePath extractRelativePath(request); Path root Paths.get(rootPath).toAbsolutePath().normalize(); Path target root.resolve(relativePath).normalize(); if (!target.startsWith(root) || !Files.isRegularFile(target)) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } FileSystemResource resource new FileSystemResource(target.toFile()); long total resource.contentLength(); String contentType Files.probeContentType(target); if (contentType null) { contentType MediaType.APPLICATION_OCTET_STREAM_VALUE; } // 没带Range头直接返回全量文件 if (rangeHeader null || rangeHeader.isEmpty()) { return ResponseEntity.ok() .header(HttpHeaders.ACCEPT_RANGES, bytes) .contentType(MediaType.parseMediaType(contentType)) .contentLength(total) .body(new ResourceRegion(resource, 0, total)); } ListHttpRange ranges HttpRange.parseRange(rangeHeader, total); if (ranges.isEmpty()) { return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE) .header(HttpHeaders.CONTENT_RANGE, bytes */ total) .build(); } // 多段Range这里简化处理取第一段专业知识浏览器和下载器基本只发单段 HttpRange range ranges.get(0); long start range.getRangeStart(total); long end range.getRangeEnd(total); long length end - start 1; ResourceRegion region new ResourceRegion(resource, start, length); return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) .header(HttpHeaders.ACCEPT_RANGES, bytes) .header(HttpHeaders.CONTENT_RANGE, bytes start - end / total) .contentType(MediaType.parseMediaType(contentType)) .contentLength(length) .body(region); } private String extractRelativePath(HttpServletRequest request) { String uri request.getRequestURI(); String contextPath request.getContextPath(); String prefix contextPath /file/; return uri.substring(prefix.length()); } }HttpRange.parseRange会把bytes1024-、bytes-1024这些格式统一转成起止位置省去手写解析器的麻烦。ResourceRegion只负责从文件的指定偏移量开始读指定长度不会一次性把整个大文件加载进内存这是它能扛大文件的关键。要注意一个细节extractRelativePath里的前缀拼接必须和RequestMapping(/file)保持一致如果contextPath不为空还要带上。Spring MVC里还有HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE这类request attribute可以更优雅地拿到原始路径但实际项目里我更喜欢自己取URI再截直白、不依赖内部语义。3.3 姿势三纯Servlet流式输出适合老项目兜底如果你的Spring版本太低没有ResourceRegion和HttpRange或者代码风格偏传统也可以用原生Servlet API自己啃。核心逻辑是解析Range之后用FileChannel跳到指定位置再拷贝到输出流。GetMapping(/download-native) public void downloadNative(HttpServletRequest request, HttpServletResponse response) throws IOException { Path file Paths.get(rootPath, 2025/04/esp32_demo.zip); long total Files.size(file); long start 0; long end total - 1; String range request.getHeader(Range); if (range ! null) { // 这里只演示 bytesstart- 的解析完整版需要兼容各类边界 String[] parts range.replace(bytes, ).split(-); start Long.parseLong(parts[0]); if (parts.length 1 !parts[1].isEmpty()) { end Math.min(Long.parseLong(parts[1]), total - 1); } } long length end - start 1; response.setStatus(range ! null ? 206 : 200); response.setHeader(Accept-Ranges, bytes); response.setHeader(Content-Range, bytes start - end / total); response.setContentType(application/octet-stream); response.setContentLengthLong(length); try (FileChannel channel FileChannel.open(file, StandardOpenOption.READ)) { channel.position(start); channel.transferTo(start, length, Channels.newChannel(response.getOutputStream())); } }自己写这套容易踩两个坑一是忘了在end大于total-1时做截断二是用stream.skip(start)跳过起始位置对大文件来说这个skip过程可能比读取文件本身还慢。FileChannel.transferTo能让内核直接做零拷贝传输在高并发下对CPU的占用会低不少。3.4 三种方式怎么选方案代码量Range支持权限控制目录列表适用场景静态资源映射2行框架自动靠拦截器无内网共享、快速验证Controller ResourceRegion80行左右手动可控内置于Service可自行实现多终端对外服务、敏感场景原生Servlet流式输出60行左右手动内置于代码可自行实现老项目、兜底方案我在生产上最终选了方案二原因很现实目录结构要接数据库表不同用户只能看到授权目录下载行为要打审计日志。这些东西在静态资源映射下都很难做方案三又失去了Spring MVC的类型安全抽象。4. 目录结构设计存储、URL、安全与多终端适配4.1 存储目录怎么分才科学大文件服务最容易出现的问题是把所有文件放在一个扁平目录底下文件名一多操作系统目录项扫描都会变慢。我建议按业务和日期分层根目录/业务线/年/月/文件名。比如/data/files/course/2025/04/lecture1.mp4好处是归档清晰、冷热迁移方便、URL也自然带有语义。文件名本身要避开特殊字符尤其是空格、#、%、中文引号。URL编码虽然能处理但会让下载器生成的文件名、日志排查、第三方分享都变得别扭。我的做法是上传时强制改名成业务ID_时间戳.mp4这类格式展示名单独存数据库。这样URL永远干净前端展示时再取真实名称。4.2 虚拟目录映射和目录JSON如果只开放下载但用户不知道有哪些文件体验等于零。我写的目录接口会接收一个相对路径参数返回该目录下的文件列表和子目录列表。GetMapping(/dir) public ListFileNode listDir(RequestParam(defaultValue /) String path) { Path root Paths.get(rootPath).toAbsolutePath().normalize(); Path target root.resolve(path).normalize(); if (!target.startsWith(root) || !Files.isDirectory(target)) { throw new ResponseStatusException(HttpStatus.NOT_FOUND); } try (StreamPath stream Files.list(target)) { return stream.map(p - { FileNode node new FileNode(); node.setName(p.getFileName().toString()); node.setDirectory(Files.isDirectory(p)); try { node.setSize(Files.size(p)); node.setLastModified(Files.getLastModifiedTime(p).toMillis()); } catch (IOException ignored) { } return node; }).collect(Collectors.toList()); } }前端拿到这个JSON就可以按照目录层级渲染出文件树。目录很深时不要每次递归整棵树只返回当前层用户点击进入下一层时再请求一次这样即使目录有几千个文件也不会卡死。4.3 目录穿越这个校验必须写死字符串路径拼接最大的敌人是../。攻击者请求/file/../../etc/passwd时如果后端直接把URL路径拼到根路径后面就会越权读到系统文件。修复方式不复杂但必须是“规范化后再校验”顺序不能乱。Path root Paths.get(rootPath).toAbsolutePath().normalize(); Path target root.resolve(relativePath).toAbsolutePath().normalize(); if (!target.startsWith(root)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN); }这里resolve之后一定要加toAbsolutePath().normalize()。normalize()会把../和.折叠成正规路径然后startsWith(root)能正确判断目标是否还在根目录之内。光用startsWith不规范化的话攻击者用一个精心构造的root/../../etc前缀路径就可能绕过校验。这是我实际追查过一次安全事件之后才彻底改对的之前犯过只校验字符串前缀的错。4.4 多终端的Range请求差异浏览器最省心video和audio标签自动发Range请求支持拖动播放。下载文件用a download或window.location触发时Chromium会自己决定是否并发连接下载默认就能利用Range续传。Android端我用OkHttp写得很简单Request request new Request.Builder() .url(downloadUrl) .header(Range, bytes alreadyDownloaded -) .build();iOS端NSURLSession同理NSMutableURLRequest *request [NSMutableURLRequest requestWithURL:url]; [request setValue:[NSString stringWithFormat:bytes%lld-, alreadyDownloaded] forHTTPHeaderField:Range];关键是alreadyDownloaded这个值要持久化而且要结合文件总大小判断该从哪一段继续。有些客户端会用临时文件记录已下载长度崩溃后读临时文件大小作为起点。服务端只要保证Content-Range头正确这些端上逻辑都能和我们的后端配合良好。另外如果服务接入了CDN或对象存储同样适用Range语义。MinIO、OSS的预签名URL天然支持Range我后来把存储层切到对象存储时Controller侧逻辑几乎不用改。这也是把“下载”抽象成“HTTP Range响应”的好处存储底层以后怎么换都不影响接口契约。5. 生产级细节从能用变好用5.1 Nginx反代场景别被缓冲吃掉如果服务前面挂了Nginx很多人会遇到奇怪的现象后端明明返回了206客户端却一直收到200或者下载速度波动巨大。大概率是Nginx的缓存和缓冲在作怪。大文件场景需要保证以下配置存在location /file/ { proxy_pass http://127.0.0.1:8080; proxy_buffering off; proxy_cache off; proxy_set_header Range $http_range; proxy_set_header If-Range $http_if_range; }proxy_buffering off是核心否则Nginx会把后端分块传来的响应先攒到自己的缓冲区Range连续传输的优势会被破坏。还要注意不要在Nginx层面对大文件开启gzip压缩一个2G的视频只会白白吃CPU而且会让Range范围失真。前端静态资源该压缩压缩接口层大文件传输就不要压缩了。5.2 高并发下的内存策略使用ResourceRegion或transferTo的好处是数据流不经过JVM堆GC压力小。我见过有同事为了图省事用FileUtils.readFileToByteArray把整个文件读成byte[]再返回文件一上G应用直接OOM。这是大文件下载最不能碰的写法。后端接口返回大文件时不要把ResponseEntitybyte[]作为返回类型要用ResourceRegion、InputStreamResource或直接操作ServletResponse。Controller线程在传输期间会持续占用所以连接超时时间也需要单独考量。Spring Boot默认的Tomcat异步请求超时并不直接作用于这种写返回流的场景但前置网关层的超时时间必须调大否则大文件传到一半被网关掐断。5.3 ETag和If-Range文件变了要让客户端知道断点续传有个隐患文件在下载过程中被替换了。比如客户端下载到50%运维把原来的zip重发了一个新版本此时客户端继续请求bytes50%-拿到的就是“前版本的前50% 新版本的后50%”文件损坏且很难察觉。HTTP的解法是用ETag服务端输出ETag客户端在续传时带上If-Range: etag值服务端发现文件变了就忽略Range直接返回200全量文件没变就返回206继续。ETag最简单生成方式是文件大小-最后修改时间String etag \ file.length() - Files.getLastModifiedTime(file.toPath()).toMillis() \;在自定义Controller里加上这段逻辑大文件下载的完整性保障会上一个台阶。如果文件频繁更新这个ETag方案强实时性足够如果要求更强一致哈希可以改用文件的md5或sha256前缀但每次计算哈希对大文件本身就是额外开销生产上通常不这么干。5.4 文件完整性校验客户端下载完怎么知道没坏服务端提供文件时如果文件源目录里已经生成了对应的.sha256校验文件我的目录接口会把它一并暴露出来lecture1.mp4 lecture1.mp4.sha256客户端下载完之后用校验工具比对哈希即可。其实我更推荐在下载接口响应时固定返回一个自定义头response.setHeader(X-Check-Code, md5(file));很多下载器的脚本模式可以读取自定义头做校验比让用户手动下载校验文件体验好得多。唯一要注意的是计算全文件md5会多一次完整读盘所以我会做成后台异步任务提前算好并缓存而不是每次都实时计算。6. 常见问题与排查技巧实录6.1 问题速查表现象可能原因排查方法修复方案下载器一直全量下载不续传响应头缺Accept-Rangescurl -I看响应头自定义Controller补上该头curl带Range却返回200请求没到达你的Controller检查拦截器和路径映射是否覆盖调整映射路径顺序Android下载一半报文件损坏服务端Range区间计算越界看服务器日志中的Content-Range边界用Math.min截断到total-1目录接口返回特别慢目录文件太多、每次全量递归压测目录列表接口改成懒加载只返回当前层反代下载到一半502网关超时或缓冲开启看Nginx错误日志和upstream超时配置调大超时,proxy_buffering off文件名显示乱码URL和Content-Disposition编码不一致浏览器开发者工具看响应头文件名使用RFC 5987编码6.2 一个容易被忽略的坑多段Range有些下载工具会发Range: bytes0-99,200-299也就是多段Range。完整支持需要返回multipart/byteranges每个分段之间用boundary隔开客户端解析复杂度高而且实际场景里几乎没有下载器依赖这个特性。我的做法是直接取第一段返回206忽略其余分段。这在规范上是允许的服务端可以选择只响应单段Range。某些特别老实的下载器可能会因此放弃多线程但总比返回一个错误让客户端完全无法下载强。如果你将来接到一个必须支持多段Range的需求推荐用Spring的ResourceRegion循环生成多个区域再用MultipartBodyBuilder组装但这种需求十年遇不到一次。6.3 移动端弱网的经验移动端的网络抖动远比PC频繁下载到一半切换到Wi-Fi、锁屏、来电都会中断。除了服务端做好Range客户端策略也很重要每次下载任务持久化已下载字节数到本地数据库下载前先请求一次HEAD拿ETag和总大小如果ETag和上次记录不一致就清掉进度重新下载。这套双保险组合让我在Android端基本没再收到过“下载损坏”的投诉。6.4 如果需求是从老jar上改别走弯路搜索相关热词时看到有人问“怎么把Spring Boot jar反编译成项目”这里提醒一句在有源码的情况下不需要反编译直接新增一个FileController就行没有源码的话反编译只能帮你确认现状不如找运维要原始工程或重新搭建一个瘦身工程把文件接口单独做成一个独立服务。大文件下载这个能力非常适合独立部署不会拖垮主业务应用。最后再分享一个实用技巧我每次改完下载接口都会用一条固定命令做冒烟测试先下载文件前1M字节记录返回的Content-Range断网模拟时从断点重新发Range请求校验最终的合并文件哈希是否等于源文件哈希。这套流程十分钟内能全面覆盖Range边界、ETag和反代配置三个核心风险点。另外目录结构这块后续如果有闲余可以加一个“目录收藏”或“最近访问”接口前端体验会有很大提升但后端存储设计这块最好在第一天就考虑好等文件数量到了几十万个再改目录组织方式成本极高。我当初按业务加月份分层现在数据盘迁移和冷热归档都省了不少事。