尽管这很可悲,但有时作为一个开发者,你会被要求为客户实现Excel导出。为什么有人会需要或者使用这样的东西而不是CSV,我不明白,但在这个领域工作了10多年后,这种事情就突然出现了。
幸运的是,在Rust生态系统中有一个xlsxwriter板块,它提供了与广泛使用的libxlsxwriterC库的绑定关系。
在这篇文章中,我们将看看如何从Rust网络服务中创建自定义Excel报告。我们将使用一些自定义的数据类型、格式化,而且即使是大文件,它的内存消耗仍然是快速和相对精简的。
让我们开始吧!
实施
首先,让我们从建立一个基本的网络服务器开始,它有一个指向我们的excel导出的路由。
让我们从依赖性开始。
[dependencies]
tokio = { version = "0.2.21", features = ["macros", "rt-threaded", "blocking", "time"] }
warp = "0.2.3"
thiserror = "1.0.20"
chrono = { version = "0.4.13", features = ["serde"] }
xlsxwriter = "0.3.1"
uuid = { version = "0.8", features = ["v4"] }
lazy_static = "=1.4.0"
由于我们正在建立一个基于tokio的Warp网络应用程序,前几个依赖项并不令人惊讶。
我们还添加了chrono ,以展示如何在Excel中处理日期,我们将使用uuid 来创建一些随机的ID。
下一步是创建一个基本的、可运行的网络服务器,它有一个报告端点,还不做任何事情。
#[tokio::main]
async fn main() {
let report_route = warp::path("report")
.and(warp::get())
.and_then(report_handler);
println!("Server started at localhost:8080");
warp::serve(report_route).run(([0, 0, 0, 0], 8080)).await;
}
async fn report_handler() -> Result<impl Reply> {
Ok("report endpoint")
}
很好。下一步是定义一些我们想输出到excel的测试数据。在实践中,我们会从数据库或其他网络服务中获取这些数据,但在这里我们只是生成一些随机数据。
为了做到这一点,我们将定义Thing 数据结构,并为其生成一些随机数据。
lazy_static! {
static ref THINGS: Vec<Thing> = create_things();
}
#[derive(Clone, Debug)]
pub struct Thing {
pub id: String,
pub start_date: DateTime<Utc>,
pub end_date: DateTime<Utc>,
pub project: String,
pub name: String,
pub text: String,
}
fn create_things() -> Vec<Thing> {
let mut result: Vec<Thing> = vec![];
for _ in 0..1000 {
result.push(Thing {
id: random_string(),
start_date: Utc::now(),
end_date: Utc::now(),
project: random_string(),
name: random_string(),
text: random_string(),
});
}
result
}
fn random_string() -> String {
Uuid::new_v4().to_string()
}
基本上,每个Thing ,都有一些字符串字段和一些日期字段。日期字段我们设置为now() ,因为我们只对日期的格式化感兴趣。其余的字段则用UUID来填充。另外,我们使用lazy_static ,使这些初始化的数据在全球范围内可用。这样,我们只需要在启动时创建一次。
解决了这个问题后,我们就可以正确地实现我们的report_handler 。
async fn report_handler() -> Result<impl Reply> {
let now = Instant::now();
let result = tokio::task::spawn_blocking(move || excel::create_xlsx(THINGS.to_vec()))
.await
.expect("can create result");
println!("report took: {:?}", now.elapsed());
Ok(result)
}
由于我们也对创建一个巨大的Excel文件所需的时间感兴趣,所以我们要记下生成的时间。实际的Excel生成将在本篇文章的下一部分的excel::create_xlsx 函数中实现。
我们使用 Tokio 的spawn_blocking 函数,该函数只有在使用blocking 功能时才可用,在 Tokio 的阻塞线程池上生成 Excel。这在异步代码库中是很有用的,当你有一个阻塞的工作负载时(例如,创建一个巨大的XLSX文件;))。
如果你不在blocking 线程池上生成这个,异步运行时的调度器线程可能会被阻塞,这将拖慢整个应用程序。
接下来让我们看看如何实际创建XLSX文件。
首先,在excel.rs 模块中,我们来创建create_xlsx 函数。
use xlsxwriter::{DateTime as XLSDateTime, Format, Workbook, Worksheet};
const FONT_SIZE: f64 = 12.0;
pub fn create_xlsx(values: Vec<Thing>) -> Vec<u8> {
let uuid = Uuid::new_v4().to_string();
let workbook = Workbook::new(&uuid);
let mut sheet = workbook.add_worksheet(None).expect("can add sheet");
...
第一步是创建一个新的工作簿,并在该工作簿中添加一个新的工作表。在这个例子中,我们将只使用一个工作表,但如果你想,你可以添加更多的工作表。
我们创建一个uuid作为工作簿的名称,因为这是它将被保存的文件名,我们希望它是唯一的,所以它不会被同时调用的文件覆盖。
接下来,我们将处理excel中width 的问题。问题是,我们可以在我们想要的行和列中写出我们的值,但是如果文本,比如说,比单元格的默认宽度长,它就不好看了,用户将不得不加大尺寸来阅读内容。
最理想的情况是,我们想让单元格足够大,让我们放进去的整个文本都能看到,直到某一点为止。然而,为了做到这一点,我们需要跟踪每一列中的最大值长度,这样我们就可以事后调整这些单元格的宽度。你可能会问自己,为什么API中没有一个 "自动宽度 "的功能。这是因为这只是Excel的一个运行时间功能,不能写入文件本身。所以我们需要手工操作。
为此,我们将使用一个叫做width_map 的概念。
let mut width_map: HashMap<u16, usize> = HashMap::new();
这只是一个从列号到包含值的字符长度宽度的映射。在最后,我们将用宽度乘以1.2来实际设置宽度。这是因为根据我们所使用的字体和字号,要知道准确的像素宽度并非易事。这可能看起来很笨拙,但效果很好,实际上这也是libxlsxwriter作者推荐的,也是一个简单的变通方法。一个更花哨的版本是将每个字符映射到它的精确像素宽度,并通过这种方式计算出精确的宽度。
接下来,我们要创建标题行。
create_headers(&mut sheet, &mut width_map);
...它看起来像这样。
fn create_headers(sheet: &mut Worksheet, mut width_map: &mut HashMap<u16, usize>) {
let _ = sheet.write_string(0, 0, "Id", None);
let _ = sheet.write_string(0, 1, "StartDate", None);
let _ = sheet.write_string(0, 2, "EndDate", None);
let _ = sheet.write_string(0, 3, "Project", None);
let _ = sheet.write_string(0, 4, "Name", None);
let _ = sheet.write_string(0, 5, "Text", None);
set_new_max_width(0, "Id".len(), &mut width_map);
set_new_max_width(1, "StartDate".len(), &mut width_map);
set_new_max_width(2, "EndDate".len(), &mut width_map);
set_new_max_width(3, "Project".len(), &mut width_map);
set_new_max_width(4, "Name".len(), &mut width_map);
set_new_max_width(5, "Text".len(), &mut width_map);
}
基本上,这只是模板,在第一行(第0行)为我们的每个数据点增加一列,并加上标题。
在这里,我们也使用了set_new_max_width 函数和我们的width_map 。
fn set_new_max_width(col: u16, new: usize, width_map: &mut HashMap<u16, usize>) {
match width_map.get(&col) {
Some(max) => {
if new > *max {
width_map.insert(col, new);
}
}
None => {
width_map.insert(col, new);
}
};
}
正如你所看到的,对于每一列,我们去更新最大宽度。在这种情况下,我们只是取了我们硬编码的标题值的字符串长度。但我们稍后将对我们生成的随机数据使用同样的技术。
不过在这之前,我们先在create_xlsx 函数中定义一些格式化规则。
let fmt = workbook
.add_format()
.set_text_wrap()
.set_font_size(FONT_SIZE);
let date_fmt = workbook
.add_format()
.set_num_format("dd/mm/yyyy hh:mm:ss AM/PM")
.set_font_size(FONT_SIZE);
这两种格式只是针对基本文本和日期的。一次性定义它们是很有用的,这样我们就不必为每一行/每一列分配一个。
解决了这个问题,我们可以继续为我们的每一行添加一个Things 。
for (i, v) in values.iter().enumerate() {
add_row(i as u32, &v, &mut sheet, &date_fmt, &mut width_map);
}
fn add_row(
row: u32,
thing: &Thing,
sheet: &mut Worksheet,
date_fmt: &Format,
width_map: &mut HashMap<u16, usize>,
) {
add_string_column(row, 0, &thing.id, sheet, width_map);
add_date_column(row, 1, &thing.start_date, sheet, width_map, date_fmt);
add_date_column(row, 2, &thing.end_date, sheet, width_map, date_fmt);
add_string_column(row, 3, &thing.project, sheet, width_map);
add_string_column(row, 4, &thing.name, sheet, width_map);
add_string_column(row, 5, &thing.text, sheet, width_map);
let _ = sheet.set_row(row, FONT_SIZE, None);
}
add_row 函数简单地浏览了我们想要显示的所有字段,并调用辅助函数来设置字符串和日期字段。然后,在最后,我们将该行的高度设置为FONT_SIZE 属性,以确保我们不会在垂直方向上切断文本。
接下来让我们看一下辅助函数。
fn add_string_column(
row: u32,
column: u16,
data: &str,
sheet: &mut Worksheet,
mut width_map: &mut HashMap<u16, usize>,
) {
let _ = sheet.write_string(row + 1, column, data, None);
set_new_max_width(column, data.len(), &mut width_map);
}
fn add_date_column(
row: u32,
column: u16,
date: &DateTime<Utc>,
sheet: &mut Worksheet,
mut width_map: &mut HashMap<u16, usize>,
date_fmt: &Format,
) {
let d = XLSDateTime::new(
date.year() as i16,
date.month() as i8,
date.day() as i8,
date.hour() as i8,
date.minute() as i8,
date.second() as f64,
);
let _ = sheet.write_datetime(row + 1, column, &d, Some(date_fmt));
set_new_max_width(column, 26, &mut width_map);
}
字符串的情况相当简单。从本质上讲,我们只是使用write_string ,用字符串的格式来设置字符串,并用数据的字符串宽度来更新width_map 。设置一个日期就比较麻烦了,因为我们需要先从我们的chrono::DateTime ,生成一个libxlsxwriter::DateTime 。
但是一旦完成,剩下的就完全一样了,除了我们手动设置宽度为26 ,这是我们使用的日期格式的字符串宽度,我们使用write_datetime 。
设置好行后,最后一步是将每一列的宽度实际设置为我们计算的最大值,生成工作表并将其返回给调用者。
width_map.iter().for_each(|(k, v)| {
let _ = sheet.set_column(*k as u16, *k as u16, *v as f64 * 1.2, Some(&fmt));
});
workbook.close().expect("workbook can be closed");
let result = fs::read(&uuid).expect("can read file");
remove_file(&uuid).expect("can delete file");
result
我们遍历width_map ,对于其中的每一列,将该列的宽度设置为最大宽度的1.2倍,这应该使我们能够舒适地阅读工作表中的所有内容。
然后,我们使用workbook.close() 写入excel文件并读入,这样我们就可以把它返回给调用者。似乎没有办法简单地从libxlsxwriter 返回字节,至少我没有找到办法,但这没关系。
将文件读入内存后,我们将其删除,这样就不会填满磁盘,并将其返回给处理程序。
现在我们可以用cargo run ,并通过调用curl http://localhost:8080/report > rep.xlsx 来测试我们的应用程序。
用Excel或LibreOffice(或任何你喜欢的工具)打开rep.xlsx ,你应该看到一个格式良好的Excel文件,宽度和高度根据内容和字体大小设置。日期也应该是实际的Excel日期。
report took: 76.507859ms
它是有效的!
完整的示例代码可以在这里找到
结论
虽然支持xlsx可能不是很多开发者的梦想,但它却时常出现,幸运的是,由于Rust出色的C语言互操作性和伟大的libxlsxwriter 库,我们能够很好地处理它。
在我的测试中,上述方法在执行时间和内存占用方面的性能远远好于使用Apache POI的流式实现,但你的里程可能会有所不同,这取决于你的用例和设置。这也不是一个公平的比较,因为POI有大量的功能,远远超过libxlsxwriter,而且不一定是为了性能而优化,而是为了完整性。