顧客の一覧を Excel に渡す、ほかのシステムから受け取ったデータを取り込む、といった CSV の読み書きは、Rust の csv クレートと serde で行うと 1 行を構造体として型付きで扱えます。ファイルの選択はフロントエンドのダイアログ、読み書きは Rust のコマンドに分けます。つまずきやすいのは、Excel との文字コードの食い違いと、大きなファイルで画面が固まることです。アプリ内の設定は Store、秘密の値は Stronghold、検索や集計をするデータは SQLite に置き、CSV は外部とのやり取りに使う、と分けると整理しやすくなります。
前提条件
cd src-tauri
cargo add csv
cd ..
npm run tauri add dialog
serde(derive 付き)はテンプレートの Cargo.toml に最初から入っています。ファイルは Rust の std::fs で直接読み書きするので、fs プラグインとその権限は要りません。ダイアログの open() と save() は dialog:default に含まれます。
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default"
]
}
1. フロントエンドから実装する (TypeScript)
ダイアログ で選ばれたパスを Rust のコマンドに渡します。invoke() の戻り値は処理が終わるまで返らないので、取り込みの進み具合は Channel で受け取ります。
import { Channel, invoke } from '@tauri-apps/api/core';
import { open, save } from '@tauri-apps/plugin-dialog';
export type Contact = { id: number; name: string; email: string; age: number | null };
export type Summary = { total: number; errors: string[]; preview: Contact[] };
const filters = [{ name: 'CSV', extensions: ['csv'] }];
export async function exportContacts(rows: Contact[]) {
const path = await save({ defaultPath: 'contacts.csv', filters });
if (!path) return; // キャンセル
await invoke('export_csv', { path, rows, excel: true });
}
export async function importContacts(onRows: (n: number) => void): Promise<Summary | null> {
const path = await open({ filters });
if (!path) return null;
const onProgress = new Channel<{ rows: number }>();
onProgress.onmessage = (m) => onRows(m.rows);
return invoke<Summary>('import_csv', { path, onProgress });
}
2. バックエンドから実装する (Rust)
書き出し: Excel 向けに BOM を付ける
日本語版の Excel は、BOM の無い CSV を Shift_JIS とみなして開くため、UTF-8 の日本語が文字化けします。ファイルの先頭に UTF-8 の BOM(EF BB BF の 3 バイト)を書いてから csv の Writer に渡せば、ダブルクリックで正しく開けます。BOM は csv クレートでは付かないので自分で書きます。Writer は内部でバッファするので BufWriter は不要です。破棄されるときにも書き出されますが、そのときのエラーは受け取れないため、最後に flush() を呼びます。
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::Write;
use tauri::ipc::Channel;
#[derive(Serialize, Deserialize)]
struct Contact {
id: u32,
name: String,
email: String,
age: Option<u32>, // 空欄は None
}
fn write_contacts(path: &str, rows: &[Contact], excel: bool) -> csv::Result<()> {
let mut file = File::create(path)?;
if excel {
file.write_all(b"\xEF\xBB\xBF")?; // UTF-8 の BOM
}
let mut wtr = csv::Writer::from_writer(file);
for row in rows {
wtr.serialize(row)?; // 最初の行の前に見出し(フィールド名)が入る
}
wtr.flush()?;
Ok(())
}
#[tauri::command]
async fn export_csv(path: String, rows: Vec<Contact>, excel: bool) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || write_contacts(&path, &rows, excel))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
見出しはフィールド名になります。「氏名」のような日本語の見出しにするなら #[serde(rename = "氏名")] を付けますが、同じ構造体を JS に返すとキーも「氏名」になります。カンマや改行を含む値は自動で " で囲まれます。
読み込み: 1 行ずつ処理する
deserialize() は 1 行ずつ読んで構造体にするので、ファイル全体をメモリに載せません。先頭の BOM は自動で読み飛ばされます。100 万行を Vec にして JS に返すと、JSON への変換と受け渡しに大きなメモリと時間を使うので、Rust 側で DB に入れる・集計するなどして、画面には件数と先頭の数行だけを返します。
async を付けないコマンドはメインスレッドで動き、処理の間ウィンドウが固まります。async コマンドにして、重い処理は spawn_blocking に回します(別スレッドでの実行)。
#[derive(Serialize)]
struct Progress {
rows: u64,
}
#[derive(Serialize)]
struct Summary {
total: u64,
errors: Vec<String>,
preview: Vec<Contact>,
}
fn read_contacts(path: &str, progress: &Channel<Progress>) -> Result<Summary, String> {
let mut rdr = csv::ReaderBuilder::new()
.trim(csv::Trim::All) // 見出しと値の前後の空白を除く
.from_path(path)
.map_err(|e| e.to_string())?;
let mut sum = Summary { total: 0, errors: Vec::new(), preview: Vec::new() };
for result in rdr.deserialize::<Contact>() {
sum.total += 1;
match result {
Ok(row) => {
// ここで DB に入れる・集計するなど、1 行ずつ処理する
if sum.preview.len() < 100 {
sum.preview.push(row);
}
}
Err(e) if matches!(e.kind(), csv::ErrorKind::Utf8 { .. }) => {
return Err("UTF-8 ではありません。Excel では「CSV UTF-8 (コンマ区切り)」で保存し直してください".into());
}
Err(e) => {
if sum.errors.len() < 100 {
sum.errors.push(e.to_string()); // 行番号入りのメッセージ
}
}
}
if sum.total % 10_000 == 0 {
let _ = progress.send(Progress { rows: sum.total });
}
}
Ok(sum)
}
#[tauri::command]
async fn import_csv(path: String, on_progress: Channel<Progress>) -> Result<Summary, String> {
tauri::async_runtime::spawn_blocking(move || read_contacts(&path, &on_progress))
.await
.map_err(|e| e.to_string())?
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![export_csv, import_csv])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
日本語版 Windows の Excel で「CSV (コンマ区切り)」として保存したファイルは Shift_JIS なので、上のコードは UTF-8 でないことを検出して保存し直しを求めます。そのまま取り込みたいなら、encoding_rs クレートなどで UTF-8 に変換してから csv に渡します。
動作確認
npm run tauri dev で exportContacts() に 2 件を渡して保存すると、次の内容になります(先頭に見えない BOM が付きます)。Excel で開いても名前は化けず、excel: false で書き出したものは化けます。
id,name,email,age
1,山田 太郎,taro@example.com,34
2,"Suzuki, Hanako",hanako@example.com,
100 万行のファイルを importContacts((n) => console.log(n)) で読むと、10000、20000…と進み具合が表示され、終わると total が 1000000 の結果が返ります。その間もウィンドウは操作できます。
よくあるエラーと対処法
- 「missing field
email」を含むエラー: 見出しの名前が構造体のフィールド名と一致していません(大文字と小文字も区別されます)。#[serde(rename = "...")]やaliasで合わせます。 - 「invalid digit found in string」を含むエラー: 数値の列に数字以外が入っています。空欄は
Optionで None になりますが、文字が入っていても None にしたいなら#[serde(deserialize_with = "csv::invalid_option")]を付けます。 - 「found record with 3 fields, but the previous record has 4 fields」を含むエラー: 列の数が行によって違います。
ReaderBuilderのflexible(true)で許し、足りない列はOptionで受けます。 - Excel で開くと文字化けする: BOM を付けずに書き出しています。
OS ごとの違いと注意点
- 改行: 読み込みは CRLF と LF のどちらでも読めます。書き出しの既定は LF で、CRLF にするなら
csv::WriterBuilder::new().terminator(csv::Terminator::CRLF)を使います。 - 区切り文字: タブ区切り(TSV)は
delimiter(b'\t')を Reader と Writer の両方に指定します。 - Excel での表示: 0 で始まる番号や桁の多い数字は、Excel では数値として扱われて表示が変わります。CSV の中身は変わりません。
- 書き込み先を絞る: Rust のコマンドは capability のファイル範囲に縛られず、渡されたパスに書き込みます。拡張子が
.csvかを確かめるなど、受け付けるパスを絞ります。 - 平文: CSV は誰でも読めるので、パスワードやトークンの列は書き出しません。それらは Stronghold に置いたままにします。
