- Published on
API Service with Go: Zero to Production
- Authors
- Name
- Somprasong Damyos
- @somprasongd
API Service with Go: Zero to Production
หลังจากที่ได้ศึกษาพื้นฐานภาษา Go รวมถึงการทำ CRUD กับ Database แบบ SQL มาแล้ว ในบทความนี้จะเอาความทั้งหมดมาสร้าง API Service กัน
โดย API นั้นจะใช้เป็นเป็นตัวกลางระหว่าง Application 2 ตัว หรือระหว่าง UI กับ Database ให้สามารถคุยกันได้ง่ายขึ้น ซึ่งจะต้องมีการกำหนด Protocal เอาไว้ใช้สำหรับสื่อสารกัน ไม่ว่าจะเป็น REST หรือ GraphQL หรือ gRPC ก็ได้
ในบทความนี้จะสร้าง Todo List API โดยใช้ REST เอาไว้เป็นตัวกลางระหว่าง web ui ที่มีการรับ-ส่งข้อมูล กันแบบ JSON เพื่อส่งไปบันทึก, ค้นหา, อัพเดท และลบข้อมูลในฐานข้อมูล PostgreSQL
ภายใน API ที่จะต้องสร้างขึ้นมาจะแบ่งได้เป็น 3 ส่วน คือ
- Handler ทำหน้าที่ติดต่อกับผู้ใช้ เพื่อรับค่าเข้ามา และส่งผลลัพธ์ตอบกลับไป
- Bussiness Logic เป็นเงื่อนไขการทำงานของ handler แต่ละตัว
- Data Access Layer เอาไว้เป็นตัวช่วยในการติดต่อกับฐานข้อมูลจริงๆ
สามารถดูโค้ดทั้งหมดได้จาก repo นี้
เริ่มจาก HTTP Web Server
เริ่มจากสร้าง HTTP Web Server ขึ้นมาก่อน โดยใช้ net/http ที่เป็น standard library ของ Go ได้เลย
สร้างโปรเจคใหม่
mkdir -p goapi
cd goapi
go mod init goapi
mkdir cmd
touch cmd/main.go
สร้าง HTTP Web Server
package main
import (
"log"
"net/http"
)
func main() {
// starting server
log.Fatal(http.ListenAndServe(":8080", nil))
}
รันโปรแกรม
ใช้คำสั่ง go run cmd/main.go
จัดการกับ Request
เมื่อ server รันขึ้นมาแล้ว แต่พอลองเรียกใช้งานจะแสดงแต่ข้อความ 404 page not found
เนื่องจากเรายังไม่สร้าง router เพื่อจัดการกับ request ที่เรียกเข้ามา
Handler Function
เราจะใช้ Handler Function ในการสร้าง router เช่น เมื่อเรียกเข้ามาที่ /greet
ให้แสดงข้อความว่า Hello world
กลับไป
func main() {
// define route
http.HandleFunc("/greet", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "Hello world")
}
// starting server
log.Fatal(http.ListenAndServe(":8080", nil))
}
หรือจะเขียน เป็น function แทนก็ได้
func main() {
// define route
http.HandleFunc("/greet", greet)
// starting server
log.Fatal(http.ListenAndServe(":8080", nil))
}
func greet(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "Hello world")
}
Request and Response Headers
เมื่อต้องตอบกลับข้อมูลที่เรียกเข้ามา ส่งกลับไปในรูปแบบของ JSON โดยนำจะข้อมูลที่เป็น struct มาส่งกลับไปผ่าน json.NewEncoder.(w).Encode(struct)
package main
import (
"encoding/json"
"log"
"net/http"
)
type Test struct {
Name string
}
func main() {
// define route
http.HandleFunc("/tests", handleTest)
// starting server
log.Fatal(http.ListenAndServe(":8080", nil))
}
func handleTest(w http.ResponseWriter, r *http.Request) {
tests := []Test{
{Name: "Test 1"},
{Name: "Test 2"},
{Name: "Test 3"},
}
json.NewEncoder(w).Encode(tests)
}
แต่ข้อมูลที่ส่งกลับไปนั่นจะเป็น text/plain
ซึ่งไม่ถูกต้อง เพราะยังไม่ได้ระบุ "Content-Type"
ใน Response Header ตอบกลับไปด้วย สามารถเพิ่มได้โดยใช้ w.Header().Add("header", "data")
func handleTest(w http.ResponseWriter, r *http.Request) {
tests := []Test{
{Name: "Test 1"},
{Name: "Test 2"},
{Name: "Test 3"},
}
w.Header().Add("Content-Type", "application/json")
json.NewEncoder(w).Encode(tests)
}
ถ้าต้องการอ่านข้อมูลจาก Request header ให้ใช้ r.Header.Get("header")
func handleTest(w http.ResponseWriter, r *http.Request) {
tests := []Test{
{Name: "Test 1"},
{Name: "Test 2"},
{Name: "Test 3"},
}
if r.Header.Get("Accept") == "application/xml" {
w.Header().Add("Content-Type", "application/xml")
xml.NewEncoder(w).Encode(tests)
} else {
w.Header().Add("Content-Type", "application/json")
json.NewEncoder(w).Encode(tests)
}
}
จัดการ Routing ด้วย gorialla/mux
http handler ใน go นั้น สามารถสร้างเป็น mux แทนได้แบบนี้
func main() {
mux := http.NewServeMux()
// define route
mux.HandleFunc("/tests", handleTest)
// starting server
log.Fatal(http.ListenAndServe(":8080", mux))
}
แต่ต้องบอกว่า net/http ใน Go นั้น จัดการเรื่อง routing ได้ไม่ดี แนะนำให้หา package อื่นมาช่วยจัดการเรื่องนี้แทน โดยในตัวอย่างนี้จะใช้ gorilla/mux
gorilla/mux
ติดตั้งgo get -u [github.com/gorilla/mux](http://github.com/gorilla/mux)
gorilla/mux
เปลี่ยนมาใช้งานimport (
"log"
"net/http"
"github.com/gorilla/mux"
)
func main() {
// เปลี่ยนตรงนี้
r := mux.NewRouter()
// define route
r.HandleFunc("/tests", handleTest)
// starting server
log.Fatal(http.ListenAndServe(":8080", r))
}
Query Parameters
อ่านค่าออกมาโดยใช้ r.URL.Query()
ซึ่งค่าของแต่ละ key
จะได้ออกมาเป็น []string
func handleTest(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
w.Header().Add("Content-Type", "application/json")
json.NewEncoder(w).Encode(query)
}
// http://localhost:8080/tests?name=abc&completed=true
// {
// "completed": [
// "true"
// ],
// "name": [
// "abc"
// ]
// }
URL Parameters
กำหนด path แบบนี้ "/todos/{id}"
และอ่านค่าออกมาใช้ mux.Vars(r)
ซึ่งค่าของแต่ละ key
จะได้ออกมาเป็น string
func main() {
// เปลี่ยนตรงนี้
r := mux.NewRouter()
// สามารถใช้ร่วมกับ regx ได้
r.HandleFunc("/tests/{id:[0-9]+}", func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
fmt.Fprint(w, vars["id"])
})
// starting server
log.Fatal(http.ListenAndServe(":8080", r))
}
Methods
การจัดการ method แค่เพิ่ม .Methods(http.MethodGet)
ไปหลัง HandleFunc()
จะทำให้ HandleFunc นั้นทำงานเฉพาะ Method ที่ระบุเท่านั้น ตัวอย่างเช่น
package main
import (
"fmt"
"log"
"net/http"
"github.com/gorilla/mux"
)
func main() {
// เปลี่ยนตรงนี้
r := mux.NewRouter()
// define route for crud
r.HandleFunc("/tests", createTest).Methods(http.MethodPost)
r.HandleFunc("/tests", listTest).Methods(http.MethodGet)
// สามารถใช้ร่วมกับ regx ได้
r.HandleFunc("/tests/{id:[0-9]+}", getTest).Methods(http.MethodGet)
r.HandleFunc("/tests/{id:[0-9]+}", updateTest).Methods(http.MethodPut)
r.HandleFunc("/tests/{id:[0-9]+}", deleteTest).Methods(http.MethodDelete)
// starting server
log.Fatal(http.ListenAndServe(":8080", r))
}
สร้าง Todo List API
Todo List API ที่จะสร้าง ออกแบบให้มีฟีเจอร์ตามนี้
- Method POST
/api/todos
สำหรับสร้างรายการใหม่ - Method GET
/api/todos
สำหรับแสดงข้อมูลทั้งหมด - Method GET
/api/todos/{id:[0-9]+}
สำหรับแสดงข้อมูลตาม id ที่ระบุ - Method PATCH
/api/todos/{id:[0-9]+}
สำหรับอัพเดทสถานะตาม id ที่ระบุ - Method DELETE
/api/todos/{id:[0-9]+}
สำหรับลบข้อมูลตาม id ที่ระบุ
เริ่มทำจาก handlers ซึ่งโค้ดในส่วนนี้จะแยกไว้ใน module handlers
mkdir -p pkg/handlers
cd pkg/handlers
touch todo.go
เริ่มจาก Todo struct
เนื่องจาก API ที่กำลังจะสร้างจะมีการรับ-ส่งข้อมูลในรูปแบบของ JSON ดังนั้นจะต้องสร้าง struct ขึ้นมาเพื่อไว้เป็นตัวแทนของข้อมูล JSON
package handlers
type Todo struct {
ID int `json:"id"`
Text string `json:"text"`
Completed bool `json:"isCompleted"`
}
สร้าง Handlers
สร้าง handlers สำหรับจัดการ routes ทั้งหมด
func CreateTodo(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Create Todo")
}
func ListTodo(w http.ResponseWriter, r *http.Request) {
todos := []Todo{
{ID: 1, Text: "Test 1", Completed: true},
{ID: 2, Text: "Test 2", Completed: false},
{ID: 3, Text: "Test 3", Completed: false},
}
// ส่งข้อมูลทั้งหมดกลับไปในรูปแบบ JSON
w.Header().Add("Content-Type", "application/json")
json.NewEncoder(w).Encode(todos)
}
func GetTodo(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
fmt.Fprint(w, "Get Todo by ID:", vars["id"])
}
func UpdateTodoStatus(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
fmt.Fprint(w, "Update Todo Status by ID:", vars["id"])
}
func DeleteTodo(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
fmt.Fprint(w, "Delete Todo by ID:", vars["id"])
}
จัดการ Router
จะสร้างเป็นฟังก์ชันชื่อ setupRouter() ขึ้นมา เพื่อจัดการ routes ทั้งหมด ที่ main.go
package main
import (
"goapi/pkg/handlers"
"log"
"net/http"
"github.com/gorilla/mux"
)
type Test struct {
Name string
}
func main() {
// เปลี่ยนตรงนี้
r := mux.NewRouter()
// define route
setupRouter(r)
// starting server
log.Fatal(http.ListenAndServe(":8080", r))
}
func setupRouter(r *mux.Router) {
r.HandleFunc("/api/todos", handlers.CreateTodo).Methods(http.MethodPost)
r.HandleFunc("/api/todos", handlers.ListTodo).Methods(http.MethodGet)
// สามารถใช้ร่วมกับ regx ได้
r.HandleFunc("/api/todos/{id:[0-9]+}", handlers.GetTodo).Methods(http.MethodGet)
r.HandleFunc("/api/todos/{id:[0-9]+}", handlers.UpdateTodoStatus).Methods(http.MethodPut)
r.HandleFunc("/api/todos/{id:[0-9]+}", handlers.DeleteTodo).Methods(http.MethodDelete)
}
แต่สร้างเป็น subrouter แทนดีกว่า เพื่อใช้จัดกลุ่มของ /todos
func setupRouter(r *mux.Router) {
todo := r.PathPrefix("/api/todos").Subrouter()
todo.HandleFunc("", handlers.CreateTodo).Methods(http.MethodPost)
todo.HandleFunc("", handlers.ListTodo).Methods(http.MethodGet)
// สามารถใช้ร่วมกับ regx ได้
todo.HandleFunc("/{id:[0-9]+}", handlers.GetTodo).Methods(http.MethodGet)
todo.HandleFunc("/{id:[0-9]+}", handlers.UpdateTodoStatus).Methods(http.MethodPut)
todo.HandleFunc("/{id:[0-9]+}", handlers.DeleteTodo).Methods(http.MethodDelete)
}
เชื่อมต่อ Database
ก่อนจะไปลง logic เรามาเชื่อมต่อ Database รอไว้ก่อน ซึ่งจากบทความก่อนหน้านี้ การเชื่อมต่อ Database สามารถใช้ได้ทั้ง standard library หรือ sqlx หรือ gorm ก็ได้ซึ่งในบทความนี้จะเลือกใช้ gorm และฐานข้อมูลเป็น PostgreSQL
- ติดตั้ง gorm และ database driver
go get -u gorm.io/gorm
go get -u gorm.io/driver/postgres
- เชื่อมต่อ Database โดยสร้างไฟล์
pkg/common/database/gorm.go
package database
import (
"log"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const (
// TODO fill this in directly or through environment variable
// Build a DSN e.g. postgres://username:password@host:port/dbName
// or "host=localhost user=gorm password=gorm dbname=gorm port=5432 sslmode=disable TimeZone=Asia/Bangkok"
DB_DSN = "postgres://fcricryh:F5a7wATfocTUNww1Dm14AfebtPaysqIn@john.db.elephantsql.com/fcricryh"
)
var DB *gorm.DB
func ConnectDB() {
var err error
DB, err = gorm.Open(postgres.Open(DB_DSN), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
log.Fatal("Cannot open DB connection", err)
}
log.Println("DB Connected")
}
- เรียกใช้งาน
func main() {
// เรียกก่อนเริ่มเปิด server เพราะถ้าเชื่อมต่อไม่ได้ให้จะได้ไม่ต้อง start server
database.ConnectDB()
r := mux.NewRouter()
// define route
setupRouter(r)
// starting server
log.Fatal(http.ListenAndServe(":8080", r))
}
สร้าง Model
เนื่องจาก GORM เป็น ORM ดังนั้นเราจะต้องสร้าง Model ซึ่งเป็น struct
ที่มีโครงสร้างเหมือนกับตารางในฐานข้อมูลขึ้นมาก่อน
ให้ไปแก้ไข Todo
struct ใน pkg/handlers/todo.go
เนื่องจากชื่อ column ใน databse ไม่ตรงกัน โดยจะต้องกำหนด gorm tag column
ลงไป
type Todo struct {
ID int `json:"id"`
Text string `json:"text" gorm:"column:title"`
Completed bool `json:"isCompleted" gorm:"column:is_done"`
}
ใส่ Bussiness Logic
ถัดมาจะรวมทุกอย่างเข้าด้วยกัน โดยการใส่ bussiness logic ที่ handlers แต่ละตัว ให้รับข้อมูลมา แล้วส่งต่อไปยังฐานข้อมูล แล้วตอบผลลัพธ์กลับไป
CreateTodo Handler
เป็น handler สำหรับ Method POST /todos
มีหน้าที่ คือ
- Step 1: รับข้อมูลที่เป็น JSON
{"text", "do something"}
เข้ามาแล้วแปลงเป็น Todo struct ถ้าแปลงไม่ได้ให้ตอบ error 400 กลับไป
func CreateTodo(w http.ResponseWriter, r *http.Request) {
// step 1: แปลง JSON จาก request body เป็น Todo struct
var todo Todo
err := json.NewDecoder(r.Body).Decode(&todo)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
- Step 2: ตรวจสอบว่าข้อมูลที่ส่งมาต้องไม่เป็นค่าว่าง ถ้าเป็นค่าว่างให้ตอบ error 400 กลับไป
func CreateTodo(w http.ResponseWriter, r *http.Request) {
// step 1: แปลง JSON จาก request body เป็น Todo struct
// step 2: validate
if todo.Title == "" {
http.Error(w, "text is required", http.StatusBadRequest)
return
}
}
แต่ถ้าต้องมีการตรวจสอบหลายๆ fields แนะนำให้ใช้ package validator แทน โดยการ tag validate
ลงไปใน struct
เพื่อความสะดวกต่อการใช้งานจะสร้างเป็น validator ของเราเองขึ้นมาครอบ package validator ไว้
package validator
import (
"errors"
"strings"
"github.com/go-playground/locales/en"
ut "github.com/go-playground/universal-translator"
"github.com/go-playground/validator/v10"
en_translations "github.com/go-playground/validator/v10/translations/en"
)
// use a single instance , it caches struct info
var (
uni *ut.UniversalTranslator
validate *validator.Validate
trans ut.Translator
)
func init() {
en := en.New()
uni = ut.New(en, en)
// this is usually know or extracted from http 'Accept-Language' header
// also see uni.FindTranslator(...)
trans, _ = uni.GetTranslator("en")
validate = validator.New()
en_translations.RegisterDefaultTranslations(validate, trans)
}
func ValidateStruct(s interface{}) error {
err := validate.Struct(s)
if err != nil {
return errors.New(errToMessage(err))
}
return nil
}
func errToMessage(err error) (message string) {
// translate all error at once
errs := err.(validator.ValidationErrors)
fields := removeTopStruct(errs.Translate(trans))
for k, v := range fields {
message += ", " + k + ": " + v
}
return message[2:]
}
func removeTopStruct(fields map[string]string) map[string]string {
res := map[string]string{}
for field, err := range fields {
res[strings.ToUpper(strings.ReplaceAll(field[strings.Index(field, ".")+1:], ".", "_"))] = err
}
return res
}
เสร็จแล้วรัน go mod tidy
เพื่อดาวน์โหลด package ที่ต้องใช้งาน
type Todo struct {
ID int `json:"id"`
Text string `json:"text" gorm:"column:title" validate:"required" `
Completed bool `json:"isCompleted" gorm:"column:is_done"`
}
func CreateTodo(w http.ResponseWriter, r *http.Request) {
// step 1: แปลง JSON จาก request body เป็น Todo struct
// step 2: validate
err = validator.ValidateStruct(todo)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
- Step 3: บันทึกข้อมูลลงฐานข้อมูล ถ้าเกิดข้อผิดพลาดให้ตอบ error 500 กลับไป
func CreateTodo(w http.ResponseWriter, r *http.Request) {
// step 1: แปลง JSON จาก request body เป็น Todo struct
// step 2: validate
// step 3: insert
tx := database.DB.Create(&todo)
if err := tx.Error; err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
- Step 4: สุดท้ายให้ตอบข้อมูลที่บันทึกส่งกลับไปในรูปแบบของ JSON และสถานะเป็น 201
func CreateTodo(w http.ResponseWriter, r *http.Request) {
// step 1: แปลง JSON จาก request body เป็น Todo struct
// step 2: validate
// step 3: insert
// step 4: response
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(todo)
}
ListTodo Handler
เป็น handler สำหรับ Method GET /todos
มีหน้าที่ คือ
- Step 1: ค้นหาข้อมูลทั้งหมดจากฐานข้อมูล ถ้าเกิดข้อผิดพลาดให้ตอบ error 500 กลับไป
func ListTodo(w http.ResponseWriter, r *http.Request) {
// step 1: query all todos from database
todos := []Todo{}
tx := database.DB.Find(&todos)
if err := tx.Error; err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
- Step 2: ส่งข้อมูลทั้งหมดกลับไปในรูปแบบ JSON
func ListTodo(w http.ResponseWriter, r *http.Request) {
// step 1: query all todos from database
// step 2: response
w.Header().Add("Content-Type", "application/json")
json.NewEncoder(w).Encode(todos)
}
หรือจะเพิ่มให้สามารถค้นหาจากสถานะที่ส่งมาทาง query parameters ด้วยก็ได้ ตามนี้
func ListTodo(w http.ResponseWriter, r *http.Request) {
// เพิ่มอ่านค่าจาก query params
query := r.URL.Query()
wheres := map[string]interface{}{}
// ถ้าส่ง completed มา ให้ใส่ไปใน map["is_done"] ตามชื่อ column จริงในฐานข้อมูล
if val, ok := query["completed"]; ok {
b1, err := strconv.ParseBool(val[0])
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
wheres["is_done"] = b1
}
todos := []Todo{}
// เพิ่ม .Where()
tx := database.DB.Where(wheres).Find(&todos)
if err := tx.Error; err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "application/json")
json.NewEncoder(w).Encode(todos)
}
GetTodo Handler
เป็น handler สำหรับ Method GET /todos/{id:[0-9]+}
มีหน้าที่ คือ
- Step 1: อ่านค่า id ออกมา แล้วแปลงเป็นตัวเลข
func GetTodo(w http.ResponseWriter, r *http.Request) {
// step 1: get id from path param
vars := mux.Vars(r)
id, _ := strconv.Atoi(vars["id"])
}
- Step 2: นำค่า id ไปค้นหาจากฐานข้อมูล ถ้าเกิดข้อผิดพลาดให้ตอบ error 500 กลับไป
func GetTodo(w http.ResponseWriter, r *http.Request) {
// step 1: get id from path param
// step 2: select where id
todo := Todo{}
tx := database.DB.First(&todo, id)
if err := tx.Error; err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
- Step 3: ถ้าไม่พบข้อมูลให้ตอบ error 404 กลับไป
func GetTodo(w http.ResponseWriter, r *http.Request) {
// step 1: get id from path param
// step 2: select where id
todo := Todo{}
tx := database.DB.First(&todo, id)
if err := tx.Error; err != nil {
// step 3: handle error not found
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "todo with given id not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
- Step 4: ถ้าพบข้อมูลให้ส่งกลับไปในรูปแบบ JSON
func GetTodo(w http.ResponseWriter, r *http.Request) {
// step 1: get id from path param
// step 2: select where id
// step 3: handle error not found
// step 4: response
w.Header().Add("Content-Type", "application/json")
json.NewEncoder(w).Encode(todo)
}
UpdateTodo Handler
เป็น handler สำหรับ Method PUT /todos/{id:[0-9]+}
มีหน้าที่ คือ
- Step 1: อ่านค่า id ออกมา แล้วแปลงเป็นตัวเลข
func UpdateTodo(w http.ResponseWriter, r *http.Request) {
// step 1: get id from path param
vars := mux.Vars(r)
id, _ := strconv.Atoi(vars["id"])
}
- Step 2: รับข้อมูลที่เป็น JSON
{"isCompleted", true}
เข้ามาแล้วแปลงเป็น Todo struct
func UpdateTodo(w http.ResponseWriter, r *http.Request) {
// step 1: get id from path param
// step 2: แปลง json body เป็น struct เพื่อเอาค่าสถานะที่ส่งมา
var todo Todo
err := json.NewDecoder(r.Body).Decode(&todo)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
- Step 3: นำข้อมูลที่ส่งมาไปอัพเดทสถานะในฐานข้อมูลจาก id ที่ระบุมา
func UpdateTodo(w http.ResponseWriter, r *http.Request) {
// step 1: get id from path param
// step 2: แปลง json body เป็น struct เพื่อเอาค่าสถานะที่ส่งมา
// step 3: update only is_done column
tx := database.DB.Model(Todo{ID: id}).Update("is_done", todo.Completed)
if err := tx.Error; err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
- Step 4: กรณีที่ไม่มี id ที่ส่งมา ให้ตอบ error 404 กลับไป
func UpdateTodo(w http.ResponseWriter, r *http.Request) {
// step 1: get id from path param
// step 2: แปลง json body เป็น struct เพื่อเอาค่าสถานะที่ส่งมา
// step 3: update only is_done column
// step 4: handle not found error
if tx.RowsAffected == 0 {
http.Error(w, "todo with given id not found", http.StatusNotFound)
return
}
}
- Step 5: ถ้าอัพเดทสำเร็จให้ตอบแค่สถานะ 204 กลับไป
func UpdateTodo(w http.ResponseWriter, r *http.Request) {
// step 1: get id from path param
// step 2: แปลง json body เป็น struct เพื่อเอาค่าสถานะที่ส่งมา
// step 3: update only is_done column
// step 4: handle not found error
// step 5: response
w.WriteHeader(http.StatusNoContent)
}
DeleteTodo Handler
เป็น handler สำหรับ Method DELETE /todos/{id:[0-9]+}
มีหน้าที่ คือ
- Step 1: อ่านค่า id ออกมา แล้วแปลงเป็นตัวเลข
func DeleteTodo(w http.ResponseWriter, r *http.Request) {
// step 1: get id from path param
vars := mux.Vars(r)
id, _ := strconv.Atoi(vars["id"])
}
- Step 2: นำค่า id ที่ส่งมาไปลบออกจากฐานข้อมูล
func DeleteTodo(w http.ResponseWriter, r *http.Request) {
// step 1: get id from path param
// step 2: delete where id
tx := database.DB.Delete(&Todo{}, id)
if err := tx.Error; err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
- Step 3: กรณีที่ไม่มี id ที่ส่งมา ให้ตอบ error 404 กลับไป
func DeleteTodo(w http.ResponseWriter, r *http.Request) {
// step 1: get id from path param
// step 2: delete where id
// step 3: handle not found error
if tx.RowsAffected <= 0 {
http.Error(w, "todo with given id not found", http.StatusNotFound)
return
}
}
- Step 4: ถ้าลบสำเร็จให้ตอบแค่สถานะ 204 กลับไป
func DeleteTodo(w http.ResponseWriter, r *http.Request) {
// step 1: get id from path param
// step 2: delete where id
// step 3: handle not found error
// step 4: response
w.WriteHeader(http.StatusNoContent)
}
Refactor
จากโค้ดด้านบนจะเห็นว่า ขั้นตอนการตอบกลับ JSON จะมีส่วนที่ทำงานซ้ำๆ กัน คือ กำหนด Content-Type, กำหนด respone code และแปลง struct เป็น JSON แล้วตอบกลับไป ดังนี้ สามารถเอาโค้ดส่วนนี้แยกมาเป็นฟังก์ชันสำหร้บส่งข้อมูลตอบกลับได้ตามนี้
func sendJson(w http.ResponseWriter, code int, data interface{}) {
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(data)
}
เมื่อเรียกใช้งานโค้ดจะดู clean ขึ้น
func GetTodo(w http.ResponseWriter, r *http.Request) {
// ...
// step 4: response
sendJson(w, http.StatusOK, todo)
}
Use Method Style
ส่วนตัวชอบเขียน handler ในรูปแบบของ method มากกว่า function โดยจะส่ง depencies ที่ต้องใช้ทั้งหมดมาให้ตอนสร้าง handler ขึ้นมาแทน
- สร้าง todoHandler struct พร้อมกำหนด depencies ที่ต้องการใช้งาน
type todoHandler struct {
db *gorm.DB
}
func NewTodoHandler(db *gorm.DB, validate *validator.Validate) *todoHandler {
return &todoHandler{
db: db,
validate: validate,
}
}
- แก้ handlers function เป็น method โดยการทำเป็น receiver function
func (h todoHandler) CreateTodo(w http.ResponseWriter, r *http.Request) {
// ...
tx := h.db.Create(&todo)
// ...
}
func (h todoHandler) ListTodo(w http.ResponseWriter, r *http.Request) {
// ...
tx := h.db.Where(wheres).Find(&todos)
// ...
}
func (h todoHandler) GetTodo(w http.ResponseWriter, r *http.Request) {
// ...
tx := h.db.First(&todo, id)
// ...
}
func (h todoHandler) UpdateTodo(w http.ResponseWriter, r *http.Request) {
// ...
tx := h.db.Model(Todo{ID: uint(id)}).Update("is_done", todo.Completed)
// ...
}
func (h todoHandler) DeleteTodo(w http.ResponseWriter, r *http.Request) {
// ...
tx := h.db.Delete(&Todo{}, id)
// ...
}
- แก้ไขการเรียกใช้ที่
cmd/main.go
func main() {
// ...
}
func setupRouter(r *mux.Router) {
todo := r.PathPrefix("/api/todos").Subrouter()
todoHandler := handlers.NewTodoHandler(database.DB)
todo.HandleFunc("", todoHandler.CreateTodo).Methods(http.MethodPost)
todo.HandleFunc("", todoHandler.ListTodo).Methods(http.MethodGet)
// สามารถใช้ร่วมกับ regx ได้
todo.HandleFunc("/{id:[0-9]+}", todoHandler.GetTodo).Methods(http.MethodGet)
todo.HandleFunc("/{id:[0-9]+}", todoHandler.UpdateTodo).Methods(http.MethodPut)
todo.HandleFunc("/{id:[0-9]+}", todoHandler.DeleteTodo).Methods(http.MethodDelete)
}
Error Handling
ในกรณีที่เกิดข้อผิดพลาด ตอนนี้จะตอบกลับไปเป็นข้อความ แต่เนื่องจาก API Service นี้ ต้องการให้ตอบกลับในรูปแบบของ JSON เท่านั้น
- เพื่อความสะดวกในการเขียนโค้ด ให้เราสร้าง
AppError
ซึ่งเป็น error ที่มี status code ขึ้นมาก่อนที่pkg/common/errs/errs.go
package errs
import "net/http"
type AppError struct {
Code int `json:"-"`
Message string `json:"error"`
}
func (e AppError) Error() string {
return e.Message
}
func NewBadRequestError(message string) error {
return AppError{
Code: http.StatusBadRequest,
Message: message,
}
}
func NewNotFoundError(message string) error {
return AppError{
Code: http.StatusNotFound,
Message: message,
}
}
func NewUnexpectedError(message string) error {
return AppError{
Code: http.StatusInternalServerError,
Message: message,
}
}
- สร้างฟังก์ชันจัดการ error ใน
pkg/handlers/todo.go
func handleError(w http.ResponseWriter, err error) {
switch e := err.(type) {
case errs.AppError:
sendJson(w, e.Code, e)
case error:
appErr := errs.AppError{
Code: http.StatusInternalServerError,
Message: e.Error(),
}
sendJson(w, appErr.Code, appErr)
}
}
- จัดการ error ด้วย AppError
func (h todoHandler) GetTodo(w http.ResponseWriter, r *http.Request) {
// step 1: get id from path param
vars := mux.Vars(r)
id, _ := strconv.Atoi(vars["id"])
// step 2: select where id
todo := Todo{}
tx := h.db.First(&todo, id)
if err := tx.Error; err != nil {
// step 3: handle error not found
if errors.Is(err, gorm.ErrRecordNotFound) {
handleError(w, errs.NewNotFoundError("todo with given id not found"))
return
}
handleError(w, errs.NewUnexpectedError(err.Error()))
return
}
// step 4: response
sendJson(w, http.StatusOK, todo)
}
Middleware
Middleware คือ function ที่รับ handler และคืน handler ใหม่ออกมา เอาไว้ห่อ handler โดยจะทำก่อน และหลังจาก handler นั้นทำงานเสร็จ และสามารถใช้ middleware ห่อซ้อนกันหลายๆ ชั้นก็ได้ โดยจะเริ่มจะงานจากชั้นนอกสุดไปจนถึง handler และออกจากชั้นในสุดไปยังชั้นนอกสุด
สามารถเอามาประยุกต์ใช้ในการทำ access log หรือการทำ authentication ก็ได้
- ตัวอย่างการทำ Access log
package middleware
import (
"log"
"net/http"
"time"
)
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
start := time.Now()
next.ServeHTTP(w, req)
log.Printf("%s %s %s", req.Method, req.RequestURI, time.Since(start))
})
}
- ตัวอย่างทำ Authentication
type authenticationMiddleware struct {
tokenUsers map[string]string
}
func NewAuthenticationMiddleware() *authenticationMiddleware {
m := map[string]string{}
m["1111"] = "user1"
m["2222"] = "user2"
return &authenticationMiddleware{
tokenUsers: m,
}
}
// Middleware function, which will be called for each request
func (amw *authenticationMiddleware) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("x-token")
if user, found := amw.tokenUsers[token]; found {
// We found the token in our map
log.Printf("Authenticated user %s\n", user)
// Pass down the request to the next middleware (or final handler)
next.ServeHTTP(w, r)
} else {
// Write an error and stop the handler chain
http.Error(w, "Forbidden", http.StatusForbidden)
}
})
}
- การเรียกใช้
func setupRouter(r *mux.Router) {
todo := r.PathPrefix("/api/todos").Subrouter()
todoHandler := handlers.NewTodoHandler(database.DB, validate)
todo.HandleFunc("", todoHandler.CreateTodo).Methods(http.MethodPost)
todo.HandleFunc("", todoHandler.ListTodo).Methods(http.MethodGet)
// สามารถใช้ร่วมกับ regx ได้
todo.HandleFunc("/{id:[0-9]+}", todoHandler.GetTodo).Methods(http.MethodGet)
todo.HandleFunc("/{id:[0-9]+}", todoHandler.UpdateTodo).Methods(http.MethodPut)
todo.HandleFunc("/{id:[0-9]+}", todoHandler.DeleteTodo).Methods(http.MethodDelete)
// Set up middleware.
amw := middleware.NewAuthenticationMiddleware()
r.Use(amw.Middleware)
r.Use(middleware.Logging)
}
// request -> logging -> authen -> router
- หรือถ้าต้องการ authen เฉพาะ
/api/todos
func setupRouter(r *mux.Router) {
todo := r.PathPrefix("/api/todos").Subrouter()
todo.HandleFunc("", handlers.CreateTodo).Methods(http.MethodPost)
todo.HandleFunc("", handlers.ListTodo).Methods(http.MethodGet)
// สามารถใช้ร่วมกับ regx ได้
todo.HandleFunc("/{id:[0-9]+}", handlers.GetTodo).Methods(http.MethodGet)
todo.HandleFunc("/{id:[0-9]+}", handlers.UpdateTodo).Methods(http.MethodPut)
todo.HandleFunc("/{id:[0-9]+}", handlers.DeleteTodo).Methods(http.MethodDelete)
r.HandleFunc("/public", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "This is public route")
})
// Set up middleware.
amw := middleware.NewAuthenticationMiddleware()
todo.Use(amw.Middleware) // <-- เปลี่ยนจาก r เป็น todo แทน
r.Use(middleware.Logging)
}
// request -> logging -> authen -> router
Handling CORS Requests
ทดสอบการใช้งานผ่านหน้า web จาก repo นี้
GitHub - somprasongd/todo-react-app at 5-http-requests
จะพบว่าไม่สามารถเรียกใช้งานอะไรได้เลย เนื่องจากมี error “strict-origin-when-cross-origin”
ซึ่งสามารถแก้ไขได้โดยการใช้ package cors เข้ามาช่วย ดังนี้
func main() {
// ...
// รับ handler กลับมา
handler := setupRouter(r)
// starting server
log.Fatal(http.ListenAndServe(":8080", handler))
}
// เพิ่ม return http.Handler
func setupRouter(r *mux.Router) http.Handler {
// ...
r.Use(middleware.Logging)
// Handling CORS Requests
c := cors.New(cors.Options{
AllowCredentials: true,
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
})
handler := c.Handler(r)
return handler
}
Non Functional Requirements
ในการทำ API Service นอกจากการทำตาม requirements แล้วนั้น ยังมีเรื่องอื่นๆ ที่ควรเพิ่มเข้ามา ก่อนที่จะนำไป deploy ให้งานจริงได้
การจัดการ Configuration
เนื่องจากค่า configurations ต่างๆ ในโปรแกรม เช่น server port และ dsn สำหรับ database ไม่ควรระบุลงไปในโค้ดตรงๆ ควรที่จะเปลี่ยนแปลงได้ตามค่า environments เมื่อถูกนำไป deploy ดูเพิ่มเติม
ควรกำหนด Rate Limit
ในบางครั้ง api ของเราจะต้องใช้เวลาในการทำงานนาน ไม่ว่าจะเป็นคิวรี่ข้อมูล หรือไปเรียกใช้งาน api ภายนอก เราจะต้องป้องกัน api ของเราด้วยการกำหนดจำนวนสูงในการรับ request ต่อวินาทีเอาไว้ ดูเพิ่มเติม
ต้องมี Graceful Shutdown
เราต้องรอให้งานที่กำลังทำงานค้างอยู่นั้น ทำงานให้เสร็จก่อน ถึงจะ shutdown ระบบไป ดูเพิ่มเติม
รองรับ Liveness Probe กับ Readiness Probe
ถ้า API ของเราต้องนำไป Deploy บน K8S มีอีก 2 เรื่องที่ต้องทำ คือ Liveness Probe กับ Readiness Probe ดูเพิ่มเติม
การทำ Logging
เมื่อมีการแสดง log เราควรมีการจัดการที่ดี เช่น มีการแสดง transaction_id เพื่อให้รู้ว่า log แต่ละส่วนเกิดจาก request ครั้งเดียวกัน รวมถึงการกำหนดรูปแบบของการแสดง log เพื่อให้สามารถนำไปใช้งานต่อได้สะดวก ดูเพิ่มเติม
Deployment
เมื่อ API ของเราเสร็จแล้ว จะต้องนำไป deploy เพื่อใช้งานจริง โดยจะใช้วิธีสร้างเป็น Docker Image เพื่อนำไป deploy ใน Docker หรือ Kubernetes
Dockerfile
เริ่มจากสร้าง Dockerfile เพื่อนำไปสร้าง Docker image
FROM golang:1.18-buster AS build
WORKDIR /app
COPY go.mod ./
COPY go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY pkg ./pkg
ENV GOARCH=amd64
RUN ls
RUN go build -o /go/bin/app cmd/main.go
## Deploy
FROM gcr.io/distroless/base-debian11
COPY /go/bin/app /app
EXPOSE 8080
USER nonroot:nonroot
CMD ["/app"]
Build Docker Image
สร้าง docker image ด้วยคำสั่ง docker build -t todo-api:1.0.0 -f Dockerfile .
Run with Docker-compose
หลังจากได้ docker image มาแล้ว เมื่อจะนำไป deploy ใน docker จะใช้รันผ่าน docker-compose ซึ่งจำต้องสร้าง docker-compose.yml
ขึ้นมาดังนี้
version: '2.4'
services:
db:
image: postgres:12-alpine
container_name: todo-db
restart: always
environment:
- TZ=Asia/Bangkok
- PGTZ=Asia/Bangkok
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=S3cretp@ssw0rd
- POSTGRES_DB=todos
volumes:
- pg_data:/var/lib/postgresql/data
logging:
options:
max-size: 10m
max-file: '3'
healthcheck:
test: pg_isready -U postgres -h 127.0.0.1
interval: 10s
timeout: 5s
retries: 5
api:
image: todo-api:1.0.0
container_name: todo-api
restart: always
ports:
- 8080:8080
environment:
- TZ=Asia/Bangkok
- DB_DRIVER=postgres
- DB_HOST=db
- DB_PORT=5432
- DB_USERNAME=postgres
- DB_PASSWORD=S3cretp@ssw0rd
- DB_DATABASE=todos
depends_on:
db:
condition: service_healthy
volumes:
pg_data:
และรันด้วยคำสั่ง docker-compose up -d
จบแล้วสำหรับการสร้าง API Service ด้วย Go ตั้งแต่เริ่มต้นว่ามีเรื่องอะไรที่ต้องทำบ้างทั้ง functional และ non-functional จนไปถึงการ Deploy เพื่อใช้งานจริง