[KBTG-GO#04] API Design

สำหรับ Week นี้ เป็นบทที่เยอะมาก 555 รอบหน้าแบ่งสอน 2 Week จะดีมาก ส่วนตัวแยก 2 Blog เหมือนกัน

1. REST API

- Vocab

Components of a URL

HTTP verbs GET (ดึงข้อมูล Select) / POST (Create) / PUT (Update) / DELETE

Resources พวก Path ที่เราอ้า่งอิงกัน เช่น /orders บราๆ ส่วนใหญ่ผมเรียก EndPoints 555

- RESTful Resource Naming Conventions

เวลาไปทำงานกันคนอื่น พอจะเดา patterns และมี standard

  • Pluralized URIs + URIs as nounsเช่น users , orders, funds
Should NOT: /getUser
Should: /users/{id}
  • Query parameters ถ้าจำเป็น เช่น กำหนดการ Sort เป็นต้น
    ฟังแล้วแอบงง หลาย Query param แล้วมัน And / Or นะ จำได้ว่าขึ้นกับ Controller ที่รับมา แล้วเขียนตีความเอง
การ Sort 
/users?sort=age

การ status
/tickets?status=done
  • Forward slashes for hierarchy
เช่น  
/users/{id}/orders        >> บอกว่า user มี order อะไรบ้าง
/portfolios/{id}/holdings >> บอกว่า Portfolio มีหลักทรัพย์ อะไรบ้าง

มองอีกมุม
/users/1/orders เท่ากับ /orders?userID=u1
  • Do not use file extensions ให้ใช้ Content-Type header ส่วนให้ pattern นี้
    น่ามีจะเรื่องเดียวที่ยกเว้น sitemap / rss feed มั้ง
  • Lowercase letters and dashes - จริงมันแสดงได้ แต่ url มันจะไม่สวย จะมี % มาด้วย เช่น space จะเป็น %20
อันนี้ผมเพิ่งรู้ บางก็พลาด ตัว _ 555
Should NOT: /users/{id}/pending_orders

Should:/users/{id}/pending-orders

และก็ Coding เยอะขึ้น เห็นใน Discord มีแชร์ Coding Style Guide เลยเอามาแปะด้วย guide/style.md at master · uber-go/guide (github.com)

2. net/http

Lib "net/http" ที่มากับ Golang ที่ช่วยให้ขึ้น Server ได้ง่ายๆ

  • sample code กำหนด handle
http.HandleFunc("<<Path>>, <<handler>>)
  • Code ทั้งหมด กำหนด path + handler เรียบร้อยแล้ว start server http.ListenAndServe(":10170", nil)
package main

import (
	"log"
	"net/http"
)

func handleAboutMe(w http.ResponseWriter, r *http.Request) {
        ` raw string 
	w.Write([]byte(`{name: "PingkungA", age: 33}`))
}

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("Hello, World!"))

	})
        //HandleFunc เขียนได้ 2 แบบ
        //1. ทำ func แยก โดยรับ (w http.ResponseWriter, r *http.Request)
	http.HandleFunc("/aboutme", handleAboutMe)

        //2. เขียน func เลย
	http.HandleFunc("/aboutme1", func(w http.ResponseWriter, r *http.Request) {
	        //Filter Specfic Http Method
		if r.Method != "GET" {
			http.Error(w, "Invalid request method", http.StatusMethodNotAllowed)
			return
		}
		w.Write([]byte(`{name: "PingkungA_GET", age: 33}`))
	})

	log.Println("Starting server on :10170")
        //ListenAndServ - Start Server 
	log.Fatal(http.ListenAndServe(":10170", nil))
	log.Println("Server Stopped")
}
  • ดัก request method จาก req *http.Request
req *http.Request

- detect method
if req.Method ="POST" {
 //you logic
} 
json.Marshal() //แปลง Struct > ByteArray Marshal=จัดเรียง เมื่อก่อนเคยลองหาเล่น เจอแปลๆไทยว่าจอมพล คงระดม CPU ไปทำของให้มั้ง 555

Unmarshall 
- Pointer &
- Struct public ตัวใหญ่
- String > JSON มันไม่สนเล็กใหญ่ ดู key เขียนตรง
> tag > field_name 

3. Middleware

Middleware เป็นตัวกลางที่มาช่วยเพิ่มความสามารถต่างๆ เช่น Logging ให้กับ Object ที่สนใจ Request / Response มี pattern

First Class Function - ตัว Function ที่ assigned to variables, passed as arguments to other functions and returned from other functions

  • assigned to variables
SampleFunc := Sum            //มันมองเป็น Value
result := SampleFunc(1, 2)

//==========================
func sum(a int, b int) int {
 return a + b
}
package main
import (
	"fmt"
)

func Math(a func(a, b int) int) {
	fmt.Println(a(25, 33))
}

func main() {
	f := func(a, b int) int {
		return a + b
	}
	Math(f)
}
package main
import (
	"fmt"
)

func Math() func(a, b int) int {
	f := func(a, b int) int {
		return a + b
	}
	return f
}

func main() {
	returnFunc := Math()
	fmt.Println(returnFunc(25, 33))
}

Function Literals - Function ที่ไม่มีชื่อ เป็น Anonymous / Closure

package main

import "fmt"

func square(x int) func() int {
    return func() int { //anonymous function
        x = x * x
        return x
    }
}

func main() {
    result:= square(2)
    fmt.Println("2 squared is: ", result) //4
}

Higher-Order Function เป็น Func ที่่อาจจะที่รับ Func มาเป็น Input หรือ คืน Func ออกเป็น Output หรือ ทำทั้งสองอย่าง

takes one or more functions as arguments and/or returns a function as its result

กลับที่มาที่ Middleware เจ้าตัว Middleware เป็นตัวกลางที่มาช่วยเพิ่มความสามารถต่างๆ เช่น Logging ให้กับ Object ที่สนใจ Request / Response ทำ wrapper นั้นเอง โดยมี pattern ที่ใช้ [Design Pattern] Decorator Pattern นั่นเอง (มี Blog ด้วย) //เข้าใจและว่าทำไมถึงสอน First Class Function / Higher-Order Function

Receiver functions

Mux (http multiplexer) ใช้ Middleware หลายๆตัวพร้อมกัน

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/users", userHandler)
	mux.HandleFunc("/healthcheck", healthCheckHandler)

        //Inject Log On ServeHTTP Method
	logMux := Logger{Handler: mux}

	srv := http.Server{
		Addr:    ":10170",
		Handler: logMux,
	}
	log.Fatal(srv.ListenAndServe())
}

ไฟล์ learn-go/API/2Middleware/2mux/json_http_mux.go at main · pingkunga/learn-go (github.com)

นอกจาก Log แล้ว อีกตัวอย่างที่เอา Middleware มาใช้งาน จะมีแต่ตัว Basic Authen

- Basic Auth

ปกติ Go มีพวกนี้มาให้แล้ว โดยถ้า Implement ครบ ตอนลองเข้า Endpoint จะมี Popup ถาม

func userHandler(w http.ResponseWriter, r *http.Request) {
	usr, pwd, ok := r.BasicAuth()
	log.Println("auth:", usr, pwd, ok)

        //OK กรอกมาครบไหม
	if !ok {
		w.Header().Add("WWW-Authenticate", `Basic realm="Give username and password"`)
		w.WriteHeader(http.StatusUnauthorized)
		w.Write([]byte(`{"message": "No basic auth present"}`))
		return
	}
        //ตรวจสิทธิ
	if !isAuthorised(usr, pwd) {
		w.Header().Add("WWW-Authenticate", `Basic realm="Give username and password"`)
		w.WriteHeader(http.StatusUnauthorized)
		w.Write([]byte(`{"message": "Invalid username or password"}`))
		return
	}

	if r.Method == "GET" {
            //Your Logic
        }

        if r.Method == "POST" {
            //Your Logic
        }
}

ไฟล์ BasicAuth: learn-go/API/2Middleware/3auth/json_http_mux_auth.go at main · pingkunga/learn-go (github.com)

- Middleware Auth

จากแบบ Basic Auth ถ้าจะเอาเรื่อง Auth ไปยัดใส่ มันต้องไปแปะลงใน Handler ทุกอันเลย เลยเป็นที่มาของการใช้ middleware

  • middleware - เอา Logic Auth มายัด ถ้าผ่านค่อยไปทำในส่วนของ next ในที่นี้ endpoint users
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, req *http.Request) {
		usr, pwd, ok := req.BasicAuth()
		log.Println("auth:", usr, pwd, ok)

		if !ok {
			w.Header().Add("WWW-Authenticate", `Basic realm="Give username and password"`)
			w.WriteHeader(http.StatusUnauthorized)
			w.Write([]byte(`{"message": "No basic auth present"}`))
			return
		}

		if !isAuthorised(usr, pwd) {
			w.Header().Add("WWW-Authenticate", `Basic realm="Give username and password"`)
			w.WriteHeader(http.StatusUnauthorized)
			w.Write([]byte(`{"message": "Invalid username or password"}`))
			return
		}

		//Pass to End Point
		fmt.Println("Auth Success")
		next(w, req)
		return
	}
}
  • เอา middleware หุ้ม
func main() {
	mux := http.NewServeMux()
        //Add AuthMiddleware ถ้าผ่านค่อยทำ userHandler
	mux.HandleFunc("/users", AuthMiddleware(userHandler))
	mux.HandleFunc("/healthcheck", healthCheckHandler)

	logMux := Logger{Handler: mux}
	srv := http.Server{
		Addr:    ":10170",
		Handler: logMux,
	}
	log.Fatal(srv.ListenAndServe())
}

ไฟล์ Middleware Auth: learn-go/API/2Middleware/3auth/json_http_mux_authMiddleware.go at main · pingkunga/learn-go (github.com)

อ๋อ พวก dotnet / spring มี middleware นะ ถ้า spring พวก Filter นับเหมือนกัน How to Define a Spring Boot Filter? | Baeldung //ไล่ code พอดีเลยมาแปะไว้

4. Echo HTTP API Framework

จากเดิมที่ใช้ net/http ที่มันจะเขียน Code เองระดับนึง แล้วมี พวก middleware มาให้ ทำอะไรแนวๆ minimal api แบบ dotnet ได้

  • ตัว Echo มี middleware บางอันให้แล้ว
  • มี Context มาจัดการ httprequest / httpresponsewriter มาให้แล้ว ใน handler จะลดพวก if ดัก
//Setup
go mod init github.com/<user>/<repo_name>
go mod tidy

go get github.com/labstack/echo/v4
go get github.com/labstack/echo/v4/middleware
  • Map Code แบบ Plain vs Framework
//Old Plain
func userHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method == "GET" {
		bj, errj := json.Marshal(users)

		if errj != nil {
			w.WriteHeader(http.StatusInternalServerError)
			w.Write([]byte("Internal Server Error"))
			w.Write([]byte(errj.Error()))
			return
		}
		w.Write(bj)
		return
	}

	if r.Method == "POST" {
		//log.Println("POST")	--->> <Move to Log Middleware>
		body, err := io.ReadAll(r.Body)
		if err != nil {
			fmt.Fprintf(w, "error : %v", err)
			return
		}

		u := User{}
		err = json.Unmarshal(body, &u)
		if err != nil {
			fmt.Fprintf(w, "error: %v", err)
			return
		}

		users = append(users, u)
		fmt.Printf("% #v\n", users)

		fmt.Fprintf(w, "hello %s created users", "POST")
		return
	}
}
//New with ECHO
func getUserHandler(c echo.Context) error {

	/* Old Manual Logic
	if r.Method == "GET" {
		//log.Println("GET")  	--->> <Move to Log Middleware>
		bj, errj := json.Marshal(users)

		if errj != nil {
			w.WriteHeader(http.StatusInternalServerError)
			w.Write([]byte("Internal Server Error"))
			w.Write([]byte(errj.Error()))
			return
		}
		w.Write(bj)
		return
	}
	*/

	return c.JSON(http.StatusOK, users)
}

type Err struct {
	Message string `json:"message"`
}

func createUserHandler(c echo.Context) error {
	usr := User{}
	if err := c.Bind(&usr); err != nil {
		//return err
		//Format error message
		return c.JSON(http.StatusBadRequest, Err{Message: err.Error()})
	}
	users = append(users, usr)
	fmt.Println("Created user:", usr)
	return c.JSON(http.StatusCreated, usr)
}

สำหรับพวก Middleware - Log / BasicAuth (ใช้ Group แยก ตรวจ / ไม่ตรวจ path ไหน) ดูได้จากไฟล์ server.go

5. SQL Database

- ElephantSQL

elephantsql - PostgreSQL as a Service //ดีอ่า plan free ไม่ต้อง Add บัตร

  • Create Instance เลือก Free Plan > เลือก Region > Review > Create Instance ฃ
  • เข้าไป Instance Copy url ตัว App ใช้

ถ้า local ใช้ docker compose ได้ ปกติผมใช้ท่านี้ เพิ่งมารู้ว่ามี Service แนวๆนี้นอกจาก Cloud เจ้าใหญ่ๆ

ส่วน url หรือ DB Connection String จะยัดลง ENV คำสั่งประมาณนี้

//ENV
//Windows Powershell
$env:DB_HOST='postgres://xomspvtd:x8g_LeOMU_Sb0QO8o9at3vK8n116hHTe@tiny.db.elephantsql.com/xomspvtd'
echo $env:DB_HOST

//Linux 
DB_HOST='<<Your Conn String>>'
echo $DB_HOST
- Setup
//Setup
go mod init github.com/<user>/<repo_name>
go mod tidy

//Postgres SQL Driver ใข้คู่กับ Basic Interface "database/sql"
go get github.com/lib/pq

func init() - ทำงานคล้ายกับ Constructor

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

//มี _ เพราะต้องเรียก func init() เพื่อไป set ค่าตาม "database/sql"

สำหรับ Code ตามนี้

เห็นมัน SQL เพียวๆ มันมี ORM ไหม แบบทาง C# มี EF / Dapper ส่วน Java จะเป็นตัว Hibernate ลองไปหาดูเหมือนมีนะ

6. API Integration Test

ปกติแล้ว go สนใจ _test แต่ตั้งชื่อให้มันสื่อด้วยว่าเป็น test รูปแบบไหน เช่น unit test (xxx__test) / Integration Test (xxx_integration_test)

go get github.com/stretchr/testify

import "github.com/stretchr/testify/assert"
  • ที่เหลือเขียน Test ปกติ
  • สุดท้ายติด Tag ด้วยตอน Run Test Local / CI จะได้
แปะหัวไฟล์
//go:build integration

ตอน Run
go test -v -tags=integration

ถ้าไม่ต้องการต่อ DB ต้องทำให้เล็กลง นั้นทำ unit test ตัด Dependency ออกทำ Stub ของ Func ที่ต่อ DB ให้คืนของหลอกมาๆ

7. Graceful shutdown

Graceful shutdown - ตายแบบหนังจีน ฝากลูกเมียข้าด้วย แล้วทำ request อยู่เดิมให้หมด

Goroutine - ทำให้งานเป็น Concurrent ซ่อนความซับซ้อนของ Thread อีกที มันทำงานแตกคนละ process จากตัว main เลย

go <your function> 

Channels - ทำให้ 2 process มีความสัมพันธ์กัน สามารถสื่อสารกันได้ เพราะประเด็นนึงจากการใข้ Goroutine มันไปทำงานข้างหลังเงียบๆ ถ้าไม่หน่วงเวลารอ หรือมี Blocking Process (มีอีก Task ทำงานคู่กัน)

สำหรับ Goroutine + Channels มันคล้ายๆกับ Task ใน C# แตก ทำขนานกัน ต้องรออะไร เป็นต้น

เหมือน Channel จะลึกว่าตรงจับจาก os ได้นะ เช่น os.Signal อย่างพวก os.Interrupt, syscall.SIGTERM, syscall.SIGINT

และเราเอาเจ้า Signal จาก OS มาทำ Graceful shutdown แต่ต้องมี Pattern การเขียนนะ เช่น แยก Task func main กับ ส่วน Start Server ให้อยู่ตัว Goroutine ให้มันเป็นคนละ Process

func main() {
	fmt.Println("สวัสดี")
	mux := http.NewServeMux()

	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte(`death`))
	})

	http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte(`pingkunga`))
	})

	srv := http.Server{
		Addr:    ":10170",
		Handler: mux,
	}
        
        //แยกอยู่ Process 
	go func() {
		log.Fatal(srv.ListenAndServe())
	}()
	fmt.Println("server starting at :10170")

	shutdown := make(chan os.Signal, 1)
	signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)

	<-shutdown
	fmt.Println("shutting down...")
	if err := srv.Shutdown(context.Background()); err != nil {
		//ตรงนี้จะทำอะไรก็ทำ ทำ Request ที่รับมาแล้ว
		//บอก K8S ฝากลูกเมียข้าด้วย ให้เรียบร้อยยย
		//เฮือกกกกก
		fmt.Println("shutdown err:", err)
	}
	fmt.Println("bye bye")
}

dotnet ต้องไปใช้ตัว OnStopping()">IHostedService > OnStopping() นะ ต้องไปทำตาม Framework ของ Go ดู Simple กว่าใช้ Goroutine + Channel + os.Signal

แปะ Implementing Graceful Shutdown in Go | RudderStack | RudderStack

8. Go Packages

มันการจัดการกลุ่มของงานตาม Domain (DDD) แหละ โดยใน Go แยกเป็น Package ซึ่งอีกตาม Domain แล้วข้างในมีไฟล์ที่ทำหน้าที่เฉพาะของมัน

เดิมทุก logic ของ user อยู่ที่นี้หมดเลย
- server.go 
ใหม่แยกตาม domain
- user
--> userDB      --ต่อ DB 
--> userHandler --จัดการ request ต่างๆตาม logic
--> user        --พวก dto / class
- server.go   --เหลือส่วนขึ้น server echo

เหมือนได้ยินว่า package ซ้อนกันไม่ใช่แม่ลูกกันนะ

Code: learn-go/API/6Package/API at main · pingkunga/learn-go (github.com)

เข้าใจและว่าทำไมถึงสอน Test ก่อนย้าย

9. How to build Go

- Local
  • go build
    Note: go run //เบื่องหลังมันไปเรียก go build
go build -o sample.exe .
//. ไฟล์ที่อยู่ใน path นั้นๆ

go build -o sample.exe server.go
  • Specific Platform Build
//Specific Platform
// - List Support platform
go tool dist list

//Set Arch + platform then build
GOOS=linux GOARCH=amd64 go build 
- Docker Build
FROM golang:1.22.1-alpine3.19 AS build

WORKDIR /app

COPY . ./
RUN go mod download

RUN go build -o /bin/app

FROM alpine:3.19.1

COPY --from=build /bin/app /bin

EXPOSE 10170

CMD [ "/bin/app" ]
  • Build & Run หรือ ใช้ Buildx แก้ Arch+Platform
// BUILD
docker build -t go-app .
//RUN
docker run go-ap

Reference


Discover more from naiwaen@DebuggingSoft

Subscribe to get the latest posts to your email.