Go で書いたローカル向けの処理を、GUI 付きのデスクトップアプリとして作るときの技術選定のまとめになります。
今作っているのは、ローカルコマンドを実行して、その進捗や結果を画面で追えるようにする開発者向けツールです。ターミナルに流れるログだけだと「いま何が走っていて、どこで失敗したのか」が見えづらいので、状態を持ったノードグラフとして眺められるようにしたい、というのが出発点でした。
アプリの細かい紹介は別の記事に回すとして、この記事では技術選定だけに絞ります。
- Go の処理をアプリ本体に持たせたい
- ローカルコマンドを実行したい
- 実行状況をリアルタイムに画面へ出したい
- ノードグラフのような少し複雑な UI を作りたい
という前提で、なぜ Wails + React + React Flow を選んだか?
作りたいものの前提
今回作ろうとしているのは、ざっくり言うと開発者向けのローカルデスクトップアプリです。
裏側ではコマンドを実行し、表側では実行状態をノードやログとして表示します。
要件は以下になります。
-
バックエンドは Go で書きたい
- ローカルファイルを読む
- 外部コマンドを実行する
- 標準出力を逐次パースする
- 履歴を保存する
-
実行状況をリアルタイムに表示したい
- コマンド実行中の状態を画面に反映する
- 成功・失敗・実行中などの状態をノードで表現する
- ログを追記表示する
-
ローカル完結にしたい
- サーバを別途立てたくない
- 外部サービスに依存したくない
- 開発者の手元で気軽に動く形にしたい
-
将来的にはクロスプラットフォーム配布も視野に入れたい
- まずは macOS
- その後 Windows / Linux も考えたい
この時点で、普通の Web アプリよりも「ローカル処理に強いデスクトップアプリ」という選択になりました。
将来的にはリモートサポートへの拡張も視野に入れています。
デスクトップ基盤:Wails vs Electron vs Tauri vs Flutter
まず悩んだのが、デスクトップアプリの土台です。
| バックエンド | ランタイム | 特徴 | 今回の相性 | |
|---|---|---|---|---|
| Wails v2 | Go | OS ネイティブ WebView | Go と Web UI を組み合わせやすい | ◎ |
| Electron | Node.js | Chromium 同梱 | エコシステムが大きい | △ |
| Tauri | Rust | OS ネイティブ WebView | 軽量・安全寄り | ○ |
| Flutter | Dart | Flutter engine | 高品質なクロスプラットフォーム UI | ○ |
結論としては Wails v2 を選びました。
一番大きな理由は、バックエンドを Go でそのまま書けることです。
今回のアプリでは、ローカルコマンドの実行や標準出力のパース、ファイル操作、履歴保存など、UI よりも裏側の処理がそれなりに重要です。Go なら os/exec、bufio.Scanner、encoding/json、database/sql などを自然に使えます。
最初は Electron でもいいかと思っていました。情報量が多く、困ったときの検索もしやすいですし、デスクトップアプリを作る選択肢としてはかなり無難です。
ただ、考えていくと「Go で書きたい処理」を Electron の外側に置くことになります。Go の小さな CLI を別バイナリとして同梱し、Node.js 側から呼び出す構成もできますが、それなら最初から Go をバックエンドにできる Wails の方がシンプルです。IPC層を増やしてまで Electron にする理由は、今回の用途ではあまりないという判断になります。
Tauri も良い選択肢だと思いましたが、基本は Rust です。Rust でバックエンドを書く前提ならかなり魅力的ですが、今回は Go の標準ライブラリと既存知識を活かしたかったので脱落。
Flutterは、以前「Flutter 2.0 の desktop 対応」について記事を書いたことがあり、その頃から「モバイルだけでなくデスクトップにも広がっていく流れ」は気になっていました。当時はまだこれからという印象もありましたが、今は Windows / macOS / Linux 向けのデスクトップアプリを作れる選択肢として十分現実的です。
しかし、今回は選びませんでした。理由は、UI の品質ではなくアプリの重心です。Flutter は Dart で UI もロジックもまとめて書けるのが強みですが、今回やりたい裏側の処理は Go で書きたいものが多い。Flutter から Go バイナリを呼び出す構成もできますが、それなら Electron と同じく「GUI 側と Go 側の境界」を自分で持つことになります。
また、ノードグラフ UI については Web / React エコシステムの資産を使いたい気持ちもありました。Flutter で同じ UI を作れないわけではありません。ただ、今回の用途では「Go バックエンド + React のグラフ UI」に寄せた方が楽かなって判断です。
Wails の良いところ
Wails は Go と Web フロントエンドを組み合わせてデスクトップアプリを作るフレームワークです。
ざっくり言うと、
- バックエンドは Go
- フロントエンドは React / Vue / Svelte など
- 表示は OS 標準の WebView
- Go のメソッドをフロントエンドから呼べる
- Go からフロントエンドへイベントを送れる
という構成です。
Electron と違って Chromium を同梱しないため、バイナリサイズを抑えやすいのもメリットです。
macOS なら WKWebView、Windows なら WebView2、Linux なら WebKitGTK を使います。
Go とフロントエンドの連携も分かりやすく、
- フロントエンド → Go: バインドされた Go メソッドを呼ぶ
- Go → フロントエンド:
runtime.EventsEmitでイベントを送る
という形で実装できます。
実行中のコマンドからログや状態イベントが流れてくるようなアプリでは、このイベント機構がかなり使いやすいです。
Wails で気をつけたいところ
一方で、ネイティブ WebView を使う以上、ブラウザそのものとは少し違う点もあります。
たとえば macOS の GUI アプリは、ターミナルから起動したときと Finder から起動したときで PATH が違います。Finder から起動すると go や node などのコマンドが見つからないことがあります。
これは Go 製の GUI ツールで最初に踏みそうな罠です。ローカルコマンドを実行するアプリでは、
-
exec.LookPathだけに頼りすぎない - Homebrew / asdf / mise / goenv などの代表的なパスも見る
- 設定画面でコマンドパスを上書きできるようにする
といった逃げ道を最初から用意しておくことをオススメします!
また、WebView では通常のブラウザと挙動が異なる API もあります。window.prompt のような簡易ダイアログに頼るより、最初から React 側でモーダルを作る方が無難です。
フロントエンドはReact
Wails は複数のフロントエンドを選べますが、今回は React + TypeScript にしました。
理由はシンプルで、今回の UI がかなり状態管理寄りだからです。
- 実行中の状態が変わる
- ノードの色が変わる
- 選択中ノードの詳細が変わる
- ログが追記される
- 履歴や設定も画面に持つ
こういう UI は、コンポーネント単位で状態を分けやすい React と相性が良いです。
もちろん Vue や Svelte でも作れます。ここは好みの問題も大きいです。ただ、後述する React Flow を使いたかったので、今回は React に寄せました。
グラフ描画はReact Flow
今回の UI では、単なるテーブルではなく、状態を持つノードグラフを描きたいと考えていました。
候補はいくつかあります。
| ライブラリ | 位置づけ | 印象 |
|---|---|---|
| React Flow | ノード UI 特化 | React でカスタムノードを書きやすい |
| Cytoscape.js | グラフ解析寄り | 高機能だが UI カスタムは少し重い |
| D3 | 低レベル | 自由度は高いが実装量が増える |
| mermaid | 静的ダイアグラム | ライブ更新には向きにくい |
| vis-network | ネットワーク図 | React との統合は工夫が必要 |
ここも少し迷いました。ノードとエッジを描くだけなら、SVG を自前で描くこともできます。D3 を使って、自分で座標計算・ドラッグ・ズームを組む案も考えました。
ただ、作りたいのはグラフ描画ライブラリではなく、実行状態を見やすくするアプリです。パン・ズーム・ノード選択・エッジ描画・カスタムノードあたりを自作し始めると、かなりの時間を UI 基盤に吸われます。
そこで React Flow を選びました。
決め手は、ノードを React コンポーネントとして書けることです。
たとえば、状態ランプ付きのノードはこんな形で表現できます。
type Status = "waiting" | "running" | "success" | "failed" | "skipped";
type StatusNodeData = {
label: string;
status: Status;
detail?: string;
};
function StatusNode({ data }: NodeProps<StatusNodeData>) {
return (
<div className={`node status-${data.status}`}>
<Handle type="target" position={Position.Left} />
<span className="lamp" />
<div className="body">
<strong>{data.label}</strong>
{data.detail && <small>{data.detail}</small>}
</div>
<Handle type="source" position={Position.Right} />
</div>
);
}
状態が変わったら data.status を更新するだけで、ノードの見た目を変えられます。
さらに React Flow には、
- パン
- ズーム
- エッジ描画
- ノード選択
- ミニマップ
- コントロール
といった、ノードグラフ UI に必要な基本機能が最初から揃っています。
「自分で SVG / Canvas を描けばできる」ものではありますが、そこを自作し始めると本題から外れます。
この判断は地味ですが大事だと考えています。
アプリとして作りたいものがノードエディタやグラフビューなら、React Flow に任せられる部分は任せた方が正しいです。
自動レイアウトはdagre を組み合わせる
React Flow はノードを描画するライブラリであって、自動レイアウトエンジンではありません。つまり、ノード座標は基本的に自分で決める必要があります。
左から右へ流れるような有向グラフなら、dagre が扱いやすいです。
import dagre from "@dagrejs/dagre";
const graph = new dagre.graphlib.Graph();
graph.setDefaultEdgeLabel(() => ({}));
graph.setGraph({
rankdir: "LR",
nodesep: 32,
ranksep: 96,
});
for (const node of nodes) {
graph.setNode(node.id, { width: 180, height: 72 });
}
for (const edge of edges) {
graph.setEdge(edge.source, edge.target);
}
dagre.layout(graph);
rankdir: "LR" にすると、左から右へ流れるレイアウトになります。
より複雑なレイアウトが必要なら elkjs も候補です。ただ、まずはシンプルなレイヤードレイアウトで十分だったので、同期的に扱いやすい dagre を選びました。
状態管理はZustand
状態管理は Zustand にしました。
理由は、実行イベントが高頻度で流れてくるためです。
最初は Context でも足りるかと思いましたが、実行イベントでノード状態やログがどんどん変わるので、全体の再レンダリングが広がりそうでした。Redux でももちろん作れますが、この規模では少し大げさに感じます。
Zustand なら、
- 必要な状態だけを購読しやすい
- Store の記述量が少ない
- React Flow のサンプルでもよく使われている
- イベントをまとめて反映する実装とも相性が良い
というメリットがあります。
実行ログやノード状態の更新は、イベントが来るたびに即 setState するのではなく、短い間隔でバッチ反映する方が画面が安定します。たとえば 100ms ごとに溜まったイベントをまとめて反映する、といった形です。
永続化はSQLite
ローカルアプリで履歴や設定を保存するなら、SQLite はかなり扱いやすい選択肢です。ファイル 1 つで済むので、ユーザーに別途 DB を用意してもらう必要もありません。
今回は SQLite ドライバとして、pure Go 実装の modernc.org/sqlite を候補にしました。
| ドライバ | 実装 | 長所 | 短所 |
|---|---|---|---|
| modernc.org/sqlite | pure Go | CGO 不要で配布しやすい | C 実装より遅い場合がある |
| mattn/go-sqlite3 | CGO | 実績豊富・高速 | C コンパイラが必要 |
デスクトップアプリとして配布することを考えると、CGO まわりのビルド環境に依存しないのは大きいです。
保存するのが設定や実行履歴であれば、極端な性能は必要ありません。クロスプラットフォーム配布の楽さを優先して、pure Go のドライバを選ぶ判断は十分ありだと思います。
全体構成
最終的には、次のような構成に寄せるつもりです。
desktop-app/
├── app.go # Wails から公開する API
├── main.go # Wails エントリポイント
├── internal/
│ ├── runner/ # ローカルコマンド実行
│ ├── parser/ # 標準出力やイベントの解析
│ ├── graph/ # 実行状態からノード/エッジを生成
│ └── store/ # SQLite 保存
└── frontend/
└── src/
├── components/ # React Flow のノードやパネル
├── stores/ # Zustand store
└── api/ # Wails バインディングのラッパー
Go 側は「ローカル処理・実行・永続化」に寄せ、React 側は「表示・操作・状態反映」に寄せる分担です。
この分け方にすると、デスクトップアプリですが、フロントエンドは普通の React アプリに近い感覚で作れます。
まとめ
Go でローカル向けのデスクトップアプリを作るなら、今回の選定はかなり素直でした。特に「裏側は Go、表側は React」という分け方にできるのが気に入っています。
- バックエンドを Go で書きたい → Wails
- UI は Web 技術で作りたい → React + TypeScript
- 状態付きノードグラフを描きたい → React Flow
- 自動レイアウトが欲しい → dagre
- 高頻度イベントを扱いたい → Zustand
- ローカル履歴を保存したい → SQLite
- 配布を楽にしたい → pure Go SQLite ドライバ
Wails は「Go の処理をそのまま GUI 化したい」ときにかなり相性が良かったです。
また、React Flow を組み合わせると、実行状態や処理フローをノードグラフとして見せる UI も思った以上に作りやすいです。
まだ実装中の部分もありますが、技術選定としてはこの構成で進めています。
完成したら、実際に作ったアプリの話も別記事にまとめる予定です。