GoとSQLiteで画像をBLOBとして保存・取得するCLIアプリ構築ハンズオン
はじめに
本記事では、 最新のGo(執筆時点ではGo 1.24) と、最も人気のあるSQLiteドライバである github.com/mattn/go-sqlite3
を使用して、JPEGおよびPNG画像をSQLiteデータベースにBLOB型で保存・取得するシンプルなCLIアプリケーションを構築してみます。Go言語でデータベースにバイナリデータを保存する方法や、画像ファイルの読み書き、エラーハンドリングの実装方法について、ハンズオン形式で詳しく解説します。読者はGoにある程度慣れていることを前提とし、コードの可読性を重視して丁寧にコメントを付けながら説明していきます。
なお、 SQLiteドライバのmattn/go-sqlite3
はC言語へのバインディングを含むため、利用する環境に Cコンパイラ(例えばgcc) がインストールされている必要があります。また、本記事で扱うコードは学習目的のシンプルな例であり、実際のアプリケーションでは画像をDBに保存する是非やエラーチェックの強化など検討すべき事項がありますが、ここではSQLiteのBLOB機能とGoによる実装手法に焦点を当てます。
プロジェクト構成
まずはプロジェクトの構成を確認します。今回はシンプルに main.goのみ で実装を行います(Go Modulesを使用)。以下のようなディレクトリ構成になります。
image-cli/
├── go.mod
└── main.go
- go.mod: Goモジュールの定義ファイルです。SQLiteドライバなどの依存関係を管理します。
go mod init
コマンドで作成できます。 - main.go: アプリケーションのエントリーポイントで、CLIの処理、SQLiteへの接続、画像の保存・取得ロジックを実装します。
まずプロジェクト用のディレクトリを作成し、Goモジュールを初期化しておきましょう。
$ mkdir image-cli
$ cd image-cli
$ go mod init example.com/image-cli # 任意のモジュール名を指定
これで go.mod
が作成されます。続いて、コードを書いていきます。
SQLiteデータベースとテーブル定義
SQLiteを使用するには、Goのデータベースインターフェース database/sql
パッケージと、SQLiteドライバである github.com/mattn/go-sqlite3
パッケージをインポートします。 go-sqlite3
はデータベースドライバとして database/sql
に登録するために、利用時には ブランクインポート (import _ "github.com/mattn/go-sqlite3") を行います。このブランクインポートにより、明示的に使わなくても init()
関数でドライバが登録され、 sql.Open()
で利用可能になります。 今回のアプリでは、SQLiteのデータベースファイル(例として images.db
)を使用します。テーブルを1つ作成し、画像データを格納するカラムをBLOB型で定義します。テーブル名を images
、カラムは自動採番IDと画像データとしましょう。SQLでのテーブル定義は次のようになります。
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
data BLOB
);
- id: 自動増分の整数ID(主キー)。
- data: 画像のバイナリデータを格納するBLOB型カラム。
それでは、コード上でこのデータベースとテーブルを準備する部分を見てみます。
package main
import (
"database/sql"
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
_ "github.com/mattn/go-sqlite3"
)
func main() {
args := os.Args
if len(args) < 2 {
printUsage()
return
}
// サブコマンド (save または get) を取得
cmd := args[1]
switch cmd {
case "save":
// 引数チェック: save コマンドは画像ファイrパスが必要
if len(args) < 3 {
printUsage()
return
}
imagePath := args[2]
// --- SQLite データベースを開く (存在しなければ作成される) ---
db, err := sql.Open("sqlite3", "images.db")
if err != nil {
log.Fatal("データベースを開けませんでした", err)
}
defer db.Close()
// テーブルがなければ作成
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
data BLOB
);
`)
if err != nil {
log.Fatal("テーブル作成に失敗しました: ", err)
}
// 画像を保存する処理を実行
err = saveImage(db, imagePath)
if err != nil {
log.Fatal("保存に失敗しました: ", err)
}
case "get":
// 引数チェック: get コマンドは ID と出力ファイルパスが必要
if len(args) != 4 {
printUsage()
return
}
idStr := args[2]
outputPath := args[3]
// 文字列の ID を整数に変換
id, err := strconv.Atoi(idStr)
if err != nil {
log.Fatal("ID は数値で指定してください")
}
// --- SQLite データベースを開く ---
db, err := sql.Open("sqlite3", "images.db")
if err != nil {
log.Fatal("データベースを開けませんでした: ", err)
}
defer db.Close()
// テーブルがなければ (存在しない場合に備えて) 作成
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
data BLOB
);
`)
if err != nil {
log.Fatal("テーブル作成に失敗しました: ", err)
}
// 画像を取得する処理を実行
err = getImage(db, id, outputPath)
if err != nil {
log.Fatal("取得に失敗しました: ", err)
}
default:
// サポートされないコマンドの場合
printUsage()
return
}
}
// ユーザーへの使い方を表示する関数
func printUsage() {
fmt.Println("Usage:")
fmt.Println(" go run main.go save <画像ファイルパス>")
fmt.Println(" go run main.go get <ID> <出力ファイルパス>")
}
上記のコードでは、 main()
関数内でコマンドライン引数を解析し、 save
または get
のサブコマンドに応じて処理を分岐しています。共通の処理として:
-
sql.Open("sqlite3", "images.db")
でSQLiteデータベースファイルimages.db
を開きます(ファイルが無ければ新規作成されます)。 -
defer db.Close()
で関数終了時にDBをクローズするようにしています。 -
db.Exec("CREATE TABLE IF NOT EXISTS ...")
でテーブルimages
が存在しなければ作成します。IF NOT EXISTS
を付けているので、テーブルがすでにある場合は何も起きません。
printUsage()
関数は、コマンドの使い方を出力します。引数の数が正しくない場合や未知のコマンドの場合に呼ばれ、正しい使用方法をユーザーに案内します。
それでは、具体的な 画像保存処理と画像取得処理の実装 を見ていきましょう。
save
コマンド)
画像保存機能の実装 ( save
コマンドでは、指定されたパスの画像ファイルを読み込み、SQLiteデータベースのBLOBカラムに保存します。対応するコードは上記の saveImage
関数として実装します。コードとともに処理の流れを説明します。
// 画像ファイルをデータベースに保存する関数
func saveImage(db *sql.DB, imagePath string) error {
// 対応可能な画像拡張子かチェック (JPEG/jpg または PNG)
ext := strings.ToLower(filepath.Ext(imagePath))
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" {
return fmt.Errorf("対応していないファイル形式です: %s", ext)
}
// ファイルを開いて中身を読み込む
data, err := os.ReadFile(imagePath)
if err != nil {
return fmt.Errorf("ファイルを読み込めません: %w", err)
}
// SQLite に BLOB データを INSERT する
result, err := db.Exec("INSERT INTO images(data) VALUES (?)", data)
if err != nil {
return fmt.Errorf("データベースへの INSERT に失敗しました: %w", err)
}
// 挿入したレコードの ID を取得
newID, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("INSERT した ID の取得に失敗しました: %w", err)
}
fmt.Printf("画像を保存しました。新しいID: %d\n", newID)
return nil
}
実装解説:
-
filepath.Ext(imagePath)
でファイルの拡張子を取得し、小文字化して、.jpg
/.jpeg
/.png
のいずれかであることを確認しています。該当しない場合はエラーを返し、許可された画像形式(JPEG/PNG)のみ処理するようにしています(簡易的な形式チェックですが、必要に応じてファイルの中身のマジックナンバーを確認する方法でも良いでしょう)。 -
os.ReadFile(imagePath)
を使って画像ファイルを読み込みます。この関数はGo 1.16から追加された便利関数で、指定したファイルの内容をすべて[]byte
として返します。画像データ全体がメモリ上に読み込まれますが、今回は対象ファイルサイズも大きくない想定でシンプルにこの方法を使っています。読み込みに失敗した場合(ファイルが存在しない、権限が無い等)はエラーを返します。 -
db.Exec("INSERT INTO images(data) VALUES(?)", data)
でプレースホルダ?を使い、画像のバイナリデータをBLOBカラムに挿入します。database/sql
では、[]byte
型のデータは自動的にBLOBとして扱われます。Exec
の戻り値result
からLastInsertId()
を呼び出すことで、SQLiteが発行した自動ID(AUTOINCREMENTで生成されたID)を取得しています。SQLiteドライバではこの機能がサポートされているので、新しく保存されたレコードのIDを知ることができます。 - 最後に、新規保存された画像のIDを表示し、処理が成功したことをユーザーにフィードバックしています。
エラーハンドリングとして、各ステップで問題が発生した場合には error
を返すようにしており、呼び出し元(main関数側)で log.Fatal
によってエラーメッセージを表示してプログラムを終了する流れになっています。 fmt.Errorf
に %w
を用いることで元のエラーをラップし、エラー原因を詳細に伝える工夫もしています。
get
コマンド)
画像取得機能の実装 (続いて、 get
コマンドではデータベースから指定IDの画像BLOBを取り出し、ファイルに書き出します。こちらは getImage
関数で実装します。
// データベースから画像を取得してファイルに書き出す関数
func getImage(db *sql.DB, id int, outputPath string) error {
// クエリを実行して指定IDの画像データを取得
row := db.QueryRow("SELECT data FROM images WHERE id = ?", id)
var imageData []byte
err := row.Scan(&imageData)
if err != nil {
if err == sql.ErrNoRows {
// 指定した ID のレコードが見つからない場合
return fmt.Errorf("指定されたID(%d)の画像は存在しません", id)
}
return fmt.Errorf("データの取得に失敗しました: %w", err)
}
// バイナリデータをファイルに書き出す
err = os.WriteFile(outputPath, imageData, 0644)
if err != nil {
return fmt.Errorf("出力ファイルに書き込めませんでした: %w", err)
}
fmt.Printf("ID %d の画像を %s に保存しました\n", id, outputPath)
return nil
}
実装解説:
-
db.QueryRow("SELECT data FROM images WHERE id = ?", id)
で指定IDに対応する画像のBLOBデータを取得します。QueryRow
は結果が必ず1行以下である場合に使えるメソッドで、存在しないIDを指定した場合は後続のScan
でsql.ErrNoRows
エラーとなります。 -
row.Scan(&imageData)
で取得したBLOBデータを[]byte
型の変数imageData
に読み込みます。SQLiteドライバはBLOB列を[]byte
として扱うため、Scan
の引数に[]byte
のポインタを渡すことで画像のバイナリをそのまま取得できます。もし該当IDの行がなかった場合はsql.ErrNoRows
となるので、これをチェックして「指定されたIDの画像は存在しません」というエラーを返しています。その他のエラー(データベース接続エラー等)の場合も考慮し、一般的なエラーとして返します。 - 正常に
imageData
が取得できたら、os.WriteFile(outputPath, imageData, 0644)
で指定パスにファイルを書き出します。0644
は作成されるファイルのパーミッション(ユーザー読み書き、その他読み取り可)です。書き込みに失敗した場合(例えばディレクトリが存在しない、権限が無い等)はエラーを返します。 - 書き出しが成功したら、標準出力に保存先のパスを含めたメッセージを表示します。
この関数でも各段階で適切にエラーをチェックし、問題があればエラーメッセージを error
として組み立てて上位に返しています。呼び出し元のmain関数ではこのエラーを受け取り、 log.Fatal
で表示してプログラムを終了するため、一連の処理の中で最初に発生したエラーがユーザーに伝えられます。
エラーハンドリングとファイルフォーマットのチェック
上記のコードでは随所にエラーハンドリングとファイル形式チェックを実装しています。このセクションでポイントを整理してみます。
-
ファイル形式(フォーマット)のチェック: JPEGまたはPNG以外のファイルは保存しないように、
saveImage
関数内で拡張子を確認しています。strings.ToLower
とfilepath.Ext
を組み合わせ、拡張子が「.jpg」または「.jpeg」または「.png」でなければエラーを返します。これは 簡易的なチェック ですが、ユーザーの入力ミス(例えばテキストファイルを誤って指定など)に気づけるようにしています。必要に応じて、ファイルの先頭数バイトを検査してJPEG/PNGのシグネチャを確認する方法や、画像デコードを試みる方法もありますが、シンプルさを優先して拡張子チェックとしました。 -
エラーハンドリングの方針: Goではエラーは値として返されるため、各処理ごとに
if err != nil { ... }
でチェックを行っています。本プログラムでは、エラーが起きたらすぐにreturn fmt.Errorf(...)
で上位にエラーを返し、最終的にmain関数でキャッチしてlog.Fatal
でログを出力しプログラムを終了するようにしています。これにより、想定外の状況に遭遇しても静かに失敗するのではなく、ユーザーに原因を示して終了する動作になります。また、fmt.Errorf
で"%w"
を使い元のエラーを巻き取っているので、デバッグ時にはエラーチェーンから根本原因も追跡しやすくなっています。 -
主なエラーシナリオ:
- 指定された画像ファイルパスが存在しない/読み込めない場合(
os.ReadFile
のエラー) - 対応外のファイル拡張子だった場合(自前のチェックによるエラー)
- データベースへの接続やINSERTに失敗した場合
-
get
で指定IDが見つからない場合(sql.ErrNoRows
) - 出力先にファイルを書き込めない場合(os.WriteFileのエラー)
それぞれに対し、適切なメッセージでerrorを返し、ユーザーには日本語メッセージまたはGoのエラーメッセージ(log.Fatalはerror.Error()を出力)で状況がわかるようになっています。
- 指定された画像ファイルパスが存在しない/読み込めない場合(
-
注意: SQLiteデータベースファイルへのアクセスに問題があった場合(ファイル書き込み権限がないディレクトリに置いている等)や、
mattn/go-sqlite3
ドライバを使用する際にビルド環境(CGO)の問題があった場合など、本コード以外の要因でエラーが発生することもあります。それらも含め、log.Fatal
で捕捉されればメッセージが表示されます。もしエラー時にプログラムを終了させずハンドリングしたい場合は、log.Fatal
の代わりにエラーを整形してfmt.Println
で表示し、適切にos.Exit(1)
で終了コードを返す、といった実装にすることも可能です。
実行例と動作確認
それでは、実際にこのCLIアプリケーションを動かして、画像の保存・取得が正しく行えることを確認してみましょう。以下ではカレントディレクトリにある cat.jpg
というJPEG画像ファイルを例にします。
-
画像の保存: saveコマンドを使って画像をデータベースに保存します。
$ go run main.go save ./cat.jpg 画像を保存しました。新しいID: 1
コマンドを実行すると、プログラムは
cat.jpg
を読み込んでデータベースに格納し、新規に割り当てられたIDを表示します。上記の例では1
というIDが出力されました。このIDはテーブル内で自動採番されたIDです。
実行後、同じディレクトリにSQLiteのデータベースファイルimages.db
が作成されているはずです。sqlite3コマンドラインツールやSQLiteブラウザをお持ちであれば、中を覗いてimages
テーブルにレコードが追加されていることを確認できます(無理に確認しなくても次の手順で検証可能です)。 -
画像の取得:
get
コマンドでデータベースから画像を取り出し、ファイルに保存します。先ほど保存時に確認したID(ここでは1番)を指定し、出力先のファイル名を指定します。$ go run main.go get 1 ./cat_out.jpg ID 1 の画像データを./cat_out.jpgとして出力しました
このコマンドにより、ID=1の画像データがデータベースから取り出され、cat_out.jpgというファイル名で出力されました。メッセージが表示されたら、同じディレクトリにcat_out.jpgというファイルが生成されているはずです。
-
保存・出力結果の確認: 最後に、出力された
cat_out.jpg
が元のcat.jpg
と同じ内容であることを確認してみましょう。方法はいくつかありますが、簡単なのはファイルサイズや画像ビューアでの確認です。
-
コマンドラインでファイルサイズを比較:
$ ls -l cat* -rwxrwxrwx 1 user user 338386 Apr 6 21:14 cat.png -rw-r--r-- 1 user user 338386 Apr 6 21:18 cat_out.png
上記のように、元画像と出力画像のサイズが同じであることがわかります(ここではどちらも338386バイト)。サイズが一致していれば、データが損なわれず保存・復元できた可能性が高いです。
-
画像を開いて目視で比較:
cat_out.png
を実際に画像ビューアで開いてみて、元のcat.png
と見た目が同一であることを確認します。もし問題なく表示でき、内容も元画像と差異がなければ、SQLiteへの保存〜取得が正しく行われたことになります。
万が一、 get
コマンドで「指定されたIDの画像は存在しません」というエラーが出た場合は、指定したIDに誤りがないか確認してください。 save
コマンドで出力されたIDをそのまま使う必要があります。また、 save
コマンドを複数回実行するとIDは増加していきますので、2回目に保存した画像はID=2、というように管理されます。
おわりに
本記事では、Go言語とSQLiteを使って画像ファイル(JPEG/PNG)をデータベースにBLOB型で保存し、再度取り出すCLIアプリケーションを構築する手順を解説しました。プロジェクト構成の準備から、SQLiteテーブルの定義、画像データの読み書き、エラーハンドリング、そして実行結果の確認まで、一通りの流れをハンズオン形式で紹介しました。
この小さなアプリケーションによって、 Goの標準パッケージだけでファイル操作とデータベース操作を連携 させる方法がお分かりいただけたと思います。 mattn/go-sqlite3
ドライバを利用することで、特別なSQLiteの知識がなくとも database/sql
経由でシンプルにBLOBデータを扱えることが確認できました。
Discussion