简介:这份资源面向Java后端初学者与需要为富文本编辑器接入图片服务的开发者,围绕Spring Boot环境讲解图片上传与下载的完整实现思路,并重点对接ckeditor4的后端上传接口。压缩包共71个文件,以35个java源码、18个class文件、7个xml与6个yml配置为主,另含properties配置、jar依赖与gitignore等,整体约133KB,结构紧凑,便于直接导入IDE运行调试。内容涵盖MultipartFile文件接收、上传目录规划、文件名重命名与路径遍历防护、异常处理,以及ckeditor4所需的RESTful接口与JSON响应格式、CORS跨域设置;下载侧则涉及静态资源映射、权限校验与防盗链思路,并延伸至云存储、缩略图生成和日志记录等优化方向。已有235人学习,适合作为快速搭建图片管理功能的参考模板,也可据此排查上传失败、跨域报错等常见问题。
1. 从一次“图片传不上去”的线上事故说起
文件上传下载是 Java Web 里最不起眼、却最容易在关键时刻翻车的功能。我见过一个后台管理系统,本地测试一切正常,上线后运营传一张 3MB 的商品图,接口直接返回 500,日志里躺着MaxUploadSizeExceededException;也见过下载接口把整个文件读进byte[]再写回响应,两个人同时下载就把堆内存顶到报警。图片上传与下载看着简单,真正落地要处理的是存储路径、文件名冲突、大小限制、类型校验、流式读写、并发和清理这一整套东西。
这篇笔记只围绕一个目标:用最朴素的 Java 技术栈,把图片上传与下载跑通,并且跑得能上生产。技术选型上我用 Spring Boot 作为 Web 层,因为它对MultipartFile的封装最省事,同时把底层Servlet的Part机制讲清楚,这样你换成纯 Servlet 或其它框架也能迁移。适合正在做课程设计、后台管理、内容管理系统的同学,也适合工作几年但一直用现成组件、没自己捋过文件流的工程师。读完你能拿到一套可复制的目录结构、参数配置和排错清单。
2. 上传下载的底层链路:从 MultipartFile 到磁盘文件
2.1 一次上传请求到底经过了什么
浏览器提交一个带图片的form表单时,请求头里会带上Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXXXX。这个boundary是分隔符,服务端靠它把请求体切成若干段,每段对应一个表单字段,图片就是其中一段二进制内容。Servlet 规范从 3.0 开始内置了 multipart 解析能力,Spring 在此基础上封装成MultipartFile,你拿到的getBytes()、getInputStream()、getOriginalFilename()都是从这个解析结果里来的。
理解这条链路的意义在于:上传失败的位置不同,排查方向完全不同。如果请求还没进 Controller 就被拦下,多半是容器或框架的大小限制;如果进了 Controller 但文件是空的,多半是表单enctype写错或字段名对不上;如果写磁盘时报错,那是路径权限或磁盘空间问题。很多人一遇到上传失败就到处加配置,其实先看异常堆栈落在哪一层,能省一半时间。
下载则是反向的:服务端设置响应头Content-Type和Content-Disposition,然后把文件字节流写进HttpServletResponse.getOutputStream()。这里的关键是不要一次性把文件读进内存,尤其是图片批量下载或大图场景,流式拷贝才是正确姿势。
2.2 存储方案怎么选:本地磁盘、对象存储还是数据库
新手最容易纠结的是图片存哪。三种常见方案各有边界:
| 方案 | 适用场景 | 主要问题 |
|---|---|---|
| 本地磁盘 | 单机部署、课程设计、内网系统 | 多实例无法共享,扩容迁移麻烦 |
| 对象存储 | 生产环境、多实例、CDN 加速 | 需要额外 SDK 和网络配置 |
| 数据库 BLOB | 极小文件、强事务一致性要求 | 数据库膨胀快,备份恢复慢 |
我的建议是:学习和中小项目先用本地磁盘,但把存储层抽象成一个接口,比如FileStorageService,本地实现叫LocalFileStorageService。这样以后换对象存储只改一个实现类,Controller 不用动。这个抽象成本很低,却是后期最值钱的一步。数据库存图片这条路,除非你有非常明确的强一致需求,否则不要碰,我踩过一次,一个 20GB 的库备份要四十分钟,血泪经验。
2.3 最小可运行的上传接口
先看依赖,Spring Boot 项目只需要 Web 起步依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>然后是上传接口本身:
@RestController @RequestMapping("/api/file") public class FileController { // 上传根目录,生产环境应配置到 application.yml,不要硬编码 private static final String UPLOAD_DIR = "/data/upload/"; @PostMapping("/upload") public Map<String, Object> upload(@RequestParam("file") MultipartFile file) throws IOException { // 1. 空文件校验,前端漏传或字段名写错都会走到这里 if (file.isEmpty()) { throw new IllegalArgumentException("上传文件为空"); } // 2. 原始文件名可能带路径,必须只取文件名部分,防止目录穿越 String original = StringUtils.cleanPath(file.getOriginalFilename()); // 3. 用 UUID 重命名,避免同名覆盖和中文名编码问题 String ext = original.substring(original.lastIndexOf(".")); String storedName = UUID.randomUUID().toString().replace("-", "") + ext; File dir = new File(UPLOAD_DIR); if (!dir.exists() && !dir.mkdirs()) { throw new IOException("创建上传目录失败"); } File dest = new File(dir, storedName); // 4. transferTo 底层是流拷贝,比 getBytes 再写省内存 file.transferTo(dest); Map<String, Object> result = new HashMap<>(); result.put("storedName", storedName); result.put("size", file.getSize()); return result; } }逻辑说明:第 2 步的StringUtils.cleanPath来自 Spring 的org.springframework.util,它会处理..和多余斜杠,是防目录穿越的第一道闸。第 3 步用 UUID 重命名是行业惯例,原始文件名只存数据库做展示,不参与磁盘路径。第 4 步transferTo在 Spring 里对大于阈值的文件会走临时文件移动,比手动getInputStream拷贝更稳。
参数说明:@RequestParam("file")里的file必须和前端表单的name属性完全一致,这是新手最高频的翻车点。UPLOAD_DIR结尾的斜杠别省,new File(dir, name)虽然能处理,但混用字符串拼接时容易出问题。
2.4 下载接口与响应头设置
@GetMapping("/download/{storedName}") public void download(@PathVariable String storedName, HttpServletResponse response) throws IOException { // 1. 路径参数同样要校验,禁止包含斜杠和 .. if (storedName.contains("/") || storedName.contains("..")) { response.setStatus(HttpServletResponse.SC_BAD_REQUEST); return; } File file = new File(UPLOAD_DIR, storedName); if (!file.exists()) { response.setStatus(HttpServletResponse.SC_NOT_FOUND); return; } // 2. 根据扩展名推断 MIME,图片要正确设置否则浏览器不预览 String mime = Files.probeContentType(file.toPath()); response.setContentType(mime != null ? mime : "application/octet-stream"); // 3. inline 表示浏览器内预览,attachment 表示强制下载 response.setHeader("Content-Disposition", "inline; filename=\"" + URLEncoder.encode(storedName, "UTF-8") + "\""); response.setContentLengthLong(file.length()); // 4. 流式拷贝,8KB 缓冲区是 IO 的常见经验值 try (InputStream in = new FileInputStream(file); OutputStream out = response.getOutputStream()) { byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } out.flush(); } }逻辑说明:第 1 步的路径校验不能省,@PathVariable虽然不会匹配斜杠,但编码后的%2F在某些容器配置下会被还原,手动挡一道更保险。第 3 步Content-Disposition里的文件名用URLEncoder编码,否则中文名在部分浏览器会乱码。第 4 步用try-with-resources保证流关闭,response.getOutputStream()不需要手动关,容器会处理,但FileInputStream必须关。
参数说明:缓冲区 8192 字节是通用值,图片场景可以调到 16384 减少系统调用次数,但收益有限。setContentLengthLong让浏览器能显示下载进度,大文件场景建议加上。
3. 把上传下载做扎实:校验、配置与目录规划
3.1 大小限制的三层配置别漏
上传大小限制在 Spring Boot 里至少有两层,很多人只配了一层就以为完事:
spring: servlet: multipart: max-file-size: 10MB # 单个文件上限 max-request-size: 20MB # 整个请求上限,多文件时要注意 file-size-threshold: 1MB # 超过此值写入临时文件而非内存 location: /data/tmp # 临时文件目录,默认是系统临时目录第一层是 Spring 的MultipartConfigElement,由上面这些配置生成。第二层是内嵌容器本身,比如 Tomcat 的maxSwallowSize,它决定请求体被拒绝后容器还愿意读多少数据,配小了会出现连接重置而不是友好的 413。第三层是反向代理,Nginx 默认client_max_body_size是 1MB,前端传 2MB 图片直接 413,这个坑我见过太多次,排查时先看代理日志。
提示:
file-size-threshold设太小会让所有文件都落临时盘,设太大则大文件占内存。1MB 是个平衡点,图片场景够用。
3.2 文件类型校验:别只信扩展名
只校验扩展名等于没校验,攻击者把.jsp改成.jpg就能绕过。正确做法是读文件头魔数:
public static boolean isImage(MultipartFile file) throws IOException { // 读取前 8 个字节判断魔数 byte[] header = new byte[8]; try (InputStream in = file.getInputStream()) { if (in.read(header) < 8) { return false; } } // JPEG: FF D8 FF if ((header[0] & 0xFF) == 0xFF && (header[1] & 0xFF) == 0xD8 && (header[2] & 0xFF) == 0xFF) { return true; } // PNG: 89 50 4E 47 if ((header[0] & 0xFF) == 0x89 && header[1] == 'P' && header[2] == 'N' && header[3] == 'G') { return true; } // GIF: 47 49 46 38 if (header[0] == 'G' && header[1] == 'I' && header[2] == 'F' && header[3] == '8') { return true; } return false; }逻辑说明:& 0xFF是把有符号 byte 转成无符号整数再比较,Java 的 byte 是有符号的,直接和0xFF比会出错,这是很多人写魔数校验时的隐藏 bug。这个方法只覆盖 JPEG、PNG、GIF 三种常见格式,WebP 的魔数是RIFF....WEBP,需要读 12 字节,按需扩展。
参数说明:读流之前要判断file.getSize(),太小的文件直接拒绝,避免read返回 -1 时的边界问题。校验完记得流会被消费,如果后面还要用transferTo,Spring 的MultipartFile支持重复读取(临时文件模式),但内存模式下的实现要小心,建议校验和保存分开处理。
3.3 目录规划:按日期分片,别堆一个目录
所有图片扔一个目录,文件数上万后ls都卡,备份和迁移也痛苦。常见做法是按日期分片:
public static String buildRelativePath(String storedName) { // 按 yyyy/MM/dd 分三级目录,单目录文件数可控 LocalDate now = LocalDate.now(); String datePath = now.format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); return datePath + "/" + storedName; }逻辑说明:yyyy/MM/dd三级目录,按每天一万张图算,单目录也就一万个文件,文件系统完全扛得住。数据库里存相对路径,读取时拼上根目录,这样迁移存储根目录不用改数据。
参数说明:分片粒度可以按业务调整,图片量小的系统用yyyy/MM就够,量大的用yyyy/MM/dd/HH。关键是分片规则一旦上线不要改,否则老数据找不到,这是后悔药都买不到的事。
3.4 用数据库记录文件元信息
磁盘上只有文件不够,业务上通常要记录谁传的、什么时候传的、原始文件名是什么:
CREATE TABLE sys_file ( id BIGINT PRIMARY KEY AUTO_INCREMENT, stored_name VARCHAR(64) NOT NULL COMMENT '磁盘存储名', original_name VARCHAR(255) NOT NULL COMMENT '原始文件名', relative_path VARCHAR(255) NOT NULL COMMENT '相对路径', content_type VARCHAR(128) COMMENT 'MIME 类型', file_size BIGINT NOT NULL COMMENT '字节数', uploader_id BIGINT COMMENT '上传人', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_stored_name (stored_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:stored_name加唯一索引,防止极端情况下 UUID 碰撞(概率极低但成本几乎为零)。relative_path存相对路径而不是绝对路径,是为了迁移时只改配置。original_name用utf8mb4存,中文和 emoji 都不会出问题。
参数说明:file_size用BIGINT而不是INT,INT上限约 2GB,虽然图片到不了,但统一用BIGINT省得以后改表。content_type存下来方便下载时直接设置响应头,不用每次重新探测。
4. 上传下载的避坑清单:五个真实翻车现场
4.1 现象:接口返回 500,日志是 MaxUploadSizeExceededException
原因:spring.servlet.multipart.max-file-size没配或配小了,默认值在部分版本里只有 1MB。前端传的图片超过限制,Spring 在解析阶段就抛异常,还没进 Controller。
解决:在application.yml里显式配置max-file-size和max-request-size,并且加一个全局异常处理器把MaxUploadSizeExceededException转成友好的 413 响应,别让用户看到 500 堆栈。同时检查 Nginx 的client_max_body_size,两层都要放开。
4.2 现象:文件传上来了,但大小是 0 字节
原因:前端表单没写enctype="multipart/form-data",或者@RequestParam的名字和表单name对不上。还有一种情况是用了@RequestBody接MultipartFile,这是接不到的,multipart 必须用@RequestParam或@RequestPart。
解决:先看浏览器开发者工具里的请求体,确认是 multipart 格式且字段名正确。后端把@RequestParam("file")的名字和前端对齐,大小写敏感。用 Postman 测试时注意选form-data而不是x-www-form-urlencoded。
4.3 现象:中文文件名下载后变成乱码
原因:Content-Disposition里的文件名没编码,HTTP 头默认按 ISO-8859-1 解析,中文直接乱。不同浏览器对编码方式的支持还不一致。
解决:用URLEncoder.encode(name, "UTF-8")编码,并且把空格替换成%20而不是+。更稳妥的写法是同时提供filename和filename*=UTF-8''两个参数,兼容老浏览器。这个坑没有银弹,测试时至少覆盖 Chrome 和 Edge。
4.4 现象:并发下载大图时内存飙升
原因:下载接口用了Files.readAllBytes或file.getBytes()把整个文件读进内存,单文件 10MB,十个并发就是 100MB 堆占用,图片再大点直接 OOM。
解决:改成流式拷贝,用固定大小缓冲区循环读写。响应头里设置Content-Length,让浏览器知道总大小。如果图片需要压缩或加水印,也要用流式处理库,别先读全量再处理。
4.5 现象:上传目录权限不足,Linux 上报 Permission denied
原因:应用以非 root 用户运行,UPLOAD_DIR指向的目录属主是 root,或者目录不存在且父目录不可写。Windows 本地测试正常,一上 Linux 就翻车。
解决:部署时用chown -R appuser:appuser /data/upload把目录给应用用户,mkdir -p确保父目录存在。代码里mkdirs失败要抛明确异常,别吞掉。容器部署时注意挂载卷的权限,readOnly挂载是写不进去的。
5. 进阶技巧:用断点续传和图片压缩把体验拉满
基础功能跑通后,真正拉开差距的是两个点:大图上传的稳定性和下载的响应速度。先说上传,普通表单上传在网络抖动时会整个失败重来,用户体验很差。一个轻量做法是前端分片、后端合并,核心逻辑是记录已上传的分片序号,全部到齐后按序拼接:
@PostMapping("/chunk") public Map<String, Object> uploadChunk(@RequestParam("chunk") MultipartFile chunk, @RequestParam("index") int index, @RequestParam("total") int total, @RequestParam("md5") String md5) throws IOException { // 每个文件用 md5 建临时目录,分片按序号命名 File chunkDir = new File(UPLOAD_DIR + "chunks/" + md5); if (!chunkDir.exists() && !chunkDir.mkdirs()) { throw new IOException("创建分片目录失败"); } chunk.transferTo(new File(chunkDir, String.valueOf(index))); // 检查是否所有分片都到齐 File[] chunks = chunkDir.listFiles(); if (chunks != null && chunks.length == total) { // 按序号排序后顺序写入最终文件 Arrays.sort(chunks, Comparator.comparingInt(f -> Integer.parseInt(f.getName()))); File target = new File(UPLOAD_DIR, md5 + ".jpg"); try (OutputStream out = new FileOutputStream(target)) { for (File c : chunks) { Files.copy(c.toPath(), out); } } // 合并完清理临时分片,避免磁盘堆积 for (File c : chunks) { c.delete(); } chunkDir.delete(); } return Collections.singletonMap("received", index); }逻辑说明:用文件 md5 做临时目录名,天然去重,同一文件重复上传不会冲突。分片按数字序号命名,合并前必须排序,否则图片会错位,这是分片上传最经典的 bug。合并用Files.copy追加写入,比手动读字节数组简洁。合并后清理临时文件,否则磁盘会被分片撑爆。
参数说明:index从 0 或 1 开始要前后端约定一致,我一般用 0。total是分片总数,前端按固定分片大小(比如 2MB)算出来。md5建议前端算,大文件后端算太慢。分片大小别太小,1MB 以下会产生大量请求,2MB 到 5MB 比较合适。
再说下载侧的图片压缩。原图动辄几 MB,列表页展示根本不需要那么高分辨率。常见做法是上传时生成一张缩略图,下载接口根据参数返回不同尺寸:
public static void writeThumbnail(File source, OutputStream out, int maxWidth) throws IOException { BufferedImage image = ImageIO.read(source); if (image == null) { throw new IOException("无法解析图片"); } // 按宽度等比缩放,高度自动计算 int width = Math.min(image.getWidth(), maxWidth); int height = image.getHeight() * width / image.getWidth(); BufferedImage thumb = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D g = thumb.createGraphics(); // 开启抗锯齿,缩放后不会太糊 g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); g.drawImage(image, 0, 0, width, height, null); g.dispose(); ImageIO.write(thumb, "jpg", out); }逻辑说明:ImageIO.read对某些 CMYK 模式的 JPEG 会返回 null,所以必须判空。缩放用Graphics2D的drawImage一步到位,比先getScaledInstance再画性能好。TYPE_INT_RGB会丢掉透明通道,PNG 转 JPG 时要注意,需要透明就换TYPE_INT_ARGB。
参数说明:maxWidth按场景定,列表缩略图 200 到 400 像素够用,详情页预览 800 到 1200 像素。抗锯齿用BILINEAR是速度和质量的平衡点,BICUBIC更清晰但慢,图片量大时慎用。输出格式统一用 JPG 体积小,但透明图要保留 PNG。
最后说一个验证习惯:每次改完上传下载相关代码,我都会用三种方式各测一遍——Postman 传正常图、传一个改了扩展名的非图片文件、传一个超过限制的大文件。这三个用例能覆盖八成以上的线上问题。图片上传下载这活儿,写起来半天,写扎实要踩不少坑,但把存储抽象、流式读写、魔数校验这几件事做对,后面基本不用再回头改。希望帮到你。
本文还有配套的精品资源,点击获取