Trong bài viết này mình sẽ cung cấp một ví dụ cụ thể cho việc vận dụng nguyên tắc và tư duy Clean Architecture cho một service REST API Golang.
Trên thực tế, các Golang service sẽ không nhất thiết phải sử dụng bất kỳ một kiến trúc nào cả. Bài viết này cũng chỉ là một ví dụ và không phải một best practice.
Các vấn đề hiện tại của source code TODO List
package main
import (
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
type ToDoItem struct {
Id int `json:"id" gorm:"column:id;"`
Title string `json:"title" gorm:"column:title;"`
Status string `json:"status" gorm:"column:status;"`
CreatedAt *time.Time `json:"created_at" gorm:"column:created_at;"`
UpdatedAt *time.Time `json:"updated_at" gorm:"column:updated_at;"`
}
func (ToDoItem) TableName() string { return "todo_items" }
func main() {
// Checking that an environment variable is present or not.
mysqlConnStr, ok := os.LookupEnv("MYSQL_CONNECTION")
if !ok {
log.Fatalln("Missing MySQL connection string.")
}
dsn := mysqlConnStr
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatalln("Cannot connect to MySQL:", err)
}
log.Println("Connected to MySQL:", db)
router := gin.Default()
v1 := router.Group("/v1")
{
v1.POST("/items", createItem(db)) // create item
v1.GET("/items", getListOfItems(db)) // list items
v1.GET("/items/:id", readItemById(db)) // get an item by ID
v1.PUT("/items/:id", editItemById(db)) // edit an item by ID
v1.DELETE("/items/:id", deleteItemById(db)) // delete an item by ID
}
router.Run()
}
func createItem(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var dataItem ToDoItem
if err := c.ShouldBind(&dataItem); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// preprocess title - trim all spaces
dataItem.Title = strings.TrimSpace(dataItem.Title)
if dataItem.Title == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "title cannot be blank"})
return
}
// do not allow "finished" status when creating a new task
dataItem.Status = "Doing" // set to default
if err := db.Create(&dataItem).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": dataItem.Id})
}
}
func readItemById(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var dataItem ToDoItem
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := db.Where("id = ?", id).First(&dataItem).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": dataItem})
}
}
func getListOfItems(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
type DataPaging struct {
Page int `json:"page" form:"page"`
Limit int `json:"limit" form:"limit"`
Total int64 `json:"total" form:"-"`
}
var paging DataPaging
if err := c.ShouldBind(&paging); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if paging.Page <= 0 {
paging.Page = 1
}
if paging.Limit <= 0 {
paging.Limit = 10
}
offset := (paging.Page - 1) * paging.Limit
var result []ToDoItem
if err := db.Table(ToDoItem{}.TableName()).
Count(&paging.Total).
Offset(offset).
Order("id desc").
Find(&result).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": result})
}
}
func editItemById(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var dataItem ToDoItem
if err := c.ShouldBind(&dataItem); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := db.Where("id = ?", id).Updates(&dataItem).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": true})
}
}
func deleteItemById(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := db.Table(ToDoItem{}.TableName()).
Where("id = ?", id).
Delete(nil).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": true})
}
}
Service TODO List bao gồm 5 REST API cơ bản cho CRUD (Creat-Read-Update-Delete), tương đương với 5 nghiệp vụ (5 business logic). Tất cả đặt hết trong một file main.go
.
Chúng ta dễ dàng nhìn ra được:
- Không có sự phân chia và tổ chức source code, không sử dụng các package hỗ trợ.
- Mỗi GIN handler method (hàm xử lý HTTP request của Gin) là một khối code đảm trách từ nhận các data từ request, kiểm tra tính hợp lệ, thao tác với DB và trả về client với JSON Format.
Hệ quả là:
- Khi muốn thay đổi logic hoặc DB chẳng hạn thì buộc phải viết lại cả handler.
- Rủi ro bị conflict code khi team work là rất cao vì code ở một file duy nhất.
- Không thể hiện Unit Test các logic của nghiệp vụ, cách duy nhất là phải run MySQL và service lên rồi thực hiện các HTTP Request để kiểm tra. Việc này gây lãng phí thời gian và tài nguyên.
Ứng dụng các nguyên lý của Clean Architecture vào service Golang
Thiết kế các tầng phù hợp
Áp dụng nguyên tắc độc lập các tầng và "hướng vào trong" của Clean Architecture nhưng với sự đơn giản tối thiểu:
- Transport: nơi tiếp nhận các HTTP Request từ Client, parse data nếu có, trả về đúng format JSON cho client. Ngoài nhiệm vụ này ra, những cái còn lại nó sẽ "uỷ thác" (delegate) cho tầng Business.
- Business: nơi thực hiện các logic chính của nghiệp vụ như cái tên của nó. Các thuật toán, logic sẽ cần dữ liệu. Tầng Business sẽ không trực tiếp đi lấy dữ liệu mà tiếp tục "uỷ thác" cho tầng Repository hoặc Storage.
- Repository (optinal): Trong hầu hết các trường hợp thực tế mình sẽ dùng tầng Repository để cung cấp data cho tầng Business. Sự khác biệt là Repository sẽ phải tổng hợp đầy đủ data cần thiết từ nhiều nơi (các mối quan hệ dữ liệu) chứ không phải từng object riêng lẻ. Cấu trúc dữ liệu cũng sẽ được tầng này convert/transform phù hợp nhất.
- Storage: là tầng chịu trách nhiệm lưu trữ và truy xuất dữ liệu. Dù các bạn đang dùng hệ thống Database, File System hay Remote API thì cũng là ở tầng này. Đây là nơi cần viết code chi tiết cách thức giao tiếp với các đầu cung cấp dữ liệu.
Phân bổ thư mục và package cho service
Dù cả service chỉ có tập trung vào TODO Item (tạm gọi là một module) nhưng có khả năng tương lai sẽ có thêm các modules khác nữa. Mình sẽ xem TODO Item chỉ là một trong số những module sẽ có trong service. Từ đó mình đề xuất cách chia thư mục và package như sau:
Như vậy có thể hiểu rằng mỗi module dù là gì cũng có 3 tầng (layer) đúng theo thiết kế.
Ở đây các bạn có thể thắc mắc rằng vì sao lại có thư mục "model" trong mỗi module?! Đơn giản là mình nỗ lực để các module độc lập với nhau và sẽ tiện để tách thành các microservice trong tương lai.
Demo ứng dụng Clean Architecture cho service TODO List Golang
Để đơn giản và đầy đủ nhất thì mình chọn API Create Item để minh hoạ kiến trúc.
Mình sẽ ứng dụng Clean Architecture cho từng API Handler chứ không phải cả service. Cách này sẽ đơn giản hoá việc tổ chức các tầng (layer) hơn so với thực hiện full cho service. Vì thế đây chỉ là sự tham khảo nha các bạn.
Thiết lập và quản lý model data
Bước đầu, cũng là bước dễ nhất, chúng ta đưa model ToDo Item vào đúng nơi của nó:
Tạo file module/item/model/item.go
:
Tên package của Golang có convention là chữ thường, viết liền hết và nên ở dạng rút gọn (VD: fmt
là format). Mình có thể đặt là todomdl
nhưng viết đầy đủ todomodel
thì dễ hiểu hơn.
Xây dựng tầng business
Có model rồi thì các bạn có thể thiết lập các tầng khác dễ dàng hơn. Mình chọn Business vì có nhiều cái để nói ở đây.
Tạo file module/item/biz/create_new_item.go
:
package todobiz
import (
"context"
"errors"
todomodel "first-app/module/item/model"
)
type CreateTodoItemStorage interface {
CreateItem(ctx context.Context, data *todomodel.ToDoItem) error
}
type createBiz struct {
store CreateTodoItemStorage
}
func NewCreateToDoItemBiz(store CreateTodoItemStorage) *createBiz {
return &createBiz{store: store}
}
func (biz *createBiz) CreateNewItem(ctx context.Context, data *todomodel.ToDoItem) error {
if data.Title == "" {
return errors.New("title can not be blank")
}
// do not allow "finished" status when creating a new task
data.Status = "Doing" // set to default
if err := biz.store.CreateItem(ctx, data); err != nil {
return err
}
Đầu tiên chúng phải có một struct createBiz
để bao đóng (encapsulation) các thuộc tính và phương thức. Tầng Business không nên biết chi tiết Storage nên chỉ cần giao tiếp qua Interface CreateTodoItemStorage
.
Interface này sẽ được truyền vào ở hàm NewCreateToDoItemBiz
. Cuối cùng là method CreateNewItem
để thực công việc chính của tầng business.
Ở đây có vài điểm cần lưu ý:
- Áp dụng nguyên tắc "Interface dùng ở đâu thì khai báo ở đó". Nếu bạn vẫn chưa nắm điều này thì có thể xem lại bài viết dưới đây.
- Interface storage chỉ có một hàm duy nhất trong trường hợp này. Trong thực tế có thể có nhiều hàm hơn hoặc có nhiều Interfaces hơn trong một Business.
- Business chỉ thực hiện đúng nhiệm vụ các logic của chính nó rồi uỷ thác xuống tầng Storage. Business lúc này sẽ độc lập với Storage nên về sau rất dễ triển khai các Unit Test ở tầng này.
- Sự xuất hiện của tham số
context.Context
chỉ là sự tham khảo và không cần thiết lúc này. Các bạn có thể loại bỏ nó. Nhưng hãy giữ lạicontext
vì sẽ nó rất có lợi về sau. 200Lab sẽ còn các bài viết liên quan đếncontext
trong tương lai. - Vì mục đích encapsulate Business nên thuộc tính
store
mình khai báoprivate
. Cả cái structcreateBiz
cũng nênprivate
. Các bạn yên tâm rằng methodCreateNewItem
vẫnpublic
nha. - Cuối cùng hãy nhớ
return error
khi có giao tiếp vớiI/O
các bạn nhé. Nếu bỏ qua, sau này các bạn không biết cái lỗi nó từ đâu chui ra. Để quản lý error trong Golang, 200Lab cũng sẽ còn chia sẻ tiếp trong các bài khác.
Và đó là toàn bộ code của tầng Business dành cho API Create New Item. Các bạn không cần phải code tầng Storage mà vẫn hoàn thiện được tầng Business. Đó chính là sức mạnh của trừu tượng hoá.
Xây dựng tầng Storage
Tầng này sẽ là code chi tiết giao tiếp xuống hệ thống cơ sở dữ liệu, trong ví dụ này là MySQL với thư viện GORM.
Tầng Storage mình cũng encapsulation tất cả vào một stuct. Vì thế sẽ cần tạo ít nhất 1 file module/item/storage/mysql_storage.go
:
package todostorage
import "gorm.io/gorm"
type mysqlStorage struct {
db *gorm.DB
}
func NewMySQLStorage(db *gorm.DB) *mysqlStorage {
return &mysqlStorage{db: db}
}
Lý do cho việc khai báo file mysql_storage.go
như trên là để encapsulation struct mysqlStorage
. Tất cả các method giao tiếp xuống table todo_items
sẽ do struct này phụ trách.
Tạo file module/item/storage/insert_new_item.go
:
package todostorage
import (
"context"
todomodel "first-app/module/item/model"
)
func (s *mysqlStorage) CreateItem(ctx context.Context, data *todomodel.ToDoItem) error {
if err := s.db.Create(data).Error; err != nil {
return err
}
return nil
}
Các bạn có để ý điểm mấu chốt ở đây không? Đó chính là method CreateItem(ctx context.Context, data *todomodel.ToDoItem) error
giống y chang method trong Storage Interface ở tầng Business.
Theo nguyên tắc implement Interface ngầm định của Golang, struct mysqlStorage
đã implement CreateTodoItemStorage
Interface mà không cần phải import hay khai báo tường minh. Một lần nữa, đây là đặc tính giúp Golang rất linh hoạt khi dùng Interface.
Xây dựng tầng Transport
Đây là tầng cuối cùng còn lại của kiến trúc 200Lab đề xuất. Trong giới hạn của bài viết này, tầng Transport có 2 nhiệm vụ chính:
- Tiếp nhận, xử lý, parse dữ liệu từ các HTTP Request để đưa về đúng dữ liệu tầng Business có thể dùng được. Tuyệt đối không truyền thẳng các object HTTP Request vào Business vì rất có thể sau này Business được gọi từ Transport khác hoặc nội bộ service (Internally).
- Setup toàn bộ các dependencies cho các tầng bên trong: Business, Repository, Storage. Hãy nhớ chúng ta vẫn đang dùng kiến trúc cho từng API các bạn nhé.
Tạo file module/item/transport/handle_create_new_item.go
:
package todotrpt
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
todobiz "first-app/module/item/business"
todomodel "first-app/module/item/model"
todostorage "first-app/module/item/storage"
)
func HanleCreateItem(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var dataItem todomodel.ToDoItem
if err := c.ShouldBind(&dataItem); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// preprocess title - trim all spaces
dataItem.Title = strings.TrimSpace(dataItem.Title)
// setup dependencies
storage := todostorage.NewMySQLStorage(db)
biz := todobiz.NewCreateToDoItemBiz(storage)
if err := biz.CreateNewItem(c.Request.Context(), &dataItem); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": dataItem.Id})
}
}
Hoàn thiện kiến trúc và kết nối để thực thi
Bước còn lại chỉ là thay đổi lại file main.go
đại khái như sau:
//...
router := gin.Default()
v1 := router.Group("/v1")
{
v1.POST("/items", todotrpt.HanleCreateItem(db)) // updated
//...
}
Lời kết
Như vậy là chúng ta đã hoàn tất việc vận dụng lý thuyết Clean Architecture để xây dựng kiến trúc phù hợp cho một service REST API đơn giản. Liệu các bạn có thể viết tiếp 4 API còn lại được không? Hãy thử sức xem sao các bạn nhé!!
Toàn bộ source code đã apply Clean Architecture cho cả 5 APIs các bạn có thể tham khảo tại đây.
Bài viết này cũng chính là một trong những nội dung 200Lab hướng dẫn rất kỹ trong khoá học Golang for Scalable Backend. Nếu bạn cảm thấy cần hướng dẫn hoặc chia sẻ thêm thì có thể cân nhắc tham gia nhé.
Để tiện theo dõi bài viết này, bạn sẽ cần biết thêm:
- Source code REST API TODO List (phiên bản không kiến trúc) hoặc full tutorial tại đây.
- Kiến thức Clean Architecture - Cách vận dụng hiệu quả
- Sử dụng Interface Golang.
Việt Trần
Yêu thích tìm hiểu các công nghệ cốt lõi, kỹ thuật lập trình và thích chia sẻ chúng tới cộng đồng
follow me :
Bài viết liên quan
Design Pattern là gì? 23 Classic Design Pattern với Golang
Aug 21, 2024 • 3 min read
Concurrency pattern trong Go - Phần 2
Apr 11, 2024 • 7 min read
Concurrency pattern trong Golang - Phần 1
Mar 25, 2024 • 8 min read
So sánh Golang và NodeJS chi tiết
Oct 14, 2023 • 22 min read
Buffered Channel là gì? Thường xuyên hỏi trong phỏng vấn Golang Dev
Jun 14, 2023 • 6 min read
Golang Channel là gì? Các ứng dụng Channel trong Golang
Jun 14, 2023 • 12 min read