なぜ「動かして覚える!」なのか
自分はどんな技術でも初めて勉強する時、文字を読んだり説明だけされても正直理解できず、実際に自分で書いてみて動きを見てから出ないとなかなか理解しにくいと感じました。
なのでこの記事は、自分のような「文字や説明だけじゃ分からん!」って人向けに実際にコードを書いて動かすことをメインに説明してみました。
データベースはよく使われているMySQLに接続してデータを操作していきます。
Dockerなどのコンテナを利用するか、GoとMySQLが同じPCにインストールされていれば同じように出来るので、良かったらこの記事を見ながらご自身の環境で試してみてください!
この記事の一番最後に丁寧に書かれているMySQLのインストール方法の記事があったので載せておきます。
この記事の対象者
- Goの勉強を始めた人
- 文字や説明だけでは理解しにくいと感じる人
- GoでDBをいじってみたい人
- 基本的なSQLを理解している人
実行環境
- M1 Macbook Air
- macOS Monterey 12.4
- Go 1.18
- MySQL 8.0.28
SQLにおけるCRUD
CRUD | SQL |
---|---|
Create | INSERT |
Read | SELECT |
Update | UPDATE |
Delete | DELETE |
事前準備
Goディレクトリ
testDBという名前のプロジェクトを作成し、その中にこのようなファイルを用意します。
.
├── go.mod
├── main.go
└── models
├── config.go
├── delete.go
├── insert.go
├── select.go
└── update.go
modelsディレクトリの中のconfig.goにデータベースの接続について書いていきます。
その他のファイルにはデータベース操作について書いていきます。
まずは、コマンドでこの2つのパッケージをインストールします。
- github.com/go-sql-driver/mysql
- github.com/joho/godotenv
1つ目は、GoでMySQLを使用するためのパッケージです。
2つ目は、.envファイルをGoで読み込むためのパッケージです。(こちらは後で説明します。)
testDB % go get github.com/go-sql-driver/mysql
go: added github.com/go-sql-driver/mysql v1.6.0
testDB % go get github.com/joho/godotenv
go: added github.com/joho/godotenv v1.4.0
↓のようなgo.sumファイルが追加されていればOK
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
使用するデータベースとテーブル情報
「test_db」を作成し、その中にIDと名前と年齢の情報があるシンプルなusersテーブルを用意しました。
あらかじめにデータを5つ入れてあります。
mysql> create database test_db;
Query OK, 1 row affected (0.01 sec)
mysql> use test_db;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A
Database changed
mysql> create table users3 (
-> id int not null primary key auto_increment,
-> name varchar(20) not null,
-> age int not null
-> );
Query OK, 0 rows affected (0.01 sec)
mysql> desc users;
+-------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+-------+--------------+------+-----+---------+----------------+
| id | int | NO | PRI | NULL | auto_increment |
| name | varchar(100) | NO | | NULL | |
| age | int | NO | | NULL | |
+-------+--------------+------+-----+---------+----------------+
3 rows in set (0.01 sec)
mysql> insert into users (name, age) values ('satou', 21),('tanaka', 18),('suzuki', 25),('ito', 30),('satou', 12);
Query OK, 5 rows affected (0.01 sec)
Records: 5 Duplicates: 0 Warnings: 0
mysql> select * from users;
+----+--------+-----+
| id | name | age |
+----+--------+-----+
| 1 | satou | 21 |
| 2 | tanaka | 18 |
| 3 | suzuki | 25 |
| 4 | ito | 30 |
| 5 | satou | 12 |
+----+--------+-----+
実際にやってみよう
準備が完了したので、実際に書いていきましょう。
GoからMySQLに接続する
環境変数を設定する
まずtestDBディレクトリに「.env」ファイルを作成します。
touch .env
.envとは、環境変数をリポジトリにコミットされなかったり、環境ごとにアカウントが違ったりコマンドが違った時に管理出来るファイルのこと。
今回でいうと、MySQLに接続時に使うアカウント名やパスワードを.envファイルで管理して他の人に見られないようにするのに使います。
では、.envファイルに記述しましょう。
DB_USER=root # MySQLのアカウント名
DB_PASS=password # MySQLのパスワード
DB_ADDRESS=localhost:3306 # MySQLのアドレス名
DB_NAME=test_db # 接続するデータベース名
MySQLに接続する
ではmodelsディレクトリのconfig.goに記述していきましょう。
package models
import (
"database/sql"
"fmt"
"github.com/go-sql-driver/mysql"
"github.com/joho/godotenv"
"os"
)
// DBに接続するための構造体
type Server struct {
DB *sql.DB
}
func DBConnect() (*sql.DB, error) {
var err error
// .envファイルを読み込む
err = godotenv.Load()
if err != nil {
fmt.Println("Error open .env file")
return nil, err
}
// 環境変数を変数に格納する
dbUser := os.Getenv("DB_USER")
dbPass := os.Getenv("DB_PASS")
dbAddr := os.Getenv("DB_ADDRESS")
dbName := os.Getenv("DB_NAME")
// 接続プロパティをキャプチャする
cfg := mysql.Config{
User: dbUser,
Passwd: dbPass,
Net: "tcp",
Addr: dbAddr,
DBName: dbName,
}
// データベースを開く
db, err := sql.Open("mysql", cfg.FormatDSN())
if err != nil {
fmt.Println("DB open Error")
return nil, err
}
// 接続が有効であるか確認する
pingErr := db.Ping()
if pingErr != nil {
fmt.Println("pingErr")
return nil, err
}
fmt.Println("接続成功!!")
return db, nil
}
main.goで「DBConnect()」メソッドを呼び出して接続確認してみましょう。
package main
import (
"testDB/models"
"log"
)
func main() {
// データベースに接続する
db, err := models.DBConnect()
if err != nil {
log.Fatal(err)
}
// 最後に接続を閉じる
defer db.Close()
}
testDB % go run main.go
接続成功!!
このように出力されていれば接続成功しています。
MySQLからデータを操作する
接続が成功したので、次はデータベースからデータを取得したり、新しいデータを入れたりなど、色々操作していきます。
modelsディレクトリのそれぞれのgoファイルに書いていきます。
データの取得
- 全てのデータを取得する方法
- 条件にあったデータのみを取得する方法
- ID検索などの単一のデータを取得する方法
この3つの取得方法を紹介します。
全てのデータを取得する
usersテーブルのデータを全て取得する、「SelectUsers」メソッドをselect.goファイルに作成します。
package models
import (
"fmt"
)
// Userの構造体
type User struct {
ID int
Name string
Age int
}
func (s *Server) SelectUsers() ([]User, error) {
// 構造体Userのスライス型、users
var users []User
// SELECT文を実行する
rows, err := s.DB.Query("SELECT * FROM users")
if err != nil {
return nil, fmt.Errorf("query error: %v", err)
}
for rows.Next() {
var user User
// データベースから読み取られた列を、
//sqlパッケージで提供されている、一般的なGoの型に変換する
if err := rows.Scan(&user.ID, &user.Name, &user.Age); err != nil {
return nil, fmt.Errorf("scan the user error: %v", err)
}
// Usersに追加する
users = append(users, user)
}
// for文でエラーが発生した場合に呼び出される
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("scan users error: %v", err)
}
return users, nil
}
main.goで「SelectUsers」メソッドを呼び出して実行してみましょう。
package main
import (
"testDB/models"
"fmt"
"log"
)
func main() {
// データベースに接続する
db, err := models.DBConnect()
if err != nil {
log.Fatal(err)
}
// 最後に接続を閉じる
defer db.Close()
// Serverの構造体を初期化する
s := models.Server{DB: db}
// SelectUsersを呼び出す
users, err := s.SelectUsers()
if err != nil {
log.Fatal(err)
}
// 結果を出力する
fmt.Println(users)
}
testDB % go run main.go
接続成功!!
[{1 satou 21} {2 tanaka 18} {3 suzuki 25} {4 ito 30} {5 satou 12}]
条件にあったデータのみを取得する
usersテーブルの中の名前が一致したデータを取得する「SelectUsersByName」メソッドを作成していきます。
...省略...
func (s *Server) SelectUsersByName(name string) ([]User, error) {
// 構造体Userのスライス型、users
var users []User
// SELECT文を実行する
rows, err := s.DB.Query("SELECT * FROM users WHERE name = ?", name)
if err != nil {
return nil, fmt.Errorf("query error: %v", err)
}
for rows.Next() {
var user User
// データベースから読み取られた列を、
//sqlパッケージで提供されている、一般的なGoの型に変換する
if err := rows.Scan(&user.ID, &user.Name, &user.Age); err != nil {
return nil, fmt.Errorf("scan user error: %v", err)
}
// Usersに追加する
users = append(users, user)
}
// for文でエラーが発生した場合に呼び出される
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("scan users error: %v", err)
}
return users, nil
}
main.goで「SelectUsersByName」メソッドを呼び出して実行してみましょう。
...省略
func main() {
// データベースに接続する
db, err := models.DBConnect()
if err != nil {
log.Fatal(err)
}
// 最後に接続を閉じる
defer db.Close()
// Serverの構造体を初期化する
var s = models.Server{DB: db}
name := "satou"
// SelectUsersByNameを呼び出す
user, err := s.SelectUsersByName(name)
if err != nil {
log.Fatal(err)
}
fmt.Println(user)
}
testDB % go run main.go
接続成功!!
[{1 satou 21} {5 satou 12}]
単一のデータを取得する
usersテーブルの中のIDが一致したデータを取得する「SelectUserByID」メソッドを作成していきます。
...省略...
func (s *Server) SelectUserByID(id int) (User, error) {
var user User
// SELECT文を実行する
row := s.DB.QueryRow("SELECT * FROM users WHERE id = ?", id)
// データベースから読み取られた列を、
//sqlパッケージで提供されている、一般的なGoの型に変換する
err := row.Scan(&user.ID, &user.Name, &user.Age)
if err != nil {
return User{}, fmt.Errorf("scan user error: %v", err)
}
return user, nil
}
main.goで「SelectUserByID」メソッドを呼び出して実行してみましょう。
...省略...
func main() {
// データベースに接続する
db, err := models.DBConnect()
if err != nil {
log.Fatal(err)
}
// 最後に接続を閉じる
defer db.Close()
// Serverの構造体を初期化する
var s = models.Server{DB: db}
// SelectUserByIDを呼び出す
user, err := s.SelectUserByID(3)
if err != nil {
log.Fatal(err)
}
fmt.Println(user)
}
testDB % go run main.go
接続成功!!
{3 suzuki 25}
データを挿入する
- 1つのデータを挿入する
- 複数のデータを挿入する
この2つを紹介します。
1つのデータを挿入する
usersテーブルに新しいデータを入れる、「InsertUser」メソッドをinsert.goに作成します。
package models
import "fmt"
func (s *Server) InsertUser(name string, age int) error {
// トランザクション開始
tx, err := s.DB.Begin()
if err != nil {
return fmt.Errorf("transaction start error: %v", err)
}
// トランザクション中にエラーが発生した場合に確実にロールバックする
defer tx.Rollback()
// INSERT文を実行する
if _, err = tx.Exec("INSERT INTO users (name, age) VALUES (?, ?)", name, age); err != nil {
return fmt.Errorf("insert user error: %v", err)
}
// 成功したらコミットする
return tx.Commit()
}
main.goで「InsertUser」メソッドを呼び出して実行してみましょう。
...省略...
func main() {
// データベースに接続する
db, err := models.DBConnect()
if err != nil {
log.Fatal(err)
}
// 最後に接続を閉じる
defer db.Close()
// Serverの構造体を初期化する
var s = models.Server{DB: db}
name := "nishida"
age := 40
// InsertUserを呼び出す
err = s.InsertUser(name, age)
if err != nil {
log.Fatal(err)
}
}
testDB % go run main.go
接続成功!!
何もエラーが返ってこなかったので成功したみたいですね。
MySQLか、先ほど作成した、「SelectUsers」メソッドを利用して確認してみましょう。
mysql> select * from users;
+----+---------+-----+
| id | name | age |
+----+---------+-----+
| 1 | satou | 21 |
| 2 | tanaka | 18 |
| 3 | suzuki | 25 |
| 4 | ito | 30 |
| 5 | satou | 12 |
| 6 | nishida | 40 |
+----+---------+-----+
こんな感じに「nishida」が追加されていればOK。
複数のデータを挿入する
複数のデータを挿入する、「InsertUsers」メソッドをinsert.goに作成します。
...省略...
func (s *Server) InsertUsers(users []User) error {
// トランザクション開始
tx, err := s.DB.Begin()
if err != nil {
return fmt.Errorf("transaction start error: %v", err)
}
// トランザクション中にエラーが発生した場合に確実にロールバックする
defer tx.Rollback()
// プリペアードステートメントを作成
stmt, err := tx.Prepare("INSERT INTO users (name, age) VALUES (?, ?)")
if err != nil {
return fmt.Errorf("prepared statement error: %v", err)
}
// 最後にプリペアードステートメントを閉じる
defer stmt.Close()
// userを一人一人Insertする
for _, user := range users {
// INSERT文を実行する
if _, err := stmt.Exec(user.Name, user.Age); err != nil {
return fmt.Errorf("insert user error: %v", err)
}
}
// 成功したらコミットする
return tx.Commit()
}
main.goで「InsertUsers」メソッドを呼び出して実行してみましょう。
...省略...
func main() {
// データベースに接続する
db, err := models.DBConnect()
if err != nil {
log.Fatal(err)
}
// 最後に接続を閉じる
defer db.Close()
// Serverの構造体を初期化する
var s = models.Server{DB: db}
// スライス型のusersを作成し、
// 挿入したいデータを格納する
users := []models.User{
{Name: "Nakagawa", Age: 19},
{Name: "Hashimoto", Age: 34},
{Name: "Nakamura", Age: 22},
{Name: "Yokoyama", Age: 56},
}
// InsertUsersを呼び出す
err = s.InsertUsers(users)
if err != nil {
log.Fatal(err)
}
}
testDB % go run main.go
接続成功!!
こちらも何もエラーが返ってこなかったので成功したみたいですね。
MySQLか、先ほど作成した、「SelectUsers」メソッドを利用して確認してみましょう。
mysql> select * from users;
+----+-----------+-----+
| id | name | age |
+----+-----------+-----+
| 1 | satou | 21 |
| 2 | tanaka | 18 |
| 3 | suzuki | 25 |
| 4 | ito | 30 |
| 5 | satou | 12 |
| 6 | nishida | 40 |
| 7 | Nakagawa | 19 |
| 8 | Hashimoto | 34 |
| 9 | Nakamura | 22 |
| 10 | Yokoyama | 56 |
+----+-----------+-----+
こんな感じに新しく4人追加されていればOK。
データを更新する
データを更新する、「UpdateUser」メソッドをupdate.goに作成します。
InsertUserメソッドで作成した方法とほとんど一緒です。
package models
import "fmt"
func (s *Server) UpdateUser(id int, name string) error {
// トランザクション開始
tx, err := s.DB.Begin()
if err != nil {
return fmt.Errorf("transaction start error: %v", err)
}
// トランザクション中にエラーが発生した場合に確実にロールバックする
defer tx.Rollback()
// UPDATE文を実行する
if _, err = tx.Exec("UPDATE users SET name = ? WHERE id = ?", name, id); err != nil {
return fmt.Errorf("delete user error: %v", err)
}
// 成功したらコミットする
return tx.Commit()
}
main.goで「UpdateUser」メソッドを呼び出して実行してみましょう。
...省略...
func main() {
// データベースに接続する
db, err := models.DBConnect()
if err != nil {
log.Fatal(err)
}
// 最後に接続を閉じる
defer db.Close()
// Serverの構造体を初期化する
var s = models.Server{DB: db}
name := "Mike"
// UpdateUserを呼び出す
err = s.UpdateUser(1, name)
if err != nil {
log.Fatal(err)
}
}
testDB % go run main.go
接続成功!!
エラーが返ってこなかったので成功したみたいですね。
MySQLか、先ほど作成した、「SelectUserByID」メソッドを利用して確認してみましょう。
mysql> select * from users where id = 1;
+----+------+-----+
| id | name | age |
+----+------+-----+
| 1 | Mike | 21 |
+----+------+-----+
id「1」のデータが「satou」から「Mike」に変わっていればOK。
データを削除する
データを削除する、「DeleteUser」メソッドをdelete.goに作成します。
こちらもInsertUserメソッドで作成した方法とほとんど一緒です。
package models
import "fmt"
func (s *Server) DeleteUser(id int) error {
// トランザクション開始
tx, err := s.DB.Begin()
if err != nil {
return fmt.Errorf("transaction start error: %v", err)
}
// トランザクション中にエラーが発生した場合に確実にロールバックする
defer tx.Rollback()
// DELETE文を実行する
if _, err = tx.Exec("DELETE FROM users WHERE id = ?", id); err != nil {
return fmt.Errorf("delete user error: %v", err)
}
// 成功したらコミットする
return tx.Commit()
}
main.goで「DeleteUser」メソッドを呼び出して実行してみましょう。
...省略...
func main() {
// データベースに接続する
db, err := models.DBConnect()
if err != nil {
log.Fatal(err)
}
// 最後に接続を閉じる
defer db.Close()
// Serverの構造体を初期化する
var s = models.Server{DB: db}
// DeleteUserを呼び出す
err = s.DeleteUser(1)
if err != nil {
log.Fatal(err)
}
}
testDB % go run main.go
接続成功!!
エラーが返ってこなかったので成功したみたいですね。
MySQLか、先ほど作成した、「SelectUsers」メソッドを利用して確認してみましょう。
mysql> select * from users;
+----+-----------+-----+
| id | name | age |
+----+-----------+-----+
| 2 | tanaka | 18 |
| 3 | suzuki | 25 |
| 4 | ito | 30 |
| 5 | satou | 12 |
| 6 | nishida | 40 |
| 7 | Nakagawa | 19 |
| 8 | Hashimoto | 34 |
| 9 | Nakamura | 22 |
| 10 | Yokoyama | 56 |
+----+-----------+-----+
ちゃんとid「1」のデータが削除されていればOK。
SQL文の「?」について
プリペアードステートメントの部分では、一度可変部分として登場しましたね。ではプリペアードステートメントではなくても使われている理由は一体なんででしょうか?
プリペアードステートメントの時と少し被るのですが、この書き方はSQLインジェクションという脆弱性の対策の一つで、QueryメソッドやQueryRowメソッド、ExecメソッドなどのSQL文では「?」と置き、第2引数以降で受け取りたいデータ(今回で言うと「idやnameやage」)を入れることで、外部からのデータの操作を出来ないようにする一つの方法です。
SQLインジェクションを体験する
ここで実際にどのように起こるか簡単な事例を試してみましょう。
「SelectUsersByName」メソッドを悪さ出来るように書き直してみましょう。
...省略...
func (s *Server) SelectUsersByName(name string) ([]User, error) {
// 構造体Userのスライス型、users
var users []User
// SELECT文を実行する
- rows, err := s.DB.Query("SELECT * FROM users WHERE name = ?", name)
+ rows, err := s.DB.Query(fmt.Sprintf("SELECT * FROM users WHERE name = '%v'", name))
if err != nil {
return nil, fmt.Errorf("query error: %v", err)
}
for rows.Next() {
var user User
// データベースから読み取られた列を、
//sqlパッケージで提供されている、一般的なGoの型に変換する
if err := rows.Scan(&user.ID, &user.Name, &user.Age); err != nil {
return nil, fmt.Errorf("scan user error: %v", err)
}
// Usersに追加する
users = append(users, user)
}
// for文でエラーが発生した場合に呼び出される
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("scan users error: %v", err)
}
return users, nil
}
fmt.Sprintf()を使用することで、通常のSQL文を実行するようにしました。
では、main.goで「sqlInjectionName」変数を作成し、「satou' OR '1' = '1」を書いて実行してみましょう。
...省略...
func main() {
// データベースに接続する
db, err := models.DBConnect()
if err != nil {
log.Fatal(err)
}
// 最後に接続を閉じる
defer db.Close()
// Serverの構造体を初期化する
var s = models.Server{DB: db}
+ sqlInjectionName := "satou' OR '1' = '1"
// SelectUsersByNameを呼び出す
user, err := s.SelectUsersByName(sqlInjectionName)
if err != nil {
log.Fatal(err)
}
fmt.Println(user)
}
testDB % go run main.go
接続成功!!
[{2 tanaka 18} {3 suzuki 25} {4 ito 30} {5 satou 12} {6 nishida 40} {7 Nakagawa 19} {8 Hashimoto 34} {9 Nakamura 22} {10 Yokoyama 56}]
意図せずに全てのデータが取得されてしまいました。
「satou' OR '1' = '1」を実際のSQL文に置き換えると、
「SELECT * FROM users WHERE name = 'satou' OR '1' = '1'」
となります。
これは、「nameがsatouに一致するもの、または、1 = 1 は正しい。つまりTRUEである場合ものを取得する」
SQLにおいてTRUEはWHERE文を使わないのとほぼ同義です。
もしこれが、住所や電話番号などの個人情報を保持するデータだったら、大変なことになってしまいます。
なので基本的には、「fmt.Sprintf()」は使わない!
必ず「?」で書いて、第2引数以降にデータを渡す!!
お疲れ様でした
実際にやってみた方、ここまでお疲れ様でした!
これで一通り基本的なデータベースを操作することが出来ると思います!
↓実際に使用したソースコードも貼っておくので良かったら参考にしてみてください!
まとめ
- データベースに接続するための情報は、環境変数に情報を保持する。
- データベースに接続するには、Openメソッドを使い、Pingメソッドで接続確認をする。
- Selectする場合は、Queryメソッドを使う。
- Insert, Update, Deleteをする場合は、トランザクションを使用する。
- 複数実行する場合は、プリペアードステートメントを作成する。
- Goからデータを渡す時は、SQL文では「?」渡しておいて、第2引数以降にデータを渡す。←SQLインジェクション対策!!
参考にしたサイト
MySQLインストール参考記事
Windows
Mac
こちら記事たちもよかったら見てね!