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

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


This Article on Mastering Golang for E-commerce Back End Development : Part 3, licensed under CC BY-NC-ND

การพัฒนา Application ที่มีประสิทธิภาพและยืดหยุ่นต้องอาศัยความเข้าใจในการจัดการข้อมูลและเวลาอย่างลึกซึ้ง ในบทความนี้ เราจะเจาะลึกถึงเทคนิคสำคัญในการทำงานกับไฟล์และการประมวลผลแบบพร้อมกันใน Go

เราจะเริ่มด้วยการเรียนรู้วิธีอ่านและเขียนไฟล์ประเภทต่าง ๆ ตั้งแต่ Text File ไปจนถึง JSON File ซึ่งเป็นทักษะพื้นฐานสำหรับนักพัฒนา จากนั้นจะก้าวไปสู่การใช้งาน Goroutine, Channel และ WaitGroup เพื่อเพิ่มประสิทธิภาพในการประมวลผลแบบพร้อมกัน

ท้ายที่สุด เราจะสำรวจการทำงานกับเวลา และ Context ซึ่งเป็นส่วนสำคัญในการพัฒนา Application ที่มีความน่าเชื่อถือ ไม่ว่าจะเป็นการจัดการกับ Timezone ที่แตกต่างกัน หรือการควบคุมการทำงานของ Function ที่ใช้เวลาทำงานนาน

พร้อมแล้วหรือยังที่จะยกระดับทักษะการเขียน Program ด้วย Go ของคุณ มาเริ่มกันเลย!

ทำงานกับ File และข้อมูล

การเรียนรู้วิธีทำงานกับไฟล์และข้อมูลเป็นทักษะสำคัญในการพัฒนา Program ที่ต้องมีการจัดการกับข้อมูลจริง

Go มี Package "os", "io" และ "bufio" ให้ใช้สำหรับการอ่านและเขียนไฟล์ ทั้งแบบ Text File, Binary File และ Structure Data File อย่างเช่น JSON, XML และ CSV ฯลฯ

ในหัวข้อนี้เราจะใช้ Function "os.ReadFile()" สำหรับการอ่านไฟล์ และ Function "os.WriteFile()" สำหรับการเขียนไฟล์ขนาดเล็ก

Text File และ Binary File ขนาดเล็ก สามารถใช้ os.ReadFile() และ os.WriteFile() สำหรับอ่านและเขียนได้ เช่นเดียวกับ Structure Data File ครับ แต่การอ่านและเขียน Structure Data File จะต้องมีขั้นตอนเพิ่มเติมในการ Parse และ Encode ข้อมูล ด้วย Package อย่างเช่น encoding/json, encoding/xml หรือ encoding/csv ก่อน

โดยทั้ง 2 Function จะจัดการเรื่องการเปิดและปิดไฟล์ให้ หลังจากที่ทำงานเสร็จ ไม่ว่าจะทำงานเสร็จแบบ Success หรือเกิด Error ไฟล์จะถูกปิดโดยอัตโนมัติ

สำหรับไฟล์ขนาดใหญ่หรือไฟล์ที่ต้องการควบคุมการอ่าน/เขียนมากขึ้น เราอาจต้องใช้ Package "bufio" ที่ต้องมีการจัดการเรื่องการเปิดและปิดไฟล์เองร่วมด้วย เพราะ os.ReadFile() อย่างเดียวจะอ่านไฟล์ทั้งหมดเข้าหน่วยความจำในครั้งเดียว ซึ่งสำหรับไฟล์ขนาดใหญ่อาจทำให้ใช้หน่วยความจำมากเกินไป

แต่ในหัวข้อนี้จะยกตัวอย่างการจัดการกับไฟล์ขนาดเล็ก และการทำ Unit Test กับไฟล์ก่อน เพื่อให้เราเข้าใจหลักการสำคัญในการทำงานกับไฟล์

Text File

โดยทั่วไป อักขระแต่ละตัวที่เก็บใน Text File จะถูกเข้ารหัส (Encode) เป็น byte โดยใช้รูปแบบการเข้ารหัสแบบ ASCII หรือ UTF-8 ซึ่งมีลักษณะคล้ายคลึงกับวิธีการเก็บข้อมูลใน string ของ Go

สมมติว่าเรามี string "Hello สวัสดี"

s := "Hello สวัสดี"

Go จะเก็บข้อมูลเป็นลำดับของ byte ดังนี้

48 65 6C  6C 6F 20  E0 B8 AA  E0 B8 A7  E0 B8 B1  E0 B8 94  E0 B8 B5 

จากตัวอย่างนี้อักขระภาษาอังกฤษจะถูก Encode แบบ ASCII ที่มีขนาด 1 byte ส่วนอักขระภาษาไทยจะถูก Encode แบบ UTF-8 ที่มีขนาด 3 byte

ภาษาไทย ภาษาญี่ปุ่น และภาษาเกาหลี ฯลฯ อยู่ในกลุ่มภาษาที่จะถูก Encode แบบ UTF-8 ขนาด 3 byte ซึ่ง byte แรกจะมีค่าเป็น "E0" ขณะที่อักขระภาษาอังกฤษ (ASCII) ที่มีขนาดเพียง byte เดียว จะมีค่าน้อยกว่าหรือเท่ากับ "7F"

วิธีที่ง่ายและปลอดภัยที่สุดในการเข้าถึงอักขระแต่ละตัวจาก string ใน Go ที่มีอักขระที่เข้ารหัสแบบ UTF-8 ปนอยู่กับอักขระที่เข้ารหัสแบบ ASCII คือการใช้ for...range loop

เพราะ for...range loop จะจัดการกับการ Decode UTF-8 ให้โดยอัตโนมัติ โดยที่เราไม่ต้องเขียน Code เพิ่มเติมเพื่อจัดการการแบ่ง byte ตามขนาดของอักขระแต่ละประเภท

s := "Hello สวัสดี"

//_ คือตัวแปรที่ไม่ใช้ (ในที่นี้คือ index ของแต่ละอักขระ)
for _, char := range s {
	fmt.Printf("%c\n", char)
}

ขณะที่การใช้ Index จะทำให้ Go เข้าถึงข้อมูลทีละ byte จึงแสดงได้เฉพาะอักขระที่มีการเข้ารหัสแบบ ASCII

s := "Hello สวัสดี"

for i := 0; i < len(s); i++ {
	fmt.Printf("%c %X\n", s[i], s[i])
}

เราจะใช้ Function "os.ReadFile()" อ่านข้อมูลเป็นลำดับของ byte จากไฟล์ แล้วแปลงเป็น string ด้วย Function "string()" ครับ

package main

import (
    "fmt"
    "os"
)

func main() {
    // อ่านไฟล์
    content, err := os.ReadFile("text.txt")
    if err != nil {
        fmt.Println("Error reading file:", err)
        return
    }
    fmt.Println("File content:", string(content))

    // เขียนไฟล์
    newContent := []byte("Hello สวัสดี")
    err = os.WriteFile("newtext.txt", newContent, 0644)
    if err != nil {
        fmt.Println("Error writing file:", err)
    }
}

จากตัวอย่างด้านบน os.ReadFile() จะรับพารามิเตอร์ filename เป็น string และ Return กลับในตัวแปร 2 ตัว คือ content ที่มีชนิดข้อมูลเป็น Slice ของ byte ([]byte) และ err ที่เป็น Interface "error"

หมายเหตุ byte เป็น Alias ของ uint8 ซึ่งเป็นหนึ่งในประเภทข้อมูลพื้นฐานของ Go ที่เป็นตัวเลขไม่มีเครื่องหมาย (Unsigned Integer) ขนาด 8 บิต

ถ้าไม่มี error มันจะแปลง content จาก []byte เป็น string แล้วแสดงผลลัพธ์ทางหน้าจอ

สำหรับการเขียนไฟล์ เราจะสร้าง Slice ของ byte จาก string "Hello, World!" ซึ่งแต่ละอักขระที่เป็นภาษาอังกฤษจะถูก Encode แบบ ASCII และอักขระภาษาไทยจะถูก Encode แบบ UTF-8

Function "os.WriteFile()" จะรับพารามิเตอร์เป็น "newtext.txt" ซึ่งเป็นชื่อไฟล์ newContent ซึ่งเป็นข้อมูลแบบ []byte และ Permission o644 เพื่อตั้งค่าสิทธิ์ให้ไฟล์ในระบบ Unix (Read และ Write สำหรับเจ้าของ และ Read สำหรับผู้อื่น)

โดย Go จะไม่พยายามตั้งค่าสิทธิ์ o644 บน Windows

Go จะใช้การจัดการสิทธิ์การเข้าถึงไฟล์ แบบ Unix-like System ที่เป็นระบบเลขฐานแปด เช่น 0644 ซึ่งแต่ละตัวเลขจะแทนสิทธิ์สำหรับ Owner, Group และ Other ตามลำดับ โดย 0644 หมายถึง

Owner อ่านและเขียนได้ (6 = 4+2)
Group อ่านอย่างเดียว (4)
Other อ่านอย่างเดียว (4)

4 หมายถึง Read Permission
2 หมายถึง Write Permission
1 หมายถึง Execute Permission

Function "os.WriteFile" จะสร้างไฟล์ใหม่ถ้าไม่มีไฟล์เดิมอยู่ หรือเขียนทับไฟล์เดิมถ้ามีอยู่แล้ว!

Binary File

Binary File เช่น รูปภาพ ไฟล์เสียง และ ไฟล์ PDF จะเก็บข้อมูลเป็นลำดับของ byte โดยตรง ไม่มีการ Encode อักขระ

package main

import (
    "fmt"
    "os"
)

func main() {
    // อ่านไฟล์รูปภาพ
    imageData, err := os.ReadFile("image.jpg")
    if err != nil {
        fmt.Println("Error reading image:", err)
        return
    }
    fmt.Printf("Image size: %d bytes\n", len(imageData))

    // เขียนไฟล์รูปภาพ (copy)
    err = os.WriteFile("copy_image.jpg", imageData, 0644)
    if err != nil {
        fmt.Println("Error writing image:", err)
    }
}

Structure Data File

สำหรับ Structure Data File เช่น JSON, XML และ CSV เราจะต้อง อ่านเป็น []byte ก่อน แล้วค่อย Parse เป็น struct ส่วนการเขียนไฟล์เราจะต้อง Encode ข้อมูลจาก struct เป็นข้อมูลในรูปแบบที่กำหนด เช่น JSON ก่อนเขียนลงไฟล์

เราใช้ json.Unmarshal() ในการ Parse ข้อมูล JSON ที่อ่านมาให้เป็น struct Person ซึ่งเปรียบเสมือนการ "แกะ" หรือ "เปิด" บรรจุภัณฑ์เพื่อนำข้อมูลออกมาใช้ และ json.Marshal() เพื่อ Encode ข้อมูลจาก struct เป็น JSON ซึ่งเปรียบเสมือนการ "บรรจุ" ข้อมูลเพื่อการขนส่งครับ

package main

import (
    "encoding/json"
    "fmt"
    "os"
)

type Person struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

func main() {
    // อ่านไฟล์ JSON
    content, err := os.ReadFile("data.json")
    if err != nil {
        fmt.Println("Error reading file:", err)
        return
    }

    var person Person
    err = json.Unmarshal(content, &person)
    if err != nil {
        fmt.Println("Error parsing JSON:", err)
        return
    }
    fmt.Printf("Person: %+v\n", person)

    // เขียนไฟล์ JSON
    newPerson := Person{Name: "Jane", Age: 25}
    newContent, err := json.Marshal(newPerson)
    if err != nil {
        fmt.Println("Error encoding JSON:", err)
        return
    }

    err = os.WriteFile("newdata.json", newContent, 0644)
    if err != nil {
        fmt.Println("Error writing file:", err)
    }
}

ในตัวอย่างนี้เราจะต้อง Import package "encoding/json" สำหรับการแปลงข้อมูลระหว่าง Go struct และ JSON

แท็ก json:"name" และ json:"age" บอกให้ JSON encoder/decoder ใช้ชื่อฟิลด์เป็น "name" และ "age" ใน JSON แทนชื่อฟิลด์จริงในภาษา Go

ซึ่ง json.Marshal() จะ Encode "struct" เป็น JSON โดยใช้ชื่อฟิลด์ตามที่กำหนดก่อนเขียนลงไฟล์

การทดสอบเบื้องต้น

เราสามารถทดสอบการ Read/Write ไฟล์ โดยการเขียน Function ไว้ใน Package "fileops" แยกต่างหาก และใช้งานมันใน main() ได้อย่างเป็นระบบ โดยใช้โครงสร้าง Project ดังต่อไปนี้

myproject/
├── cmd
│   └── main.go
├── go.mod
└── internal
    └── fileops
        ├── file_ops.go
        └── file_ops_test.go

file_ops.go คือ ไฟล์ที่เก็บ Code ใน Package "fileops" ซึ่งมี Function "ReadTextFile", "WriteTextFile", "ReadJSONFile" และ "WriteJSONFile" ที่จะ Test

// file_ops.go
package fileops

import (
    "encoding/json"
    "os"
)

func ReadTextFile(filename string) (string, error) {
    content, err := os.ReadFile(filename)
    if err != nil {
        return "", err
    }
    return string(content), nil
}

func WriteTextFile(filename string, content string) error {
    return os.WriteFile(filename, []byte(content), 0644)
}

type Person struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

func ReadJSONFile(filename string) (Person, error) {
    var person Person
    content, err := os.ReadFile(filename)
    if err != nil {
        return person, err
    }
    err = json.Unmarshal(content, &person)
    return person, err
}

func WriteJSONFile(filename string, person Person) error {
    content, err := json.Marshal(person)
    if err != nil {
        return err
    }
    return os.WriteFile(filename, content, 0644)
}

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

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

  1. สร้างไฟล์ "test.txt" และเขียนข้อความ "Hello, World!" ลงไป
  2. อ่านไฟล์กลับมาและตรวจสอบว่าเนื้อหาตรงกับที่เขียนไปหรือไม่
  3. ถ้าการเขียนหรืออ่านไฟล์ล้มเหลว จะใช้ t.Fatalf เพื่อหยุดการทดสอบทันที
  4. ถ้าเนื้อหาไม่ตรงกัน จะใช้ t.Errorf เพื่อรายงานข้อผิดพลาด
  5. เมื่อ Test เสร็จแล้วจะลบไฟล์ทดสอบทิ้ง เพื่อทำความสะอาดและป้องกันผลกระทบต่อการ Test ครั้งต่อไป

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

  1. สร้างไฟล์ "test.json" และเขียนข้อมูล Person ลงไปในรูปแบบ JSON
  2. อ่านไฟล์ JSON กลับมาเป็น struct Person
  3. ตรวจสอบว่าข้อมูลที่อ่านได้ตรงกับข้อมูลที่เขียนไปหรือไม่
  4. ใช้ t.Fatalf และ t.Errorf เช่นเดียวกับ TestReadWriteTextFile
  5. ลบไฟล์ Test ทิ้งเมื่อ Test เสร็จแล้ว

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

พยายามอ่านไฟล์ที่ไม่มีอยู่ แล้วตรวจสอบว่าเกิด Error หรือไม่ (ซึ่งควรเกิด Error) ถ้าไม่เกิด Error จะรายงานข้อผิดพลาด

// file_ops_test.go
package fileops

import (
    "os"
    "testing"
)

func TestReadWriteTextFile(t *testing.T) {
    filename := "test.txt"
    content := "Hello, World!"

    // Test writing
    err := WriteTextFile(filename, content)
    if err != nil {
        t.Fatalf("Failed to write file: %v", err)
    }

    // Test reading
    readContent, err := ReadTextFile(filename)
    if err != nil {
        t.Fatalf("Failed to read file: %v", err)
    }

    if readContent != content {
        t.Errorf("Read content does not match written content. Got %s, want %s", readContent, content)
    }

    // Clean up
    os.Remove(filename)
}

func TestReadWriteJSONFile(t *testing.T) {
    filename := "test.json"
    person := Person{Name: "John", Age: 30}

    // Test writing
    err := WriteJSONFile(filename, person)
    if err != nil {
        t.Fatalf("Failed to write JSON file: %v", err)
    }

    // Test reading
    readPerson, err := ReadJSONFile(filename)
    if err != nil {
        t.Fatalf("Failed to read JSON file: %v", err)
    }

    if readPerson.Name != person.Name || readPerson.Age != person.Age {
        t.Errorf("Read person does not match written person. Got %+v, want %+v", readPerson, person)
    }

    // Clean up
    os.Remove(filename)
}

func TestReadNonExistentFile(t *testing.T) {
    _, err := ReadTextFile("non_existent.txt")
    if err == nil {
        t.Error("Expected an error when reading non-existent file, got nil")
    }
}

การเขียน Unit Test ในไฟล์ file_ops_test.go นี้ประกอบด้วย Test Case ทั้งแบบ Positive Test (Happy Path) ที่การทำงานควรสำเร็จโดยไม่มีปัญหา และแบบ Negative Test (Error Case) โดยการอ่านไฟล์ที่ไม่มีอยู่ ซึ่งควรเกิด Error เพื่อให้มั่นใจว่าโปรแกรมสามารถจัดการกับสถานการณ์ที่ไม่ปกติได้อย่างถูกต้องครับ

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

myproject/
├── cmd
└── internal
    └── fileops
        ├── file_ops.go
        └── file_ops_test.go

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

go mod init myproject

เริ่มทดสอบด้วยคำสั่ง go test ./...

go test ./...

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

go test -cover ./...

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

package main

import (
	"fmt"
	"log"

	"myproject/internal/fileops"
)

func main() {
	// 1. เขียนและอ่านไฟล์ข้อความ
	textFilename := "note.txt"
	noteContent := "สวัสดี นี่คือบันทึกส่วนตัว"

	err := fileops.WriteTextFile(textFilename, noteContent)
	if err != nil {
		log.Fatalf("ไม่สามารถเขียนไฟล์ข้อความ: %v", err)
	}
	fmt.Println("เขียนไฟล์ข้อความเรียบร้อยแล้ว")

	readContent, err := fileops.ReadTextFile(textFilename)
	if err != nil {
		log.Fatalf("ไม่สามารถอ่านไฟล์ข้อความ: %v", err)
	}
	fmt.Printf("เนื้อหาในไฟล์ข้อความ: %s\n\n", readContent)

	// 2. เขียนและอ่านไฟล์ JSON
	jsonFilename := "user.json"
	user := fileops.Person{Name: "ณัฐโชติ", Age: 25}

	err = fileops.WriteJSONFile(jsonFilename, user)
	if err != nil {
		log.Fatalf("ไม่สามารถเขียนไฟล์ JSON: %v", err)
	}
	fmt.Println("เขียนไฟล์ JSON เรียบร้อยแล้ว")

	readUser, err := fileops.ReadJSONFile(jsonFilename)
	if err != nil {
		log.Fatalf("ไม่สามารถอ่านไฟล์ JSON: %v", err)
	}
	fmt.Printf("ข้อมูลผู้ใช้จากไฟล์ JSON: ชื่อ: %s, อายุ: %d\n", readUser.Name, readUser.Age)
}

จากตัวอย่าง เราใช้ Function "Fatalf()" จาก Package "log" เพื่อแสดงข้อผิดพลาดและจบการทำงานของ Program ทันที โดย log.Fatalf() จะเพิ่มข้อมูลเวลาและวันที่ไว้ด้านหน้าข้อความโดยอัตโนมัติ เช่น

2023/08/06 15:04:05 ไม่สามารถเขียนไฟล์ข้อความ: open note.txt: no such file or directory

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

go build cmd/main.go

./main 

Text File และ JSON File จะมีเนื้อหาดังนี้ครับ

Exercise

เขียน Program ที่ใช้ Package "fileops" เพื่อบันทึกและอ่านข้อมูลนักเรียนที่เป็น Struct ดังนี้

type Student struct {
    Name string `json:"name"`
    Age int `json:"age"`
    Grade string `json:"grade"`
}

โดยสร้างข้อมูลนักเรียนอย่างน้อย 3 คน ใน main() เขียนข้อมูลนักเรียนลงในไฟล์ "students.json" อ่านข้อมูลแล้วแสดงข้อมูลนักเรียนที่ได้ทางหน้าจอ

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

Q&A?

รวม Cheat Sheet ทำงานกับ File และข้อมูล

ทำงานกับ File และข้อมูล
--------------------

1. อ่านไฟล์
   content, err := os.ReadFile(filename)

2. เขียนไฟล์
   err := os.WriteFile(filename, []byte(content), 0644)

3. แปลง struct เป็น JSON
   data, err := json.Marshal(structVar)

4. แปลง JSON เป็น struct
   err := json.Unmarshal([]byte(jsonStr), &structVar)

ทำงานพร้อมกันหลายอย่าง

การทำงานพร้อมกันหลายอย่าง (Concurrency) เป็นหนึ่งในจุดเด่นของ Go ทั้งในเรื่องความง่ายในการเขียน Code และความมีประสิทธิภาพ

Goroutine เป็น "Lightweight Thread" ที่จัดการโดย Go Runtime ขณะที่ Thread ในภาษาอื่นบางภาษา เช่น C++, Python และ Java จะถูกจัดการโดยระบบปฏิบัติการ

Goroutine จะใช้หน่วยความจำน้อยมาก ซึ่งถูกสร้าง สลับการทำงาน และทำลายได้เร็วกว่า Thread เราจึงสามารถสร้าง Goroutine ได้หลายล้านตัวในเครื่องทั่วไป

หัวข้อนี้เราจะเรียนรู้เกี่ยวกับ Goroutine, Channel และ WaitGroup เพื่อจัดการการทำงานพร้อมกันหลายอย่าง (Concurrency)

Goroutine คือ Function ที่ทำงานพร้อมกับ Code อื่น ๆ ทำให้ใช้ประโยชน์จาก CPU หลาย Core ได้อย่างเต็มที่ โดยเฉพาะในฝั่ง Back End ที่ต้องการจัดการงาน (Worker) หลาย ๆ งานพร้อมกัน

ถ้าเราสร้าง Worker 10 ตัว โดยใช้ Goroutine แต่ละ Worker จะทำงานบน CPU Core ที่ว่างตามการจัดการของ Go Scheduler ถ้าเรามี CPU 2 Core (ซึ่งน้อยกว่า 10) Worker ต่าง ๆ จะถูกสลับไปมาระหว่าง Core ที่มีอยู่ แต่ถ้ามี CPU Core มากกว่าหรือเท่ากับ 10 แต่ละ Worker อาจมีโอกาสทำงานบน Core แยกกันได้พร้อมกันจริง ๆ (Parallelism)

สมมติว่าเราสั่งอาหาร 2 อย่างพร้อมกัน คือ ผัดกะเพรา และต้มยำกุ้ง ที่ร้านอาหารแห่งหนึ่ง โดยมีพ่อครัวสองคนช่วยกันทำอาหาร

เรามี Function "prepareDish" ที่จำลองการทำอาหาร ที่จะใช้เวลาในการปรุงตามชนิดของอาหารที่สั่ง

ใน Function "main" เราจะใช้คำสั่ง go เพื่อสั่งให้พ่อครัวทำอาหาร 2 จานพร้อมกัน ได้แก่ ผัดกะเพรา และต้มยำกุ้ง โดยการทำผัดกะเพราจะใช้เวลา 2 วินาที และต้มยำกุ้งจะใช้เวลา 3 วินาที

package main

import (
    "fmt"
    "time"
)

func prepareDish(dish string, duration time.Duration) {
    fmt.Printf("เริ่มทำ%s...\n", dish)
    time.Sleep(duration)
    fmt.Printf("%sเสร็จแล้ว!\n", dish)
}

func main() {
    start := time.Now()

    go prepareDish("ผัดกะเพรา", 2*time.Second)
    go prepareDish("ต้มยำกุ้ง", 3*time.Second)

    time.Sleep(3 * time.Second)

    fmt.Printf("ใช้เวลาทั้งหมด: %.2f วินาที\n", time.Since(start).Seconds())
}

เมื่อใช้ Goroutine เราจะรอ 3 วินาที เพื่อให้อาหารทั้งสองจานเสร็จ แทนที่จะใช้เวลา 5 วินาทีถ้าต้องทำงานตามลำดับ

Goroutines จะทำงานแบบ "ไม่รอ" (Non-blocking) และเมื่อเราใช้ go นำหน้า Function แล้ว Program หลักก็จะไม่รอให้ Function นั้นทำงานเสร็จเช่นกัน

ถ้า Function "main" ทำงานเสร็จเร็วเกินไป Program จะปิดตัวลงพร้อมกับยกเลิก Goroutine ที่กำลังทำงานอยู่ทั้งหมด จากตัวอย่างนี้เราจึงต้องเพิ่มคำสั่ง time.Sleep(3 * time.Second) เพื่อให้ Function "main" จบการทำงานช้าลงหลังที่ Function "prepareDish" จะทำอาหารทั้งสองจานเสร็จ

ถ้าเราตัดคำสั่ง time.Sleep(3 * time.Second) ออก เวลาที่แสดงจะเป็น 0.00 วินาที เพราะ Program หลักจะจบการทำงานก่อน โดยที่ prepareDish ยังคงไม่มีโอกาสทำงานให้เสร็จ

package main

import (
	"fmt"
	"time"
)

func prepareDish(dish string, duration time.Duration) {
	fmt.Printf("เริ่มทำ%s...\n", dish)
	time.Sleep(duration)
	fmt.Printf("%sเสร็จแล้ว!\n", dish)
}

func main() {
	start := time.Now()

	go prepareDish("ผัดกะเพรา", 2*time.Second)
	go prepareDish("ต้มยำกุ้ง", 3*time.Second)

	fmt.Printf("ใช้เวลาทั้งหมด: %.2f วินาที\n", time.Since(start).Seconds())
}

Channel ใช้สำหรับการสื่อสารระหว่าง Goroutine เหมือนท่อที่ Goroutine ใช้ส่งข้อมูลถึงกัน

Channel ทำงานแบบ "ส่งแล้วต้องมีคนรับ" หรือ "รับแล้วต้องมีคนส่ง" ถ้าไม่มีการรับ/ส่ง หรือในทางตรงข้าม จะเกิดการทำงานแบบหยุดรอ (Block)

เราสามารถสร้าง Channel ส่งข้อมูลเข้า Channel รับข้อมูลจาก Channel และปิด Channel ได้ดังตัวอย่างต่อไปนี้

การสร้าง Channel

ch := make(chan int)  // สร้าง channel ที่ส่งข้อมูลประเภท int

การส่งข้อมูลเข้า Channel

ch <- 42  // ส่งค่า 42 เข้าไปใน channel

การรับข้อมูลจาก Channel

value := <-ch  // รับค่าจาก channel และเก็บใน value

การปิด Channel และการตรวจสอบว่า Channel ปิดหรือยัง

close(ch)  // ปิด channel

value, ok := <-ch
if !ok {
    fmt.Println("Channel ถูกปิดแล้ว")
}

จากตัวอย่างด้านล่าง Function "producer" ซึ่งเป็น Goroutine จะส่งค่า i เข้า Channel โดยที่มี Function "consumer" รับค่าจาก Channel จนกว่าจะถูกปิดจากใน Function "producer" ซึ่งเราต้องการให้ main() รอจนกว่า consumer() จะทำงานเสร็จ จึงไม่ใส่ go หน้า consumer()

func producer(ch chan int) {
    for i := 0; i < 5; i++ {
        ch <- i  // ส่งค่า i เข้า channel
    }
    close(ch)  // ปิด channel เมื่อส่งครบ
}

func consumer(ch chan int) {
    for v := range ch {  // รับค่าจาก channel จนกว่าจะปิด
        fmt.Println("รับค่า:", v)
    }
}

func main() {
    ch := make(chan int)
    go producer(ch)
    consumer(ch)
}

เราสามารถประยุกต์ใช้ Channel เพื่อให้ Goroutine แจ้งเมื่อทำงานเสร็จ ทำให้ main() ยังคงรอให้ Function "prepareDish" ทำอาหารเสร็จทั้ง 2 จานก่อนจะแสดงเวลาที่ใช้ทั้งหมด โดยที่ไม่ต้องใช้คำสั่ง time.Sleep(3 * time.Second)

package main

import (
    "fmt"
    "time"
)

func prepareDish(dish string, duration time.Duration, done chan bool) {
    fmt.Printf("เริ่มทำ%s...\n", dish)
    time.Sleep(duration)
    fmt.Printf("%sเสร็จแล้ว!\n", dish)
    done <- true
}

func main() {
    start := time.Now()
    done := make(chan bool)

    go prepareDish("ผัดกะเพรา", 2*time.Second, done)
    go prepareDish("ต้มยำกุ้ง", 3*time.Second, done)

    // รอให้ทั้งสองจานเสร็จ
    // เราไม่สนใจค่าที่รับมา (เพราะเราใช้ bool channel เพียงเพื่อส่งสัญญาณ)
    <-done
    <-done

    // คำนวณและแสดงเวลาที่ใช้ทั้งหมด
    elapsedTime := time.Since(start).Seconds()
    fmt.Printf("ใช้เวลาทั้งหมด: %.2f วินาที\n", elapsedTime)
}

แต่วิธีนี้ทำให้ Program ดูซับซ้อนขึ้น ซึ่งยังมีวิธีที่ง่ายกว่านั้นโดยการใช้ WaitGroup

WaitGroup เป็นโครงสร้างข้อมูลใน Go ที่ Import จาก Package "sync" ช่วยให้เราสามารถรอให้ Goroutine ทำงานเสร็จ ก่อนที่จะดำเนินการอื่น ๆ ต่อ มันทำงานเหมือน "ตัวนับถอยหลัง" สำหรับ Goroutine โดยมีวิธีการใช้งานดังนี้

Add(delta int)
	เพิ่มจำนวน goroutines ที่ต้องรอ
    
Done()
	ลดจำนวน goroutines ที่ต้องรอลง 1 มักใช้ควบคู่กับ defer
    
Wait()
	บล็อกจนกว่าตัวนับจะเป็นศูนย์

จากตัวอย่าง การใช้ defer wg.Done() เป็นแนวปฏิบัติที่ดีเพื่อรับประกันว่า wg.Done() จะถูกเรียกเมื่อฟังก์ชันเสร็จสิ้น ไม่ว่าจะเกิดอะไรขึ้นก็ตาม

ในกรณีที่ Function มีความซับซ้อนมาก หรือมีหลายจุดที่อาจจะ return ออกจาก Function การใช้ defer จะลดโอกาสที่จะลืมเรียก wg.Done() รวมทั้งหากมีการ Panic เกิดขึ้นใน Function, wg.Done() จะยังคงถูกเรียก

package main

import (
	"fmt"
	"sync"
	"time"
)

func PrintNumbers(prefix string, count int) {
	for i := 1; i <= count; i++ {
		fmt.Printf("%s: %d\n", prefix, i)
		time.Sleep(time.Millisecond * 100)
	}
}

func main() {
	var wg sync.WaitGroup
	wg.Add(2)

	go func() {
		defer wg.Done()
		PrintNumbers("X", 3)
	}()

	go func() {
		defer wg.Done()
		PrintNumbers("Y", 3)
	}()

	wg.Wait()
}

WaitGroup เป็นเครื่องมือที่มีประสิทธิภาพสำหรับการจัดการ Goroutine จำนวนมาก โดยเฉพาะเมื่อเราต้องการรอให้งานทั้งหมดเสร็จสิ้นก่อนที่จะดำเนินการต่อ เช่น การจำลองการทำอาหาร เพื่อให้ Function "main" จบการทำงานหลัง Function "prepareDish" จะทำอาหารทั้งสองจานเสร็จครับ

package main

import (
    "fmt"
    "time"
    "sync"
)

func prepareDish(dish string, duration time.Duration, wg *sync.WaitGroup) {
    defer wg.Done()  // แจ้งว่า goroutine นี้เสร็จแล้ว
    fmt.Printf("เริ่มทำ%s...\n", dish)
    time.Sleep(duration)
    fmt.Printf("%sเสร็จแล้ว!\n", dish)
}

func main() {
    start := time.Now()

    var wg sync.WaitGroup

    wg.Add(2)  // บอกว่าเรามี 2 goroutines ที่ต้องรอ
    go prepareDish("ผัดกะเพรา", 2*time.Second, &wg)
    go prepareDish("ต้มยำกุ้ง", 3*time.Second, &wg)

    wg.Wait()  // รอจนกว่าทุก goroutine จะเสร็จ

    fmt.Printf("ใช้เวลาทั้งหมด: %.2f วินาที\n", time.Since(start).Seconds())
}

การทดสอบเบื้องต้น

เราสามารถทดสอบ Package "cooking" แยกต่างหาก และใช้งานมันใน Program ได้อย่างเป็นระบบครับ โดยใช้โครงสร้าง Project ดังต่อไปนี้ครับ

myproject/
├── cmd
│   └── main.go
├── go.mod
└── internal
    └── cooking
        ├── cookmeals.go
        └── cookmeals_test.go

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

// cookmeals.go
package cooking

import (
    "fmt"
    "sync"
    "time"
)

func PrepareDish(dish string, duration time.Duration, wg *sync.WaitGroup) {
    defer wg.Done()
    fmt.Printf("เริ่มทำ%s...\n", dish)
    time.Sleep(duration)
    fmt.Printf("%sเสร็จแล้ว!\n", dish)
}

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

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

ใน TestPrepareDish เราแบ่งการ Test เป็น 2 กรณี โดยใช้ t.Run() คือ

Test Case 1 ทดสอบการทำอาหารหนึ่งจาน (ผัดกะเพรา 2 วินาที) โดยการตรวจสอบว่าใช้เวลาอย่างน้อย 2 วินาทีหรือไม่

Test Case 2 ทดสอบการทำอาหารสองจานพร้อมกัน (ผัดกะเพรา 2 วินาที และต้มยำกุ้ง 3 วินาที) โดยการตรวจสอบว่าใช้เวลาอย่างน้อย 3 วินาที (เวลาของจานที่นานที่สุด) และใช้เวลาน้อยกว่า 5 วินาที (เพื่อยืนยันว่าทำงานพร้อมกันจริง) หรือไม่

// cookmeals_test.go
package cooking

import (
    "sync"
    "testing"
    "time"
)

func TestPrepareDish(t *testing.T) {
    t.Run("ทดสอบการทำอาหารหนึ่งจาน", func(t *testing.T) {
        var wg sync.WaitGroup
        wg.Add(1)

        start := time.Now()
        go PrepareDish("ผัดกะเพรา", 2*time.Second, &wg)
        wg.Wait()
        duration := time.Since(start)

        if duration < 2*time.Second {
            t.Errorf("ใช้เวลาน้อยเกินไป คาดหวัง >= 2 วินาที, ได้รับ %v", duration)
        }
    })

    t.Run("ทดสอบการทำอาหารสองจานพร้อมกัน", func(t *testing.T) {
        var wg sync.WaitGroup
        wg.Add(2)

        start := time.Now()
        go PrepareDish("ผัดกะเพรา", 2*time.Second, &wg)
        go PrepareDish("ต้มยำกุ้ง", 3*time.Second, &wg)
        wg.Wait()
        duration := time.Since(start)

        if duration < 3*time.Second {
            t.Errorf("ใช้เวลาน้อยเกินไป คาดหวัง >= 3 วินาที, ได้รับ %v", duration)
        }
        if duration >= 5*time.Second {
            t.Errorf("ใช้เวลามากเกินไป คาดหวัง < 5 วินาที, ได้รับ %v", duration)
        }
    })
}

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

myproject/
├── cmd
└── internal
    └── cooking
        ├── cookmeals.go
        └── cookmeals_test.go

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

go mod init myproject

เริ่มทดสอบด้วยคำสั่ง go test ./...

go test ./...

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

go test -cover ./...

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

// main.go
package main

import (
    "fmt"
    "sync"
    "time"

    "myproject/internal/cooking"
)

func main() {
    fmt.Println("เริ่มต้นการทำอาหาร")

    var wg sync.WaitGroup
    wg.Add(2)

    start := time.Now()

    go cooking.PrepareDish("ผัดกะเพรา", 2*time.Second, &wg)
    go cooking.PrepareDish("ต้มยำกุ้ง", 3*time.Second, &wg)

    wg.Wait()

    duration := time.Since(start)

    fmt.Printf("การทำอาหารเสร็จสิ้น ใช้เวลาทั้งหมด: %.2f วินาที\n", duration.Seconds())
}

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

go build cmd/main.go

./main 

Exercise

สร้างโปรแกรมจำลองการแข่งขันนับเลขมงคลระหว่าง "คุณ" และ "เพื่อน" โดยแต่ละคนจะนับเลขที่หารด้วย 3 ลงตัวตั้งแต่ 1 ถึง 30

โดยมีผลลัพธ์ดังตัวอย่างต่อไปนี้

คุณ พบเลขมงคล: 3
เพื่อน พบเลขมงคล: 3
คุณ พบเลขมงคล: 6
เพื่อน พบเลขมงคล: 6
เพื่อน พบเลขมงคล: 9
คุณ พบเลขมงคล: 9
คุณ พบเลขมงคล: 12
เพื่อน พบเลขมงคล: 12
เพื่อน พบเลขมงคล: 15
คุณ พบเลขมงคล: 15
คุณ พบเลขมงคล: 18
เพื่อน พบเลขมงคล: 18
เพื่อน พบเลขมงคล: 21
คุณ พบเลขมงคล: 21
คุณ พบเลขมงคล: 24
เพื่อน พบเลขมงคล: 24
เพื่อน พบเลขมงคล: 27
คุณ พบเลขมงคล: 27
คุณ พบเลขมงคล: 30
เพื่อน พบเลขมงคล: 30
แข่งขันเสร็จสิ้น ใช้เวลาทั้งหมด: 3.01 วินาที

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

Q&A?

รวม Cheat Sheet ทำงานพร้อมกันหลายอย่าง

ทำงานพร้อมกันหลายอย่าง
-------------------

1. Goroutine
   go functionName()

2. Channel
   ch := make(chan int) // สร้าง Channel
   ch <- value  // ส่งค่า
   value := <-ch  // รับค่า
   close(ch)  // ปิด channel

3. WaitGroup
   var wg sync.WaitGroup
   wg.Add(n)  // เพิ่มจำนวน goroutines ที่ต้องรอ
   wg.Done()  // เรียกเมื่อ goroutine ทำงานเสร็จ
   wg.Wait()  // รอให้ทุก goroutine เสร็จ

4. ข้อควรระวัง
   - พิจารณาใช้ channel หรือ sync.WaitGroup อย่างระมัดระวัง

Time Package และ Context : การทำงานกับเวลาและบริบท

การเข้าใจการทำงานกับเวลาและบริบท (Context) ใน Go เป็นส่วนสำคัญในการพัฒนา Application ที่มีประสิทธิภาพและควบคุมได้

การทำงานกับเวลา

สำหรับระบบอย่างเช่น E-commerce Platform การจัดการเวลาเป็นสิ่งสำคัญมาก โดยเฉพาะในการติดตามคำสั่งซื้อ การจัดการสินค้าคงคลัง และการจัดการกับการแจ้งเตือน ฯลฯ

https://colin-scott.github.io/personal_website/research/interactive_latency.html

Go มี Package "time" สำหรับการจัดการเรื่องเวลา โดยเมื่อมีการใช้ Function "time.Now()" มันจะคืนค่าเวลาตาม Timezone ท้องถิ่นของระบบ ซึ่งเราจะได้รับค่าเป็น time.Time ที่มี Type เป็น struct สำหรับเก็บข้อมูลเวลาตามระบบของ Golang เอง

แต่เมื่อพิมพ์ค่าของ time.Time ออกมาทางหน้าจอ Go จะแสดงเวลาเป็น String ดังต่อไปนี้

now := time.Now()
fmt.Println(now) // 2024-08-07 08:26:41.419123 +0700 +07 m=+0.000271334

"2024-08-07" คือ วันที่ในรูปแบบ YYYY-MM-DD

"08:26:41.419123" คือ เวลาในรูปแบบ HH:MM:SS.nnnnnn

"+0700" คือ ค่า Offset ของ Timezone จาก UTC

"+07" คือ ตัวย่อของ Timezone

"m=+0.000271334" หรือ Monotonic Clock คือ ระยะเวลาที่ผ่านไปนับจากจุดอ้างอิง (หน่วยเป็นนาโนวินาที) เช่น ระยะเวลาที่ระบบเริ่มทำงาน (System Boot Time) ซึ่งมักใช้สำหรับการวัดประสิทธิภาพและการทำ Benchmarking ของ Program

package main

import (
	"fmt"
	"time"
)

func main() {
	t1 := time.Now()
	fmt.Println("Time 1:", t1)

	time.Sleep(2 * time.Second)

	t2 := time.Now()
	fmt.Println("Time 2:", t2)

	fmt.Printf("Duration: %v\n", t2.Sub(t1))
}

Go จะนำค่า Monotonic Clock ของเวลาที่จุด t2 ลบ ด้วยจุด t1 เมื่อเราใช้คำสั่ง t2.Sub(t1) ดัง Code ด้านบน

โดย time.Time เป็น struct ที่มีโครงสร้างดังต่อไปนี้

type Time struct {
    wall uint64
    ext  int64
    loc *Location
}

wall เป็น uint64 ที่เก็บข้อมูลวันที่และเวลา
ext เป็น int64 ที่ใช้เก็บข้อมูลเพิ่มเติม (นาโนวินาที)
loc เป็น Pointer ไปยัง Location struct ที่เก็บข้อมูลเกี่ยวกับ Timezone

เราไม่แนะนำให้เข้าถึง time.Time โดยตรง เพราะโครงสร้างภายในของ time.Time อาจเปลี่ยนแปลงใน Version ต่อไป ทำให้ Code ที่เข้าถึงโดยตรงทำงานผิดพลาด รวมทั้งอาจทำให้ Program ไม่ปลอดภัย

อย่างไรก็ตาม หากต้องการเข้าถึง time.Time โดยตรงเพื่อวัตถุประสงค์ในการศึกษา เราสามารถทำได้โดยใช้ Package "unsafe"

package main

import (
    "fmt"
    "time"
    "unsafe"
)

func main() {
    t := time.Now()

    // สร้าง struct ที่มีโครงสร้างเหมือน time.Time
    type internalTime struct {
        wall uint64
        ext  int64
        loc  *time.Location
    }

    // ใช้ unsafe.Pointer เพื่อเข้าถึงข้อมูลภายใน
    internalT := (*internalTime)(unsafe.Pointer(&t))

    fmt.Printf("wall: %d\n", internalT.wall)
    fmt.Printf("ext: %d\n", internalT.ext)
    fmt.Printf("loc: %v\n", internalT.loc)
}

แทนที่จะเข้าถึง time.Time โดยตรง เราควรใช้ Method ที่ Go เตรียมไว้ให้ เช่น

t := time.Now()
fmt.Println(t)
fmt.Println("Year:", t.Year())
fmt.Println("Month:", t.Month())
fmt.Println("Day:", t.Day())
fmt.Println("Hour:", t.Hour())
fmt.Println("Minute:", t.Minute())
fmt.Println("Second:", t.Second())
fmt.Println("Nanosecond:", t.Nanosecond())
fmt.Println("Location:", t.Location())

// zoneOffset คือ จำนวนวินาทีที่ต้องบวกเข้าไปใน UTC เพื่อให้ได้เวลาใน Zone เวลานั้น ๆ
zoneName, zoneOffset := t.Zone()
fmt.Printf("Zone: %s, Offset: %d\n", zoneName, zoneOffset)

fmt.Println("Unix timestamp:", t.Unix())
fmt.Println("Unix nano:", t.UnixNano())

time.Now() จะคืนค่าเวลาตาม Timezone ท้องถิ่นของระบบ แต่การเก็บข้อมูลเวลาที่ขึ้นกับ Timezone จะต้องระมัดระวังเมื่อต้องจัดการกับเหตุการณ์ที่เกิดขึ้นในหลาย Timezone เช่น การจองตั๋วเครื่องบินระหว่างประเทศ โดยเฉพาะในประเทศที่มีการปรับนาฬิกาให้เร็วขึ้น 1 ชั่วโมงในช่วงฤดูร้อนที่กลางวันยาวนาน และปรับกลับในช่วงฤดูหนาว ที่กลางวันสั้นกว่า (Daylight Saving Time) เช่น สหรัฐอเมริกา แคนาดา และอังกฤษ เพื่อทำให้มีแสงสว่างในช่วงเย็นนานขึ้นในฤดูร้อน ซึ่งช่วยให้ประหยัดพลังงานและใช้ประโยชน์จากแสงธรรมชาติอย่างเต็มที่

UTC หรือ Coordinated Universal Time เป็นมาตรฐานเวลาสากลที่ไม่ขึ้นกับ Timezone ใด Timezone หนึ่ง และไม่มีการปรับเปลี่ยนตาม Daylight Saving Time (DST) การใช้ UTC จึงช่วยให้การจัดการเวลาในระบบที่มีผู้ใช้จากหลาย Timezone ทำได้ง่ายขึ้น

ยกตัวอย่างเช่น การจัดการกับการแจ้งเตือน ที่ต้องมีการส่ง Notification ตามเวลาที่เหมาะสมสำหรับแต่ละ Timezone ของ User ด้วยการกำหนดเวลาแจ้งเตือนเป็น 9:00 น. ในเวลาท้องถิ่นของ User แล้วแปลงเป็น UTC ก่อนเก็บในระบบ แต่ถ้าระบบตั้งค่าให้ส่งการแจ้งเตือนทุกวัน เวลา 9:00 น. โดยใช้เวลาท้องถิ่นของ Server ในกรุงเทพ User ในลอนดอนจะได้รับแจ้งเตือนที่เวลา 2:00 น. หรือ 3:00 น. ตามเวลาในลอนดอน ขึ้นอยู่กับว่าเป็นช่วง Daylight Saving Time หรือไม่ (UTC+0 ในช่วงเวลาปกติ และ UTC+1 ในช่วง Daylight Saving Time)

หรือการคำนวณเวลาจัดส่งโดยประมาณที่ต้องใช้เวลาสากล (UTC) ในการคำนวณ แล้วแสดงเวลาให้ตรงกับเวลาท้องถิ่นของลูกค้า ซึ่งถ้าเราใช้เวลาท้องถิ่นของ Server ในกรุงเทพ ในการคำนวณ เมื่อลูกค้าสั่งของตอน 15:00 น. (บ่าย 3 โมง) ของวันจันทร์ ตามเวลา Server กรุงเทพ ซึ่งของจะถูกส่งถึงลูกค้าภายใน 3 วัน ระบบจึงแจ้งเวลาจัดส่งโดยประมาณเป็นวันพฤหัสบดี เวลา 15:00 น. แต่จริง ๆ ของจะถึงโตเกียวประมาณ 17:00 น. พฤหัสบดี ทำให้ลูกค้าในโตเกียวอาจคิดว่าของจะมาเร็วกว่าความเป็นจริง 2 ชั่วโมง

การแปลงเวลาท้องถิ่นของ Server ให้เป็นเวลาสากล UTC ด้วย Function "UTC"

t := time.Now()
fmt.Println(t)
utcTime := t.UTC()
fmt.Println(utcTime)

ใช้เวลาสากล (UTC) ในการคำนวณเวลาจัดส่งโดยประมาณ!

สมมติว่า Order 1 Order ใช้ระยะเวลาในการจัดส่ง (delivery_days) 3 วัน เราสามารถคำนวณเวลาจัดส่งโดยประมาณ (estimatedDelivery) จาก เวลาการสั่งซื้อ (created_at) + ระยะเวลาในการจัดส่ง (delivery_days)

ในทางปฏิบัติเราอาจเก็บ created_at แบบ UTC รวมทั้ง delivery_days ใน Database แล้วคำนวณ estimatedDelivery เมื่อต้องการใช้งาน

CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
    delivery_days INTEGER NOT NULL
);
ตัวอย่างคำสั่ง SQL สำหรับการ CREATE TABLE ใน PostgreSQL

เมื่อมีการ Query ข้อมูลจาก Table "order" ด้านบน PostgreSQL จะส่งข้อมูล created_at มาเป็น UTC แต่ Go จะแปลงเป็น time.Time ใน Zone เวลา Local ของเครื่อง เราสามารถคำนวณ estimatedDelivery ได้ดังตัวอย่างต่อไปนี้

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

// ควรใช้ UTC ในการคำนวณเพื่อความสอดคล้อง
estimatedDelivery := CalculateEstimatedDelivery(order.CreatedAt.UTC(), order.DeliveryDays)

AddDate เป็น Method ของ time.Time ใน Go ที่ใช้สำหรับเพิ่มระยะเวลาให้กับวันที่และเวลา ซึ่งมี Parameter 3 ตัว คือ Years, Months และ Days โดย AddDate จะจัดการเรื่องการข้ามเดือนหรือปีให้โดยอัตโนมัติ เช่น หากวันที่เริ่มต้น คือ 31 มีนาคม และมีการเพิ่ม 1 เดือน ผลลัพธ์จะเป็น 30 เมษายน (เนื่องจากเดือนเมษายนมี 30 วัน) ใน Go เราสามารถใช้ Function "Add" แทน ถ้าไม่ต้องการให้มีการจัดการเรื่องการข้ามเดือนหรือปีให้โดยอัตโนมัติ

สมมติว่าเรามีบัตรเครดิตที่มีรอบบัญชีสิ้นสุดทุกวันที่ 20 ของเดือน และกำหนดชำระเงินภายใน 25 วันหลังจากวันสรุปยอด เราสามารถใช้ Function "Add" คำนวณวันครบกำหนดชำระเงินได้ดังต่อไปนี้

package main

import (
	"fmt"
	"time"
)

func main() {
	// กำหนดวันสรุปยอดเป็นวันที่ 20 มกราคม 2024
	statementDate := time.Date(2024, time.January, 20, 0, 0, 0, 0, time.UTC)
	fmt.Printf("วันสรุปยอด %v\n\n", statementDate)

	// คำนวณวันครบกำหนดชำระโดยใช้ Add
	dueDateAdd := statementDate.Add(25 * 24 * time.Hour)
	fmt.Printf("วันครบกำหนดชำระ %v\n", dueDateAdd)
	fmt.Printf("จำนวนวันระหว่างวันสรุปยอดและวันครบกำหนด: %d\n\n", int(dueDateAdd.Sub(statementDate).Hours()/24))

	// ทดสอบกับเดือนกุมภาพันธ์ (ปีอธิกสุรทิน)
	statementDateFeb := time.Date(2024, time.February, 20, 0, 0, 0, 0, time.UTC)
	fmt.Printf("วันสรุปยอด (กุมภาพันธ์) %v\n\n", statementDateFeb)

	dueDateAddFeb := statementDateFeb.Add(25 * 24 * time.Hour)
	fmt.Printf("วันครบกำหนดชำระ %v\n", dueDateAddFeb)
	fmt.Printf("จำนวนวันระหว่างวันสรุปยอดและวันครบกำหนด %d\n", int(dueDateAddFeb.Sub(statementDateFeb).Hours()/24))
}

แต่การแสดงผลวันที่แบบ UTC อาจไม่เป็นมิตรกับผู้ใช้มากนัก เราสามารถใช้ Format อย่างเช่น ISO 8601 ที่เป็นมาตรฐานสากล ซึ่งมีข้อมูล Timezone ครบถ้วนและอ่านง่ายแทน

ในการแสดงเวลาด้วย ISO 8601 Format ตาม Timezone ที่เหมาะสม เรามักจะเก็บ Timezone ของ User ลง Database เพื่อใช้ในการแสดงเวลาใน Timezone ของพวกเขา ซึ่งจะมอบประสบการณ์ที่ดีแก่ User

เราจะใช้ Function "time.LoadLocation" สำหรับโหลด Location จาก Timezone ของ User เช่น Asia/Bangkok เพื่อกำหนดค่า loc ให้กับตัวแปรเวลาในระบบของ Go (time.Time)

userTimezone := "Asia/Bangkok"
userLocation, err := time.LoadLocation(userTimezone)
statementDateLocal := statementDate.In(userLocation)

เมื่อดู Location ของตัวแปร จะได้ค่าเป็น Asia/Bangkok

fmt.Printf("Location: %v\n", statementDateLocal.Location())

เราสามารถแสดงเวลาตาม ISO 8601 Format ด้วย Function "Format" โดยรับ Parameter เป็น time.RFC3339

fmt.Printf("วันสรุปยอด %v\n\n", statementDateLocal.Format(time.RFC3339))

ตัวอย่างด้านล่าง คือ Code ที่มีการปรับเพื่อแสดงเวลาตาม ISO 8601 Format ด้วย Timezone "Asia/Bangkok" ครับ

package main

import (
	"fmt"
	"log"
	"time"
)

func main() {
	// โหลด Timezone ของผู้ใช้
	userTimezone := "Asia/Bangkok"
	userLocation, err := time.LoadLocation(userTimezone)
	if err != nil {
		log.Fatal("ไม่สามารถโหลด timezone ได้:", err)
	}

	// กำหนดวันสรุปยอดเป็นวันที่ 20 มกราคม 2024
	statementDate := time.Date(2024, time.January, 20, 0, 0, 0, 0, time.UTC)
	statementDateLocal := statementDate.In(userLocation)
	fmt.Printf("วันสรุปยอด %v\n\n", statementDateLocal.Format(time.RFC3339))

	// คำนวณวันครบกำหนดชำระโดยใช้ Add
	dueDateAdd := statementDate.Add(25 * 24 * time.Hour)
	dueDateAddLocal := dueDateAdd.In(userLocation)
	fmt.Printf("วันครบกำหนดชำระ %v\n", dueDateAddLocal.Format(time.RFC3339))
	fmt.Printf("จำนวนวันระหว่างวันสรุปยอดและวันครบกำหนด: %d\n\n", int(dueDateAdd.Sub(statementDate).Hours()/24))

	// ทดสอบกับเดือนกุมภาพันธ์ (ปีอธิกสุรทิน)
	statementDateFeb := time.Date(2024, time.February, 20, 0, 0, 0, 0, time.UTC)
	statementDateFebLocal := statementDateFeb.In(userLocation)
	fmt.Printf("วันสรุปยอด (กุมภาพันธ์) %v\n\n", statementDateFebLocal.Format(time.RFC3339))

	dueDateAddFeb := statementDateFeb.Add(25 * 24 * time.Hour)
	dueDateAddFebLocal := dueDateAddFeb.In(userLocation)
	fmt.Printf("วันครบกำหนดชำระ %v\n", dueDateAddFebLocal.Format(time.RFC3339))
	fmt.Printf("จำนวนวันระหว่างวันสรุปยอดและวันครบกำหนด %d\n", int(dueDateAddFeb.Sub(statementDateFeb).Hours()/24))
}

จากตัวอย่าง การใช้ Add กับ statementDate ซึ่งเป็นเวลาแบบ UTC  ทำให้หลีกเลี่ยงปัญหาที่อาจเกิดขึ้นจากการเปลี่ยนเวลาตามฤดูกาล (Daylight Saving Time) และการเปลี่ยนแปลงของ Timezone

หลังจากคำนวณวันครบกำหนดใน UTC แล้ว เราสามารถแปลงกลับเป็น Timezone ของ User ได้ทีหลังโดยใช้ In(userLocation)

ISO 8601 Format ที่ระบุ Timezone ของ User เป็นที่นิยมสำหรับการส่งข้อมูลผ่าน API ด้วยเช่นกัน

{
  "statement_date": "2024-01-20T07:00:00+07:00",
  "due_date": "2024-02-14T07:00:00+07:00"
}
สำหรับเดือนมกราคม

เราสามารถใช้ Function "time.Parse" เพื่อแปลงข้อมูลแบบ String จาก ISO 8601 Format กลับเป็นเวลาใน Go ด้วย Layout String "time.RFC3339" ครับ

// ข้อมูล ISO 8601 จาก API
statementDateStr := "2024-01-20T07:00:00+07:00"

// ใช้ time.RFC3339 เป็น Layout
statementDate, err := time.Parse(time.RFC3339, statementDateStr)

Context ช่วยในการควบคุมการยกเลิกการทำงาน เหมือนป้ายชื่อที่มีข้อมูลพิเศษติดอยู่ เราอาจส่งป้ายที่เขียนเวลาที่ต้องเลิกทำงานแล้วส่งไปยัง Function ต่าง ๆ ได้ เมื่อถึงเวลานั้น เราจะรู้ว่าต้องหยุดทำงานแล้ว

เพื่อจะใช้งานป้ายชื่อเราจะต้อง Import Package "context"

Context มีหลายประเภท เช่น context.Background() ซึ่งเป็น Context พื้นฐานที่สุด เหมือนกับกระดานวาดรูปว่าง ๆ ส่วน context.WithTimeout() เป็น Context ที่มีป้ายบอกว่าทำงานได้นานเท่าไหร่

context.WithTimeout() รับ Parameter เป็น context.Background() และเวลาการทำงาน หรือ Timeout โดยมันจะคืนค่ากลับเป็น Context ที่มีการกำหนด Timeout และ Function "Cancel" ที่ใช้สำหรับยกเลิก Context เพื่อ Clear ทรัพยากร ก่อนที่จะ Timeout

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

เรามาดูการทำงานของ Context ร่วมกับ select case เพื่อกำหนดให้ Fucntion "makeCoffee" หยุดการทำงานถ้าใช้เวลาชงกาแฟเกินค่า Timeout กันครับ

select case เป็นคำสั่งควบคุมการทำงาน ที่ใช้สำหรับจัดการกับการสื่อสารระหว่าง Channel โดยการ ฟังสัญญาณที่ส่งจาก Channel ต่าง ๆ ตาม case ที่กำหนด

select case ช่วยให้ Program สามารถจัดการกับหลาย ๆ เหตุการณ์ที่อาจเกิดขึ้นพร้อมกันได้อย่างมีประสิทธิภาพ

นึกภาพว่าเรากำลังยืนอยู่ในห้องที่มีประตูหลายบาน ประตูแต่ละบาน (case) จะเป็นตัวแทนของช่องทางการสื่อสาร (Channel) ในโปรแกรม

select คือการที่เรายืนรอฟังเสียงจากประตูเหล่านี้!

ถ้ามีเสียงดังจากประตูบานใด เราจะเปิดประตูบานนั้น แล้วทำงานตามคำสั่งที่ต้องทำ ถ้ามีเสียงดังพร้อมกันจากประตูหลายบาน เราจะสุ่มเลือกประตูหนึ่งบานแล้วเปิดมัน แต่ถ้าไม่มีเสียงเลย และมี default case เราจะทำสิ่งที่ระบุไว้ใน default

จากตัวอย่างนี้ select จะรอฟังเสียง Channel จาก 2 case โดยใน case แรก จะเป็นการจำลองการชงกาแฟที่ใช้เวลา 2 วินาที (time.After(2 * time.Second)) ซึ่งเมื่อครบ 2 วินาทีจะเกิดเสียงดังที่ Channel นี้ ส่วน case ที่สอง จะเกิดเสียงดังเมื่อ Context เกิด Timeout ทำให้ Channel ctx.Done() ถูกปิด

package main

import (
	"context"
	"fmt"
	"time"
)

func makeCoffee(ctx context.Context) {
	select {
	case <-time.After(2 * time.Second):
		fmt.Println("กาแฟของคุณเสร็จแล้ว! ☕")
	case <-ctx.Done():
		fmt.Println("ยกเลิกการชงกาแฟ: " + ctx.Err().Error())
	}
}

func main() {
	fmt.Println("ยินดีต้อนรับสู่ร้านกาแฟ Go!")

	// สร้าง context ที่มีเวลาจำกัด 3 วินาที
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	fmt.Println("กำลังชงกาแฟ...")
	makeCoffee(ctx)

	fmt.Println("ขอบคุณที่ใช้บริการ!")
}

เราจะจำลอง Function "After" เพื่อให้เข้าใจหลักการทำงานของมันดังต่อไปนี้

package time

import (
    "time"
)

func After(d Duration) <-chan Time {
    c := make(chan Time, 1)
    go func() {
        time.Sleep(d)
        c <- time.Now()
    }()
    return c
}

ฟังก์ชันจะรับพารามิเตอร์ d ที่เป็น Duration ซึ่ง Duration คือ ระยะเวลาที่เราทำกิจกรรมต่าง ๆ (มีหน่วยนับเวลาเป็น Nanosecond)

time.Second      // กำหนด Duration เป็น 1 วินาที
time.Minute      // กำหนด Duration เป็น 1 นาที
time.Hour        // กำหนด Duration เป็น 1 ชั่วโมง
2 * time.Minute  // กำหนด Duration เป็น 2 นาที
30 * time.Second // กำหนด Duration เป็น 30 วินาที

1*time.Hour + 35*time.Minute // กำหนด Duration เป็น 1 ชั่วโมง 35 นาที
1*time.Second + 500*time.Millisecond // กำหนด Duration เป็น 1.5 วินาที

หมายเหตุ การใช้ time.Duration ช่วยให้การทำงานกับระยะเวลา จะทำให้ Program มีความชัดเจนและป้องกันข้อผิดพลาดที่อาจเกิดจากการสับสนในหน่วยของเวลาครับ

จากตัวอย่างด้านบน มีการสร้าง Buffer Channel ที่สามารถเก็บค่า Time ได้ 1 ค่า แล้วสร้าง Goroutine ใหม่ที่จะทำงานแยกไปต่างหาก

ภายใน Goroutine มีการใช้ time.Sleep(d) เพื่อทำให้เกิดการรอตามระยะเวลาที่กำหนด หลังจากรอเสร็จ มันจะส่งเวลาปัจจุบัน (time.Now()) เข้าไปใน Channel (สร้างเสียงดังที่ Channel นี้) ดังนั้น return c ซึ่งอยู่นอก Goroutine จึงถูกทำงานก่อนที่จะมีการส่งเวลาปัจจุบันเข้าไปใน Channel

เพื่อทดสอบการ Timeout ของ Function ด้วย Context เราจะสร้าง Function "findPrimes" สำหรับหาจำนวนเฉพาะที่น้อยกว่าหรือเท่ากับค่า Limit

วิธีนี้ไม่ใช่วิธีที่มีประสิทธิภาพที่สุดในการหาจำนวนเฉพาะ แต่เป็นวิธีที่เข้าใจง่ายและแสดงให้เห็นถึงการใช้เวลาที่เพิ่มขึ้นเมื่อขนาดของปัญหาใหญ่ขึ้น

จากตัวอย่างนี้ เราใช้ select ใน Loop เพื่อตรวจสอบ Context ในทุกรอบ ทำให้สามารถหยุดการทำงานได้ทันทีเมื่อ Timeout โดยไม่ต้องรอให้การคำนวณเสร็จสิ้น

package main

import (
	"context"
	"fmt"
	"time"
)

func isPrime(n int) bool {
	if n < 2 {
		return false
	}
	for i := 2; i*i <= n; i++ {
		if n%i == 0 {
			return false
		}
	}
	return true
}

func findPrimes(ctx context.Context, limit int) {
	var primes []int
	for num := 2; num <= limit; num++ {
		select {
		case <-ctx.Done():
			fmt.Println("ยกเลิกการหาจำนวนเฉพาะ: " + ctx.Err().Error())
			return
		default:
			if isPrime(num) {
				primes = append(primes, num)
			}
		}
	}
	fmt.Println("พบจำนวนเฉพาะทั้งหมด ", len(primes), "ตัว")

}

func main() {
	start := time.Now()
	// สร้าง context ที่มีเวลาจำกัด 1 วินาที
	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
	defer cancel()

	fmt.Println("กำลังหาจำนวนเฉพาะ...")
	findPrimes(ctx, 9000000)

	fmt.Println("ขอบคุณที่ใช้บริการ!")
	duration := time.Since(start)
	fmt.Printf("เวลาที่ใช้ %v\n", duration)
}

เราจะปรับ Code ใหม่ จากการทำงานแบบ Sequential ให้สามารถแบ่งงานออกเป็นหลายส่วนที่ทำงานแบบ Concurrent ด้วย Goroutine ซึ่งแต่ละ Goroutine มีการสร้าง Context แยกกัน ดังต่อไปนี้

package main

import (
	"context"
	"fmt"
	"sync"
	"time"
)

func isPrime(n int) bool {
	if n < 2 {
		return false
	}
	for i := 2; i*i <= n; i++ {
		if n%i == 0 {
			return false
		}
	}
	return true
}

func findPrimes(ctx context.Context, start, end int, wg *sync.WaitGroup) {
	defer wg.Done()
	var primes []int
	for num := start; num <= end; num++ {
		select {
		case <-ctx.Done():
			fmt.Printf("ยกเลิกการหาจำนวนเฉพาะ (%d-%d): %v\n", start, end, ctx.Err())
			return
		default:
			if isPrime(num) {
				primes = append(primes, num)
			}
		}
	}
	fmt.Printf("พบจำนวนเฉพาะทั้งหมด %d ตัว ในช่วง %d-%d\n", len(primes), start, end)
}

func main() {
	start := time.Now()

	var wg sync.WaitGroup
	wg.Add(2)

	fmt.Println("กำลังหาจำนวนเฉพาะ...")

	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
		defer cancel()
		findPrimes(ctx, 2, 4500000, &wg)
	}()

	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
		defer cancel()
		findPrimes(ctx, 4500001, 9000000, &wg)
	}()

	wg.Wait()

	fmt.Println("ขอบคุณที่ใช้บริการ!")
	duration := time.Since(start)
	fmt.Printf("เวลาที่ใช้ %v\n", duration)
}

Code ที่สองที่ปรับแก้นี้ มีการออกแบบให้ทำงานแบบ Concurrent ซึ่งสามารถทำให้มีประสิทธิภาพสูงกว่าในการประมวลผลข้อมูลจำนวนมาก โดยไม่ Timeout ตามที่กำหนดใน Context ไปก่อนครับ

การทดสอบเบื้องต้น

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

myproject/
├── cmd
│   └── main.go
├── go.mod
└── internal
    └── prime
        ├── prime.go
        └── prime_test.go

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

เราได้ปรับ Function "FindPrimes" เล็กน้อยเพื่อให้เหมาะกับการ Test โดยเพิ่ม Callback Function "primeFound" เป็น Parameter ตัวสุดท้าย เพื่อให้ primeFound() ถูกเรียก เมื่อมีการตรวจพบจำนวนเฉพาะ โดย primeFound จะนับจำนวนเฉพาะให้เรา แทนที่จะเก็บมันไว้ใน Slide เหมือน Code ที่ผ่านมา ทำให้ไม่ต้องกังวลว่าจะใช้หน่วยความจำมากเกินไปเมื่อต้องการทดสอบหาจำนวนเฉพาะในช่วงที่กว้างมาก ๆ

// prime.go
package prime

import (
	"context"
)

// IsPrime checks if a number is prime
func IsPrime(n int) bool {
	if n < 2 {
		return false
	}
	for i := 2; i*i <= n; i++ {
		if n%i == 0 {
			return false
		}
	}
	return true
}

// FindPrimes finds all prime numbers in the given range
func FindPrimes(ctx context.Context, start, end int, primeFound func(int)) {
	for num := start; num <= end; num++ {
		select {
		case <-ctx.Done():
			return
		default:
			if IsPrime(num) {
				primeFound(num)
			}
		}
	}
}

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

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

สร้าง Test Case แบบ Table-Driven เพื่อทดสอบจำนวนเฉพาะด้วยตัวเลขหลาย ๆ  ขนาด

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

สร้าง Test Case แบบ Table-Driven เพื่อหาจำนวนเฉพาะหลาย ๆ ช่วงตัวเลข โดยแต่ละ Test Case เรียกใช้ FindPrimes พร้อมกับ Callback Function สำหรับนับจำนวนจำนวนเฉพาะ โดยมี Timeout ที่ถูกจัดการด้วย context.WithTimeout() ซึ่งครอบคลุมทั้ง Positive Test (Happy Path) และ Negative Test หรือ Edge case ที่จะตรวจสอบว่าเกิด Timeout จริงหรือไม่

// prime_test.go
package prime

import (
	"context"
	"testing"
	"time"
)

func TestIsPrime(t *testing.T) {
	tests := []struct {
		name     string
		input    int
		expected bool
	}{
		{"Zero", 0, false},
		{"One", 1, false},
		{"Two", 2, true},
		{"Three", 3, true},
		{"Four", 4, false},
		{"Large prime", 97, true},
		{"Large non-prime", 100, false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := IsPrime(tt.input); got != tt.expected {
				t.Errorf("IsPrime(%d) = %v, want %v", tt.input, got, tt.expected)
			}
		})
	}
}

func TestFindPrimes(t *testing.T) {
	tests := []struct {
		name     string
		start    int
		end      int
		timeout  time.Duration
		expected int
	}{
		{"Small range", 2, 10, 1 * time.Second, 4},
		{"Medium range", 2, 100, 1 * time.Second, 25},
		{"Timeout", 2, 1000000, 1 * time.Millisecond, 0},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ctx, cancel := context.WithTimeout(context.Background(), tt.timeout)
			defer cancel()

			var count int
			FindPrimes(ctx, tt.start, tt.end, func(prime int) {
				count++
			})

			if tt.name == "Timeout" {
				if ctx.Err() == nil {
					t.Errorf("Expected timeout, but didn't occur")
				}
			} else if count != tt.expected {
				t.Errorf("FindPrimes(%d, %d) found %d primes, want %d", tt.start, tt.end, count, tt.expected)
			}
		})
	}
}

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

myproject/
├── cmd
└── internal
    └── prime
        ├── prime.go
        └── prime_test.go

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

go mod init myproject

เริ่มทดสอบด้วยคำสั่ง go test ./...

go test -v ./...

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

go test -cover ./...

หลังจากรัน Unit Test แล้ว เราจะทดลองใช้งาน Package "prime" ใน main.go โดยมีการส่ง Callback Function เป็น Parameter ไปยัง prime.FindPrimes() 2 แบบ ดังตัวอย่างต่อไปนี้

func(p int) {
	fmt.Printf("%d ", p)
	count++
}
Callback 1
func(p int) {
	timeoutCount++
}
Callback 2
package main

import (
	"context"
	"fmt"
	"myproject/internal/prime"
	"time"
)

func main() {
	fmt.Println("\nกำลังค้นหาจำนวนเฉพาะ...")

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

	count := 0
	prime.FindPrimes(ctx, 2, 100, func(p int) {
		fmt.Printf("%d ", p)
		count++
	})

	fmt.Printf("\nพบจำนวนเฉพาะทั้งหมด %d ตัวในช่วง 2 ถึง 100\n", count)

	ctxTimeout, cancelTimeout := context.WithTimeout(context.Background(), 1*time.Millisecond)
	defer cancelTimeout()

	timeoutCount := 0
	prime.FindPrimes(ctxTimeout, 2, 1000000, func(p int) {
		timeoutCount++
	})

	if ctxTimeout.Err() != nil {
		fmt.Println("เกิด Timeout ตามที่คาดไว้")
	} else {
		fmt.Println("ไม่เกิด Timeout ตามที่คาดไว้")
	}

	fmt.Printf("พบจำนวนเฉพาะ %d ตัวก่อนเกิด Timeout\n", timeoutCount)
}
main.go

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

go build cmd/main.go


Exercise

สร้าง Program ที่จำลองตัวจับเวลาทำอาหารอย่างง่าย โดยมีรายละเอียดดังนี้

กำหนดเวลาทำอาหารเป็น 5 นาที (300 วินาที) โดยใช้ context.WithTimeout() และแสดงเวลาที่เหลือทุก ๆ 30 วินาที เมื่อครบ 5 นาทีแล้ว ให้แสดงข้อความ "อาหารพร้อมเสิร์ฟ!"

ตัวอย่างผลลัพธ์

เริ่มจับเวลาทำอาหาร 5 นาที
เวลาที่เหลือ: 4 นาที 30 วินาที
เวลาที่เหลือ: 4 นาที
เวลาที่เหลือ: 3 นาที 30 วินาที
เวลาที่เหลือ: 3 นาที
เวลาที่เหลือ: 2 นาที 30 วินาที
เวลาที่เหลือ: 2 นาที
เวลาที่เหลือ: 1 นาที 30 วินาที
เวลาที่เหลือ: 1 นาที
เวลาที่เหลือ: 30 วินาที
อาหารพร้อมเสิร์ฟ!

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

Q&A?

รวม Cheat Sheet Time Package และ Context

Time Package และ Context 
------------------------

1. การทำงานกับเวลา
	time.Now() // เวลาปัจจุบันตาม Timezone ท้องถิ่น
	t.UTC() // แปลงเวลาเป็น UTC
	time.Parse(time.RFC3339, dateString) // แปลง ISO 8601 String เป็น time
	t.Format(time.RFC3339) // แปลง time เป็น ISO 8601 String
	t.Add(duration) // เพิ่มระยะเวลา
	t.AddDate(years, months, days) // เพิ่มปี เดือน วัน
	t1.Sub(t2) // หาความต่างของเวลา
	time.Since(startTime) // หาเวลาที่ผ่านไปจากจุดเริ่มต้น
	time.LoadLocation("Asia/Bangkok") // โหลดข้อมูล Timezone
	t.In(location) // แปลงเวลาเป็น Timezone ที่ต้องการ

2. การใช้ Context
	ctx := context.Background() // สร้าง Context พื้นฐาน
	ctx, cancel := context.WithTimeout(parentCtx, timeout) // สร้าง Context แบบ WithTimeout
	defer cancel() // ใช้ defer เพื่อเรียก Function "cancel"
	select case // ใช้ select case สำหรับจัดการกับการสื่อสารระหว่าง Channel
	
    select {
	case <-ctx.Done():
		return ctx.Err()
	case <-time.After(duration):
		// ทำงานหลังจากผ่านไป duration
	default:
		// ทำงานต่อ
	}

3. การทดสอบ
	- เปรียบเทียบเวลาโดยยอมให้มีความคลาดเคลื่อนเล็กน้อย
	- ทดสอบทั้งกรณีที่ทำงานสำเร็จและล้มเหลว (Timeout)
	- ใช้ Table-Driven Tests สำหรับทดสอบหลายกรณี

4. Best Practice
	- ใช้ UTC ในการคำนวณเวลา โดยเฉพาะในระบบที่ทำงานข้าม Timezone
	- ใช้ ISO 8601 Format สำหรับการแสดงผลและส่งข้อมูลผ่าน API
	- ใช้ Context เพื่อควบคุมการยกเลิกการทำงานในฟังก์ชันที่ใช้เวลานาน
	- ใช้ time.After() สำหรับการทำ Timeout ใน select statement
	- หลีกเลี่ยงการเข้าถึงโครงสร้างภายในของ time.Time โดยตรง
	- ใช้ Monotonic Clock สำหรับการวัดระยะเวลาในการทำ Benchmarking

5. ข้อควรระวัง
	- ระวังปัญหา Daylight Saving Time เมื่อทำงานกับเวลาในหลาย Timezone
	- ตรวจสอบ Context ว่าหมดเวลาหรือถูกยกเลิกก่อนทำงานที่ใช้เวลานาน
	- ใช้ time.Duration สำหรับระยะเวลา แทนการใช้ int หรือ float

พบกับ Mastering Golang for E-commerce Back End Development : Part 4 เร็ว ๆ  นี้ครับ