Published on

Connect to SQL Database with Go

Authors

Connect to SQL Database with Go

ในภาษา Go ถ้าต้องการเขียนโปรแกรมต่อกับระบบฐานข้อมูล สามารถใช้ standard library ที่ Go เตรียมไว้ให้ได้เลย ซึ่งก็คือ database/sql

ความรู้พื้นฐาน

  1. ความรู้พื้นฐานภาษา Go
  2. ความรู้เรื่องภาษา SQL

มาเริ่มกันเลย

สร้างโปรเจคใหม่ และเปิดใช้งาน Go Module

mkdir -p godb/sql
cd godb/sql
go mod init godb/sql

สร้างไฟล์ main.go

// godb/sql/main.go
package main

func main() {

}

ในบทความนี้จะใช้ระบบฐานข้อมูลเป็น PostgreSQL ต้องติดตั้ง database driver ดังนี้

go get github.com/lib/pq

เชื่อมต่อ Database

ในบทความนี้จะใช้ระบบฐานข้อมูลเป็น PostgreSQL และใช้ standard library ของ Go มาเชื่อมต่อผ่านฟังก์ชัน sql.Open()

package main

import "database/sql"

func main() {
  // required
  var driverName, dataSourceName string
  // return มา 2 ตัว
	db, err := sql.Open(driverName, dataSourceName)

  if err != nil {
      panic(err)
  }
}

ซึ่งสิ่งที่ func Open() ต้องการ คือ driverName และ dataSourceName ดังนั้นเราจะต้องรู้ว่าฐานข้อมูลที่เราจะใช้งานนั้นใช้ driver name อะไร เช่น PostgreSQL จะใช้ https://github.com/lib/pq เป็น database driver และมี driver name เป็น postgres

และเชื่อมต่อฐานข้อมูล PostgreSQL จะมีรูปแบบของ dataSourceName คือ postgres://username:password@host:port/dbName

package main

import (
	"database/sql"
	"log"

	_ "github.com/lib/pq"
)

const (
	// TODO fill this in directly or through environment variable
	// Build a DSN e.g. postgres://username:password@host:port/dbName
	DB_DSN = "postgres://fcricryh:F5a7wATfocTUNww1Dm14AfebtPaysqIn@john.db.elephantsql.com/fcricryh"
)

func main() {
  db, err := sql.Open("postgres", DB_DSN)
  if err != nil {
		log.Fatal("Cannot open DB connection", err)
  }
	defer db.Close()
}

ทดสอบการเชื่อมต่อใช้ db.Ping()

package main

import (
	"database/sql"
	"log"

	_ "github.com/lib/pq"
)

const (
	// TODO fill this in directly or through environment variable
	// Build a DSN e.g. postgres://username:password@host:port/dbName
	DB_DSN = "postgres://fcricryh:F5a7wATfocTUNww1Dm14AfebtPaysqIn@john.db.elephantsql.com/fcricryh"
)

func main() {
  db, err := sql.Open("postgres", DB_DSN)
  if err != nil {
		log.Fatal("Cannot open DB connection", err)
  }
	defer db.Close()

  // force a connection and test that it worked
	err = db.Ping()
	if err != nil {
			panic(err)
	}

	log.Println("DB Connected")
}

ขอย้ายโค้ดการเชื่อมต่อไปไว้ที่ฟังก์ชัน connectDb()

package main

import (
	"database/sql"
	"log"

	_ "github.com/lib/pq"
)

const (
	// TODO fill this in directly or through environment variable
	// Build a DSN e.g. postgres://username:password@host:port/dbName
	DB_DSN = "postgres://fcricryh:F5a7wATfocTUNww1Dm14AfebtPaysqIn@john.db.elephantsql.com/fcricryh"
)

var db *sql.DB

func main() {
  connectDb()
  // defer ต้องอยู่ที่ func main()
	defer db.Close()
}

func connectDb() {
	// เมื่อประกาศ db เป็น global ต้องประกาศ err แยกออกมา
	var err error
  // ใช้ = แทน :=
	db, err = sql.Open("postgres", DB_DSN)
  if err != nil {
		log.Fatal("Cannot open DB connection", err)
  }

  // ทดสอบการเชื่อมต่อ
	err = db.Ping()
	if err != nil {
		log.Fatal(err)
	}

	log.Println("DB Connected")
}

การค้นหาข้อมูลจาก Database

การค้นหาข้อมูลจะใช้ฟังก์ชัน db.Query()

ตัวอย่างการใช้งาน

func main() {
  connectDb()
	defer db.Close()

	query := "select * from todos"
  rows, err := db.Query(query)
  if (err != nil) {
		// handle error
    panic(err)
  }
	// ปิดเมื่อจบการทำงาน
	defer rows.Close()

	// อ่านได้ครั้งละ 1 row return เป็น boolean ว่ามีหรือไม่
	for rows.Next() {
		// การอ่าน row ต้องเอาตัวแปรไปรับ
		id := 0
		title := ""
		is_done := false
		err = rows.Scan(&id, &title, &is_done)
		if err != nil {
			panic(err)
		}

		fmt.Println(id, title, is_done)
	}
}

แต่เวลาใช้งานจริง เราจะสร้าง struct{} ขึ้นมา เพื่อเอาไปรับค่า และ return ค่ากลับไปให้ฟังก์ชันที่เรียกใช้งาน

type Todo struct {
	Id     int
	Title  string
	IsDone bool
}

แก้การค้นหาเป็นฟังก์ชัน func GetTodos() ([]Todo, error) ซึ่งจะ return []Todo กลับออกไป และถ้ามี error จะไม่ได้จัดการเองฟังก์ชันนี้ โดยจะส่ง error ออกไปให้ฟังก์ชันที่เรียกมาเอาไปจัดการต่อเอง

func main() {
  connectDb()
	defer db.Close()

	todos, err := GetTodos()
	if err != nil {
		log.Println(err)
    return
	}

	for _, todo := range todos {
		fmt.Println(todo)
	}
}

func GetTodos() ([]Todo, error) {
	q := "select * from todos"
	rows, err := db.Query(q)
	if err != nil {
		return nil, err
	}
	// ปิดเมื่อจบการทำงาน
	defer rows.Close()

	todos := []Todo{}

	for rows.Next() {
		todo := Todo{}
		err = rows.Scan(&todo.Id, &todo.Title, &todo.IsDone)
		if err != nil {
			return nil, err
		}
		todos = append(todos, todo)
	}
	return todos, nil
}

การค้นหาแบบมีเงื่อนไข

ถ้าเราต้องการเพิ่มเงื่อนไขในการค้นหา เช่น จะค้นหาข้อมูลตามสถานะที่ทำเสร็จแล้ว ก็จะเขียนภาษา sql ได้แบบนี้ select * from todos where is_done = true หรือเฉพาะที่ยังไม่เสร็จ select * from todos where is_done = false จะเห็นว่าค่า true/false เป็น parameter ที่สามารถเปลี่ยนแปลงได้ตามที่ต้องการ

ซึ่งการแทนค่า parameter ในของ database driver นี้ จะใช้ $n ซึ่ง n คือ ตัวเลขลำดับ เริ่มต้นที่ 1

func GetTodos(status *bool) ([]Todo, error) {
	var rows *sql.Rows
	var err error
	if status != nil {
		q := "select * from todos where is_done=$1"
		rows, err = db.Query(q, *status)
	} else {
		q := "select * from todos"
		rows, err = db.Query(q)
	}

	if err != nil {
		return nil, err
	}
	// ปิดเมื่อจบการทำงาน
	defer rows.Close()

	todos := []Todo{}

	for rows.Next() {
		todo := Todo{}
		err = rows.Scan(&todo.Id, &todo.Title, &todo.IsDone)
		if err != nil {
			return nil, err
		}
		todos = append(todos, todo)
	}
	return todos, nil
}
// PostgreSQL
q := "select * from todos where is_done=$1"
rows, err = db.Query(q, *status)

// MySQL ต้องใส่ค่าให้ถูกลำดับด้วย
q := "select * from todos where is_done=?"
rows, err = db.Query(q, *status)

// MSSQL
q := "select * from todos where is_done=@status"
rows, err = db.Query(q, sql.Named("status", *status)

การค้นหาข้อมูลจาก Id

การค้นหาจาก Id ถ้ามีข้อมูลจากได้ออกมาแค่ 1 row ดังนั้นจะใช้ db.QueryRow() แทน

func main() {
	connectDB()
  defer db.Close()

	id := 1
	todo, err := GetTodo(id)

	if err != nil {
		fmt.Println("Not found id", id)
		return
	}

	fmt.Println(todo)
}

func GetTodo(id int) (*Todo, error) {
	q := "select * from todos where id=$1"
  // เปลี่ยนจาก Query มาเป็น QueryRow
	row := db.QueryRow(q, id)

	todo := Todo{}
	err := row.Scan(&todo.Id, &todo.Title, &todo.IsDone)

	if err != nil {
		return nil, err
	}
	return &todo, nil
}

การเพิ่มข้อมูลลง Database

การเพิ่มข้อมูลใช้ฟังก์ชัน db.Exec() และถ้าต้องการตรวจสอบว่าเพิ่มสำเร็จหรือไม่ให้ตรวจสอบจาก RowsAffected() ถ้าสำเร็จจะได้ค่ามากกว่า 0

func main() {
  connectDb()
	defer db.Close()

	todo := Todo{Title: "do something", IsDone: false}
	err := AddTodo(todo)
	if err != nil {
		log.Println(err)
		return
	}

	todos, err := GetTodos(nil)
	if err != nil {
		log.Println(err)
		return
	}

	for _, todo := range todos {
		fmt.Println(todo)
	}
}

func AddTodo(todo Todo) error {
	q := "INSERT INTO todos (title) VALUES ($1)"
	result, err := db.Exec(q, todo.Title, todo.IsDone)
	if err != nil {
		return err
	}

	affected, err := result.RowsAffected()
	if err != nil {
		return err
	}

	if affected <= 0 {
		return errors.New("cannot insert")
	}

	return nil
}

หรือจะใช้วิธีการสร้าง statment ด้วย Prepare() แล้วส่งค่า parameter ใน Exec() แทน

func AddTodo(todo Todo) error {
	stmt, err := db.Prepare("INSERT INTO todos (title) VALUES ($1)")
	if err != nil {
		return err
	}
	defer stmt.Close()

	result, err := stmt.Exec(todo.Title)
	if err != nil {
		return err
	}

	affected, err := result.RowsAffected()
	if err != nil {
		return err
	}

	if affected <= 0 {
		return errors.New("cannot insert")
	}

	return nil
}

ถ้าต้องการดึงค่า id ของ row ที่เพิ่งสร้างไปตัว library ได้เตรียม result.LastInsertId() ไว้ให้แล้ว แต่ใน PostgreSQL ไม่รองรับ ซึ่งมีวิธีทำดังนี้

func main() {
	connectDB()
	defer db.Close()

	todo := Todo{Title: "do something"}
	err := AddTodo(&todo)
	if err != nil {
		log.Println(err)
		return
	}
	fmt.Println(todo)
}

// รับเป็น pointer เพราะจะใส่ค่า id กลับไปให้
func AddTodo(todo *Todo) error {
  // เพิ่ม returning id เข้าไป
	stmt, err := db.Prepare("INSERT INTO todos (title) VALUES ($1) returning id")
	if err != nil {
		return err
	}
	defer stmt.Close()

  // เปลี่ยน Exec() เป็น QueryRow()
	err = stmt.QueryRow(todo.Title).Scan(&todo.Id)
	if err != nil {
		return err
	}

	return nil
}

การแก้ไขข้อมูล

โค้ดก็จะเหมือนกันการเพิ่มข้อมูล

func main() {
  connectDb()
	defer db.Close()

	todo := Todo{Title: "do something"}
	err := AddTodo(&todo)
	if err != nil {
		log.Println(err)
		return
	}
	fmt.Println("New", todo)

	todo.IsDone = true

	err = UpdateTodo(todo)
	if err != nil {
		log.Println(err)
		return
	}
	fmt.Println("Updated", todo)
}

func UpdateTodo(todo Todo) error {
	stmt, err := db.Prepare("UPDATE todos SET is_done=$1 where id=$2")
	if err != nil {
		return err
	}
	defer stmt.Close()

	result, err := stmt.Exec(todo.IsDone, todo.Id)
	if err != nil {
		return err
	}

	affected, err := result.RowsAffected()
	if err != nil {
		return err
	}

	if affected <= 0 {
		return errors.New("cannot update")
	}

	return nil
}

การลบข้อมูล

func main() {
  connectDb()
	defer db.Close()

	todo := Todo{Title: "do something"}
	err := AddTodo(&todo)
	if err != nil {
		log.Println(err)
		return
	}
	fmt.Println("New", todo)

	err = DeleteTodo(todo.Id)
	if err != nil {
		log.Println(err)
		return
	}

	_, err = GetTodo(todo.Id)
	if err != nil {
		log.Println("Not found Id:", todo.Id)
		return
	}
}

func DeleteTodo(id int) error {
	stmt, err := db.Prepare("DELETE FROM todos where id=$1")
	if err != nil {
		return err
	}
	defer stmt.Close()

	result, err := stmt.Exec(id)
	if err != nil {
		return err
	}

	affected, err := result.RowsAffected()
	if err != nil {
		return err
	}

	if affected <= 0 {
		return errors.New("cannot delete")
	}

	return nil
}

การใช้งาน Transaction

ในบางครั้ง การบันทึกข้อมูลลงฐานข้อมูลไม่ได้จบใน statement เดียว อาจจะต้อง insert หลายๆ statement หรือทั้ง insert, update และ delete ตารางอื่นด้วย ในการทำ 1 business logic ถ้างานใดงานหนึ่งมีปัญหา และต้องการ rollback ทั้งหมด จะต้องใช้ transaction

func AddTodoTx(todo *Todo) error {
	// เปิดใช้งาน transaction
	tx, err := db.Begin()
	if err != nil {
		return err
	}
	// เปลี่ยน db เป็น tx
	stmt, err := tx.Prepare("INSERT INTO todos (title) VALUES ($1) returning id, is_done")
	if err != nil {
		return err
	}
	defer stmt.Close()

	// เปลี่ยน Exec() เป็น QueryRow()
	err = stmt.QueryRow(todo.Title).Scan(&todo.Id, &todo.IsDone)
	if err != nil {
		// เมื่อ error ก็สั่ง rollback
		tx.Rollback()
		return err
	}
	// ทำงานสำเร็จก็สั่ง commit
	tx.Commit()
	return nil
}

ก็จบแล้วสำหรับการทำ Select, Insert, Update และ Delete ลงฐานข้อมูลโดยใช้ standard library สิ่งสำคัญคือต้องใช้ database driver ให้ถูกต้องการระบบฐานข้อมูลที่เราใช้ และต้องไปศึกษาเพิ่มเติมว่าแต่ละ database driver มีวิธีการใช้งานที่แตกต่างกันอย่างไร