CSV ファイルの読み書き (Rust)

csv クレートと serde で CSV を構造体で読み書きする。Excel で文字化けしない BOM 付き UTF-8 の書き出し、Shift_JIS の検出、大きなファイルを 1 行ずつ処理し Channel で進捗を返す方法も示す。

データ保存 対象: Tauri 2.x 更新日: 読了目安: 約8分 db-009
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 2. バックエンドから実装する (Rust)
  4. 書き出し: Excel 向けに BOM を付ける
  5. 読み込み: 1 行ずつ処理する
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

顧客の一覧を 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 に置いたままにします。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する