Published on

เรียนรู้ Go - หัดทำ API ด้วย gin

เรียนรู้ Go หัดทำ API ด้วย gin
เรียนรู้ Go หัดทำ API ด้วย gin

อันยองฮาเซโยวววว วันนี้เจมส์จะมาเขียนบทความเกี่ยวกับการเรียนรู้ Go คือ หัดทำ API ด้วย gin web framework

ก่อนหน้านี้ก็เคยลองศึกษา Go มาแล้ว แต่ไม่เคยลองเขียนแบบจริง ๆ จัง ๆ สักที คิดว่าตอนนี้ถึงเวลาแล้วแหละ

สิ่งที่เจมส์จะลองทำวันนี้คือเขียน Todo นั้นเอง ทำแบบไม่ต้องใช้ database อะไร ให้เก็บไว้ในตัวแปรแล้วก็ดึงเอาครับ ถือว่าเป็นการเริ่มต้น

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

สร้างโปรเจค simple-todo และเข้าไปในโปรเจค

mkdir simple-todo && cd simple-todo

เริ่มต้นโปรเจค Go ด้วยคำสั่ง

go mod init simple-todo

Install gin web framework

go get -u github.com/gin-gonic/gin

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

ขั้นตอนต่อไปให้เราสร้างไฟล์ main.go จากนั้นเราจะลองใส่โค้ดตามตัวอย่างของ gin

main.go
package main

import (
	"net/http"

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

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

	r.GET("/ping", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{
			"message": "pong",
		})
	})

	r.Run()
}

จากนั้นใช้คำสั่ง

go run main.go

แล้วลองเข้า http://localhost:8080/ping

จะพบว่าจะได้ response เป็น

{
  "message": "pong"
}

ถ้าหากต้องการเปลี่ยน PORT ที่ Run

เราสามารถเปลี่ยน port ที่ต้องการ run ได้ จากเดิม default คือ 8080 ถ้าหากเราต้องการ run บน port 2000 เราสามารถแก้ไขตรง r.Run() ให้เป็น r.Run(":2000")

ขั้นต่อไปคือเดี๋ยวเราจะมาสร้าง route ให้มีดังนี้ครับ

  • GET /todos ให้ดึงข้อมูล todos ทั้งหมด
  • GET /todos/:id ให้ดึงข้อมูล todo จาก id
  • POST /todos จะสร้าง todo โดยรับค่า task
  • PATCH /todos/:id จะแก้ไขข้อมูล โดยรับค่า task หรือ status
  • DELETE /todos/:id จะลบข้อมูล todo จาก id

GET /todos

เดี๋ยวเรามาเริ่มทำในส่วนของดึงข้อมูล todos ทั้งหมดมาแสดงก่อนครับ โดยขั้นแรกเจมส์จะสร้าง Todo เป็น struct ขึ้นมาก่อนครับ

main.go
type Todo struct {
	Id     int    `json:"id"`
	Task   string `json:"task" binding:"required"`
	Status string `json:"status"`
}

และกำหนด todos ให้มีค่าเป็น slice ของ struct Todo

main.go
var todos []Todo = []Todo{}

จากนั้นจะเพิ่ม route GET /todos ให้ return ค่า todos ออกไป ซึ่งโค้ดในตอนนี้จะเป็นดังนี้ครับ

main.go
package main

import (
	"net/http"

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

type Todo struct {
	Id     int    `json:"id"`
	Task   string `json:"task" binding:"required"`
	Status string `json:"status"`
}

var todos []Todo = []Todo{}
var id = 1

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

	r.GET("/", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{
			"success": true,
			"message": "Server is running",
		})
	})

	r.GET("/todos", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{
			"success": true,
			"todos":   todos,
		})
	})

	r.Run(":2000")
}

เมื่อเราลองเข้า http://localhost:2000/todos จะพบว่าจะแสดงข้อมูล

{
  "success": true,
  "todos": []
}

POST /todos

route ต่อมาที่เจมส์จะทำต่อไปคือการสร้าง todos โดยเจมส์จะเพิ่ม route เป็น POST /todos โดยใส่โค้ดลงไปดังนี้

main.go
r.POST("/todos", func(c *gin.Context) {
	var todo Todo

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

	todo.Id = id
	todo.Status = "todo"
	todos = append(todos, todo)
	id++

	c.JSON(http.StatusCreated, gin.H{
		"success": true,
		"todo":    todo,
	})
})

คือกำหนด todo ให้มีค่าเป็น Struct Todo จากนั้นจะ bind ค่าที่ส่งเข้ามาใส่ใน todo โดยจะเอาค่า json ไป map กับค่าที่เราเขียนไว้ใน Todo Struct

จากนั้นเจมส์กำหนดค่า Id ให้ todo ให้มีค่า id ที่เรากำหนดไว้ และให้มี Status เริ่มต้นคือ "todo" และเอาค่าไป append ใส่ใน todos และให้เพิ่มค่า id เตรียมไว้ สำหรับ todo ที่เพิ่มถัดไป

จากนั้นเจมส์จะ return ค่า todo ที่เพิ่ม กลับไปครับ

เราลองมายิง POST ไปที่ http://localhost:2000/todos โดยส่ง task ไป จะพบว่าเมื่อเรายิง post request เรียบร้อย จะได้ response success และค่า todo ตามที่เราได้สร้าง ดังรูปด้านล่างครับ

ตัวอย่างการ create task และ response
ตัวอย่างการ create task และ response

ซึ่งเมื่อเราลองไปเรียก GET /todos ใหม่อีกรอบจะพบว่าข้อมูล todos ทั้งหมดแสดงขึ้นมาแล้ว

GET /todos/:id

เดี๋ยวเราจะมาทำในส่วนของ GET /todos/:id กันครับ ใน path นี้เราจะแทน :id ด้วย id ของ task ที่เราสร้าง ซึ่งถ้าหากใส่ id มาไม่ถูกต้องเราจะให้ Return status เป็น 404 และ Error message กลับไปครับ

ให้เราสร้าง route GET /todos/:id ขึ้นมาครับ โดยโค้ดที่ใส่จะเป็นดังนี้ครับ

main.go
r.GET("/todos/:id", func(c *gin.Context) {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "Invalid ID",
		})
		return
	}

	var todo Todo

	for _, t := range todos {
		if t.Id == id {
			todo = t
			break
		}
	}

	if todo.Id == 0 {
		c.JSON(http.StatusNotFound, gin.H{
			"error": fmt.Sprintf("Not found ID: %d ", id),
		})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"success": true,
		"todo":    todo,
	})
})

คือเราจะรับ id ที่ส่งเข้ามาโดยใช้คำสั่ง c.Param("id")

แต่ในที่นี้เจมส์จะแปลงค่า string เป็น int ด้วยคำสั่ง strconv.Atoi

เนื่องจาก Id ของ todo ที่เราสร้างเป็น int และเราจะเอาไปเช็คทำให้เราต้องแปลงค่าเป็น int ก่อนครับ

ถ้าหากมี Error เจมส์จะให้ Return status 400 และ message Invalid ID

จากนั้นเราจะหา id ของ todos ที่ตรงกับ id ที่ส่งเข้ามา โดยจะเอาไปเก็บไว้ในตัวแปร todo ที่เราสร้างไว้

จากนั้นเราจะเช็คว่า todo ที่เราสร้างมีค่า id เป็น 0 ไหม คือถ้าหากมีค่าเป็น 0 แสดงว่า id ที่ส่งเข้ามานั้นไม่ตรงกับ todos ที่มี เราจึงให้ return เป็น 404 ออกไป

แต่ถ้า id ใน todo ไม่ได้เป็นค่า 0 จะ return 200 และค่า todo ออกไป

DELETE /todos/:id

มาทำ Delete กันต่อเลยดีกว่าครับ path จะเหมือนกับการดึง todo ด้วย id ก่อนหน้านี้เลยครับ เพียงแต่เราจะใช้ Method เป็น DELETE ครับ โค้ดจะเป็นประมาณนี้ครับ

main.go
r.DELETE("/todos/:id", func(c *gin.Context) {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "Invalid ID",
		})
		return
	}
	var newTodos []Todo

	for _, todo := range todos {
		if todo.Id != id {
			newTodos = append(newTodos, todo)
		}
	}

	if len(todos) == len(newTodos) {
		c.JSON(http.StatusNotFound, gin.H{
			"error": fmt.Sprintf("Not found ID: %d to delete", id),
		})
		return
	}

	todos = newTodos

	c.JSON(http.StatusNoContent, gin.H{
		"success": true,
		"id":      id,
	})
})

คือเจมส์จะสร้าง route DELETE /todos/:id ขึ้นมา จากนั้นก็จะทำเหมือนก่อนหน้านี้เลยครับคือแปลง id ที่รับเข้ามาเป็น int

ถ้าหากมี Error ในตอนแปลงก็จะให้ return เป็น 400 Bad Request

จากนั้นเราจะวนลูป todos ที่มี โดยจะเอาค่าที่ไม่ตรงกับ id ที่ส่งเข้ามาใส่ไปใน newTodos ที่เราสร้างขึ้นมาใหม่

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

จากนั้นเจมส์ก็กำหนด todos ให้มีค่าเป็น newTodos (ไม่มีข้อมูล todo ของ id ที่เราส่งมาแล้ว)

จากนั้นก็ให้ return เป็น status 204 NoContent ออกไป

PATCH /todos/:id

เราจะมาทำ route สุดท้ายกันครับ คือการอัพเดท todo เดี๋ยวในส่วนนี้เราจะให้ค่า status สามารถส่งได้แค่ todo, doing, done ดังนั้นเดี๋ยวเราจะมาติดตั้ง package go-playground/validator เพิ่มครับ

ขั้นแรกให้ใช้คำสั่ง

go get go-playground/validator

จากนั้นเดี๋ยวเราจะเพิ่ม struct อีกตัวนึงสำหรับอัพเดท todo ซึ่งโค้ดจะเป็นดังนี้ครับ

main.go
type UpdateTodo struct {
	Task   *string `json:"task,omitempty"`
	Status *string `json:"status,omitempty" validate:"oneof=todo doing done"`
}

ถ้าสังเกตจะพบว่า ตรง type ของ Task จะเป็น *string เพื่อให้เราเช็คได้ว่ามีการส่งเข้ามา หรือไม่ส่งเข้ามา

และมีส่วน validate:"oneof=todo doing done" คือค่าที่ส่งเข้ามาของ status จะต้องเป็น todo, doing หรือ done

จากนั้นเพิ่มโค้ดด้านล่างนี้ที่ด้านนอก main()

main.go
var validate *validator.Validate

และเพิ่มโค้ดด้านล่างนี้ด้านใน main()

main.go
validate = validator.New()

โค้ดในส่วน route PATCH /todos/:id จะเป็นดังนี้ครับ

main.go
r.PATCH("/todos/:id", func(c *gin.Context) {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "Invalid ID",
		})
		return
	}
	var todo Todo
	var reqTodo UpdateTodo

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

	if err := validate.Struct(reqTodo); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	if reqTodo.Task != nil && *reqTodo.Task == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Task cannot be empty"})
		return
	}

	found := false

	for index, t := range todos {
		if t.Id == id {
			if reqTodo.Task != nil {
				todos[index].Task = *reqTodo.Task
			}

			if reqTodo.Status != nil {
				todos[index].Status = *reqTodo.Status
			}

			todo = todos[index]
			found = true
			break
		}
	}

	if !found {
		c.JSON(http.StatusNotFound, gin.H{"error": "Todo not found"})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"success": true,
		"todo":    todo,
	})
})

ขั้นแรกก็คือแปลง id ที่รับเข้ามาเป็น int

ถ้าหากมี Error ในตอนแปลงก็จะให้ Return เป็น 400 Bad Request

จากนั้นก็ Bind ค่าใส่ใน reqTodo

ถ้าหากมี Error ก็จะให้ Return เป็น 400 Bad Request ครับ

แล้วเราก็ validate ค่า reqTodo กับ struct ว่าถูกไหม คือในที่นี้ Task ต้องเป็นค่า todo หรือ doing หรือ done เท่านั้น

ถ้าหาก Error ตรงส่วนนี้ก็จะ Return 400 Bad Request เหมือนเดิม

และเช็คว่าค่า Task หากส่งมาเป็นค่าว่างให้ Error 400 Bad Request เหมือนกันครับ

ต่อมา เราจะกำหนดค่า found ให้เป็น false เพื่อไว้สำหรับเช็คว่า id ที่ส่งเข้ามามีค่าตรงกับ id ใน todos ไหม

จากนั้นเราก็วนลูปเพื่อดูว่า id ที่ส่งเข้ามาตรงกับ id ใน todos อันไหน จากนั้นก็ให้แก้ค่า Task หรือค่า Status ถ้าหากมีการส่งค่านั้น ๆ เข้ามา

และกำหนดค่า found ให้เป็น true

ขั้นต่อมาให้เช็คว่า ถ้าหาก found เป็น false คือ id ที่ส่งเข้ามาไม่ตรงกับ id ใน todos เลย จะ return status 404 ออกไป พร้อมกับ error "Todo not found"

โค้ดทั้งหมดจะเป็นประมาณนี้ครับ

main.go
package main

import (
	"fmt"
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"
	"github.com/go-playground/validator/v10"
)

type Todo struct {
	Id     int    `json:"id"`
	Task   string `json:"task" binding:"required"`
	Status string `json:"status"`
}

type UpdateTodo struct {
	Task   *string `json:"task,omitempty"`
	Status *string `json:"status,omitempty" validate:"oneof=todo doing done"`
}

var validate *validator.Validate
var todos []Todo = []Todo{}
var id = 1

func main() {
	validate = validator.New()
	r := gin.Default()

	r.GET("/", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{
			"success": true,
			"message": "Server is running",
		})
	})

	r.GET("/todos", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{
			"success": true,
			"todos":   todos,
		})
	})

	r.GET("/todos/:id", func(c *gin.Context) {
		id, err := strconv.Atoi(c.Param("id"))
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{
				"error": "Invalid ID",
			})
			return
		}

		var todo Todo

		for _, t := range todos {
			if t.Id == id {
				todo = t
				break
			}
		}

		if todo.Id == 0 {
			c.JSON(http.StatusNotFound, gin.H{
				"error": fmt.Sprintf("Not found ID: %d ", id),
			})
			return
		}

		c.JSON(http.StatusOK, gin.H{
			"success": true,
			"todo":    todo,
		})
	})

	r.POST("/todos", func(c *gin.Context) {
		var todo Todo

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

		todo.Id = id
		todo.Status = "todo"
		todos = append(todos, todo)
		id++

		c.JSON(http.StatusCreated, gin.H{
			"success": true,
			"todo":    todo,
		})
	})

	r.DELETE("/todos/:id", func(c *gin.Context) {
		id, err := strconv.Atoi(c.Param("id"))
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{
				"error": "Invalid ID",
			})
			return
		}
		var newTodos []Todo

		for _, todo := range todos {
			if todo.Id != id {
				newTodos = append(newTodos, todo)
			}
		}

		if len(todos) == len(newTodos) {
			c.JSON(http.StatusNotFound, gin.H{
				"error": fmt.Sprintf("Not found ID: %d to delete", id),
			})
			return
		}

		todos = newTodos

		c.JSON(http.StatusNoContent, gin.H{
			"success": true,
			"id":      id,
		})
	})

	r.PATCH("/todos/:id", func(c *gin.Context) {
		id, err := strconv.Atoi(c.Param("id"))
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{
				"error": "Invalid ID",
			})
			return
		}
		var todo Todo
		var reqTodo UpdateTodo

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

		if err := validate.Struct(reqTodo); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}

		if reqTodo.Task != nil && *reqTodo.Task == "" {
			c.JSON(http.StatusBadRequest, gin.H{"error": "Task cannot be empty"})
			return
		}

		found := false

		for index, t := range todos {
			if t.Id == id {
				if reqTodo.Task != nil {
					todos[index].Task = *reqTodo.Task
				}

				if reqTodo.Status != nil {
					todos[index].Status = *reqTodo.Status
				}

				todo = todos[index]
				found = true
				break
			}
		}

		if !found {
			c.JSON(http.StatusNotFound, gin.H{"error": "Todo not found"})
			return
		}

		c.JSON(http.StatusOK, gin.H{
			"success": true,
			"todo":    todo,
		})
	})

	r.Run(":2000")
}

เรียบร้อยแล้วครับ เขียน Todo ด้วย Go

หากบทความนี้มีส่วนไหนผิดพลาดประการใด ก็ขออภัยมา ณ ที่นี้ด้วยเน้อครับ

เดี๋ยวคิดว่าน่าจะพยายามลงบทความเกี่ยวกับ Go มากขึ้น พยายามลองเล่นให้มากขึ้นครับ

หากคุณผู้อ่านมีส่วนไหนแนะนำเกี่ยวกับ Go หรือบทความ สามารถพิมพ์ทิ้งไว้ใน comment ได้เลยเน้อครับ