跳到主内容
创建时间: 2026-10-04最后更新: 2026-10-04

JSON 适合结构化 API,运维工作也常需要导入一份配置、下载一份处理结果。文件接口除了接收 MultipartFile,还要约定大小、编码、内容规则,以及哪些文件允许下载。

本章基于 Java 21、Spring Boot 4.1.1,实现「导入监控目标名称清单,再下载规范化后的结果」。清单作为独立配置文件保存,不会修改已有的 monitored_service 表,也不会自动创建定时任务或访问文件里的任意 URL。

1. 先定义文件契约

上传文件使用 UTF-8 纯文本,一行一个服务名称:

targets.txt
1
# 每行一个监控目标名称
2
spring
3
postgresql
4
payment-api

本例允许空行和以 # 开头的注释,名称统一转成小写。其余规则如下:

项目约束
文件大小最多 64 KiB
目标数量1 到 200,重复名称拒绝
名称格式小写字母开头,后面只允许小写字母、数字、连字符,最多 50 字符
编码错误拒绝,不使用替换字符悄悄修复
保存方式完整校验后原子替换当前清单
下载对象只能下载当前清单,不接受客户端路径

这是一种适合小型配置的格式,不是通用 CSV 或 YAML 解析器。以后确实需要表头、引号或嵌套结构时,再选择对应格式与解析器,不能用 split(",") 假装完整支持 CSV。

文件中的名称只是配置数据。启用这些目标还需要应用显式读取清单、校验目标是否存在并检查操作权限;本章不把上传等同于执行。

2. Multipart 大小限制

项目已有 Web MVC Starter,使用 Servlet 原生 Multipart 支持,不需要添加 Commons FileUpload。合并以下配置:

src/main/resources/application.yaml
01
spring:
02
servlet:
03
multipart:
04
max-file-size: 64KB
05
max-request-size: 80KB
06
mvc:
07
problemdetails:
08
enabled: true
09
10
app:
11
target-files:
12
directory: ${TARGET_FILES_DIRECTORY:./var/target-files}

整个请求的限制稍大于文件本身,因为 Multipart 还包含 boundary、字段头等额外内容。反向代理、Servlet 容器和应用都可能限制请求大小,部署时应一起核对。

目录由服务器配置决定,应由应用专用账号拥有、禁止其他不可信进程写入,并放在静态资源目录之外。容器中需要挂载持久卷,否则重建容器后清单可能丢失。相对路径以进程工作目录为基准,生产环境宜提供明确的绝对路径。

框架限制保护请求解析阶段,下面的代码仍检查实际读取的字节数。这样直接调用文件服务或以后调整框架配置时,不会意外失去内容大小边界。参见 Spring Boot 文件上传配置。

3. 读取、校验与保存

下面的类只承担小型文件配置的读写,不调用 Repository,也不接受客户端提供的落盘文件名:

src/main/java/cn/usehook/spring/files/TargetFileStorage.java
001
package cn.usehook.spring.files;
002
003
import java.io.IOException;
004
import java.io.InputStream;
005
import java.nio.ByteBuffer;
006
import java.nio.charset.CharacterCodingException;
007
import java.nio.charset.CodingErrorAction;
008
import java.nio.charset.StandardCharsets;
009
import java.nio.file.AtomicMoveNotSupportedException;
010
import java.nio.file.Files;
011
import java.nio.file.LinkOption;
012
import java.nio.file.Path;
013
import java.nio.file.StandardCopyOption;
014
import java.util.LinkedHashSet;
015
import java.util.List;
016
import java.util.Locale;
017
import java.util.Set;
018
import org.springframework.beans.factory.annotation.Value;
019
import org.springframework.http.HttpStatus;
020
import org.springframework.stereotype.Component;
021
import org.springframework.web.multipart.MultipartFile;
022
import org.springframework.web.server.ResponseStatusException;
023
024
@Component
025
public class TargetFileStorage {
026
private static final int MAX_BYTES = 64 * 1024;
027
private final Path directory;
028
private final Path current;
029
030
public TargetFileStorage(
031
@Value("${app.target-files.directory:./var/target-files}") String directory
032
) throws IOException {
033
this.directory = Path.of(directory).toAbsolutePath().normalize();
034
Files.createDirectories(this.directory);
035
this.current = this.directory.resolve("targets.txt");
036
}
037
038
public List<String> importTargets(MultipartFile file) throws IOException {
039
if (file.isEmpty()) {
040
throw invalid("A non-empty target file is required");
041
}
042
if (file.getSize() > MAX_BYTES) {
043
throw tooLarge();
044
}
045
byte[] bytes;
046
try (InputStream input = file.getInputStream()) {
047
bytes = input.readNBytes(MAX_BYTES + 1);
048
}
049
if (bytes.length > MAX_BYTES) {
050
throw tooLarge();
051
}
052
List<String> targets = parse(bytes);
053
String normalized = String.join("\n", targets) + "\n";
054
Path temporary = Files.createTempFile(directory, "targets-", ".tmp");
055
try {
056
Files.writeString(temporary, normalized, StandardCharsets.UTF_8);
057
Files.move(temporary, current,
058
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
059
} catch (AtomicMoveNotSupportedException exception) {
060
throw new ResponseStatusException(
061
HttpStatus.SERVICE_UNAVAILABLE,
062
"The configured storage does not support atomic replacement"
063
);
064
} finally {
065
Files.deleteIfExists(temporary);
066
}
067
return targets;
068
}
069
070
public byte[] downloadCurrent() throws IOException {
071
if (!Files.isRegularFile(current, LinkOption.NOFOLLOW_LINKS)) {
072
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No target file has been imported");
073
}
074
try (InputStream input = Files.newInputStream(current, LinkOption.NOFOLLOW_LINKS)) {
075
byte[] bytes = input.readNBytes(MAX_BYTES + 1);
076
if (bytes.length > MAX_BYTES) {
077
throw new IOException("Stored target file exceeds the configured limit");
078
}
079
return bytes;
080
}
081
}
082
083
private List<String> parse(byte[] bytes) {
084
String text;
085
try {
086
text = StandardCharsets.UTF_8.newDecoder()
087
.onMalformedInput(CodingErrorAction.REPORT)
088
.onUnmappableCharacter(CodingErrorAction.REPORT)
089
.decode(ByteBuffer.wrap(bytes)).toString();
090
} catch (CharacterCodingException exception) {
091
throw invalid("The file must use valid UTF-8 encoding");
092
}
093
if (text.startsWith("\uFEFF")) {
094
text = text.substring(1);
095
}
096
Set<String> targets = new LinkedHashSet<>();
097
String[] lines = text.split("\\R", -1);
098
for (int index = 0; index < lines.length; index++) {
099
String name = lines[index].strip().toLowerCase(Locale.ROOT);
100
if (name.isEmpty() || name.startsWith("#")) {
101
continue;
102
}
103
if (!name.matches("[a-z][a-z0-9-]{0,49}")) {
104
throw invalid("Invalid target name at line " + (index + 1));
105
}
106
if (!targets.add(name)) {
107
throw invalid("Duplicate target name at line " + (index + 1));
108
}
109
if (targets.size() > 200) {
110
throw invalid("At most 200 targets are allowed");
111
}
112
}
113
if (targets.isEmpty()) {
114
throw invalid("At least one target is required");
115
}
116
return List.copyOf(targets);
117
}
118
119
private ResponseStatusException invalid(String reason) {
120
return new ResponseStatusException(HttpStatus.BAD_REQUEST, reason);
121
}
122
123
private ResponseStatusException tooLarge() {
124
return new ResponseStatusException(HttpStatus.CONTENT_TOO_LARGE, "The file exceeds 64 KiB");
125
}
126
}

getOriginalFilename() 和 getContentType() 都由客户端提供。本例不使用它们决定目标路径或证明文件安全,而是检查实际内容,再写入服务器选择的 targets.txt。上传一个叫 ../../application.yaml 的文件也不会把它写到那个位置。MultipartFile 官方 API明确说明原始文件名不能被直接信任。

临时文件与目标文件位于同一目录,校验全部通过后才替换。坏文件不会留下半份新配置;文件系统不支持原子移动时返回失败,而不是先清空旧文件再尝试写入。原子替换保证读者不会看到半写入内容,不等于断电后的持久性承诺。

并发导入采用「最后一次成功替换生效」,不提供多人编辑冲突检测。需要版本控制时,应把清单作为带版本的配置资源保存,再使用条件更新或乐观锁;多实例还需要共享存储。本例只服务于单实例、小文件的运维配置。

4. 上传与受控下载

创建两个固定路径:一个导入当前清单,一个下载规范化结果。

src/main/java/cn/usehook/spring/controller/TargetFileController.java
01
package cn.usehook.spring.controller;
02
03
import cn.usehook.spring.files.TargetFileStorage;
04
import java.io.IOException;
05
import java.nio.charset.StandardCharsets;
06
import java.util.List;
07
import org.springframework.http.CacheControl;
08
import org.springframework.http.ContentDisposition;
09
import org.springframework.http.HttpHeaders;
10
import org.springframework.http.MediaType;
11
import org.springframework.http.ResponseEntity;
12
import org.springframework.web.bind.annotation.GetMapping;
13
import org.springframework.web.bind.annotation.PostMapping;
14
import org.springframework.web.bind.annotation.RequestMapping;
15
import org.springframework.web.bind.annotation.RequestParam;
16
import org.springframework.web.bind.annotation.RestController;
17
import org.springframework.web.multipart.MultipartFile;
18
19
@RestController
20
@RequestMapping("/api/v1/health/files/targets")
21
public class TargetFileController {
22
private final TargetFileStorage storage;
23
24
public TargetFileController(TargetFileStorage storage) {
25
this.storage = storage;
26
}
27
28
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
29
public ImportResult upload(@RequestParam("file") MultipartFile file) throws IOException {
30
List<String> targets = storage.importTargets(file);
31
return new ImportResult(targets.size(), targets);
32
}
33
34
@GetMapping(produces = MediaType.TEXT_PLAIN_VALUE)
35
public ResponseEntity<byte[]> download() throws IOException {
36
byte[] bytes = storage.downloadCurrent();
37
String disposition = ContentDisposition.attachment()
38
.filename("monitor-targets.txt", StandardCharsets.UTF_8).build().toString();
39
return 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
}
47
48
public 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。

terminal.bash
1
curl -i http://localhost:8080/api/v1/health/files/targets \
2
-H "Authorization: Bearer $TOKEN" \
3
-F 'file=@targets.txt;type=text/plain'
4
5
curl -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 的临时目录会隔离测试文件。以下测试不启动服务器:

src/test/java/cn/usehook/spring/files/TargetFileStorageTests.java
01
package cn.usehook.spring.files;
02
03
import java.nio.charset.StandardCharsets;
04
import java.nio.file.Files;
05
import java.nio.file.Path;
06
import org.junit.jupiter.api.Test;
07
import org.junit.jupiter.api.io.TempDir;
08
import org.springframework.mock.web.MockMultipartFile;
09
import org.springframework.web.server.ResponseStatusException;
10
11
import static org.assertj.core.api.Assertions.assertThat;
12
import static org.assertj.core.api.Assertions.assertThatThrownBy;
13
14
class TargetFileStorageTests {
15
@TempDir
16
Path directory;
17
18
@Test
19
void ignoresClientFilenameAndPreservesLastValidImport() throws Exception {
20
Path storageDirectory = directory.resolve("storage");
21
TargetFileStorage storage = new TargetFileStorage(storageDirectory.toString());
22
var valid = new MockMultipartFile("file", "../outside.txt", "image/png",
23
"Spring\npostgresql\n".getBytes(StandardCharsets.UTF_8));
24
25
assertThat(storage.importTargets(valid)).containsExactly("spring", "postgresql");
26
assertThat(Files.readString(storageDirectory.resolve("targets.txt")))
27
.isEqualTo("spring\npostgresql\n");
28
assertThat(Files.exists(directory.resolve("outside.txt"))).isFalse();
29
try (var files = Files.list(storageDirectory)) {
30
assertThat(files.map(path -> path.getFileName().toString()).toList())
31
.containsExactly("targets.txt");
32
}
33
34
var invalid = new MockMultipartFile("file", "targets.txt", "text/plain",
35
"spring\n../private\n".getBytes(StandardCharsets.UTF_8));
36
assertThatThrownBy(() -> storage.importTargets(invalid))
37
.isInstanceOf(ResponseStatusException.class);
38
assertThat(new String(storage.downloadCurrent(), StandardCharsets.UTF_8))
39
.isEqualTo("spring\npostgresql\n");
40
}
41
}

测试中的 image/png 是故意伪造的客户端类型,实际合法文本仍由内容规则判断。它验证了名称不会进入存储路径,也验证了坏文件不能覆盖旧结果。Servlet 层的 Multipart 限额还需要真实 HTTP 请求验证,普通存储类测试无法覆盖请求解析器。

原子移动的文件系统语义可继续阅读 Java Files.move 文档。当前实现把文件能力限制在明确的业务清单上;后续需要完整导入数据库时,再把解析完成的目标列表交给具有事务边界与资源授权的应用服务。