Rust-导入导出
👉常见的库以及解决方法
Cargo.toml
使用的库
javascript
futures-util = "0.3" # 异步任务工具
csv = "1.1" # CSV文件处理
calamine = "0.21" # 解析Excel(.xlsx/.xls/.ods)
umya-spreadsheet = "1.1"
👉导出Excel
接下来我们开发一个路由接口/system/users/export
,用于导出Excel文件
🍎添加路由
src\modules\user\routes.rs
添加路由接口
javascript
// 新增导出
cfg.route("/system/users/export",
web::post().to(crate::modules::user::handlers::export_users));
🍎非流式返回
接下来我们先写一个导出格式的excel,这个时候我们直接返回
javascript
pub async fn export_users(pool: web::Data<MySqlPool>) -> HttpResponse {
let rows = sqlx::query_as::<_, User>(
"SELECT user_id, username, password, age, name, sex, address, state, phone, avatar, user_height, user_weight, disease
FROM sys_user
WHERE is_deleted = 0"
)
.fetch_all(pool.get_ref())
.await;
let users = match rows {
Ok(v) => v,
Err(e) => {
eprintln!("/system/USER/export query error: {:?}", e);
return HttpResponse::InternalServerError().json(crate::common::response::ApiResponse::<()>{
code: 500,
msg: "查询用户失败",
data: None
});
}
};
// CSV 表头(中文)
let mut csv = String::new();
csv.push_str("用户ID,用户名,姓名,年龄,性别,电话,地址,状态,头像,身高,体重,疾病\n");
for u in users {
let line = vec![
u.user_id.to_string(),
escape_csv(&u.username),
escape_csv(u.name.as_deref().unwrap_or("")),
escape_csv(u.age.as_deref().unwrap_or("")),
u.sex.unwrap_or(0).to_string(),
escape_csv(u.phone.as_deref().unwrap_or("")),
escape_csv(u.address.as_deref().unwrap_or("")),
u.state.unwrap_or(0).to_string(),
escape_csv(u.avatar.as_deref().unwrap_or("")),
escape_csv(u.user_height.as_deref().unwrap_or("")),
escape_csv(u.user_weight.as_deref().unwrap_or("")),
escape_csv(u.disease.as_deref().unwrap_or("")),
].join(",");
csv.push_str(&line);
csv.push('\n');
}
// UTF-8 BOM,Excel 识别中文更好
let mut bytes = Vec::from([0xEF, 0xBB, 0xBF]);
bytes.extend_from_slice(csv.as_bytes());
HttpResponse::Ok()
.content_type("text/csv; charset=utf-8")
.append_header(("Content-Disposition", "attachment; filename=\"users.csv\""))
.body(bytes)
}
这里我们直接返回的数据,非流式,但是大型数据无法使用,返回的数据格式如下,很明显不适用于我们现在的数据
javascript
用户ID,用户名,姓名,年龄,性别,电话,地址,状态,头像,身高,体重,疾病
44,666666,san,666666,1,187XXX77,666666,0,/uploads/images/1746683201317-nexusvue-feedback.png,,,18735797977
65,123456,嗯,,1,187357XXX977,111,0,/uploads/images/1748584154718-2.png,,,18735797977
67,admin,,20,0,admin,,0,/uploads/images/9ff7b427-fe1b-46c6-a7c9-20823aac07c0.png,,,
69,user_o_1,郝颖,34,2,13531503450,天津市正定县大厦737号,1,/avatar/default1.png,183cm,63kg,过敏
70,user_y_2,毛伟,75,1,13503536954,新疆维吾尔自治区桥西区大厦342号,1,/avatar/female1.jpg,171cm,99kg,健康
71,user_w_3,韩彬,28,1,13469265460,河南省井陉矿区园718号,1,/avatar/custom1.jpg,195cm,99kg,健康
72,user_h_4,高明,68,2,15298848187,吉林省裕华区山庄480号,1,/avatar/female1.jpg,195cm,69kg,健康
73,user_q_5,石欣,62,1,19619145883,青海省栾城区小区218号,1,/avatar/female2.jpg,173cm,99kg,健康
74,user_j_6,唐娜,22,2,15342003992,吉林省辛集市胡同498号,1,/avatar/female3.jpg,179cm,63kg,健康
75,user_q_7,林欣,56,2,15687021122,河南省辛集市坊301号,2,/avatar/female3.jpg,191cm,63kg,健康
🍎流返回Excel
接下来我们返回一个流,处理我们的Excel功能,返回类型改为分块传输(chunked 流
)
这里我们依然基本实现,然后优化
javascript
// 流式导出 CSV
pub async fn export_users(pool: web::Data<MySqlPool>) -> HttpResponse {
use actix_web::web::Bytes;
use futures_util::stream::{self, StreamExt};
// BOM + 表头
let head = {
let mut v = vec![0xEF, 0xBB, 0xBF];
v.extend_from_slice("用户ID,用户名,姓名,年龄,性别,电话,地址,状态,头像,身高,体重,疾病\n".as_bytes());
v
};
let head_stream = stream::once(async { Ok::<Bytes, actix_web::Error>(Bytes::from(head)) });
// 分页流:每次查询一批,转换为 Bytes 后产出
let pool = pool.get_ref().clone();
let rows_stream = stream::unfold(Some((pool, 0i32)), |state| async move {
let (pool, last_id) = state?;
let rows = match sqlx::query_as::<_, User>(
"SELECT user_id, username, password, age, name, sex, address, state, phone, avatar, user_height, user_weight, disease
FROM sys_user
WHERE is_deleted = 0 AND user_id > ?
ORDER BY user_id
LIMIT 1000"
)
.bind(last_id)
.fetch_all(&pool)
.await
{
Ok(v) => v,
Err(e) => {
eprintln!("export_users query error: {e:?}");
return Some((Err(actix_web::error::ErrorInternalServerError("查询用户失败")), None));
}
};
if rows.is_empty() {
return None;
}
let mut next_last = last_id;
let mut buf = String::with_capacity(rows.len() * 128);
for u in rows {
next_last = u.user_id.max(next_last);
let line = format!(
"{},{},{},{},{},{},{},{},{},{},{},{}\n",
u.user_id,
escape_csv(&u.username),
escape_csv(u.name.as_deref().unwrap_or("")),
escape_csv(u.age.as_deref().unwrap_or("")),
u.sex.unwrap_or(0),
escape_csv(u.phone.as_deref().unwrap_or("")),
escape_csv(u.address.as_deref().unwrap_or("")),
u.state.unwrap_or(0),
escape_csv(u.avatar.as_deref().unwrap_or("")),
escape_csv(u.user_height.as_deref().unwrap_or("")),
escape_csv(u.user_weight.as_deref().unwrap_or("")),
escape_csv(u.disease.as_deref().unwrap_or("")),
);
buf.push_str(&line);
}
Some((Ok(Bytes::from(buf)), Some((pool, next_last))))
});
HttpResponse::Ok()
.content_type("application/x-www-form-urlencoded; charset=utf-8")
.append_header(("Content-Disposition", "attachment; filename=\"users.csv\""))
.append_header(("Access-Control-Expose-Headers", "Content-Disposition, Content-Type"))
.streaming(head_stream.chain(rows_stream))
}
测试接口,ok 。
现在我们返回Excel 导出就没问题了
👉导出Excel模板接口system/users/exporttemplate
🍎添加路由
src\modules\user\routes.rs
添加路由接口
javascript
// src\modules\user\routes.rs
// 导出模板
cfg.route("/system/users/exporttemplate",
web::post().to(crate::modules::user::handlers::export_users_template));
🍎数据逻辑
javascript
// 流式导出用户模板
pub async fn export_users_template() -> HttpResponse {
use actix_web::web::Bytes;
use futures_util::stream::{self, StreamExt};
// BOM + 表头
let head = {
let mut v = vec![0xEF, 0xBB, 0xBF];
v.extend_from_slice("用户ID,用户名,姓名,年龄,性别,电话,地址,状态,头像,身高,体重,疾病\n".as_bytes());
v
};
let head_stream = stream::once(async { Ok::<Bytes, actix_web::Error>(Bytes::from(head)) });
// 空数据流,保持结构但不查询数据
let empty_data_stream = stream::once(async {
Ok::<Bytes, actix_web::Error>(Bytes::from("")) // No user data, just empty content
});
HttpResponse::Ok()
.content_type("application/x-www-form-urlencoded; charset=utf-8")
.append_header(("Content-Disposition", "attachment; filename=\"empty_users_template.csv\""))
.append_header(("Access-Control-Expose-Headers", "Content-Disposition, Content-Type"))
.streaming(head_stream.chain(empty_data_stream))
}
测试接口 ok
🍎优化接口为标准XLSX
文件

javascript
use umya_spreadsheet::*;
use std::io::Cursor;
pub async fn export_users_template() -> HttpResponse {
// 创建一个带默认 Sheet1 的工作簿
let mut book = new_file();
let sheet = book.get_sheet_by_name_mut("Sheet1").unwrap();
// 表头
sheet.get_cell_mut((1, 1)).set_value("用户名");
sheet.get_cell_mut((2, 1)).set_value("姓名");
sheet.get_cell_mut((3, 1)).set_value("年龄");
sheet.get_cell_mut((4, 1)).set_value("电话");
// 写到内存
let mut buffer = Cursor::new(Vec::new());
writer::xlsx::write_writer(&book, &mut buffer).unwrap();
let bytes = buffer.into_inner();
HttpResponse::Ok()
.append_header(("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.append_header(("Content-Disposition", "attachment; filename=\"users_template.xlsx\""))
.append_header(("Access-Control-Expose-Headers", "Content-Disposition, Content-Type"))
.body(bytes)
}
👉导入Excel数据接口
system/users/import
🍎添加路由
src\modules\user\routes.rs
添加路由接口
javascript
// src\modules\user\routes.rs
// 导入数据
cfg.route("/system/users/import",
web::post().to(crate::modules::user::handlers::import_users));
🍎数据逻辑
接下来,我们实现一个导入用户数据的接口,你需要处理上传的 CSV 文件,并解析其中的用户数据。然后,你可以将这些数据保存到数据库中。
添加依赖
javascript
calamine = "0.21" # 解析Excel(.xlsx/.xls/.ods)
这里我以自己最简单的数据为主,并且我们给一个uuid,确保文件唯一性
javascript
use actix_web::{post};
use actix_multipart::Multipart;
use csv;
use csv::ReaderBuilder;
use uuid::Uuid;
use std::fs::File;
use std::io::Write;
use calamine::{open_workbook_auto, Reader, RangeDeserializerBuilder};
#[derive(Debug, Deserialize)]
struct UserRow {
username: String,
name: String,
age: i32,
phone: String,
}
pub async fn import_users(
mut payload: Multipart,
db_pool: web::Data<Pool<MySql>>,
) -> impl Responder {
while let Some(item) = payload.next().await {
let mut field = item.unwrap();
let file_id = Uuid::new_v4().to_string();
let file_path = format!("/uploads/tmp/{}.xlsx", file_id);
let mut f = File::create(&file_path).unwrap();
while let Some(chunk) = field.next().await {
let data = chunk.unwrap();
f.write_all(&data).unwrap();
}
match open_workbook_auto(&file_path) {
Ok(mut workbook) => {
if let Some(Ok(range)) = workbook.worksheet_range("Sheet1") {
let mut iter = RangeDeserializerBuilder::new()
.has_headers(true)
.from_range::<_, UserRow>(&range) // 这里指定类型
.unwrap();
let mut count = 0;
while let Some(Ok(row)) = iter.next() {
// row 已经是 UserRow 类型
let hashed_pwd = hash("123456", DEFAULT_COST).unwrap();
sqlx::query!(
r#"
INSERT INTO sys_user (username, name, age, phone, password)
VALUES (?, ?, ?, ?, ?)
"#,
row.username,
row.name,
row.age,
row.phone,
hashed_pwd
)
.execute(db_pool.get_ref())
.await
.unwrap();
count += 1;
}
return format!("成功导入 {} 条用户数据", count);
} else {
return "未找到 Sheet1 或解析失败".to_string();
}
}
Err(e) => {
return format!("Excel 解析失败: {}", e);
}
}
}
"未收到文件".to_string()
}
🍎Windows系统和linux文件兼容
测试接口问题
测试一下我们的接口,发现报错如下
javascript
thread 'actix-rt|system:0|arbiter:0' panicked at src\modules\user\handlers.rs:503:46:
called `Result::unwrap()` on an `Err` value:
Os { code: 3, kind: NotFound, message: " 系统找不到指定的路径。" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
thread 'actix-rt|system:0|arbiter:1' panicked at src\modules\user\handlers.rs:503:46:
called `Result::unwrap()` on an `Err`
value: Os { code: 3, kind: NotFound, message: " 系统找不到指定的路径。" }
这是因为我使用Windows系统,我写法采用的linux模式,这里我们切换一种win和linux都适用的
javascript
// linux命令模式
let file_path = format!("/uploads/tmp/{}.xlsx", file_id);
更改以后,这里我们采用两种写法
方式一 自己创建目录
javascript
//自己创建目录
pub async fn import_users(
mut payload: Multipart,
db_pool: web::Data<Pool<MySql>>,
) -> impl Responder {
// 自己创建目录
fs::create_dir_all("uploads").unwrap();
while let Some(item) = payload.next().await {
let mut field = item.unwrap();
let file_id = Uuid::new_v4().to_string();
let file_path = format!("uploads/tmp/{}.xlsx", file_id);
let mut f = File::create(&file_path).unwrap();
while let Some(chunk) = field.next().await {
let data = chunk.unwrap();
f.write_all(&data).unwrap();
}
match open_workbook_auto(&file_path) {
Ok(mut workbook) => {
if let Some(Ok(range)) = workbook.worksheet_range("Sheet1") {
let mut iter = RangeDeserializerBuilder::new()
.has_headers(true)
.from_range::<_, UserRow>(&range) // 这里指定类型
.unwrap();
let mut count = 0;
while let Some(Ok(row)) = iter.next() {
// row 已经是 UserRow 类型
let hashed_pwd = hash("123456", DEFAULT_COST).unwrap();
sqlx::query!(
r#"
INSERT INTO sys_user (username, name, age, phone, password)
VALUES (?, ?, ?, ?, ?)
"#,
row.username,
row.name,
row.age,
row.phone,
hashed_pwd
)
.execute(db_pool.get_ref())
.await
.unwrap();
count += 1;
}
return format!("成功导入 {} 条用户数据", count);
} else {
return "未找到 Sheet1 或解析失败".to_string();
}
}
Err(e) => {
return format!("Excel 解析失败: {}", e);
}
}
}
"未收到文件".to_string()
}
方式二 借助临时目录
用 std::env::temp_dir()
获取跨平台的临时目录,然后拼接文件名
javascript
use std::path::PathBuf;
use uuid::Uuid;
let file_id = Uuid::new_v4().to_string();
let mut file_path: PathBuf = std::env::temp_dir();
file_path.push(format!("{}.xlsx", file_id));
let mut f = File::create(&file_path).unwrap();
🍎报错处理-未找到 Sheet1 或解析失败
过程之中我们遇到了一个报错,未找到 Sheet1 或解析失败
主要写法出现就是这部分
javascript
workbook.worksheet_range("Sheet1")
主要原因
我们的 **calamine**
是严格大小写匹配表名 的
如果你的 Excel 里的表名是 "Sheet2"
、"用户信息"
、"sheet1"
或其他名字,它就会返回 None
。
解决方法 → 自动获取第一个 Sheet 名称
javascript
if let Some(Ok(range)) = workbook.worksheet_range_at(0) {
// 0 表示第一个 sheet
}
测试接口 ,上传成功,但是我们还没存入数据库之中
🍎Xlsx
和Xls
格式报错
javascript
Excel 解析失败:
Xlsx error: Zip error: invalid Zip archive: Could not find central directory end
这是因为文件不是标准 .xlsx
,我们将导出模板接口和导入都更改为标准的xlsx格式
现在的导出是 CSV ,导入是用 calamine
解析 Excel ,这两个格式不一致,
所以即使扩展名写成 .xlsx
,导出文件在 WPS 或 Excel 打开没问题,但上传解析会直接报错(因为实际上是 CSV,不是 ZIP 的 .xlsx
)
想**完全兼容 WPS / Excel 的 ****.xlsx**
,导出时就必须真的生成 .xlsx
文件,而不是伪装成 .xlsx
的 CSV。
在 Rust 里,可以用 umya-spreadsheet
这个 crate 来生成标准的 Excel 2007+ 格式文件。
完善一下,测试接口。ok
javascript
#[derive(Debug, Deserialize)]
struct UserRow {
#[serde(rename = "用户名")]
username: String,
#[serde(rename = "姓名")]
name: String,
#[serde(rename = "年龄")]
age: i32,
#[serde(rename = "电话")]
phone: String,
}
pub async fn import_users(
mut payload: Multipart,
db_pool: web::Data<Pool<MySql>>,
) -> impl Responder {
fs::create_dir_all("uploads/tmp").unwrap();
println!("收到上传请求");
// 打印当前连接的数据库(调试用)
match sqlx::query!("SELECT DATABASE() AS db")
.fetch_one(db_pool.get_ref())
.await
{
Ok(db) => println!("当前连接数据库: {:?}", db.db),
Err(e) => println!("无法获取数据库信息: {}", e),
}
while let Some(item) = payload.next().await {
let mut field = match item {
Ok(f) => f,
Err(_) => return "文件读取失败".to_string(),
};
let file_id = Uuid::new_v4().to_string();
let file_path = format!("uploads/tmp/{}.xlsx", file_id);
let mut f = File::create(&file_path).unwrap();
while let Some(chunk) = field.next().await {
let data = match chunk {
Ok(d) => d,
Err(_) => return "文件写入失败".to_string(),
};
f.write_all(&data).unwrap();
}
// 解析 Excel
match open_workbook_auto(&file_path) {
Ok(mut workbook) => {
if let Some(Ok(range)) = workbook.worksheet_range_at(0) {
let mut iter = RangeDeserializerBuilder::new()
.has_headers(true)
.from_range::<_, UserRow>(&range)
.unwrap();
let mut count = 0;
while let Some(Ok(row)) = iter.next() {
println!("读取到行数据: {:?}", row);
let hashed_pwd = match hash("123456", DEFAULT_COST) {
Ok(p) => p,
Err(e) => {
println!("密码加密失败: {}", e);
continue;
}
};
// 增加 is_deleted 字段
match sqlx::query!(
r#"
INSERT INTO sys_user (username, name, age, phone, password, is_deleted)
VALUES (?, ?, ?, ?, ?, '0')
"#,
row.username,
row.name,
row.age,
row.phone,
hashed_pwd
)
.execute(db_pool.get_ref())
.await
{
Ok(result) => {
println!("插入成功,影响行数: {}", result.rows_affected());
if result.rows_affected() > 0 {
count += 1;
}
}
Err(e) => {
println!("插入失败: {}", e);
}
}
}
return format!("成功导入 {} 条用户数据", count);
} else {
return "未找到工作表或解析失败".to_string();
}
}
Err(e) => {
return format!("Excel 解析失败: {}", e);
}
}
}
"未收到文件".to_string()
}
测试接口,功能ok
🍎优化提示(可用)
接下来优化一下我们接口给我们返回的提示
现在的提示
javascript
成功导入 1 条用户数据
改成提示
javascript
{
"code": 200,
"msg": "成功导入 1 条用户数据"
}
优化以后的写法
javascript
// 版本2
#[derive(Serialize)]
pub struct BasicImportResponse {
pub code: i32,
pub msg: String,
}
impl BasicImportResponse {
pub fn new(code: i32, msg: impl Into<String>) -> Self {
Self {
code,
msg: msg.into(),
}
}
}
#[derive(Debug, Deserialize)]
struct UserRow {
#[serde(rename = "用户名")]
username: String,
#[serde(rename = "姓名")]
name: String,
#[serde(rename = "年龄")]
age: i32,
#[serde(rename = "电话")]
phone: String,
}
pub async fn import_users(
mut payload: Multipart,
db_pool: web::Data<Pool<MySql>>,
) -> impl Responder {
fs::create_dir_all("uploads/tmp").unwrap();
println!("收到上传请求");
while let Some(item) = payload.next().await {
let mut field = match item {
Ok(f) => f,
Err(_) => {
return web::Json(BasicImportResponse::new(400, "文件读取失败"));
}
};
let file_id = Uuid::new_v4().to_string();
let file_path = format!("uploads/tmp/{}.xlsx", file_id);
let mut f = File::create(&file_path).unwrap();
while let Some(chunk) = field.next().await {
let data = match chunk {
Ok(d) => d,
Err(_) => {
return web::Json(BasicImportResponse::new(400, "文件写入失败"));
}
};
f.write_all(&data).unwrap();
}
match open_workbook_auto(&file_path) {
Ok(mut workbook) => {
if let Some(Ok(range)) = workbook.worksheet_range_at(0) {
let mut iter = RangeDeserializerBuilder::new()
.has_headers(true)
.from_range::<_, UserRow>(&range)
.unwrap();
let mut count = 0;
let mut duplicated = 0;
while let Some(Ok(row)) = iter.next() {
let hashed_pwd = match hash("123456", DEFAULT_COST) {
Ok(p) => p,
Err(_) => continue,
};
match sqlx::query!(
r#"
INSERT INTO sys_user (username, name, age, phone, password, is_deleted)
VALUES (?, ?, ?, ?, ?, '0')
"#,
row.username,
row.name,
row.age,
row.phone,
hashed_pwd
)
.execute(db_pool.get_ref())
.await
{
Ok(result) => {
if result.rows_affected() > 0 {
count += 1;
}
}
Err(_) => {
duplicated += 1;
}
}
}
let msg = if duplicated > 0 {
format!("成功导入 {} 条用户数据,{} 条已存在", count, duplicated)
} else {
format!("成功导入 {} 条用户数据", count)
};
return web::Json(BasicImportResponse::new(200, msg));
} else {
return web::Json(BasicImportResponse::new(400, "未找到工作表或解析失败"));
}
}
Err(e) => {
return web::Json(BasicImportResponse::new(400, format!("Excel 解析失败: {}", e)));
}
}
}
web::Json(BasicImportResponse::new(400, "未收到文件"))
}
然后我们按照心意自己完善一下即可