Skip to content

Goでデータベースを扱う

ここからは Go でプログラムを書いてデータベースを扱っていきます。task upを実行してデータベースが立ち上がっていることを確認してください。 まずは VSCode で先ほどクローンしてきたリポジトリを開きましょう。画像のようなファイルが入っているはずです。 main.go を開いてください。

データベースに接続する

Goのプログラムを書く

サンプルのプログラムが書いてありますが、データベースと接続できるように書き換えます。 Go でデータベースに接続するためのライブラリは様々ありますが、今回は SQL 文を書く sqlx を使います。

go
package main

import (
	"database/sql"
	"errors"
	"log"
	"os"
	"time"

	"github.com/go-sql-driver/mysql"
	"github.com/jmoiron/sqlx"
)

// #region city
type City struct {
	ID          int    `json:"ID,omitempty" db:"ID"`
	Name        string `json:"name,omitempty" db:"Name"`
	CountryCode string `json:"countryCode,omitempty"  db:"CountryCode"`
	District    string `json:"district,omitempty"  db:"District"`
	Population  int    `json:"population,omitempty"  db:"Population"`
}

// #endregion city
func main() {
	jst, err := time.LoadLocation("Asia/Tokyo")
	if err != nil {
		log.Fatal(err)
	}

	conf := mysql.Config{
		User:      os.Getenv("DB_USERNAME"),
		Passwd:    os.Getenv("DB_PASSWORD"),
		Net:       "tcp",
		Addr:      os.Getenv("DB_HOSTNAME") + ":" + os.Getenv("DB_PORT"),
		DBName:    os.Getenv("DB_DATABASE"),
		ParseTime: true,
		Collation: "utf8mb4_unicode_ci",
		Loc:       jst,
	}

	db, err := sqlx.Open("mysql", conf.FormatDSN())

	if err != nil {
		log.Fatal(err)
	}

	log.Println("connected")
	// #region get
	var city City
	err = db.Get(&city, "SELECT * FROM city WHERE Name = ?", "Tokyo")
	if errors.Is(err, sql.ErrNoRows) {
		log.Printf("no such city Name = '%s'\n", "Tokyo")
		return
	}
	if err != nil {
		log.Fatalf("DB Error: %s\n", err)
	}
	// #endregion get
	log.Printf("Tokyoの人口は%d人です\n", city.Population)
}

// #regionなどのコメントは無視してください。

書き換えた後、 import の周りで赤字のエラーが出た場合は、ターミナルでgo mod tidyを実行してください。 26 から 40 行目でデータベースに接続するための設定をして、42 行目のdb, err := sqlx.Open("mysql", conf.FormatDSN())でデータベースに接続しています。32 行目などでos.Getenv()という関数が出てきていますが、これは環境変数と呼ばれる、コンピューター側で設定してプログラムで使えるようにしている変数です。今は必要なデータベースのパスワードなどの環境変数を何も設定していないので、設定します。

詳しく知りたい人向け

dsn とは

42 行目にFormatDSNという関数がありますが、DSNは「Data Source Name」の頭文字をとったものです。プログラムがデータベースを指定するために使われます。今回のFormatDSNという関数は、データベースのユーザー名、パスワード、使うデータベース、どこにデータベースのサーバーがあるのか、用いる標準時、文字種などの設定をconfという変数から読み取って DSN を組み立てています。

Wikipedia DSN(英語)

環境変数を設定する

.envというファイルを作り、以下の内容を書いてください。

sh
export DB_USERNAME="root"
export DB_PASSWORD="password"
export DB_HOSTNAME="localhost"
export DB_PORT="3306"
export DB_DATABASE="world"

今回は手元で動いているデータベースを使うのでパスワードなどが知られても問題ありませんが、実際には環境変数など外部の人に知られたくない、GitHub などに上げたくないファイルもあります。そのような場合は、.gitignoreというファイルを使うことで特定のファイルやフォルダを Git 管理の対象外にできます。.gitignoreファイルの最後に.envを追記しましょう。

txt
...
# Go workspace file
go.work

.tool-versions
.env

.gitignoreファイルは便利ですが、既に Git の追跡対象になっているファイルを書いても追跡対象から外れないので注意しましょう。

https://docs.github.com/ja/get-started/getting-started-with-git/ignoring-files

最後に環境変数ファイル.envを環境変数として読み込むために、ターミナルでsource .envを実行してください。

WARNING

sh
$ source .env

このコマンドによって読み込んだ環境変数は、コマンドを入力したターミナルを終了すると消えてしまいます。また、コマンドを入力したターミナル以外では環境変数として読み込まれません。新しくターミナルを開きなおした場合などは、もう一度実行してください。

実行する

sh
$ go run main.go

出力はこのようになります。

txt
connected
Tokyoの人口は7980230人です

main.goを解説してきます。

go
type City struct {
	ID          int    `json:"ID,omitempty" db:"ID"`
	Name        string `json:"name,omitempty" db:"Name"`
	CountryCode string `json:"countryCode,omitempty"  db:"CountryCode"`
	District    string `json:"district,omitempty"  db:"District"`
	Population  int    `json:"population,omitempty"  db:"Population"`
}

このCity構造体の横にあるバッククオートで囲まれたタグにdbでデータベースのカラム名を指定します。これによってライブラリがデータベースから取得したレコードを構造体に上手くあてはめてくれます。

参考: Struct タグについて | text.Baldanders.info

go
var city City
err = db.Get(&city, "SELECT * FROM city WHERE Name = ?", "Tokyo")
if errors.Is(err, sql.ErrNoRows) {
	log.Printf("no such city Name = '%s'\n", "Tokyo")
	return
}
if err != nil {
	log.Fatalf("DB Error: %s\n", err)
}

City型のcityという変数のポインタを sqlx ライブラリのGet関数の第 1 引数に指定します。第 2 引数には SQL 文を書きます。Name = ?としていますが、第 3 引数以降の値が順番に?へと当てはめられて SQL 文が実行され、取得したレコードがcity変数に代入されます。

基本問題

sh
$ go run main.go {都市の名前}

と入力して、同様に人口を表示するようにしましょう。

ヒント:Go言語 - os.Argsでコマンドラインパラメータを受け取る - 覚えたら書く

答え
go
package main

import (
	"database/sql"
	"errors"
	"log"
	"os"
	"time"

	"github.com/go-sql-driver/mysql"
	"github.com/jmoiron/sqlx"
)

type City struct {
	ID          int    `json:"ID,omitempty" db:"ID"`
	Name        string `json:"name,omitempty" db:"Name"`
	CountryCode string `json:"countryCode,omitempty"  db:"CountryCode"`
	District    string `json:"district,omitempty"  db:"District"`
	Population  int    `json:"population,omitempty"  db:"Population"`
}

func main() {
	jst, err := time.LoadLocation("Asia/Tokyo")
	if err != nil {
		log.Fatal(err)
	}

	conf := mysql.Config{
		User:      os.Getenv("DB_USERNAME"),
		Passwd:    os.Getenv("DB_PASSWORD"),
		Net:       "tcp",
		Addr:      os.Getenv("DB_HOSTNAME") + ":" + os.Getenv("DB_PORT"),
		DBName:    os.Getenv("DB_DATABASE"),
		ParseTime: true,
		Collation: "utf8mb4_unicode_ci",
		Loc:       jst,
	}

	db, err := sqlx.Open("mysql", conf.FormatDSN())

	if err != nil {
		log.Fatal(err)
	}

	log.Println("connected")

	cityName := os.Args[1] //[!code ++]

	var city City
	err = db.Get(&city, "SELECT * FROM city WHERE Name = ?", "Tokyo")  //[!code --]
	err = db.Get(&city, "SELECT * FROM city WHERE Name = ?", cityName) //[!code ++]
	if errors.Is(err, sql.ErrNoRows) {
		log.Printf("no such city Name = '%s'\n", "Tokyo") //[!code --]
		log.Printf("no such city Name = '%s'\n", cityName) //[!code ++]
		return
	}
	if err != nil {
		log.Fatalf("DB Error: %s\n", err)
	}

	log.Printf("Tokyoの人口は%d人です\n", city.Population)
}

応用問題

基本問題 1 と同様に都市を入力したとき、その都市の人口がその国の人口の何%かを表示してみましょう。

ヒント: 1 回のクエリでも取得できますが、2 回に分けた方が楽に考えられます。

答え
go
package main

import (
	"database/sql"
	"errors"
	"log"
	"os"
	"time"

	"github.com/go-sql-driver/mysql"
	"github.com/jmoiron/sqlx"
)

type City struct {
	ID          int    `json:"ID,omitempty" db:"ID"`
	Name        string `json:"name,omitempty" db:"Name"`
	CountryCode string `json:"countryCode,omitempty"  db:"CountryCode"`
	District    string `json:"district,omitempty"  db:"District"`
	Population  int    `json:"population,omitempty"  db:"Population"`
}

func main() {
	jst, err := time.LoadLocation("Asia/Tokyo")
	if err != nil {
		log.Fatal(err)
	}

	conf := mysql.Config{
		User:      os.Getenv("DB_USERNAME"),
		Passwd:    os.Getenv("DB_PASSWORD"),
		Net:       "tcp",
		Addr:      os.Getenv("DB_HOSTNAME") + ":" + os.Getenv("DB_PORT"),
		DBName:    os.Getenv("DB_DATABASE"),
		ParseTime: true,
		Collation: "utf8mb4_unicode_ci",
		Loc:       jst,
	}

	db, err := sqlx.Open("mysql", conf.FormatDSN())

	if err != nil {
		log.Fatal(err)
	}

	log.Println("connected")

	cityName := os.Args[1]

	var city City
	err = db.Get(&city, "SELECT * FROM city WHERE Name = ?", cityName)
	if errors.Is(err, sql.ErrNoRows) {
		log.Printf("no such city Name = '%s'\n", cityName)
		return
	}
	if err != nil {
		log.Fatalf("DB Error: %s\n", err)
	}

	log.Printf("%sの人口は%d人です\n", city.Name, city.Population)

	var population int                                                                           //[!code ++]
	err = db.Get(&population, "SELECT Population FROM country WHERE Code = ?", city.CountryCode) //[!code ++]
	if errors.Is(err, sql.ErrNoRows) {                                                           //[!code ++]
		log.Printf("no such country Code = '%s'\n", city.CountryCode) //[!code ++]
		return                                                        //[!code ++]
	} //[!code ++]
	if err != nil { //[!code ++]
		log.Fatalf("DB Error: %s\n", err) //[!code ++]
	} //[!code ++]
	//[!code ++]
	percent := (float64(city.Population) / float64(population)) * 100 //[!code ++]
	//[!code ++]
	log.Printf("これは%sの人口の%f%%です\n", city.CountryCode, percent) //[!code ++]
}

複数レコードを取得する

Get関数の代わりにSelect関数を使い、第 1 引数を配列のポインタに変えると、複数レコードを取得できます。main.gomain関数を以下のように書き換えて実行してみましょう。

go
func main() {
	jst, err := time.LoadLocation("Asia/Tokyo")
	if err != nil {
		log.Fatal(err)
	}

	conf := mysql.Config{
		User:      os.Getenv("DB_USERNAME"),
		Passwd:    os.Getenv("DB_PASSWORD"),
		Net:       "tcp",
		Addr:      os.Getenv("DB_HOSTNAME") + ":" + os.Getenv("DB_PORT"),
		DBName:    os.Getenv("DB_DATABASE"),
		ParseTime: true,
		Collation: "utf8mb4_unicode_ci",
		Loc:       jst,
	}

	db, err := sqlx.Open("mysql", conf.FormatDSN())

	if err != nil {
		log.Fatal(err)
	}

	log.Println("connected")

	var cities []City
	err = db.Select(&cities, "SELECT * FROM city WHERE CountryCode = 'JPN'") //?を使わない場合、第3引数以降は不要
	if err != nil {
		log.Fatal(err)
	}

	log.Println("日本の都市一覧")
	for _, city := range cities {
		log.Printf("都市名: %s, 人口: %d\n", city.Name, city.Population)
	}
}

以下のように日本の都市一覧を取得できます。

txt
connected
日本の都市一覧
都市名: Tokyo, 人口: 7980230
都市名: Jokohama [Yokohama], 人口: 3339594
都市名: Osaka, 人口: 2595674
都市名: Nagoya, 人口: 2154376
都市名: Sapporo, 人口: 1790886
都市名: Kioto, 人口: 1461974
...省略

日本の都市一覧を取得出来たら、スクリーンショットを講習会用チャンネルに投稿しましょう。

レコードを書き換える

INSERTUPDATEDELETEを実行したい場合は、Exec関数を使うことができます。第 1 引数に SQL 文を渡し、第 2 引数以降は?に当てはめたい値を入れます。

go
result, err := db.Exec("INSERT INTO city (Name, CountryCode, District, Population) VALUES (?,?,?,?)", name, countryCode, district, population)

例えばINSERTならば、このように使うことができます。resultには操作によって変更があったレコード数などの情報が入っています。

詳しく知りたい人向け

なぜ「?」を使うのか

sqlx で変数を含む SQL を使いたいときは「?」を使わなくてはいけません。これはセキュリティ上の問題です。例として、国のコードからその国の都市の情報一覧を取得することを考えましょう。fmtライブラリのSprintf関数を使うとこのように処理を書くことができます。

go
err = db.Select(&city, fmt.Sprintf("SELECT * FROM city WHERE CountryCode = '%s'", code))

codeに入っている値がただの国名コードなら問題はないのですが、JPN' OR 'A' = 'Aという値が入っていたらどうなるでしょうか。データベースで実行されるとき、SQL 文は下のようになります。

sql
SELECT * FROM city WHERE CountryCode = 'JPN' OR 'A' = 'A'

ORでつなげた条件文のうち、「'A' = 'A'」は常に成り立つので、WHERE句の条件は常に真です。よって、この SQL を実行すると、作成者が意図しない方法で全ての都市が取得できてしまいます。このような攻撃は「SQL インジェクション」と呼ばれます。

sqlx ではこれを防ぐために?を使うことができ、SQL 文が意図しない動きをしないようになっています。