Facebook Pixel

Ứng dụng Clean Architecture cho service Golang REST API

13 Jun, 2023

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

Ứng dụng Clean Architecture cho service Golang REST API

Mục Lục

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

Go
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.

Một hàm đảm trách nhiều việc và dính chùm với nhau

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 MySQLservice 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:

Ba tầng đơn giản: Transport, Business và Storage
  • 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:

Folder Structure đề xuất cho TODO List service

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:

Go
package todomodel

import "time"

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" }
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:

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.
Golang Interface - Những lỗi sai thường gặp và giải pháp
Liệu bạn đã dùng Golang Interface đúng cách và hiệu quả? Bài viết này mình sẽ hướng dẫn chi tiết để các bạn dễ dàng sử dụng trong các service Golang nhé
Golang Interface - Những lỗi sai thường gặp và giải pháp
  • 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ại context vì sẽ nó rất có lợi về sau. 200Lab sẽ còn các bài viết liên quan đến context trong tương lai.
  • Vì mục đích encapsulate Business nên thuộc tính store mình khai báo private. Cả cái struct createBiz cũng nên private. Các bạn yên tâm rằng method CreateNewItem vẫn public nha.
  • Cuối cùng hãy nhớ return error khi có giao tiếp với I/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:

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:

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:

  1. 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).
  2. 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:

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:

Go
//...
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.

Folder structure Clean Architecture Golang REST API

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:

Bài viết liên quan

Lập trình backend expressjs

xây dựng hệ thống microservices
  • Kiến trúc Hexagonal và ứng dụngal font-
  • TypeScript: OOP và nguyên lý SOLIDal font-
  • Event-Driven Architecture, Queue & PubSubal font-
  • Basic scalable System Designal font-

Đăng ký nhận thông báo

Đừng bỏ lỡ những bài viết thú vị từ 200Lab