10.2 实战:使用 Tauri 和 DuckDB 构建本地数据分析工具

引言:当桌面应用遇上嵌入式 OLAP

在上一章,我们学习了如何使用 Tauri 构建轻量级的桌面应用。Tauri 的一个巨大优势在于,它的后端是强大的 Rust,这意味着我们可以直接在桌面应用中执行高性能的数据处理和系统级操作,而无需依赖云服务。

在数据分析领域,我们经常需要处理和分析本地的 CSV 或 Parquet 文件。传统的做法是启动一个 Jupyter Notebook,使用 pandasPolars 来加载和分析。但如果我们能构建一个原生的、图形化的桌面应用来完成这项工作呢?用户只需拖放文件,就可以通过 SQL 或图形界面进行交互式分析。

这正是本章我们要挑战的项目:一个本地数据分析工具。这个工具将:

  • 使用 Tauri 构建图形用户界面。
  • 允许用户通过文件对话框选择并加载一个 CSV 文件。
  • 在 Rust 后端,使用 DuckDB 这个强大的嵌入式分析数据库来对 CSV 数据进行高性能的 SQL 查询。
  • 将查询结果返回给前端并以表格形式展示。

为什么是 DuckDB

DuckDB 是一个专门为分析查询 (OLAP) 设计的嵌入式数据库。可以把它想象成“数据分析领域的 SQLite”。

DuckDB 的核心特性:

  • 嵌入式: 它是一个库,而不是一个独立的服务器。它直接在你的应用程序进程中运行,读写本地文件,无需复杂的设置。
  • 为分析而生: 它在内部采用列式存储和向量化执行引擎,对聚合、连接等分析型 SQL 查询进行了深度优化,速度极快。
  • 强大的 SQL 功能: 支持窗口函数、CTE 等高级 SQL 特性。
  • 可以直接查询文件: DuckDB 最神奇的功能之一是它可以直接对 CSVParquet 文件执行 SQL 查询,而无需先将数据导入数据库。例如 SELECT * FROM 'my_file.csv';
  • 易于集成: 提供了 C, C++, Python, Java, Rust 等多种语言的绑定。

对于我们的桌面应用来说,DuckDB 是一个完美的“后端大脑”。它轻量、快速、无需安装,并且能直接处理用户的文件。

项目架构

  1. 前端 (Tauri - WebView):
    • 提供一个简单的 UI,包含一个“加载文件”按钮和一个用于显示结果的表格。
    • 当用户选择文件后,通过 invoke 将文件路径发送给 Rust 后端。
    • 提供一个输入框,让用户可以输入 SQL 查询语句,并通过 invoke 发送。
  2. 后端 (Tauri - Rust):
    • Tauri 命令:
      • load_csv(path: String): 接收文件路径,在 DuckDB 中将其注册为一个表。
      • run_sql(sql: String): 接收 SQL 查询,在 DuckDB 中执行它,并将结果序列化为 JSON 返回给前端。
    • 状态管理: 我们需要在 Tauri 的状态中持有一个 DuckDB 的连接实例,以便在不同的 invoke 调用之间共享它。

环境准备

1. 创建 Tauri 项目

cargo create-tauri-app duckdb-analyzer --template vanilla
cd duckdb-analyzer

2. 添加 Rust 依赖

src-tauri/Cargo.toml:

[dependencies]
tauri = { version = "1.4", features = ["shell-open", "dialog"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
duckdb = "0.8" # DuckDB 的 Rust 绑定
  • tauridialog feature 让我们能使用原生的文件选择对话框。
  • duckdb crate 提供了与 DuckDB 交互所需的所有功能。

后端实现 (src-tauri/src/main.rs)

1. 设置状态

我们需要一个线程安全的方式来持有 DuckDB 连接。duckdb::Connection 本身不是 Sync,但我们可以将它包裹在 std::sync::Mutex 中。

use std::sync::Mutex;
use tauri::State;
use duckdb::Connection;

// 定义我们的应用状态
pub struct AppState {
    // 使用 Mutex 来保证对 DuckDB 连接的线程安全访问
    db_conn: Mutex<Connection>,
}

fn main() {
    // 在内存中打开一个 DuckDB 实例
    let conn = Connection::open_in_memory().expect("Failed to open DuckDB in-memory");
    let state = AppState {
        db_conn: Mutex::new(conn),
    };

    tauri::Builder::default()
        .manage(state) // 使用 .manage() 来让 Tauri 管理我们的状态
        .invoke_handler(tauri::generate_handler![load_csv, run_sql])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
  • Connection::open_in_memory(): 创建一个完全在内存中运行的 DuckDB 实例,它不会在磁盘上留下任何文件。
  • .manage(state): 这是 Tauri 推荐的状态管理方式。它将我们的状态实例放入一个由 Tauri 管理的类型映射中。
  • State<T> 提取器: 稍后,我们的命令函数可以通过 tauri::State<AppState> 参数来获取对这个状态的访问权限。

2. 实现 load_csv 命令

这个命令会打开一个文件对话框,让用户选择一个 CSV 文件,然后告诉 DuckDB 将这个文件注册为一个名为 data 的表。

use tauri::{AppHandle, command, State};
use tauri::api::dialog::blocking::FileDialogBuilder;

#[command]
fn load_csv(app: AppHandle, state: State<AppState>) -> Result<(), String> {
    // 打开文件选择对话框
    let file_path = FileDialogBuilder::new()
        .set_parent(&app.get_window("main").unwrap())
        .add_filter("CSV", &["csv"])
        .pick_file();

    if let Some(path) = file_path {
        let path_str = path.to_str().unwrap().to_string();
        println!("Loading CSV from: {}", path_str);

        // 获取数据库连接
        let conn = state.db_conn.lock().unwrap();

        // DuckDB 的魔法:直接从 CSV 文件创建或替换一个表
        // read_csv_auto 会自动推断 schema
        conn.execute(
            &format!("CREATE OR REPLACE TABLE data AS SELECT * FROM read_csv_auto('{}');", path_str),
            [],
        ).map_err(|e| e.to_string())?;

        println!("CSV loaded successfully into table 'data'");
        Ok(())
    } else {
        Err("No file selected".to_string())
    }
}
  • FileDialogBuilder: Tauri 的 API,用于打开原生的文件对话框。
  • CREATE OR REPLACE TABLE ... FROM read_csv_auto(...): 这是 DuckDB 的强大功能。这一句 SQL 就完成了加载、解析、类型推断和建表的所有工作。

3. 实现 run_sql 命令

这个命令接收一个 SQL 字符串,在 DuckDB 中执行它,然后将结果返回给前端。

由于查询结果的行和列是动态的,将其转换为一个强类型的 Rust struct 很困难。最好的方法是将其序列化为一个通用的 JSON 格式,例如 Vec<HashMap<String, Value>>

use serde_json::{Value, map::Map};

// DuckDB 查询结果中的单行
// 我们将其转换为一个 JSON 对象 (HashMap)
fn row_to_json(row: &duckdb::Row) -> Result<Map<String, Value>, duckdb::Error> {
    let mut map = Map::new();
    for i in 0..row.len() {
        let col_name = row.column_name(i)?;
        // duckdb::Value 可以被转换为 serde_json::Value
        let val: duckdb::Value = row.get(i)?;
        map.insert(col_name.to_string(), val.into());
    }
    Ok(map)
}

#[command]
fn run_sql(sql: String, state: State<AppState>) -> Result<Vec<Map<String, Value>>, String> {
    println!("Running SQL: {}", sql);
    let conn = state.db_conn.lock().unwrap();

    // 准备一个查询语句
    let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;

    // 执行查询并迭代结果行
    let result_iter = stmt.query_map([], row_to_json).map_err(|e| e.to_string())?;

    // 收集所有行
    let mut results = Vec::new();
    for result in result_iter {
        results.push(result.map_err(|e| e.to_string())?);
    }
    
    Ok(results)
}
  • conn.prepare(): 准备一个 SQL 语句以供执行。
  • stmt.query_map(...): 执行查询,并为结果集中的每一行应用一个转换函数(我们的 row_to_json)。这是一种高效的、逐行处理结果的方式。
  • duckdb::Value -> serde_json::Value: duckdb crate 很好地与 serde_json 集成,duckdb::Value 可以被轻易地转换为 serde_json::Value,这使得构建 JSON 响应非常方便。

前端实现 (src/main.jsindex.html)

前端需要提供一个 UI 来调用我们刚刚创建的两个 Rust 命令。

index.html

<div class="container">
    <div class="controls">
        <button id="load-csv-btn">Load CSV File</button>
        <textarea id="sql-input" placeholder="SELECT * FROM data LIMIT 10;"></textarea>
        <button id="run-sql-btn">Run SQL</button>
    </div>
    <div id="result-container">
        <!-- 结果表格将在这里渲染 -->
    </div>
</div>

main.js

import { invoke } from '@tauri-apps/api/tauri';

let loadCsvBtn;
let runSqlBtn;
let sqlInput;
let resultContainer;

// 将 JSON 结果渲染成一个 HTML 表格
function renderTable(data) {
    if (!data || data.length === 0) {
        resultContainer.innerHTML = "<p>No results</p>";
        return;
    }

    const headers = Object.keys(data[0]);
    const headerHtml = `<thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead>`;

    const bodyHtml = `<tbody>${data.map(row => {
        const cells = headers.map(h => `<td>${row[h]}</td>`);
        return `<tr>${cells.join('')}</tr>`;
    }).join('')}</tbody>`;

    resultContainer.innerHTML = `<table>${headerHtml}${bodyHtml}</table>`;
}

window.addEventListener('DOMContentLoaded', () => {
    loadCsvBtn = document.querySelector('#load-csv-btn');
    runSqlBtn = document.querySelector('#run-sql-btn');
    sqlInput = document.querySelector('#sql-input');
    resultContainer = document.querySelector('#result-container');

    loadCsvBtn.addEventListener('click', async () => {
        try {
            await invoke('load_csv');
            alert('CSV loaded successfully! You can now query the "data" table.');
        } catch (error) {
            alert(`Failed to load CSV: ${error}`);
        }
    });

    runSqlBtn.addEventListener('click', async () => {
        const sql = sqlInput.value;
        if (!sql) {
            alert('SQL query cannot be empty.');
            return;
        }

        try {
            const result = await invoke('run_sql', { sql });
            renderTable(result);
        } catch (error) {
            alert(`Failed to run SQL: ${error}`);
        }
    });
});

运行应用

  1. 准备一个 CSV 文件,例如 stocks.csv
  2. 运行 cargo tauri dev
  3. 点击 “Load CSV File” 按钮,并选择你的 stocks.csv 文件。
  4. 在 SQL 输入框中,输入一个查询,例如 SELECT Symbol, AVG(High) as avg_high FROM data GROUP BY Symbol ORDER BY avg_high DESC LIMIT 10;
  5. 点击 “Run SQL”。
  6. 稍等片刻,你应该就能在界面上看到由 DuckDB 计算、由 Rust 后端返回、由 JavaScript 前端渲染出的查询结果表格!

总结

通过这个实战项目,我们构建了一个功能强大且极具实用价值的桌面应用,并在此过程中整合了多项关键技术。

  1. Tauri 作为应用框架: 我们利用 Tauri 创建了跨平台的原生窗口,并使用其 dialog API 访问了原生文件系统。Tauri 的 invoke 机制成为了前后端通信的安全桥梁。
  2. Web 技术作为 UI: 我们用简单的 HTML/CSS/JS 快速构建了功能性的用户界面,而无需学习复杂的原生 GUI 框架。
  3. DuckDB 作为后端引擎: 我们将一个高性能的嵌入式分析数据库无缝地集成到了我们的 Rust 后端。DuckDB 强大的 read_csv_auto 和 SQL 执行能力,为我们的应用提供了核心的数据处理动力。
  4. Rust 作为胶水和核心逻辑: Rust 在这个项目中扮演了至关重要的角色。它不仅是 Tauri 的后端语言,更是连接前端 UI、操作系统(通过文件对话框)和 DuckDB 数据引擎的“胶水”。我们用 Rust 编写了安全、类型化的命令,并处理了复杂的数据转换(从 duckdb::Rowserde_json::Value)。
  5. 状态管理: 我们学习了如何使用 tauri::StateMutex 来安全地在不同的命令调用之间共享状态(如 duckdb::Connection)。

这个项目完美地体现了 Tauri + Rust 的开发模式的优势:你可以利用 Web 生态快速构建漂亮的用户界面,同时又能在后端利用 Rust 生态中任何一个强大的库(无论是 sqlx, duckdb 还是 reqwest)来执行高性能的、系统级的任务。

思考题

  1. 在我们的 AppState 中,我们使用了 std::sync::Mutex 来包装 duckdb::Connection。为什么我们不使用 tokio::sync::Mutex?在 Tauri 的命令执行上下文中,这两种 Mutex 有什么区别?
  2. stmt.query_map() 是一个流式 API。我们的 run_sql 实现中,使用了 collect() 将所有结果收集到一个 Vec 中再返回。如果查询结果非常大(例如一百万行),这种实现会有什么问题?你会如何修改前端和后端,以实现结果的流式展示?
  3. DuckDB 是一个嵌入式数据库。这意味着所有的计算都在 Tauri 的主 Rust 进程中发生。这对于一个桌面应用来说通常是好事。但如果一个 SQL 查询非常耗时,它会阻塞什么?我们应该如何改进以避免 UI 卡顿?(提示:std::thread::spawntokio::task::spawn_blocking)。
  4. 我们的应用目前只支持 CSV。如果要同时支持 Parquet 文件,你需要对 Rust 后端和前端做哪些修改?(提示:DuckDB 可以直接查询 Parquet 文件)。
  5. 除了 DuckDB,你还能想到哪些可以被嵌入到 Tauri 应用中,用于本地数据处理的 Rust 库?(例如 Polars)。请比较一下使用 PolarsDuckDB 来实现这个应用的异同。

实践练习

  1. 添加错误处理和反馈:
    • 在前端,当 invoke 调用 reject 时,在 UI 上显示一个友好的错误提示框,而不是 alert()
    • run_sql 命令中,更精细地处理 duckdb::Error,并将有用的错误信息返回给前端。
  2. 实现结果分页:
    • 在前端添加“上一页”和“下一页”按钮。
    • 修改 run_sql 命令,使其能够接收分页参数(page, page_size)。
    • run_sql 的 SQL 查询中使用 LIMITOFFSET 来实现分页。
    • run_sql 除了返回当页数据外,还返回总记录数,以便前端计算总页数。
  3. 保存和加载查询:
    • 在 UI 上添加“保存查询”按钮。
    • 创建一个新的 Tauri 命令 save_query(name: String, sql: String),它将用户编写的 SQL 查询保存到本地文件(例如,一个 JSON 文件)。
    • 在 UI 侧边栏显示所有已保存的查询列表,点击可以加载到输入框中。
  4. 数据可视化(挑战):
    • 在前端集成一个简单的图表库(如 Chart.js)。
    • 添加一个新的 UI 元素,允许用户选择要可视化的列(例如,X 轴和 Y 轴)。
    • run_sql 返回结果后,使用图表库将数据渲染成一个简单的条形图或折线图。
Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐