文件上传和下载看似简单,却是 Java Web 服务中最常见的 I/O 场景之一。网盘、CMS、后台管理系统和 OSS 网关都会反复处理这条链路:客户端通过 multipart/form-data 发送文件,服务器把文件流写入磁盘;客户端再次请求时,服务器读取磁盘文件并返回给浏览器。
本文用一个小型文件服务串起这两个动作,说明如何在 wastnet 中组织上传与下载接口。由于不同 wastnet 版本的 handler 和请求对象命名可能存在差异,下面的 Java 代码按常见 wastnet API 风格编写,接入具体版本时只需要对照当前版本替换少量方法名。
一个最小文件服务
服务可以只提供两个接口:
POST /files:接收一个 multipart 文件并保存到服务器目录。GET /files/{name}:根据文件名读取文件并返回给客户端。
上传接口的关键是不要先把整个文件读入内存,而是从 multipart 部分取得输入流,直接复制到目标文件。下载接口则需要检查目标文件是否存在、是否确实位于指定目录内,然后以文件流响应客户端。
目录可以这样准备:
mkdir -p data/uploads
客户端上传和下载可以用 curl 快速验证:
curl -X POST http://localhost:8080/files \\
-F "file=@./README.md"
curl -OJ http://localhost:8080/files/README.md
-F 会构造 multipart/form-data 请求,-O 和 -J 会让 curl 根据响应中的文件名保存下载结果。
上传:让文件流直接落盘
下面是一个接近 wastnet handler 风格的示例。这里假设请求对象能够访问 multipart 文件,并提供原始文件名和输入流;实际项目中请根据 wastnet 当前版本的 API 替换 multipartFile、inputStream 和响应方法。
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.UUID;
public final class FileHandlers {
private static final Path UPLOAD_DIR = Paths.get("data/uploads")
.toAbsolutePath()
.normalize();
public static void upload(Request request, Response response) throws IOException {
Files.createDirectories(UPLOAD_DIR);
MultipartFile file = request.multipartFile("file");
if (file == null || file.inputStream() == null) {
response.status(400).text("missing multipart field: file");
return;
}
String originalName = file.originalFilename();
String safeName = sanitizeFilename(originalName);
if (safeName.isBlank()) {
response.status(400).text("invalid filename");
return;
}
// 使用随机前缀避免同名文件覆盖;生产环境也可以改用数据库 ID。
String storedName = UUID.randomUUID() + "-" + safeName;
Path target = UPLOAD_DIR.resolve(storedName).normalize();
try (InputStream input = file.inputStream()) {
Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING);
}
response.status(201).json("{\"name\":\"" + storedName + "\"}");
}
private static String sanitizeFilename(String filename) {
if (filename == null) {
return "";
}
String name = Paths.get(filename).getFileName().toString();
return name.replaceAll("[^a-zA-Z0-9._-]", "_");
}
// 以下接口是示意类型,对应替换为 wastnet 实际的请求、响应和 multipart 类型。
interface Request {
MultipartFile multipartFile(String fieldName);
}
interface Response {
Response status(int code);
void text(String body);
void json(String body);
}
interface MultipartFile {
String originalFilename();
InputStream inputStream() throws IOException;
}
}
注册路由时,可以按类似下面的方式组织:
server.post("/files", FileHandlers::upload);
server.get("/files/{name}", FileHandlers::download);
这个实现有几个重要边界:
originalFilename只能作为展示信息,不能直接拼接到磁盘路径中。- 使用
getFileName()和字符过滤可以降低路径穿越风险,但生产服务还应增加扩展名、文件大小和内容类型校验。 - 大文件上传应保持流式处理,并配置请求体大小上限,避免恶意请求耗尽磁盘或连接资源。
- 如果文件名需要长期稳定,建议把文件元数据写入数据库,磁盘名使用不可预测的 ID。
下载:校验路径后返回文件
下载接口更容易出现路径穿越问题。用户传入的 name 不能直接作为 Paths.get(name) 使用,否则诸如 ../../application.properties 这样的路径可能访问上传目录之外的文件。
示例实现如下:
public static void download(Request request, Response response) throws IOException {
String requestedName = request.pathParam("name");
if (requestedName == null || requestedName.isBlank()) {
response.status(400).text("missing file name");
return;
}
Path file = UPLOAD_DIR.resolve(requestedName).normalize();
if (!file.startsWith(UPLOAD_DIR)) {
response.status(400).text("invalid file path");
return;
}
if (!Files.isRegularFile(file)) {
response.status(404).text("file not found");
return;
}
String contentType = Files.probeContentType(file);
if (contentType == null) {
contentType = "application/octet-stream";
}
response.header("Content-Type", contentType);
response.header("Content-Length", String.valueOf(Files.size(file)));
response.header("Content-Disposition", "attachment; filename=\""
+ file.getFileName() + "\"");
response.file(file);
}
在 wastnet 中,response.file(file) 应替换为当前版本提供的文件响应或流响应 API。核心顺序不要改变:解析参数、规范化路径、确认路径仍在上传目录内、确认文件存在,最后才打开文件并写入响应。
对于图片、PDF 等需要浏览器直接预览的内容,可以把 Content-Disposition 改成 inline;对于压缩包、安装包等下载型文件,使用 attachment 更符合用户预期。
错误处理和生产边界
一个能跑通的 demo 还不足以成为文件服务。部署前至少要补上以下约束:
- 限制单文件大小和请求体总大小。
- 限制允许的扩展名,并尽可能根据文件内容而不是客户端声明的
Content-Type判断类型。 - 对下载接口增加鉴权,避免用户通过猜测文件名访问其他人的文件。
- 文件目录不要放在静态资源根目录下,避免绕过业务鉴权直接访问。
- 上传完成后再写入数据库记录,失败时清理不完整的临时文件。
- 对文件名、用户 ID、请求 ID 和失败原因记录日志,但不要把完整敏感路径返回给客户端。
- 多实例部署时不要把本地磁盘当作共享存储;可以把 handler 后面的存储层替换为对象存储。
采用建议
如果只是内部工具或单机服务,wastnet 加上本地目录就足以实现上传和下载闭环。代码重点不在接口数量,而在流式 I/O、路径校验和错误边界。
上线前可以按这份清单检查:
- 上传是否真正使用流,而不是把文件整体读入内存?
- 文件大小、扩展名和内容类型是否有明确限制?
- 下载路径是否经过
normalize,并确认仍位于受控目录? - 下载是否设置了正确的
Content-Type、长度和处置方式? - 文件是否需要鉴权、过期时间或病毒扫描?
- 多实例运行时,文件是否应迁移到对象存储?
把这些边界处理好后,上传和下载接口就能从一个演示例子,稳定地演进为 CMS、网盘或文件网关中的基础能力。