Wails 実践講座 #2 SQLite ローカルデータベース — CGO なしの純粋な Go で

読了 5分

#1 でノートアプリをバインディング・サービス・リポジトリの三層に分けました。この記事はそのうちリポジトリ層を SQLite で実装し、アプリを閉じてもノートが残るようにします。ファイル一つに収まる SQLite はローカルデスクトップアプリのストレージとして理想的ですが、Wails では一つの落とし穴を先に越える必要があります。CGO です。

全 10 編(2 部構成)のうち 2 番目の記事です。

  • #1 実践プロジェクトの設計 — 何を作り、どう分けるか
  • #2 SQLite ローカルデータベース — CGO なしの純粋な Go で ← この記事
  • #3 全文検索とデータフロー — FTS5 とイベント駆動の更新
  • #4 トレイ常駐とグローバルショートカット — バックグラウンドから素早くキャプチャ
  • #5 署名と公証 — デプロイしたアプリが信頼される仕組み
  • #6 CI/CD 自動リリース — GitHub Actions で 3 プラットフォームへ配信

落とし穴から: CGO がクロスコンパイルを妨げる #

Go で SQLite を使うとき最も広く知られたドライバーは mattn/go-sqlite3 です。ところがこのドライバーは C で書かれた SQLite をリンクするので、CGO が有効になる必要があります。CGO が有効になるとビルドに C コンパイラが入り込み、その瞬間に Go の強みである手軽なクロスコンパイルが崩れます。macOS から Windows 用のバイナリを作るには Windows 用の C ツールチェーンが必要になる、といった具合です。この問題は #6 の CI 配信で 3 プラットフォームのバイナリを作るときに正面からぶつかります。

解法は純粋な Go で書かれた SQLite ドライバー modernc.org/sqlite です。SQLite を Go に移した実装なので C 依存がなく、CGO なしでコンパイルされます。性能は CGO 版よりわずかに低いですが、ローカルノートアプリの規模では差が体感されず、クロスコンパイルがそのまま動くという利点のほうがはるかに大きいです。

ドライバーのインポート — アンダースコアで登録だけ
import (
	"database/sql"

	_ "modernc.org/sqlite" // ドライバー名は "sqlite"
)

インポートに付いたアンダースコア(_)は、パッケージを使わずに初期化だけを行うという意味です。これで database/sql"sqlite" というドライバーが登録されます。mattn 版のドライバー名が "sqlite3" なのと異なるので、混同しないよう注意します。

リポジトリ層: 開くとスキーマ #

リポジトリは DB ファイルを開いてスキーマを準備することから始めます。ファイルの場所は 入門 #5 で扱った os.UserConfigDir の下に取り、インストールパスの権限問題を避けます。

repository.go — リポジトリを開く
type NoteRepository struct {
	db *sql.DB
}

func OpenRepository(appName string) (*NoteRepository, 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
	}

	db, err := sql.Open("sqlite", filepath.Join(dir, "notes.db"))
	if err != nil {
		return nil, err
	}
	repo := &NoteRepository{db: db}
	if err := repo.migrate(); err != nil {
		return nil, err
	}
	return repo, nil
}

マイグレーション: バージョンでスキーマを管理 #

スキーマを CREATE TABLE IF NOT EXISTS だけで作ると最初のバージョンは動きますが、後でカラムを追加するときに困ります。実践ではスキーマのバージョンを DB 自身に記録し、バージョンに応じて段階的に上げます。SQLite の PRAGMA user_version がこの用途にぴったりです。

migrate — user_version で段階を管理
func (r *NoteRepository) migrate() error {
	var version int
	if err := r.db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil {
		return err
	}

	if version < 1 {
		_, err := r.db.Exec(`
			CREATE TABLE notes (
				id         INTEGER PRIMARY KEY AUTOINCREMENT,
				title      TEXT NOT NULL,
				body       TEXT NOT NULL,
				created_at DATETIME NOT NULL,
				updated_at DATETIME NOT NULL
			);
			PRAGMA user_version = 1;
		`)
		if err != nil {
			return err
		}
	}
	// 以降のバージョンはここに if version < 2 { ... } で続ける
	return nil
}

この構造のおかげで、#3 で全文検索用のテーブルを追加するとき、既存ユーザーの DB も自動で次のバージョンに上がります。新規インストールでも既存インストールでも、同じコードが正しいスキーマに到達します。

CRUD: リポジトリが SQL を担う #

リポジトリには純粋に DB アクセスだけを置きます。検証のようなルールはサービス層の担当です。

repository.go — 作成と取得
func (r *NoteRepository) Insert(n Note) (Note, error) {
	res, err := r.db.Exec(
		`INSERT INTO notes (title, body, created_at, updated_at) VALUES (?, ?, ?, ?)`,
		n.Title, n.Body, n.CreatedAt, n.UpdatedAt,
	)
	if err != nil {
		return Note{}, err
	}
	n.ID, _ = res.LastInsertId()
	return n, nil
}

func (r *NoteRepository) List() ([]Note, error) {
	rows, err := r.db.Query(
		`SELECT id, title, body, created_at, updated_at FROM notes ORDER BY updated_at DESC`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	notes := []Note{} // nil ではない空スライス — フロントエンドで [] として届く
	for rows.Next() {
		var n Note
		if err := rows.Scan(&n.ID, &n.Title, &n.Body, &n.CreatedAt, &n.UpdatedAt); err != nil {
			return nil, err
		}
		notes = append(notes, n)
	}
	return notes, rows.Err()
}

値を SQL に入れるときは常に ? プレースホルダで渡します。文字列を直接つなぐと SQL インジェクションのリスクが生まれますが、ローカルアプリでもノート本文にどんな文字が入るか分からないので習慣を守ります。空のリストを nil ではなく []Note{} で返すのも重要です。nil スライスは JSON で null になり、フロントエンドの配列の反復を壊すからです。

サービス層: 検証はここで #

サービスはリポジトリを包んでドメインのルールをかけます。時刻の値もここで埋めます。

service.go — 作成時の検証
func (s *NoteService) Create(title, body string) (Note, error) {
	title = strings.TrimSpace(title)
	if title == "" {
		return Note{}, errors.New("タイトルを入力してください")
	}
	now := time.Now()
	return s.repo.Insert(Note{
		Title:     title,
		Body:      body,
		CreatedAt: now,
		UpdatedAt: now,
	})
}

ここで返した error入門 #5 で扱ったとおり、フロントエンドで拒否された Promise になります。タイトルが空なら画面に「タイトルを入力してください」がそのまま表示されます。

まとめ #

  • Wails で SQLite を使うとき、mattn/go-sqlite3 は CGO を要求してクロスコンパイルを妨げます。純粋な Go ドライバー modernc.org/sqlite(ドライバー名 "sqlite")でこの問題をなくします。
  • DB ファイルは os.UserConfigDir 配下のアプリディレクトリに置き、インストールパスの権限問題を避けます。
  • スキーマは PRAGMA user_version でバージョンを付けて段階的にマイグレーションします。新規インストールと既存インストールが同じコードで最新のスキーマに到達します。
  • リポジトリには純粋な DB アクセスだけを、検証のようなルールはサービス層に置きます。値は常に ? プレースホルダで渡し、空のリストは []Note{} で返します。
  • 次の記事ではこのリポジトリの上に FTS5 の全文検索を載せ、データの変更をイベントでフロントエンドに知らせます。
X