サーバーサイドGo入門 #2 ~database/sqlでDBを扱う~

前回はこちら prelude.hatenablog.jp

今回作るもの

今回は、インメモリに作成したデータをDBに永続化します。 MySQLをDockerで動かしGoから接続、前回作成したTODOを格納してWebサーバのレスポンスとして扱うところまで書きます。

Dockerで動かす

コンテナを立てる

Dockerイメージを取得してください。Dockerの慣れている方はお好みの手順でセットアップしてもらえればと思います。

$ docker pull mysql

MySQLのイメージを実行します。

$ docker run --rm --name mysql -e MYSQL_ROOT_PASSWORD=mysql -e MYSQL_DATABASE=todo -p 3306:3306 mysql

MySQLの中に入る

実行中のコンテナの一覧を取得します。

$ docker container ls

コンテナのIDを用いてコンテナ内のシェルを立ち上げます。

$ docker exec -it ${↑で確認したmysqlのコンテナID} bash
root@container_id:/# mysql -p
Enter password: ここに先ほどMYSQL_ROOT_PASSWORDに渡した値を入力(今回はmysql)
mysql> SHOW DATABASES;

最後にデータベースの一覧を表示して、MYSQL_DATABASEに設定したtodoが存在することを確認してください。

GoからDBを扱う

さっそく接続してみましょう。

// main.go

package main

import (
    "database/sql"
    "encoding/json"
    "fmt"
    "net/http"
    "time"

    _ "github.com/go-sql-driver/mysql"
)

// 前回書いたコード
// ...
// ...

func main() {
    db, err := sql.Open("mysql", "root:mysql@tcp(127.0.0.1:3306)/todo?parseTime=true")
    if err != nil {
        panic(err)
    }
    defer db.Close()
    if err := db.Ping(); err != nil {
        panic(err)
    }
    fmt.Printf("%+v\n", db)

    // 前回書いたコード
    // ...
    // ...
}

実行し標準出力にDBの情報が出力されれば疎通に成功しています。

ではコードについて補足をします。
まず、importにてgithub.com/go-sql-driver/mysql_という記述があります。import時にはpackage名の前に別名をつけることができます。その別名に_を用いるとpackage名を破棄します。そのため、packageにアクセスすることはできなくなります(厳密には識別子がないためアクセスする方法がなくなります)。 _blank identifierと呼びます。

Effective Go - The Go Programming Language

次に、initについても説明する必要があります。initはimportされた際に実行される特別な関数です。

Effective Go - The Go Programming Language

つまり、blank identifierを用いたimportはinitのみを行うということです。

では、go-sql-driver/mysqlがinitで何をしているのか読んでみましょう。

func init() {
    sql.Register("mysql", &MySQLDriver{})
}

mysql/driver.go at master · go-sql-driver/mysql · GitHub

database/sqlRegisterを呼び出し、引数にMySQLDriverを渡しています。Registerの第2引数の型はdatabase/sql/driverDriver interfaceです。このinterfaceは何ができるのか見てみましょう。

type Driver interface {
    Open(name string) (Conn, error)
}

go/driver.go at 59bfc18e3441d9cd0b1b2f302935403bbf52ac8b · golang/go · GitHub

ここまで読むとdatabase/sqlOpenが呼ばれるとMySQLDriverを使ったMySQLの接続情報をConn型として返却していそうなことがわかります。では最後にmain.goで読んでいるsql.Openを読んでみましょう。

func Open(driverName, dataSourceName string) (*DB, error) {
    driversMu.RLock()
    driveri, ok := drivers[driverName]
    driversMu.RUnlock()
    if !ok {
        return nil, fmt.Errorf("sql: unknown driver %q (forgotten import?)", driverName)
    }

    if driverCtx, ok := driveri.(driver.DriverContext); ok {
        connector, err := driverCtx.OpenConnector(dataSourceName)
        if err != nil {
            return nil, err
        }
        return OpenDB(connector), nil
    }

    return OpenDB(dsnConnector{dsn: dataSourceName, driver: driveri}), nil
}

go/sql.go at master · golang/go · GitHub

このOpenDBを読んでいくと設定したDriverを使ってコネクションを作成していることがわかると思います。

見てきたようにblank importは設定を隠蔽する時に使っているものをよく見かけます。blank importinitも使い方によっては非常に便利ですが、濫用するとコードから副作用を追うことが難しくなるため注意が必要です。

SQLを渡す

ではTODOObject用のテーブルを作成します。TODOObjectをそのままカラムにしているだけです。

if _, err := db.Exec("CREATE TABLE IF NOT EXISTS todos (id INTEGER AUTO_INCREMENT, author VARCHAR(32), name VARCHAR(32), description VARCHAR(64), due_date DATETIME, created_at DATETIME, PRIMARY KEY (id))"); err != nil {
    panic(err)
}
object := TODOObject{
    MetaData: MetaData{
        ID:        1,
        CreatedAt: time.Now(),
    },
    TODO: TODO{
        Author:      "author",
        Name:        "name",
        Description: "description",
        DueDate:     time.Now(),
    },
}
if _, err := db.Exec("INSERT INTO todos (author, name, description, due_date, created_at) VALUES (?, ?, ?, ?, ?)", object.Author, object.Name, object.Description, object.DueDate.Format("2006-01-02 15:03:04"), object.CreatedAt.Format("2006-01-02 15:03:04")); err != nil {
    panic(err)
}

MySQLにて実際にテーブルが作成されたことを確認しましょう。

mysql> SHOW CREATE TABLE todos;
mysql> SELECT * FROM todos;

見てわかる通り、database/sqlSQLを渡すだけです。

DBを読み込む

カラムに対応したフィールドのポインタを渡すとそこに書き込んでくれます。

var todo TODOObject
if err := db.QueryRow("SELECT * FROM todos WHERE id=?", 1).Scan(&todo.ID, &todo.Author, &todo.Name, &todo.Description, &todo.DueDate, &todo.CreatedAt); err != nil {
    panic(err)
}

Queryから始まる関数はRowもしくはRowsを返します。いま使っているQueryRowはクエリにマッチした1行を返却するためRowを返します。
登録した全てのTODOを取得したい場合には下記のように書きます。

rows, err := db.Query("SELECT * FROM todos")
if err != nil {
    panic(err)
}
defer rows.Close()

todos := make([]TODOObject, 0)
for rows.Next() {
    var todo TODOObject
    if err := db.QueryRow("SELECT * FROM todos WHERE id=?", 1).Scan(&todo.ID, &todo.Author, &todo.Name, &todo.Description, &todo.DueDate, &todo.CreatedAt); err != nil {
        panic(err)
    }
    todos = append(todos, todo)
}
fmt.Printf("%+v\n", todos)

他の関数についてはドキュメントを参照してください。

sql - The Go Programming Language

TODOをインメモリからDBへ

さて、DBの読み書きがわかったので、http経由でTODOをDBを扱ってみましょう。

// main.go

package main

import (
    "database/sql"
    "encoding/json"
    "net/http"
    "time"

    _ "github.com/go-sql-driver/mysql"
)

var DB *sql.DB

type MetaData struct {
    ID        int       `json:"id"`
    CreatedAt time.Time `json:"createdAt"`
}

type TODO struct {
    Author      string    `json:"author"`
    Name        string    `json:"name"`
    Description string    `json:"description"`
    DueDate     time.Time `json:"dueDate"`
}

type TODOObject struct {
    MetaData
    TODO
}

func list(response http.ResponseWriter, request *http.Request) {
    rows, err := DB.Query("SELECT * FROM todos")
    if err != nil {
        panic(err)
    }
    defer rows.Close()

    todos := make([]TODOObject, 0)
    for rows.Next() {
        var todo TODOObject
        if err := rows.Scan(&todo.ID, &todo.Author, &todo.Name, &todo.Description, &todo.DueDate, &todo.CreatedAt); err != nil {
            panic(err)
        }
        todos = append(todos, todo)
    }

    _ = json.NewEncoder(response).Encode(todos)
}

func add(response http.ResponseWriter, request *http.Request) {
    encoder := json.NewEncoder(response)
    if request.Body == http.NoBody {
        _ = encoder.Encode("request body is nil")
        return
    }

    var todo TODO
    if err := json.NewDecoder(request.Body).Decode(&todo); err != nil {
        _ = encoder.Encode(err.Error())
        return
    }

    object := TODOObject{
        TODO: todo,
        MetaData: MetaData{
            CreatedAt: time.Now(),
        },
    }
    result, err := DB.Exec("INSERT INTO todos (author, name, description, due_date, created_at) VALUES (?, ?, ?, ?, ?)", object.Author, object.Name, object.Description, object.DueDate.Format("2006-01-02 15:03:04"), object.CreatedAt.Format("2006-01-02 15:03:04"))
    if err != nil {
        panic(err)
    }

    lastInsertID, err := result.LastInsertId()
    if err != nil {
        panic(err)
    }
    var responseData TODOObject
    if err := DB.QueryRow("SELECT * FROM todos WHERE id=?", lastInsertID).Scan(&responseData.ID, &responseData.Author, &responseData.Name, &responseData.Description, &responseData.DueDate, &responseData.CreatedAt); err != nil {
        panic(err)
    }

    _ = encoder.Encode(responseData)
}

func main() {
    var err error
    DB, err = sql.Open("mysql", "root:mysql@tcp(127.0.0.1:3306)/todo?parseTime=true")
    if err != nil {
        panic(err)
    }
    defer DB.Close()
    if err := DB.Ping(); err != nil {
        panic(err)
    }

    if _, err := DB.Exec("CREATE TABLE IF NOT EXISTS todos (id INTEGER AUTO_INCREMENT, author VARCHAR(32), name VARCHAR(32), description VARCHAR(64), due_date DATETIME, created_at DATETIME, PRIMARY KEY (id))"); err != nil {
        panic(err)
    }

    http.HandleFunc("/todo/list", list)
    http.HandleFunc("/todo/add", add)
    _ = http.ListenAndServe(":8080", nil)
}

curlのコマンドとレスポンスのJSONも全く同じなため、省略します。(前回を参照してください。)

まとめ

database/sqlSQLをそのまま渡せるため、そこまでわからないところは多くなかったのではないでしょうか?単にライブラリのどの関数を呼べばよいのかの紹介程度になっているかと思います。
次回はこれら標準ライブラリではなくプロダクションで使われることの多いライブラリに書き換えていこうと思います。

雑談

こないだ初めて検便をした。