Wailsでデスクトップアプリを作る #5 実践機能 — 設定の保存とエラー処理

読了 6分

「Wailsでデスクトップアプリを作る #4 システム統合 — ダイアログ・メニュー・ウィンドウ制御」までで作ったToDoアプリには、致命的な穴がひとつあります。アプリを終了するとデータが消えます。リストがGo構造体のメモリにしかないためです。今回はこのアプリを実際に使える水準に引き上げる実践機能3つ、すなわちデータの永続化、エラー処理、ログを扱います。

全 8 編(本編 6 編 + 応用 2 編)です。

  • #1 Wailsとは — Goで作る軽量デスクトップアプリ
  • #2 プロジェクト構造と開発ループ — wails devとバインディング
  • #3 Goとフロントエンドの連携 — メソッドバインディングとイベント
  • #4 システム統合 — ダイアログ・メニュー・ウィンドウ制御
  • #5 実践機能 — 設定の保存とエラー処理 ← この記事
  • #6 ビルドと配布 — プラットフォーム別パッケージング

どこに保存するか — os.UserConfigDir #

最初の問いは保存場所です。実行ファイルの隣にファイルを書く方式は、インストール先に書き込み権限がない場合(WindowsのProgram Files、macOSのApplications)が多く、配布した直後に壊れます。各OSがアプリデータ用に定めているパスを使うべきで、Go標準ライブラリのos.UserConfigDirがそのパスを返してくれます。

OSos.UserConfigDir() の返すパス
macOS~/Library/Application Support
Windows%AppData%C:\Users\<名前>\AppData\Roaming
Linux~/.configXDG_CONFIG_HOME

この下にアプリ名でディレクトリを作り、その中に保存します。プラットフォームの分岐コードなしで、3つのOSで同じコードが正しい場所を指します。

Store — JSONの保存と読み込み #

ToDoリストをファイルとして扱う小さなストア型を作ります。形式はJSONで十分です。

store.go — JSONファイルストア
type Store struct {
	path string
}

func NewStore(appName string) (*Store, error) {
	base, err := os.UserConfigDir()
	if err != nil {
		return nil, err
	}
	dir := filepath.Join(base, appName)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return nil, err
	}
	return &Store{path: filepath.Join(dir, "todos.json")}, nil
}

func (s *Store) Load() ([]Todo, error) {
	data, err := os.ReadFile(s.path)
	if errors.Is(err, os.ErrNotExist) {
		return []Todo{}, nil // 初回起動 — 空のリストで開始
	}
	if err != nil {
		return nil, err
	}
	var todos []Todo
	if err := json.Unmarshal(data, &todos); err != nil {
		return nil, err
	}
	return todos, nil
}

func (s *Store) Save(todos []Todo) error {
	data, err := json.MarshalIndent(todos, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(s.path, data, 0o644)
}

初回起動でファイルがないのはエラーではなく正常な状態なので、os.ErrNotExistを区別して空のリストを返します。この区別がないと、初回起動のたびにエラー通知が出るアプリになります。

startupとshutdown — ライフサイクルへの接続 #

ストアをアプリのライフサイクルにつなぎます。#3で見たstartupフックで読み込み、終了フックで保存します。

app.go — ライフサイクルの接続
func (a *App) startup(ctx context.Context) {
	a.ctx = ctx
	store, err := NewStore("wails-todo")
	if err == nil {
		a.store = store
		a.todos, _ = store.Load()
	}
}

func (a *App) shutdown(ctx context.Context) {
	if a.store != nil {
		a.store.Save(a.todos)
	}
}

shutdownoptions.AppOnShutdownに登録します。ただし強制終了やクラッシュではshutdownが実行されないことがあるため、項目の追加・削除のようにデータが変わるタイミングごとに保存するほうが、実践では安全です。ToDoリスト程度のデータなら、毎回保存してもコストは体感できません。

エラー処理 — Goのerrorが通知になるまで #

バインディングされたGoメソッドがerrorを返すと、フロントエンドでは拒否されたPromiseになります。このつながりがエラー処理設計の土台です。

app.go — errorを返す
func (a *App) AddTodo(title string) ([]Todo, error) {
	title = strings.TrimSpace(title)
	if title == "" {
		return nil, fmt.Errorf("ToDoの内容を入力してください")
	}
	a.todos = append(a.todos, Todo{Title: title})
	if err := a.store.Save(a.todos); err != nil {
		return nil, fmt.Errorf("保存に失敗しました: %w", err)
	}
	return a.todos, nil
}
frontend — catchで受け取る
try {
  const todos = await AddTodo(input.value);
  render(todos);
} catch (err) {
  showToast(String(err)); // Go が返したエラーメッセージ
}

ここでエラーを2種類に分ける基準を立てておくと、コードが整理されます。

  • 想定内のエラー — 空の入力、不正な形式のように、ユーザーが直せる問題です。ユーザーに見せる文としてメッセージを作って返します。
  • 想定外のエラー — ディスク書き込みの失敗、壊れたファイルのように、ユーザーにはどうにもならない問題です。ユーザーには短い案内だけを見せ、詳細はログに残します。

ログ — リリース後への備え #

開発中はターミナルが見えますが、配布されたアプリにはターミナルがありません。問題が起きたときに調べられる記録をファイルに残しておきます。Wailsはロガーを差し替えられるオプションを提供しています。

main.go — ファイルロガー
import "github.com/wailsapp/wails/v2/pkg/logger"

err := wails.Run(&options.App{
	Title:  "todo",
	Logger: logger.NewFileLogger(logPath), // 設定ディレクトリ配下のパスを推奨
	// ...
})

アプリのコードではruntime.LogInfo(ctx, ...)runtime.LogError(ctx, ...)系の関数で記録します。想定外のエラーに出会う地点ごとにLogErrorを1行残しておけば、ユーザーからログファイルをひとつ受け取るだけで原因の特定が可能になります。

外部API呼び出しはGo側で #

天気や為替のように外部APIを呼ぶ機能が必要なら、フロントエンドのfetchではなく、Goのnet/httpで呼び出してバインディングメソッドとして公開する構成を推奨します。理由は2つです。

  • CORSがありません。 WebViewのfetchはブラウザと同じオリジン制約を受けますが、GoのHTTPクライアントは通常のプログラムのネットワークリクエストなので制約がありません。
  • キーが隠れます。 APIキーをフロントエンドのコードに置くと、配布ファイルの中でそのまま露出します。Go側に置けばバイナリの中にあり、リクエストの組み立てもサーバーコードのように扱えます。
注記
バイナリに含めたキーもリバースエンジニアリングで抽出されうるため、課金が絡む重要なキーならデスクトップアプリに直接入れず、中継サーバーを置くのが原則です。Go側での呼び出しはCORSと露出の問題を減らすだけで、完全な秘匿ではありません。

まとめ #

今回の要点は3つです。

  • データはos.UserConfigDir配下のアプリディレクトリに保存します。3つのOSで同じコードが正しいパスを指します。
  • Goメソッドのerrorはフロントエンドの拒否されたPromiseになります。ユーザーが直せるエラーは文として、想定外のエラーはログへ送ります。
  • 外部API呼び出しはGo側に置くと、CORSとキー露出の問題が減ります。

これでアプリは機能的に完成しました。次回の「Wailsでデスクトップアプリを作る #6 ビルドと配布 — プラットフォーム別パッケージング」では、このアプリを実行ファイルにして他の人のコンピュータへ届ける最後のステップを扱い、シリーズを締めくくります。

X