Wails 実践講座 #3 全文検索とデータフロー — FTS5 とイベント駆動の更新
#2 でノートを SQLite に保存しました。ノートが数百件に増えると LIKE '%検索語%' では遅くなり、精度も落ちます。この記事は SQLite に内蔵された全文検索エンジン FTS5 で高速な検索を付け、データが変わったときにフロントエンドをどう更新するかデータフローを整理します。
全 10 編(2 部構成)のうち 3 番目の記事です。
- #1 実践プロジェクトの設計
- #2 SQLite ローカルデータベース
- #3 全文検索とデータフロー — FTS5 とイベント駆動の更新 ← この記事
- #4 トレイ常駐とグローバルショートカット — バックグラウンドから素早くキャプチャ
- #5 署名と公証 — デプロイしたアプリが信頼される仕組み
- #6 CI/CD 自動リリース — GitHub Actions で 3 プラットフォームへ配信
なぜ LIKE ではなく FTS5 か #
LIKE '%単語%' の検索は毎回すべての行の本文を最初からなめます。ノートが少ないときはよいですが、増えると線形に遅くなり、単語単位のマッチングや複数語の組み合わせなども自分で作る必要があります。FTS5 は SQLite に内蔵された全文検索の拡張で、本文をトークンに分割してインデックスを前もって作っておくので検索が速く、単語ベースのクエリをそのまま支援します。#2 で使った純粋な Go ドライバー modernc.org/sqlite も FTS5 を含むので、別途の設定なしですぐ使えます。
マイグレーション: FTS5 テーブルと同期トリガー #
#2 の user_version マイグレーションに次の段階を続けます。FTS5 仮想テーブルを作り、元の notes テーブルの変更が検索インデックスに自動で反映されるようにトリガーをかけます。
-- 検索インデックス用の仮想テーブル (title, body のみ索引)
CREATE VIRTUAL TABLE notes_fts USING fts5(
title, body, content='notes', content_rowid='id'
);
-- notes が変わればインデックスも追従するようトリガーで結び付ける
CREATE TRIGGER notes_ai AFTER INSERT ON notes BEGIN
INSERT INTO notes_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
END;
CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, title, body) VALUES ('delete', old.id, old.title, old.body);
END;
CREATE TRIGGER notes_au AFTER UPDATE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, title, body) VALUES ('delete', old.id, old.title, old.body);
INSERT INTO notes_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
END;核心は content='notes' の設定です。こうすると FTS5 が本文を重複して保存せず、元のテーブルを参照します(外部コンテンツ方式)。三つのトリガー(挿入・削除・更新)が元とインデックスを常に同じ状態に保つので、リポジトリの CRUD コードはインデックスを気にする必要がありません。INSERT ... VALUES ('delete', ...) は FTS5 でインデックス項目を消す慣用の構文です。
検索をサービスとして公開 #
リポジトリに検索メソッドを追加します。FTS5 テーブルを元と結合し、マッチしたノートを新しい順で返します。
func (r *NoteRepository) Search(query string) ([]Note, error) {
rows, err := r.db.Query(`
SELECT n.id, n.title, n.body, n.created_at, n.updated_at
FROM notes_fts f
JOIN notes n ON n.id = f.rowid
WHERE notes_fts MATCH ?
ORDER BY n.updated_at DESC
`, query)
if err != nil {
return nil, err
}
defer rows.Close()
return scanNotes(rows) // #2 のスキャンロジックを関数に抽出して再利用
}サービス層では空の検索語をふるい落として全体一覧に流し、ユーザー入力を FTS5 クエリに安全に整えます。
func (s *NoteService) Search(query string) ([]Note, error) {
query = strings.TrimSpace(query)
if query == "" {
return s.repo.List() // 検索語がなければ全体一覧
}
// 入力した単語で前方一致検索: "会議" → "会議"* で "会議録" もマッチ
return s.repo.Search(query + "*")
}検索語が空なら検索ではなく全体一覧を返すこの分岐一つが、フロントエンドで「検索ボックスを消すと元の一覧に戻る」という自然な動作を作ります。
データフロー: イベントでフロントエンドを更新 #
#1 で決めた原則、つまりデータの真実は Go に置くを、検索と編集で守る方法がデータフローの設計です。ノートを新規に作ったり消したりすると一覧が変わりますが、このとき二つの道があります。
- フロントエンドが自分で一覧を直す: 作ったばかりのノートを画面の配列に直接押し込みます。速いですが、Go の実際の状態と画面がずれるリスクがあります。
- Go が変わったと知らせ、フロントエンドは問い合わせ直す: 変更後に 入門 #3 で扱った Wails イベントを発行し、フロントエンドはその信号を受けて一覧を再読み込みします。
実践では二つ目が安全です。画面は常に Go の最新の状態を反映し、後で #4 でトレイやグローバルショートカットからアプリの外でノートが追加されても、同じイベント 1 行ですべてのウィンドウが更新されます。
func (a *App) CreateNote(title, body string) (Note, error) {
note, err := a.notes.Create(title, body)
if err != nil {
return Note{}, err
}
runtime.EventsEmit(a.ctx, "notes:changed") // 一覧が変わったことを知らせる
return note, nil
}import { EventsOn } from "../wailsjs/runtime/runtime";
import { Search } from "../wailsjs/go/main/App";
EventsOn("notes:changed", async () => {
notes = await Search(currentQuery); // 現在の検索語で問い合わせ直す
});素早い入力感が重要なところ(たとえばエディタの保存表示)は、画面を先に楽観的に変え、失敗時にイベントで戻す折衷も可能です。ただし一覧・検索結果のように整合性が重要なデータは、Go に問い合わせ直すほうを基本にします。
まとめ #
LIKEの検索はノートが増えると線形に遅くなります。SQLite 内蔵の FTS5 で前もって作ったインデックスを使い、高速な単語ベースの検索を付けます。純粋な Go ドライバーも FTS5 を含みます。- FTS5 は
content='notes'の外部コンテンツ方式で本文の重複を避け、挿入・削除・更新のトリガーで元とインデックスを自動同期します。CRUD コードはインデックスを知らなくてもよいです。 - サービス層で空の検索語を全体一覧に分岐し、前方一致検索(
単語*)で部分マッチを支援します。 - データフローは「Go が変わったとイベントで知らせ、フロントエンドが問い合わせ直す」を基本にします。真実が Go にあるので、アプリの外でデータが変わってもイベント 1 行ですべてのウィンドウが更新されます。
- 次の記事では、このイベント更新の上にトレイ常駐とグローバルショートカットを載せ、アプリの外でノートをキャプチャします。