Mastering Golang for E-commerce Back End Development : Part 4

บทความโดย ผศ.ดร.ณัฐโชติ พรหมฤทธิ์ และ อ.ดร.สัจจาภรณ์ ไวจรรยา
ภาควิชาคอมพิวเตอร์
คณะวิทยาศาสตร์
มหาวิทยาลัยศิลปากร



การพัฒนา API ที่สามารถตอบสนองต่อคำขอจาก User ทั่วโลก ต้องอาศัยการจัดการที่ดีในหลายด้าน บทความนี้จะพาคุณเจาะลึกการพัฒนา API ระดับมืออาชีพด้วย GO โดยครอบคลุมหัวข้อสำคัญดังนี้

  1. การจัดการการตั้งค่าอย่างปลอดภัยด้วย Environment Variable
  2. การพัฒนา REST API ที่มีประสิทธิภาพด้วย Go
  3. การทดสอบขั้นสูง และ Mock Object
  4. การทำงานกับ SQL Database และ Connection Pooling
  5. การ Deploy API ด้วย Docker Container

ไม่ว่าคุณจะเป็นนักพัฒนามือใหม่หรือมีประสบการณ์ บทความนี้จะช่วยยกระดับทักษะการพัฒนา Go ของคุณไปอีกขั้น

การจัดการการตั้งค่า

การจัดการการตั้งค่าใน Go เป็นส่วนสำคัญในการพัฒนา Application ที่ปลอดภัยและปรับแต่งได้ ซึ่งการแยกการตั้งค่าที่สําคัญไว้ใน Environment Variable เป็นวิธีหนึ่งที่ปลอดภัยในการเก็บข้อมูลที่ละเอียดอ่อน เหมาะกับการใช้งานใน Container Environment เช่น Docker ซึ่งสามารถจัดการ Environment Variable ได้อย่างปลอดภัย

Environment Variable หรือตัวแปรสภาพแวดล้อม เป็นค่าที่เก็บไว้ในระบบปฏิบัติการและสามารถใช้ร่วมกันได้ระหว่าง Program ต่าง ๆ ซึ่งมีรูปแบบเป็น Key-value Pair ตัวอย่าง  Environment Variable ทั่วไป เช่น PATH, HOME และ TEMP

ใน Unix/Linux เราจะใช้คำสั่ง export VARIABLE_NAME=VALUE ในการตั้งค่าแบบชั่วคราวผ่าน Command Line สำหรับใน Windows จะใช้คำสั่ง set VARIABLE_NAME=VALUE

ขณะที่การอ่านค่าจาก Command Line ใน Unix/Linux จะใช้คำสั่ง echo $VARIABLE_NAME และใน Windows จะใช้คำสั่ง echo %VARIABLE_NAME%

การตั้งค่า Environment Variable ชั่วคราว

Unix/Linux
	export VARIABLE_NAME=VALUE
    
Windows
	set VARIABLE_NAME=VALUE

ตัวอย่างการอ่านค่า Environment Variable

Unix/Linux
	echo $VARIABLE_NAME

Windows
	echo %VARIABLE_NAME%

ตัวอย่างการตั้งค่าและอ่านค่าสำหรับ Database Connection

export MYAPP_DATABASE_HOST=myhost.com
export MYAPP_DATABASE_PORT=5433
export MYAPP_DATABASE_PASSWORD=mypassword

echo $MYAPP_DATABASE_HOST
echo $MYAPP_DATABASE_PORT
echo $MYAPP_DATABASE_PASSWORD

การตั้งค่า Environment Variable ชั่วคราว ค่าจะคงอยู่เฉพาะใน Session ของ Terminal นั้น ๆ เมื่อปิด Terminal ค่าที่ตั้งไว้จะหายไปทันที

ส่วนการตั้งค่าถาวรบน Unix/Linux เราอาจเพิ่มในไฟล์ /etc/environment หรือ ~/.bashrc หรือ ~/.profile ฯลฯ แต่สำหรับ Windows จะตั้งค่าผ่าน System Properties > Environment Variable หรือคำสั่ง setx

เราอาจใช้การตั้งค่า Environment Variable ด้วยมือบนเครื่อง Local ของ Dev แต่สำหรับ Environment อย่างเช่น Production แนะนำให้ใช้ Secret Management System เช่น HashiCorp Vault ที่เป็น Open Source Software สำหรับการจัดการแทนครับ

Environment Variable เป็นส่วนสำคัญในการทำให้การพัฒนา Application เป็นไปตามหลักการ 12-Factor App ซึ่งเป็นหัวใจสำคัญของการพัฒนา Application สมัยใหม่ โดยเฉพาะ หลักการข้อที่ 3 "Config" ซึ่งแนะนำให้แยกการตั้งค่าที่สําคัญ เช่น ข้อมูลสำหรับการ Connect Database ออกจาก Code เพื่อให้สามารถเปลี่ยนมันได้โดยไม่ต้องเขียน Code ใหม่ เมื่อต้อง Deploy ในแต่ละ Environment เช่น Development, UAT และ Production

ซึ่งในทางปฏิบัติ เราควรเก็บการตั้งค่าแยกแต่ละ Environment ไว้นอก Codebaseโดยเฉพาะข้อมูลที่ละเอียดอ่อน เช่น รหัสผ่าน

ในหัวข้อนี้เราจะใช้ Package "os" และ "viper" ในการดึงค่า Environment Variable ออกมา

เมื่อเรารัน Program (รวมถึงการทำ Unit Test) Environment Variable ทั้งหมดของ Program จะสืบทอดมากจาก Environment Variable ใน Environment แม่ เช่น Environment ของ OS ที่เราเคย Export ผ่าน Command มาก่อนหน้านี้

จาก Code ด้านล่าง Function "GetEnv" จะมีการใช้ os.Getenv() เพื่อดึงค่าของ Environment Variable แต่ถ้าค่าที่ได้เป็นสตริงว่าง ("") มันจะคืนค่า Default Value

ส่วน Function "GetEnvAsInt" นอกจากจะเรียกใช้ os.Getenv() แล้ว ยังมีการใช้ strconv.Atoi() เพื่อแปลงสตริงเป็นจำนวนเต็ม ซึ่งถ้าแปลงสำเร็จ มันจะคืนค่าจำนวนเต็มนั้นกลับมา แต่ถ้าแปลงไม่สำเร็จ (เกิด Error) มันจะคืนค่า Default Value

จากตัวอย่าง ค่าจาก MYAPP_DATABASE_HOST และ MYAPP_DATABASE_PASSWORD จะถูกดึงด้วย GetEnv ส่วน MYAPP_DATABASE_PORT ซึ่งเป็นเลขจำนวนเต็มจะถูกดึงด้วย GetEnvAsInt

package main

import (
	"fmt"
	"os"
	"strconv"
)

func GetEnv(key, defaultValue string) string {
	value := os.Getenv(key)
	if value == "" {
		return defaultValue
	}
	return value
}

func GetEnvAsInt(key string, defaultValue int) int {
	valueStr := GetEnv(key, "")
	if value, err := strconv.Atoi(valueStr); err == nil {
		return value
	}
	return defaultValue
}

func main() {
	// ดึงค่า MYAPP_DATABASE_HOST
	dbHost := GetEnv("MYAPP_DATABASE_HOST", "localhost")

	// ดึงค่า MYAPP_DATABASE_PORT
	dbPort := GetEnvAsInt("MYAPP_DATABASE_PORT", 5432)

	// ดึงค่า MYAPP_DATABASE_PASSWORD
	dbPassword := GetEnv("MYAPP_DATABASE_PASSWORD", "")

	// แสดงผลลัพธ์
	fmt.Printf("Database Host: %s\n", dbHost)
	fmt.Printf("Database Port: %d\n", dbPort)
	fmt.Printf("Database Password: %s\n", dbPassword)
}

สำหรับการจัดการการตั้งค่าที่ซับซ้อนขึ้น อาจพิจารณาใช้ Library อย่างเช่น Viper แทนการดึงด้วย Package "os" เนื่องจาก Viper มีความยืดหยุ่นกว่า เช่น มี Function สำหรับการแปลงค่าและการตั้งค่า Default และรองรับรูปแบบของการตั้งค่า ได้หลายรูปแบบ เช่น JSON, YAML, TOML รวมทั้ง Environment Variable ด้วย แต่ก่อนการใช้งาน เราต้องติดตั้ง Viper ก่อน เนื่องจาก Viper ไม่ได้เป็น Standard Package เหมือนกับ Package "os"

go get github.com/spf13/viper

จากตัวอย่างด้านล่าง เราจะใช้ Viper ดึงค่าเฉพาะ Environment Variable ที่ใช้ Prefix "MYAPP" เก็บไว้ใน struct "Config"

type Config struct {
	DatabaseHost     string
	DatabasePort     int
	DatabasePassword string
}

โดยคำสั่ง viper.AutomaticEnv() จะถูกใช้เพื่อให้ Viper อ่านค่าจาก Environment Variable โดยอัตโนมัติ

เพื่อให้สามารถใช้โครงสร้างการตั้งค่าที่อ่านง่ายใน Code เราจะใช้ viper.SetEnvKeyReplacer(strings.NewReplacer(".","_")) เพื่อแทนที่ขีดล่างด้วยจุด ซึ่งหากในอนาคตเราต้องการเพิ่มการอ่านค่าจากไฟล์ Config อย่างเช่น YAML หรือ JSON การใช้จุดในโค้ดจะสอดคล้องกับโครงสร้างของไฟล์เหล่านั้น

Viper ช่วยให้เรากำหนดค่า Default ด้วยคำสั่ง viper.SetDefault() และใช้คำสั่ง viper.GetString() และ viper.GetInt() เพื่อดึงค่าจาก Environment Variable แล้วแปลงเป็นชนิดข้อมูลที่ต้องการ (string และ int) โดยไม่ต้องจัดการด้วยตัวเองโดยใช้ strconv.Atoi() เหมือน Code ที่ผ่านมา

package main

import (
	"fmt"
	"log"
	"strings"

	"github.com/spf13/viper"
)

type Config struct {
	DatabaseHost     string
	DatabasePort     int
	DatabasePassword string
}

// อ่านค่าจาก Environment Variable โดยอัตโนมัติ เฉพาะ Environment Variable ที่ใช้ Prefix "MYAPP"
func LoadConfig() (config Config, err error) {
	viper.SetEnvPrefix("MYAPP") // Viper จะแปลงเป็นตัวพิมพ์ใหญ่โดยอัตโนมัติ
	viper.AutomaticEnv()

	// แทนที่จุดด้วยขีดล่าง
	viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))

	// Set default values
	viper.SetDefault("DATABASE.HOST", "localhost")
	viper.SetDefault("DATABASE.PORT", 5432)
	viper.SetDefault("DATABASE.PASSWORD", "")

	// Set config values
	config = Config{
		DatabaseHost:     viper.GetString("DATABASE.HOST"),
		DatabasePort:     viper.GetInt("DATABASE.PORT"),
		DatabasePassword: viper.GetString("DATABASE.PASSWORD"),
	}

	return config, nil
}

func main() {
	config, err := LoadConfig()
	if err != nil {
		log.Fatal("Cannot load config:", err)
	}

	fmt.Printf("Database Configuration:\n")
	fmt.Printf("Host: %s\n", config.DatabaseHost)
	fmt.Printf("Port: %d\n", config.DatabasePort)
	fmt.Printf("Password is set: %v\n", config.DatabasePassword != "")
}

หมายเหตุ ระมัดระวังการแสดงรหัสผ่านโดยตรงใน Output ของ Program ซึ่งในสถานการณ์จริงควรหลีกเลี่ยง!

การทำ Unit Test เบื้องต้น

เราสามารถทดสอบ Package "config" โดยใช้โครงสร้าง Project ดังต่อไปนี้ครับ

myproject/
├── cmd/
│   └── main.go
├── config/
│   ├── config.go
│   └── config_test.go
└── go.mod

config.go คือ ไฟล์ที่เก็บ Code ใน Package "config" ซึ่งมี Function "LoadConfig" ที่จะ Test

// config.go

package config

import (
	"strings"

	"github.com/spf13/viper"
)

type Config struct {
	DatabaseHost     string
	DatabasePort     int
	DatabasePassword string
}

func LoadConfig() (config Config, err error) {
	viper.SetEnvPrefix("MYAPP")
	viper.AutomaticEnv()
	viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))

	// Set default values
	viper.SetDefault("DATABASE.HOST", "localhost")
	viper.SetDefault("DATABASE.PORT", 5432)
	viper.SetDefault("DATABASE.PASSWORD", "")

	// Set config values
	config = Config{
		DatabaseHost:     viper.GetString("DATABASE.HOST"),
		DatabasePort:     viper.GetInt("DATABASE.PORT"),
		DatabasePassword: viper.GetString("DATABASE.PASSWORD"),
	}

	return config, nil
}

config_test.go คือไฟล์ Test ซึ่งต้องอยู่ใน Package เดียวกันกับ Code ที่จะทดสอบ ประกอบด้วย Function ต่าง ๆ ดังนี้

Function "clearEnvVars" มีการดำเนินการดังนี้

Clear ค่า Environment Variables ทั้งหมดของ Program ที่สืบทอดมากจาก Environment Variable ใน Environment แม่ ด้วยคำสั่ง os.Unsetenv()

Function "resetViper" มีการดำเนินการดังนี้

รีเซ็ต Viper ให้กลับสู่สถานะเริ่มต้นเพื่อช่วยให้แต่ละ Test Case เป็นอิสระต่อกัน ไม่มีผลกระทบจาก Test Case ก่อนหน้า

Function "TestLoadConfig" มีการดำเนินการดังนี้

TestLoadConfig เป็น Function หลักในการ Test ที่มี Test Case 2 Test Case

Test Case ที่ 1 สำหรับทดสอบค่า Default ซึ่งเริ่มต้นด้วยการ Clear ค่า Environment Variables ทั้งหมดของ Program รีเซ็ต Viper แล้วเรียกใช้ LoadConfig() ซึ่งจะตรวจสอบว่ามี Error หรือไม่ รวมทั้งตรวจสอบค่า Default ที่ถูกต้อง จากแต่ละฟิลด์ใน struct "Config"

Test Case ที่ 2 สำหรับทดสอบการอ่านค่าจาก Environment Variable โดยการ Clear ค่า Environment Variables ทั้งหมด และรีเซ็ต Viper อีกครั้ง แล้วตั้งค่า Environment Variable ด้วยคำสั่ง os.Setenv() เพื่อทดสอบว่าสามารถอ่านค่าที่ตั้งไว้ได้หรือไม่ เราจะเรียกใช้ LoadConfig() แล้วตรวจสอบว่าค่าที่ได้ว่าตรงกับ Environment Variable หรือไม่

หมายเหตุ ใช้ t.Fatalf() เมื่อเกิด Error ที่ทำให้ไม่สามารถ Test ต่อได้ และใช้ t.Errorf() เพื่อรายงานความผิดพลาดแต่ยังคง Test ต่อไป

// config_test.go

package config

import (
	"os"
	"strings"
	"testing"

	"github.com/spf13/viper"
)

var envVars = []string{
	"MYAPP_DATABASE_HOST",
	"MYAPP_DATABASE_PORT",
	"MYAPP_DATABASE_PASSWORD",
}

func clearEnvVars() {
	for _, env := range envVars {
		os.Unsetenv(env)
	}
}

func resetViper() {
	viper.Reset()
	viper.SetEnvPrefix("MYAPP")
	viper.AutomaticEnv()
	viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
}

func TestLoadConfig(t *testing.T) {
	// Clear Environment Variable ก่อนการ Test
	clearEnvVars()

	// Test case 1: ทดสอบค่า default
	resetViper()
	config, err := LoadConfig()
	if err != nil {
		t.Fatalf("LoadConfig() returned an error: %v", err)
	}

	if config.DatabaseHost != "localhost" {
		t.Errorf("config.DatabaseHost = %s; want localhost", config.DatabaseHost)
	}
	if config.DatabasePort != 5432 {
		t.Errorf("config.DatabasePort = %d; want 5432", config.DatabasePort)
	}
	if config.DatabasePassword != "" {
		t.Errorf("config.DatabasePassword = %s; want empty string", config.DatabasePassword)
	}

	// Clear Environment Variable ก่อนการ Test
	clearEnvVars()

	// Test case 2: ทดสอบการอ่านค่าจาก Environment Variable
	resetViper()
	os.Setenv("MYAPP_DATABASE_HOST", "testhost.com")
	os.Setenv("MYAPP_DATABASE_PORT", "5678")
	os.Setenv("MYAPP_DATABASE_PASSWORD", "testpassword")

	config, err = LoadConfig()
	if err != nil {
		t.Fatalf("LoadConfig() returned an error: %v", err)
	}

	if config.DatabaseHost != "testhost.com" {
		t.Errorf("config.DatabaseHost = %s; want testhost.com", config.DatabaseHost)
	}
	if config.DatabasePort != 5678 {
		t.Errorf("config.DatabasePort = %d; want 5678", config.DatabasePort)
	}
	if config.DatabasePassword != "testpassword" {
		t.Errorf("config.DatabasePassword = %s; want testpassword", config.DatabasePassword)
	}

	// เพิ่ม Test Case อื่น ๆ ตามต้องการ
	// clearEnvVars()
}

ให้สร้างไฟล์ และ Folder ดังต่อไปนี้

myproject/
├── cmd
└── internal
    └── config
        ├── config.go
        └── config_test.go

สร้างไฟล์​ go.mod ด้วยคำสั่ง go mod init myproject

go mod init myproject

ติดตั้ง Package "viper"

go get github.com/spf13/viper

รันคำสั่ง go mod tidy ทุกครั้งที่มีการเปลี่ยนแปลง Dependency เพื่อรักษาความสอดคล้องและความสมบูรณ์ของ Dependency ใน Project

go mod tidy

เริ่มทดสอบด้วยคำสั่ง go test -v ร่วมกับ -count=1 เพื่อให้มั่นใจว่าจะมีการ Test ใหม่ทุกครั้ง โดยไม่มีการดึงผลการ Test มาจาก Cache

go test -v -count=1 ./...

ดู % ของ Coverage ด้วยคำสั่ง go test -cover

go test -cover ./...

หลังจากรัน Unit Test แล้ว เราจะทดลองใช้งาน Package "config" ใน main.go ดังตัวอย่างต่อไปนี้

// main.go

package main

import (
	"fmt"
	"log"

	"myproject/internal/config"
)

func main() {
	cfg, err := config.LoadConfig()
	if err != nil {
		log.Fatalf("Failed to load configuration: %v", err)
	}

	fmt.Printf("Database Configuration:\n")
	fmt.Printf("Host: %s\n", cfg.DatabaseHost)
	fmt.Printf("Port: %d\n", cfg.DatabasePort)
	fmt.Printf("Password is set: %v\n", cfg.DatabasePassword != "")
}

Compile Code ด้วยคำสั่ง go build และรัน Program

go build cmd/main.go

./main 

เมื่อรัน Program ใหม่ Environment Variable ของ Program จะถูกสืบทอดมากจาก Environment แม่ รวมทั้ง Environment Variable ที่มีการ Export ผ่าน Command Line ในครั้งแรก

Exercise

สร้าง Program ที่พิมพ์ข้อความทักทาย "Hello, [ชื่อ]!" โดยใช้ ชื่อ จาก Environment Variable ชื่อ `GREETER_NAME` ด้วย Viper ถ้าไม่มีการตั้งค่า Environment Variable ให้ใช้ชื่อ Default เป็น "World"

Go Quiz 14 (14 ข้อ) ขอให้สนุกกับการทำ Quiz นะครับ

Q&A?

รวม Cheat Sheet การจัดการการตั้งค่า

การจัดการการตั้งค่า
---------------

1. ตั้งค่า Environment Variable
   export VARIABLE_NAME=VALUE // Unix/Linux
   set VARIABLE_NAME=VALUE // Windows

2. อ่านค่า Environment Variable:
   echo $VARIABLE_NAME // Unix/Linux
   echo %VARIABLE_NAME% // Windows

3. อ่านค่าใน Go โดยใช้ package "os"
   value := os.Getenv("VARIABLE_NAME")

4. ตั้งค่า Default Value
   func GetEnv(key, defaultValue string) string {
       value := os.Getenv(key)
       if value == "" {
           return defaultValue
       }
       return value
   }

5. แปลงค่าเป็น int
   func GetEnvAsInt(key string, defaultValue int) int {
       valueStr := GetEnv(key, "")
       if value, err := strconv.Atoi(valueStr); err == nil {
           return value
       }
       return defaultValue
   }

6. ใช้ Viper สำหรับการจัดการ Config
   viper.SetEnvPrefix("MYAPP")
   viper.AutomaticEnv()
   viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))

7. ตั้งค่า Default ใน Viper
   viper.SetDefault("DATABASE.HOST", "localhost")

8. อ่านค่าจาก Viper
   host := viper.GetString("DATABASE.HOST")
   port := viper.GetInt("DATABASE.PORT")

9. ตัวอย่าง struct "Config" ใน Go
   type Config struct {
       DatabaseHost     string
       DatabasePort     int
       DatabasePassword string
   }

10. Best Practices
    - แยกการตั้งค่าที่สำคัญไว้ใน Environment Variable
    - ใช้ Secret Management System เช่น HashiCorp Vault สำหรับ Production
    - หลีกเลี่ยงการแสดงรหัสผ่านโดยตรงใน Output ของ Program

11. การทดสอบ
    - Clear Environment Variables ของ Program ก่อนการทดสอบ
    - ใช้ os.Setenv() เพื่อตั้งค่า Environment Variables ของ Program ในการทดสอบ
    - รีเซ็ต Viper ก่อนแต่ละ Test Case

การทำงานกับเครือข่ายเบื้องต้น

การทำงานกับเครือข่ายอย่างมีประสิทธิภาพเป็นสิ่งสำคัญในการพัฒนา Application ที่ต้องรองรับ User จำนวนมากในเวลาเดียวกัน

Go มี Standard Library "net/http" สำหรับการทำงานกับ HTTP ที่ช่วยให้เราสามารถสร้าง Web Server ของเราเอง และดึงข้อมูลจาก Web ได้

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "ยินดีต้อนรับสู่เว็บไซต์ของฉัน!")
})
http.ListenAndServe(":8080", nil)
การสร้าง Web Server ด้วย Go
url := "localhost:8080"
response, _ := http.Get(url)
body, _ := ioutil.ReadAll(response.Body)
fmt.Println(string(body))  // แสดงข้อมูลสภาพอากาศ
การดึงข้อมูลจาก Web

http.HandleFunc() เป็นเหมือนการตั้งกฎว่า ถ้ามีคนมาที่นี่ เช่น ที่ "/" ให้ทำแบบนี้ เหมือนเป็นการบอกพนักงานว่าต้องทำอะไรเมื่อลูกค้ามาที่ส่วนต่างๆ ของร้าน (Web Server)

http.HandleFunc() จะรับ parameters 2 ตัว คือ Pattern String ที่เป็น URL Path ที่ต้องการให้ Handler Function จัดการ เช่น "/", "/about", "/users/{id}" และ Handler Function ซึ่งเป็น Function ที่จะถูกเรียกเมื่อมี Request มาที่ URL Path ที่ตรงกับ Pattern

Handler function รับผิดชอบในการอ่านข้อมูลจาก Request (เช่น Header และ Body) และเขียนข้อมูลลงใน Response (เช่น Status Code, Header และ Body)

เราสามารถแยก Handler Function ออกมาต่างหากได้ ซึ่งเป็นวิธีที่ดีวิธีหนึ่งในการจัดระเบียบ Code และทำให้ Code อ่านง่ายขึ้น

package main

import (
    "fmt"
    "net/http"
)

func welcomeHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "ยินดีต้อนรับสู่เว็บไซต์ของฉัน!")
}

func main() {
    http.HandleFunc("/", welcomeHandler)
    
    fmt.Println("เซิร์ฟเวอร์กำลังทำงานที่พอร์ต 8080...")
    err := http.ListenAndServe(":8080", nil)
    if err != nil {
        fmt.Printf("เกิดข้อผิดพลาดในการเริ่มเซิร์ฟเวอร์: %v\n", err)
    }
}

Handler Function ต้องรับ Parameters 2 ตัว คือ http.ResponseWriter และ *http.Request

func welcomeHandler(w http.ResponseWriter, r *http.Request)

w http.ResponseWriter สำหรับเขียน Response กลับไปยัง Client สามารถใช้เพื่อ Set Header, Write Response Body และ Set Status Code

r *http.Request เป็น Pointer ไปยัง struct "Request" ที่มีข้อมูลทั้งหมดเกี่ยวกับ HTTP Request ใช้เพื่อเข้าถึงข้อมูลต่าง ๆ เช่น HTTP Method, Header, URL Parameter และ Request Body

ส่วน fmt.Fprintf() ใน Handler Function "welcomeHandler" เป็น Function ที่ช่วยเราเขียนข้อความไปยังที่ต่าง ๆ ไม่ว่าจะเป็นไฟล์ หน้าจอ หรือในกรณีของ Web Server คือการส่งข้อมูลกลับไปให้ Client

เมื่อดึงข้อมูลด้วย Postman โดยใช้ Method GET และ POST เราจะได้รับข้อความ "ยินดีต้อนรับสู่เว็บไซต์ของฉัน!" ตามที่เขียนด้วย fmt.Fprintf()

หมายเหตุ เนื่องจาก welcomeHandler ในตัวอย่างนี้ ไม่มีการกรองให้รับข้อมูลจาก Method ไหนเลย เราจึงสามารถดึงข้อมูลจาก Web Server ได้ทุก Method

เราสามารถดึงข้อมูลจาก Web Server ด้วย net/http เหมือนกับที่เราใช้ Web Browser เพื่อเข้า Website ต่าง ๆ ด้วย Fucntion เช่น http.Get() และ http.Post()

response, err := http.Get("https://example.com")

แล้วใช้ Function "io.ReadAll()" อ่านข้อมูลจาก Response Body และส่งคืนข้อมูลทั้งหมดที่อ่านได้ในรูปแบบของ Byte Slice ซึ่งเราจะใช้ defer response.Body.Close() เพื่อปิด Response Body เมื่อ Function ทำงานเสร็จ

โดย io.ReadAll() เป็น Function ที่ช่วยอ่านข้อมูลทั้งหมดจากแหล่งที่เราส่งให้มัน ลองนึกภาพว่ามันเป็นเหมือนเครื่องถ่ายเอกสารที่จะคัดลอกเอกสารทั้งหน้าให้เราในครั้งเดียว

package main

import (
	"fmt"
	"io"
	"net/http"
)

func main() {
	// URL ของ Server ที่เราต้องการเชื่อมต่อ
	url := "http://localhost:8080"

	// ส่ง GET Request ไปยัง Server
	response, err := http.Get(url)
	if err != nil {
		fmt.Printf("เกิดข้อผิดพลาดในการส่ง request: %v\n", err)
		return
	}
	// ปิด Response Body เมื่อ Function ทำงานเสร็จ
	defer response.Body.Close()

	// อ่านข้อมูลจาก Response Body โดยใช้ io.ReadAll
	body, err := io.ReadAll(response.Body)
	if err != nil {
		fmt.Printf("เกิดข้อผิดพลาดในการอ่าน Response: %v\n", err)
		return
	}

	// แสดงผลลัพธ์
	fmt.Printf("Status Code: %d\n", response.StatusCode)
	fmt.Printf("Content-Type: %s\n", response.Header.Get("Content-Type"))
	fmt.Printf("Body: %s\n", string(body))
}

หมายเหตุ
response.StatusCode และ response.Header เป็นส่วนสำคัญในการทำความเข้าใจและจัดการกับ HTTP Response การใช้งานอย่างถูกต้องจะช่วยให้ Application ของเราสามารถจัดการกับสถานการณ์ต่าง ๆ ได้อย่างมีประสิทธิภาพ

response.StatusCode คือ ฟิลด์ที่เก็บรหัสสถานะของ HTTP Response เป็นตัวเลขจำนวนเต็ม เพื่อแสดงว่าผลลัพธ์ของการ Request สำเร็จหรือล้มเหลว

ตัวอย่างค่า response.StatusCode

200: OK (การร้องขอสำเร็จ)
	- http.StatusOK
    
201: Created (สร้างทรัพยากรใหม่สำเร็จ)
	- http.StatusCreated
    
400: Bad Request (คำขอไม่ถูกต้อง)
	- http.StatusBadRequest
    
401: Unauthorized (ต้องการการยืนยันตัวตน)
	- http.StatusUnauthorized
    
403: Forbidden (ไม่มีสิทธิ์เข้าถึง)
	- http.StatusForbidden
    
404: Not Found (ไม่พบทรัพยากรที่ร้องขอ)
	- http.StatusNotFound
    
500: Internal Server Error (เกิดข้อผิดพลาดภายใน Server)
	- http.StatusInternalServerError

response.Header เป็น Map ที่เก็บ HTTP Header ของ Response (Key-value Pair)

ตัวอย่าง Header (Key)

Content-Type: ระบุประเภทของเนื้อหาใน Response Body
Content-Length: ระบุความยาวของ Response Body
Set-Cookie: ใช้ในการตั้งค่า Cookie
Authorization: ใช้สำหรับการยืนยันตัวตน
Cache-Control: ควบคุมการ Cache
Location: ใช้ในการ Redirect

Gin เป็น Web Framework สำหรับการพัฒนา Web Application ใน Go

Gin เป็น Framework ที่มีประสิทธิภาพสูง เหมาะสำหรับการพัฒนา API ที่ต้องการความเร็ว รองรับการใช้ Middleware ทำให้สามารถจัดการ Request/Response ได้อย่างยืดหยุ่น รวมทั้งมี Function สำเร็จรูปสำหรับการ Validate และ Parse JSON เป็น struct

การใช้ Gin เป็นทางเลือกที่ดีสำหรับการพัฒนา Web Application หรือ API ใน Go โดยเฉพาะเมื่อต้องการความมีประสิทธิภาพ การพัฒนาที่รวดเร็ว และ Feature ที่หลากหลาย อย่างไรก็ตาม สำหรับ Project ขนาดเล็ก หรืองานที่ต้องการควบคุมทุกส่วนของ Application ด้วยตัวเอง การใช้ Standard Library "net/http" อย่างเดียวก็อาจเพียงพอ

package main

import (
	"net/http"
    
	"github.com/gin-gonic/gin"
)

func welcomeHandler(c *gin.Context) {
	c.String(http.StatusOK, "ยินดีต้อนรับสู่เว็บไซต์ของฉัน!")
}

func main() {
	r := gin.Default()
	r.GET("/", welcomeHandler)

	println("เซิร์ฟเวอร์กำลังทำงานที่พอร์ต 8080...")
	err := r.Run(":8080")
	if err != nil {
		printf("เกิดข้อผิดพลาดในการเริ่มเซิร์ฟเวอร์: %v\n", err)
	}
}

ใน Gin เราใช้ gin.Default() แทน http.HandleFunc() และ r.Run() แทน http.ListenAndServe() ซึ่งเหมือนกับการซื้อชุดร้านค้าสำเร็จรูปที่มาพร้อมอุปกรณ์ที่จำเป็น แทนที่จะสร้างร้านค้าด้วยอุปกรณ์พื้นฐาน ซึ่งเราต้องจัดการทุกอย่างเอง

นอกจากนี้เรายังมีการจัดการกับ Method ด้วย r.GET("/", welcomeHandler) ทำให้ Code อ่านเข้าใจง่ายขึ้น

Handler Function ใน Gin จะรับ Parameters เพียงตัวเดียว คือ c ที่มี Type เป็น *gin.Context

func welcomeHandler(c *gin.Context)

gin.Context เป็นเหมือนกล่องใส่ข้อมูลสำคัญที่ Gin ส่งมาให้เรา เช่น ข้อมูล Request และ Response

จากตัวอย่าง Handler Function "welcomeHandler" ด้านบน ข้อความตอบกลับไปยัง Client "ยินดีต้อนรับสู่เว็บไซต์ของฉัน!" จะถูกส่งด้วยคำสั่ง c.String() พร้อมรหัสสถานะ HTTP 200 ซึ่งหมายถึงทุกอย่างเรียบร้อยดี

c.String(http.StatusOK, "ยินดีต้อนรับสู่เว็บไซต์ของฉัน!")

เราจะสร้าง REST API ที่มี Resource เป็น GET /orders/{order_id} ซึ่งเมื่อรัน Server และส่ง GET Request ไปที่ URL http://localhost:8080/orders/123 (เปลี่ยน 123 เป็น order_id อื่นๆ ได้) เราจะได้รับ Response ที่มี JSON ในรูปแบบนี้

{
    "orderid": "123",
    "created_at": "2024-08-14T15:27:27+07:00",
    "estimated_delivery": "2024-08-17T15:27:27+07:00"
}

เตรียมโครงสร้างข้อมูล Order สำหรับใช้ใน API (json:"-" หมายถึงไม่แสดงฟิลด์นี้ใน JSON)

type Order struct {
    OrderID           string    `json:"orderid"`
    CreatedAt         time.Time `json:"created_at"`
    EstimatedDelivery time.Time `json:"estimated_delivery"`
    DeliveryDays      int       `json:"-"`
}

Function "getOrderHandler()" จะรับ order_id จาก URL ด้วยคำสั่ง c.Param() แล้วสร้างข้อมูล Order จำลอง (ในระบบจริงจะดึงจาก Database) แล้วคำนวณวันที่ที่คาดว่าจะส่งมอบโดยใช้ UTC และส่งข้อมูลกลับเป็น JSON ด้วย c.JSON()

แต่ก่อนส่ง JSON กลับ จะมีการเปลี่ยน Timezone เป็น "Asia/Bangkok" และแปลง Format เป็น time.RFC3339 ก่อน

package main

import (
	"fmt"
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
)

type Order struct {
	OrderID           string `json:"orderid"`
	CreatedAt         string `json:"created_at"`      // เปลี่ยนเป็น string
	EstimatedDelivery string `json:"estimated_delivery"` // เปลี่ยนเป็น string
	DeliveryDays      int    `json:"-"`               // ไม่แสดงใน JSON output
}

func CalculateEstimatedDelivery(createdAt time.Time, deliveryDays int) time.Time {
	return createdAt.AddDate(0, 0, deliveryDays)
}

func getOrderHandler(c *gin.Context) {
	orderID := c.Param("order_id")

	// สมมติว่าเราได้ข้อมูล Order จากฐานข้อมูล
	// ในที่นี้เราจะสร้างข้อมูลจำลอง
	createdAt := time.Now().UTC()
	order := Order{
		OrderID:      orderID,
		DeliveryDays: 3, // สมมติว่าใช้เวลาส่ง 3 วัน
	}

	// คำนวณ Estimated Delivery โดยใช้ UTC
	estimatedDelivery := CalculateEstimatedDelivery(createdAt, order.DeliveryDays)

	// กำหนด timezone เป็น Asia/Bangkok
	userLocation, err := time.LoadLocation("Asia/Bangkok")
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to load timezone"})
		return
	}

	// แปลงเวลาเป็น Asia/Bangkok timezone และ format เป็น RFC3339
	order.CreatedAt = createdAt.In(userLocation).Format(time.RFC3339)
	order.EstimatedDelivery = estimatedDelivery.In(userLocation).Format(time.RFC3339)

	c.JSON(http.StatusOK, order)
}

func main() {
	r := gin.Default()
	r.GET("/orders/:order_id", getOrderHandler)

	fmt.Println("เซิร์ฟเวอร์กำลังทำงานที่พอร์ต 8080...")
	err := r.Run(":8080")
	if err != nil {
		fmt.Printf("เกิดข้อผิดพลาดในการเริ่มเซิร์ฟเวอร์: %v\n", err)
	}
}

เมื่อดึงข้อมูลด้วย Postman โดยใช้ Method GET กับ URL http://localhost:8080/orders/123 เราจะได้รับข้อมูล Order กลับมาเป็น JSON Format

Routing เป็นกลไกในการกำหนดว่า Application ควรตอบสนองอย่างไรต่อ HTTP Request ที่มาที่ URL ที่ระบุ โดยใช้ HTTP Method ต่างๆ

ตัวอย่าง Route ของ orders Service

r.POST("/orders", createOrder)
r.GET("/orders", listOrders)
r.GET("/orders/:id", getOrder)
r.PUT("/orders/:id", updateOrder)

Route มีองค์ประกอบ 3 ส่วน คือ HTTP Method, URL Pattern และ Handler Function โดย URL Pattern คือ รูปแบบที่ใช้ในการกำหนดว่า URL ใดจะถูกจับคู่กับ Handler Function ใด

รูปแบบของ URL Pattern เช่น

Static Route

/orders

Path Parameter

/orders/:id 

// ใช้ ':' ตามด้วยชื่อ Parameter
// ตัวอย่าง URL /orders/123

Catch-all Parameter

 /files/*filepath
 
 // ใช้ '*' ตามด้วยชื่อ Parameter
 // จับคู่กับทุกอย่างหลัง /files/ เหมาะสำหรับการจัดการไฟล์หรือ sub-paths ที่ไม่แน่นอน
 // ตัวอย่าง URL /files/images/photo.jpg
 

Multiple Path Parameter

/orders/:orderId/items/:itemId

// ตัวอย่าง URL /orders/1234/items/5678

Query Parameter

/orders?param1=value1&param2=value2

// ตัวอย่าง URL /orders?status=pending&page=1
// สำหรับการ Filter, Sort, หรือ Paginate ข้อมูล
// สามารถแชร์ URL ได้ง่าย เพราะข้อมูลทั้งหมดอยู่ใน URL

// การรับค่า c.Query("param1")

เราสามารถจัดกลุ่ม Route ที่มีลักษณะร่วมกัน เช่น มี URL Prefix เดียวกัน (เช่น มี /api/v1 อยู่ด้านหน้า) หรือใช้ Middleware เหมือนกัน ใน Gin Framework ได้

orders := r.Group("/api/v1", AuthMiddleware())
{
	orders.POST("/orders", createOrder)
	orders.GET("/orders", listOrders)
	orders.GET("/orders/:id", getOrder)
	orders.PUT("/orders/:id", updateOrder)
}

ตัวอย่างนี้มีการสร้าง Group สำหรับ Route ที่เริ่มต้นด้วย "/api/v1" และใช้ Middleware "AuthMiddleware()"

Middleware เป็น Function ที่ทำงานระหว่าง Request และ Response ใน Application มันเหมือนกับ "คนกลาง" ที่สามารถตรวจสอบ แก้ไข หรือทำงานเพิ่มเติมกับ Request หรือ Response ได้

ลองนึกถึง Middleware เหมือนกับการผ่านด่านตรวจต่าง ๆ ในสนามบิน เช่น

  • ตรวจสอบตั๋ว (Authentication)
  • ตรวจสอบกระเป๋า (Validation)
  • ตรวจสอบความปลอดภัย (Security Check)
  • ประทับตราหนังสือเดินทาง (Logging)

โดยแต่ละด่านทำหน้าที่เฉพาะ ซึ่งเราต้องผ่านทุกด่านก่อนขึ้นเครื่อง (ก่อนเข้าถึง Handler Function)

เมื่อเราสร้าง route ด้วยคำสั่ง gin.Default() มันจะมาพร้อม Middleware พื้นฐาน ได้แก่

  • Logger Middleware สำหรับบันทึกข้อมูลการเข้าถึง (Access Log) เช่น IP, เวลา, HTTP Method และ Path
  • Recovery middleware สำหรับกู้คืนจากการ Panic ที่อาจเกิดขึ้นในระหว่างการประมวลผล Request เพื่อป้องกันการล่มของ Application

เราสามารถสร้าง Middleware ของตัวเองได้ตามรูปแบบในตัวอย่างต่อไปนี้

func SimpleMiddleware() gin.HandlerFunc

SimpleMiddleware คือ ชื่อ Function ที่มีการคืนค่าเป็น Type แบบ Function ที่อยู่ใน Package "Gin" ที่ชื่อ gin.HandlerFunc

return func(c *gin.Context) {
   // ... โค้ดข้างใน ...
}
โครงสร้างของ Function ที่ Return

เราใช้ r.Use() เพื่อเพิ่ม Middleware ของตัวเองเข้าไปใน Route

package main

import (
	"log"
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
)

// Middleware ที่พิมพ์ข้อความก่อนและหลังการประมวลผล Request
func SimpleMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {

		log.Println("ก่อนการประมวลผล Request")

		startTime := time.Now()

		// ส่งต่อไปยัง Handler ของ "hello"
		c.Next()

		// หลังการประมวลผล Request
		endTime := time.Now()
		log.Println("หลังการประมวลผล Request, ใช้เวลา ", endTime.Sub(startTime))
	}
}

func helloHandler(c *gin.Context) {
	c.JSON(http.StatusOK, gin.H{
		"message": "Hello, World!",
	})
}

func main() {
	r := gin.Default()

	// ใช้ Middleware ของตัวเอง
	r.Use(SimpleMiddleware())

	// กำหนด Route
	r.GET("/hello", helloHandler)

	r.Run(":8080")
}

เมื่อมี Request เข้ามาที่ "/hello" จะมีลำดับการทำงานดังต่อไปนี้

  1. SimpleMiddleware จะทำงานก่อน โดยการพิมพ์ข้อความ "ก่อนการประมวลผล Request" และเริ่มจับเวลา
  2. Handler ของ "/hello" จะทำงาน โดยการส่ง JSON Response กลับไปยัง Client
  3. SimpleMiddleware จะทำงานต่อ โดยการพิมพ์ข้อความ "หลังการประมวลผล Request, ใช้เวลา ..."

หมายเหตุ ถ้า Handler Function มีการทำงานที่ใช้เวลานานและสามารถทำงานพร้อมกันได้ เราอาจพิจารณาใช้ Goroutine เพื่อไม่ให้ ไป Block การทำงานของ Request อื่น ๆ แต่ตัวอย่างก่อนหน้ามีการทำงานค่อนข้างเร็วและตรงไปตรงมา การใช้ Goroutine จึงอาจเพิ่มความซับซ้อนโดยไม่จำเป็น

Exercise

สร้าง API ที่ช่วยตัดสินใจว่าจะรับประทานอะไร โดยการสุ่มเมนูอาหาร แล้วส่งผลลัพธ์กลับเป็น JSON

import (
	"math/rand"
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
)

func getRandomFood() string {
    foods := []string{"ข้าวผัด 🍚", "ผัดไทย 🥡", "ต้มยำกุ้ง 🍲", "ส้มตำ 🥗", "ไก่ทอด 🍗"}
    return foods[rand.Intn(len(foods))]
}
ตัวอย่าง Function สุ่มอาหาร

เมื่อผู้ใช้ส่ง GET Request ไปที่ Resource '/random' มันจะตอบกลับด้วย Response ที่มี JSON กลับมาในรูปแบบนี้

{
  "food": "ต้มยำกุ้ง 🍲",
  "message": "วันนี้คุณควรทาน ต้มยำกุ้ง 🍲"
}

Go Quiz 15 (16 ข้อ) ขอให้สนุกกับการทำ Quiz นะครับ

Q&A?

รวม Cheat Sheet การทำงานกับเครือข่ายเบื้องต้น

การทำงานกับเครือข่ายเบื้องต้น
-----------------------

1. การสร้าง Web Server พื้นฐาน
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "ยินดีต้อนรับสู่เว็บไซต์ของฉัน!")
	})
	http.ListenAndServe(":8080", nil)
    
2. การดึงข้อมูลจาก Web
	response, err := http.Get("https://example.com")
	body, err := io.ReadAll(response.Body)
	defer response.Body.Close()
    
3. การใช้ Gin Framework
	r := gin.Default()
	r.GET("/", func(c *gin.Context) {
		c.String(http.StatusOK, "ยินดีต้อนรับสู่เว็บไซต์ของฉัน!")
	})
	r.Run(":8080")
    
4. การสร้าง REST API
	r.GET("/orders/:order_id", func(c *gin.Context) {
		orderID := c.Param("order_id")
		// จัดการคำขอ
		c.JSON(http.StatusOK, gin.H{"orderid": orderID})
	})
    
5. การใช้ Middleware
	func SimpleMiddleware() gin.HandlerFunc {
		return func(c *gin.Context) {
			// ทำงานก่อน request
			c.Next()
			// ทำงานหลัง request
		}
	}
	r.Use(SimpleMiddleware())
    
6. การจัดกลุ่ม Route
	orders := r.Group("/api/v1")
	{
		orders.POST("/orders", createOrder)
		orders.GET("/orders", listOrders)
		orders.GET("/orders/:id", getOrder)
	}
    
7. Best Practices
	- ใช้ defer เพื่อปิด Response Body เสมอ
	- ใช้ UTC สำหรับการคำนวณเวลา
	- ใช้ time.RFC3339 สำหรับ Format เวลาใน JSON

การทดสอบขั้นสูง

การทำ Unit Test กับ API และการใช้ Mock Database เป็นเทคนิคสำคัญในการพัฒนา Web Application ซึ่งจะช่วยให้นักพัฒนาสามารถตรวจสอบความถูกต้องของ API และการจัดการข้อมูลได้อย่างมีประสิทธิภาพ โดยไม่ต้องพึ่งพาสภาพแวดล้อมจริง

การทดสอบ API ทำให้มั่นใจได้ว่า API ของเราจะทำงานถูกต้อง รับและส่งข้อมูลได้ดี รวมทั้งมีการจัดการ Error อย่างเหมาะสม ในขณะที่การใช้ Mock Database จะช่วยให้สามารถทดสอบการจัดการขัอมูลได้เร็วขึ้น ควบคุมสภาพแวดล้อมการทดสอบได้ และไม่ส่งผลกระทบต่อข้อมูลจริงใน Database

การทดสอบ API

การทดสอบ API ก็เหมือนกับการจำลองสถานการณ์ที่ลูกค้าเข้ามาสั่งอาหาร โดยเราจะทดสอบว่าเมื่อลูกค้าสั่ง "ข้าวไข่เขียวปู" เราจะเสิร์ฟ "ข้าวไข่เขียวปู" จริงๆ หรือไม่ ถ้าลูกค้าสั่งอาหารที่ไม่มีในเมนู เราบอกพวกเขาอย่างสุภาพไหม รวมทั้งเมื่อถึงเวลาเช็คบิล เราคิดเงินถูกต้องไหม

เพื่อเตรียมความพร้อมแก่ร้านอาหารในการซ้อมรับมือกับการให้บริการลูกค้าในสภาพแวดล้อมที่สามารถควบคุมได้ เราจะจำลองสถานการณ์การทำงานของ API โดยจะ Focus ไปที่การทดสอบ Handler Function โดยตรง ด้วยการตั้งค่า Gin ให้อยู่ในโหมดทดสอบ ซึ่งจะทำให้มันปิดการทำงานของ Middleware พื้นฐานที่อาจมีผลกระทบต่อผลลัพธ์ของการทดสอบ ทำให้ยากต่อการระบุว่าปัญหาเกิดจาก Handler หรือ Middleware

gin.SetMode(gin.TestMode)

พร้อมทั้งจัดเตรียมพนักงานต้อนรับเพื่อการทดสอบโดยเฉพาะ โดยมีคำสั่งว่าถ้าลูกค้ามีการ Oder เมนูนี้จะต้องส่งต่อไปยังพ่อครัว (Hendler Function) คนไหน

r := gin.Default()
r.GET("/hello", HelloHandler)

ในการทำ Unit Test เพื่อทดสอบ API จะไม่มีการส่ง HTTP Request จริง ๆ ออกไปยัง Network

เราจะเตรียม Request ไว้ก่อน เหมือนกับการจำลองรายการอาหารที่ลูกค้าอาจสั่งลงบนกระดาษเพื่อใช้ทดสอบระบบ ด้วยคำสั่ง http.NewRequest()

ซึ่งต่างจากตัวอย่างก่อนหน้าในการดึงข้อมูลจาก Web Server ด้วย http.Get() ที่พนักงานจะมีการส่ง Request ออกไปยัง Network โดยทันที

req, err := http.NewRequest(http.MethodGet, "/hello", nil)

เพื่อช่วยให้เราสามารถตรวจสอบผลลัพธ์ของ Handler Function ที่ทำหน้าที่เหมือนพ่อครัวด้วยการรับ Order (Request) และส่งอาหารที่เตรียมเสร็จแล้วกลับมา (Response) ว่าสามารถเตรียมอาหารได้ถูกต้องหรือไม่ โดยไม่ต้องให้พ่อครัวส่ง HTTP Response กลับมาให้พนักงานเสิร์ฟผ่านเครือข่ายจริง ๆ จึงต้องมีการเตรียมกล่องพิเศษสำหรับเก็บอาหารที่พ่อครัวทำเสร็จด้วย Function "httptest.NewRecorder()" จาก Package "net/http/httptest"

w := httptest.NewRecorder()

เราจะส่ง Request ไปยัง Handler Function โดยตรง โดยไม่ผ่านเครือข่ายจริง เหมือนการเรียกพนักงานมารับ Order ที่จดไว้ในกระดาษ (reg) ซึ่งพนักงานจะส่ง Order ไปให้พ่อครัวด้วยช่องทางเฉพาะ (r.ServeHTTP()) พร้อมฝากกล่องพิเศษสำหรับเก็บอาหารที่ได้จัดเตรียมไว้เมื่อสักครู่ เพื่อให้พ่อครัวใส่อาหารที่ทำเสร็จแล้วกลับมา

r.ServeHTTP(w, req)

ในการทดสอบเราจะเปรียบเทียบ HTTP Status สองค่า ระหว่าง HTTP Response (w.Code) กับค่าที่คาดหวัง (http.StatusOK) ด้วย Function "assert.Equal()" จาก Package "testify/assert" แทนที่จะเขียนเงื่อนไขการทดสอบด้วยตัวเอง พร้อมส่งตัวแปร t ที่ใช้ในการรายงานผลการทดสอบเข้าไปใน Function

การใช้ assert.Equal() จะช่วยให้ Code สั้นและกระชับกว่า ทำให้อ่าน Code ทดสอบง่ายขึ้น ลดโอกาสที่จะเขียนเงื่อนไขผิดพลาด และให้ข้อมูลที่เป็นประโยชน์เมื่อการทดสอบล้มเหลว รวมทั้งสามารถเปรียบเทียบโครงสร้างข้อมูล เช่น Slice, Map, หรือ Struct ได้โดยไม่ต้องเขียน Loop หรือเงื่อนไขที่ซับซ้อน

assert.Equal(t, http.StatusOK, w.Code)

เหมือนมีผู้จัดการร้านอาหารฝีมือดี ที่จะตรวจสอบความถูกต้องของการตอบกลับของพ่อครัวจากกล่องพิเศษสำหรับเก็บอาหาร ซึ่งถ้าลูกค้าได้รับ Status 200 (OK) จะถือว่าเราได้ผ่านการทดสอบใน Case อย่างง่ายแล้ว แต่ถ้ามีการตอบกลับเป็นอย่างอื่น เช่น Status 404 หรือ 500 ก็จะถือว่าการทดสอบนั้นล้มเหลว และมีข้อความแสดงความผิดพลาด

if w.Code != http.StatusOK {
    t.Errorf("Expected status code %d, but got %d", http.StatusOK, w.Code)
}
ตัวอย่างการเขียนเงื่อนไขการทดสอบด้วยตัวเอง โดยไม่ใช้ assert.Equal()

ส่วน Test Case อื่น ๆ เราอาจจะทดสอบกับข้อความใน JSON ที่ตอบกลับมาใน Response Body ว่าใช่ข้อความ "Hello, World!" หรือไม่

โดยในการทดสอบ w.Body.Bytes() จะถูก Unmarshal ไปเป็นข้อมูลประเภท MAP ซึ่งเป็นโครงสร้างข้อมูลที่เหมาะสมสำหรับ JSON ที่มี Key-value Pair ตามตัวอย่างเต็มทางด้านล่างนี้

// hello.go
package handlers

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func HelloHandler(c *gin.Context) {
	c.JSON(http.StatusOK, gin.H{
		"message": "Hello, World!",
	})
}
hello.go
// hello_test.go
package handlers

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/assert"
)

func TestHelloHandler(t *testing.T) {
	// Set Gin to Test Mode
	gin.SetMode(gin.TestMode)

	// Setup the router
	r := gin.Default()
	r.GET("/hello", HelloHandler)

	// Create a test request
	req, err := http.NewRequest(http.MethodGet, "/hello", nil)
	if err != nil {
		t.Fatalf("Couldn't create request: %v\n", err)
	}

	// Create a response recorder
	w := httptest.NewRecorder()

	// Perform the request
	r.ServeHTTP(w, req)

	// Check the status code
	assert.Equal(t, http.StatusOK, w.Code)

	// Parse the response body
	var response map[string]string
	err = json.Unmarshal(w.Body.Bytes(), &response)
	if err != nil {
		t.Fatalf("Couldn't parse response body: %v\n", err)
	}

	// Check the response body
	assert.Equal(t, "Hello, World!", response["message"])
}
hello_test.go

เราสามารถทดสอบ Package "handlers" โดยใช้โครงสร้าง Project ดังต่อไปนี้ครับ

myproject/
├── cmd
│   └── main.go
├── go.mod
└── internal
    └── handlers
        ├── hello.go
        └── hello_test.go

hello.go คือ ไฟล์ที่เก็บ Code ใน Package "handler" ซึ่งมี Function "HelloHandler" ที่จะ Test

hello_test.go คือไฟล์ Test ซึ่งต้องอยู่ใน Package เดียวกันกับ Code ที่จะทดสอบ โดย Function TestHelloHandler ในไฟล์ hello_test.go จะมีการจำลอง HTTP Request และ Response เพื่อทดสอบว่า HelloHandler ทำงานถูกต้องหรือไม่ โดยการส่ง Request ไปที่ "/hello" และตรวจสอบ Status Code รวมทั้ง Message จาก Response ที่ได้รับ

ไฟล์ hello_test.go มีการ Import Package ที่จำเป็น เช่น

net/http และ net/http/httptest สำหรับการจำลอง HTTP Request และ Response

github.com/gin-gonic/gin สำหรับใช้ Gin Framework

github.com/stretchr/testify/assert สำหรับใช้ในการทำ Assertion เพื่อเปรียบเทียบค่าที่คาดหวังกับค่าจริง

เพื่อจะทดสอบ API ให้สร้างไฟล์ และ Folder ดังต่อไปนี้

myproject/
├── cmd
└── internal
    └── handlers
        ├── hello.go
        └── hello_test.go

สร้างไฟล์​ go.mod ด้วยคำสั่ง go mod init myproject

go mod init myproject

ติดตั้ง Package "github.com/gin-gonic/gin"

go get github.com/gin-gonic/gin

ติดตั้ง Package "github.com/stretchr/testify/assert"

go get github.com/stretchr/testify/assert

รันคำสั่ง go mod tidy ทุกครั้งที่มีการเปลี่ยนแปลง Dependency เพื่อรักษาความสอดคล้องและความสมบูรณ์ของ Dependency ใน Project

go mod tidy

เริ่มทดสอบด้วยคำสั่ง go test -v ร่วมกับ -count=1 เพื่อให้มั่นใจว่าจะมีการ Test ใหม่ทุกครั้ง โดยไม่มีการดึงผลการ Test มาจาก Cache

go test -v -count=1 ./...

ดู % ของ Coverage ด้วยคำสั่ง go test -cover

go test -cover ./...

หลังจากรัน Unit Test แล้ว เราจะทดลองสร้าง API ด้วย Package "handlers" ใน main.go ดังตัวอย่างต่อไปนี้

package main

import (
	"log"

	"github.com/gin-gonic/gin"
	"myproject/internal/handlers" 
)

func main() {
	// สร้าง Gin router
	r := gin.Default()

	// กำหนด route
	r.GET("/hello", handlers.HelloHandler)

	// เริ่มต้นรัน Server
	if err := r.Run(":8080"); err != nil {
		log.Fatalf("Failed to run server: %v", err)
	}
}

Compile Code ด้วยคำสั่ง go build รัน Program แล้วทดลองใช้งาน API ผ่าน Browser

go build cmd/main.go

./main 

การใช้ Mock Database

เพื่อความง่ายในการทดสอบ Program ในเบื้องต้น เราจะใช้ Mock Database (ฐานข้อมูลจำลอง) เพื่อทดสอบว่ามันทำงานถูกต้องหรือไม่ ก่อนที่จะทำ Integration Test กับ Database จริงในลำดับต่อไป

เราจะออกแบบ Program ให้สามารถสลับกันระหว่าง Mock Database และ Database จริง โดยไม่ต้องปรับ Code หลักใหม่ระหว่างสลับ ด้วยเทคนิค Dependency Injection

ซึ่งนอกจากการสลับ Database ได้ง่ายแล้ว เทคนิค Dependency Injection ยังทำให้ทีมสามารถพัฒนา Program และ Database คู่ขนานกันไปได้โดยไม่ต้องกังวลว่าจะเกิดผลกระทบต่อกัน

การใช้เทคนิค Dependency Injection สอดคล้องกับหลักการ SOLID โดย Robert C. ในข้อที่ 5 ที่แนะนําให้มีการใช้ Interface เพื่อลดการพึ่งพาในระบบ (Dependency Inversion Principle; DIP)

สมมติว่า Module A ในภาพ คือ หุ่นยนต์ทำความสะอาดบ้าน (CleaningRobot) และ  Module B คือ อุปกรณ์ทำความสะอาดเฉพาะอย่าง เช่น ไม้กวาด (Broom) ไม้ถูพื้น (Mop) หรือเครื่องดูดฝุ่น (VacuumCleaner) ส่วน Interface A คือ ข้อตกลงหรือสัญญา ว่าอุปกรณ์ทำความสะอาดควรมีความสามารถ (Method) อะไรบ้าง

ก่อนใช้ DIP ในภาพด้านซ้าย หุ่นยนต์ (A) จะพึ่งพาไม้กวาด (B) โดยตรง

// ไม้กวาด
type Broom struct{}
func (b Broom) Sweep() string {
    return "กวาดพื้นด้วยไม้กวาด"
}

// หุ่นยนต์ทำความสะอาด
type CleaningRobot struct {
    broom Broom // Embedding Struct Broom, CleaningRobot "Has-A" Broom
}

func (r CleaningRobot) Clean() string {
    r.broom.Sweep()
}

หุ่นยนต์กวาดพื้นด้วยไม้กวาด

func main() {
    robot := CleaningRobot{Broom{}}
    fmt.Println(robot.Clean()) // ผลลัพธ์: กวาดพื้นด้วยไม้กวาด
}

ซึ่งถ้าต้องการใช้อุปกรณ์อื่นในการทำความสะอาด เราจะต้องมีการแก้ไข Code ดังต่อไปนี้

  1. ต้อง แก้ไข struct ของหุ่นยนต์ โดยเปลี่ยน broom Broom เป็น mop Mop
  2. ต้อง แก้ไข Method Clean() โดยเปลี่ยนจากการเรียก r.broom.Sweep() เป็น r.mop.Mop()
  3. ต้อง สร้างหุ่นยนต์ใหม่ โดยเปลี่ยนจาก CleaningRobot{Broom{}} เป็น CleaningRobot{Mop{}}
// ไม้ถูพื้น
type Mop struct{}
func (m Mop) Mop() string {
    return "ถูพื้นด้วยไม้ถูพื้น"
}

// หุ่นยนต์ทำความสะอาด
type CleaningRobot struct {
	mop Mop // Embedding Struct Mop, CleaningRobot "Has-A" Mop
}

func (r CleaningRobot) Clean() {
	r.mop.Mop()
}

หุ่นยนต์ถูพื้นด้วยไม้ถูพื้น

func main() {
    newRobot := CleaningRobot{Mop{}}
    fmt.Println(newRobot.Clean()) // ผลลัพธ์: ถูพื้นด้วยไม้ถูพื้น
}

หลังใช้ DIP ในภาพด้านขวา หุ่นยนต์ (A) จะพึ่งพา CleaningTool Interface (Interface A) ทำให้อุปกรณ์ทำความสะอาดต่าง ๆ (B) จะต้อง Implement CleaningTool Interface แทน หุ่นยนต์ของเราจึงสามารถใช้อุปกรณ์อะไรก็ได้โดย ไม่จำเป็นต้องรู้รายละเอียดการทำงานของอุปกรณ์แต่ละชนิด

// ข้อตกลงหรือสัญญาว่าอุปกรณ์ใดก็ตามที่มี Method "Clean()"
// จะถือว่าอุปกรณ์นั้นเป็น CleaningTool
type CleaningTool interface {
    Clean() string
}

// ไม้กวาด ทำตามสัญญาของ CleaningTool Interface ด้านบน
// ดังนั้นไม้กวาดจึงเป็น CleaningTool
type Broom struct{}
func (b Broom) Clean() string {
    return "กวาดพื้นสะอาดแล้ว"
}

แทนที่ A จะพึ่งพา B โดยตรง ตอนนี้ B กลับเป็นฝ่ายที่ต้องปรับตัวให้เข้ากับ Interface A
ซึ่งคือการ "กลับทิศ" ของการพึ่งพา ตามหลักการ Dependency Inversion ของ SOLID นั่นเอง

// หุ่นยนต์ทำความสะอาด
type CleaningRobot struct {
	// Embedding Interface CleaningTool,
	// CleaningRobot "Has-A" CleaningTool
	tool CleaningTool 
}

// สร้างหุ่นยนต์ทำความสะอาดใหม่
func NewCleaningRobot(t CleaningTool) *CleaningRobot {
	return &CleaningRobot{tool: t}
}

// ให้หุ่นยนต์ทำความสะอาด
func (r *CleaningRobot) DoClean() string {
	return r.tool.Clean()
}

มอบไม้กวาดให้หุ่นยนต์ แล้วสั่งหุ่นยนต์กวาดพื้นด้วยไม้กวาด

func main() {
    // สร้างหุ่นยนต์ที่ใช้ไม้กวาด
    broomRobot := NewCleaningRobot(Broom{})
    fmt.Println(broomRobot.DoClean())  // พิมพ์ "กวาดพื้นสะอาดแล้ว"
}

ถ้าต้องการเปลี่ยนอุปกรณ์อื่นในการทำความสะอาด เช่น ไม้ถูพื้น เราไม่ต้องแก้ไข struct และ Method Clean() ของหุ่นยนต์เหมือน Code ก่อนใช้ DIP เพียงแต่สร้างหุ่นยนต์ใหม่ที่ใช้ไม้ถูพื้นขึ้นมาแทน

// ไม้ถูพื้น ทำตามสัญญาของ CleaningTool Interface ด้านบน
// ดังนั้นไม้ถูพื้นจึงเป็น CleaningTool
type Mop struct{}

func (m Mop) Clean() string {
    return "ถูพื้นเรียบร้อย"
}

func main() {
    // สร้างหุ่นยนต์ที่ใช้ไม้ถูพื้น
    mopRobot := NewCleaningRobot(Mop{})
    fmt.Println(mopRobot.DoClean())  // พิมพ์ "ถูพื้นเรียบร้อย"
}

จากตัวอย่างนี้เรา Inject (ฉีด) CleaningTool เข้าไปใน Function "NewCleaningRobot" เพื่อสร้างหุ่นยนต์ใหม่ โดยหุ่นยนต์ของเราไม่จำเป็นต้องรู้ว่า CleaningTool ซึ่งเป็น Interface คืออะไรกันแน่ (อาจเป็นไม้กวาด ไม้ถูพื้น หรือเครื่องดูดฝุ่น ฯลฯ)

func NewCleaningRobot(tool CleaningTool) *CleaningRobot {
    return &CleaningRobot{tool: tool}
}
Dependency Injection

ดังนั้นในที่นี้ CleaningTool จึงเป็น Dependency ของ CleaningRobot เพราะหุ่นยนต์ต้องการเครื่องมือทำความสะอาดเพื่อทำงานได้

ตัวอย่าง Dependency ในชีวิตประจำวัน
🚗 รถยนต์ต้องการน้ำมัน (น้ำมันเป็น Dependency ของรถ)
📱 โทรศัพท์ต้องการ Battery (Battery เป็น Dependency ของโทรศัพท์)
👨‍🍳 พ่อครัวต้องการวัตถุดิบ (วัตถุดิบเป็น Dependency ของการทำอาหาร)

หมายเหตุ SOLID คือชุดของหลักการออกแบบพื้นฐาน 5 ประการสำหรับนักพัฒนา Software ในการสร้างระบบ Software ที่ดูแลรักษาง่าย ปรับขนาดได้ และมีความยืดหยุ่น โดย DIP หรือ Dependency Inversion Principle เป็นหลักการข้อสุดท้าย (D) ใน SOLID

Code เต็มต่อไปนี้เป็นตัวอย่างในการใช้หลักการ Dependency Inversion โดยจำลองสถานการณ์ของหุ่นยนต์ที่สามารถเปลี่ยนอุปกรณ์ทำความสะอาดในแบบต่าง ๆ ได้ ซึ่งมีการดำเนินการสำคัญ ๆ ดังต่อไปนี้

  1. ประกาศ Interface "CleaningTool" ซึ่งสัญญาว่าอะไรก็ตามที่มี Method "Clean()" ที่คืนค่าเป็น string มันคืออุปกรณ์ทำความสะอาด (CleaningTool) ทำให้เราสามารถเพิ่มอุปกรณ์ชิ้นใหม่ได้ง่ายในอนาคตโดยแค่ทำตามสัญญานี้
  2. ประกาศ struct Broom และ Mop แทนไม้กวาดและไม้ถูพื้น โดยทั้ง 2 struct มีการ Implement Method "Clean()" ดังนั้นมันจึงเป็นอุปกรณ์ทำความสะอาดตามสัญญา
  3. เราจะแยกความรับผิดชอบระหว่างหุ่นยนต์และอุปกรณ์ทำความสะอาด โดยการประกาศ struct "CleaningRobot" เป็นหุ่นยนต์ที่มีฟิลด์ tool เป็นประเภท CleaningTool ซึ่งเป็น Interface ดังนั้นหุ่นยนต์ของเราจึงสามารถใช้อุปกรณ์อะไรก็ได้ที่ทำตามสัญญาด้วยการมี Method "Clean()" ที่คืนค่าเป็น string
  4. ใช้หลักการ Dependency Injection ในการเขียน Function "NewCleaningRobot" สำหรับสร้างหุ่นยนต์ตัวใหม่ที่สามารถ Inject (ฉีด) อุปกรณ์ทำความสะอาด (Dependency) เข้าไปใน Function ซึ่งมันจะส่งคืน Pointer ไปยัง CleaningRobot ตัวใหม่กลับมา ทำให้ง่ายต่อการทำ Unit Test ในภายหลัง ด้วยการฉีด Mock Object แทนอุปกรณ์ทำความสะอาดจริง
  5. เพิ่ม Method "DoClean()" ให้แก่หุ่นยนต์ (CleaningRobot) โดยใน DoClean() จะมีการเรียกใช้ Method "Clean()" ของอุปกรณ์ทำความสะอาดอีกที ซึ่งในอนาคตเราสามารถเพิ่ม Function การทำงานอื่น ๆ ให้แก่หุ่นยนต์ได้
  6. ใน Function "main()" มีการสร้างหุ่นยนต์สองตัว ตัวหนึ่งใช้ไม้กวาด อีกตัวหนึ่งใช้ไม้ถูพื้น โดยมีการเรียกใช้ Mothod "DoClean()" ของหุ่นยนต์แต่ละตัว
package main

import "fmt"

// CleaningTool เป็น Interface ที่แทนอุปกรณ์ทำความสะอาด
type CleaningTool interface {
	Clean() string
}

// ไม้กวาด
type Broom struct{}

func (b Broom) Clean() string {
	return "กวาดพื้นสะอาดแล้ว"
}

// ไม้ถูพื้น
type Mop struct{}

func (m Mop) Clean() string {
	return "ถูพื้นเรียบร้อย"
}

// หุ่นยนต์ทำความสะอาด
type CleaningRobot struct {
	tool CleaningTool
}

// สร้างหุ่นยนต์ทำความสะอาดใหม่
func NewCleaningRobot(t CleaningTool) *CleaningRobot {
	return &CleaningRobot{tool: t}
}

// ให้หุ่นยนต์ทำความสะอาด
func (r *CleaningRobot) DoClean() string {
	return r.tool.Clean()
}

func main() {
	// สร้างหุ่นยนต์ที่ใช้ไม้กวาด
	broomRobot := NewCleaningRobot(Broom{})
	fmt.Println(broomRobot.DoClean()) // พิมพ์ "กวาดพื้นสะอาดแล้ว"

	// สร้างหุ่นยนต์ที่ใช้ไม้ถูพื้น
	mopRobot := NewCleaningRobot(Mop{})
	fmt.Println(mopRobot.DoClean()) // พิมพ์ "ถูพื้นเรียบร้อย"
}

DIP (Dependency Inversion Principle) ช่วยลดการจับคู่ระหว่าง Module แบบแนบแน่น (Tight Coupling)

แทนที่ Module จะพึ่งพากันโดยตรง DIP ใช้ Interface เป็นตัวกลางเพื่อแยกการเพิ่งพา ทำให้แต่ละ Module ไม่จำเป็นต้องรู้จักรายละเอียดการทำงานของกันและกัน รู้เพียงแต่ว่าต้องทำตามสัญญาใน Interface เท่านั้น

อย่างไรก็ตามเราควรระมัดระวังในการใช้ DIP มากเกินไปซึ่งอาจทำให้ Code ซับซ้อนโดยไม่จำเป็นครับ

เพื่อให้เห็นภาพของการประยุกต์ใช้งานมากยิ่งขึ้น เราจะเขียน Program ระบบร้านหนังสือ ที่มีการสลับ Dependency ระหว่าง Database จริง และ Mock Database ดังต่อไปนี้

สร้าง Interface ที่กำหนดความสามารถของ Database หนังสือ ที่มี 3 Method ได้แก่ GetBook(), AddBook() และ DeleteBook() ทำให้สามารถสร้าง Database จริงและ Mock Database ที่มีความสามารถเหมือนกันได้

// BookDatabase เป็น Interface ที่กำหนดว่าฐานข้อมูลหนังสือต้องทำอะไรได้บ้าง
type BookDatabase interface {
    GetBook(id int) (string, error)
    AddBook(title string) error
    DeleteBook(id int) error
}

จำลองการเชื่อมต่อกับ Database จริง โดย Implement Method ตาม BookDatabase Interface ซึ่ง GetBook คืนค่า "Real Book: Fundamental of Deep Learning in Practice" เสมอ (สมมติว่าเป็นข้อมูลจริง)

// RealDatabase เป็นโครงสร้างที่เชื่อมต่อกับ Database จริง
type RealDatabase struct {
    // ในที่นี้เราจะสมมติว่ามีการเชื่อมต่อกับ Database จริง
}

func (db RealDatabase) GetBook(id int) (string, error) {
    // สมมติว่าเป็นการดึงข้อมูลจาก Database จริง
    return "Real Book: Fundamental of Deep Learning in Practice", nil
}

func (db RealDatabase) AddBook(title string) error {
    // สมมติว่าเป็นการเพิ่มหนังสือใน Database จริง
    return nil
}

func (db RealDatabase) DeleteBook(id int) error {
    // สมมติว่าเป็นการลบหนังสือจาก Database จริง
    return nil
}

จำลอง Database สำหรับการทดสอบ โดยใช้ map[int]string เก็บข้อมูลหนังสือ ที่ Implement Metgod ตาม BookDatabase Interface ซึ่ง GetBook จะคืนค่าจาก map

// MockDatabase เป็นโครงสร้างที่จำลอง Database สำหรับการทดสอบ
type MockDatabase struct {
    books map[int]string
}

func (db MockDatabase) GetBook(id int) (string, error) {
    book, exists := db.books[id]
    if !exists {
        return "", fmt.Errorf("book not found")
    }
    return fmt.Sprintf("Mock Book: %s", book), nil
}

func (db MockDatabase) AddBook(title string) error {
    newID := len(db.books) + 1
    db.books[newID] = title
    return nil
}

func (db MockDatabase) DeleteBook(id int) error {
    delete(db.books, id)
    return nil
}

นิยาม BookStore เป็นโครงสร้างหลักของ Program มีฟิลด์ db เป็นประเภท BookDatabase ซึ่งเป็น Interface

// BookStore เป็นโครงสร้างหลักของ Application
type BookStore struct {
    db BookDatabase
}

สร้าง Function สำหรับส่ง Pointer ไปยัง BookStore Object กลับมา โดยการรับ BookDatabase เป็น Parameter (Dependency Injection)

// NewBookStore สร้าง BookStore ใหม่โดยรับ Database ที่จะใช้
func NewBookStore(db BookDatabase) *BookStore {
    return &BookStore{db: db}
}

สร้าง Method "GetBook()", "AddBook()" และ "DeleteBook()" ให้กับ struct "BookStore"

// GetBookTitle เป็น Method ของ BookStore ที่ใช้ดึงชื่อหนังสือ
func (bs *BookStore) GetBookTitle(id int) (string, error) {
    return bs.db.GetBook(id)
}

// AddBook เป็นเมธอดของ BookStore ที่ใช้เพิ่มหนังสือ
func (bs *BookStore) AddBook(title string) error {
	return bs.db.AddBook(title)
}

// DeleteBook เป็นเมธอดของ BookStore ที่ใช้ลบหนังสือ
func (bs *BookStore) DeleteBook(id int) error {
	return bs.db.DeleteBook(id)
}

เราสามารถสลับใช้ Database จริง กับ Mock Database ได้โดยไม่ต้องแก้ไข Code ของ BookStore ด้วยการสร้าง RealDatabase และใช้งานผ่าน BookStore กับสร้าง Mock Database ที่ใช้งานผ่าน BookStore อีกตัว

func main() {
    // ใช้ Database จริง
    realDB := RealDatabase{}
    realStore := NewBookStore(realDB)
    title, _ := realStore.GetBookTitle(1)
    fmt.Println(title)  // พิมพ์ "Real Book: Fundamental of Deep Learning in Practice"

    // ใช้ Mock Database
    mockDB := MockDatabase{books: map[int]string{1: "The Hobbit"}}
    mockStore := NewBookStore(mockDB)
    title, _ = mockStore.GetBookTitle(1)
    fmt.Println(title)  // พิมพ์ "Mock Book: The Hobbit"
}

จากการประยุกต์ใช้ DIP ใน Code ดังกล่าว นอกจากจะทำให้สามารถเปลี่ยนหรือสลับ Database โดยไม่ต้องแก้ไข Code ของ BookStore แล้วยังเป็นการรองรับการขยายตัว เช่นการเพิ่ม Database ประเภทใหม่ในอนาคตอีกด้วย

จากตรงนี้ไปจะเป็นส่วนสำคัญในการทำ Unit Test ด้วย Mock Database หลังจากได้มีการปูพื้นฐานในหลักการของ Dependency Injection มาพอสมควรแล้ว

เราจะสร้าง Package "bookstore" โดยแยก Code ที่ไม่เกี่ยวข้องกับการ Mock Database ไว้ใน File "bookstore.go" และ Code สำหรับการ Mock Database เพื่อทำ Unit Test ใน File "bookstore_test.go"

เราสามารถทดสอบ Package "bookstore" โดยใช้โครงสร้าง Project ดังต่อไปนี้ครับ

myproject/
├── cmd
│   └── main.go
├── go.mod
└── internal
    └── bookstore
        ├── bookstore.go
        └── bookstore_test.go

bookstore.go คือ ไฟล์ที่เก็บ Code ใน Package "bookstore" ซึ่งมี Function ที่จะ Test

bookstore_test.go คือไฟล์ Test ซึ่งต้องอยู่ใน Package เดียวกันกับ Code ที่จะทดสอบ โดยใน bookstore_test.go มี Function ทั้งหมด 3 Function ได้แก่

TestGetBookTitle() สำหรับทดสอบ Method "GetBookTitle()" ของ BookStore โดยการสร้าง Mock Database ที่มีข้อมูลหนังสือตัวอย่าง 2 เล่ม แล้วทดสอบการดึงชื่อหนังสือทั้งในกรณีมีหนังสือและไม่มีหนังสืออยู่ใน Database

TestAddBook() สำหรับทดสอบ Method "AddBook()" ของ BookStore โดยการสร้าง Mock Database เปล่า ทดสอบเพิ่มหนังสือใหม่ และตรวจสอบว่าเพิ่มหนังสือสำเร็จหรือไม่

TestDeleteBook() สำหรับทดสอบ Method "DeleteBook()" ของ BookStore โดยการสร้าง Mock Database ที่มีหนังสือ 1 เล่ม ทดสอบว่ามีหนังสืออยู่ไหม ลบหนังสือแล้วตรวจสอบว่าลบสำเร็จหรือไม่

เราจะใช้ Assert จาก Package "github.com/stretchr/testify/assert" แทนที่จะเขียนเงื่อนไขตรวจสอบด้วยตัวเอง

การใช้ Assert ช่วยให้การเขียน Test Case มีความชัดเจน ทำให้อ่านและเข้าใจง่ายขึ้น โดยมีรายละเอียดของ Assert ดังนี้

assert.Error(t, err)
สำหรับคาดการณ์ว่าจะต้องเกิด Error เช่น กรณีหาหนังสือไม่พบ โดยตรวจสอบว่า err ไม่เป็น nil (มี Error เกิดขึ้น)

assert.Empty(t, got)
สำหรับตรวจสอบว่า got (ค่าที่ได้รับ) เป็นค่าว่างหรือไม่ เช่น กรณีหาหนังสือไม่พบ

assert.NoError(t, err)
สำหรับคาดการณ์ว่าการทำงานจะต้องสำเร็จโดยไม่มี Error โดยตรวจสอบว่า err เป็น nil หรือไม่ (ไม่มี Error เกิดขึ้น)

assert.Equal(t, expected, got)
สำหรับเปรียบเทียบค่าที่คาดหวัง (expected) กับค่าที่ได้จริง (got) ว่าเท่ากันหรือไม่ เช่น กรณีที่ต้องการตรวจสอบว่าได้รับชื่อหนังสือถูกต้องตามที่คาดหวังหรือไม

// bookstore.go

package bookstore

// BookDatabase เป็น Interface ที่กำหนดว่า Book Database ต้องทำอะไรได้บ้าง
type BookDatabase interface {
	GetBook(id int) (string, error)
	AddBook(title string) error
	DeleteBook(id int) error
}

// RealDatabase เป็น struct ที่เชื่อมต่อกับ Database จริง
type RealDatabase struct {
	// ในที่นี้เราจะสมมติว่ามีการเชื่อมต่อกับ Database จริง
}

func (db RealDatabase) GetBook(id int) (string, error) {
	// สมมติว่าเป็นการดึงข้อมูลจาก Database จริง
	return "Real Book: Harry Potter", nil
}

func (db RealDatabase) AddBook(title string) error {
	// สมมติว่าเป็นการเพิ่มหนังสือใน Database จริง
	return nil
}

func (db RealDatabase) DeleteBook(id int) error {
	// สมมติว่าเป็นการลบหนังสือจาก Database จริง
	return nil
}

// BookStore เป็นโครงสร้างหลักของ Application
type BookStore struct {
	db BookDatabase
}

// NewBookStore สร้าง BookStore ใหม่โดยรับ Database ที่จะใช้
func NewBookStore(db BookDatabase) *BookStore {
	return &BookStore{db: db}
}

// GetBookTitle เป็น Method ของ BookStore ที่ใช้ดึงชื่อหนังสือ
func (bs *BookStore) GetBookTitle(id int) (string, error) {
	return bs.db.GetBook(id)
}

// AddBook เป็น Method ของ BookStore ที่ใช้เพิ่มหนังสือ
func (bs *BookStore) AddBook(title string) error {
	return bs.db.AddBook(title)
}

// DeleteBook เป็น Method ของ BookStore ที่ใช้ลบหนังสือ
func (bs *BookStore) DeleteBook(id int) error {
	return bs.db.DeleteBook(id)
}
bookstore.go
// bookstore_test.go

package bookstore

import (
	"fmt"
	"testing"

	"github.com/stretchr/testify/assert"
)

// MockDatabase เป็นโครงสร้างที่จำลอง Database สำหรับการทดสอบ
type MockDatabase struct {
	books map[int]string
}

func (db *MockDatabase) GetBook(id int) (string, error) {
	book, exists := db.books[id]
	if !exists {
		return "", fmt.Errorf("book not found")
	}
	return book, nil
}

func (db *MockDatabase) AddBook(title string) error {
	newID := len(db.books) + 1
	db.books[newID] = title
	return nil
}

func (db *MockDatabase) DeleteBook(id int) error {
	delete(db.books, id)
	return nil
}

func TestGetBookTitle(t *testing.T) {
	mockDB := &MockDatabase{
		books: map[int]string{
			1: "The Go Programming Language",
			2: "Clean Code",
		},
	}

	bookStore := NewBookStore(mockDB)

	tests := []struct {
		name     string
		bookID   int
		expected string
		wantErr  bool
	}{
		{"Existing book", 1, "The Go Programming Language", false},
		{"Another existing book", 2, "Clean Code", false},
		{"Non-existing book", 3, "", true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := bookStore.GetBookTitle(tt.bookID)
			if tt.wantErr {
				assert.Error(t, err)
				assert.Empty(t, got)
			} else {
				assert.NoError(t, err)
				assert.Equal(t, tt.expected, got)
			}
		})
	}
}

func TestAddBook(t *testing.T) {
	mockDB := &MockDatabase{books: make(map[int]string)}
	bookStore := NewBookStore(mockDB)

	title := "New Book"
	err := bookStore.AddBook(title) // ใช้ method ของ BookStore

	assert.NoError(t, err)

	// ทดสอบว่าสามารถดึงหนังสือที่เพิ่งเพิ่มได้
	gotTitle, err := bookStore.GetBookTitle(1) // สมมติว่า ID คือ 1
	assert.NoError(t, err)
	assert.Equal(t, title, gotTitle)
}

func TestDeleteBook(t *testing.T) {
	mockDB := &MockDatabase{
		books: map[int]string{1: "Book to Delete"},
	}
	bookStore := NewBookStore(mockDB)

	// ตรวจสอบว่ามีหนังสืออยู่ก่อน
	_, err := bookStore.GetBookTitle(1)
	assert.NoError(t, err)

	err = bookStore.DeleteBook(1) // ใช้ method ของ BookStore
	assert.NoError(t, err)

	// ตรวจสอบว่าหนังสือถูกลบไปแล้ว
	_, err = bookStore.GetBookTitle(1)
	assert.Error(t, err)
}
bookstore_test.go

การใช้งาน mock.Mock แทนการเขียน Mock Database ด้วยตัวเอง

เราจะใช้ "github.com/stretchr/testify/mock" ซึ่งเป็นส่วนหนึ่งของ Package "testify" ที่เราเคยใช้อยู่แล้ว สำหรับการ Mock Database แทนการใช้ Map

แทนที่จะใช้ Map เก็บข้อมูล เราใช้ mock.Mock เป็น Embedded Struct แทน

mock.Mock เป็นเครื่องมือที่มีประสิทธิภาพสำหรับการทำ Unit Test โดยเฉพาะเมื่อต้องทดสอบระบบที่มีการทำงานซับซ้อน

// MockDatabase เป็น struct ที่ใช้ mock.Mock
type MockDatabase struct {
	mock.Mock // Embedded Struct
}
Code ใหม่หลังการปรับปรุง
// MockDatabase เป็น struct ที่จำลอง Database สำหรับการทดสอบ
type MockDatabase struct {
	books map[int]string
}
Code เดิมก่อนปรับปรุง

เปลี่ยนมาใช้ m.Called() แทน "GetBook()", "AddBook()", และ "DeleteBook()"

func (m *MockDatabase) GetBook(id int) (string, error) {
    args := m.Called(id)
    return args.String(0), args.Error(1)
}
Method "GetBook" ใหม่หลังการปรับปรุง
func (db *MockDatabase) GetBook(id int) (string, error) {
	book, exists := db.books[id]
	if !exists {
		return "", fmt.Errorf("book not found")
	}
	return book, nil
}
Method "GetBook" เดิมก่อนปรับปรุง

m.Called() เป็น Method ที่มาจาก mock.Mock โดยเมื่อเรียก m.Called(id) จะเกิดการบันทึกว่า Method "GetBook()" ถูกเรียกด้วย Argument "id"

args := m.Called(id)

m.Called() จะคืนค่าตามที่เราได้กำหนดไว้ล่วงหน้าผ่าน On().Return()

return args.String(0), args.Error(1)

args.String(0) จะดึงค่า Return ตัวแรกในรูปแบบ string
args.Error(1) จะดึงค่า Return ตัวที่สองในรูปแบบ error

โดยตัวเลข 0 และ 1 หมายถึงลำดับของ Argument ที่เรากำหนดใน On().Return() เช่น

mockDB.On("GetBook", 1).Return("Go Programming", nil)

Code ใหม่ใน File "bookstore_test.go" ที่มีการปรับปรุงโดยใช้งาน mock.Mock แทนการใช้ Map แต่ Logic ในการทดสอบยังคงเหมือนเดิม

// bookstore_test.go

package bookstore

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
)

// MockDatabase เป็นโครงสร้างที่ใช้ mock.Mock
type MockDatabase struct {
	mock.Mock
}

func (m *MockDatabase) GetBook(id int) (string, error) {
	args := m.Called(id)
	return args.String(0), args.Error(1)
}

func (m *MockDatabase) AddBook(title string) error {
	args := m.Called(title)
	return args.Error(0)
}

func (m *MockDatabase) DeleteBook(id int) error {
	args := m.Called(id)
	return args.Error(0)
}

func TestGetBookTitle(t *testing.T) {
	mockDB := new(MockDatabase)
	bookStore := NewBookStore(mockDB)

	tests := []struct {
		name     string
		bookID   int
		mockResp string
		mockErr  error
		wantErr  bool
	}{
		{"Existing book", 1, "The Go Programming Language", nil, false},
		{"Another existing book", 2, "Clean Code", nil, false},
		{"Non-existing book", 3, "", assert.AnError, true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockDB.On("GetBook", tt.bookID).Return(tt.mockResp, tt.mockErr)

			got, err := bookStore.GetBookTitle(tt.bookID)

			if tt.wantErr {
				assert.Error(t, err)
				assert.Empty(t, got)
			} else {
				assert.NoError(t, err)
				assert.Equal(t, tt.mockResp, got)
			}

			mockDB.AssertExpectations(t)
		})
	}
}

func TestAddBook(t *testing.T) {
	mockDB := new(MockDatabase)
	bookStore := NewBookStore(mockDB)

	title := "New Book"
	mockDB.On("AddBook", title).Return(nil)
	mockDB.On("GetBook", 1).Return(title, nil)

	err := bookStore.AddBook(title)
	assert.NoError(t, err)

	gotTitle, err := bookStore.GetBookTitle(1)
	assert.NoError(t, err)
	assert.Equal(t, title, gotTitle)

	mockDB.AssertExpectations(t)
}

func TestDeleteBook(t *testing.T) {
	mockDB := new(MockDatabase)
	bookStore := NewBookStore(mockDB)

	mockDB.On("GetBook", 1).Return("Book to Delete", nil).Once()
	mockDB.On("DeleteBook", 1).Return(nil)
	mockDB.On("GetBook", 1).Return("", assert.AnError)

	_, err := bookStore.GetBookTitle(1)
	assert.NoError(t, err)

	err = bookStore.DeleteBook(1)
	assert.NoError(t, err)

	_, err = bookStore.GetBookTitle(1)
	assert.Error(t, err)

	mockDB.AssertExpectations(t)
}
bookstore_test.go ใหม่

หมายเหตุ assert.AnError เป็นตัวแปรที่เก็บค่า Error ที่ไม่เป็น nil ใช้ในกรณีที่เราต้องการทดสอบว่ามี Error เกิดขึ้น แต่ไม่สนใจว่าเป็น Error อะไรที่เฉพาะเจาะจง

เพื่อจะทดสอบการจัดการข้อมูลให้สร้าง File และ Folder ดังต่อไปนี้

myproject/
├── cmd
└── internal
    └── bookstore
        ├── bookstore.go
        └── bookstore_test.go

สร้างไฟล์​ go.mod ด้วยคำสั่ง go mod init myproject

go mod init myproject

ติดตั้ง Package "github.com/stretchr/testify/assert"

go get github.com/stretchr/testify/assert

รันคำสั่ง go mod tidy ทุกครั้งที่มีการเปลี่ยนแปลง Dependency เพื่อรักษาความสอดคล้องและความสมบูรณ์ของ Dependency ใน Project

go mod tidy

เริ่มทดสอบด้วยคำสั่ง go test -v ร่วมกับ -count=1 เพื่อให้มั่นใจว่าจะมีการ Test ใหม่ทุกครั้ง โดยไม่มีการดึงผลการ Test มาจาก Cache

go test -v -count=1 ./...

ดู % ของ Coverage ด้วยคำสั่ง go test -cover

go test -cover ./...

เนื่องจากเราใช้ Mock Database ในการทดสอบ แต่ RealDatabase ยังไม่ได้ถูกทดสอบ ทำให้ Coverage ต่ำ ถ้าเป็นไปได้ ควรเขียน Test สำหรับ RealDatabase ด้วย รวมทั้งควรต้องมีการทำ Integration Test เพิ่มเติม

หลังจากรัน Unit Test แล้ว เราจะทดลองใช้งาน BookStore ที่มีการ Inject "RealDatabase" ใน main.go ดังตัวอย่างต่อไปนี้

// main.go

package main

import (
	"fmt"
	"log"
	"myproject/internal/bookstore"
)

func main() {
	// สร้าง RealDatabase
	realDB := &bookstore.RealDatabase{}

	// สร้าง BookStore โดย Inject RealDatabase
	store := bookstore.NewBookStore(realDB)

	// ทดลองเพิ่มหนังสือ
	err := store.AddBook("The Go Programming Language")
	if err != nil {
		log.Fatalf("Failed to add book: %v", err)
	}
	fmt.Println("Book added successfully")

	// ทดลองดึงข้อมูลหนังสือ
	title, err := store.GetBookTitle(1) // สมมติว่า ID ของหนังสือคือ 1
	if err != nil {
		log.Fatalf("Failed to get book: %v", err)
	}
	fmt.Printf("Book title: %s\n", title)

	// ทดลองลบหนังสือ
	err = store.DeleteBook(1)
	if err != nil {
		log.Fatalf("Failed to delete book: %v", err)
	}
	fmt.Println("Book deleted successfully")
}

Compile Code ด้วยคำสั่ง go build แล้วรัน Program

go build cmd/main.go

./main 

Exercise

  1. สร้าง File "book_api_test.go" และเขียน Unit Test เพื่อทดสอบ API สำหรับ Program จัดการหนังสือที่อยู่ใน Package "bookapi" ดังต่อไปนี้
// book_api.go

package bookapi

import (
    "net/http"
    "github.com/gin-gonic/gin"
)

type Book struct {
    ID    string `json:"id"`
    Title string `json:"title"`
}

var books = []Book{
    {ID: "1", Title: "Go Programming"},
    {ID: "2", Title: "API Design"},
}

func SetupRouter() *gin.Engine {
    r := gin.Default()
    r.GET("/books", getBooks)
    r.GET("/books/:id", getBookByID)
    return r
}

func getBooks(c *gin.Context) {
    c.JSON(http.StatusOK, books)
}

func getBookByID(c *gin.Context) {
    id := c.Param("id")
    for _, book := range books {
        if book.ID == id {
            c.JSON(http.StatusOK, book)
            return
        }
    }
    c.JSON(http.StatusNotFound, gin.H{"message": "Book not found"})
}

2. สร้าง File "icecream_shop_test.go" และเขียน Unit Test เพื่อทดสอบการจัดการข้อมูล ที่อยู่ใน Package "icecreamshop" ดังต่อไปนี้

// icecream_shop.go

package icecreamshop

// IceCreamDatabase เป็น Interface ที่กำหนดว่า Ice Cream Database ต้องทำอะไรได้บ้าง
type IceCreamDatabase interface {
    GetFlavor(id int) (string, error)
    AddFlavor(flavor string) error
    DeleteFlavor(id int) error
}

// RealDatabase เป็น struct ที่เชื่อมต่อกับ Database จริง
type RealDatabase struct {
    // ในที่นี้เราจะสมมติว่ามีการเชื่อมต่อกับ Database จริง
}

func (db RealDatabase) GetFlavor(id int) (string, error) {
    // สมมติว่าเป็นการดึงข้อมูลจาก Database จริง
    return "Real Flavor: Chocolate Chip", nil
}

func (db RealDatabase) AddFlavor(flavor string) error {
    // สมมติว่าเป็นการเพิ่มรสชาติไอศกรีมใน Database จริง
    return nil
}

func (db RealDatabase) DeleteFlavor(id int) error {
    // สมมติว่าเป็นการลบรสชาติไอศกรีมจาก Database จริง
    return nil
}

// IceCreamShop เป็นโครงสร้างหลักของ Application
type IceCreamShop struct {
    db IceCreamDatabase
}

// NewIceCreamShop สร้าง IceCreamShop ใหม่โดยรับ Database ที่จะใช้
func NewIceCreamShop(db IceCreamDatabase) *IceCreamShop {
    return &IceCreamShop{db: db}
}

// GetIceCreamFlavor เป็น Method ของ IceCreamShop ที่ใช้ดึงชื่อรสชาติไอศกรีม
func (shop *IceCreamShop) GetIceCreamFlavor(id int) (string, error) {
    return shop.db.GetFlavor(id)
}

// AddNewFlavor เป็น Method ของ IceCreamShop ที่ใช้เพิ่มรสชาติไอศกรีมใหม่
func (shop *IceCreamShop) AddNewFlavor(flavor string) error {
    return shop.db.AddFlavor(flavor)
}

// DiscontinueFlavor เป็น Method ของ IceCreamShop ที่ใช้ยกเลิกรสชาติไอศกรีม
func (shop *IceCreamShop) DiscontinueFlavor(id int) error {
    return shop.db.DeleteFlavor(id)
}

Go Quiz 16 (20 ข้อ) ขอให้สนุกกับการทำ Quiz นะครับ

Q&A?

รวม Cheat Sheet การทดสอบขั้นสูง

การทดสอบขั้นสูง
------------

1. การทดสอบ API
	func TestAPIEndpoint(t *testing.T) {
		// ตั้งค่า Gin ให้อยู่ในโหมดทดสอบ
		gin.SetMode(gin.TestMode)
		
		// สร้าง Router
		r := gin.Default()
		r.GET("/hello", HelloHandler)
		
		// สร้าง Request
		req, _ := http.NewRequest(http.MethodGet, "/hello", nil)
		
		// สร้าง Response Recorder
		w := httptest.NewRecorder()
		
		// ส่ง Request ไปยัง Handler Function
		r.ServeHTTP(w, req)
		
		// ตรวจสอบ Status Code
		assert.Equal(t, http.StatusOK, w.Code)
		
		// ตรวจสอบ Response Body
		var response map[string]string
		json.Unmarshal(w.Body.Bytes(), &response)
		assert.Equal(t, "Hello, World!", response["message"])
	}

2. การใช้ Dependency Injection
	type BookDatabase interface {
		GetBook(id int) (string, error)
	}

	type BookStore struct {
		db BookDatabase
	}

	func NewBookStore(db BookDatabase) *BookStore {
		return &BookStore{db: db}
	}

3. การใช้ Mock Database
	type MockDatabase struct {
		mock.Mock
	}

	func (m *MockDatabase) GetBook(id int) (string, error) {
		args := m.Called(id)
		return args.String(0), args.Error(1)
	}

	func TestGetBookTitle(t *testing.T) {
		mockDB := new(MockDatabase)
		bookStore := NewBookStore(mockDB)
		
		mockDB.On("GetBook", 1).Return("The Go Programming Language", nil)
		
		title, err := bookStore.GetBookTitle(1)
		
		assert.NoError(t, err)
		assert.Equal(t, "The Go Programming Language", title)
		mockDB.AssertExpectations(t)
	}

4. การใช้ Assert จาก Testify
	import "github.com/stretchr/testify/assert"

	func TestSomething(t *testing.T) {
		result, err := SomeFunction()
		assert.Equal(t, expected, result)
		assert.NoError(t, err)
		assert.Error(t, err)
	}

5. Best Practice
	- ใช้ Table-Driven Test เพื่อทดสอบหลายกรณีใน Test Function เดียว
	- ใช้ Mock Object สำหรับ Dependency ภายนอก
	- ตั้งชื่อ Function ทดสอบให้มีความหมาย เช่น TestGetBookTitle_ExistingBook
	- ใช้ Subtest (t.Run) เพื่อจัดกลุ่มการทดสอบที่เกี่ยวข้องกัน
	- ทำให้การทดสอบเป็น Deterministic (ให้ผลลัพธ์เหมือนเดิมทุกครั้งที่รัน)

การทำงานกับ Database เบื้องต้น

Go สามารถทำงานกับ SQL Database ได้อย่างมีประสิทธิภาพ ด้วยวิธีการเขียน SQL Query แบบ Prepared Statement เพื่อป้องกัน SQL Injection และการจัดการ Connection Pooling ซึ่งจะเพิ่มความสามารถในการรองรับการทำงานพร้อมกัน และลดภาระงานของ Database Server ลง ฯลฯ

ในหัวข้อนี้เราจะปรับปรุง Program ระบบร้านหนังสือ โดยการฉีด Database Object ที่เชื่อมต่อกับ PostgreSQL ซึ่งมีการจัดการ Connection Pooling เข้าไปใน Function "NewBookStore" ทำให้เราสามารถดึงชื่อหนังสือ เพิ่มหนังสือ และลบหนังสือจาก Database จริง ๆ ได้ด้วยคำสั่ง SQL แบบ Prepared Statement ของ Go

เราจะติดตั้ง PostgreSQL และ pgAdmin ซึ่งเป็น GUI (Graphical User Interface) สำหรับจัดการฐานข้อมูล PostgreSQL บน Docker Container พร้อมสร้างตาราง "books" ที่มี Schema ดังต่อไปนี้

แต่ก่อนอื่นให้ Download Docker Desktop และติดตั้งบนระบบปฎิบัติการของท่าน แล้วสร้าง File และ Folder ตามโครงสร้างของ Project ดังนี้

bookstoredatabase
├── .env
├── docker
│   ├── Dockerfile
│   └── init.sql
└── docker-compose.yml
services:
  db:
    build: ./docker
    container_name: bookstore_postgres
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "${POSTGRES_PORT}:5432"
    restart: unless-stopped

  pgadmin:
    image: dpage/pgadmin4
    container_name: bookstore_pgadmin
    environment:
      PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL}
      PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD}
    volumes:
      - pgadmin_data:/var/lib/pgadmin
    ports:
      - "${PGADMIN_PORT}:80"
    restart: unless-stopped
    depends_on:
      - db

volumes:
  postgres_data:
  pgadmin_data:
docker-compose.yml
# PostgreSQL Environment Variables
POSTGRES_DB=bookstore
POSTGRES_USER=bookstore_user
POSTGRES_PASSWORD=your_strong_password
POSTGRES_PORT=5432

# pgAdmin Environment Variables
PGADMIN_DEFAULT_EMAIL=nuttachot@hotmail.com
PGADMIN_DEFAULT_PASSWORD=password
PGADMIN_PORT=5050
.env
FROM postgres:14-alpine

# Copy initialization scripts
COPY init.sql /docker-entrypoint-initdb.d/

# Expose the PostgreSQL port
EXPOSE 5432
Dockerfile
-- ตรวจสอบว่า database bookstore มีอยู่แล้วหรือไม่
SELECT 'CREATE DATABASE bookstore'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'bookstore');

-- เชื่อมต่อกับ Database ที่สร้างขึ้น
\c bookstore

-- สร้างตาราง books
CREATE TABLE books (
    id SERIAL PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- สร้าง function สำหรับอัพเดท updated_at โดยอัตโนมัติ
CREATE OR REPLACE FUNCTION update_modified_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = now();
    RETURN NEW;
END;
$$ language 'plpgsql';

-- สร้าง trigger เพื่อเรียกใช้ function update_modified_column
CREATE TRIGGER update_books_modtime
BEFORE UPDATE ON books
FOR EACH ROW
EXECUTE FUNCTION update_modified_column();

-- สร้าง index บน title เพื่อเพิ่มประสิทธิภาพการค้นหา
CREATE INDEX idx_books_title ON books(title);

-- เพิ่มข้อมูลตัวอย่าง
INSERT INTO books (title) VALUES 
    ('Fundamental of Deep Learning in Practice'),
    ('Practical DevOps and Cloud Engineering'),
    ('Mastering Golang for E-commerce Back End Development');
init.sql

ติดตั้ง PostgreSQL ด้วยคำสั่ง docker-compose up -d

docker-compose up -d

ตรวจสอบสถานะการทำงานของ Container ด้วยคำสั่ง docker-compose ps

ติดตั้ง PostgreSQL Driver

go get github.com/lib/pq

Import Package "database/sql" สำหรับการทำงานกับ SQL Database และ Package "github.com/lib/pq" ซึ่งเป็น Driver สำหรับ PostgreSQL รวมทั้ง Package "fmt"

import (
    "database/sql"
    "fmt"
    _ "github.com/lib/pq"
)

หมายเหตุ เครื่องหมาย _ หมายถึงการไม่ได้ใช้งาน Package นี้โดยตรงใน Code แต่เรายังต้องการให้มีการเรียกใช้ Function "init()" ของ Package

ประกาศ Interface "BookDatabase" ซึ่งสัญญาว่าอะไรก็ตามที่มี Method "GetBook()", "AddBook()", "DeleteBook()" และ "Close()" มันคือ BookDatabase

type BookDatabase interface {
    GetBook(id int) (string, error)
    AddBook(title string) error
    DeleteBook(id int) error
    Close() error
}

ประกาศ PostgresDatabase เป็น struct ที่จะเชื่อมต่อกับ PostgreSQL Database จริง

type PostgresDatabase struct {
    db *sql.DB
}

สร้าง Founction NewPostgresDatabase ที่เชื่อมต่อกับ PostgreSQL โดยใช้ Connection String ที่ให้มา และทดสอบการเชื่อมต่อด้วย Ping()

func NewPostgresDatabase(connStr string) (*PostgresDatabase, error) {
    db, err := sql.Open("postgres", connStr)
    if err != nil {
        return nil, fmt.Errorf("failed to connect to database: %v", err)
    }
    
    err = db.Ping()
    if err != nil {
        return nil, fmt.Errorf("failed to ping database: %v", err)
    }
    
    return &PostgresDatabase{db: db}, nil
}

Implement Method ของ BookDatabase Interface

GetBook
Method "GetBook()" เป็นส่วนหนึ่งของ PostgresDatabase struct ใช้สำหรับดึงชื่อหนังสือจาก Database ด้วย Prepared Statement โดยรับ Parameter เป็น id และคืนค่าเป็น string (ชื่อหนังสือ) และ error

Prepared Statement เป็นเทคนิคในการป้องกัน SQL Injection ซึ่งเป็นช่องโหว่ด้านความปลอดภัยที่อันตราย โดยการเขียน SQL Query แยกส่วนคำสั่ง SQL และข้อมูลออกจากกัน ทำให้รู้ว่าอะไรเป็นคำสั่ง อะไรเป็นข้อมูล ไม่ว่าเราจะใส่ข้อมูลอะไรมา ก็จะถูกมองเป็นข้อมูลเสมอ ไม่ใช่คำสั่ง

// แบบปลอดภัยใช้ Prepared Statement
stmt, err := db.Prepare("SELECT * FROM users WHERE username = $1 AND password = $2")
if err != nil {
    return fmt.Errorf("failed to prepare statement: %v", err)
}
defer stmt.Close() // ปิด stmt หลังจากใช้งานเสร็จเพื่อคืนทรัพยากรให้กับระบบ

rows, err := stmt.Query("admin", "password")
Prepared Statement
// แบบไม่ปลอดภัย
username := "admin' --"
query := "SELECT * FROM users WHERE username = '" + username + "' AND password = 'password'"

// SELECT * FROM users WHERE username = 'admin' --' AND password = 'password'
SQL Injection

หมายเหตุ "--" ในภาษา SQL คือการ Comment ดังนั้นส่วนที่เหลือของ Query จะถูกละเว้น คำสั่งนี้จึงเป็นการดึงข้อมูลของผู้ใช้ที่มี username เป็น 'admin' โดยไม่ตรวจสอบรหัสผ่าน ถ้าเราใช้ผลลัพธ์นี้เพื่อตัดสินใจว่าการ Login สำเร็จหรือไม่ ก็จะทำให้ Hacker เข้าสู่ระบบในฐานะ Admin ได้โดยไม่ต้องรู้รหัสผ่าน

การใช้ Prepared Statement ไม่เพียงแต่จะป้องกัน SQL Injection เท่านั้น มันยังช่วยเพิ่มประสิทธิภาพในกรณีที่ต้องรันคำสั่ง SQL เดิมซ้ำ ๆ ด้วยข้อมูลต่างกันอีกด้วย

Go มีวิธีการใช้ Prepared Statement หลายแบบ เราสามารถใช้ QueryRow() ในการค้นหาข้อมูลที่คาดว่า จะมีผลลัพธ์เพียงหนึ่งแถว เช่น การค้นหาด้วย Primary Key

QueryRow() ต้องใช้ควบคู่กับ .Scan() เพื่อดึงข้อมูลจากผลลัพธ์ โดยไม่จำเป็นต้องปิด (Close) เหมือนกับ Query()

err := pdb.db.QueryRow("SELECT title FROM books WHERE id = $1", id).Scan(&title)

"SELECT title FROM books WHERE id = $1" คือคำสั่ง SQL ที่ใช้ดึงข้อมูล โดย  $1  เป็นตัวแทน (Placeholder) ของค่า Parameter ตัวแรกที่เราจะส่งเข้าไป และ , id เป็นการส่งค่า id เข้าไปแทนที่ $1 ในคำสั่ง SQL

สัญลักษณ์ Placeholder ของ PostgreSQL คือ $1, $2, ... ซึ่งแต่ละ Driver อาจจะมีสัญลักษณ์ที่แตกต่างกันออกไป

หลังจากที่ได้ผลลัพธ์จาก Database เราจะใช้ Scan() เพื่อนำค่าที่ได้มาใส่ในตัวแปร title

func (pdb *PostgresDatabase) GetBook(id int) (string, error) {
	var title string
	err := pdb.db.QueryRow("SELECT title FROM books WHERE id = $1", id).Scan(&title)
	if err != nil {
    	// sql.ErrNoRows เป็นค่าคงที่ที่ใช้ในการตรวจสอบว่าไม่พบข้อมูลจากการ Query โดยเฉพาะเมื่อใช้ QueryRow()
		if err == sql.ErrNoRows {
			return "", fmt.Errorf("book not found")
		}
		return "", fmt.Errorf("failed to get book: %v", err)
	}
	return title, nil
}
GetBook

เพิ่มเติม แต่ถ้าต้องการ Query หนังสือทุกเล่ม เราต้องใช้ pdb.db.Query() แทน pdb.db.QueryRow()

rows, err := pdb.db.Query("SELECT title FROM books")

เมื่อมีการเรียก pdb.db.Query() มันจะส่งคืน *sql.Rows (Interface) ที่ใช้จัดการผลลัพธ์จากการ Query Database

*sql.Rows ไม่ใช่ Cursor โดยตรงแต่ทำหน้าที่คล้ายกัน เมื่อรับมันมามันจะยังไม่ได้ชี้ไปที่ข้อมูล การเรียกใช้ Method "rows.Next()" ในครั้งแรก จะทำให้มีการเลื่อน *sql.Rows ไปยังแถวแรกของผลลัพธ์ (ถ้ามี)

ถ้ามีข้อมูล rows.Next() จะคืนค่า true แต่ถ้าไม่มีข้อมูล rows.Next() จะคืนค่า false ดังนั้นเราสามารถใช้ for rows.Next() ในการวน Loop เพื่ออ่านข้อมูลจากแถวปัจจุบันที่ *sql.Rows กำลังชี้อยู่จนกว่าจะหมด

for rows.Next() {
    // อ่านข้อมูลจากแถวปัจจุบัน
}

rows.Scan() เป็นเมธอดที่ใช้ในการอ่านข้อมูลจากแถวปัจจุบันที่ *sql.Rows กำลังชี้ มันจะรับ Parameter เป็น Pointer ไปยังตัวแปรที่จะเก็บค่า

var title string
if err := rows.Scan(&title); err != nil {
    // จัดการกับข้อผิดพลาด
}

การใช้ rows.Next() และ rows.Scan() ร่วมกันใน Loop จะทำให้เราสามารถอ่านชื่อหนังสือจากทุกแถวที่ Query ได้ และเก็บผลลัพธ์ลงใน Slice ของ string

var titles []string
for rows.Next() {
    var title string
    if err := rows.Scan(&title); err != nil {
        return nil, fmt.Errorf("failed to scan book title: %v", err)
    }
    titles = append(titles, title)
}

เราควรใช้ defer rows.Close() ในการปิด rows หลังจากใช้งานเสร็จ เพื่อคืนทรัพยากรให้กับระบบ

Code เต็มของ Method "GetAllBooks()" สำหรับดึงชื่อหนังสือทั้งหมดอาจมีดังนี้

func (pdb *PostgresDatabase) GetAllBooks() ([]string, error) {
	rows, err := pdb.db.Query("SELECT title FROM books")
	if err != nil {
		return nil, fmt.Errorf("failed to query books: %v", err)
	}
	defer rows.Close()

	var titles []string
	for rows.Next() {
		var title string
		if err := rows.Scan(&title); err != nil {
			return nil, fmt.Errorf("failed to scan book title: %v", err)
		}
		titles = append(titles, title)
	}

	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("error iterating over books: %v", err)
	}

	return titles, nil
}
GetAllBooks

AddBook
Method "AddBook()" เป็นส่วนหนึ่งของ PostgresDatabase struct ใช้สำหรับเพิ่มหนังสือใหม่ใน Database ด้วย Prepared Statement โดยรับ Parameter "title" เป็น string และคืนค่าเป็น error (ถ้ามี)

เราจะใช้ Exec() สำหรับรันคำสั่ง SQL ที่ไม่ต้องการผลลัพธ์กลับมา (เช่น INSERT, UPDATE, DELETE)ในการเพิ่มหนังสือ

_, err := pdb.db.Exec("INSERT INTO books (title) VALUES ($1)", title)

title จะถูกส่งเป็น Parameter ซึ่งจะถูกแทนที่ใน $1 ซึ่งการใช้ $1 และส่ง title เป็น Parameter จะช่วยป้องกัน SQL Injection

func (pdb *PostgresDatabase) AddBook(title string) error {
	_, err := pdb.db.Exec("INSERT INTO books (title) VALUES ($1)", title)
	if err != nil {
		return fmt.Errorf("failed to add book: %v", err)
	}
	return nil
}
AddBook

DeleteBook
Method "DeleteBook()" เป็นส่วนหนึ่งของ PostgresDatabase struct ใช้สำหรับลบหนังสือออกจาก Database ด้วย Prepared Statement โดยรับ Parameter เป็น id และคืนค่าเป็น error (ถ้ามี)

เราจะใช้ Exec() เพื่อรันคำสั่ง DELETE โดยเก็บผลลัพธ์ของการลบหนังสือในตัวแปร result

result, err := pdb.db.Exec("DELETE FROM books WHERE id = $1", id)

ตรวจสอบว่ามีการลบเกิดขึ้นจริงไหม จากจำนวนแถวที่ถูกลบที่ได้มาด้วยคำสั่ง result.RowsAffected()

rowsAffected, err := result.RowsAffected()

ถ้า rowsAffected = 0 แสดงว่าไม่พบหนังสือที่ต้องการลบ

func (pdb *PostgresDatabase) DeleteBook(id int) error {
	result, err := pdb.db.Exec("DELETE FROM books WHERE id = $1", id)
	if err != nil {
		return fmt.Errorf("failed to delete book: %v", err)
	}
	
	rowsAffected, err := result.RowsAffected()
	if err != nil {
		return fmt.Errorf("failed to get rows affected: %v", err)
	}
	
	if rowsAffected == 0 {
		return fmt.Errorf("book not found")
	}
	
	return nil
}
DeleteBook

Close
Method "Close()" เป็นส่วนหนึ่งของ PostgresDatabase struct ใช้สำหรับปิดการเชื่อมต่อกับฐานข้อมูล

func (pdb *PostgresDatabase) Close() error {
	return pdb.db.Close()
}
Close

เพิ่มประสิทธิภาพในการจัดการการเชื่อมต่อฐานข้อมูลให้มากขึ้นด้วยการใช้ Connection Pooling

Go มี Connection Pooler ให้ใช้งานผ่าน Standard Package "database/sql"

Connection Pooler คือ ตัวกลางที่จัดการการเชื่อมต่อระหว่าง Application กับ PostgreSQL ทำให้ลดจำนวนการเชื่อมต่อที่ต้องจัดการโดยตรงด้วย PostgreSQL

เราอาจเลือกใช้ PgBouncer ซึ่งเป็น Open-source Connection Pooler Server ที่ทำงานอยู่ระหว่าง Application และ Database เหมือนกับภาพด้านบน หรือใช้ Connection Pooler ของ Go ที่ทำงานในระดับ Application (Go's Built-in Connection Pooling) โดยตรง โดยไม่ต้องติดตั้ง Server เพิ่ม

PostgreSQL Database เปรียบเสมือนร้านอาหารขนาดใหญ่ที่มีลูกค้าจำนวนมาก ซึ่งมีพ่อครัวและพนักงานเสิร์ฟหลายคนคอยให้บริการ แต่ก็มีข้อจำกัดด้านพื้นที่ครัว อุปกรณ์ และกำลังคน

Connection Pooler (เช่น PgBouncer หรือ Connection Pooler ของ Go) เปรียบได้กับระบบจัดการโต๊ะ (Pool) ของร้านอาหารให้พร้อมรับลูกค้าอยู่เสมอ

เมื่อมีลูกค้าเข้ามาพนักงานจะจัดสรรโต๊ะที่ว่างให้ทันทีโดยไม่ต้องรอเตรียมโต๊ะใหม่ หลังจากลูกค้ารับประทานอาหารเสร็จ โต๊ะนั้นจะถูกทำความสะอาดและพร้อมรับลูกค้ารายต่อไปทันที ผู้จัดการร้านสามารถเพิ่มหรือลดจำนวนโต๊ะตามตามจำนวนลูกค้าในแต่ละช่วงเวลา

หมายเหตุ การปรับจำนวน Connection ตามความต้องการในแต่ละช่วงเวลาเป็นคุณสมบัติของ Connection Pooler ภายนอก เช่น PgBouncer ซึ่งสามารถปรับการตั้งค่าแบบ Dynamic ได้

ระบบนี้ช่วยให้ร้านอาหารสามารถลดเวลารอของลูกค้า เพราะมีโต๊ะพร้อมให้บริการเสมอ ทำให้มีการใช้ทรัพยากรของร้านทั้งพื้นที่และพนักงานอย่างมีประสิทธิภาพ สามารถรองรับลูกค้าจำนวนมากได้โดยไม่ทำให้พ่อครัวทำงานหนักเกินไป

หากไม่มี Connection Pooler ในระบบร้านอาหารของเรา ทุกครั้งที่มีลูกค้าใหม่เข้ามาพนักงานต้องวิ่งไปเตรียมโต๊ะใหม่ทุกครั้งแม้ว่าจะมีลูกค้าเพิ่งลุกออกไป ทำให้เกิดความล่าช้าในการให้บริการ เพราะต้องใช้เวลาในการเตรียมโต๊ะใหม่ทุกครั้ง รวมทั้งในช่วงเวลาเร่งด่วนร้านอาจไม่สามารถรองรับลูกค้าได้ทันที เพราะต้องรอการเตรียมโต๊ะ และอาจเกิดคอขวดที่หน้าร้านเพราะลูกค้าต้องรอโต๊ะว่าง

สมมติว่า PostgreSQL สามารถรองรับการเชื่อมต่อพร้อมกันได้ 100-300 Connection (จำนวนจริงขึ้นอยู่กับหลายปัจจัย เช่น Hardware การตั้งค่า และลักษณะของ Application ฯลฯ) แต่ละ Connection สามารถทำธุรกรรม (Transaction) ได้ประมาณ 1-5 TPS (Transactions Per Second) ดังนั้น PostgreSQL จะรองรับ Transaction ได้ประมาณ 100-1,500 TPS โดยตรง (ประมาณการอย่างหยาบ ๆ) หากมีลูกค้ามาใช้บริการเกิน 300 คน (300 Connection) ลูกค้าคนที่ 301 เป็นต้นไปจะต้องรอจนกว่าจะมี Connection ว่าง และอาจทำให้เกิด Timeout หรือการยกเลิกการเชื่อมต่อโดยอัตโนมัติ

https://pganalyze.com/blog/postgres-14-performance-monitoring

รวมทั้ง PostgreSQL อาจปฏิเสธการเชื่อมต่อใหม่ทั้งหมดเมื่อถึงขีดจำกัด และในกรณีรุนแรงก็อาจทำให้ PostgreSQL ล่มได้ การมีระบบจัดการโต๊ะที่ดีจะช่วยจำกัดจำนวนลูกค้าที่เข้ามาในร้านพร้อมกัน ทำให้ร้านอาหารสามารถให้บริการลูกค้าได้มากกว่า 300 คน

ไม่เพียงแต่ช่วยจัดการจำนวน Connection เท่านั้น แต่ Connection Pooler ยังช่วยลดเวลาในการสร้าง Connection ด้วยการใช้ Connection เดิมเพื่อให้บริการลูกค้าคนต่อไปโดยไม่ต้องมีการสร้าง Connection ใหม่ทุก ๆ ครั้ง

แต่การใช้ Connection Pooling มีทั้งข้อดีและผลกระทบที่ควรพิจารณา ทั้งในเรื่องที่ Connection Pool จะรักษา Connection ที่เปิดไว้แม้ไม่ได้ใช้งาน ทำให้ใช้หน่วยความจำมากขึ้น และการใช้ Connection ร่วมกัน อาจเป็นการเพิ่มความซับซ้อนในการ Debug ทำให้ยากต่อการระบุว่าปัญหาเกิดจาก Query ใด หรือส่วนใดของ Application ฯลฯ

การเข้าใจผลกระทบเหล่านี้จะช่วยให้เราสามารถออกแบบและใช้งาน Connection Pooling ได้อย่างมีประสิทธิภาพและปลอดภัย

การตั้งค่าสำคัญของ Connection Pooling

1. MaxOpenConns คือ จำนวน Connection สูงสุด หรือจำนวนโต๊ะที่ร้านอาหารจัดเตรียมไว้ปริการลูกค้า ซึ่งอาจอยู่ระหว่าง 20-50 Connection สำหรับ Application ทั่วไป ที่ไม่มีการใช้งานร่วมกับ Connection Pooler ภายนอก เช่น PgBouncer

หากไม่มีการตั้งค่า Go จะไม่จำกัดจำนวน Connection ที่เปิดพร้อมกัน

db.SetMaxOpenConns(25)

2. MaxIdleConns คือ จำนวน Connection สูงสุดที่พร้อมทำงาน (เปิด Connection รอใน Pool) ซึ่งควรตั้งค่าให้เท่ากับหรือน้อยกว่า MaxOpenConns (ควรพิจารณาตามลักษณะการใช้งานจริงของ Application)

หากไม่มีการตั้งค่า Go จะรักษา Idle Connection (โต๊ะในร้านอาหารที่ว่างและพร้อมรับลูกค้า) ไว้สูงสุด 2 Connection

db.SetMaxIdleConns(25)

3. ConnMaxLifetime(duration) คือ อายุของ Connection ที่ยอมให้เปิดค้างไว้ก่อนปิด เพื่อให้แน่ใจว่าจะไม่มี Connection ไหนเปิดไว้นานเกินไป โดยทั่วไปจะตั้งค่าไว้ประมาณ 5 นาที ถึง 1 ชั่วโมง การตั้งค่านี้ไม่ได้รับประกันว่า Connection จะถูกปิดทันทีเมื่อถึงเวลาที่กำหนด แต่จะถูกปิดเมื่อถูกส่งกลับมาที่ Pool หลังจากใช้งานเสร็จ

หากไม่มีการตั้งค่า Connection จะไม่มีการ Expire อาจทำให้ Connection เปิดค้างไว้นานเกินไป!

db.SetConnMaxLifetime(5 * time.Minute)

ใช้ Context Timeout ควบคุมระยะเวลาการทำงานแต่ละ Operation (Query)

การใช้ทั้ง ConnMaxLifetime และ Context Timeout เป็นแนวปฏิบัติที่ดีในการพัฒนา Application ด้วย Go ที่ทำงานกับ Database

ConnMaxLifetime จะควบคุมอายุการใช้งานสูงสุดของการเชื่อมต่อใน Pool ซึ่งทำงานกับทุกการเชื่อมต่อ (ไม่รับประกันว่าการเชื่อมต่อจะถูกปิดทันทีเมื่อถึงเวลาที่กำหนด แต่จะถูกปิดเมื่อมันถูกส่งกลับมาที่ Pool หลังจากใช้งานเสร็จ) เป็นการป้องกันปัญหาการใช้การเชื่อมต่อที่เก่าเกินไป ซึ่งการเชื่อมต่อเก่าอาจทำให้มีการทำงานที่ช้าลงเนื่องจากการสะสมของข้อมูล  รวมทั้งอาจมีการเก็บ Cache หรือสถานะที่ไม่เป็นปัจจุบัน ฯลฯ ขณะที่ Context Timeout จะควบคุมระยะเวลาการทำงานสูงสุดของแต่ละ Operation (Query) ซึ่งจะเป็นการป้องกัน Query ที่ใช้เวลานานเกินไป

เราจะตั้งค่า Connection Pooling ใน Function "NewPostgresDatabase()" และใช้ PingContext() แทน Ping() เพื่อทดสอบการเชื่อมต่อด้วย Timeout ที่กำหนดโดยรับ Context Timeout มาเป็น Parameter (ctx)

การตั้งค่า db.SetConnMaxLifetime() เพื่อควบคุมอายุการใช้งานสูงสุดของแต่ละ Connection ใน Pool ทำให้ Connection ถูกปิดและสร้างใหม่หลังจากถูกใช้งานครบ 5 นาที และการตั้งค่า Context Timeout ใน db.PingContext(ctx) เพื่อควบคุมระยะเวลาในการทดสอบการเชื่อมต่อ ซึ่งหาก Ping ไม่สำเร็จภายใน 5 วินาที จะเกิด Timeout Error
จะช่วยให้เราสามารถควบคุมทั้งอายุการใช้งานของ Connection และระยะเวลาการทำงานของแต่ละ Query ได้อย่างมีประสิทธิภาพ

func NewPostgresDatabase(connStr string) (*PostgresDatabase, error) {
    db, err := sql.Open("postgres", connStr)
    if err != nil {
        return nil, fmt.Errorf("failed to connect to database: %v", err)
    }

    // ตั้งค่า connection pool
    db.SetMaxOpenConns(25)
    db.SetMaxIdleConns(25)
    db.SetConnMaxLifetime(5 * time.Minute)

    // ทดสอบการเชื่อมต่อด้วย context
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    if err := db.PingContext(ctx); err != nil {
        return nil, fmt.Errorf("failed to ping database: %v", err)
    }

    return &PostgresDatabase{db: db}, nil
}

เปลี่ยน QueryRow() เป็น QueryRowContext() ใน Method "GetBook()" โดยรับ Context Timeout มาเป็น Parameter (ctx)

func (pdb *PostgresDatabase) GetBook(ctx context.Context, id int) (string, error) {
    var title string
    err := pdb.db.QueryRowContext(ctx, "SELECT title FROM books WHERE id = $1", id).Scan(&title)
    if err != nil {
        if err == sql.ErrNoRows {
            return "", fmt.Errorf("book not found")
        }
        return "", fmt.Errorf("failed to get book: %v", err)
    }
    return title, nil
}

เปลี่ยน Exec() เป็น ExecContext() ใน Method "AddBook()" โดยรับ Context Timeout มาเป็น Parameter (ctx)

func (pdb *PostgresDatabase) AddBook(ctx context.Context, title string) error {
    _, err := pdb.db.ExecContext(ctx, "INSERT INTO books (title) VALUES ($1)", title)
    if err != nil {
        return fmt.Errorf("failed to add book: %v", err)
    }
    return nil
}

เปลี่ยน Exec() เป็น ExecContext() ใน Method "DeleteBook()" โดยรับ Context Timeout มาเป็น Parameter (ctx)

func (pdb *PostgresDatabase) DeleteBook(ctx context.Context, id int) error {
    result, err := pdb.db.ExecContext(ctx, "DELETE FROM books WHERE id = $1", id)
    if err != nil {
        return fmt.Errorf("failed to delete book: %v", err)
    }

    rowsAffected, err := result.RowsAffected()
    if err != nil {
        return fmt.Errorf("failed to get rows affected: %v", err)
    }

    if rowsAffected == 0 {
        return fmt.Errorf("book not found")
    }

    return nil
}
Package "bookstore"

ตัวอย่าง Function "main()" ที่มีการเรียกใช้งาน Package "bookstore"

package main

import (
    "context"
    "fmt"
    "log"
    "time"
    "myproject/internal/bookstore"
)

func main() {
    // สร้างการเชื่อมต่อกับ Database
    db, err := bookstore.NewPostgresDatabase("host=localhost port=5432 user=bookstore_user password=your_strong_password dbname=bookstore sslmode=disable")
    if err != nil {
        log.Fatalf("Failed to connect to database: %v", err)
    }
    defer db.Close()

    // สร้าง BookStore
    store := bookstore.NewBookStore(db)

    // สร้าง Context พร้อม Timeout
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    // เพิ่มหนังสือ
    err = store.AddBook(ctx, "The Go Programming Language")
    if err != nil {
        log.Printf("Failed to add book: %v", err)
    } else {
        fmt.Println("Book added successfully")
    }

    // ดึงข้อมูลหนังสือ
    title, err := store.GetBookTitle(ctx, 1) // สมมติว่า id = 1
    if err != nil {
        log.Printf("Failed to get book: %v", err)
    } else {
        fmt.Printf("Book title: %s\n", title)
    }

    // ลบหนังสือ
    err = store.DeleteBook(ctx, 1) // สมมติว่า id = 1
    if err != nil {
        log.Printf("Failed to delete book: %v", err)
    } else {
        fmt.Println("Book deleted successfully")
    }
}

แต่ เราไม่ควรฝังค่า Connection String ลงใน Code โดยตรง!!!

วิธีที่ปลอดภัยกว่า คือ การใช้ Environment Variable หรือ Configuration File

เราจะปรับปรุง package "config" ในหัวข้อที่แล้วสำหรับอ่าน Environment Variable ด้วย Viper เพื่อสร้าง Connection String

// config.go

package config

import (
    "fmt"
    "strings"

    "github.com/spf13/viper"
)

type Config struct {
    DatabaseHost     string
    DatabasePort     int
    DatabaseUser     string
    DatabasePassword string
    DatabaseName     string
    DatabaseSSLMode  string
}

func LoadConfig() (Config, error) {
    viper.SetEnvPrefix("POSTGRES")
    viper.AutomaticEnv()
    viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))

    // Set default values
    viper.SetDefault("HOST", "localhost")
    viper.SetDefault("PORT", 5432)
    viper.SetDefault("USER", "postgres")
    viper.SetDefault("PASSWORD", "")
    viper.SetDefault("DBNAME", "bookstore")
    viper.SetDefault("SSLMODE", "disable")

    // Set config values
    config := Config{
        DatabaseHost:     viper.GetString("HOST"),
        DatabasePort:     viper.GetInt("PORT"),
        DatabaseUser:     viper.GetString("USER"),
        DatabasePassword: viper.GetString("PASSWORD"),
        DatabaseName:     viper.GetString("DBNAME"),
        DatabaseSSLMode:  viper.GetString("SSLMODE"),
    }

    return config, nil
}

func (c *Config) GetConnectionString() string {
    return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
        c.DatabaseHost,
        c.DatabasePort,
        c.DatabaseUser,
        c.DatabasePassword,
        c.DatabaseName,
        c.DatabaseSSLMode)
}
config.go

ปรับ Function "main()" ให้ใช้งาน Package "config" ที่เราได้สร้างไว้ เพื่อจัดการการเชื่อมต่อกับ Database อย่างเป็นระบบมากขึ้น

// main.go

package main

import (
    "context"
    "fmt"
    "log"
    "time"
    "myproject/internal/bookstore"
    "myproject/internal/config"
)

func main() {
    // โหลด Config
    cfg, err := config.LoadConfig()
    if err != nil {
        log.Fatalf("Failed to load config: %v", err)
    }

    // สร้างการเชื่อมต่อกับ Database โดยใช้ Connection String จาก Config
    db, err := bookstore.NewPostgresDatabase(cfg.GetConnectionString())
    if err != nil {
        log.Fatalf("Failed to connect to database: %v", err)
    }
    defer db.Close()

    // สร้าง BookStore
    store := bookstore.NewBookStore(db)

    // สร้าง Context พร้อม Timeout
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    // เพิ่มหนังสือ
    err = store.AddBook(ctx, "The Go Programming Language")
    if err != nil {
        log.Printf("Failed to add book: %v", err)
    } else {
        fmt.Println("Book added successfully")
    }

    // ดึงข้อมูลหนังสือ
    title, err := store.GetBookTitle(ctx, 1) // สมมติว่า id = 1
    if err != nil {
        log.Printf("Failed to get book: %v", err)
    } else {
        fmt.Printf("Book title: %s\n", title)
    }

    // ลบหนังสือ
    err = store.DeleteBook(ctx, 1) // สมมติว่า id = 1
    if err != nil {
        log.Printf("Failed to delete book: %v", err)
    } else {
        fmt.Println("Book deleted successfully")
    }
}
main.go

Code เต็มใน Package "bookstore" มีดังนี้

// bookstore.go
package bookstore

import (
    "context"
    "database/sql"
    "fmt"
    "time"
    _ "github.com/lib/pq"
)

// BookDatabase เป็น Interface ที่กำหนดว่า Book Database ต้องทำอะไรได้บ้าง
type BookDatabase interface {
    GetBook(ctx context.Context, id int) (string, error)
    AddBook(ctx context.Context, title string) error
    DeleteBook(ctx context.Context, id int) error
    Close() error
}

// PostgresDatabase เป็น struct ที่เชื่อมต่อกับ PostgreSQL Database จริง
type PostgresDatabase struct {
    db *sql.DB
}

// NewPostgresDatabase สร้าง PostgresDatabase ใหม่และเชื่อมต่อกับฐานข้อมูล
func NewPostgresDatabase(connStr string) (*PostgresDatabase, error) {
    db, err := sql.Open("postgres", connStr)
    if err != nil {
        return nil, fmt.Errorf("failed to connect to database: %v", err)
    }

    // ตั้งค่า connection pool
    db.SetMaxOpenConns(25)
    db.SetMaxIdleConns(25)
    db.SetConnMaxLifetime(5 * time.Minute)

    // ทดสอบการเชื่อมต่อด้วย context
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    if err := db.PingContext(ctx); err != nil {
        return nil, fmt.Errorf("failed to ping database: %v", err)
    }

    return &PostgresDatabase{db: db}, nil
}

func (pdb *PostgresDatabase) GetBook(ctx context.Context, id int) (string, error) {
    var title string
    err := pdb.db.QueryRowContext(ctx, "SELECT title FROM books WHERE id = $1", id).Scan(&title)
    if err != nil {
        if err == sql.ErrNoRows {
            return "", fmt.Errorf("book not found")
        }
        return "", fmt.Errorf("failed to get book: %v", err)
    }
    return title, nil
}

func (pdb *PostgresDatabase) AddBook(ctx context.Context, title string) error {
    _, err := pdb.db.ExecContext(ctx, "INSERT INTO books (title) VALUES ($1)", title)
    if err != nil {
        return fmt.Errorf("failed to add book: %v", err)
    }
    return nil
}

func (pdb *PostgresDatabase) DeleteBook(ctx context.Context, id int) error {
    result, err := pdb.db.ExecContext(ctx, "DELETE FROM books WHERE id = $1", id)
    if err != nil {
        return fmt.Errorf("failed to delete book: %v", err)
    }

    rowsAffected, err := result.RowsAffected()
    if err != nil {
        return fmt.Errorf("failed to get rows affected: %v", err)
    }

    if rowsAffected == 0 {
        return fmt.Errorf("book not found")
    }

    return nil
}

func (pdb *PostgresDatabase) Close() error {
    return pdb.db.Close()
}

// BookStore เป็นโครงสร้างหลักของ Application
type BookStore struct {
    db BookDatabase
}

// NewBookStore สร้าง BookStore ใหม่โดยรับ Database ที่จะใช้
func NewBookStore(db BookDatabase) *BookStore {
    return &BookStore{db: db}
}

// GetBookTitle เป็น Method ของ BookStore ที่ใช้ดึงชื่อหนังสือ
func (bs *BookStore) GetBookTitle(ctx context.Context, id int) (string, error) {
    return bs.db.GetBook(ctx, id)
}

// AddBook เป็น Method ของ BookStore ที่ใช้เพิ่มหนังสือ
func (bs *BookStore) AddBook(ctx context.Context, title string) error {
    return bs.db.AddBook(ctx, title)
}

// DeleteBook เป็น Method ของ BookStore ที่ใช้ลบหนังสือ
func (bs *BookStore) DeleteBook(ctx context.Context, id int) error {
    return bs.db.DeleteBook(ctx, id)
}

// Close เป็น Method ของ BookStore ที่ใช้ปิดการเชื่อมต่อกับฐานข้อมูล
func (bs *BookStore) Close() error {
    return bs.db.Close()
}
bookstore.go

เพื่อจะทดสอบการจัดการข้อมูลกับ Database จริง ให้สร้าง File และ Folder ดังต่อไปนี้

myproject/
├── cmd
│   └── main.go
└── internal
    ├── bookstore
    │   └── bookstore.go
    └── config
        └── config.go

สร้างไฟล์​ go.mod ด้วยคำสั่ง go mod init myproject

go mod init myproject

ติดตั้ง Package "github.com/lib/pq"

go get github.com/lib/pq

ติดตั้ง Package "github.com/spf13/viper"

go get github.com/spf13/viper

รันคำสั่ง go mod tidy ทุกครั้งที่มีการเปลี่ยนแปลง Dependency เพื่อรักษาความสอดคล้องและความสมบูรณ์ของ Dependency ใน Project

go mod tidy

ตั้งค่า Environment Variable ดังต่อไปนี้

export POSTGRES_HOST=localhost
export POSTGRES_PORT=5432
export POSTGRES_USER=bookstore_user
export POSTGRES_PASSWORD=your_strong_password
export POSTGRES_DBNAME=bookstore
export POSTGRES_SSLMODE=disable
การตั้งค่า Environment Variable บน Unix/Linux
set POSTGRES_HOST=localhost
set POSTGRES_PORT=5432
set POSTGRES_USER=bookstore_user
set POSTGRES_PASSWORD=your_strong_password
set POSTGRES_DBNAME=bookstore
set POSTGRES_SSLMODE=disable
การตั้งค่า Environment Variable บน Windows

Compile Code ด้วยคำสั่ง go build แล้วรัน Program

go build cmd/main.go

./main 

Exercise

สร้าง Program จัดการรายการหนังสือที่มีการเชื่อมต่อกับ PostgreSQL ซึ่งมีตาราง books ที่มี Field "id" และ "title"

สร้าง Method "AddBook()" เพื่อเพิ่มชื่อหนังสือใหม่ลงใน Database และ Method "GetAllBooks()" เพื่อดึงรายชื่อหนังสือทั้งหมดจาก Database

ใน Function "main()" ให้เพิ่มหนังสือ 2-3 เล่ม แล้วดึงรายชื่อหนังสือทั้งหมดจาก Database มาแสดง

Go Quiz 17 (20 ข้อ) ขอให้สนุกกับการทำ Quiz นะครับ

Q&A?

รวม Cheat Sheet การทำงานกับ Database เบื้องต้น

การทำงานกับ Database เบื้องต้น
--------------------------
1 การเชื่อมต่อ Database
	- sql.Open("postgres", connStr) // เพื่อเชื่อมต่อ
	- db.Ping() // เพื่อทดสอบการเชื่อมต่อ
	- defer db.Close() // เพื่อปิดการเชื่อมต่อเมื่อเสร็จสิ้น

2. การตั้งค่า Connection Pool
	- db.SetMaxOpenConns(n)
	- db.SetMaxIdleConns(n)
	- db.SetConnMaxLifetime(duration)

3. การ Query ข้อมูล
	- db.QueryContext(ctx, query, args...) // สำหรับ SELECT หลายแถว
	- db.QueryRowContext(ctx, query, args...) // สำหรับ SELECT แถวเดียว
	- rows.Scan(&var1, &var2, ...) // เพื่ออ่านข้อมูลจาก rows

4. การ Execute คำสั่ง
	- db.ExecContext(ctx, query, args...) // สำหรับ INSERT, UPDATE, DELETE
	- result.RowsAffected() // เพื่อดูจำนวนแถวที่ได้รับผลกระทบ

5. Prepared Statement
	- stmt, err := db.PrepareContext(ctx, query)
	- defer stmt.Close()
	- stmt.ExecContext(ctx, args...)
	- stmt.QueryContext(ctx, args...)

6. Error Handling
	- if err == sql.ErrNoRows // {} สำหรับกรณีไม่พบข้อมูล
	- ใช้ errors.Is() // เพื่อเช็ค Specific Error

7. Best Practice
	- ใช้ Prepared Statement เพื่อป้องกัน SQL Injection
	- ใช้ Context เพื่อควบคุม Timeout
	- ปิด rows และ statements เมื่อใช้งานเสร็จ

การพัฒนาและ Deploy REST API ด้วย Go และ Docker Container

หัวข้อนี้จะมีการบูรณาการความรู้หลากหลายส่วน โดยเฉพาะการจัดการการตั้งค่า การทำงานกับเครือข่าย Dependency Injection การทำงานกับ Database และ Goroutine เพื่อสร้าง REST API ที่มีคุณภาพ

เราจะพัฒนาและ Deploy REST API โดยใช้โครงสร้าง Project แบบง่าย ๆ ดังต่อไปนี้

myproject
├── cmd
│   └── main.go   
├── internal
│   ├── bookstore
│   │   └── bookstore.go
│   ├── config
│   │   └── config.go
│   └── handlers
│       └── book_handlers.go
├── .env
├── .gitignore
├── Dockerfile
├── docker-compose.yml
├── go.mod
└── go.sum

cmd/ เป็น Folder สำหรับเก็บ File เริ่มต้นของ Application

  • cmd/main.go คือ File หลักที่รัน Application ทำหน้าที่เชื่อมต่อส่วนต่าง ๆ เข้าด้วยกัน ทั้ง Web Server และการเชื่อมต่อ Database

internal/ เป็น Folder สำหรับเก็บ Code ที่ใช้เฉพาะภายใน Project นี้ ใน internal จะมี Folder อีก 3 Folder คือ bookstore/, config/ และ handlers/

  • internal/bookstore/bookstore.go คือ File ที่เก็บ Core Business Logic เกี่ยวกับการจัดการหนังสือ มี Function สำหรับการเพิ่ม ลบ และค้นหาหนังสือ
  • internal/config/config.go คือ File ที่จัดการการตั้งค่าของ Application โดยการอ่านค่าจาก Environment Variable
  • internal/handlers/book_handlers.go คือ File ที่จัดการ HTTP Request ต่าง ๆ เชื่อมต่อระหว่าง HTTP Request และ Business Logic ใน bookstore.go

.env คือ File ที่เก็บ Environment Variable เช่น App Port และการตั้งค่า Database

.gitignore คือ File ที่บอกให้ Git เพิกเฉย ไม่ควรนำเข้า Codebase

Dockerfile คือ File ที่เก็บคำสั่งสำหรับสร้าง Docker Image

docker-compose.yml คือ File ที่เก็บคำสั่งในการ Config และจัดการ Container

go.mod และ go.sum คือ File สำหรับจัดการ Dependency ของ Go

เราจะต่อยอดการจัดการ Database โดยพัฒนา REST API ที่มี Endpoint สำหรับจัดการ HTTP Request และเชื่อมต่อกับ Business Logic ใน bookstore.go จากหัวข้อที่แล้ว

API Endpoint
Get Book

  • Method GET
  • Path /api/v1/books/:id
  • Description ดึงข้อมูลหนังสือตาม ID ที่ระบุ

Add Book

  • Method POST
  • Path /api/v1/books
  • Description เพิ่มหนังสือใหม่

Delete Book

  • Method DELETE
  • Path /api/v1/books/:id
  • Description ลบหนังสือตาม ID ที่ระบุ

Health Check

  • Method GET
  • Path /health
  • Description ตรวจสอบสถานะของ Server และ Database Connection

นอกจากการเพิ่ม ลบ และค้นหาหนังสือ ซึ่งเป็น  Core Business Logic แล้วเราจะเพิ่ม Health Check Endpoint เพื่อตรวจสอบสถานะของ Server และ Database Connection อีกด้วย

เราจะใช้ Gin Framework ในการพัฒนา REST API โดยรวบรวม Handler Function ไว้ใน Package "handlers"

เพื่อแยกความรับผิดชอบระหว่าง BookHandlers (ใน Package "handlers") และ BookStore (ใน Package "bookstore") โดยให้ BookHandlers มีหน้าที่เพียงการจัดการ HTTP Request และ Response ส่วน BookStore มีหน้าที่จัดการ Business Logic และการทำงานกับ Database รวมทั้งทำให้สามารถสร้าง Mock ของ BookStore เพื่อทดสอบ Handler ได้โดยไม่ต้องใช้ Database จริง เราจะสร้าง Function ที่ใช้หลักการ Dependency Injection ซึ่งมีการรับ BookStore และส่งคืนเป็น BookHandlers ตัวใหม่

type BookHandlers struct {
	bs *bookstore.BookStore
}

func NewBookHandlers(bs *bookstore.BookStore) *BookHandlers {
	return &BookHandlers{bs: bs}
}

รวมทั้งสร้าง Method ต่าง ๆ ของ BookHandlers ไว้ให้เรียกใช้เมื่อมีการทำ Routing "HTTP Request" ที่เข้ามา ได้แก่ Method "GetBook()", "AddBook()", "DeleteBook()" และ "HealthCheck()" ดัง Code ในไฟล์ book_handlers.go ด้านล่าง

// book_handlers.go
package handlers

import (
	"myproject/internal/bookstore"
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"
)

type BookHandlers struct {
	bs *bookstore.BookStore
}

func NewBookHandlers(bs *bookstore.BookStore) *BookHandlers {
	return &BookHandlers{bs: bs}
}

func (h *BookHandlers) GetBook(c *gin.Context) {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid book ID"})
		return
	}

	title, err := h.bs.GetBookTitle(c.Request.Context(), id)
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusOK, gin.H{"id": id, "title": title})
}

func (h *BookHandlers) AddBook(c *gin.Context) {
	var book struct {
		Title string `json:"title" binding:"required"`
	}

	if err := c.ShouldBindJSON(&book); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	if err := h.bs.AddBook(c.Request.Context(), book.Title); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusCreated, gin.H{"message": "Book added successfully"})
}

func (h *BookHandlers) DeleteBook(c *gin.Context) {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid book ID"})
		return
	}

	if err := h.bs.DeleteBook(c.Request.Context(), id); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusOK, gin.H{"message": "Book deleted successfully"})
}

func (h *BookHandlers) HealthCheck(c *gin.Context) {
	err := h.bs.Ping()
	if err != nil {
		c.JSON(http.StatusServiceUnavailable, gin.H{
			"status": "unhealthy",
			"reason": "Database connection failed",
		})
		return
	}
	c.JSON(http.StatusOK, gin.H{"status": "healthy"})
}
book_handlers.go

GetBook() ดึงข้อมูลหนังสือตาม ID
GetBook() จะใช้ c.Param() สำหรับรับ ID จาก URL Parameter แล้วแปลงเป็นตัวเลข เรียกใช้ GetBookTitle() จาก BookStore แล้วส่งคืนข้อมูลหนังสือในรูปแบบ JSON

AddBook() เพิ่มหนังสือใหม่
AddBook() จะรับข้อมูลหนังสือจาก JSON Body โดยใช้ c.ShouldBindJSON() แปลง JSON Body เป็น struct แล้วเรียกใช้ AddBook() จาก BookStore เพื่อเพิ่มหนังสือ และส่งคืนข้อความยืนยันการเพิ่มหนังสือ

DeleteBook() ลบหนังสือตาม ID
DeleteBook() จะใช้ c.Param() สำหรับรับ ID จาก URL Parameter เรียกใช้ DeleteBook() จาก BookStore เพื่อลบหนังสือ แล้วส่งคืนข้อความยืนยันการลบหนังสือ

HealthCheck() ตรวจสอบสถานะของระบบ
HealthCheck() จะเรียกใช้ Ping() จาก BookStore เพื่อทดสอบการเชื่อมต่อกับ Database แล้วส่งคืนสถานะ "healthy" หรือ "unhealthy" พร้อมเหตุผล

แต่ละ Method จะใช้ c.JSON() สำหรับส่งคืนข้อมูลในรูปแบบ JSON พร้อมทั้งมีการตรวจสอบข้อผิดพลาดและส่ง HTTP Status Code กลับ เช่น 400 Bad Request เมื่อดึงข้อมูลจาก Request ไม่สำเร็จ 404 Not Found สำหรับการไม่พบหนังสือ และ 500 Internal Server Error สำหรับข้อผิดพลาดในการเพิ่ม/ลบหนังสือ

เพื่อตรวจสอบสถานะของ Server และการเชื่อมต่อกับ Database จึงต้องมีการปรับปรุง Code ใน File "bookstore.go" ดังต่อไปนี้

เพิ่ม Method "Ping()" และ "Reconnect()" ใน Interface BookDatabase

type BookDatabase interface {
	GetBook(ctx context.Context, id int) (string, error)
	AddBook(ctx context.Context, title string) error
	DeleteBook(ctx context.Context, id int) error
	Close() error
	Ping() error
	Reconnect(connStr string) error
}

Implement Method "Ping()" ตาม Interface BookDatabase เพื่อให้มีการ Ping ไปยัง PostgreSQL

func (pdb *PostgresDatabase) Ping() error {
	if pdb == nil {
		return errors.New("database connection is not initialized")
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	return pdb.db.PingContext(ctx)
}

Implement Method "Reconnect()" ตาม Interface BookDatabase ซึ่งจะถูกเรียกใน Function "main()" เพื่อให้สามารถ Reconnect ใหม่เมื่อ PostgreSQL กลับมา

func (pdb *PostgresDatabase) Reconnect(connStr string) error {
	if pdb.db != nil {
		pdb.db.Close()
	}

	db, err := sql.Open("postgres", connStr)
	if err != nil {
		return fmt.Errorf("failed to connect to database: %v", err)
	}

	// ตั้งค่า connection pool
	db.SetMaxOpenConns(25)
	db.SetMaxIdleConns(25)
	db.SetConnMaxLifetime(5 * time.Minute)

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	if err := db.PingContext(ctx); err != nil {
		return fmt.Errorf("failed to ping database: %v", err)
	}

	pdb.db = db
	return nil
}

สร้าง Method "Ping()" ให้กับ BookStore เพื่อเรียก Ping() ใน PostgresDatabase อีกที

func (bs *BookStore) Ping() error {
	if bs.db == nil {
		return fmt.Errorf("database connection is not initialized")
	}
	return bs.db.Ping()
}

Code เต็มอยู่ใน File "bookstore.go" ด้านล่างนี้

// bookstore.go
package bookstore

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"time"

	_ "github.com/lib/pq"
)

// BookDatabase เป็น Interface ที่กำหนดว่า Book Database ต้องทำอะไรได้บ้าง
type BookDatabase interface {
	GetBook(ctx context.Context, id int) (string, error)
	AddBook(ctx context.Context, title string) error
	DeleteBook(ctx context.Context, id int) error
	Close() error
	Ping() error
	Reconnect(connStr string) error
}

// PostgresDatabase เป็น struct ที่เชื่อมต่อกับ PostgreSQL Database จริง
type PostgresDatabase struct {
	db *sql.DB
}

// NewPostgresDatabase สร้าง PostgresDatabase ใหม่และเชื่อมต่อกับฐานข้อมูล
func NewPostgresDatabase(connStr string) (*PostgresDatabase, error) {
	db, err := sql.Open("postgres", connStr)
	if err != nil {
		return nil, fmt.Errorf("failed to connect to database: %v", err)
	}

	// ตั้งค่า connection pool
	db.SetMaxOpenConns(25)
	db.SetMaxIdleConns(25)
	db.SetConnMaxLifetime(5 * time.Minute)

	// ทดสอบการเชื่อมต่อด้วย context
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	if err := db.PingContext(ctx); err != nil {
		return nil, fmt.Errorf("failed to ping database: %v", err)
	}

	return &PostgresDatabase{db: db}, nil
}

func (pdb *PostgresDatabase) GetBook(ctx context.Context, id int) (string, error) {
	var title string
	err := pdb.db.QueryRowContext(ctx, "SELECT title FROM books WHERE id = $1", id).Scan(&title)
	if err != nil {
		if err == sql.ErrNoRows {
			return "", fmt.Errorf("book not found")
		}
		return "", fmt.Errorf("failed to get book: %v", err)
	}
	return title, nil
}

func (pdb *PostgresDatabase) AddBook(ctx context.Context, title string) error {
	_, err := pdb.db.ExecContext(ctx, "INSERT INTO books (title) VALUES ($1)", title)
	if err != nil {
		return fmt.Errorf("failed to add book: %v", err)
	}
	return nil
}

func (pdb *PostgresDatabase) DeleteBook(ctx context.Context, id int) error {
	result, err := pdb.db.ExecContext(ctx, "DELETE FROM books WHERE id = $1", id)
	if err != nil {
		return fmt.Errorf("failed to delete book: %v", err)
	}

	rowsAffected, err := result.RowsAffected()
	if err != nil {
		return fmt.Errorf("failed to get rows affected: %v", err)
	}

	if rowsAffected == 0 {
		return fmt.Errorf("book not found")
	}

	return nil
}

func (pdb *PostgresDatabase) Close() error {
	return pdb.db.Close()
}

func (pdb *PostgresDatabase) Ping() error {
	if pdb == nil {
		return errors.New("database connection is not initialized")
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	return pdb.db.PingContext(ctx)
}

func (pdb *PostgresDatabase) Reconnect(connStr string) error {
	if pdb.db != nil {
		pdb.db.Close()
	}

	db, err := sql.Open("postgres", connStr)
	if err != nil {
		return fmt.Errorf("failed to connect to database: %v", err)
	}

	// ตั้งค่า connection pool
	db.SetMaxOpenConns(25)
	db.SetMaxIdleConns(25)
	db.SetConnMaxLifetime(5 * time.Minute)

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	if err := db.PingContext(ctx); err != nil {
		return fmt.Errorf("failed to ping database: %v", err)
	}

	pdb.db = db
	return nil
}

// BookStore เป็นโครงสร้างหลักของ Application
type BookStore struct {
	db BookDatabase
}

// NewBookStore สร้าง BookStore ใหม่โดยรับ Database ที่จะใช้
func NewBookStore(db BookDatabase) *BookStore {
	return &BookStore{db: db}
}

// GetBookTitle เป็น Method ของ BookStore ที่ใช้ดึงชื่อหนังสือ
func (bs *BookStore) GetBookTitle(ctx context.Context, id int) (string, error) {
	return bs.db.GetBook(ctx, id)
}

// AddBook เป็น Method ของ BookStore ที่ใช้เพิ่มหนังสือ
func (bs *BookStore) AddBook(ctx context.Context, title string) error {
	return bs.db.AddBook(ctx, title)
}

// DeleteBook เป็น Method ของ BookStore ที่ใช้ลบหนังสือ
func (bs *BookStore) DeleteBook(ctx context.Context, id int) error {
	return bs.db.DeleteBook(ctx, id)
}

// Close เป็น Method ของ BookStore ที่ใช้ปิดการเชื่อมต่อกับฐานข้อมูล
func (bs *BookStore) Close() error {
	return bs.db.Close()
}

func (bs *BookStore) Ping() error {
	if bs.db == nil {
		return fmt.Errorf("database connection is not initialized")
	}
	return bs.db.Ping()
}
bookstore.go

นอกจากนี้เราจะมีการปรับปรุง Package "config" ให้มีการอ่าน Application Port จาก Environment Variable ดังนี้

// config.go

package config

import (
	"fmt"
	"strings"

	"github.com/spf13/viper"
)

type Config struct {
	AppPort          string
	DatabaseHost     string
	DatabasePort     int
	DatabaseUser     string
	DatabasePassword string
	DatabaseName     string
	DatabaseSSLMode  string
}

func LoadConfig() (Config, error) {
	// viper.SetEnvPrefix("POSTGRES")
	viper.AutomaticEnv()
	viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))

	// Set default values
	viper.SetDefault("POSTGRES.HOST", "localhost")
	viper.SetDefault("POSTGRES.PORT", 5432)
	viper.SetDefault("POSTGRES.USER", "postgres")
	viper.SetDefault("POSTGRES.PASSWORD", "")
	viper.SetDefault("POSTGRES.DBNAME", "bookstore")
	viper.SetDefault("POSTGRES.SSLMODE", "disable")

	// Set config values
	config := Config{
		AppPort:          viper.GetString("APP.PORT"),
		DatabaseHost:     viper.GetString("POSTGRES.HOST"),
		DatabasePort:     viper.GetInt("POSTGRES.PORT"),
		DatabaseUser:     viper.GetString("POSTGRES.USER"),
		DatabasePassword: viper.GetString("POSTGRES.PASSWORD"),
		DatabaseName:     viper.GetString("POSTGRES.DBNAME"),
		DatabaseSSLMode:  viper.GetString("POSTGRES.SSLMODE"),
	}

	return config, nil
}

func (c *Config) GetConnectionString() string {
	return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
		c.DatabaseHost,
		c.DatabasePort,
		c.DatabaseUser,
		c.DatabasePassword,
		c.DatabaseName,
		c.DatabaseSSLMode)
}
config.go

จาก Code ด้านบน เราจะทำ Unit Test โดยการสร้าง Mock ของ BookStore เพื่อทดสอบ Handler Function ที่ไม่ต้องใช้ Database จริง

เราจะทำ Unit Test เพื่อทดสอบ Package "handlers" โดยใช้โครงสร้างของ Project ดังนี้

myproject
├── bookstore
│   └── bookstore.go
├── config
│   └── config.go
└── handlers
    ├── book_handlers.go
    └── book_handlers_test.go

book_handlers_test.go คือ File Test ซึ่งต้องอยู่ใน Package เดียวกันกับ Code ที่จะทดสอบ

โดยการสร้าง struct ชื่อ MockBookStore ที่จำลองการทำงานของ BookStore จริง ด้วย package "github.com/stretchr/testify/mock" ซึ่งจะช่วยให้เราสามารถกำหนดค่าที่จะส่งคืนจาก Method ต่าง ๆ ได้

book_handlers_test.go มี Function สำคัญสำหรับการทดสอบ ได้แก่ TestGetBook(), TestAddBook(), TestDeleteBook() และ TestHealthCheck()

Function "TestGetBook()" สำหรับทดสอบการดึงข้อมูลหนังสือตาม ID ของ Handler Function "GetBook()" โดยมีการดำเนินการดังนี้

กำหนด Test Cases ที่ครอบคลุมสถานการณ์ต่าง ๆ ได้แก่ การดึงข้อมูลสำเร็จ ไม่พบหนังสือ และ ID ไม่ถูกต้อง

วน Loop ทดสอบแต่ละ Test Case เพื่อ
1. สร้าง Mock ของ BookStore
2. ตั้งค่า Mock ให้ส่งคืนข้อมูลตามที่กำหนด
3. สร้าง HTTP Request จำลอง
4. เรียกใช้ Handler Function "GetBook()"
5. ตรวจสอบ HTTP Status Code และ Response Body

Function "TestAddBook()" สำหรับทดสอบการเพิ่มหนังสือของ Handler Function "AddBook()" โดยมีการดำเนินการดังนี้

กำหนด Test Cases ที่ครอบคลุมสถานการณ์ต่าง ๆ ได้แก่ การเพิ่มหนังสือสำเร็จ ข้อมูลไม่ครบถ้วน (เช่น ไม่มีชื่อหนังสือ) และการเกิดข้อผิดพลาดจาก Database

วน Loop ทดสอบแต่ละ Test Case เพื่อ
1. สร้าง Mock ของ BookStore
2. ตั้งค่า Mock ให้ส่งคืนผลลัพธ์ตามที่กำหนด
3. สร้าง HTTP POST Request จำลองพร้อม JSON Body
4. เรียกใช้ Handler Function "AddBook()"
5. ตรวจสอบ HTTP Status Code และ Response Body

Function "TestDeleteBook()" สำหรับทดสอบการลบหนังสือตาม ID ของ Handler Function "DeleteBook()" โดยมีการดำเนินการดังนี้

กำหนด Test Cases ที่ครอบคลุมสถานการณ์ต่าง ๆ ได้แก่ การลบหนังสือสำเร็จ ไม่พบหนังสือที่ต้องการลบ และ ID ไม่ถูกต้อง

วน Loop ทดสอบแต่ละ Test Case เพื่อ
1. สร้าง Mock ของ BookStore
2. ตั้งค่า Mock ให้ส่งคืนผลลัพธ์ตามที่กำหนด
3. สร้าง HTTP Request จำลอง
4. เรียกใช้ Handler Function "DeleteBook()"
5. ตรวจสอบ HTTP Status Code และ Response Body

Function "TestHealthCheck()" สำหรับทดสอบการตรวจสอบสถานะของระบบ ของ Handler Function "HealthCheck()" โดยมีการดำเนินการดังนี้

กำหนด Test Cases ที่ครอบคลุมสถานการณ์ต่าง ๆ ได้แก่ ระบบทำงานปกติ และระบบมีปัญหา (เช่น ไม่สามารถเชื่อมต่อกับ Database ได้)

วน Loop ทดสอบแต่ละ Test Case เพื่อ
1. สร้าง Mock ของ BookStore
2 ตั้งค่า Mock ให้ส่งคืนผลลัพธ์ตามที่กำหนด
3. สร้าง HTTP Request จำลอง
4. เรียกใช้ Handler Function "HealthCheck()"
5. ตรวจสอบ HTTP Status Code และ Response Body

// book_handlers_test.go
package handlers

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"myproject/internal/bookstore"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
)

type MockBookDatabase struct {
	mock.Mock
}

func (m *MockBookDatabase) GetBook(ctx context.Context, id int) (string, error) {
	args := m.Called(ctx, id)
	return args.String(0), args.Error(1)
}

func (m *MockBookDatabase) AddBook(ctx context.Context, title string) error {
	args := m.Called(ctx, title)
	return args.Error(0)
}

func (m *MockBookDatabase) DeleteBook(ctx context.Context, id int) error {
	args := m.Called(ctx, id)
	return args.Error(0)
}

func (m *MockBookDatabase) Close() error {
	args := m.Called()
	return args.Error(0)
}

func (m *MockBookDatabase) Ping() error {
	args := m.Called()
	return args.Error(0)
}

func (m *MockBookDatabase) Reconnect(connStr string) error {
	args := m.Called(connStr)
	return args.Error(0)
}

func TestGetBook(t *testing.T) {
	gin.SetMode(gin.TestMode)

	tests := []struct {
		name           string
		bookID         string
		mockReturnBook string
		mockReturnErr  error
		expectedStatus int
		expectedBody   map[string]interface{}
		setupMock      func(*MockBookDatabase)
	}{
		{
			name:           "Successful retrieval",
			bookID:         "1",
			mockReturnBook: "Test Book",
			mockReturnErr:  nil,
			expectedStatus: http.StatusOK,
			expectedBody:   map[string]interface{}{"id": float64(1), "title": "Test Book"},
			setupMock: func(mockDB *MockBookDatabase) {
				mockDB.On("GetBook", mock.Anything, 1).Return("Test Book", nil)
			},
		},
		{
			name:           "Book not found",
			bookID:         "2",
			mockReturnBook: "",
			mockReturnErr:  errors.New("book not found"),
			expectedStatus: http.StatusNotFound,
			expectedBody:   map[string]interface{}{"error": "book not found"},
			setupMock: func(mockDB *MockBookDatabase) {
				mockDB.On("GetBook", mock.Anything, 2).Return("", errors.New("book not found"))
			},
		},
		{
			name:           "Invalid book ID",
			bookID:         "invalid",
			mockReturnBook: "",
			mockReturnErr:  nil,
			expectedStatus: http.StatusBadRequest,
			expectedBody:   map[string]interface{}{"error": "Invalid book ID"},
			setupMock: func(mockDB *MockBookDatabase) {

			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockDB := new(MockBookDatabase)
			tt.setupMock(mockDB)

			bs := bookstore.NewBookStore(mockDB)
			handler := NewBookHandlers(bs)

			w := httptest.NewRecorder()
			c, _ := gin.CreateTestContext(w)

			req, _ := http.NewRequest("GET", "/books/"+tt.bookID, nil)
			c.Request = req
			c.Params = gin.Params{{Key: "id", Value: tt.bookID}}

			handler.GetBook(c)

			assert.Equal(t, tt.expectedStatus, w.Code)

			var response map[string]interface{}
			err := json.Unmarshal(w.Body.Bytes(), &response)
			assert.NoError(t, err)
			assert.Equal(t, tt.expectedBody, response)

			mockDB.AssertExpectations(t)
		})
	}
}

func TestAddBook(t *testing.T) {
	gin.SetMode(gin.TestMode)

	tests := []struct {
		name           string
		requestBody    map[string]interface{}
		mockReturnErr  error
		expectedStatus int
		expectedBody   map[string]interface{}
		setupMock      func(*MockBookDatabase)
	}{
		{
			name:           "Successful addition",
			requestBody:    map[string]interface{}{"title": "New Book"},
			mockReturnErr:  nil,
			expectedStatus: http.StatusCreated,
			expectedBody:   map[string]interface{}{"message": "Book added successfully"},
			setupMock: func(mockDB *MockBookDatabase) {
				mockDB.On("AddBook", mock.Anything, "New Book").Return(nil)
			},
		},
		{
			name:           "Missing title",
			requestBody:    map[string]interface{}{},
			mockReturnErr:  nil,
			expectedStatus: http.StatusBadRequest,
			expectedBody:   map[string]interface{}{"error": "Key: 'Title' Error:Field validation for 'Title' failed on the 'required' tag"},
			setupMock: func(mockDB *MockBookDatabase) {

			},
		},
		{
			name:           "Database error",
			requestBody:    map[string]interface{}{"title": "New Book"},
			mockReturnErr:  errors.New("database error"),
			expectedStatus: http.StatusInternalServerError,
			expectedBody:   map[string]interface{}{"error": "database error"},
			setupMock: func(mockDB *MockBookDatabase) {
				mockDB.On("AddBook", mock.Anything, "New Book").Return(errors.New("database error"))
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockDB := new(MockBookDatabase)
			tt.setupMock(mockDB)

			bs := bookstore.NewBookStore(mockDB)
			handler := NewBookHandlers(bs)

			w := httptest.NewRecorder()
			c, _ := gin.CreateTestContext(w)

			jsonBody, _ := json.Marshal(tt.requestBody)
			req, _ := http.NewRequest("POST", "/books", bytes.NewBuffer(jsonBody))
			req.Header.Set("Content-Type", "application/json")
			c.Request = req

			handler.AddBook(c)

			assert.Equal(t, tt.expectedStatus, w.Code)

			var response map[string]interface{}
			err := json.Unmarshal(w.Body.Bytes(), &response)
			assert.NoError(t, err)
			assert.Equal(t, tt.expectedBody, response)

			mockDB.AssertExpectations(t)
		})
	}
}

func TestDeleteBook(t *testing.T) {
	gin.SetMode(gin.TestMode)

	tests := []struct {
		name           string
		bookID         string
		mockReturnErr  error
		expectedStatus int
		expectedBody   map[string]interface{}
		setupMock      func(*MockBookDatabase)
	}{
		{
			name:           "Successful deletion",
			bookID:         "1",
			mockReturnErr:  nil,
			expectedStatus: http.StatusOK,
			expectedBody:   map[string]interface{}{"message": "Book deleted successfully"},
			setupMock: func(mockDB *MockBookDatabase) {
				mockDB.On("DeleteBook", mock.Anything, 1).Return(nil)
			},
		},
		{
			name:           "Book not found",
			bookID:         "2",
			mockReturnErr:  errors.New("book not found"),
			expectedStatus: http.StatusInternalServerError,
			expectedBody:   map[string]interface{}{"error": "book not found"},
			setupMock: func(mockDB *MockBookDatabase) {
				mockDB.On("DeleteBook", mock.Anything, 2).Return(errors.New("book not found"))
			},
		},
		{
			name:           "Invalid book ID",
			bookID:         "invalid",
			mockReturnErr:  nil,
			expectedStatus: http.StatusBadRequest,
			expectedBody:   map[string]interface{}{"error": "Invalid book ID"},
			setupMock: func(mockDB *MockBookDatabase) {

			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockDB := new(MockBookDatabase)
			tt.setupMock(mockDB)

			bs := bookstore.NewBookStore(mockDB)
			handler := NewBookHandlers(bs)

			w := httptest.NewRecorder()
			c, _ := gin.CreateTestContext(w)

			req, _ := http.NewRequest("DELETE", "/books/"+tt.bookID, nil)
			c.Request = req
			c.Params = gin.Params{{Key: "id", Value: tt.bookID}}

			handler.DeleteBook(c)

			assert.Equal(t, tt.expectedStatus, w.Code)

			var response map[string]interface{}
			err := json.Unmarshal(w.Body.Bytes(), &response)
			assert.NoError(t, err)
			assert.Equal(t, tt.expectedBody, response)

			mockDB.AssertExpectations(t)
		})
	}
}

func TestHealthCheck(t *testing.T) {
	gin.SetMode(gin.TestMode)

	tests := []struct {
		name           string
		mockReturnErr  error
		expectedStatus int
		expectedBody   map[string]interface{}
	}{
		{
			name:           "Healthy",
			mockReturnErr:  nil,
			expectedStatus: http.StatusOK,
			expectedBody:   map[string]interface{}{"status": "healthy"},
		},
		{
			name:           "Unhealthy",
			mockReturnErr:  errors.New("database connection failed"),
			expectedStatus: http.StatusServiceUnavailable,
			expectedBody: map[string]interface{}{
				"status": "unhealthy",
				"reason": "Database connection failed",
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockDB := new(MockBookDatabase)
			mockDB.On("Ping").Return(tt.mockReturnErr)

			bs := bookstore.NewBookStore(mockDB)
			handler := NewBookHandlers(bs)

			w := httptest.NewRecorder()
			c, _ := gin.CreateTestContext(w)

			req, _ := http.NewRequest("GET", "/health", nil)
			c.Request = req

			handler.HealthCheck(c)

			assert.Equal(t, tt.expectedStatus, w.Code)

			var response map[string]interface{}
			err := json.Unmarshal(w.Body.Bytes(), &response)
			assert.NoError(t, err)
			assert.Equal(t, tt.expectedBody, response)

			mockDB.AssertExpectations(t)
		})
	}
}

สร้างไฟล์​ go.mod ด้วยคำสั่ง go mod init myproject

go mod init myproject

ติดตั้ง Package "github.com/lib/pq"

go get github.com/lib/pq

ติดตั้ง Package "github.com/spf13/viper"

go get github.com/spf13/viper

ติดตั้ง Package "github.com/gin-gonic/gin"

go get github.com/gin-gonic/gin

ติดตั้ง Package "github.com/stretchr/testify/assert"

go get github.com/stretchr/testify/assert

ติดตั้ง Package "github.com/stretchr/testify/mock"

go get github.com/stretchr/testify/mock

รันคำสั่ง go mod tidy ทุกครั้งที่มีการเปลี่ยนแปลง Dependency เพื่อรักษาความสอดคล้องและความสมบูรณ์ของ Dependency ใน Project

go mod tidy

ในการ Test เราสามารถใช้คำสั่ง go test -v เพื่อให้ Go แสดงรายละเอียดที่มากขึ้น

go test -v -count=1 ./internal/handlers

ดู % ของ Coverage ด้วยคำสั่ง go test -cover

go test -cover -count=1 ./internal/handlers

หลังจากรัน Unit Test แล้ว เราจะสร้าง Folder "cmd/" และ File "main.go" ใน Folder นี้

ใน File "main.go" จะมี Function "main()" ซึ่งเป็นจุดเริ่มต้นของ Web Server ที่ให้บริการ API เกี่ยวกับการจัดการหนังสือโดยมีการจัดการการตั้งค่า การเชื่อมต่อ Database และการกำหนด Route ต่าง ๆ โดยมีลำดับการทำงานดังนี้

  1. โหลดการตั้งค่าของ Application ด้วย Package "config" จาก Environment Variable
  2. สร้างการเชื่อมต่อกับ PostgreSQL โดยใช้ Connection String จากการตั้งค่าที่กำหนด
  3. สร้าง BookStore Object ที่ใช้ Database ที่เชื่อมต่อไว้
  4. สร้าง Handler Object ที่ใช้ BookStore
  5. สร้าง Goroutine ที่ทำงานในพื้นหลัง เพื่อตรวจสอบการเชื่อมต่อ Database ทุก 10 วินาที ถ้าการเชื่อมต่อหลุด จะพยายามเชื่อมต่อใหม่
  6. ตั้ง Gin ให้ทำงานในโหมด Release
  7. สร้าง Default Gin Router
  8. กำหนด Route สำหรับ Health Check
  9. สร้าง Route Group สำหรับ API Version 1
  10. กำหนด Route สำหรับการจัดการหนังสือ (GET, POST, DELETE)
  11. รัน Web Server ตาม Port ที่กำหนดใน Environment Variable

การสร้าง Version ของ API ที่ชัดเจนและเข้าใจง่ายจะหลีกเลี่ยงความสับสนให้กับนักพัฒนาเมื่อมีการเพิ่มคุณสมบัติใหม่ ๆ ให้กับ API หรือเมื่อต้องการแก้ไขปัญหาที่มีอยู่ หรือเมื่อต้องการเปลี่ยนวิธีการทํางานของ API

หากเราเปลี่ยนชื่อ Field เดิมโดยไม่มีระบบ Version แล้ว Application ที่เรียกใช้ API อาจหยุดทํางานหรือมีการแสดงข้อผิดพลาด เพื่อแก้ไขปัญหานี้เราต้องขอให้นักพัฒนาเลือกเวลาที่จะ Update Application ให้สามารถทํางานกับ API Version ใหม่

ดังนั้นจึงเป็นแนวทางที่ดีที่จะมีระบบ Version ใน API ของเรา อย่างไรก็ตามก็มีข้อยกเว้นกับ Health Check Endpoint ที่ควรวางไว้นอก Version

ระบบ Monitoring หรือ Load Balancer มักจะใช้ Health Check Endpoint เพื่อตรวจสอบว่า Component ต่าง ๆ ยังทำงานอยู่หรือไม่ Health Check จึงควรเข้าถึงได้ง่ายและไม่ควรขึ้นกับ Version ของ API เพราะมันใช้สำหรับตรวจสอบสถานะของระบบ และไม่ใช่ส่วนหนึ่งของ Business Logic

package main

import (
	"log"
	"myproject/internal/bookstore"
	"myproject/internal/config"
	"myproject/internal/handlers"
	"time"

	"github.com/gin-gonic/gin"
)

func main() {
	cfg, err := config.LoadConfig()
	if err != nil {
		log.Fatalf("Failed to load config: %v", err)
	}
	db, err := bookstore.NewPostgresDatabase(cfg.GetConnectionString())
	if err != nil {
		log.Printf("Failed to connect to database: %v", err)
	}
	defer db.Close()

	bs := bookstore.NewBookStore(db)
	h := handlers.NewBookHandlers(bs)

	go func() {
		for {
			time.Sleep(10 * time.Second)
			if err := db.Ping(); err != nil {
				log.Printf("Database connection lost: %v", err)
				// พยายามเชื่อมต่อใหม่
				if reconnErr := db.Reconnect(cfg.GetConnectionString()); reconnErr != nil {
					log.Printf("Failed to reconnect: %v", reconnErr)
				} else {
					log.Printf("Successfully reconnected to the database")
				}
			}
		}
	}()

	gin.SetMode(gin.ReleaseMode)
	r := gin.Default()

	r.GET("/health", h.HealthCheck)

	// API v1
	v1 := r.Group("/api/v1")
	{
		v1.GET("/books/:id", h.GetBook)
		v1.POST("/books", h.AddBook)
		v1.DELETE("/books/:id", h.DeleteBook)
	}

	if err := r.Run(":" + cfg.AppPort); err != nil {
		log.Printf("Failed to run server: %v", err)
	}
}
main.go

ตั้งค่า Environment Variable ชั่วคราวบน OS ของท่านผ่าน Command Line แต่ก่อนตั้งค่าให้ตรวจสอบ IP Address ด้วยสำสั่ง ifconfig บน Unix/Linux หรือคำสั่ง ipconfig บน Windows เพื่อกำหนด IP Address ของ POSTGRES_HOST  

export APP_PORT=8080
export POSTGRES_HOST=172.20.10.2
export POSTGRES_PORT=5432
export POSTGRES_USER=bookstore_user
export POSTGRES_PASSWORD=your_strong_password
export POSTGRES_DBNAME=bookstore
export POSTGRES_SSLMODE=disable
การตั้งค่า Environment Variable ชั่วคราว บน Unix/Linux
set APP_PORT 8080
set POSTGRES_HOST 172.20.10.2
set POSTGRES_PORT 5432
set POSTGRES_USER bookstore_user
set POSTGRES_PASSWORD your_strong_password
set POSTGRES_DBNAME bookstore
set POSTGRES_SSLMODE disable
การตั้งค่า Environment Variable ชั่วคราว บน Windows

Compile Code ด้วยคำสั่ง go build และรัน Program

go build cmd/main.go

./main 

ตรวจสอบสถานะของ Server และ Database Connection ผ่าน Web Browser ด้วย URL http://localhost:8080/health

ถ้าได้รับ Status "healthy" ก็แสดงว่าเราได้รัน Web Server และสามารถเชื่อมต่อกับ PostgreSQL แล้ว

ทดลอง Stop PostgreSQL ด้วยคำสั่ง docker-compose stop ใน Folder "bookstoredatabase\" ที่สร้างไว้เมื่อหัวข้อที่แล้ว

docker-compose stop

ตรวจสอบสถานะของ Server และ Database Connection ผ่าน Web Browser ด้วย URL http://localhost:8080/health อีกครั้ง

Start PostgreSQL ด้วยคำสั่ง docker-compose up -d ใน Folder "bookstoredatabase\"

docker-compose up -d

เมื่อทดลองรัน Web Server บน Localhost โดยตรงแล้วให้ Stop มันโดยไปที่ Terminal ของ VS Code กด Ctrl+C

เตรียม File ต่าง ๆ สำหรับการ Deploy บน Docker Container ได้แก่ .env, .gitignore, Dockerfile และ docker-compose.yml ดังต่อไปนี้

# App
APP_PORT=8080

# Database
POSTGRES_HOST=172.20.10.2
POSTGRES_PORT=5432
POSTGRES_USER=bookstore_user
POSTGRES_PASSWORD=your_strong_password
POSTGRES_DBNAME=bookstore
POSTGRES_SSLMODE=disable
.env

.env จะถูกใช้ในการตั้งค่าที่สำคัญของ Application ซึ่งมันจะถูกเรียกจากใน File "docker-compose.yml" เพื่อกำหนด Environment Variable ให้ Container ที่ docker-compose.yml ดูแล

การบอกให้ Git แยก File ".env" ซึ่งมีข้อมูลการตั้งค่าที่สําคัญไว้นอก Codebase โดยใช้ .gitignore เป็นวิธีที่ปลอดภัยในการเก็บข้อมูลที่ละเอียดอ่อน

# Environment variables
.env
.gitignore

Docker จะช่วยสร้าง Container ที่มีสภาพแวดล้อมเหมือนกันทุกครั้งที่มีการรัน จึงช่วยลดปัญหา "But it works on my machine" รวมทั้งมีความยืดหยุ่นในการปรับขนาดเพื่อรองรับโหลดที่เปลี่ยนแปลง

Dockerfile คือ File ที่เก็บคำสั่งสำหรับการ Build Image ซึ่งเราจะใช้วิธีการ Multi-stage Build ในการลดขนาดของ Image สุดท้ายสำหรับการ Deploy

แต่ก่อนจะ Build Image เราต้องเลือก Base Image ที่เหมาะสมสำหรับการ Compile Code โดยการตรวจสอบ Version ของ Go จาก File "go.mod"

ดังนั้นใน Stage แรกเราจะใช้ golang:1.21.0 ซึ่งเป็น Official Image ขนาดใหญ่ที่ได้รับการ Maintain จาก Go Team ซึ่งมี Tool ที่จำเป็นเป็นฐานในการ Compile Code โดยเราจะ Download Dependency ที่ต้องใช้ทั้งหมดด้วยคำสั่ง go mod download แล้ว Compile มัน

และใน Stage ที่สอง เราจะใช้ Image alpine:latest ซึ่งมีขนาดเล็กกว่าและมีความปลอดภัยสูงสำหรับการรัน Web Server

# Build Stage
FROM golang:1.21.0 AS builder

WORKDIR /app

COPY . .
RUN go mod download

RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd

# Run Stage
FROM alpine:latest  

RUN apk --no-cache add ca-certificates

WORKDIR /root/

COPY --from=builder /app/main .

CMD ["./main"]
Dockerfile

docker-compose.yml คือ File ที่เก็บคำสั่งในการ Config และจัดการ Container

เพื่อให้ง่ายในการตั้งค่าเราจะบอกให้ Docker Compose โหลดข้อมูลจาก File ".env" เข้า Environment Variable ของ Container สำหรับใช้ตั้งค่า Web Server

services:
  app:
    build: .
    ports:
      - "${APP_PORT}:${APP_PORT}"
    env_file: .env
docker-compose.yml

Deploy Web Server ด้วยคำสั่ง docker-compose up -d

docker-compose up -d

และตรวจสอบสถานะการทำงานของ Container ด้วยคำสั่ง docker-compose ps

 docker-compose ps

ตรวจสอบสถานะของ Server และ Database Connection ผ่าน Postman ที่ Endpoint GET http://localhost:8080/health

GET http://localhost:8080/health

ทดสอบการดึงข้อมูลหนังสือด้วย ID 1 ที่ไม่มีอยู่ใน Database ผ่าน Postman ที่ Endpoint GET http://localhost:8080/api/v1/books/1 ซึ่งควรมีการส่ง Error "book not found" กลับมา

GET http://localhost:8080/api/v1/books/1

ทดสอบการดึงข้อมูลหนังสือด้วย ID 2 ที่มีใน Database ผ่าน Postman ที่ Endpoint GET http://localhost:8080/api/v1/books/2 ซึ่งควรมีการส่งชื่อหนังสือกลับมา

GET http://localhost:8080/api/v1/books/2

ทดสอบการเพิ่มหนังสือใหม่ผ่าน Postman ที่ Endpoint POST http://localhost:8080/api/v1/books โดยส่งข้อมูลหนังสือผ่าน Boby แบบ JSON ดังนี้

{
	"title": "Clean Code"
}
POST http://localhost:8080/api/v1/books

ทดสอบการลบหนังสือด้วย ID 2 ผ่าน Postman ที่ Endpoint DELETE http://localhost:8080/api/v1/books/2

DELETE http://localhost:8080/api/v1/books/2

ดู Logs ของ Web Server ที่รันใน Container ด้วยคำสั่ง docker-compose logs

docker-compose logs

Exercise

เพิ่ม Endpoint ใหม่ให้กับ API ร้านหนังสือสำหรับนับจำนวนหนังสือทั้งหมดใน Database โดยมี Endpoint คือ GET /api/v1/books/count และส่งคืนจำนวนหนังสือเป็น JSON เช่น {"count": 5}

Go Quiz 18 (23 ข้อ) ขอให้สนุกกับการทำ Quiz นะครับ

Q&A?

รวม Cheat Sheet การพัฒนาและ Deploy REST API ด้วย Go และ Docker Container

การพัฒนาและ Deploy REST API ด้วย Go และ Docker Container
------------------------------------------------------
1. โครงสร้าง Project
   - cmd/main.go        // จุดเริ่มต้นของ Application
   - internal/
     - bookstore/       // Business Logic
     - config/          // การจัดการ Configuration
     - handlers/        // HTTP Handler
   - .env               // Environment Variable
   - Dockerfile         // สำหรับ Build Docker Image
   - docker-compose.yml // สำหรับจัดการ Container

2. การจัดการการตั้งค่า
   - ใช้ Viper สำหรับอ่านค่า Environment Variable
   - viper.AutomaticEnv()
   - viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
   - viper.GetString("KEY"), viper.GetInt("KEY")

3. การเชื่อมต่อ Database
   - sql.Open("postgres", connStr) // สร้างการเชื่อมต่อ
   - db.Ping() // ทดสอบการเชื่อมต่อ
   - defer db.Close() // ปิดการเชื่อมต่อ
   - ตั้งค่า Connection Poolเช่น
     db.SetMaxOpenConns(25)
     db.SetMaxIdleConns(25)
     db.SetConnMaxLifetime(5 * time.Minute)

4. Business Logic (bookstore.go)
   - แยกBusiness Logic ออกมา ทำให้โค้ดอ่านง่าย ดูแลรักษาง่าย
   - GetBook(ctx, id) // ดึงข้อมูลหนังสือ
   - AddBook(ctx, title) // เพิ่มหนังสือ
   - DeleteBook(ctx, id) // ลบหนังสือ
   - Ping() // ตรวจสอบการเชื่อมต่อ Database

5. HTTP Handler (book_handlers.go)
   - จัดการ HTTP requests, เชื่อมต่อกับ business logic
   - GetBook(c *gin.Context)
   - AddBook(c *gin.Context)
   - DeleteBook(c *gin.Context)
   - HealthCheck(c *gin.Context)

6. Gin Framework Routing (main.go)
   - r := gin.Default()
   - r.GET("/health", h.HealthCheck)
   - v1 := r.Group("/api/v1") // แยก API versions ชัดเจน
   - v1.GET("/books/:id", h.GetBook)
   - v1.POST("/books", h.AddBook)
   - v1.DELETE("/books/:id", h.DeleteBook)

7. Unit Test (book_handlers_test.go)
   - ใช้ MockBookDatabase สำหรับจำลอง Database
   - ทดสอบ handlers โดยไม่ต้องใช้ DB จริง
   - ทดสอบ GetBook, AddBook, DeleteBook, HealthCheck
   - httptest.NewRecorder() // สำหรับจำลอง HTTP response
   - gin.CreateTestContext() // สำหรับสร้าง Test Context

8. Docker
   - ใช้ Docker สร้างสภาพแวดล้อมที่แน่นอน ง่ายต่อการ Deploy
   - Dockerfile // สร้าง image แบบ multi-stage build
   - docker-compose.yml // กำหนดการทำงานของ Container

9. การ Deploy
   - docker-compose up -d
   - docker-compose ps
   - docker-compose logs

10. API Endpoints
    - GET /health
    - GET /api/v1/books/:id
    - POST /api/v1/books
    - DELETE /api/v1/books/:id

เมื่อท่านอ่านและทำ Workshop ครบทั้ง 4 Part รวม 19 Chapter แล้ว ผู้เขียนหวังเป็นอย่างยิ่งว่าท่านจะได้รับความรู้พื้นฐานที่สำคัญเกี่ยวกับการเขียน Program ด้วยภาษา Go สำหรับการพัฒนาระบบ Back End ของ E-commerce Platform ที่ครอบคลุมหัวข้อสำคัญใน Part ต่าง ๆ ดังนี้

Part 1
การติดตั้งและเริ่มต้นใช้งาน Go ไวยากรณ์พื้นฐาน ตัวแปรและชนิดข้อมูล การควบคุมการทำงานด้วย if-else และ Loop รวมถึงการสร้างและใช้งาน Function

นอกจากนี้ยังอธิบายถึงคุณสมบัติพิเศษของ Go เช่น การเป็น First-Class Citizen Function ซึ่งรวมทั้งการใช้งาน Anonymous Function, Closure และการส่ง Function เป็น Parameter

Part 2
ความรู้เชิงลึกเกี่ยวกับโครงสร้างข้อมูลและแนวคิดสำคัญในการเขียน Program ด้วย Go สำหรับการพัฒนาระบบ Back End ของ E-commerce Platform โดยเฉพาะหัวข้อสำคัญ เช่น Array, Slice และ Map การใช้งาน Pointer การสร้าง Struct และ Method การทำงานกับ Interface การจัดการ Package และ Module

รวมถึงแนวคิดและวิธีการใช้งานฟีเจอร์ต่าง ๆ ของ Go อย่างละเอียด เช่น การใช้ defer, panic และ recover ในการจัดการกับข้อผิดพลาดร้ายแรง การเขียน Unit Test และการใช้ Table-Driven Test เพื่อทดสอบ Code อย่างมีประสิทธิภาพ

Part 3
ความรู้เชิงลึกเกี่ยวกับการทำงานกับ File และข้อมูล การทำงานแบบ Concurrent ด้วย Goroutine และ Channel การจัดการเวลาและ Context สำหรับการพัฒนาระบบ Back End ของ E-commerce Platform

โดยครอบคลุมการอ่านและเขียน File ประเภทต่าง ๆ การใช้ Goroutine และ Channel เพื่อเพิ่มประสิทธิภาพในการประมวลผล และการจัดการกับเวลาและ Timezone รวมถึงการใช้ Context เพื่อควบคุมการทำงานของ Function ที่ใช้เวลานาน

นอกจากนี้ยังรวมถึงแนวปฏิบัติที่ดีในการทำงานกับเวลาและ Context เช่น การใช้ UTC ในการคำนวณเวลา การใช้ ISO 8601 Format สำหรับการแสดงผลและส่งข้อมูลผ่าน API และการใช้ Context เพื่อควบคุมการยกเลิกการทำงาน

Part 4
การพัฒนาและ Deploy REST API ด้วย Go และ Docker Container อย่างครบวงจร ครอบคลุมหัวข้อสำคัญตั้งแต่การจัดการการตั้งค่าด้วย Environment Variable การพัฒนา API ด้วย Gin Framework การทำงานกับ PostgreSQL Database และ Connection Pooling การทำ Unit Test ขั้นสูง และการใช้ Mock Object ไปจนถึงการ Deploy ด้วย Docker Container

โดยแสดงตัวอย่าง Code และคำอธิบายในการสร้างโครงสร้าง Project การเขียน Business Logic การจัดการ HTTP Request/Response การทดสอบ API และการตั้งค่า Docker พร้อมทั้งอธิบายแนวคิดสำคัญ เช่น Dependency Injection การใช้ Interface เพื่อลดการพึ่งพาระหว่าง Module และแนวปฏิบัติที่ดีในการพัฒนา API เช่นการทำ Versioning

พร้อมรับมือกับความท้าทายในโลกของการพัฒนา Software ยุคใหม่แล้วหรือยัง