Golang Clean Architecture with gin and gorm
หลังจากที่ผมได้ลองศึกษาภาษา Go มาสักพักนึง ดู gin framework มาเล็กน้อย และได้ลองใช้ gorm มาบ้าง คราวนี้ก็ลองมาดู concept Clean Arcitecture ซึ่งเสนอโดย Robert C. Martin (Uncle Bob) มาใช้ implement กับ project นี้ โดยผมได้ศึกษาตัวอย่างมาจาก https://amitshekhar.me/blog/go-backend-clean-architecture แต่ส่วนที่เป็น database ผมจะเปลี่ยนจาก mongodb มาใช้ gorm และ mysql แทนครับ
หลักการของ Clean Architecture
จากรูปนี้ วงกลมที่อยู่ข้างในสุดจะเป็นที่รวบรวม business rule นั่นคือ Entities และวงกลมที่อยู่ข้างนอกจะเป็นการทำงานที่เชื่อมต่อกับระบบ เช่น web, database หรือ API อื่นๆ โดยหลักการของ dependency คือ วงกลมที่อยู่ข้างนอกจะขึ้นอยู่กับวงกลมที่อยู่ข้างในเรียงเป็นชั้นๆ ลงไป ดังนั้น สิ่งใดก็ตามที่ทำที่วงกลมข้างนอก จะต้องไม่กระทบกับโค้ดที่เขียนอยู่ในวงกลมข้างในเลย
Clean Architecture มีหลักการดังนี้
- Independent of Frameworks Architecture ต้องเป็นอิสระจาก frame และ library ซึ่งหลักการข้อนี้จะทำให้ระบบของเราใช้ frameworkในฐานะเครื่องมือ ไม่ใช่เป็นข้อบังคับให้เราต้องทำ เมื่อเราพบว่า framework ที่เราใช้อยู่นั้นไม่เหมาะสม และมีตัวอื่นที่เหมาะสมกับงานของเรามากกว่า เราก็สามารถเปลี่ยนไปใช้ตัวอื่นได้โดยไม่กระทบกับ business logic
- Testable เราต้องสามารถ test business rule โดยต้องไม่ขึ้นอยู่กับ UI, database, web หรือ library ภายนอกอื่นๆ
- Independent of UI หลักการนี้ UI ต้องเปลี่ยนแปลงได้ง่ายโดยไม่กระทบกับ business rule เช่น การเปลี่ยน return type จาก JSON เป็น csv ก็ไม่ควรต้องแก้ business logic
- Independent of Database เราสามารถเปลี่ยน database เป็น MySql, Oracle หรือ MongoDB ได้โดยไม่กระทบกับ business rule
- Independent of any external agency ตัว business rule ไม่รู้อะไรจากภายนอกเลย นั่นคือตัว business จะสนใจเฉพาะงานของมันเท่านั้น
Golang Clean Architecture
จะแบ่ง 5 layer ด้วยกัน
- Router ใช้สำหรับรับ request ทั้งหมด
- Controller ใช้ validate request และ return model
- Usecase จัดการเรื่อง business logic
- Repository ใช้ในการจัดการกับ database
- Domain/Model/Entity เป็น layer ที่ดูเรื่อง model, entity และเป็น interface สำหรับ Usecase และ Repository
ลูกศรที่ชี้แสดงถึง dependency นั่นคือ Controller จะรู้จัก Router, Usecase, Domain และ Model หรือ Entity
Usecase ก็จะรู้จัก Repository และ Model
Repository ก็จะรู้จัก database ที่เป็น external และ Entity
โดยผมจะอธิบายรายละเอียดทีละ layer จากตัวอย่าง project ที่ทดลองทำขึ้นมาครับ
project นี้ใช้สำหรับทำ centralize configuration ซึ่งใช้ concept จาก spring cloud โดยให้มี micro service นึงใช้สำหรับเก็บค่า configuration ของ micro service ต่างๆ ซึ่งทำให้เกิดความสะดวกเวลานำไป deploy บน environment ต่างๆ ซึ่งตัว feature ของ project มีเพียงแค่อ่านค่า property สำหรับ config เท่านั้น
Router
Router มีหน้าที่รับ request และเรียก Controller
package route
import (
"time"
"github.com/Piyawat-T/go-centralize-configuration/bootstrap"
"github.com/gin-gonic/gin"
)
func Setup(env *bootstrap.Env, timeout time.Duration, db bootstrap.Database, route *gin.RouterGroup) {
publicRouter := route.Group("")
PropertiesRouter(env, timeout, db, publicRouter)
}
Controller ขึ้นอยู่กับ Usecase และ Usecase ขึ้นอยู่กับ Repository
package route
import (
"time"
"github.com/Piyawat-T/go-centralize-configuration/api/controller"
"github.com/Piyawat-T/go-centralize-configuration/bootstrap"
"github.com/Piyawat-T/go-centralize-configuration/domain"
"github.com/Piyawat-T/go-centralize-configuration/repository"
"github.com/Piyawat-T/go-centralize-configuration/usecase"
"github.com/gin-gonic/gin"
)
func PropertiesRouter(env *bootstrap.Env, timeout time.Duration, db bootstrap.Database, group *gin.RouterGroup) {
repo := repository.NewPropertiesRepository(db, domain.CollectionProperties)
cont := controller.PropertiesController{
PropertiesUsecase: usecase.NewPropertiesUsecase(repo, timeout),
Env: env,
}
group.GET("/:application/:profile", cont.GetConfiguration)
}
Controller
Controller จะ validate data และเรียกใช้งาน Usecase
package controller
import (
"net/http"
"github.com/Piyawat-T/go-centralize-configuration/bootstrap"
"github.com/Piyawat-T/go-centralize-configuration/domain"
"github.com/gin-gonic/gin"
)
type PropertiesController struct {
PropertiesUsecase domain.PropertiesUseCase
Env *bootstrap.Env
}
func (controller *PropertiesController) GetProperties(c *gin.Context) {
properties, err := controller.PropertiesUsecase.GetProperties(c)
if err != nil {
c.JSON(http.StatusInternalServerError, domain.ErrorResponse{Message: err.Error()})
return
}
c.JSON(http.StatusOK, properties)
}
func (controller *PropertiesController) GetConfiguration(c *gin.Context) {
application := c.Param("application")
profile := c.Param("profile")
properties, err := controller.PropertiesUsecase.GetByApplicationAndProfile(c, application, profile)
if err != nil {
c.JSON(http.StatusInternalServerError, domain.ErrorResponse{Message: err.Error()})
return
}
c.JSON(http.StatusOK, properties)
}
Usecase
Usecase จะเป็นที่สำหรับเขียน business logic และเรียกใช้งาน repository
package usecase
import (
"context"
"time"
"github.com/Piyawat-T/go-centralize-configuration/domain"
)
type propertiesUsecase struct {
propertiesRepository domain.PropertiesRepository
contextTimeout time.Duration
}
func NewPropertiesUsecase(propertiesRepository domain.PropertiesRepository, timeout time.Duration) domain.PropertiesUseCase {
return &propertiesUsecase{
propertiesRepository: propertiesRepository,
contextTimeout: timeout,
}
}
func (usecase *propertiesUsecase) GetProperties(c context.Context) ([]domain.Properties, error) {
ctx, cancel := context.WithTimeout(c, usecase.contextTimeout)
defer cancel()
properties, err := usecase.propertiesRepository.Fetch(ctx)
if err != nil {
return nil, err
}
return properties, nil
}
func (usecase *propertiesUsecase) GetByApplicationAndProfile(c context.Context, application string, profile string) ([]domain.Properties, error) {
ctx, cancel := context.WithTimeout(c, usecase.contextTimeout)
defer cancel()
properties, err := usecase.propertiesRepository.FetchByApplicationAndProfile(ctx, application, profile)
if err != nil {
return nil, err
}
return properties, nil
}
Repository
Repository จะ independent กับ database รวมถึง ORM library ต่างๆ ซึ่งมีหน้าที่ในการ call database ผ่าน interface
package repository
import (
"context"
"github.com/Piyawat-T/go-centralize-configuration/bootstrap"
"github.com/Piyawat-T/go-centralize-configuration/domain"
)
type propertiesRepository struct {
database bootstrap.Database
collection string
}
func NewPropertiesRepository(db bootstrap.Database, collection string) domain.PropertiesRepository {
return &propertiesRepository{
database: db,
collection: collection,
}
}
func (ur *propertiesRepository) Fetch(c context.Context) ([]domain.Properties, error) {
var properties []domain.Properties
err := ur.database.Find(&properties)
return properties, err
}
func (repository *propertiesRepository) FetchByApplicationAndProfile(c context.Context, application string, profile string) ([]domain.Properties, error) {
var properties []domain.Properties
err := repository.database.Find(&properties, "application = ? AND profile = ?", "deposit", "default")
return properties, err
}
Domain
Domain จะเป็น layer ที่ประกอบไปด้วยสิ่งต่างๆ เหล่านี้
- Model สำหรับ request และ response
- Entity สำหรับ database
- interface สำหรับ usecase และ repository
package domain
import "context"
const (
CollectionProperties = "properties"
)
type Properties struct {
Id uint `json:"id"`
Application string `json:"application"`
Profile string `json:"profile"`
Key string `json:"key"`
Value string `json:"value"`
}
type PropertiesRepository interface {
Fetch(c context.Context) ([]Properties, error)
FetchByApplicationAndProfile(c context.Context, application string, profile string) ([]Properties, error)
}
type PropertiesUseCase interface {
GetProperties(c context.Context) ([]Properties, error)
GetByApplicationAndProfile(c context.Context, application string, profile string) ([]Properties, error)
}
ซึ่งในบทความนี้ผมจะยังไม่รวมถึงเรื่องการ test และการ mock การตั้งค่า database โดยจะกล่าวถึงต่อไปในอนาคต ท่านผู้อ่านสามารถไปดู link project ได้ที่นี่ครับ go centralize configuration