TiDB と Golang を使用して単純な CRUD アプリを構築する
このドキュメントでは、TiDB と Golang を使用して単純な CRUD アプリケーションを構築する方法について説明します。
ノート:
Golang 1.16 以降のバージョンを使用することをお勧めします。
ステップ 1. TiDB クラスターを起動する
以下にTiDBクラスターの起動方法を紹介します。
TiDB Cloudの無料クラスターを使用する
詳細な手順については、 無料のクラスターを作成するを参照してください。
ローカル クラスターを使用する
詳細な手順については、 ローカル テスト クラスターをデプロイするまたはTiUP を使用して TiDBクラスタをデプロイするを参照してください。
ステップ 2. コードを取得する
git clone https://github.com/pingcap-inc/tidb-example-golang.git
- Using go-sql-driver/mysql
- Using GORM (Recommended)
sqldriver
ディレクトリに移動します。
cd sqldriver
このディレクトリの構造は次のとおりです。
.
├── Makefile
├── dao.go
├── go.mod
├── go.sum
├── sql
│ └── dbinit.sql
├── sql.go
└── sqldriver.go
テーブル作成の初期化ステートメントはdbinit.sql
にあります。
USE test;
DROP TABLE IF EXISTS player;
CREATE TABLE player (
`id` VARCHAR(36),
`coins` INTEGER,
`goods` INTEGER,
PRIMARY KEY (`id`)
);
sqldriver.go
はsqldriver
の本体です。 TiDB は MySQL プロトコルとの互換性が高いため、MySQL ソース インスタンスdb, err := sql.Open("mysql", dsn)
を初期化して TiDB に接続する必要があります。その後、 dao.go
を使用して、データの読み取り、編集、追加、および削除を行うことができます。
package main
import (
"database/sql"
"fmt"
_ "github.com/go-sql-driver/mysql"
)
func main() {
// 1. Configure the example database connection.
dsn := "root:@tcp(127.0.0.1:4000)/test?charset=utf8mb4"
openDB("mysql", dsn, func(db *sql.DB) {
// 2. Run some simple examples.
simpleExample(db)
// 3. Explore more.
tradeExample(db)
})
}
func simpleExample(db *sql.DB) {
// Create a player, who has a coin and a goods.
err := createPlayer(db, Player{ID: "test", Coins: 1, Goods: 1})
if err != nil {
panic(err)
}
// Get a player.
testPlayer, err := getPlayer(db, "test")
if err != nil {
panic(err)
}
fmt.Printf("getPlayer: %+v\n", testPlayer)
// Create players with bulk inserts. Insert 1919 players totally, with 114 players per batch.
err = bulkInsertPlayers(db, randomPlayers(1919), 114)
if err != nil {
panic(err)
}
// Count players amount.
playersCount, err := getCount(db)
if err != nil {
panic(err)
}
fmt.Printf("countPlayers: %d\n", playersCount)
// Print 3 players.
threePlayers, err := getPlayerByLimit(db, 3)
if err != nil {
panic(err)
}
for index, player := range threePlayers {
fmt.Printf("print %d player: %+v\n", index+1, player)
}
}
func tradeExample(db *sql.DB) {
// Player 1: id is "1", has only 100 coins.
// Player 2: id is "2", has 114514 coins, and 20 goods.
player1 := Player{ID: "1", Coins: 100}
player2 := Player{ID: "2", Coins: 114514, Goods: 20}
// Create two players "by hand", using the INSERT statement on the backend.
if err := createPlayer(db, player1); err != nil {
panic(err)
}
if err := createPlayer(db, player2); err != nil {
panic(err)
}
// Player 1 wants to buy 10 goods from player 2.
// It will cost 500 coins, but player 1 cannot afford it.
fmt.Println("\nbuyGoods:\n => this trade will fail")
if err := buyGoods(db, player2.ID, player1.ID, 10, 500); err == nil {
panic("there shouldn't be success")
}
// So player 1 has to reduce the incoming quantity to two.
fmt.Println("\nbuyGoods:\n => this trade will success")
if err := buyGoods(db, player2.ID, player1.ID, 2, 100); err != nil {
panic(err)
}
}
func openDB(driverName, dataSourceName string, runnable func(db *sql.DB)) {
db, err := sql.Open(driverName, dataSourceName)
if err != nil {
panic(err)
}
defer db.Close()
runnable(db)
}
TiDB トランザクションを適応させるには、次のコードに従ってツールキットユーティリティを作成します。
package util
import (
"context"
"database/sql"
)
type TiDBSqlTx struct {
*sql.Tx
conn *sql.Conn
pessimistic bool
}
func TiDBSqlBegin(db *sql.DB, pessimistic bool) (*TiDBSqlTx, error) {
ctx := context.Background()
conn, err := db.Conn(ctx)
if err != nil {
return nil, err
}
if pessimistic {
_, err = conn.ExecContext(ctx, "set @@tidb_txn_mode=?", "pessimistic")
} else {
_, err = conn.ExecContext(ctx, "set @@tidb_txn_mode=?", "optimistic")
}
if err != nil {
return nil, err
}
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
return &TiDBSqlTx{
conn: conn,
Tx: tx,
pessimistic: pessimistic,
}, nil
}
func (tx *TiDBSqlTx) Commit() error {
defer tx.conn.Close()
return tx.Tx.Commit()
}
func (tx *TiDBSqlTx) Rollback() error {
defer tx.conn.Close()
return tx.Tx.Rollback()
}
dao.go
は、データを書き込む機能を提供する一連のデータ操作メソッドを定義します。これは、この例の核心部分でもあります。
package main
import (
"database/sql"
"fmt"
"math/rand"
"strings"
"github.com/google/uuid"
"github.com/pingcap-inc/tidb-example-golang/util"
)
type Player struct {
ID string
Coins int
Goods int
}
// createPlayer create a player
func createPlayer(db *sql.DB, player Player) error {
_, err := db.Exec(CreatePlayerSQL, player.ID, player.Coins, player.Goods)
return err
}
// getPlayer get a player
func getPlayer(db *sql.DB, id string) (Player, error) {
var player Player
rows, err := db.Query(GetPlayerSQL, id)
if err != nil {
return player, err
}
defer rows.Close()
if rows.Next() {
err = rows.Scan(&player.ID, &player.Coins, &player.Goods)
if err == nil {
return player, nil
} else {
return player, err
}
}
return player, fmt.Errorf("can not found player")
}
// getPlayerByLimit get players by limit
func getPlayerByLimit(db *sql.DB, limit int) ([]Player, error) {
var players []Player
rows, err := db.Query(GetPlayerByLimitSQL, limit)
if err != nil {
return players, err
}
defer rows.Close()
for rows.Next() {
player := Player{}
err = rows.Scan(&player.ID, &player.Coins, &player.Goods)
if err == nil {
players = append(players, player)
} else {
return players, err
}
}
return players, nil
}
// bulk-insert players
func bulkInsertPlayers(db *sql.DB, players []Player, batchSize int) error {
tx, err := util.TiDBSqlBegin(db, true)
if err != nil {
return err
}
stmt, err := tx.Prepare(buildBulkInsertSQL(batchSize))
if err != nil {
return err
}
defer stmt.Close()
for len(players) > batchSize {
if _, err := stmt.Exec(playerToArgs(players[:batchSize])...); err != nil {
tx.Rollback()
return err
}
players = players[batchSize:]
}
if len(players) != 0 {
if _, err := tx.Exec(buildBulkInsertSQL(len(players)), playerToArgs(players)...); err != nil {
tx.Rollback()
return err
}
}
if err := tx.Commit(); err != nil {
tx.Rollback()
return err
}
return nil
}
func getCount(db *sql.DB) (int, error) {
count := 0
rows, err := db.Query(GetCountSQL)
if err != nil {
return count, err
}
defer rows.Close()
if rows.Next() {
if err := rows.Scan(&count); err != nil {
return count, err
}
}
return count, nil
}
func buyGoods(db *sql.DB, sellID, buyID string, amount, price int) error {
var sellPlayer, buyPlayer Player
tx, err := util.TiDBSqlBegin(db, true)
if err != nil {
return err
}
buyExec := func() error {
stmt, err := tx.Prepare(GetPlayerWithLockSQL)
if err != nil {
return err
}
defer stmt.Close()
sellRows, err := stmt.Query(sellID)
if err != nil {
return err
}
defer sellRows.Close()
if sellRows.Next() {
if err := sellRows.Scan(&sellPlayer.ID, &sellPlayer.Coins, &sellPlayer.Goods); err != nil {
return err
}
}
sellRows.Close()
if sellPlayer.ID != sellID || sellPlayer.Goods < amount {
return fmt.Errorf("sell player %s goods not enough", sellID)
}
buyRows, err := stmt.Query(buyID)
if err != nil {
return err
}
defer buyRows.Close()
if buyRows.Next() {
if err := buyRows.Scan(&buyPlayer.ID, &buyPlayer.Coins, &buyPlayer.Goods); err != nil {
return err
}
}
buyRows.Close()
if buyPlayer.ID != buyID || buyPlayer.Coins < price {
return fmt.Errorf("buy player %s coins not enough", buyID)
}
updateStmt, err := tx.Prepare(UpdatePlayerSQL)
if err != nil {
return err
}
defer updateStmt.Close()
if _, err := updateStmt.Exec(-amount, price, sellID); err != nil {
return err
}
if _, err := updateStmt.Exec(amount, -price, buyID); err != nil {
return err
}
return nil
}
err = buyExec()
if err == nil {
fmt.Println("\n[buyGoods]:\n 'trade success'")
tx.Commit()
} else {
tx.Rollback()
}
return err
}
func playerToArgs(players []Player) []interface{} {
var args []interface{}
for _, player := range players {
args = append(args, player.ID, player.Coins, player.Goods)
}
return args
}
func buildBulkInsertSQL(amount int) string {
return CreatePlayerSQL + strings.Repeat(",(?,?,?)", amount-1)
}
func randomPlayers(amount int) []Player {
players := make([]Player, amount, amount)
for i := 0; i < amount; i++ {
players[i] = Player{
ID: uuid.New().String(),
Coins: rand.Intn(10000),
Goods: rand.Intn(10000),
}
}
return players
}
sql.go
は、SQL ステートメントを定数として定義します。
package main
const (
CreatePlayerSQL = "INSERT INTO player (id, coins, goods) VALUES (?, ?, ?)"
GetPlayerSQL = "SELECT id, coins, goods FROM player WHERE id = ?"
GetCountSQL = "SELECT count(*) FROM player"
GetPlayerWithLockSQL = GetPlayerSQL + " FOR UPDATE"
UpdatePlayerSQL = "UPDATE player set goods = goods + ?, coins = coins + ? WHERE id = ?"
GetPlayerByLimitSQL = "SELECT id, coins, goods FROM player LIMIT ?"
)
GORM と比較すると、go-sql-driver/mysql の実装はベスト プラクティスではない可能性があります。これは、エラー処理ロジックを記述し、 *sql.Rows
を手動で閉じる必要があり、コードを簡単に再利用できないため、コードがわずかに冗長になるためです。
GORM は、Golang 向けの人気のあるオープンソース ORM ライブラリです。次の手順では、例としてv1.23.5
を取り上げます。
TiDB トランザクションを適応させるには、次のコードに従ってツールキットユーティリティを作成します。
package util
import (
"context"
"database/sql"
)
type TiDBSqlTx struct {
*sql.Tx
conn *sql.Conn
pessimistic bool
}
func TiDBSqlBegin(db *sql.DB, pessimistic bool) (*TiDBSqlTx, error) {
ctx := context.Background()
conn, err := db.Conn(ctx)
if err != nil {
return nil, err
}
if pessimistic {
_, err = conn.ExecContext(ctx, "set @@tidb_txn_mode=?", "pessimistic")
} else {
_, err = conn.ExecContext(ctx, "set @@tidb_txn_mode=?", "optimistic")
}
if err != nil {
return nil, err
}
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
return &TiDBSqlTx{
conn: conn,
Tx: tx,
pessimistic: pessimistic,
}, nil
}
func (tx *TiDBSqlTx) Commit() error {
defer tx.conn.Close()
return tx.Tx.Commit()
}
func (tx *TiDBSqlTx) Rollback() error {
defer tx.conn.Close()
return tx.Tx.Rollback()
}
gorm
ディレクトリに移動します。
cd gorm
このディレクトリの構造は次のとおりです。
.
├── Makefile
├── go.mod
├── go.sum
└── gorm.go
gorm.go
はgorm
の本体です。 go-sql-driver/mysql と比較して、GORM は異なるデータベース間のデータベース作成の違いを回避します。また、AutoMigrate やオブジェクトの CRUD などの多くの操作を実装しているため、コードが大幅に簡素化されます。
Player
は、テーブルのマッピングであるデータ エンティティ構造体です。 Player
の各プロパティは、 player
テーブルのフィールドに対応します。 go-sql-driver/mysql と比較して、GORM のPlayer
はgorm:"primaryKey;type:VARCHAR(36);column:id"
などの詳細情報のマッピング関係を示す構造タグを追加します。
package main
import (
"fmt"
"math/rand"
"github.com/google/uuid"
"github.com/pingcap-inc/tidb-example-golang/util"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
)
type Player struct {
ID string `gorm:"primaryKey;type:VARCHAR(36);column:id"`
Coins int `gorm:"column:coins"`
Goods int `gorm:"column:goods"`
}
func (*Player) TableName() string {
return "player"
}
func main() {
// 1. Configure the example database connection.
db := createDB()
// AutoMigrate for player table
db.AutoMigrate(&Player{})
// 2. Run some simple examples.
simpleExample(db)
// 3. Explore more.
tradeExample(db)
}
func tradeExample(db *gorm.DB) {
// Player 1: id is "1", has only 100 coins.
// Player 2: id is "2", has 114514 coins, and 20 goods.
player1 := &Player{ID: "1", Coins: 100}
player2 := &Player{ID: "2", Coins: 114514, Goods: 20}
// Create two players "by hand", using the INSERT statement on the backend.
db.Clauses(clause.OnConflict{UpdateAll: true}).Create(player1)
db.Clauses(clause.OnConflict{UpdateAll: true}).Create(player2)
// Player 1 wants to buy 10 goods from player 2.
// It will cost 500 coins, but player 1 cannot afford it.
fmt.Println("\nbuyGoods:\n => this trade will fail")
if err := buyGoods(db, player2.ID, player1.ID, 10, 500); err == nil {
panic("there shouldn't be success")
}
// So player 1 has to reduce the incoming quantity to two.
fmt.Println("\nbuyGoods:\n => this trade will success")
if err := buyGoods(db, player2.ID, player1.ID, 2, 100); err != nil {
panic(err)
}
}
func simpleExample(db *gorm.DB) {
// Create a player, who has a coin and a goods.
if err := db.Clauses(clause.OnConflict{UpdateAll: true}).
Create(&Player{ID: "test", Coins: 1, Goods: 1}).Error; err != nil {
panic(err)
}
// Get a player.
var testPlayer Player
db.Find(&testPlayer, "id = ?", "test")
fmt.Printf("getPlayer: %+v\n", testPlayer)
// Create players with bulk inserts. Insert 1919 players totally, with 114 players per batch.
bulkInsertPlayers := make([]Player, 1919, 1919)
total, batch := 1919, 114
for i := 0; i < total; i++ {
bulkInsertPlayers[i] = Player{
ID: uuid.New().String(),
Coins: rand.Intn(10000),
Goods: rand.Intn(10000),
}
}
if err := db.Session(&gorm.Session{Logger: db.Logger.LogMode(logger.Error)}).
CreateInBatches(bulkInsertPlayers, batch).Error; err != nil {
panic(err)
}
// Count players amount.
playersCount := int64(0)
db.Model(&Player{}).Count(&playersCount)
fmt.Printf("countPlayers: %d\n", playersCount)
// Print 3 players.
threePlayers := make([]Player, 3, 3)
db.Limit(3).Find(&threePlayers)
for index, player := range threePlayers {
fmt.Printf("print %d player: %+v\n", index+1, player)
}
}
func createDB() *gorm.DB {
dsn := "root:@tcp(127.0.0.1:4000)/test?charset=utf8mb4"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
panic(err)
}
return db
}
func buyGoods(db *gorm.DB, sellID, buyID string, amount, price int) error {
return util.TiDBGormBegin(db, true, func(tx *gorm.DB) error {
var sellPlayer, buyPlayer Player
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Find(&sellPlayer, "id = ?", sellID).Error; err != nil {
return err
}
if sellPlayer.ID != sellID || sellPlayer.Goods < amount {
return fmt.Errorf("sell player %s goods not enough", sellID)
}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Find(&buyPlayer, "id = ?", buyID).Error; err != nil {
return err
}
if buyPlayer.ID != buyID || buyPlayer.Coins < price {
return fmt.Errorf("buy player %s coins not enough", buyID)
}
updateSQL := "UPDATE player set goods = goods + ?, coins = coins + ? WHERE id = ?"
if err := tx.Exec(updateSQL, -amount, price, sellID).Error; err != nil {
return err
}
if err := tx.Exec(updateSQL, amount, -price, buyID).Error; err != nil {
return err
}
fmt.Println("\n[buyGoods]:\n 'trade success'")
return nil
})
}
ステップ 3. コードを実行する
次のコンテンツでは、コードを実行する方法を順を追って紹介します。
ステップ 3.1 テーブルの初期化
- Using go-sql-driver/mysql
- Using GORM (Recommended)
go-sql-driver/mysql を使用する場合、データベース テーブルを手動で初期化する必要があります。ローカル クラスタを使用していて、MySQL クライアントがローカルにインストールされている場合は、 sqldriver
ディレクトリで直接実行できます。
make mysql
または、次のコマンドを実行できます。
mysql --host 127.0.0.1 --port 4000 -u root<sql/dbinit.sql
非ローカル クラスターを使用している場合、または MySQL クライアントがインストールされていない場合は、クラスターに接続し、 sql/dbinit.sql
ファイルのステートメントを実行します。
テーブルを手動で初期化する必要はありません。
ステップ 3.2 TiDB Cloudのパラメーターを変更する
- Using go-sql-driver/mysql
- Using GORM (Recommended)
TiDB Cloudやその他のリモート クラスターなど、ローカル以外の既定のクラスターを使用している場合は、 dsn
in sqldriver.go
の値を変更します。
dsn := "root:@tcp(127.0.0.1:4000)/test?charset=utf8mb4"
設定したパスワードが123456
で、 TiDB Cloudから取得した接続文字列が次のとおりであるとします。
mysql --connect-timeout 15 -u root -h xxx.tidbcloud.com -P 4000 -p
この場合、次のようにパラメータを変更できます。
dsn := "root:123456@tcp(xxx.tidbcloud.com:4000)/test?charset=utf8mb4"
TiDB Cloudやその他のリモート クラスターなど、ローカル以外の既定のクラスターを使用している場合は、 dsn
in gorm.go
の値を変更します。
dsn := "root:@tcp(127.0.0.1:4000)/test?charset=utf8mb4"
設定したパスワードが123456
で、 TiDB Cloudから取得した接続文字列が次のとおりであるとします。
mysql --connect-timeout 15 -u root -h xxx.tidbcloud.com -P 4000 -p
この場合、次のようにパラメータを変更できます。
dsn := "root:123456@tcp(xxx.tidbcloud.com:4000)/test?charset=utf8mb4"
ステップ 3.3 実行
- Using go-sql-driver/mysql
- Using GORM (Recommended)
コードを実行するには、それぞれmake mysql
、 make build
、およびmake run
を実行します。
make mysql # this command executes `mysql --host 127.0.0.1 --port 4000 -u root<sql/dbinit.sql`
make build # this command executes `go build -o bin/sql-driver-example`
make run # this command executes `./bin/sql-driver-example`
または、ネイティブ コマンドを使用できます。
mysql --host 127.0.0.1 --port 4000 -u root<sql/dbinit.sql
go build -o bin/sql-driver-example
./bin/sql-driver-example
または、 make mysql
、 make build
、およびmake run
の組み合わせであるmake all
コマンドを直接実行します。
コードを実行するには、それぞれmake build
とmake run
を実行します。
make build # this command executes `go build -o bin/gorm-example`
make run # this command executes `./bin/gorm-example`
または、ネイティブ コマンドを使用できます。
go build -o bin/gorm-example
./bin/gorm-example
または、 make build
とmake run
の組み合わせであるmake
コマンドを直接実行します。
ステップ 4. 期待される出力
- Using go-sql-driver/mysql
- Using GORM (Recommended)