N-tier Architecture สำหรับ Golang REST API Application

Songpon Ninwong
Finnomena
Published in
11 min readApr 17, 2023

--

การเขียนโค้ดให้ clean คือการที่เราเขียนให้คนอื่นที่มาอ่านเข้าใจโค้ดเราว่าโค้ดเรามีหน้าที่ทำอะไร หนึ่งในนั้นคือการทำให้ function หรือ class นั้นมีหน้าที่เดียว หรือการ group function นั้นให้เป็น layer ไป การแบ่ง layer ของ code นั้นยังช่วยให้ developer คนอื่นรู้อีกด้วยว่า code ที่สร้างเข้ามาใหม่นั้นควรไปอยู่ใน folder ไหน

ลองคิดในโลกความเป็นจริงถ้าคุณจัดของในบ้านให้เป็นที่และเป็นหมวดหมู่ก็จะช่วยลดเวลาในการหาและการเก็บของต่างๆใช่ไหมครับ code ก็เช่นกัน

ก่อนจะเริ่มกันผมเชื่อว่าสมัยเขียน code ใหม่ๆ ทุกคนต้องเคยเขียนโค้ดทุกอย่างอยู่ใน function เดียวกัน เช่น

package main

import (
"context"
"encoding/json"
"log"
"net/http"
"os"

"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"

mUser "github.com/petchill/n-tier-rest-api-golang/internal/model/user"
)

func main() {

ctx := context.Background()

mongoOpts := options.Client().ApplyURI(os.Getenv("MONGO_CONN"))
client, err := mongo.Connect(ctx, mongoOpts)
if err != nil {
log.Fatal("Cannot connect to mongodb client", err)
}
defer func() {
if err = client.Disconnect(context.TODO()); err != nil {
log.Fatal("Cannot disconnect to mongodb client", err)
}
}()
bankDB := client.Database(os.Getenv("BANK_DB"))

http.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
collection := bankDB.Collection("user")
users := []mUser.User{}

cursor, err := collection.Find(ctx, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = cursor.All(ctx, &users)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
return
})

}

แล้วข้อเสียของการทำอย่างนี้คืออะไรหล่ะ

  1. เวลาที่เราต้องการเรียกใช้ function ที่เอาข้อมูลจาก Database เราต้อง duplicate โค้ดนั้นไปใช้ที่อื่นเรื่อยๆ หรือว่าโค้ดอื่นๆที่มีการเรียกใช้ซ้ำก็ต้อง duplicate ไป
  2. โค้ดมีโอกาสที่จะมี circular dependency คือ การที่สอง module หรือ package เรียกใช้กันเองเป็นงูกินหาง
  3. โค้ดอ่านยาก เวลาเขียนโค้ดใหม่ก็ไม่มี pattern ที่ชัดเจน ทุกอย่างวุ่นวายอยู่ใน function เดียว
  4. เทสได้ยากเพราะต้องเทสทั้งเชิง database, business logic ในที่เดียว เวลากลับมา refactor ก็ต้องแก้ test ใหม่หมด

TL;DR

1. แบ่ง code ออกเป็น 3 layer

  • Handler Layer → หน้าที่คือรับส่ง request, validate req body และต่างๆที่เกี่ยวกับทางเข้าทางออก
  • Service Layer → หน้าที่คือ processing ต่างๆที่เกี่ยวกับ business logic
  • Repository Layer → หน้าที่คือ ติดต่อรับส่งข้อมูลกับ database หรือ data resource อื่นๆ เช่น http client ไปยิง service อื่น

2. การเรียกใช้ layer จะเรียกใช้ทางเดียวไม่มีการเรียกย้อนกลับเพื่อป้องกัน circular dependency เช่น

  • Handler จะเรียกให้ Service และ Repository ได้
  • Service จะเรียกให้ Repository ได้แต่เรียกใช้ Handler ไม่ได้
  • Repository ถูกใช้อย่างเดียว ไม่สามารถเรียกใช้ Handler กับ Service ได้

3. dependency injection จะฝัง resource ที่จะใช้เข้าไปในตอนสร้าง struct ใน layer เลย

4. source code → https://github.com/petchill/n-tier-rest-api-golang

Prerequisite

ก่อนจะเริ่มกันผมขอ Intro ระบบที่ผมยกตัวอย่างก่อนนะครับ

  • ระบบนี้เป็น REST API ที่มี endpoint เดียวคือการ POST เพื่อถอนเงิน
  • ใช้ MongoDB เป็น Database
  • ผมจะแยกพวก struct และ entity และ interface ต่างๆมาอยู่ใน folder model และจะ import ด้วย prefix m เช่น mRepo คือ struct ต่างๆ ที่อยู่ใน folder model/repository จะเห็นได้ใน code เช่น
import (
mAccount "github.com/petchill/n-tier-rest-api-golang/internal/model/account"
mRepo "github.com/petchill/n-tier-rest-api-golang/internal/model/repository"
mUser "github.com/petchill/n-tier-rest-api-golang/internal/model/user"
)
  • การออกแบบ Database มีดังนี้
type User struct {
ID int `json:"id" bson:"id"` // unique id
Name string `json:"name" bson:"name"` //ชื่อลูกค้า
}

type Account struct {
ID string `json:"id" bson:"id"` // unique id
UserID int `json:"user_id" bson:"user_id"` // id ของ collection user
AmountRemain float64 `json:"amount_remain" bson:"amount_remain"` // เงินคงเหลือของบัญชี
IsAvailable bool `json:"is_available" bson:"is_available"` // บัญชีใช้งานได้อยู่ไหม ถูกปิดไปรึยัง}
  • user ก็คือลูกค้า ส่วน account ก็คือบัญชี
  • ความสัมพันธ์เป็นแบบ one to many (ลูกค้า 1 คนมีได้หลายบัญชี)

N-tier Architecture

![[https://commons.wikimedia.org/wiki/File:Client-Server_N-tier_architecture_-_en.png](https://commons.wikimedia.org/wiki/File:Client-Server_N-tier_architecture_-_en.png)](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/54e5594f-84b1-486c-a002-9283ba6da86e/Untitled.png) [https://commons.wikimedia.org/wiki/File:Client-Server_N-tier_architecture_-_en.png](https://commons.wikimedia.org/wiki/File:Client-Server_N-tier_architecture_-_en.png)
https://commons.wikimedia.org/wiki/File:Client-Server_N-tier_architecture_-_en.png

เป็น design pattern ที่แบ่งโค้ดออกเป็น layer ต่าง ๆ n layer โดยทุก ๆ layer จะมีหน้าที่การทำงานที่ชัดเจนในตัวมันเองและมีการเรียกใช้เป็นลำดับขั้นและชัดเจน

ซึ่งด้วยชื่อว่า n-tier หมายถึง คุณจะแบ่ง layer เป็นกี่ tier ก็ได้แล้วแต่การใช้งาน แต่ layer ต่างๆจะต้องที่หน้าที่ชัดเจนและการเรียกใช้เป็นลำดับขั้นเพื่อป้องกัน circular dependency

โดยในบทความนี้ ผมจะแบ่ง REST API application ของผมเป็น 3 layer คือ

  • Handler Layer หรือ Controller Layer
  • Service Layer
  • Repository Layer หรือ Data Resource Layer

ถ้าจะให้ยกตัวอย่าง 3 layer นี้แบบเห็นภาพ ลองเปรียบเทียบกับการถอนเงินในสมัยที่ใช้แค่เอกสารละกันครับ (ถ้าคุณเกิดไม่ทันผมก็เกิดไม่ทันเหมือนกันครับ)

  • Handler Layer → พนักงานหน้า counter ที่ธนาคาร
  • Service Layer → พนักงานบัญชีของธนาคาร
  • Repository Layer → แผนกเอกสาร

โดย flow การทำงานโดยจะสรุปเป็น diagram ได้แบบนี้

ถ้าเชิง code จะเป็นอย่างนี้

ถ้ายังไม่เข้าใจไม่เป็นไรครับลองจำไว้แค่เท่านี้ก่อนเดี๋ยวผมจะอธิบายต่อไปเรื่อยๆ ^^

Handler Layer

หรือ Controller layer สำหรับบางคน ใน layer นี้จะจัดการเกี่ยวกับ API หรือการรับ request การส่ง response กลับไปให้ client หรือผู้ที่ร้องขอข้อมูล ลองดูตัวอย่าง code กันเลย

package handler

import (
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"

mHandler "github.com/petchill/n-tier-rest-api-golang/internal/model/handler"
mService "github.com/petchill/n-tier-rest-api-golang/internal/model/service"
)

type transactionHandler struct {
transactionService mService.TransactionService
}

// WithdrawHandler implements handler.TransactionHandler
func (h transactionHandler) WithdrawHandler(w http.ResponseWriter, r *http.Request) {
// allow only POST Method
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

withdrawPayload := mHandler.WithdrawReqBody{}
err := json.NewDecoder(r.Body).Decode(&withdrawPayload)
if err != nil {
http.Error(w, "Some request information are missing.", http.StatusBadRequest)
return
}

if bodyValidateErr := validateRequestBody(withdrawPayload); bodyValidateErr != nil {
http.Error(w, bodyValidateErr.Error(), http.StatusBadRequest)
return
}

reply, err := h.transactionService.Withdraw(r.Context(), withdrawPayload)
if err != nil {
errMsg := fmt.Sprintf("Withdraw process is fail because of %s", err.Error())
http.Error(w, errMsg, http.StatusUnprocessableEntity)
return
}

replyMsg := fmt.Sprintf("%s withdraw money %v baht from account %s success. Remained %v baht.", reply.UserName, reply.WithdrawAmount, reply.AccountID, reply.RemainAmount)
fmt.Fprintf(w, replyMsg)
return
}

func validateRequestBody(withdrawPayload mHandler.WithdrawReqBody) error {
if nameValid := validateEnglishName(withdrawPayload.Withdrawer); !nameValid {
return errors.New("withdrawer must be English name and in format (fisrtname surname).")
}

if accountIDValid := validateAccountID(withdrawPayload.AccountID); !accountIDValid {
return errors.New("account_id must be number with 8 length.")

}

if amountValid := validateAccountID(withdrawPayload.AccountID); !amountValid {
return errors.New("withdraw_amount must be more than 0.")
}

return nil
}

func validateEnglishName(name string) bool {
regex, _ := regexp.Compile("^[a-zA-Z]+ [a-zA-Z]+$")
return regex.MatchString(name)
}

func validateAccountID(id string) bool {
// Assume as id must have 8 length numbers
regex, _ := regexp.Compile("^[0-9]{8}$")
return regex.MatchString(id)
}

func validateAmount(amount float64) bool {
return amount > 0
}

func NewTransactionHandler(transactionService mService.TransactionService) mHandler.TransactionHandler {
return transactionHandler{
transactionService: transactionService,
}
}

จะเห็นว่า layer นี้จะทำงานที่อย่างที่เกี่ยวกับ request ที่เข้ามาไม่ว่าจะเป็น

  • การรับ request
  • validate body
  • การส่ง response กลับไป

โดยส่วนของการประมวลผมข้อมูลที่จะส่งออกไปจะไปอยู่ใน Service Layer และถูกเรียกโดย Handler Layer ครับ

ถ้าจะให้ยกตัวอย่างกับการถอนเงิน มันก็เปรียบเสมือน พนักงานหน้าเค้าท์เตอร์

WithdrawHandler ก็คือ function http handler ที่รับเข้ามา

ทีนี้ลองคิดว่า HTTP request ของเราคือใบถอนเงินเราควรตรวจสอบอะไรบ้างหล่ะในขั้นแรก ลองคิดว่าคุณคือพนักงานหน้าเค้าท์เตอร์แล้วคุณยังไม่สามารถ check ข้อมูลลูกค้าคนนี้ได้เลย คุณจะตรวจสอบอะไรได้บ้าง

งั้นลองมาสรุปขั้นตอนตามโค้ดเลยนะครับ

  1. รับข้อมูลมาแล้วเอามาจับคู่ key ต่างๆลงใน struct
type WithdrawReqBody struct { // mHandler.WithdrawReqBody
AccountID string `json:"account_id"`
Amount float64 `json:"amount"`
Withdrawer string `json:"withdrawer"`
}

func (h transactionHandler) WithdrawHandler(w http.ResponseWriter, r *http.Request) {
// allow only POST Method
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

withdrawPayload := mHandler.WithdrawReqBody{}
// bind req body to WithdrawReqBody struct
err := json.NewDecoder(r.Body).Decode(&withdrawPayload)
if err != nil {
http.Error(w, "Some request information are missing.", http.StatusBadRequest)
return
}
...
}

2. ตรวจสอบข้อมูลเบื้องต้นต่างๆ ถ้าไม่ตรงตามเงื่อนไขของเราก็จะยกเลิกรายการของลูกค้า

func (h transactionHandler) WithdrawHandler(w http.ResponseWriter, r *http.Request) {
...
if bodyValidateErr := validateRequestBody(withdrawPayload); bodyValidateErr != nil {
http.Error(w, bodyValidateErr.Error(), http.StatusBadRequest)
return
}
...
}
func validateRequestBody(withdrawPayload mHandler.WithdrawReqBody) error {
if nameValid := validateEnglishName(withdrawPayload.Withdrawer); !nameValid {
return errors.New("withdrawer must be English name and in format (fisrtname surname).")
}

if accountIDValid := validateAccountID(withdrawPayload.AccountID); !accountIDValid {
return errors.New("account_id must be number with 8 length.")
}

if amountValid := validateAmount(withdrawPayload.Amount); !amountValid {
return errors.New("withdraw_amount must be more than 0.")
}

return nil
}

func validateEnglishName(name string) bool {
regex, _ := regexp.Compile("^[a-zA-Z]+ [a-zA-Z]+$")
return regex.MatchString(name)
}

func validateAccountID(id string) bool {
// Assume as id must have 8 length numbers
regex, _ := regexp.Compile("^[0-9]{8}$")
return regex.MatchString(id)
}

func validateAmount(amount float64) bool {
return amount > 0
}
  • ชื่อเป็นภาษาไทยหรือตรงกับนโยบายที่ใช้มั้ย เช่น ลูกค้าเขียนชื่อภาษาจีนมาแต่เราอนุญาตแค่ภาษาอังกฤษ แล้วก็ต้องไม่เว้นว่าง ในโค้ดผมจะใช้ regex เป็นส่วนใหญ่นะครับ
  • เลขบัญชีครบ 8 หลักรึเปล่าและต้องเป็นแค่เลขเท่านั้นห้ามมีตัวอักษรเข้ามา
  • ตรวจสอบว่าใส่เงินที่ต้องการถอนมาถูกไหมไม่ใช่ว่าใส่ค่าจำนวนติดลบหรือ 0 มา

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

func (h transactionHandler) WithdrawHandler(w http.ResponseWriter, r *http.Request) {
...
// call service function
reply, err := h.transactionService.Withdraw(r.Context(), withdrawPayload)
if err != nil {
errMsg := fmt.Sprintf("Withdraw process is fail because of %s", err.Error())
http.Error(w, errMsg, http.StatusUnprocessableEntity)
return
}
replyMsg := fmt.Sprintf("%s withdraw money %v baht from account %s success. Remained %v baht.", reply.UserName, reply.WithdrawAmount, reply.AccountID, reply.RemainAmount)
// reply message to client
fmt.Fprintf(w, replyMsg)
return
}

กระบวนการของ Handler layer ก็จะประมาณนี้ต่อไปเราลองมาดูกันว่า พนักงานบัญชี ( Service layer ) เขาทำอะไรบ้าง

Service Layer

ใน layer นี้เราจะพูดกันในเรื่องของ business logic หรือ การประมวลผลต่างๆ ที่ต้อง custom ตามที่ business ต้องการ

package service

import (
"context"
"errors"

mAccount "github.com/petchill/n-tier-rest-api-golang/internal/model/account"
mHandler "github.com/petchill/n-tier-rest-api-golang/internal/model/handler"
mRepo "github.com/petchill/n-tier-rest-api-golang/internal/model/repository"
mRes "github.com/petchill/n-tier-rest-api-golang/internal/model/response"
mService "github.com/petchill/n-tier-rest-api-golang/internal/model/service"
mUser "github.com/petchill/n-tier-rest-api-golang/internal/model/user"
)

type transactionService struct {
accountRepo mRepo.AccountRepository
userRepo mRepo.UserRepository
}

// Withdraw implements service.TransactionService
func (s transactionService) Withdraw(ctx context.Context, payload mHandler.WithdrawReqBody) (mRes.WithdrawReply, error) {
// Get Account from repository
account, err := s.accountRepo.GetAccountByID(ctx, payload.AccountID)
if err != nil {
return mRes.WithdrawReply{}, err
}
// Get User from repository
user, err := s.userRepo.GetUserByID(ctx, account.UserID)
if err != nil {
return mRes.WithdrawReply{}, err
}

// validate withdrawer is match with account owner
if userNameValid := validateUserName(user, payload.Withdrawer); !userNameValid {
return mRes.WithdrawReply{}, errors.New("Withdrawers' name is not match with accounts' owner.")
}

// validate account available
if accountValid := validateAccount(account); !accountValid {
return mRes.WithdrawReply{}, errors.New("Account is invalid, Please contact banks' staff.")
}

// validate withdraw amount
if withdrawAmountValid := validateWithdrawAmount(account, payload.Amount); !withdrawAmountValid {
return mRes.WithdrawReply{}, errors.New("Money amount in your account is not enough for transaction.")
}

newRemainAmount := account.AmountRemain - payload.Amount

// update account with new remain amount
err = s.accountRepo.UpdateRemainAmountByID(ctx, account.ID, newRemainAmount)
if err != nil {
return mRes.WithdrawReply{}, err
}

reply := mRes.WithdrawReply{
AccountID: account.ID,
UserName: user.Name,
WithdrawAmount: payload.Amount,
RemainAmount: newRemainAmount,
}

return reply, nil
}

func validateAccount(account mAccount.Account) bool {
return account.IsAvailable
}

func validateWithdrawAmount(account mAccount.Account, amount float64) bool {
return account.AmountRemain >= amount
}

func validateUserName(user mUser.User, name string) bool {
return user.Name == name
}

func NewTransactionService(accountRepo mRepo.AccountRepository, userRepo mRepo.UserRepository) mService.TransactionService {
return transactionService{
accountRepo: accountRepo,
userRepo: userRepo,
}
}

เอาจริงๆ ผมว่าใน layer นี้จะทำงานเยอะหน่อย คือเกือบทุกอย่างที่ไม่ใช่การรับส่ง request (Handler layer) และการจัดการฐานข้อมูล (Repository layer)

function Withdraw ก็คือการถอนที่พนักงานบัญชีต้องทำ

ถ้าตามตัวอย่างที่ผมยกข้างต้นไป layer นี้ก็เหมือนพนักงานบัญชีที่ต้องจัดการ process ทุกอย่างเกี่ยวกับการถอนเงินแล้วทำอะไรบ้างหล่ะ

  1. ไปถาม ฝ่ายเอกสาร(Repository layer) ว่ามีบัญชีของคนนี้รึเปล่าถ้าไม่มีก็ให้คืน error กลับไปว่าบัญชีไม่มีอยู่จริงหรือหากเกิดปัญหาอะไรจากฝ่ายเอกสารให้ส่งต่อไปที่หน้า counter บอกว่าบัญชีมีปัญหาไม่สามารถทำรายการได้
func (s transactionService) Withdraw(ctx context.Context, payload mHandler.WithdrawReqBody) (mRes.WithdrawReply, error) {
account, err := s.accountRepo.GetAccountByID(ctx, payload.AccountID)
if err != nil {
return mRes.WithdrawReply{}, err
}
// Get User from repository
user, err := s.userRepo.GetUserByID(ctx, account.UserID)
if err != nil {
return mRes.WithdrawReply{}, err
}
...
}

2. เมื่อได้ข้อมูลของบัญชีมาแล้วก็จะตรวจสอบว่าข้อมูลที่กรอกมาถูกต้องรึเปล่า

func (s transactionService) Withdraw(ctx context.Context, payload mHandler.WithdrawReqBody) (mRes.WithdrawReply, error) {
...
// validate withdrawer is match with account owner
if userNameValid := validateUserName(user, payload.Withdrawer); !userNameValid {
return mRes.WithdrawReply{}, errors.New("Withdrawers' name is not match with accounts' owner.")
}

// validate account available
if accountValid := validateAccount(account); !accountValid {
return mRes.WithdrawReply{}, errors.New("Account is invalid, Please contact banks' staff.")
}

// validate withdraw amount
if withdrawAmountValid := validateWithdrawAmount(account, payload.Amount); !withdrawAmountValid {
return mRes.WithdrawReply{}, errors.New("Money amount in your account is not enough for transaction.")
}
...
}

func validateAccount(account mAccount.Account) bool {
return account.IsAvailable
}

func validateWithdrawAmount(account mAccount.Account, amount float64) bool {
return account.AmountRemain >= amount
}

func validateUserName(user mUser.User, name string) bool {
return user.Name == name
}
  • ตรวจชื่อว่าชื่อตรงกับเจ้าของบัญชีรึเปล่าถ้าไม่ตรงให้ยกเลิกรายการแล้วแจ้งพนักงานเค้าท์เตอร์ว่าชื่อบัญชีกับผู้ถอนไม่ตรงกัน
  • ตรวจสอบว่าบัญชียังสามารถทำรายการได้อยู่มั้ย เช่นบัญชีถูกปิดหรืออายัด จะไม่สามารถทำรายการได้ เราก็จะแจ้งไปที่หน้าเค้าท์เตอร์เช่นกัน
  • ตรวจสอบว่าเงินในบัญชีพอให้ถอนหรือไม่ถ้าไม่พอให้ยกเลิก

3. หลังจากตรวจสอบแล้วว่าข้อมูลทุกอย่างสามารถทำรายการได้ เราก็จะทำการคำนวณเงินคงเหลือในบัญชีใหม่ แล้วส่งเรื่องไปให้ฝ่ายเอกสารอัปเดตข้อมูล

func (s transactionService) Withdraw(ctx context.Context, payload mHandler.WithdrawReqBody) (mRes.WithdrawReply, error) {
...
newRemainAmount := account.AmountRemain - payload.Amount

// update account with new remain amount
err = s.accountRepo.UpdateRemainAmountByID(ctx, account.ID, newRemainAmount)
if err != nil {
return mRes.WithdrawReply{}, err
}
...
}

4. แล้วหลังจากนั้นหล่ะ ก็ปั้นสารพร้อมเงินไปบอกพนักงานเค้าท์เตอร์ว่าเงินบัญชีไหนถูกถอนไปเท่าไรเหลือเท่าไหร่ เท่านี้ก็เป็นอันจบของขั้นตอน Service layer

func (s transactionService) Withdraw(ctx context.Context, payload mHandler.WithdrawReqBody) (mRes.WithdrawReply, error) {
...
reply := mRes.WithdrawReply{
AccountID: account.ID,
UserName: user.Name,
WithdrawAmount: payload.Amount,
RemainAmount: newRemainAmount,
}

return reply, nil
}

Repository Layer

หรืออีกชื่อนึงว่า Data resource layer ซึ่งชื่อก็บอกอยู่แล้วว่าจัดการเกี่ยวกับที่มาของข้อมูล ดังนั้นที่ layer นี้จะทำการรันคำสั่งที่เกี่ยวกับ database ไม่ว่าจะรับข้อมูลหรือเปลี่ยนแปลงข้อมูล

หรืออีก usercase นึงคือการใช้ protocal ต่างๆ เช่น http client ยิงไปเพื่อติดต่อรับส่งข้อมูลกับ service อื่น หรือ external อื่นๆ ก็จะอยู่ใน layer นี้เช่นกัน

จากตัวอย่างที่ผมได้เกริ่นไว้จะมี repository อยู่ 2 domain คือ user และ account

ซึ่งผมจะแยกเก็บไว้ใน mongodb collection

มาดู code กัน

// internal/repository/user.go
package repository

import (
"context"
"errors"

mRepo "github.com/petchill/n-tier-rest-api-golang/internal/model/repository"
mUser "github.com/petchill/n-tier-rest-api-golang/internal/model/user"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
)

type userRepository struct {
bankDB *mongo.Database
}

// GetUserByID implements user.Repository
func (r userRepository) GetUserByID(ctx context.Context, id int) (mUser.User, error) {
collection := r.bankDB.Collection("user")
user := mUser.User{}

query := bson.M{"id": id}
err := collection.FindOne(ctx, query).Decode(&user)
if err != nil {
if err == mongo.ErrNoDocuments {
return user, errors.New("Cannot find User by this id.")
}
return user, errors.New("Cannot find User, something wrong.")
}
return user, nil
}

func NewUserRepository(bankDB *mongo.Database) mRepo.UserRepository {
return userRepository{
bankDB: bankDB,
}
}
// internal/repository/account.go
package repository

import (
"context"
"errors"

mAccount "github.com/petchill/n-tier-rest-api-golang/internal/model/account"
mRepo "github.com/petchill/n-tier-rest-api-golang/internal/model/repository"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
)

type accountRepository struct {
bankDB *mongo.Database
}

// GetAccountByID implements repository.AccountRepository
func (r accountRepository) GetAccountByID(ctx context.Context, id string) (mAccount.Account, error) {
collection := r.bankDB.Collection("account")
account := mAccount.Account{}

query := bson.M{"id": id}
err := collection.FindOne(ctx, query).Decode(&account)
if err != nil {
if err == mongo.ErrNoDocuments {
return account, errors.New("Account is not found.")
}
return account, errors.New("Fail from account finding.")
}
return account, nil
}

// UpdateRemainAmountByID implements repository.AccountRepository
func (r accountRepository) UpdateRemainAmountByID(ctx context.Context, id string, remainAmount float64) error {
collection := r.bankDB.Collection("account")

query := bson.M{"id": id}
payload := bson.M{
"$set": bson.M{
"remain_amount": remainAmount,
},
}
err := collection.FindOneAndUpdate(ctx, query, payload).Err()

if err != nil {
return errors.New("Fail from account updating.")
}
return nil
}

func NewAccountRepository(bankDB *mongo.Database) mRepo.AccountRepository {
return accountRepository{
bankDB: bankDB,
}
}

หากไม่เข้าใจ syntax ของ mongodb ก็ไม่เป็นไรเพราะ layer ขึ้นอยู่กับ DB ที่แต่ละ service เลือกใช้ ให้เข้าใจว่าใน layer นี้จะทำงานหลักเกี่ยวกับ data

โดยฟังก์ชั่นมีความหมายดังนี้

  • GetUserByID → เอาข้อมูล user ที่ ID ตรงกับที่เลือก จาก collection user
  • GetAccountByID → เอาข้อมูล account ที่ ID ตรงกับที่เลือก จาก collection account
  • UpdateRemainAmountByID → update remain_amount ของ account เลือกจาก ID

ถ้าให้เทียบกับตัวอย่างธนาคารที่ผมยกตัวอย่าง layer นี้คือพนักงานเอกสารที่คอยหาข้อมูล บัยทึกข้อมูลให้พนักงานบัญชีครับ

Hierarchy

จะเห็นได้ว่า function ใน Repository layer จะถูกเรียกใช้ใน Service layer และ function ใน Service layer จะถูกเรียกใช้โดย Handler layer เป็นทอดๆกัน เพื่อป้องกัน circular dependency เราจะให้การ import module เป็นไปทางเดียวเสมอ กล่าวคือ

  • Handler จะเรียกให้ Service และ Repository ได้
  • Service จะเรียกให้ Repository ได้แต่เรียกใช้ Handler ไม่ได้
  • Repository ถูกใช้อย่างเดียว ไม่สามารถเรียกใช้ Handler กับ Service ได้

จริงๆแล้วระดับเดียวกันก็สามารถเรียกกันได้เช่น ServiceA เรียก ServiceB แต่ก็อาจจะเกิด circular dependency ได้เช่นกัน

วิธีที่ผมใช้จัดการ dependency แต่ละ layer คือ Dependency Injection

Dependency Injection

คือการฝังสิ่งที่จะใช้เข้ามาใน class หรือ struct ตอนที่ init เลย ซึ่งประโยชน์ของมันคือที่ให้สิ่งที่ใช้กับสิ่งที่ถูกใช้เป็นอิสระต่อกัน

ลองดูตัวอย่าง code ตอนที่ initial ทั้ง 3 layer เลยดีกว่า

แรกเริ่มเลยใน file main.go จะประกาศ Init ทุกอย่าง

// main.go
package main

import (
"context"
"fmt"
"log"
"net/http"
"os"

"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"

"github.com/joho/godotenv"
_handler "github.com/petchill/n-tier-rest-api-golang/internal/handler"
_repo "github.com/petchill/n-tier-rest-api-golang/internal/repository"
_service "github.com/petchill/n-tier-rest-api-golang/internal/service"
)

func main() {
ctx := context.Background()

godotenv.Load()

// init mongoกิ
mongoOpts := options.Client().ApplyURI(os.Getenv("MONGO_CONN"))
client, err := mongo.Connect(ctx, mongoOpts)
if err != nil {
log.Fatal("Cannot connect to mongodb client", err)
}
defer func() {
if err = client.Disconnect(context.TODO()); err != nil {
log.Fatal("Cannot disconnect to mongodb client", err)
}
}()
bankDB := client.Database(os.Getenv("BANK_DB"))

// init repository
userRepo := _repo.NewUserRepository(bankDB)
accountRepo := _repo.NewAccountRepository(bankDB)

// init service
transactionService := _service.NewTransactionService(accountRepo, userRepo)

// init handler
transactionHanler := _handler.NewTransactionHandler(transactionService)

http.HandleFunc("/withdraw", transactionHanler.WithdrawHandler)
fmt.Println("server start on port 5000 ...")
log.Fatal(http.ListenAndServe(":5000", nil))
}

จะเห็นว่าในการสร้าง repository _repo.NewUserRepository จะใส่ parameter bankDB ไปด้วย เพราะว่า repository เป็นส่วนที่ต้องเชื่อมต่อกับ DB เลยต้องฝัง DB เข้าไปใน struct เพื่อจะได้ไม่ต้อง import ใหม่ทุกครั้งที่เรียกใช้ หรือ connect DB ใหม่ทุกครั้งที่ init repository

ใน code ด้านบนจะเห็นว่า userRepo และ accountRepo ไม่ต้อง connect DB ใหม่ใน function NewUserRepository และ NewAccountRepository แต่ใช้ bankDB ตัวเดียวกันเลย

ที่นี้ลองมาดู code ของ NewUserRepository

// internal/repository/user.go
package repository

import (
...
mRepo "github.com/petchill/n-tier-rest-api-golang/internal/model/repository"
mUser "github.com/petchill/n-tier-rest-api-golang/internal/model/user"
...
)

type userRepository struct {
bankDB *mongo.Database
}

// GetUserByID implements user.Repository
func (r userRepository) GetUserByID(ctx context.Context, id int) (mUser.User, error) {
collection := r.bankDB.Collection("user")
...
}

func NewUserRepository(bankDB *mongo.Database) mRepo.UserRepository {
return userRepository{
bankDB: bankDB,
}
}

จะเห็นได้ว่าใน NewUserRepository เรา return struct userRepository ออกไปเพื่อให้ส่วนที่ใช้ repository นี้ เช่น service เรียกใช้ function ใน repository นี้ได้

และจะเห็นได้ว่าใน function GetUserByID จะใช้ r.bankDB ที่เราได้ฝังไว้ใน struct ตอนสร้าง

ซึ่ง return parameter ที่เราประกาศไว้คือ mRepo.UserRepository อันนี้คือ interface ของ userRepository

// internal/model/repository/user.go
package repository

import (
"context"

mUser "github.com/petchill/n-tier-rest-api-golang/internal/model/user"
)

type UserRepository interface {
GetUserByID(ctx context.Context, id int) (mUser.User, error)
}

ในทุก Layer เราจะใช้ interface แทนการใช้ struct ตรงๆ เพื่อให้ง่ายต่อการเทสและการใช้ โดย struct จะต้องมี function ตามที่ interface ต้องการ

ลองมาดูในส่วนของ service กันบ้าง

// internal/service/transaction.go
package service

import (
...
mAccount "github.com/petchill/n-tier-rest-api-golang/internal/model/account"
mHandler "github.com/petchill/n-tier-rest-api-golang/internal/model/handler"
mRepo "github.com/petchill/n-tier-rest-api-golang/internal/model/repository"
mRes "github.com/petchill/n-tier-rest-api-golang/internal/model/response"
mService "github.com/petchill/n-tier-rest-api-golang/internal/model/service"
...
)

type transactionService struct {
accountRepo mRepo.AccountRepository
userRepo mRepo.UserRepository
}

// Withdraw implements service.TransactionService
func (s transactionService) Withdraw(ctx context.Context, payload mHandler.WithdrawReqBody) (mRes.WithdrawReply, error) {
// Get Account from repository
account, err := s.accountRepo.GetAccountByID(ctx, payload.AccountID)
if err != nil {
return mRes.WithdrawReply{}, err
}
...
}

...

func NewTransactionService(accountRepo mRepo.AccountRepository, userRepo mRepo.UserRepository) mService.TransactionService {
return transactionService{
accountRepo: accountRepo,
userRepo: userRepo,
}
}

เช่นกันใน NewTransactionService ก็จะใส่ parameter มาเป็น interface ของ AccountRepository และ UserRepository

เวลาเรียกใช้ repository ก็ใช้ s.accountRepo.GetAccountByID ดั่งใน function Withdraw

parameter ที่ return ออกไปจาก NewTransactionService ก็เป็น interface ของ service เช่นกัน

Summary

สรุปแล้ว n-tier architecture ก็เป็น design pattern นึงที่ผมคิดว่าค่อนข้าง clean และเป็นระเบียบโดยแต่ละคนอาจจะแบ่ง tier ไม่เหมือนกัน

ส่วนผมแบ่ง tier ของ REST API Application ออกเป็น 3 tier คือ

  • Handler Layer → หน้าที่คือรับส่ง request, validate req body และต่างๆที่เกี่ยวกับทางเข้าทางออก
  • Service Layer → หน้าที่คือ processing ต่างๆที่เกี่ยวกับ business logic
  • Repository Layer → หน้าที่คือ ติดต่อรับส่งข้อมูลกับ database หรือ data resource อื่นๆ เช่น http client ไปยิง service อื่น

การเรียกใช้ layer จะเรียกใช้ทางเดียวไม่มีการเรียกย้อนกลับเพื่อป้องกัน circular dependency เช่น

  • Handler จะเรียกให้ Service และ Repository ได้
  • Service จะเรียกให้ Repository ได้แต่เรียกใช้ Handler ไม่ได้
  • Repository ถูกใช้อย่างเดียว ไม่สามารถเรียกใช้ Handler กับ Service ได้

และใช้ dependency injection ในการสร้าง struct แต่ละ layer เพื่อการเรียกใช้จะได้ไม่ต้องสร้าง struct ใหม่หรือ import ใหม่

Thank you all

เนื่องจากเป็นบทความที่เขียนเป็นครั้งแรกถ้าข้อมูลอะไรผิดพลาดหรืออธิบายไม่เข้าใจก็ขออภัยด้วยนะครับ ^^

อีกอย่างคือขอบคุณ Finnomena ที่ๆผมทำงานเพราะ pattern พวกนี้ผมก็ได้เริ่มเรียนรู้จาก project ที่ทำงาน

สามารถอ่าน blog อื่นๆเพิ่มเติมได้ที่ https://blog.finnomena.com/

Reference

Source Code

https://github.com/petchill/n-tier-rest-api-golang

--

--