巨大なファイルを少しずつ読み込む

fs プラグインの open() と read() で巨大なファイルを一定量ずつ読み、進み具合の表示や中断をする。readTextFileLines() の落とし穴と、Rust から Channel でバイト列を送る方法も示す。

ファイルシステム 対象: Tauri 2.x 更新日: 読了目安: 約11分 fs-027
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 一定量ずつ読み、進み具合を出し、中断できるようにする
  4. テキストを行ごとに処理する(文字化けを防ぐ)
  5. readTextFileLines() は最後まで読むときだけ使う
  6. 末尾だけ読む
  7. 2. バックエンドから実装する (Rust)
  8. 動作確認
  9. よくあるエラーと対処法
  10. OS ごとの違いと注意点
  11. 関連レシピ

ログや動画、データのダンプのような数百 MB〜数 GB のファイルを readFile() で読むと、全体を読み終えるまで何も返らず、同じ大きさのデータが Rust 側と JS 側に一時的に並びます。File System プラグインの open() でファイルを開き、read() で一定量ずつ読めば、メモリを抑えたまま進み具合を出したり、途中でやめたりできます。テキストを 1 行ずつ返す readTextFileLines() もありますが、途中で抜けるとファイルが開いたまま残る点に注意が要ります。

前提条件

fs プラグインと、読むファイルを選ばせるダイアログプラグインを追加します。

npm run tauri add fs
npm run tauri add dialog

open()(fs:allow-open)、read()(fs:allow-read)、大きさを取る stat()(fs:allow-fstat)、位置を移す seek()(fs:allow-seek)は、どれも fs:default に含まれないので追加します。readTextFileLines() と、閉じる close()(core:default に含まれる)は追加不要です。読める範囲は、fs:default が持つアプリ用フォルダーと、ダイアログで選ばれたファイル(その起動中だけ)です。ほかの場所の許可は ファイルやディレクトリを削除する を参照してください。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "fs:default",
    "dialog:default",
    "fs:allow-open",
    "fs:allow-read",
    "fs:allow-fstat",
    "fs:allow-seek"
  ]
}
読み方向いている場面
readFile() / readTextFile()小さめのファイルを一度に(バイナリ・テキスト)
open() + read()進み具合の表示や中断が要るとき
seek() + read()ログの末尾など一部だけ
readTextFileLines()行数の多くないテキストを最後まで
Rust のコマンドハッシュ計算や集計など、読みながら重い処理をするとき

1. フロントエンドから実装する (TypeScript)

一定量ずつ読み、進み具合を出し、中断できるようにする

read() は渡した Uint8Array に読み込み、読めたバイト数を返します(終わりなら null)。1 回の read() が Rust 側との 1 往復なので、チャンクが小さすぎると往復の回数で遅くなり、大きすぎるとメモリを使います。1 MiB 前後から試すのが目安です。中断は AbortSignal で受け、finally で必ず close() します。閉じ忘れたファイルは、アプリを終了するまで開いたままです。

import { open } from '@tauri-apps/plugin-fs';

type ReadOptions = {
  signal?: AbortSignal;
  onProgress?: (done: number, total: number) => void;
  chunkSize?: number;
};

// chunkSize ずつ読み、そのたびに onChunk を呼ぶ。読んだバイト数を返す
export async function readInChunks(
  path: string,
  onChunk: (chunk: Uint8Array) => void | Promise<void>,
  { signal, onProgress, chunkSize = 1024 * 1024 }: ReadOptions = {},
): Promise<number> {
  const file = await open(path, { read: true }); // fs:allow-open
  try {
    const total = (await file.stat()).size; // fs:allow-fstat
    const buf = new Uint8Array(chunkSize);
    let done = 0;
    for (;;) {
      signal?.throwIfAborted(); // 中断されていたら AbortError を投げる
      const n = await file.read(buf); // fs:allow-read。終わりなら null
      if (n === null) break;
      await onChunk(buf.subarray(0, n)); // n は chunkSize より小さいことがある
      done += n;
      onProgress?.(done, total);
    }
    return done;
  } finally {
    await file.close(); // 中断や例外でも必ず閉じる
  }
}

onChunk に渡すのは同じバッファーの一部で、次の read() で上書きされます。チャンクを配列にためておくなら slice() でコピーします。

ダイアログと組み合わせる例です。dialog と fs はどちらも open という関数を持っているので、片方を別名で読み込みます。

// (続き)
import { open as openDialog } from '@tauri-apps/plugin-dialog';

export async function countLines(bar: HTMLProgressElement, cancel: HTMLButtonElement) {
  const path = await openDialog({ multiple: false, directory: false });
  if (path === null) return; // キャンセル
  const controller = new AbortController();
  cancel.onclick = () => controller.abort();
  let lines = 0;
  try {
    const size = await readInChunks(path, (chunk) => {
      for (const b of chunk) if (b === 0x0a) lines++; // 改行の数を数える
    }, {
      signal: controller.signal,
      onProgress: (done, total) => { bar.max = total; bar.value = done; },
    });
    console.log(`${size} バイト / ${lines} 行`);
  } catch (e) {
    if (!controller.signal.aborted) throw e;
    console.log('中断しました');
  }
}

テキストを行ごとに処理する(文字化けを防ぐ)

UTF-8 の日本語は 1 文字が 3 バイトなので、チャンクの境目で文字が分かれます。チャンクごとに TextDecoder の decode() を呼ぶと境目が「�」に化けるため、{ stream: true } を付けて途中の文字を次回に持ち越します。行も同じで、最後の 1 行は次のチャンクとつなげてから処理します。

// (続き)

export async function forEachLine(path: string, onLine: (line: string) => void, signal?: AbortSignal) {
  const decoder = new TextDecoder('utf-8');
  let rest = '';
  await readInChunks(path, (chunk) => {
    const lines = (rest + decoder.decode(chunk, { stream: true })).split('\n');
    rest = lines.pop() ?? ''; // 最後の行は途中かもしれないので持ち越す
    for (const line of lines) onLine(line.replace(/\r$/, ''));
  }, { signal });
  rest += decoder.decode(); // 持ち越した分を確定させる
  if (rest !== '') onLine(rest.replace(/\r$/, ''));
}

readTextFileLines() は最後まで読むときだけ使う

readTextFileLines() は 1 行読むたびに Rust 側と 1 往復するので、数百万行のファイルでは上の方法よりかなり遅くなります。また、最後まで読むと自動で閉じられますが、break や例外で途中で抜けると閉じる手段がなく、アプリを終了するまで開いたままになります。見つけたら止める検索などは forEachLine() と AbortController で書きます。

import { readTextFileLines, BaseDirectory } from '@tauri-apps/plugin-fs';

// 最後まで読む。途中で break しない
export async function countErrors(): Promise<number> {
  let count = 0;
  const lines = await readTextFileLines('logs/app.log', { baseDir: BaseDirectory.AppLog });
  for await (const line of lines) {
    if (line.includes('ERROR')) count++;
  }
  return count;
}

末尾だけ読む

seek() で読む位置を移せます。先頭より前には移動できないので、大きさから位置を計算します。途中から読むと、最初の文字が欠けて「�」になることがあります。

import { open, SeekMode } from '@tauri-apps/plugin-fs';

// 末尾の bytes バイトだけを文字列で返す
export async function readTail(path: string, bytes = 64 * 1024): Promise<string> {
  const file = await open(path, { read: true });
  try {
    const size = (await file.stat()).size;
    await file.seek(Math.max(0, size - bytes), SeekMode.Start); // fs:allow-seek
    const buf = new Uint8Array(Math.min(bytes, size));
    let filled = 0;
    while (filled < buf.length) {
      const n = await file.read(buf.subarray(filled));
      if (n === null) break;
      filled += n;
    }
    return new TextDecoder().decode(buf.subarray(0, filled));
  } finally {
    await file.close();
  }
}

2. バックエンドから実装する (Rust)

読みながらハッシュを計算する、行を集計するといった重い処理は、Rust で最後まで済ませて結果だけを返すのが最も速く、IPC にバイト列を流さずに済みます。JS でバイト列が必要なら tauri::ipc::Channel で送ります。Channel はバイト列を ArrayBuffer のまま順番どおりに届けます。イベント(emit)は数値の配列に変換され、すべてのリスナーに配られるので、大量のデータには向きません。逆向き(JS から Rust へ大きなバイト列を送る)の注意は バイナリデータをファイルに保存する で扱います。

Rust の std::fs には fs プラグインの範囲が効かないので、例では fs_scope().is_allowed() で、ダイアログで選ばれたかドロップされたファイルだけを読むようにしています(capability に書いた範囲はここには含まれません)。中断は、ジョブごとのフラグを別のコマンドから立てて伝えます。

use std::collections::HashMap;
use std::io::Read;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tauri::ipc::{Channel, InvokeResponseBody};
use tauri::State;
use tauri_plugin_fs::FsExt;

/// 読み込み中のジョブ。id ごとに中断フラグを持つ
#[derive(Default)]
struct ReadJobs(Mutex<HashMap<u32, Arc<AtomicBool>>>);

/// 1 MiB ずつ読んで Channel で送り、最後に空のチャンクを送る。読んだバイト数を返す
#[tauri::command]
async fn stream_file(
    app: tauri::AppHandle,
    jobs: State<'_, ReadJobs>,
    job_id: u32,
    path: String,
    on_chunk: Channel<InvokeResponseBody>,
) -> Result<u64, String> {
    if !app.fs_scope().is_allowed(&path) {
        return Err(format!("not allowed: {path}"));
    }
    let cancel = Arc::new(AtomicBool::new(false));
    jobs.0.lock().unwrap().insert(job_id, cancel.clone());
    // ファイルの読み込みで非同期処理のスレッドをふさがないよう、専用のスレッドで行う
    let result = tauri::async_runtime::spawn_blocking(move || -> Result<u64, String> {
        let mut file = std::fs::File::open(&path).map_err(|e| format!("{path}: {e}"))?;
        let mut buf = vec![0u8; 1024 * 1024];
        let mut sent = 0u64;
        loop {
            if cancel.load(Ordering::Relaxed) {
                return Err("cancelled".into());
            }
            let n = file.read(&mut buf).map_err(|e| e.to_string())?;
            if n == 0 {
                break;
            }
            on_chunk.send(InvokeResponseBody::Raw(buf[..n].to_vec())).map_err(|e| e.to_string())?;
            sent += n as u64;
        }
        on_chunk.send(InvokeResponseBody::Raw(Vec::new())).map_err(|e| e.to_string())?; // 終わりの合図
        Ok(sent)
    })
    .await
    .map_err(|e| e.to_string())?;
    jobs.0.lock().unwrap().remove(&job_id);
    result
}

#[tauri::command]
fn cancel_stream(jobs: State<'_, ReadJobs>, job_id: u32) {
    if let Some(flag) = jobs.0.lock().unwrap().get(&job_id) {
        flag.store(true, Ordering::Relaxed);
    }
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_fs::init())
        .plugin(tauri_plugin_dialog::init())
        .manage(ReadJobs::default())
        .invoke_handler(tauri::generate_handler![stream_file, cancel_stream])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

invoke() が解決した時点で、最後のチャンクがまだ届いていないことがあります。Channel のメッセージは順番どおりに届くので、例では空のチャンクを終わりの合図にしています。

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

let nextJobId = 1;

// done は全チャンクが届いたら解決し、中断やエラーなら reject される
export function streamFile(path: string, onChunk: (chunk: Uint8Array) => void) {
  const jobId = nextJobId++;
  const done = new Promise<void>((resolve, reject) => {
    const channel = new Channel<ArrayBuffer>((buf) => {
      if (buf.byteLength === 0) resolve(); // これより前のチャンクはすべて届いている
      else onChunk(new Uint8Array(buf));
    });
    invoke<number>('stream_file', { jobId, path, onChunk: channel }).catch(reject);
  });
  return { done, cancel: () => invoke('cancel_stream', { jobId }) };
}

動作確認

npm run tauri dev で起動し、countLines() で 1 GB 程度のテキストファイルを選ぶと、プログレスバーが進み、終わるとコンソールにバイト数と行数が出ます(数値は例)。途中で中断ボタンを押すと「中断しました」と出て、ファイルは閉じられます。

1073741824 バイト / 8388608 行

streamFile() の戻り値の cancel() を呼ぶと、done が「cancelled」で reject されます。

よくあるエラーと対処法

  • 「fs.open not allowed. Permissions associated with this command:」の後に候補が並ぶ: fs:allow-open の追加漏れです。read() なら「fs.read not allowed」、stat() なら「fs.fstat not allowed」で始まります。リリースビルドでは「Command plugin:fs|open not allowed by ACL」のようになります。
  • 「forbidden path: 」で始まるエラー: 読める範囲の外です。デバッグビルドでは後ろに「maybe it is not allowed on the scope for allow-open permission in your capability file」が付きます。
  • ためておいたチャンクの中身が壊れている: subarray() のまま保持しています。slice() でコピーします。
  • 途中の文字が「�」になる: チャンクごとに decode() しています。{ stream: true } を付けます。
  • Rust のコマンドが「not allowed: 」で失敗する: 例のコマンドは、ダイアログで選ばれたかドロップされたファイルだけを読みます。アプリ用フォルダーのファイルなら、そのフォルダーの下でパスを組み立てる形にします(ファイルやディレクトリを削除する の Rust の例)。

OS ごとの違いと注意点

  • Android: ダイアログで選ばれたファイルは content:// で始まる URI です。JS の open() には渡せますが、例の Rust コマンドは std::fs を使うので開けません(ファイルを開くダイアログを表示する)。
  • 文字コード: readTextFileLines() は encoding を指定すれば Shift_JIS なども行ごとに読めます。自前で分ける forEachLine() では new TextDecoder('shift_jis') のように指定します。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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