文件上传与字符编码
HTTP 请求交给服务器的是字节。查询参数、表单字段、JSON 和文件使用不同的表示方式,Servlet 应选择相应解析入口:
| 输入 | Content-Type 或位置 | Servlet 常用入口 |
|---|---|---|
| URL 查询串 | ?name=value | getParameter() / getParameterValues() |
| URL 编码表单 | application/x-www-form-urlencoded | getParameter() |
| 多部分表单 | multipart/form-data; boundary=... | 配置 multipart 后使用 getParts() / getPart() |
| JSON、普通文本 | application/json、text/plain | getReader() 或原始流,再由对应解析器处理 |
| 任意二进制 | 如 application/octet-stream | getInputStream() |
getParameter() 不会把任意 JSON 自动变成表单参数。已经被 reader 或输入流消费的请求体,也不能指望后面的另一个解析器从头再读。入口日志、认证 Filter 和业务 Servlet 应事先约定由谁读取,以及是否需要有界缓存。
编码在什么位置生效
字节和字符之间需要明确规则
“深度”按 UTF-8 编码是六个字节:
深 度
E6 B7 B1 E5 BA A6相同字节若按 ISO-8859-1 解码,会得到另一组字符。String 一旦生成,再设置请求编码不会追溯重建已经解析的参数。若中间过程已经用替换字符丢弃非法字节,简单“转回原编码”也无法恢复信息。
请求体字符编码应在第一次获取参数或 reader 前确定。容器还可能使用应用默认编码;不能只因数据库使用 UTF-8,就假定 HTTP 参数也按 UTF-8 解析。设置时机和各 API 的关系见 ServletRequest API。
URL 查询串有另一条解码路径。Tomcat 的 URIEncoding 控制 URI 字节解码,useBodyEncodingForURI 影响是否把 body 编码用于查询串;这些属于 Connector 配置,不能用 setCharacterEncoding() 概括所有 URI 行为。参数上限和相关配置见 Tomcat HTTP Connector。
同一表单的早设置与晚设置
下载完整实验,解压进入 servlet-upload/。工程采用 Tomcat 11.0.25 / Servlet 6.1、Temurin 25 与 Java 17 编译目标,含上传 Servlet、启动类、样例文件和三个集成测试。Linux 宿主需 Docker 与 curl;构建目录、缓存由普通操作者拥有,容器进程显式使用相同 UID/GID。
BUILD_IMAGE=maven:3.9.12-eclipse-temurin-25
RUN_IMAGE=eclipse-temurin:25.0.4_7-jdk
mkdir -p .m2
docker run --rm --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/cache -v "$PWD/.m2:/cache" \
-v "$PWD:/work" -w /work "$BUILD_IMAGE" \
mvn -B -Dmaven.repo.local=/cache/repository clean verify
docker run --detach --name servlet-upload \
--user "$(id -u):$(id -g)" --read-only \
--cap-drop ALL --security-opt no-new-privileges \
--tmpfs /tmp:rw,nosuid,nodev,size=64m,mode=1777 \
-p 127.0.0.1:18089:8080 \
-v "$PWD/target:/app:ro" -w /app \
"$RUN_IMAGE" java -cp 'classes:dependency/*' lab.Server 8080
docker logs servlet-upload看到 LISTENING 8080 后发相同表单,只改变服务端的编码设置时机:
curl -q --noproxy '*' --fail-with-body --max-time 5 \
--data 'label=%E6%B7%B1%E5%BA%A6' \
http://127.0.0.1:18089/form
curl -q --noproxy '*' --fail-with-body --max-time 5 \
-H 'X-Late-Encoding: true' \
--data 'label=%E6%B7%B1%E5%BA%A6' \
http://127.0.0.1:18089/form第一次返回 correct,第二次返回 wrong。反例服务端先显式设为 ISO-8859-1 并调用 getParameter,再切换 UTF-8;控制其他条件后,直接观察参数已被缓存的结果。完整处理代码在 Server 的 /form 注册中:
boolean late = "true".equals(request.getHeader("X-Late-Encoding"));
request.setCharacterEncoding(late ? "ISO-8859-1" : "UTF-8");
String value = request.getParameter("label");
request.setCharacterEncoding("UTF-8");
response.setContentType("text/plain;charset=UTF-8");
response.getWriter().println("深度".equals(value) ? "correct" : "wrong");实际项目应把编码策略放到任何参数访问之前,例如顺序明确的 Filter。排查乱码时也要查安全、日志和包装 Filter 是否提前读取了参数,而不只看 Controller 里的最后一次设置。
企业网络需要通过批准的 Maven 仓库和镜像仓库下载依赖;离线实验需准备依赖缓存及镜像。项目下载成功、Maven 依赖齐全和容器启动成功是三个独立检查点,分别看首个失败命令的输出。
multipart 怎样分出字段和文件
boundary 分隔的是字节帧
一个文件字段的结构可以表示为:
Content-Type: multipart/form-data; boundary=demo
--demo
Content-Disposition: form-data; name="file"; filename="note.txt"
Content-Type: text/plain
file bytes
--demo--实际行结束使用 CRLF。boundary 是 Content-Type 参数,正文分隔行在其前面再加 --,最终分隔行后面还有 --。每个 part 有自己的 Header 和内容,字段名称由 Content-Disposition 的 name 指定;filename 是客户端提供的元数据。协议规则见 RFC 7578。
网络分块可以把 boundary 拆到两次读取中,文件正文也可包含普通换行和类似分隔符的内容。容器解析器必须维持跨缓冲区状态;把全部 body 转 String 后 split,会同时破坏二进制数据、编码和容量约束。
同名字段可以出现多次。普通多值参数使用 getParameterValues,多个同名文件则遍历 getParts 并按 part.getName 分类。只调用 getPart 会得到单个入口,不能据此判定整个请求只有一个文件。
Part 的阈值与上限各管一件事
Servlet 的 @MultipartConfig、XML multipart-config 或注册 API 都能配置:
| 参数 | 含义 |
|---|---|
| location | multipart 临时存储位置 |
| fileSizeThreshold | 从内存暂存切换到磁盘暂存的阈值 |
| maxFileSize | 单个上传文件允许的最大字节数 |
| maxRequestSize | 整个 multipart 请求允许的最大字节数 |
阈值不是文件上限。请求总大小还包含各 part Header、边界和普通字段,不能把单文件上限直接复制为总请求上限。参数定义见 MultipartConfigElement API。
实验在启动时创建 parts 和 objects 两个私有目录,并给上传组件配置:
var upload = Tomcat.addServlet(context, "upload", new UploadServlet(objects));
upload.setMultipartConfigElement(
new MultipartConfigElement(parts.toString(), 1024, 2048, 0));
context.addServletMapping("/upload", "upload");这里单文件最多 1024 字节、整个请求最多 2048 字节,阈值为零,便于观察落盘和清理。它们是演示值,真实配置应由上传契约、并发数和存储预算推导。
Tomcat 还提供 maxPartCount、maxPartHeaderSize 和 maxParameterCount 等解析限制。maxPostSize 限制特定请求中被转换为参数的 body 大小,不能当成所有二进制上传的总限制。代理入口、容器解析和应用保存可能各自设置上限,排查 413 时应先确认是哪一层拒绝。
从 Part 保存为应用文件
服务端名称与失败清理
上传 Servlet 使用服务端新建的随机文件名,客户端 filename 不参与磁盘路径计算。完整代码如下:
package lab;
import java.io.IOException;
import java.io.StringReader;
import org.apache.tomcat.util.http.parser.MediaType;
import java.nio.file.*;
import java.util.Collection;
import java.util.List;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.*;
public final class UploadServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private final transient Path objects;
public UploadServlet(Path objects) { this.objects = objects; }
@Override protected void doPost(HttpServletRequest request,
HttpServletResponse response) throws IOException {
String contentType = request.getContentType();
MediaType type = contentType == null ? null : MediaType.parseMediaType(new StringReader(contentType));
if (type == null || !"multipart".equalsIgnoreCase(type.getType())
|| !"form-data".equalsIgnoreCase(type.getSubtype())
|| type.getParameterValue("boundary") == null || type.getParameterValue("boundary").isEmpty()) {
response.sendError(400); return;
}
Collection<Part> parts = List.of();
try {
parts = request.getParts();
Part file = request.getPart("file");
if (file == null || file.getSubmittedFileName() == null) {
response.sendError(400); return;
}
Path object = Files.createTempFile(objects, "upload-", ".bin");
String objectId = object.getFileName().toString();
try (var input = file.getInputStream()) {
Files.copy(input, object, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException failure) {
try { Files.deleteIfExists(object); }
catch (IOException cleanup) { failure.addSuppressed(cleanup); }
throw failure;
}
response.setStatus(201);
response.setContentType("text/plain;charset=UTF-8");
response.getWriter().printf("storedBytes=%d%nobject=%s%n", file.getSize(), objectId);
} catch (IllegalStateException tooLarge) {
response.sendError(413);
} catch (ServletException malformed) {
response.sendError(400);
} finally {
for (Part part : parts) {
try { part.delete(); }
catch (IOException failure) { getServletContext().log("Part cleanup failed", failure); }
}
}
}
}objects 是启动时创建的私有目录。createTempFile() 先创建本次操作拥有的目标;复制只覆盖这个刚创建的空文件,不覆盖客户端指定路径。读取和复制失败时删除不完整目标;最后调用 Part.delete 清理已解析部件的临时存储。
Part 的临时数据由容器维护,Part.write() 可能复制或移动数据,其调用次数和存储行为有实现条件。需要明确目标生命周期时,像这里通过输入流复制到应用拥有的位置会更直观。方法契约见 Part API。
示例只保存到该进程的临时目录,关闭时全部删除,没有病毒扫描、认证或永久业务记录。生产文件应保存到独立受控存储,并在通过必要校验后才变为可下载对象。
正常上传与两个稳定负例
样例文件 sample.txt 包含“深度”和末尾换行,UTF-8 大小为七字节:
curl -q --noproxy '*' --fail-with-body --max-time 5 \
-F 'file=@sample.txt;type=text/plain' \
http://127.0.0.1:18089/upload正常响应状态为 201,正文包含 storedBytes=7 和服务端生成的对象名。对象名在每次运行不同,应检查名称形态和保存内容,不比对某个固定随机值。curl 会自动生成匹配的 multipart boundary,使用 -F 时不要自行拼出一个缺少对应 boundary 的请求体。
可以改变客户端声明的文件名:
curl -q --noproxy '*' --fail-with-body --max-time 5 \
-F 'file=@sample.txt;filename=../outside.txt' \
http://127.0.0.1:18089/upload响应仍使用服务端文件名。集成测试检查真实保存文件的内容,同时确认应用目录外没有出现 outside.txt。仅仅删除 filename 中的 ../ 不能构成通用路径安全策略,物理对象名最好完全不来自客户端。
工程的另外两个测试发送 1025 字节文件,以及缺失 boundary 参数的 multipart 请求,分别断言 413 和 400;两条失败路径结束后,objects 与 parts 都没有遗留文件。测试模式可单独执行:
docker run --rm --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/cache -v "$PWD/.m2:/cache" \
-v "$PWD:/work" -w /work "$BUILD_IMAGE" \
mvn -B -Dmaven.repo.local=/cache/repository \
-Dtest=UploadTest#oversizedAndMalformedDoNotPublish test代码先用 Tomcat 的媒体类型解析器检查 multipart 类型和 boundary,缺失时返回 400;multipart 超限异常映射成 413,Servlet 解析异常映射成 400。请求 I/O 或存储 I/O 失败仍可能返回 500 或直接断开,不能把它们全部算作格式错误;这是应用显式选择的状态,不意味着每个容器、代理或框架都会自动给出同样结果。
上传的容量、安全与恢复
流式复制仍会占资源
流式处理避免把整个文件装进堆,但请求仍占连接、执行时间、临时盘、文件描述符和下游带宽。并发上传数乘以最大暂存体积,才接近需要准备的临时空间;如果临时目录挂在 tmpfs,它还会消耗容器内存配额。
设置请求体大小之外,还需处理慢上传、空闲读和整体请求时限。非阻塞读取能够在数据未就绪时归还线程,但不能把无限上传变成低成本操作;异步 Servlet中的 ReadListener 示例同时累计字节并执行上限判断。
网络与业务失败的清理位置不同:
接收/解析 → 临时 Part → 校验 → 应用文件/对象 → 业务记录可用
│ │ │ │
└─解析失败──┴─复制失败─┴─校验拒绝──┘ → 关闭输入、删除本次未完成数据
进程崩溃 → 独立过期清扫finally 能处理当前调用走到清理代码的情况。进程崩溃或机器断电后,需要按存储状态、租约和保留时间识别未完成对象;不能无条件删除“看起来旧”的活跃上传。业务记录可以先为 PENDING,字节保存和校验完成后变为 READY,未通过检查的对象不公开读取入口。
文件名、类型和内容分别校验
客户端声明 text/plain 不代表文件内容就是安全文本。允许类型应由业务契约决定,必要时识别文件格式、校验实际内容并用资源受限的扫描或转换进程处理。复杂图片、PDF、Office 与压缩包解析器需要维护补丁,不能因为由内部接口上传就跳过检查。
压缩包还应限制展开后的总字节、文件数和层级,防止小压缩体占满磁盘。解压条目路径规范化后必须仍在目标目录内;嵌套归档和符号链接会增加处理条件。文件存储、扫描与下载授权的一般实践可查 OWASP 文件上传指南。
下载时使用正确的 Content-Type 和 Content-Disposition,展示名按协议编码,拒绝控制字符。不要把应用临时目录直接作为静态资源根目录,也不要让上传文件进入可执行脚本路径。
根据失败位置处理
| 现象 | 首查位置与下一步 |
|---|---|
| 字段乱码 | 检查 Content-Type、URIEncoding、首次参数/reader 访问;用同一字节请求复测 |
| 400 且未进入业务 | 核对 boundary、字段格式与容器解析日志;不要把所有 400 都归为业务校验 |
| 413 或解析超限异常 | 对齐代理、容器、MultipartConfig 和业务上限,再用临界大小上下各一条请求复测 |
| 临时盘持续增长 | df -h、du -sh 检查指定目录;核对正在上传和已失败对象,先限制新上传再修清理 |
| 容器 OOM、堆却没有大量 byte[] | 检查 tmpfs、并发上传与容器内存限制,不只增加 Java 堆 |
| 已保存文件但业务记录缺失 | 查对象状态和提交结果,按可恢复状态清理或重试,避免删除已被引用的文件 |
日志可以记录上传操作标识、实际字节数、拒绝阶段和清理结果,不记录正文、完整凭据或敏感原文件名。反复超限、中断后,临时文件数量和字节应回到同负载的稳定水平;某次 delete 成功不足以代表所有失败路径都已处理。
关闭本地实验:
docker stop --timeout 15 servlet-upload
docker rm servlet-upload这会删除实验进程新建的整个临时目录,包括成功上传的样例文件。正式存储不应复用这个演示性的退出删除策略。
权威资料与规范地址
完整契约、配置选项和实现细节可按下列地址查阅。
