Rust WASM 文件上传全链路:从浏览器到 S3,一个字节都不能少

最近在做用户头像上传功能时,把 Rust/WASM 前端到 S3 后端的完整文件上传链路跑通了。整个过程不算顺滑------光 WASM 里读个文件就踩了一下午的坑。这篇文章把这个链路的每一环都拆开来讲,希望能帮到同样在折腾 Rust 全栈的同学。

提前声明:本文基于 Leptos SSR + WASM 方案,后端对接 S3 兼容存储,部分实现细节和框架绑定较强,仅供参考。


一、整体架构:四个环节,哪个掉了都传不上去

整个文件上传链路从用户拖拽一个文件开始,到文件落盘在 S3 上,中间要经过四个关键环节:

rust 复制代码
浏览器 FileReader → JsFuture 异步读取 → Vec<u8>
     ↓
Leptos Server Function → HTTP POST → 后端校验
     ↓
FileCommandService → 领域校验 + 分类存储
     ↓
RustFSGateway → S3 PutObject → 落盘

每一层都有校验,每一层都可能失败。 下面按数据流的顺序逐个拆解。


二、浏览器端:FileReader + JsFuture,差点把我逼疯

为什么在 WASM 里读文件这么麻烦?

在 JS 里读取用户选中的文件很简单,file.arrayBuffer() 一行搞定。但在 Rust/WASM 里,我们没有 JS 的 async/await 语法糖,必须用 web_sys::FileReader 手动绑定回调,然后用 JsFuture 把回调式的 API 转成 Rust 的 Future。

听起来不复杂?写起来是真要命。

举个例子:把 FileReader 的 onload 回调转成 Promise

核心思路是用 js_sys::Promise::new() 手动创建一个 Promise,在 FileReader 的 onload 回调里 resolve,在 onerror 里 reject,然后用 JsFuture::from(promise) 去 await。

但这里有个大坑:闭包的生命周期。

Promise::new() 的回调是同步执行的(构造函数里马上跑),所以如果闭包在这个回调里创建、又在这个回调结束时被 drop,那文件还没读完,onload 闭包就已经被释放了------文件永远读不出来。

正确的做法是用 Rc<RefCell<Option<Closure>>> 把闭包所有权移到 executor 外面。这是整个 WASM 上传里最 tricky 的一段代码:

rust 复制代码
pub async fn read_file_as_bytes(file: &File) -> Result<Vec<u8>, String> {
    let file_reader = FileReader::new().map_err(|_| "创建FileReader失败")?;

    // 关键:闭包持有者放在 executor 外面,保证生命周期跨越 await
    type ClosureHolder = Rc<RefCell<Option<Closure<dyn FnMut(web_sys::Event)>>>>;
    let onload_holder: ClosureHolder = Rc::new(RefCell::new(None));
    let onerror_holder: ClosureHolder = Rc::new(RefCell::new(None));

    let promise = js_sys::Promise::new(&mut |resolve, reject| {
        let reader = file_reader.clone();
        let onload_holder_inner = onload_holder.clone();
        let onerror_holder_inner = onerror_holder.clone();

        // onload 回调:读到数据后 resolve
        let onload = Closure::wrap(Box::new(move |_: web_sys::Event| {
            // 先释放 onerror,保证互斥:两个回调只有可能一个执行
            onerror_holder_inner.borrow_mut().take();
            if let Ok(result) = reader.result() {
                resolve.call1(&JsValue::NULL, &result).unwrap();
            }
        }) as Box<dyn FnMut(web_sys::Event)>);

        // onerror 回调:读失败就 reject
        let onerror = Closure::wrap(Box::new(move |_: web_sys::Event| {
            onload_holder_inner.borrow_mut().take();
            reject.call1(&JsValue::NULL, &JsValue::from_str("读取文件出错")).unwrap();
        }) as Box<dyn FnMut(web_sys::Event)>);

        file_reader.set_onload(Some(onload.as_ref().unchecked_ref()));
        file_reader.set_onerror(Some(onerror.as_ref().unchecked_ref()));

        // 把闭包所有权移到外面,防止 Promise 构造函数返回后被 drop
        *onload_holder.borrow_mut() = Some(onload);
        *onerror_holder.borrow_mut() = Some(onerror);

        file_reader.read_as_array_buffer(file).unwrap();
    });

    // await Promise,此时闭包还在外面活着
    let result = JsFuture::from(promise)
        .await
        .map_err(|_| "文件读取Promise失败")?;

    // holders 在这里离开作用域,闭包安全释放,零泄漏

    // ArrayBuffer → Uint8Array → Vec<u8>
    let array_buffer = js_sys::ArrayBuffer::from(result);
    let uint8_array = js_sys::Uint8Array::new(&array_buffer);
    let mut bytes = vec![0; uint8_array.length() as usize];
    uint8_array.copy_to(&mut bytes);

    Ok(bytes)
}

这段代码三个踩坑点

1. 闭包生命周期是核心难点。 Promise::new() 传入的闭包是同步执行完就返回的,如果在里面创建的 Closure 不挪到外面,它的 drop 会直接把 WASM 侧的 event listener 干掉。Rc<RefCell<Option<Closure>>> 这个双保险模式是实测下来最稳的方案------最近的 commit 专门重构了这一段。

2. 两个闭包互相释放。 onload 执行时要释放 onerror,onerror 执行时也要释放 onload,保证只有一个回调能跑,不会互相干扰。

3. ArrayBuffer → Vec 的转换必须经过 Uint8Array。 不能直接从 ArrayBuffer 拿到原始字节,必须用它构造一个 Uint8Array 视图,然后 copy_to 到 Rust 的 Vec<u8>


三、跨边界传输:Leptos Server Function 自动序列化

你写的是一行函数调用,框架做的是 HTTP POST + 序列化

拿到 Vec<u8> 之后,怎么把它从 WASM 前端送到 Rust 后端?

在 Leptos 里,这靠 #[server] 宏。前端只需要构造一个请求结构体,调用函数,剩下的序列化/反序列化都由框架处理:

rust 复制代码
// 前端调用代码(app/src/utils/file_upload.rs)
let request = FileUploadRequest {
    file_name,
    file_data,    // Vec<u8> 二进制数据
    content_type,
};

let result = call_api(upload_file(request)).await;

框架自动把 FileUploadRequest 序列化,发一个 POST /api/upload_file,然后后端同一个函数体拿到的就是已经反序列化好的结构体。对开发者来说,就像在调一个本地函数,实际上跨越了 WASM→HTTP→Axum 三层边界

前后端校验:两道防线

进入 server function 后,第一件事是校验------不能信任任何来自客户端的数据:

rust 复制代码
// app/src/server/file_handlers.rs
#[server(name = UploadFile, prefix = "/api", endpoint = "/upload_file")]
pub async fn upload_file(request: FileUploadRequest) -> Result<FileUploadResponse, ServerFnError> {
    // 第一道防线:server function 层
    if file_name.is_empty() {
        return Err(ServerFnError::Args("文件名不能为空".to_string()));
    }
    if !content_type.starts_with("image/") {
        return Err(ServerFnError::Args("只允许上传图片文件".to_string()));
    }
    const MAX_FILE_SIZE: usize = 5 * 1024 * 1024;
    if file_data.len() > MAX_FILE_SIZE {
        return Err(ServerFnError::Args("文件大小不能超过5MB".to_string()));
    }
    // ... 继续进入领域层
}

注意这里前端 FileInput 组件也会做 MIME 类型和大小校验,但后端必须再做一次------前端的校验只是 UX 优化,防不了恶意请求。


四、领域层:FileCommandService 的分类存储

不同类型的文件走不同的 key 前缀

通过 server function 校验后,数据进入领域层的 FileCommandService。它不是简单的"把文件存起来",而是按用途分类,自动生成不同的 S3 key:

rust 复制代码
// backend/src/application/commands/shared/file_service.rs

// 头像 → avatars/{user_id}.{ext}
pub async fn upload_avatar(&self, user_id: &str, ...) -> Result<FileUploadResponse, String> {
    let key = format!("avatars/{}.{}", user_id, file_extension);
    self.upload_file_internal(key, file_name, data, content_type).await
}

// 文档 → documents/{document_id}/{filename}
pub async fn upload_document(&self, document_id: &str, filename: &str, ...) -> Result<FileUploadResponse, String> {
    let key = format!("documents/{}/{}", document_id, filename);
    self.upload_file_internal(key, filename, data, content_type).await
}

// 临时文件 → temp/{uuid}/{filename}
pub async fn upload_temp_file(&self, temp_id: &str, filename: &str, data: Vec<u8>) -> Result<FileUploadResponse, String> {
    let key = format!("temp/{}/{}", temp_id, filename);
    self.upload_file_internal(key, filename, data, content_type).await
}

头像上传目前用的就是 upload_temp_file,因为新用户可能还没分配 ID,先存到临时区,等用户创建完再迁移到 avatars/ 下。

FileValidationRules:按文件类型分档的校验策略

领域层的校验比 server function 层更细致,不同的存储分类对应不同的规则:

rust 复制代码
impl FileValidationRules {
    pub fn for_images() -> Self {
        Self {
            max_size: 5 * 1024 * 1024,  // 5MB
            allowed_mime_types: vec!["image/jpeg", "image/png", "image/gif", "image/webp"],
            allowed_extensions: vec!["jpg", "jpeg", "png", "gif", "webp"],
        }
    }

    pub fn for_documents() -> Self {
        Self {
            max_size: 10 * 1024 * 1024,  // 10MB
            allowed_mime_types: vec!["application/pdf", ...],
            allowed_extensions: vec!["pdf", "doc", "docx", ...],
        }
    }
}

图片 5MB,文档 10MB------这是实际业务中比较合理的阈值。


五、基础设施层:S3 兼容网关,不绑定云厂商

直接用 aws_sdk_s3,适配任何 S3 兼容存储

领域层通过 FileStorageGateway trait 跟存储解耦,实际实现是 RustFSGateway,底层直接用 AWS 官方的 aws_sdk_s3 crate:

rust 复制代码
// backend/src/infrastructure/gateways/rustfs_gateway.rs
async fn upload_file(&self, request: FileUploadRequest) -> Result<FileUploadResponse, String> {
    let mut put_object = self.client
        .put_object()
        .bucket(&request.bucket)       // "pico-crm"
        .key(&request.key)             // "temp/{uuid}/avatar.png"
        .body(ByteStream::from(request.data));

    if let Some(content_type) = &request.content_type {
        put_object = put_object.content_type(content_type);
    }

    match put_object.send().await {
        Ok(res) => Ok(FileUploadResponse {
            file_url: format!("{}/{}/{}", endpoint_url, request.bucket, request.key),
            file_size: request.data.len() as u64,
            etag: res.e_tag().map(|s| s.to_string()),
            // ...
        }),
        Err(e) => Err(format!("Failed to upload file: {}", e)),
    }
}

环境变量配置,无痛切换存储

网关初始化全靠环境变量,不硬编码任何云厂商信息:

rust 复制代码
RustFSConfig {
    region: env::var("RUSTFS_REGION"),
    access_key_id: env::var("RUSTFS_ACCESS_KEY_ID"),
    secret_access_key: env::var("RUSTFS_SECRET_ACCESS_KEY"),
    endpoint_url: env::var("RUSTFS_ENDPOINT_URL"),
}

这意味着你可以:

  • 开发环境对接 MinIOENDPOINT_URL=http://localhost:9000
  • 生产环境对接 AWS S3Cloudflare R2、** Garage**,甚至自建对象存储

只要实现了 S3 兼容 API,都能用,不用改一行代码。


六、完整数据流回顾

把整个链路串起来就是:

scss 复制代码
1. 用户拖拽/选择文件
   → FileInput 组件校验 MIME 类型和大小(前端第一道)

2. handle_files() 触发 spawn_local
   → read_file_as_bytes(&web_file) 用 FileReader + JsFuture 读取(WASM 最复杂的一步)

3. 拿到 Vec<u8>
   → 构造 FileUploadRequest,调 call_api(upload_file(request))

4. Leptos #[server] 序列化 + HTTP POST
   → server function 层校验:空值、类型、大小(后端第一道)

5. FileCommandService::upload_temp_file()
   → 领域校验 + 生成 key "temp/{uuid}/{filename}"(后端第二道)

6. RustFSGateway::upload_file()
   → aws_sdk_s3 PutObject → S3 落盘

7. Response 一路返回
   → file_url 写入 avatar_url signal → UI 响应式更新头像预览

四层防线:前端组件校验 → Server Function 层校验 → 领域层 FileValidationRules → S3 Gateway 本身也会报错。每层都有清晰的职责边界。


总结

在 Rust 全栈里做文件上传,最难的其实不是 S3 对接(aws_sdk_s3 用起来很舒服),也不是后端校验,而是 WASM 里那几十行 FileReader + JsFuture 的胶水代码 。理解清楚 Closure 在 WASM 边界的生命周期管理,后面的路就好走多了。

如果你也在搞 Rust 全栈,或者对这套方案有什么想法,欢迎在评论区交流。

相关推荐
濮水大叔1 小时前
告别 Django Admin!这个 NodeJS 全栈框架让你在 DTO 中直接配置 Table/Form 渲染
前端·typescript·node.js
JarvanMo1 小时前
Flutter 3.44 & Dart 3.12重磅发布!这些新特性太香了
前端
竹林8181 小时前
用Viem替换ethers.js:一次合约交互的"减负"实战,我总算把TypeScript类型搞明白了
前端·javascript
To_OC1 小时前
一个让我懵了半小时的时钟 Bug,注重前端三权分立落地
前端·代码规范
归故里1 小时前
harmony-next.skills 为 AI 而生!
前端·后端·github
threelab1 小时前
Three.js 3D 热力图效果 | 三维可视化 / AI 提示词
开发语言·前端·javascript·人工智能·3d·着色器
vivo互联网技术1 小时前
VAPD AgentKit:可组合 Agent 前端通用库实践
前端·ai·架构·agent
lichenyang4532 小时前
鸿蒙聊天 Demo 练习 02:AI 回复打字机输出与 ForEach 刷新问题
前端
Hello--_--World2 小时前
利用CDN进行首屏优化。能不能看CDN与本地服务器谁快用谁?
运维·服务器·前端·javascript·vite