JSON 适合结构化 API,运维工作也常需要导入一份配置、下载一份处理结果。文件接口除了接收 MultipartFile,还要约定大小、编码、内容规则,以及哪些文件允许下载。
本章基于 Java 21、Spring Boot 4.1.1,实现「导入监控目标名称清单,再下载规范化后的结果」。清单作为独立配置文件保存,不会修改已有的 monitored_service 表,也不会自动创建定时任务或访问文件里的任意 URL。
1. 先定义文件契约
上传文件使用 UTF-8 纯文本,一行一个服务名称:
1# 每行一个监控目标名称2spring3postgresql4payment-api
本例允许空行和以 # 开头的注释,名称统一转成小写。其余规则如下:
| 项目 | 约束 |
|---|---|
| 文件大小 | 最多 64 KiB |
| 目标数量 | 1 到 200,重复名称拒绝 |
| 名称格式 | 小写字母开头,后面只允许小写字母、数字、连字符,最多 50 字符 |
| 编码错误 | 拒绝,不使用替换字符悄悄修复 |
| 保存方式 | 完整校验后原子替换当前清单 |
| 下载对象 | 只能下载当前清单,不接受客户端路径 |
这是一种适合小型配置的格式,不是通用 CSV 或 YAML 解析器。以后确实需要表头、引号或嵌套结构时,再选择对应格式与解析器,不能用 split(",") 假装完整支持 CSV。
文件中的名称只是配置数据。启用这些目标还需要应用显式读取清单、校验目标是否存在并检查操作权限;本章不把上传等同于执行。
2. Multipart 大小限制
项目已有 Web MVC Starter,使用 Servlet 原生 Multipart 支持,不需要添加 Commons FileUpload。合并以下配置:
01spring:02servlet:03multipart:04max-file-size: 64KB05max-request-size: 80KB06mvc:07problemdetails:08enabled: true0910app:11target-files:12directory: ${TARGET_FILES_DIRECTORY:./var/target-files}
整个请求的限制稍大于文件本身,因为 Multipart 还包含 boundary、字段头等额外内容。反向代理、Servlet 容器和应用都可能限制请求大小,部署时应一起核对。
目录由服务器配置决定,应由应用专用账号拥有、禁止其他不可信进程写入,并放在静态资源目录之外。容器中需要挂载持久卷,否则重建容器后清单可能丢失。相对路径以进程工作目录为基准,生产环境宜提供明确的绝对路径。
框架限制保护请求解析阶段,下面的代码仍检查实际读取的字节数。这样直接调用文件服务或以后调整框架配置时,不会意外失去内容大小边界。参见 Spring Boot 文件上传配置。
3. 读取、校验与保存
下面的类只承担小型文件配置的读写,不调用 Repository,也不接受客户端提供的落盘文件名:
001package cn.usehook.spring.files;002003import java.io.IOException;004import java.io.InputStream;005import java.nio.ByteBuffer;006import java.nio.charset.CharacterCodingException;007import java.nio.charset.CodingErrorAction;008import java.nio.charset.StandardCharsets;009import java.nio.file.AtomicMoveNotSupportedException;010import java.nio.file.Files;011import java.nio.file.LinkOption;012import java.nio.file.Path;013import java.nio.file.StandardCopyOption;014import java.util.LinkedHashSet;015import java.util.List;016import java.util.Locale;017import java.util.Set;018import org.springframework.beans.factory.annotation.Value;019import org.springframework.http.HttpStatus;020import org.springframework.stereotype.Component;021import org.springframework.web.multipart.MultipartFile;022import org.springframework.web.server.ResponseStatusException;023024@Component025public class TargetFileStorage {026private static final int MAX_BYTES = 64 * 1024;027private final Path directory;028private final Path current;029030public TargetFileStorage(031@Value("${app.target-files.directory:./var/target-files}") String directory032) throws IOException {033this.directory = Path.of(directory).toAbsolutePath().normalize();034Files.createDirectories(this.directory);035this.current = this.directory.resolve("targets.txt");036}037038public List<String> importTargets(MultipartFile file) throws IOException {039if (file.isEmpty()) {040throw invalid("A non-empty target file is required");041}042if (file.getSize() > MAX_BYTES) {043throw tooLarge();044}045byte[] bytes;046try (InputStream input = file.getInputStream()) {047bytes = input.readNBytes(MAX_BYTES + 1);048}049if (bytes.length > MAX_BYTES) {050throw tooLarge();051}052List<String> targets = parse(bytes);053String normalized = String.join("\n", targets) + "\n";054Path temporary = Files.createTempFile(directory, "targets-", ".tmp");055try {056Files.writeString(temporary, normalized, StandardCharsets.UTF_8);057Files.move(temporary, current,058StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);059} catch (AtomicMoveNotSupportedException exception) {060throw new ResponseStatusException(061HttpStatus.SERVICE_UNAVAILABLE,062"The configured storage does not support atomic replacement"063);064} finally {065Files.deleteIfExists(temporary);066}067return targets;068}069070public byte[] downloadCurrent() throws IOException {071if (!Files.isRegularFile(current, LinkOption.NOFOLLOW_LINKS)) {072throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No target file has been imported");073}074try (InputStream input = Files.newInputStream(current, LinkOption.NOFOLLOW_LINKS)) {075byte[] bytes = input.readNBytes(MAX_BYTES + 1);076if (bytes.length > MAX_BYTES) {077throw new IOException("Stored target file exceeds the configured limit");078}079return bytes;080}081}082083private List<String> parse(byte[] bytes) {084String text;085try {086text = StandardCharsets.UTF_8.newDecoder()087.onMalformedInput(CodingErrorAction.REPORT)088.onUnmappableCharacter(CodingErrorAction.REPORT)089.decode(ByteBuffer.wrap(bytes)).toString();090} catch (CharacterCodingException exception) {091throw invalid("The file must use valid UTF-8 encoding");092}093if (text.startsWith("\uFEFF")) {094text = text.substring(1);095}096Set<String> targets = new LinkedHashSet<>();097String[] lines = text.split("\\R", -1);098for (int index = 0; index < lines.length; index++) {099String name = lines[index].strip().toLowerCase(Locale.ROOT);100if (name.isEmpty() || name.startsWith("#")) {101continue;102}103if (!name.matches("[a-z][a-z0-9-]{0,49}")) {104throw invalid("Invalid target name at line " + (index + 1));105}106if (!targets.add(name)) {107throw invalid("Duplicate target name at line " + (index + 1));108}109if (targets.size() > 200) {110throw invalid("At most 200 targets are allowed");111}112}113if (targets.isEmpty()) {114throw invalid("At least one target is required");115}116return List.copyOf(targets);117}118119private ResponseStatusException invalid(String reason) {120return new ResponseStatusException(HttpStatus.BAD_REQUEST, reason);121}122123private ResponseStatusException tooLarge() {124return new ResponseStatusException(HttpStatus.CONTENT_TOO_LARGE, "The file exceeds 64 KiB");125}126}
getOriginalFilename() 和 getContentType() 都由客户端提供。本例不使用它们决定目标路径或证明文件安全,而是检查实际内容,再写入服务器选择的 targets.txt。上传一个叫 ../../application.yaml 的文件也不会把它写到那个位置。MultipartFile 官方 API明确说明原始文件名不能被直接信任。
临时文件与目标文件位于同一目录,校验全部通过后才替换。坏文件不会留下半份新配置;文件系统不支持原子移动时返回失败,而不是先清空旧文件再尝试写入。原子替换保证读者不会看到半写入内容,不等于断电后的持久性承诺。
并发导入采用「最后一次成功替换生效」,不提供多人编辑冲突检测。需要版本控制时,应把清单作为带版本的配置资源保存,再使用条件更新或乐观锁;多实例还需要共享存储。本例只服务于单实例、小文件的运维配置。
4. 上传与受控下载
创建两个固定路径:一个导入当前清单,一个下载规范化结果。
01package cn.usehook.spring.controller;0203import cn.usehook.spring.files.TargetFileStorage;04import java.io.IOException;05import java.nio.charset.StandardCharsets;06import java.util.List;07import org.springframework.http.CacheControl;08import org.springframework.http.ContentDisposition;09import org.springframework.http.HttpHeaders;10import org.springframework.http.MediaType;11import org.springframework.http.ResponseEntity;12import org.springframework.web.bind.annotation.GetMapping;13import org.springframework.web.bind.annotation.PostMapping;14import org.springframework.web.bind.annotation.RequestMapping;15import org.springframework.web.bind.annotation.RequestParam;16import org.springframework.web.bind.annotation.RestController;17import org.springframework.web.multipart.MultipartFile;1819@RestController20@RequestMapping("/api/v1/health/files/targets")21public class TargetFileController {22private final TargetFileStorage storage;2324public TargetFileController(TargetFileStorage storage) {25this.storage = storage;26}2728@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)29public ImportResult upload(@RequestParam("file") MultipartFile file) throws IOException {30List<String> targets = storage.importTargets(file);31return new ImportResult(targets.size(), targets);32}3334@GetMapping(produces = MediaType.TEXT_PLAIN_VALUE)35public ResponseEntity<byte[]> download() throws IOException {36byte[] bytes = storage.downloadCurrent();37String disposition = ContentDisposition.attachment()38.filename("monitor-targets.txt", StandardCharsets.UTF_8).build().toString();39return ResponseEntity.ok()40.contentType(new MediaType("text", "plain", StandardCharsets.UTF_8))41.contentLength(bytes.length)42.cacheControl(CacheControl.noStore())43.header(HttpHeaders.CONTENT_DISPOSITION, disposition)44.header("X-Content-Type-Options", "nosniff")45.body(bytes);46}4748public record ImportResult(int count, List<String> targets) {}49}
下载不接收 path、filename 或磁盘绝对路径,只读取存储类中固定的文件。返回的文件名同样由服务端决定,不能把未经处理的上传名称拼进响应头。
这里最多读入 64 KiB,因此使用 byte[] 简单且有明确上限。大文件下载应改为受控资源或流式输出,并重新设计存储、超时与并发容量,不能直接扩大限制后继续无限读入内存。
5. 授权与失败响应
路径位于 /api/v1/health/** 下,因此在JWT 章节的配置中已要求 SCOPE_monitor,不会被末尾的 denyAll() 意外阻断。合并后续资源权限方案后,旧的 health 管理接口收紧为 SCOPE_monitor.admin,这里属于运维配置与导出,也应使用管理员 Token。
不要为了上传方便改成 permitAll()。以后改用浏览器 Session Cookie,还应按安全章节恢复 CSRF 防护;Multipart 不会天然避开 CSRF 风险。
| 情况 | 结果 |
|---|---|
| 文件字段缺失、为空、非法 UTF-8、名称错误 | 400 |
| 单文件或请求超过配置上限 | 在应用能够生成响应时为 413 |
| 还没有可下载的清单 | 404 |
| 身份或权限不满足 | 401 或 403 |
| 存储不支持原子替换 | 503 |
| 磁盘不可写等未预期 I/O 失败 | 通用 500,细节留在服务端日志 |
继续使用统一异常处理对 ErrorResponse 的转交规则,避免把 ResponseStatusException 或 MaxUploadSizeExceededException 统一改成 500。Multipart 解析可能发生在 Controller 调用之前;代理或容器提前拒绝连接时,也不保证能得到相同的 JSON 错误体。
6. 导入并下载
保存第一节的 targets.txt,然后上传。TOKEN 应满足当前安全配置;资源权限篇完成后使用管理员 Scope。
1curl -i http://localhost:8080/api/v1/health/files/targets \2-H "Authorization: Bearer $TOKEN" \3-F 'file=@targets.txt;type=text/plain'45curl -f http://localhost:8080/api/v1/health/files/targets \6-H "Authorization: Bearer $TOKEN" \7-o normalized-targets.txt
使用 -F 时让 curl 生成包含 boundary 的 Content-Type,不要再手动设置一个缺少 boundary 的 Multipart 请求头。
导入返回 count 与规范化后的目标数组,下载文件不再包含注释和空行。再提交一个包含重复名称或非法路径字符的文件,应返回 400,重新下载仍然得到之前的完整清单。
7. 验证文件名与失败原子性
使用已有的 spring-boot-starter-test,JUnit 的临时目录会隔离测试文件。以下测试不启动服务器:
01package cn.usehook.spring.files;0203import java.nio.charset.StandardCharsets;04import java.nio.file.Files;05import java.nio.file.Path;06import org.junit.jupiter.api.Test;07import org.junit.jupiter.api.io.TempDir;08import org.springframework.mock.web.MockMultipartFile;09import org.springframework.web.server.ResponseStatusException;1011import static org.assertj.core.api.Assertions.assertThat;12import static org.assertj.core.api.Assertions.assertThatThrownBy;1314class TargetFileStorageTests {15@TempDir16Path directory;1718@Test19void ignoresClientFilenameAndPreservesLastValidImport() throws Exception {20Path storageDirectory = directory.resolve("storage");21TargetFileStorage storage = new TargetFileStorage(storageDirectory.toString());22var valid = new MockMultipartFile("file", "../outside.txt", "image/png",23"Spring\npostgresql\n".getBytes(StandardCharsets.UTF_8));2425assertThat(storage.importTargets(valid)).containsExactly("spring", "postgresql");26assertThat(Files.readString(storageDirectory.resolve("targets.txt")))27.isEqualTo("spring\npostgresql\n");28assertThat(Files.exists(directory.resolve("outside.txt"))).isFalse();29try (var files = Files.list(storageDirectory)) {30assertThat(files.map(path -> path.getFileName().toString()).toList())31.containsExactly("targets.txt");32}3334var invalid = new MockMultipartFile("file", "targets.txt", "text/plain",35"spring\n../private\n".getBytes(StandardCharsets.UTF_8));36assertThatThrownBy(() -> storage.importTargets(invalid))37.isInstanceOf(ResponseStatusException.class);38assertThat(new String(storage.downloadCurrent(), StandardCharsets.UTF_8))39.isEqualTo("spring\npostgresql\n");40}41}
测试中的 image/png 是故意伪造的客户端类型,实际合法文本仍由内容规则判断。它验证了名称不会进入存储路径,也验证了坏文件不能覆盖旧结果。Servlet 层的 Multipart 限额还需要真实 HTTP 请求验证,普通存储类测试无法覆盖请求解析器。
原子移动的文件系统语义可继续阅读 Java Files.move 文档。当前实现把文件能力限制在明确的业务清单上;后续需要完整导入数据库时,再把解析完成的目标列表交给具有事务边界与资源授权的应用服务。