用 wastnet 实现文件上传与下载:从 multipart 流到服务器文件

2026-08-21 39 预计阅读时间: 1 分钟
来源: oschina.net AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:9 分钟

文件上传和下载看似简单,却是 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 替换 multipartFileinputStream 和响应方法。

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);

这个实现有几个重要边界:

  1. originalFilename 只能作为展示信息,不能直接拼接到磁盘路径中。
  2. 使用 getFileName() 和字符过滤可以降低路径穿越风险,但生产服务还应增加扩展名、文件大小和内容类型校验。
  3. 大文件上传应保持流式处理,并配置请求体大小上限,避免恶意请求耗尽磁盘或连接资源。
  4. 如果文件名需要长期稳定,建议把文件元数据写入数据库,磁盘名使用不可预测的 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、网盘或文件网关中的基础能力。


相关推荐