T-CREATOR

Tauri アーキテクチャ設計指針:コマンド(Rust)と UI(Web)分離のベストプラクティス

Tauri アーキテクチャ設計指針:コマンド(Rust)と UI(Web)分離のベストプラクティス

Tauri はデスクトップアプリケーション開発フレームワークとして、Rust のバックエンドと Web 技術のフロントエンドを融合させています。しかし、この 2 つの世界をどう分離し、どう設計すれば保守性や拡張性の高いアプリケーションになるのでしょうか。

この記事では、Tauri における「コマンド(Rust)」と「UI(Web)」の適切な分離方法と、その設計指針について詳しく解説します。実際のコード例を交えながら、Tauri アプリケーションのアーキテクチャ設計のベストプラクティスをご紹介していきますね。

背景

Tauri のアーキテクチャ概要

Tauri は、Web 技術でフロントエンドを構築し、Rust でバックエンドロジックを実装するハイブリッドなフレームワークです。従来の Electron と比較して、バイナリサイズが小さく、メモリ使用量も抑えられる点が大きな魅力となっています。

Tauri のアーキテクチャは大きく 3 つの層に分けられます。

mermaidflowchart TB
  subgraph frontend["フロントエンド層"]
    ui["Web UI<br/>(HTML/CSS/JavaScript)"]
  end

  subgraph ipc["IPC 層"]
    invoke["invoke 関数"]
    event["イベントシステム"]
  end

  subgraph backend["バックエンド層"]
    cmd["コマンド<br/>(Rust 関数)"]
    core["コアロジック<br/>(Rust)"]
    sys["システムAPI<br/>(OS 連携)"]
  end

  ui -->|"invoke('command')"| invoke
  invoke --> cmd
  cmd --> core
  core --> sys

  sys -.->|"emit('event')"| event
  event -.-> ui

図で理解できる要点

  • フロントエンド(Web UI)とバックエンド(Rust)は IPC(プロセス間通信)で分離されています
  • invoke 関数でフロントエンドからバックエンドのコマンドを呼び出します
  • イベントシステムでバックエンドからフロントエンドへ非同期通知が可能です

なぜ分離が重要なのか

Web とネイティブの境界を明確にすることで、以下のメリットが得られます。

#メリット説明
1セキュリティ向上フロントエンドから直接システムリソースにアクセスできないため、XSS などの攻撃リスクを低減できます
2パフォーマンス最適化重い処理を Rust 側で実行し、UI のレスポンスを保てます
3テスタビリティRust のコマンドを独立してユニットテストできます
4保守性責務が明確になり、コードの見通しが良くなります

しかし、適切な分離を行わないと、フロントエンドとバックエンドが密結合になり、変更の影響範囲が広がってしまいます。

課題

よくある設計上の問題

Tauri アプリケーションを開発する際、以下のような設計上の問題に直面することがあります。

1. ビジネスロジックの漏出

フロントエンド側にビジネスロジックを書いてしまい、Rust 側のコマンドが単なるデータ取得関数になっているケースです。

typescript// ❌ 悪い例:フロントエンドにビジネスロジックが漏出
async function calculateTotal() {
  const items = await invoke('get_items');
  const tax = 0.1;

  // ビジネスロジックがフロントエンドに存在
  const subtotal = items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );
  const total = subtotal * (1 + tax);

  return total;
}

このコードでは、税率計算や合計金額の計算ロジックがフロントエンドに存在しています。税率が変更された場合や計算ロジックが複雑化した場合、フロントエンドの変更が必要になってしまいますね。

2. 粒度の不適切なコマンド設計

コマンドが細かすぎる、または粗すぎる場合も問題です。

typescript// ❌ 粒度が細かすぎる例
const user = await invoke('get_user_name');
const age = await invoke('get_user_age');
const email = await invoke('get_user_email');

// ❌ 粒度が粗すぎる例
const everything = await invoke('get_all_data');

前者は IPC 通信のオーバーヘッドが大きくなり、後者は不要なデータまで取得してしまいます。

3. エラーハンドリングの曖昧さ

エラーが発生した際の責任範囲が不明確で、どちらで処理すべきか判断できないケースです。

typescript// ❌ エラー処理が不明瞭
try {
  const result = await invoke('save_file', {
    path: filePath,
  });
} catch (error) {
  // どのようなエラーが返ってくるのか不明
  console.error(error);
}

4. 状態管理の重複

フロントエンドとバックエンドの両方で同じ状態を管理してしまい、同期が取れなくなる問題です。

以下の図は、これらの問題がアーキテクチャに与える影響を示しています。

mermaidflowchart LR
  subgraph problem["問題のある設計"]
    fe["フロントエンド"]
    be["バックエンド"]

    fe <-->|"密結合<br/>頻繁な通信"| be
    fe -.->|"ビジネスロジック漏出"| fe
    be -.->|"状態管理重複"| be
  end

  problem -->|"結果"| issues["★保守性低下<br/>★パフォーマンス劣化<br/>★テストが困難"]

図で理解できる要点

  • フロントエンドとバックエンドが密結合になると、変更の影響範囲が広がります
  • ビジネスロジックや状態管理が重複すると、整合性を保つのが困難になります
  • これらの問題は保守性やパフォーマンスに直接影響を及ぼします

解決策

アーキテクチャ設計の 3 原則

Tauri アプリケーションのアーキテクチャを設計する際は、以下の 3 つの原則を守ることが重要です。

#原則説明
1単一責任の原則フロントエンドは表示とユーザー操作、バックエンドはビジネスロジックとシステム連携に専念します
2疎結合の原則コマンドのインターフェースを明確にし、実装の詳細を隠蔽します
3DRY の原則状態管理やロジックの重複を避け、Single Source of Truth を保ちます

これらの原則に基づいて、具体的な設計パターンを見ていきましょう。

パターン 1:コマンド層の適切な設計

コマンドは「ユースケース」単位で設計します。フロントエンドが「何をしたいか」を表現し、バックエンドがその実現方法を提供する形です。

コマンドの定義(Rust)

まず、Rust 側でコマンドを定義します。

rust// src-tauri/src/commands/user.rs

use serde::{Deserialize, Serialize};
use tauri::State;

// ユーザー情報の構造体
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
    pub id: u32,
    pub name: String,
    pub email: String,
    pub created_at: String,
}

次に、エラー型を定義します。

rust// エラー型の定義
#[derive(Debug, thiserror::Error)]
pub enum UserError {
    #[error("ユーザーが見つかりません: {0}")]
    NotFound(u32),

    #[error("データベースエラー: {0}")]
    Database(String),

    #[error("バリデーションエラー: {0}")]
    Validation(String),
}

// Tauri の Result 型に変換するための実装
impl From<UserError> for String {
    fn from(error: UserError) -> Self {
        error.to_string()
    }
}

エラー型を明確にすることで、フロントエンド側でも適切なエラーハンドリングが可能になります。

コマンド関数を実装します。

rust// ユーザー取得コマンド
#[tauri::command]
pub async fn get_user(user_id: u32) -> Result<User, String> {
    // ビジネスロジックをここに実装
    // データベースアクセスや検証処理など

    let user = User {
        id: user_id,
        name: "山田太郎".to_string(),
        email: "yamada@example.com".to_string(),
        created_at: "2024-01-15".to_string(),
    };

    Ok(user)
}

複数のユーザーを取得するコマンドも同様に定義します。

rust// ユーザー一覧取得コマンド
#[tauri::command]
pub async fn list_users(
    page: u32,
    limit: u32
) -> Result<Vec<User>, String> {
    // ページネーション処理
    // データベースクエリ実行

    let users = vec![
        User {
            id: 1,
            name: "山田太郎".to_string(),
            email: "yamada@example.com".to_string(),
            created_at: "2024-01-15".to_string(),
        },
        User {
            id: 2,
            name: "佐藤花子".to_string(),
            email: "sato@example.com".to_string(),
            created_at: "2024-01-16".to_string(),
        },
    ];

    Ok(users)
}

ユーザー作成のコマンドでは、入力値の検証も行います。

rust// ユーザー作成の入力データ
#[derive(Debug, Deserialize)]
pub struct CreateUserInput {
    pub name: String,
    pub email: String,
}

// ユーザー作成コマンド
#[tauri::command]
pub async fn create_user(input: CreateUserInput) -> Result<User, String> {
    // 入力値の検証
    if input.name.is_empty() {
        return Err(UserError::Validation("名前は必須です".to_string()).into());
    }

    if !input.email.contains('@') {
        return Err(UserError::Validation("有効なメールアドレスを入力してください".to_string()).into());
    }

    // ユーザー作成処理
    let user = User {
        id: 3,
        name: input.name,
        email: input.email,
        created_at: chrono::Utc::now().to_rfc3339(),
    };

    Ok(user)
}

このように、Rust 側でビジネスロジックと検証を完結させることで、フロントエンドはシンプルに保てます。

コマンドの登録

定義したコマンドをアプリケーションに登録します。

rust// src-tauri/src/main.rs

mod commands;

use commands::user::{get_user, list_users, create_user};

fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            get_user,
            list_users,
            create_user,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

パターン 2:フロントエンド API クライアントの実装

フロントエンド側では、Tauri の invoke 関数を直接呼び出すのではなく、専用の API クライアントを作成します。

TypeScript での型定義

まず、Rust 側の型定義に対応する TypeScript の型を定義します。

typescript// src/types/user.ts

export interface User {
  id: number;
  name: string;
  email: string;
  created_at: string;
}

export interface CreateUserInput {
  name: string;
  email: string;
}

型の整合性を保つことで、型安全なコードを書けるようになりますね。

API クライアントの作成

次に、API クライアントを実装します。

typescript// src/api/userApi.ts

import { invoke } from '@tauri-apps/api/tauri';
import type { User, CreateUserInput } from '../types/user';

export class UserApi {
  /**
   * ユーザーを ID で取得します
   * @param userId ユーザーID
   * @returns ユーザー情報
   */
  static async getUser(userId: number): Promise<User> {
    try {
      return await invoke<User>('get_user', { userId });
    } catch (error) {
      throw new Error(
        `ユーザー取得に失敗しました: ${error}`
      );
    }
  }
}

ユーザー一覧取得のメソッドも追加します。

typescript  /**
   * ユーザー一覧を取得します
   * @param page ページ番号(1始まり)
   * @param limit 1ページあたりの件数
   * @returns ユーザーの配列
   */
  static async listUsers(page: number = 1, limit: number = 10): Promise<User[]> {
    try {
      return await invoke<User[]>('list_users', { page, limit });
    } catch (error) {
      throw new Error(`ユーザー一覧取得に失敗しました: ${error}`);
    }
  }

ユーザー作成のメソッドでは、入力値の検証結果を適切にハンドリングします。

typescript  /**
   * 新しいユーザーを作成します
   * @param input ユーザー作成データ
   * @returns 作成されたユーザー情報
   */
  static async createUser(input: CreateUserInput): Promise<User> {
    try {
      return await invoke<User>('create_user', { input });
    } catch (error) {
      // バリデーションエラーの場合、メッセージをそのまま表示
      if (typeof error === 'string' && error.includes('バリデーションエラー')) {
        throw new Error(error);
      }
      throw new Error(`ユーザー作成に失敗しました: ${error}`);
    }
  }

このように、API クライアントを一箇所にまとめることで、エラーハンドリングやロギングなどの横断的な関心事を統一的に処理できます。

React での使用例

API クライアントを React コンポーネントから使用する例です。

typescript// src/components/UserList.tsx

import React, { useEffect, useState } from 'react';
import { UserApi } from '../api/userApi';
import type { User } from '../types/user';

export const UserList: React.FC = () => {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    loadUsers();
  }, []);

  const loadUsers = async () => {
    try {
      setLoading(true);
      const data = await UserApi.listUsers(1, 10);
      setUsers(data);
      setError(null);
    } catch (err) {
      setError(
        err instanceof Error ? err.message : '不明なエラー'
      );
    } finally {
      setLoading(false);
    }
  };

  // レンダリング処理は省略
  return <div>{/* UI コード */}</div>;
};

パターン 3:状態管理の戦略

状態管理は「どこで保持するか」を明確にすることが重要です。

mermaidflowchart TB
  subgraph decision["状態の種類による判断"]
    state["状態"]

    state --> ui_state["UI 状態"]
    state --> app_state["アプリケーション状態"]
    state --> persistent_state["永続化状態"]
  end

  ui_state -->|"保存場所"| frontend1["フロントエンド<br/>(React State)"]
  app_state -->|"保存場所"| frontend2["フロントエンド<br/>(状態管理ライブラリ)"]
  persistent_state -->|"保存場所"| backend["バックエンド<br/>(Rust + DB/File)"]

  backend -.->|"必要時に同期"| frontend2

図で理解できる要点

  • UI 状態(モーダルの開閉など)はフロントエンドで完結させます
  • アプリケーション状態(ユーザー情報など)は状態管理ライブラリで管理します
  • 永続化が必要な状態はバックエンドで管理し、必要時にフロントエンドと同期します

UI 状態の管理

モーダルの開閉状態やフォームの入力値など、一時的な UI 状態はフロントエンドで管理します。

typescript// src/components/UserForm.tsx

import React, { useState } from 'react';
import { UserApi } from '../api/userApi';

export const UserForm: React.FC = () => {
  // UI 状態はローカルで管理
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsSubmitting(true);

    try {
      await UserApi.createUser({ name, email });
      // 成功時の処理
      setName('');
      setEmail('');
    } catch (error) {
      // エラー処理
      console.error(error);
    } finally {
      setIsSubmitting(false);
    }
  };

  // レンダリング処理は省略
  return (
    <form onSubmit={handleSubmit}>{/* フォーム UI */}</form>
  );
};

アプリケーション状態の管理

複数のコンポーネントで共有する状態は、Zustand や Jotai などの状態管理ライブラリを使用します。

typescript// src/store/userStore.ts

import { create } from 'zustand';
import { UserApi } from '../api/userApi';
import type { User } from '../types/user';

interface UserStore {
  users: User[];
  currentUser: User | null;
  loading: boolean;
  error: string | null;

  // アクション
  fetchUsers: () => Promise<void>;
  fetchUser: (userId: number) => Promise<void>;
  clearError: () => void;
}

状態管理ストアの実装です。

typescriptexport const useUserStore = create<UserStore>((set) => ({
  users: [],
  currentUser: null,
  loading: false,
  error: null,

  fetchUsers: async () => {
    set({ loading: true, error: null });
    try {
      const users = await UserApi.listUsers();
      set({ users, loading: false });
    } catch (error) {
      set({
        error:
          error instanceof Error
            ? error.message
            : '不明なエラー',
        loading: false,
      });
    }
  },

  fetchUser: async (userId: number) => {
    set({ loading: true, error: null });
    try {
      const user = await UserApi.getUser(userId);
      set({ currentUser: user, loading: false });
    } catch (error) {
      set({
        error:
          error instanceof Error
            ? error.message
            : '不明なエラー',
        loading: false,
      });
    }
  },

  clearError: () => set({ error: null }),
}));

コンポーネントからストアを使用する例です。

typescript// src/components/UserProfile.tsx

import React, { useEffect } from 'react';
import { useUserStore } from '../store/userStore';

export const UserProfile: React.FC<{ userId: number }> = ({
  userId,
}) => {
  const { currentUser, loading, error, fetchUser } =
    useUserStore();

  useEffect(() => {
    fetchUser(userId);
  }, [userId, fetchUser]);

  if (loading) return <div>読み込み中...</div>;
  if (error) return <div>エラー: {error}</div>;
  if (!currentUser) return null;

  return (
    <div>
      <h2>{currentUser.name}</h2>
      <p>{currentUser.email}</p>
    </div>
  );
};

永続化状態の管理

ファイルシステムやデータベースに保存する必要がある状態は、Rust 側で管理します。

rust// src-tauri/src/state/app_state.rs

use std::sync::Mutex;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppSettings {
    pub theme: String,
    pub language: String,
    pub auto_save: bool,
}

pub struct AppState {
    settings: Mutex<AppSettings>,
}

状態の初期化と保存のメソッドを実装します。

rustimpl AppState {
    pub fn new() -> Self {
        Self {
            settings: Mutex::new(AppSettings {
                theme: "light".to_string(),
                language: "ja".to_string(),
                auto_save: true,
            }),
        }
    }

    pub fn get_settings(&self) -> AppSettings {
        self.settings.lock().unwrap().clone()
    }

    pub fn update_settings(&self, settings: AppSettings) {
        *self.settings.lock().unwrap() = settings;
        // ファイルに保存する処理もここに追加
    }
}

設定を取得・更新するコマンドを定義します。

rust// src-tauri/src/commands/settings.rs

use tauri::State;
use crate::state::app_state::{AppState, AppSettings};

#[tauri::command]
pub fn get_settings(state: State<AppState>) -> Result<AppSettings, String> {
    Ok(state.get_settings())
}

#[tauri::command]
pub fn update_settings(
    state: State<AppState>,
    settings: AppSettings
) -> Result<(), String> {
    state.update_settings(settings);
    Ok(())
}

パターン 4:イベント駆動アーキテクチャ

長時間実行される処理や、バックエンドからフロントエンドへの通知には、イベントシステムを活用します。

イベント定義(Rust)

まず、イベントのペイロード型を定義します。

rust// src-tauri/src/events/download.rs

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadProgress {
    pub id: String,
    pub current: u64,
    pub total: u64,
    pub percentage: f64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadComplete {
    pub id: String,
    pub file_path: String,
}

イベントを発行するコマンドを実装します。

rust// src-tauri/src/commands/download.rs

use tauri::{AppHandle, Manager};
use crate::events::download::{DownloadProgress, DownloadComplete};

#[tauri::command]
pub async fn download_file(
    app: AppHandle,
    url: String,
    file_id: String
) -> Result<(), String> {
    // 非同期でダウンロード処理を実行
    tokio::spawn(async move {
        let total_size = 1000000u64; // 例:1MB

        for i in 0..=10 {
            let current = (total_size * i) / 10;
            let percentage = (i * 10) as f64;

            // 進捗イベントを発行
            app.emit_all("download-progress", DownloadProgress {
                id: file_id.clone(),
                current,
                total: total_size,
                percentage,
            }).ok();

            // 処理を模擬
            tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
        }

        // 完了イベントを発行
        app.emit_all("download-complete", DownloadComplete {
            id: file_id.clone(),
            file_path: "/path/to/downloaded/file".to_string(),
        }).ok();
    });

    Ok(())
}

イベントリスナー(TypeScript)

フロントエンド側でイベントをリッスンします。

typescript// src/hooks/useDownload.ts

import { useEffect, useState } from 'react';
import { listen, UnlistenFn } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/tauri';

interface DownloadProgress {
  id: string;
  current: number;
  total: number;
  percentage: number;
}

interface DownloadComplete {
  id: string;
  file_path: string;
}

カスタムフックで進捗状態を管理します。

typescriptexport const useDownload = (fileId: string) => {
  const [progress, setProgress] = useState<number>(0);
  const [isComplete, setIsComplete] = useState(false);
  const [filePath, setFilePath] = useState<string | null>(
    null
  );

  useEffect(() => {
    let unlistenProgress: UnlistenFn;
    let unlistenComplete: UnlistenFn;

    // 進捗イベントのリスナーを設定
    listen<DownloadProgress>(
      'download-progress',
      (event) => {
        if (event.payload.id === fileId) {
          setProgress(event.payload.percentage);
        }
      }
    ).then((unlisten) => {
      unlistenProgress = unlisten;
    });

    // 完了イベントのリスナーを設定
    listen<DownloadComplete>(
      'download-complete',
      (event) => {
        if (event.payload.id === fileId) {
          setIsComplete(true);
          setFilePath(event.payload.file_path);
        }
      }
    ).then((unlisten) => {
      unlistenComplete = unlisten;
    });

    // クリーンアップ
    return () => {
      unlistenProgress?.();
      unlistenComplete?.();
    };
  }, [fileId]);

  const startDownload = async (url: string) => {
    await invoke('download_file', { url, fileId });
  };

  return { progress, isComplete, filePath, startDownload };
};

コンポーネントでカスタムフックを使用します。

typescript// src/components/DownloadButton.tsx

import React from 'react';
import { useDownload } from '../hooks/useDownload';

export const DownloadButton: React.FC<{
  url: string;
  fileId: string;
}> = ({ url, fileId }) => {
  const { progress, isComplete, filePath, startDownload } =
    useDownload(fileId);

  const handleClick = () => {
    startDownload(url);
  };

  if (isComplete) {
    return <div>ダウンロード完了: {filePath}</div>;
  }

  return (
    <div>
      <button onClick={handleClick}>ダウンロード</button>
      {progress > 0 && <div>進捗: {progress}%</div>}
    </div>
  );
};

パターン 5:エラーハンドリング戦略

エラーハンドリングは、各層で適切に処理することが重要です。

Rust 側のエラー定義

カスタムエラー型を定義し、詳細なエラー情報を提供します。

rust// src-tauri/src/errors/mod.rs

use serde::Serialize;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum AppError {
    #[error("ファイルが見つかりません: {0}")]
    FileNotFound(String),

    #[error("権限がありません: {0}")]
    PermissionDenied(String),

    #[error("ネットワークエラー: {0}")]
    Network(String),

    #[error("データベースエラー: {0}")]
    Database(String),

    #[error("バリデーションエラー: {field} - {message}")]
    Validation { field: String, message: String },
}

エラーをシリアライズ可能な形式に変換します。

rust#[derive(Debug, Serialize)]
pub struct ErrorResponse {
    pub code: String,
    pub message: String,
    pub details: Option<String>,
}

impl From<AppError> for ErrorResponse {
    fn from(error: AppError) -> Self {
        match error {
            AppError::FileNotFound(path) => ErrorResponse {
                code: "FILE_NOT_FOUND".to_string(),
                message: error.to_string(),
                details: Some(path),
            },
            AppError::PermissionDenied(resource) => ErrorResponse {
                code: "PERMISSION_DENIED".to_string(),
                message: error.to_string(),
                details: Some(resource),
            },
            AppError::Network(details) => ErrorResponse {
                code: "NETWORK_ERROR".to_string(),
                message: error.to_string(),
                details: Some(details),
            },
            AppError::Database(details) => ErrorResponse {
                code: "DATABASE_ERROR".to_string(),
                message: error.to_string(),
                details: Some(details),
            },
            AppError::Validation { field, message } => ErrorResponse {
                code: "VALIDATION_ERROR".to_string(),
                message: error.to_string(),
                details: Some(format!("{}: {}", field, message)),
            },
        }
    }
}

TypeScript 側のエラーハンドリング

エラーレスポンスの型定義を行います。

typescript// src/types/error.ts

export interface ErrorResponse {
  code: string;
  message: string;
  details?: string;
}

export class TauriApiError extends Error {
  constructor(
    public code: string,
    message: string,
    public details?: string
  ) {
    super(message);
    this.name = 'TauriApiError';
  }

  static fromResponse(
    response: ErrorResponse
  ): TauriApiError {
    return new TauriApiError(
      response.code,
      response.message,
      response.details
    );
  }
}

API クライアントでエラーを適切に変換します。

typescript// src/api/fileApi.ts

import { invoke } from '@tauri-apps/api/tauri';
import {
  TauriApiError,
  ErrorResponse,
} from '../types/error';

export class FileApi {
  static async readFile(path: string): Promise<string> {
    try {
      return await invoke<string>('read_file', { path });
    } catch (error) {
      // エラーを構造化された形式に変換
      if (typeof error === 'object' && error !== null) {
        const errorResponse = error as ErrorResponse;
        throw TauriApiError.fromResponse(errorResponse);
      }
      throw new Error(
        `ファイル読み込みに失敗しました: ${error}`
      );
    }
  }
}

コンポーネントでエラーの種類に応じて処理を分岐します。

typescript// src/components/FileReader.tsx

import React, { useState } from 'react';
import { FileApi } from '../api/fileApi';
import { TauriApiError } from '../types/error';

export const FileReader: React.FC = () => {
  const [content, setContent] = useState<string>('');
  const [error, setError] = useState<string | null>(null);

  const handleReadFile = async (path: string) => {
    try {
      const data = await FileApi.readFile(path);
      setContent(data);
      setError(null);
    } catch (err) {
      if (err instanceof TauriApiError) {
        // エラーコードに応じて処理を分岐
        switch (err.code) {
          case 'FILE_NOT_FOUND':
            setError(
              `ファイルが見つかりません: ${err.details}`
            );
            break;
          case 'PERMISSION_DENIED':
            setError(
              `アクセス権限がありません: ${err.details}`
            );
            break;
          default:
            setError(err.message);
        }
      } else {
        setError('予期しないエラーが発生しました');
      }
    }
  };

  return <div>{/* UI コード */}</div>;
};

具体例

実践例:ファイル管理アプリケーション

ここまでの設計パターンを組み合わせて、ファイル管理アプリケーションを構築してみましょう。

アーキテクチャ全体図

mermaidflowchart TB
  subgraph ui["UI 層(React)"]
    components["コンポーネント"]
    hooks["カスタムフック"]
    store["状態管理<br/>(Zustand)"]
  end

  subgraph api["API 層(TypeScript)"]
    client["API クライアント"]
    types["型定義"]
  end

  subgraph ipc_layer["IPC 層"]
    invoke_fn["invoke 関数"]
    events["イベント"]
  end

  subgraph backend["バックエンド層(Rust)"]
    commands["コマンド"]
    services["サービス"]
    file_system["ファイルシステム"]
  end

  components --> hooks
  hooks --> store
  hooks --> client
  client --> types
  client --> invoke_fn
  invoke_fn --> commands
  commands --> services
  services --> file_system

  services -.->|"進捗通知"| events
  events -.-> hooks

図で理解できる要点

  • UI 層、API 層、バックエンド層が明確に分離されています
  • IPC 層が両者の橋渡しをし、疎結合を実現しています
  • イベントによる非同期通知で、長時間処理の進捗を UI に反映できます

Rust 側の実装

まず、ファイル情報の構造体を定義します。

rust// src-tauri/src/models/file.rs

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileInfo {
    pub path: String,
    pub name: String,
    pub size: u64,
    pub is_directory: bool,
    pub modified_at: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileOperation {
    pub id: String,
    pub operation_type: String,
    pub source: String,
    pub destination: Option<String>,
}

ファイル操作のサービス層を実装します。

rust// src-tauri/src/services/file_service.rs

use std::path::Path;
use std::fs;
use tauri::{AppHandle, Manager};
use crate::models::file::{FileInfo, FileOperation};
use crate::errors::AppError;

pub struct FileService;

impl FileService {
    // ディレクトリ内のファイル一覧を取得
    pub fn list_files(directory: &str) -> Result<Vec<FileInfo>, AppError> {
        let path = Path::new(directory);

        if !path.exists() {
            return Err(AppError::FileNotFound(directory.to_string()));
        }

        let mut files = Vec::new();

        for entry in fs::read_dir(path).map_err(|e| {
            AppError::PermissionDenied(format!("{}: {}", directory, e))
        })? {
            let entry = entry.map_err(|e| {
                AppError::Database(format!("エントリー読み込みエラー: {}", e))
            })?;

            let metadata = entry.metadata().map_err(|e| {
                AppError::Database(format!("メタデータ取得エラー: {}", e))
            })?;

            files.push(FileInfo {
                path: entry.path().to_string_lossy().to_string(),
                name: entry.file_name().to_string_lossy().to_string(),
                size: metadata.len(),
                is_directory: metadata.is_dir(),
                modified_at: format!("{:?}", metadata.modified().ok()),
            });
        }

        Ok(files)
    }
}

ファイルコピーの非同期処理を実装します。

rust    // ファイルコピー(進捗通知付き)
    pub async fn copy_file(
        app: AppHandle,
        operation_id: String,
        source: String,
        destination: String,
    ) -> Result<(), AppError> {
        let source_path = Path::new(&source);
        let dest_path = Path::new(&destination);

        if !source_path.exists() {
            return Err(AppError::FileNotFound(source.clone()));
        }

        // ファイルサイズを取得
        let file_size = fs::metadata(source_path)
            .map_err(|e| AppError::Database(format!("メタデータ取得エラー: {}", e)))?
            .len();

        // コピー開始イベントを発行
        app.emit_all("file-operation-start", FileOperation {
            id: operation_id.clone(),
            operation_type: "copy".to_string(),
            source: source.clone(),
            destination: Some(destination.clone()),
        }).ok();

        // 実際のコピー処理(簡略化)
        fs::copy(source_path, dest_path)
            .map_err(|e| AppError::Database(format!("コピーエラー: {}", e)))?;

        // 完了イベントを発行
        app.emit_all("file-operation-complete", operation_id).ok();

        Ok(())
    }

コマンド層でサービスを呼び出します。

rust// src-tauri/src/commands/file.rs

use tauri::{AppHandle, State};
use crate::models::file::FileInfo;
use crate::services::file_service::FileService;
use crate::errors::{AppError, ErrorResponse};

#[tauri::command]
pub fn list_files(directory: String) -> Result<Vec<FileInfo>, ErrorResponse> {
    FileService::list_files(&directory)
        .map_err(|e| e.into())
}

#[tauri::command]
pub async fn copy_file(
    app: AppHandle,
    operation_id: String,
    source: String,
    destination: String,
) -> Result<(), ErrorResponse> {
    FileService::copy_file(app, operation_id, source, destination)
        .await
        .map_err(|e| e.into())
}

TypeScript 側の実装

型定義を作成します。

typescript// src/types/file.ts

export interface FileInfo {
  path: string;
  name: string;
  size: number;
  is_directory: boolean;
  modified_at: string;
}

export interface FileOperation {
  id: string;
  operation_type: string;
  source: string;
  destination?: string;
}

API クライアントを実装します。

typescript// src/api/fileApi.ts

import { invoke } from '@tauri-apps/api/tauri';
import type { FileInfo } from '../types/file';
import { TauriApiError } from '../types/error';

export class FileApi {
  /**
   * ディレクトリ内のファイル一覧を取得します
   */
  static async listFiles(
    directory: string
  ): Promise<FileInfo[]> {
    try {
      return await invoke<FileInfo[]>('list_files', {
        directory,
      });
    } catch (error) {
      throw TauriApiError.fromResponse(error as any);
    }
  }

  /**
   * ファイルをコピーします
   */
  static async copyFile(
    operationId: string,
    source: string,
    destination: string
  ): Promise<void> {
    try {
      await invoke('copy_file', {
        operationId,
        source,
        destination,
      });
    } catch (error) {
      throw TauriApiError.fromResponse(error as any);
    }
  }
}

状態管理ストアを作成します。

typescript// src/store/fileStore.ts

import { create } from 'zustand';
import { FileApi } from '../api/fileApi';
import type { FileInfo } from '../types/file';

interface FileStore {
  files: FileInfo[];
  currentDirectory: string;
  loading: boolean;
  error: string | null;

  // アクション
  loadFiles: (directory: string) => Promise<void>;
  copyFile: (
    source: string,
    destination: string
  ) => Promise<string>;
  clearError: () => void;
}

export const useFileStore = create<FileStore>(
  (set, get) => ({
    files: [],
    currentDirectory: '',
    loading: false,
    error: null,

    loadFiles: async (directory: string) => {
      set({
        loading: true,
        error: null,
        currentDirectory: directory,
      });
      try {
        const files = await FileApi.listFiles(directory);
        set({ files, loading: false });
      } catch (error) {
        set({
          error:
            error instanceof Error
              ? error.message
              : '不明なエラー',
          loading: false,
        });
      }
    },

    copyFile: async (
      source: string,
      destination: string
    ) => {
      const operationId = `copy-${Date.now()}`;
      await FileApi.copyFile(
        operationId,
        source,
        destination
      );
      return operationId;
    },

    clearError: () => set({ error: null }),
  })
);

イベントをリッスンするカスタムフックを作成します。

typescript// src/hooks/useFileOperation.ts

import { useEffect, useState } from 'react';
import { listen } from '@tauri-apps/api/event';
import type { FileOperation } from '../types/file';

export const useFileOperation = (operationId: string) => {
  const [isRunning, setIsRunning] = useState(false);
  const [isComplete, setIsComplete] = useState(false);

  useEffect(() => {
    const setupListeners = async () => {
      // 開始イベント
      const unlistenStart = await listen<FileOperation>(
        'file-operation-start',
        (event) => {
          if (event.payload.id === operationId) {
            setIsRunning(true);
          }
        }
      );

      // 完了イベント
      const unlistenComplete = await listen<string>(
        'file-operation-complete',
        (event) => {
          if (event.payload === operationId) {
            setIsRunning(false);
            setIsComplete(true);
          }
        }
      );

      return () => {
        unlistenStart();
        unlistenComplete();
      };
    };

    setupListeners();
  }, [operationId]);

  return { isRunning, isComplete };
};

最後に、コンポーネントを実装します。

typescript// src/components/FileExplorer.tsx

import React, { useEffect, useState } from 'react';
import { useFileStore } from '../store/fileStore';
import { useFileOperation } from '../hooks/useFileOperation';

export const FileExplorer: React.FC = () => {
  const {
    files,
    currentDirectory,
    loading,
    error,
    loadFiles,
    copyFile,
  } = useFileStore();

  const [operationId, setOperationId] =
    useState<string>('');
  const { isRunning, isComplete } =
    useFileOperation(operationId);

  useEffect(() => {
    // 初期ディレクトリを読み込み
    loadFiles('/Users');
  }, []);

  const handleCopyFile = async (sourcePath: string) => {
    const destination = `${sourcePath}.copy`;
    const id = await copyFile(sourcePath, destination);
    setOperationId(id);
  };

  if (loading) return <div>読み込み中...</div>;
  if (error) return <div>エラー: {error}</div>;

  return (
    <div>
      <h2>現在のディレクトリ: {currentDirectory}</h2>
      <ul>
        {files.map((file) => (
          <li key={file.path}>
            {file.name} ({file.size} bytes)
            {!file.is_directory && (
              <button
                onClick={() => handleCopyFile(file.path)}
              >
                コピー
              </button>
            )}
          </li>
        ))}
      </ul>
      {isRunning && <div>コピー中...</div>}
      {isComplete && <div>コピー完了!</div>}
    </div>
  );
};

パフォーマンス最適化のポイント

アーキテクチャ設計において、パフォーマンスも重要な要素です。

#最適化ポイント説明
1バッチ処理複数の小さなコマンド呼び出しを 1 つのコマンドにまとめます
2キャッシング頻繁にアクセスされるデータは Rust 側でキャッシュします
3非同期処理長時間実行される処理は非同期で実行し、イベントで通知します
4データの最小化IPC 経由で送信するデータ量を最小限に抑えます

バッチ処理の例を見てみましょう。

rust// ❌ 非効率な例:個別に呼び出し
// フロントエンド側で複数回 invoke を呼ぶ
const user = await invoke('get_user', { userId: 1 });
const posts = await invoke('get_user_posts', { userId: 1 });
const comments = await invoke('get_user_comments', { userId: 1 });
rust// ✅ 効率的な例:バッチで取得
// Rust 側で 1 つのコマンドにまとめる

#[derive(Serialize)]
pub struct UserDetails {
    user: User,
    posts: Vec<Post>,
    comments: Vec<Comment>,
}

#[tauri::command]
pub async fn get_user_details(user_id: u32) -> Result<UserDetails, String> {
    // 並列で取得
    let (user, posts, comments) = tokio::join!(
        fetch_user(user_id),
        fetch_user_posts(user_id),
        fetch_user_comments(user_id),
    );

    Ok(UserDetails {
        user: user?,
        posts: posts?,
        comments: comments?,
    })
}

このように、1 回の IPC 呼び出しで必要なデータをまとめて取得することで、オーバーヘッドを削減できます。

まとめ

Tauri アプリケーションのアーキテクチャ設計において、コマンド(Rust)と UI(Web)の適切な分離は、保守性、拡張性、パフォーマンスを向上させる重要な要素です。

この記事で紹介した設計パターンをまとめると、以下のようになります。

#パターンポイント
1コマンド設計ユースケース単位で設計し、ビジネスロジックは Rust 側に集約します
2API クライアントinvoke 関数を直接呼ばず、専用クライアントでカプセル化します
3状態管理状態の種類(UI・アプリ・永続化)に応じて適切な層で管理します
4イベント駆動長時間処理や通知にはイベントシステムを活用します
5エラーハンドリング構造化されたエラー型を定義し、各層で適切に処理します

これらのパターンを組み合わせることで、以下のような特徴を持つアプリケーションを構築できるでしょう。

セキュアで安全性が高く、XSS などの攻撃リスクが低減されます。フロントエンドとバックエンドが明確に分離されているため、各レイヤーでの責務が明確になりますね。

パフォーマンスが最適化され、IPC のオーバーヘッドが最小限に抑えられます。必要なデータをバッチで取得し、長時間処理は非同期で実行することで、UI の応答性が保たれるでしょう。

テスタビリティが高く、Rust のコマンドを独立してテストできます。ビジネスロジックが Rust 側に集約されているため、ユニットテストが書きやすくなっています。

保守性が高く、変更の影響範囲が限定的です。インターフェースが明確なため、実装の詳細を変更しても他の層への影響が最小限に抑えられますね。

Tauri は進化し続けるフレームワークです。しかし、今回紹介した設計原則は、フレームワークのバージョンが変わっても適用できる普遍的なものです。ぜひ、あなたのプロジェクトでもこれらのベストプラクティスを活用してみてください。

関連リンク