简介:面向Java Web开发者的图片上传下载示例项目,基于Spring Boot框架整合ckeditor4富文本编辑器,演示从后端接收图片、保存至服务器目录、再由前端访问的完整实现链路。资源适合初步接触文件上传的开发者,可从中掌握MultipartFile参数处理、文件重命名与路径安全、CORS跨域配置以及REST接口的JSON返回格式。压缩包共71个文件,包含35个Java源码、18个class文件、7个xml配置、6个yml配置、3个properties配置及1个jar包,整体大小仅133KB,目录结构包含标准Maven工程、资源目录和构建产物,便于对照学习。目前已有235人学习。内容还涉及静态资源映射、防盗链设置、云存储与缩略图处理等扩展方向,能够帮助读者搭建一个带ckeditor4后台上传能力的轻量级文件管理演示,适合课程设计或企业内部工具快速落地。
1. Java 图片上传与下载:一个 Spring Boot 工程把链路走通
做 Java Web 开发,图片上传与下载算是绕不开的一关。做内容管理系统、博客后台、课程设计的时候,需要跟富文本编辑器打交道,而 ckeditor4 依然是老项目和新课设里的常客。这次拆的这份 springboot_file 工程,就是用 Spring Boot 把一个「图片上传 + 下载 + 静态资源访问」的后端接口完整跑起来,并且专门对齐了 ckeditor4 的图片上传协议:它要什么字段、你回什么 JSON,都写明白了。适合两类人看:一是刚学完 Java 基础,准备把课程设计落到代码上的同学;二是接手了带富文本功能的旧项目,正被「编辑器里选完图就裂」折磨的开发者。这东西不复杂,但把链路走通、把坑填平,需要一点血泪经验。
2. 上传接口设计:从 MultipartFile 到文件落盘的完整链路
2.1 为什么先选本地磁盘存储
上传接口的第一步不是写代码,而是决定文件存哪。最常见的做法是存服务器本地磁盘,比如项目根目录下的uploads/,或者操作系统里的/var/www/uploads/。本地磁盘的优势很直接:零依赖、性能好、单机部署时读写都够快,不需要引入额外的 SDK 或中间件,调试起来也直观——文件到底有没有写进去,看一眼目录就明白。
但本地存储也有明确边界。应用多实例部署时,用户上传的图片落在 A 机器,请求却被负载均衡转发到 B 机器,B 机器上找不到这张图,页面就裂了。另外重启或重新部署时,如果文件写在 classpath 里(比如src/main/resources/static/uploads),打出来的 jar 包根本写不进新文件,旧文件也随构建过程被覆盖。所以这个工程里,我建议把上传目录配在外部绝对路径,Spring Boot 只负责读写,不把上传目录塞进打包产物。
这份springboot_file工程默认也是这个思路:application.properties里配一个file.upload-dir自定义属性,代码里通过@Value注入。这样换机器、换环境,只改配置不改代码。
2.2 pom.xml 与 application.properties:先把地基打对
打开工程里的pom.xml,核心依赖其实就一个:spring-boot-starter-web。文件上传解析、REST 接口、内嵌 Tomcat 都靠它。其他像spring-boot-starter-test是测试用的,跑不跑无所谓。如果你是从零起项目,用 Maven 构建,父工程指向spring-boot-starter-parent,然后加 web starter,Maven 仓库会自动把依赖拉齐。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies>这段依赖的要点是版本继承。spring-boot-starter-parent会把依赖版本统一管理,你不需要写 Spring MVC 或 Tomcat 的具体版本号,省去一堆版本冲突。从零建工程时,最先确认的就是这点——很多人花半天排依赖,最后发现是自己手动写的版本和 Spring Boot 内置版本打架。
然后是application.properties里的上传配置:
spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=20MB file.upload-dir=./uploadsmax-file-size限制单文件大小,max-request-size限制一次请求的总大小,后者在批量上传时必须大于前者。file.upload-dir是自定义属性,代码里用@Value("${file.upload-dir}")取。需要注意,这个目录如果不存在,代码里要负责创建,Spring 不会帮你建。
2.3 写一个能扛住校验的上传接口
上传接口的核心是MultipartFile。Spring MVC 在收到multipart/form-data请求时,会自动把文件对象封装成MultipartFile注入 Controller 方法参数。这里最容易踩的坑是参数名不匹配:ckeditor4 默认的文件字段名是upload,而很多通用前端传的是file。先用@RequestParam("upload")对齐 ckeditor4,如果你的前端传别的名字,改成对应字段即可。
@RestController public class FileUploadController { @Value("${file.upload-dir}") private String uploadDir; @PostMapping("/api/upload/image") public Map<String, Object> uploadImage(@RequestParam("upload") MultipartFile file) { if (file.isEmpty()) { throw new RuntimeException("上传文件不能为空"); } String originalFilename = file.getOriginalFilename(); String ext = ""; if (originalFilename != null && originalFilename.contains(".")) { ext = originalFilename.substring(originalFilename.lastIndexOf(".")); } List<String> allowedExt = Arrays.asList(".jpg", ".jpeg", ".png", ".gif", ".webp"); if (!allowedExt.contains(ext.toLowerCase())) { throw new RuntimeException("不支持的文件类型: " + ext); } String newName = UUID.randomUUID().toString().replace("-", "") + ext; File dir = new File(uploadDir); if (!dir.exists()) { dir.mkdirs(); } File dest = new File(uploadDir + File.separator + newName); try { file.transferTo(dest); } catch (IOException e) { throw new RuntimeException("文件保存失败", e); } Map<String, Object> result = new HashMap<>(); result.put("uploaded", 1); result.put("fileName", newName); result.put("url", "/uploads/" + newName); return result; } }逻辑上分五步:判空、取扩展名、白名单校验、UUID 重命名、落盘。transferTo是 Spring 封装的原子操作,内部处理了临时文件迁移,比自己用FileOutputStream复制稳妥。扩展名白名单是必须的,否则任意文件都能上传,服务器上被丢一个 JSP 或者可执行脚本就麻烦了。UUID 重命名避免文件名碰撞,也顺手把中文名和特殊字符问题解决了。
3. 对接 ckeditor4 的后端接口:请求字段、JSON 响应与 CORS
3.1 ckeditor4 的上传请求到底长什么样
ckeditor4 本身不处理文件上传,它通过配置项filebrowserUploadUrl把上传动作转交给后端。页面里的编辑器初始化代码通常长这样:
CKEDITOR.replace('editor', { filebrowserUploadUrl: '/api/upload/image' });配置之后,用户在编辑器里点击「图片」按钮、选中本地文件,ckeditor4 会向/api/upload/image发一个multipart/form-data的 POST 请求,文件字段名固定是upload。这就是上一章代码里@RequestParam("upload")的来源。
很多第一次对接的人在这里翻车:后端接口写的是@RequestParam("file"),前端选完图接口报 400,因为字段名对不上。你可以在浏览器开发者工具里看到实际请求的 Form Data 里是upload: xxx.png,后端接收时就必须写upload。
3.2 返回给编辑器的 JSON:字段对不上就是黑匣子
ckeditor4 的图片上传响应格式是固定的,少了url或者把uploaded写成success,编辑器都会静默失败——看起来选完图了,但编辑区没有反应,也不报错,就是一片空白。这个黑匣子曾经坑过不少人。
| 字段 | 类型 | 说明 |
|---|---|---|
| uploaded | int | 固定返回 1,表示上传成功 |
| fileName | String | 重命名后的文件名 |
| url | String | 图片的可访问地址,编辑区会用它渲染 img 标签 |
| error | Object | 上传失败时返回,包含message字段 |
第 2 章代码里返回的正是这个结构。注意url可以是相对路径/uploads/xxx.jpg,也可以拼成完整的http://host:port/uploads/xxx.jpg。如果前端项目和后端项目部署在不同域名,建议直接返回绝对 URL,省得前端还要猜协议和端口。
失败时按 ckeditor4 的约定,uploaded必须置 0,同时给出error.message:
{ "uploaded": 0, "error": { "message": "文件类型不允许" } }3.3 CORS 与跨域:本地联调最常见的翻车现场
前后端分离开发时,前端页面跑在http://localhost:8081,后端接口在http://localhost:8080,浏览器会拦截跨域请求。ckeditor4 的图片上传本质上是从前端页面发起的 AJAX 请求,同样受同源策略限制。不配 CORS,接口在 Postman 里能通,编辑器里就是不行。
Spring Boot 里配 CORS 最干净的方式是让WebMvcConfigurer统一处理:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("http://localhost:8081") .allowedMethods("POST", "GET", "OPTIONS") .allowedHeaders("*") .maxAge(3600); } }allowedOrigins建议写成具体地址,不要直接用*。OPTIONS方法必须放开,因为浏览器跨域请求会先发一次预检。配置完记得重启,CORS 是请求层面的拦截,改完不重启不生效。如果你用 Spring Security,还要注意 Security 的过滤器链会先于 CORS 配置执行,两套配置都要放行。
4. 图片下载与静态资源映射:把 uploads 变成可访问的 URL
4.1 静态资源映射:让 /uploads/** 指向磁盘目录
文件落盘之后,下一个问题是让用户能通过 URL 访问到它。Spring Boot 默认静态资源路径是classpath:/static/、classpath:/public/这些,但上传到服务器磁盘的文件并不在这些目录里,直接请求/uploads/xxx.jpg会返回 404。需要手动把 URL 路径映射到磁盘路径。
@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${file.upload-dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/uploads/**") .addResourceLocations("file:" + uploadDir + File.separator); } }addResourceHandler("/uploads/**")声明 URL 前缀,addResourceLocations("file:" + uploadDir)声明磁盘根目录。注意file:前缀不能丢,它告诉 Spring 这是文件系统路径而不是 classpath 路径;路径末尾的File.separator也要补上,否则目录拼接可能出错。配好之后,http://localhost:8080/uploads/abc.jpg就能直接访问./uploads/abc.jpg。
4.2 URL 拼接:把相对路径变成完整地址
上一章返回的url字段用的是相对路径/uploads/xxx.jpg。如果图片只在页面里展示,相对路径够用。但如果你要把这个 URL 存数据库、推给第三方系统,或者给小程序前端用,就必须拼成完整地址。常见做法是在 Controller 里通过HttpServletRequest动态拼:
String fullUrl = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort() + "/uploads/" + newName;getScheme()返回http或https,getServerName()是请求的域名,getServerPort()是端口。如果后端前面挂了 Nginx 做了 HTTPS 终止,这里拿到的可能是http和内网端口,需要额外处理X-Forwarded-Proto头。最简单的替代方案是把请求域名配在application.properties里,部署时人工确认,避免自动拼接在代理环境下出错。
4.3 防直接访问与防盗链:轻量方案和它的边界
默认情况下/uploads/**里的图片是公开的,任何人拿到 URL 都能访问。如果图片涉及用户隐私,公开访问就不合适。轻量做法是写一个下载接口,加权限校验后再读文件返回流:
@GetMapping("/files/{fileName}") public ResponseEntity<Resource> download(@PathVariable String fileName, @RequestHeader(value = "Authorization", required = false) String token) { if (!"valid-token".equals(token)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } Path path = Paths.get(uploadDir).resolve(fileName).normalize(); Resource resource = new FileSystemResource(path); return ResponseEntity.ok() .contentType(MediaType.IMAGE_JPEG) .body(resource); }resolve(...).normalize()是防路径穿越的关键,确保fileName里的../不能跳出上传目录。但这个方案要求前端拿图片时带上鉴权头,<img>标签的src做不到,你只能用 base64 或者 XMLHttpRequest 拉取后转 blob。防盗链同理,可以通过拦截器检查Referer头来实现,但Referer可以被伪造,它只能防君子不防小人,适合给图片加个门槛,不适合做核心安全边界。
5. 避坑指南:上传下载与 ckeditor4 联调的五个常见坑
5.1 上传成功但图片 404:资源映射没配或者写进了 classpath
现象:接口返回uploaded: 1,数据也显示保存成功,但浏览器访问返回的url是 404。
原因:最常见的是没有配置WebMvcConfigurer.addResourceHandlers,Spring Boot 根本不认识/uploads/这个路径。另一种情况是文件被写进了src/main/resources/static/uploads,开发运行时能访问,打包成 jar 部署后既写不进新文件,旧文件也读不到。
解决:上传目录独立到外部路径,比如./uploads,用addResourceHandlers把/uploads/**映射到磁盘目录。打包前确认application.properties里的file.upload-dir指向的是外部路径,而不是 classpath 里的相对路径。
5.2 中文文件名乱码和路径穿越
现象:用原始文件名保存,中文名变成一串乱码;或者构造特殊 URL,发现能读到上传目录之外的文件。
原因:文件系统编码不一致,Windows GBK 和 Linux UTF-8 对中文的处理不同。路径穿越则是直接拿客户端提交的文件名去拼接磁盘路径,没有做过滤。
解决:统一用 UUID 重命名,从源头规避文件名编码问题。虽然你不需要展示原始文件名,但作为这行的习惯,落盘文件名里永远不要出现用户可控制的字符串。Path.normalize()是对路径类操作的最后一道防线,这两步一起做才稳妥。
5.3 大文件上传直接报错:Spring 默认限制只有 1MB
现象:上传 2MB 的图片,后端报MaxUploadSizeExceededException,或者前端直接收到 500。
原因:Spring Boot 的spring.servlet.multipart.max-file-size默认是 1MB,max-request-size默认 10MB。超过限制不进 Controller,在过滤器层就被拦了。
解决:在application.properties里调大限制,单文件 10MB、请求总量 20MB 是常见配置。注意改了配置要重启,@Value注入的 multipart 配置在启动时就确定了。如果用了 Nginx,还要同步调大client_max_body_size,否则请求到不了 Tomcat。
5.4 ckeditor4 选完图没反应:JSON 字段对不上
现象:接口返回 200,编辑器里图片没有插入,控制台也不报错。
原因:ckeditor4 对响应格式有严格要求。它只认uploaded和url字段,你返回{"code": 0, "data": {"url": "..."}}之类的结构,它解析不出来就静默放弃。
解决:严格按官方格式返回{"uploaded": 1, "fileName": "xxx.jpg", "url": "/uploads/xxx.jpg"}。调试时用浏览器开发者工具看 Network 面板的响应体,确认 JSON 结构没问题再检查前端配置。如果接口返回 HTML 错误页,通常是 404,检查filebrowserUploadUrl路径是否和后端 Controller 路径一致。
5.5 多实例部署后图片时好时坏:本地存储的天然短板
现象:上报到负载均衡环境,用户上传的头像有时能显示有时 404,两台机器上查文件,只在其中一台找到了。
原因:本地磁盘是单机存储,多实例部署时请求被分发到不同机器,文件不在同一份目录里。
解决:这种场景要换共享存储。最省事的是对象存储,把文件丢到 OSS 或 S3,返回 URL 即可。如果暂时不换云,至少要做 NFS 把多台机器的磁盘挂载成同一个目录。这点在设计阶段就要想清楚,否则上线后迁移文件会是一段痛苦的经历。
6. 进阶:从本地磁盘到对象存储,接口层怎么改才不伤筋动骨
本地磁盘方案撑到一定规模,迟早要面对一个问题:文件存储需要迁移到对象存储。这时候最怕的是上传逻辑散落在各个 Controller 里,改一处漏一处。所以从第一版开始,就应该把存储动作抽成接口。
public interface StorageService { String store(MultipartFile file); void delete(String fileName); }本地实现放在一个类里,把第 2 章的落盘逻辑原封不动搬进来;将来要做 OSS,就再写一个OssStorageServiceImpl,内部用云厂商 SDK 上传,返回 URL。Controller 里只依赖StorageService,不关心底层是磁盘还是对象存储,切换时把标注@Service的实现类换掉即可。后续要加缩略图生成,在接口层加一个FileProcessService,按需处理原图和质量压缩,也不会污染上传主流程。
从那以后,我每次新开一个带文件上传功能的后端项目,都会强制自己先花十来分钟把存储接口抽出来,哪怕第一版只有本地实现。这个习惯救过我很多次——数据量一旦上来,从一台服务器往对象存储迁移的代价远超你当时写接口省下的那点时间。文件上传下载这条路,功能做完只是开始,存储边界、安全校验、部署形态,每一项都要在动手前想清楚。希望帮到你。
本文还有配套的精品资源,点击获取