サーバーサイド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/sql
のRegister
を呼び出し、引数にMySQLDriver
を渡しています。Register
の第2引数の型はdatabase/sql/driver
のDriver
interfaceです。このinterfaceは何ができるのか見てみましょう。
type Driver interface { Open(name string) (Conn, error) }
go/driver.go at 59bfc18e3441d9cd0b1b2f302935403bbf52ac8b · golang/go · GitHub
ここまで読むとdatabase/sql
でOpen
が呼ばれると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 import
もinit
も使い方によっては非常に便利ですが、濫用するとコードから副作用を追うことが難しくなるため注意が必要です。
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/sqlはSQLを渡すだけです。
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/sql
はSQLをそのまま渡せるため、そこまでわからないところは多くなかったのではないでしょうか?単にライブラリのどの関数を呼べばよいのかの紹介程度になっているかと思います。
次回はこれら標準ライブラリではなくプロダクションで使われることの多いライブラリに書き換えていこうと思います。
雑談
こないだ初めて検便をした。