Goの公式チュートリアルをやってみる【リレーショナルデータベース】

前回

Goの公式チュートリアルをやってみる【ジェネリクス】

はじめに

前回はジェネリクス関数の使い方について学んでいきました。今回は標準ライブラリのdatabase/sqlパッケージを使用してリレーショナルデータベースにアクセスする基本的な処理を学んでいきます。

tabase/sqlパッケージにはデータベースへの接続、トランザクションの実行、実行中の操作のキャンセルなどの型と変数が含まれています。

今回はデータベースを作成して、そのデータベースにアクセスするコードを記述していきます。サンプルプロジェクトはビンテージジャズのレコードに関するデータのリポジトリです。

モジュールの作成

作業フォルダにdata-accessフォルダを作成して、コマンドプロンプトでdata-accessディレクトリに移動し、go mod init example/data-accessをコマンドでモジュールを作成しましょう。

$ go mod init example/data-access
go: creating new go.mod: module example/data-access

データベースのセットアップ

この記事ではDBMSのCLIを使用してデータベース、テーブルを作成していきます。チュートリアルではMySQLを使用していますが、今回はPostgreSQLを使用しています。ほかのデータベースでも基本独自のCLIが用意されています。

データベースの作成

まずはPostgreSQLにログインします。
$ psql -U ユーザー名

以下のコマンドを実行してデータベースを作成していきます。
$ postgres=# create database recordings;

テーブルの追加

SQLファイルの作成

テキストエディタでdata-accessフォルダにcreate-tables.sqlというファイルを作成して以下のコードを追加します。

/data-access/create-tables.sql
DROP TABLE IF EXISTS album;
CREATE TABLE album (
  id         INT AUTO_INCREMENT NOT NULL,
  title      VARCHAR(128) NOT NULL,
  artist     VARCHAR(255) NOT NULL,
  price      DECIMAL(5,2) NOT NULL,
  PRIMARY KEY (`id`)
);

INSERT INTO album
  (title, artist, price)
VALUES
  ('Blue Train', 'John Coltrane', 56.99),
  ('Giant Steps', 'John Coltrane', 63.99),
  ('Jeru', 'Gerry Mulligan', 17.99),
  ('Sarah Vaughan', 'Sarah Vaughan', 34.98);

このコードはこんなことをしています。

  • albumというテーブルを削除。
    (後でテーブルをやり直したい場合の時のため)
  • ID・タイトル・アーティスト・価格の列を持つalbumテーブルを作成。IDの値はDBMSによって自動作成される。
  • 4行のデータを追加

SQLファイルの実行

まずは作成したデータベースに接続いたします。
$ postgres=# \c recordings;

その後、作成したSQLファイルを実行します。
$ postgres=# \i /create-tables.sql;

recordings=# \i ./create-tables.sql
psql:create-tables.sql:1: NOTICE:  テーブル"album"は存在しません、スキップします
DROP TABLE
CREATE TABLE
INSERT 0 4

正常に追加されたかを確認してみましょう。
$ postgres=# SELECT * FROM album;

以下のテーブルが表示されたら作成完了です。

recordings=# SELECT * FROM album;
 id |     title     |     artist     | price
----+---------------+----------------+-------
  1 | Blue Train    | John Coltrane  | 56.99
  2 | Giant Steps   | John Coltrane  | 63.99
  3 | Jeru          | Gerry Mulligan | 17.99
  4 | Sarah Vaughan | Sarah Vaughan  | 34.98
(4 行)

データベースドライバーの検索とインポート

先ほどはデータベースを作成していくつかのデータを用意しました。続いてdatabase/sqlパッケージの関数を使用したリクエストを、データベースが理解できるリクエストに変換してくれるデータベースドライバーを探してインポートします。

まずはブラウザでSQLDrivers wikiページにアクセスし、使用できるドライバを特定します。チュートリアルではMySQLを使用しているためGo-MySQL-Driverを使用していますが、今回はPostgreSQLを使用しているためpgxを使用いたします。
pgxのパッケージ名はgithub.com/jackc/pgx/v5ですが、database/sqlと一緒に使う場合はgithub.com/jackc/pgx/v5/stdlibであることに注意してください。

data-accessディレクトリでmain.goファイルを作成し、以下のコードを追加します。

/data-access/main.go
package main

import "github.com/jackc/pgx/v5/stdlib"

データベースハンドルの取得と接続

パッケージのインポートができたところで、データベースハンドルを使用してデータベースにアクセスるGoコードを書いてみます。特定のデータベースへのアクセスを表す、sql.DB構造体へのポインターを使用します。

以下のコードをmain.goに追加しましょう。

/data-access/main.go
var db *sql.DB

func main() {
    // 接続プロパティの宣言
    user := os.Getenv("DBUSER")
    passwd := os.Getenv("DBPASS")
    host := "127.0.0.1"
    port := "5432"
    dbName := "recordings"

    // 接続プロパティの代入を空白区切りで結合
    keyValue := "user=" + user + " " +
        "password=" + passwd + " " +
        "host=" + host + " " +
        "port=" + port + " " +
        "database=" + dbName

    // データベースハンドルの取得
    var err error
    db, err = sql.Open("pgx", keyValue)
    if err != nil {
        log.Fatal(err)
    }

    // データベースへの接続を閉じる
    defer db.Close()

    // 接続確認
    pingErr := db.Ping()
    if pingErr != nil {
        log.Fatal(pingErr)
    }
    fmt.Println("Connected!")
}

このコードはこんなことをしています。

  • *sql.DB型のグローバル変数dbを宣言する。
    この例では単純化するためにグローバル変数としていますが、実運用では関数に渡すか構造体でラップする等、グローバル変数を使わないことが望ましいです。
  • 接続プロパティを収集してフォーマットする。
  • sql.Openに接続プロパティを渡して対象のデータベースに接続する。
  • sql.Openでエラーが発生していないかをチェックする。
    (今回はエラーの場合log.Fatalを呼び出して実行を終了させています)
  • 関数のすべての処理が終了したらDB.Closeを必ずするように、Openの直後に記述する。
  • DB.Pingを呼び出してデータベースへの接続がうまくいっていることを確認します。ドライバによってはsql.Openがすぐに接続しないかもしれません。database.sqlパッケージがっ必要な時に接続できることを確認するために、ここでPingを使用しています。
  • 接続に失敗した場合はエラーをだし、成功した場合はメッセージを表示する。

これらの処理を実行するためにはインポートするパッケージがないので、以下のパッケージを追加でインポートします。

/data-access/main.go
import (
    "database/sql"
    "fmt"
    "log"
    "os"

    _ "github.com/jackc/pgx/v5/stdlib"
)

よく見るとgithub.com/jackc/pgx/v5/stdlibのインポートの書き方だけが異なっています。これは「blank import」と呼ばれます。ブランクインポートはパッケージ内のすべての宣言を無視してパッケージの初期化だけを行います。パッケージにinit関数がある場合にinit関数のみが実行されます。

ユーザー名・パスワードを環境変数に登録

main.goを保存したらgo mod tidyを行い、依存関係を整理しましょう。その後コマンドプロンプトで環境変数を設定します。<username><password>にはデータベースに登録したユーザー名とパスワードを入力してください。

$ set DBUSER=<username>
$ set DBPASS=<password>

実行確認

環境変数への登録が完了したら、コマンドプロンプトでdata-accessディレクトリに移動しgo run .を実行しましょう。以下の内容が出力されたらアクセス成功です。

$ go run .
Connected!

複数行のクエリ

先ほどテーブルへの接続まで成功しました。続いて、複数行を返すように設計されたSQLクエリを実装して実行します。

まずはmain.gomain関数のすぐ上にalbum構造体の定義を追加します。この構造体を使用してクエリから返された行のデータを保持します。

/data-access/main.go
type Album struct {
    ID     int64
    Title  string
    Artist string
    Price  float32
}

続いてmain.goの最後にデータベースに問い合わせる、albumByArtist関数を追加します。以下のコードを追加しましょう。

/data-access/main.go
// 指定したアーティスト名を持つアルバムを検索します。
func albumByArtist(name string) ([]Album, error) {
    // 返された行のデータを保持するスライス。
    var albums []Album

    rows, err := db.Query("SELECT * FROM album WHERE artist = $1", name)
    if err != nil {
        return nil, fmt.Errorf("albumsByArtist %q: %v", name, err)
    }
    defer rows.Close()

    // 行をループし、スキャンを使用して列データを構造体フィールドに割り当てます。
    for rows.Next() {
        var alb Album
        if err := rows.Scan(&alb.ID, &alb.Title, &alb.Artist, &alb.Price); err != nil {
            return nil, fmt.Errorf("albumsByArtist %q: %v", name, err)
        }
        albums = append(albums, alb)
    }

    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("albumsByArtist %q: %v", name, err)
    }

    return albums, nil
}

このコードはこんなことをしています。

  • 返された行のデータを保持するためのAlbum型のスライスを宣言する。
  • DB.Queryを使用してSELECT文を実行し、指定したアーティスト名のアルバムを検索する。
    DB.Queryの第一引数にはSQL文、第二引数以降には任意の型のパラメーターを渡すことができます。
  • すべての処理が完了したら保持しているリソースを開放するためにdeferClose関数を延期する。
  • 取得した行データをループして、各列の値を構造体に割り当てる。Scan関数は列の値が書き込まれます。ここでは&演算子を使用してalb変数のフィールドへのポインタを渡しています。Scan関数はポインターを通して書き込みを行います。
  • Scanでエラーが発生した場合はエラーを返します。
  • エラーがなければAlbum構造体のデータをスライスに追加します。
  • ループが完了したら、クエリ全体のエラーをチェックします。クエリ自体が失敗した場合はここでエラーをチェックするのが唯一の方法ですので、忘れずにチェックしましょう。

main関数を更新して、albumByArtistを実行するように変更しましょう。main関数の最後に以下のコードを追加します。

/data-access/main.go
func main() {
    // ...
    albums, err := albumsByArtist("John Coltrane")
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("Albums found: %v\n", albums)
}

コードを追加したらgo run .で実行確認してみましょう。以下の内容が出力されたらデータ取得の成功です。

$ go run .
Connected!
Albums found: [{1 Blue Train John Coltrane 56.99} {2 Giant Steps John Coltrane 63.99}]

一行のクエリ

先ほどは複数の行を取得しました。一つだけ取得することが分かっている場合はQueryRow関数を使用することでさらに簡単にデータ取得することができます。

IDから行を取得する関数を追加してみましょう。以下のコードをmain.goの最後に追加してください。

/data-access/main.go
// 指定されたIDのアルバムを検索する
func albumByID(id int64) (Album, error) {
    // 取得した行のデータを保持するアルバム.
    var alb Album

    row := db.QueryRow("SELECT * FROM album WHERE id = $1", id)
    if err := row.Scan(&alb.ID, &alb.Title, &alb.Artist, &alb.Price); err != nil {
        if err == sql.ErrNoRows {
            return alb, fmt.Errorf("albumsById %d: no such album", id)
        }
        return alb, fmt.Errorf("albumsById %d: %v", id, err)
    }
    return alb, nil
}

このコードはこんなことをしています。

  • DB.QueryRowを使用してSELECT文を実行し、指定されたIDのアルバムを検索する。
    DB.QueryRowsqlRowを返しますがエラーを返しません。その代わりにRow.Scanで発生したエラーを後で返すようにします。
  • Row.Scanを使用して列の値を構造体フィールドにコピーする。
  • Row.Scanで発生したエラーの確認。

続いてmain関数にalbumByID関数を呼ぶ処理を追加します。main関数の最後に以下の処理を追加してください。

/data-access/main.go
func main() {
    // ...

    // クエリーをテストするために、ID 2をハードコードする。
    alb, err := albumByID(2)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Album found: %v\n", alb)
}

コードを追加したらgo run .を実行して確認してみましょう。以下の内容が出力されたら一行のデータ取得成功です。

$ go run .
Connected!
Albums found: [{1 Blue Train John Coltrane 56.99} {2 Giant Steps John Coltrane 63.99}]     
Album found: {2 Giant Steps John Coltrane 63.99}

データの追加

ここまで一行・複数行のデータ取得の方法を学びました。続いて、INSERT文を実行し、データベースに新しい行を追加する方法を学んでいきます。

main.goの最後に以下のコードを追加しましょう。

/data-access/main.go
// 指定されたアルバムをデータベースに追加し、新しいエントリーのアルバムIDを返す。
func addAlbum(alb Album) error {
    _, err := db.Exec("INSERT INTO album (title, artist, price) VALUES ($1, $2, $3)", alb.Title, alb.Artist, alb.Price)
    if err != nil {
        return fmt.Errorf("addAlbum: %v", err)
    }
    // id, err = result.LastInsertId()
    // if err != nil {
    //     return fmt.Errorf("addAlbum: %v", err)
    // }
    return nil
}

このコードはこんなことをしています。

  • DB.Execを使用してINSERT文を実行する。
    DB.Queryと同じように第二引数以降でパラメータを入力することができる。
  • INSERT文実行によるエラーが発生しているかををチェックする。
  • Result.LastInsertIdを使用して挿入されたデータベース行のIDを取得する。
    (pgxは上記関数に対応していないためコメントアウトしています。)
  • ID取得によるエラーが発生しているかをチェックする。

続いてmain関数にaddAlbum関数を呼ぶ処理を追加していきます。以下のコードをmain関数の最後に追加しましょう。

/data-access/main.go
func main() {
    // ...

    addData := Album{
        Title:  "The Modern Sound of Betty Carter",
        Artist: "Betty Carter",
        Price:  49.99,
    }
    err = addAlbum(addData)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Added album: %v\n", addData)
}

コードを追加したらgo run . を実行して確認してみます。以下の出力が出力されたら成功です。pgxはID取得に対応していないため、album構造体の初期値である0が出力されます。

$ go run .
Connected!
Albums found: [{1 Blue Train John Coltrane 56.99} {2 Giant Steps John Coltrane 63.99}]     
Album found: {2 Giant Steps John Coltrane 63.99}
Added album: {0 The Modern Sound of Betty Carter Betty Carter 49.99}

まとめ

  • database/sqlはデータベースアクセス用の標準パッケージ。
    • データベースを操作するためのインターフェースを提供する。
    • dbDriverが実際にデータベースへの操作を行う。
  • github.com/jackc/pgxはPostgreSQLドライバー。
    • インポートするパッケージ名はgithub.com/jackc/pgx/v5
    • database/sqlと併用する場合はgithub.com/jackc/pgx/v5/stdlibをインポートする。
  • sql.Openでデータベースハンドルを取得する
  • DB.Closeでデータベースへの接続を閉じる
  • DB.Pingで接続確認を行う
  • DB.QueryRowSELECT文を実行し一つの行を取得できる
  • DB.QuerySELECT文を実行し複数行を取得できる
  • Row.Scanに構造体等のポインターを渡すことで取得した行の各データを格納することができる
  • DB.ExecINSERT文を実行すると行をデータベースに追加することができる
    • Result.LastInsertIdで追加した行のIDを取得できる。
  • ドライバーによってはサポートしていない関数がある。(今回はLastInsertId

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です