Bản dịch của bài viết "Trying Clean Architecture on Golang" của tác giả Iman Tumorang, người Indonesia. Một số đoạn được biên tập để bạn đọc dễ hiểu hơn.
Sau khi đọc bài viết "Clean Architecture Concept" của bác Bob, (một chuyên gia về kiến trúc phần mềm), tôi thử thực hiện theo bằng cách áp dụng với Golang. Kiến trúc phần mềm của công ty tôi, Kurio - App Berita Indonesia cũng khá giống với đề xuất của bác Bob, có khác chút xíu về cấu trúc thư mục thôi.
Bạn có thể tham khảo dự án mẫu tôi đã đẩy lên Github ở đây https://github.com/bxcodec/go-clean-arch . Đây là ví dụ ứng dụng quản lý bài viết có thao tác: Create - Read - Update - Delete, gọi tắt là CRUD.
Chú ý: tôi không khuyến nghị bất kỳ một thư viện hay khung mẫu nào ở bài viết này. Bạn có thể thay thế bất kỳ thành phần gì bằng thư viện riêng của bạn hay của người khác miễn chúng có cùng chức năng.
Kiến trúc sạch (Clean Architecture) có những ràng buộc như sau:
Tìm đọc chi tiết hơn về đề xuất kiến trúc sạch của bác Bob ở đây https://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.html
Dựa trên các ràng buộc nói trên, mỗi lớp (layer) cần phải độc lập và kiểm thử riêng biệt được. Bác Bob đề xuất 4 layer:
Trong dự án của tôi, tôi dùng 4 lớp (layer):
Để các bạn dễ hình dung, tôi chuyển diagram tác gia minh hoạ lên đầu
Giống như Entities, được sử dụng ở tất cả các thành phần còn lại. Thành phần này sẽ lưu trữ thuộc tính các đối tượng nghiệp vụ và các phương thức đi cùng. Ví dụ: Article, Student, Book.
Ví dụ kiểu struct của Article. Golang không có khái niệm class
import "time"
type Article struct {
ID int64 `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
}
Tất cả các thể hiện bản ghi trong cơ sở dữ liệu (entity), đối tượng mô hình dữ liệu (model) sẽ được lưu ở đây.
Repository sẽ hoạt động (tác động) vào ra cơ sở dữ liệu: truy vấn (query), tạo (create / insert), cập nhật (update), xoá (delete). Repository sẽ thao tác CRUD vào cơ sở dữ liệu thôi, chứ không có liên quan đến các quy luật nghiệp vụ. Bổ xung từ người dịch trong kiến trúc microservice, repository có thể nối sang các microservice để lấy dữ liệu. Cách này người ta gọi là aggregate data.
Repository phụ thuộc vào từng database cụ thể: MySQL, MongoDB, MariaDB, Postgresql. Nó sử dụng thư viện database driver để kết nối. Nếu bạn dùng ORM (Object Relational Mapping) thì bạn đẩy sự phụ thuộc vào CSDL cụ thể sang cho thư viện ORM.
Nếu repository gọi tới microservice: nó tạo ra HTTP request, đưa dữ liệu vào request hoặc lấy dữ liệu ra khỏi response. Repository cần tập trung vào gửi nhận dữ liệu chứ không bận tâm đến quy luật nghiệp vụ cụ thể nào cả.
Thành phần này chứa logic thực hiện quy luật nghiệp vụ. Các tiến trình xử lý nghiệp vụ được viết ở đây. Thành phần này sẽ quyết định dùng respository nào và có trách nhiệm trả về dữ liệu cho Delivery:
Thành phần này là phần trình bày, quyết định xem dữ liệu được trả về cho người dùng thế nào. Dữ liệu có thể ở dạng:
Delivery cũng nhận dữ liệu người dùng nhập. Nó sẽ lọc chặn (sanitize) những dữ liệu theo quy luật căn bản phổ biến kiểu như (so sánh password với re-password, kiểm tra email hợp lệ, độ dài trường text). Không nên cài đặt quy luật nghiệp vụ kiểm tra dữ liệu ở Delivery, việc đó để cho UseCase, nếu không bạn sẽ phải bảo trì logic nghiệp vụ ở nhiều chỗ !
Ngoại trừ Models, mỗi lớp sẽ truyền tin qua interface. Ví dụ UseCase lấy dữ liệu từ Repository bằng cách nào?
Repository cấp một giao diện interface làm hợp đồng truyền nhận giữa 2 bên. Nếu không có interface, 2 layer sẽ khó kiểm soát cấu trúc dữ liệu trao đổi gây nên lỗi.
Dưới đây là code ArticleRepository định nghĩa các hàm đọc/ghi dữ liệu
package repository
import models "github.com/bxcodec/go-clean-arch/article"
type ArticleRepository interface {
Fetch(cursor string, num int64) ([]*models.Article, error)
GetByID(id int64) (*models.Article, error)
GetByTitle(title string) (*models.Article, error)
Update(article *models.Article) (*models.Article, error)
Store(a *models.Article) (int64, error)
Delete(id int64) (bool, error)
}
Lớp UseCase sẽ trao đổi với Repository sử dụng giao kèo interface này. Repository layer phải viết code cho từng hàm trong interface này để nó có thể được gọi từ UseCase.
Ví dụ của UseCase interface. Lớp UseCase sẽ viết code chi tiết cho những hàm trong ArticleUseCase. Lớp Deliver sẽ gọi qua UseCase interface
package usecase
import (
"github.com/bxcodec/go-clean-arch/article"
)
type ArticleUsecase interface {
Fetch(cursor string, num int64) ([]*article.Article, string, error)
GetByID(id int64) (*article.Article, error)
Update(ar *article.Article) (*article.Article, error)
GetByTitle(title string) (*article.Article, error)
Store(*article.Article) (*article.Article, error)
Delete(id int64) (bool, error)
}
Có thể hiểu clean là độc lập. Từng lớp phải kiểm thử độc lập không quan tâm đến layer khác có tồn tại hay chạy không:
Để mocking, tôi đã dùng thư viện này https://github.com/vektra/mockery Xem thêm bài Mocking dependencies in Go
Để kiểm thử lớp repository, tôi sử sql-mock để làm giả quá trình truy vấn. Trong đoạn code dưới, bạn thấy sqlmock được chèn thêm một dòng để làm dữ liệu giả
func TestGetByID(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf(“an error ‘%s’ was not expected when opening a stub
database connection”, err)
}
defer db.Close()
rows := sqlmock.NewRows([]string{
“id”, “title”, “content”, “updated_at”, “created_at”}).
AddRow(1, “title 1”, “Content 1”, time.Now(), time.Now())
query := “SELECT id,title,content,updated_at, created_at FROM
article WHERE ID = \\?”
mock.ExpectQuery(query).WillReturnRows(rows)
a := articleRepo.NewMysqlArticleRepository(db)
num := int64(1)
anArticle, err := a.GetByID(num)
assert.NoError(t, err)
assert.NotNil(t, anArticle)
}
Usecase sử dụng Repository do đó mockery sẽ sinh ra một đối tượng repository giả. Tôi không cần hoàn thành repository mà vẫn có thể lập trình UseCase.
package usecase_test
import (
"errors"
"strconv"
"testing"
"github.com/bxcodec/faker"
models "github.com/bxcodec/go-clean-arch/article"
"github.com/bxcodec/go-clean-arch/article/repository/mocks"
ucase "github.com/bxcodec/go-clean-arch/article/usecase"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestFetch(t *testing.T) {
mockArticleRepo := new(mocks.ArticleRepository)
var mockArticle models.Article
err := faker.FakeData(&mockArticle)
assert.NoError(t, err)
mockListArtilce := make([]*models.Article, 0)
mockListArtilce = append(mockListArtilce, &mockArticle)
mockArticleRepo.On("Fetch", mock.AnythingOfType("string"), mock.AnythingOfType("int64")).Return(mockListArtilce, nil)
u := ucase.NewArticleUsecase(mockArticleRepo)
num := int64(1)
cursor := "12"
list, nextCursor, err := u.Fetch(cursor, num)
cursorExpected := strconv.Itoa(int(mockArticle.ID))
assert.Equal(t, cursorExpected, nextCursor)
assert.NotEmpty(t, nextCursor)
assert.NoError(t, err)
assert.Len(t, list, len(mockListArtilce))
mockArticleRepo.AssertCalled(t, "Fetch", mock.AnythingOfType("string"), mock.AnythingOfType("int64"))
}
Delivery test phụ thuộc cách bạn trả về dữ liệu, nhận dữ liệu. Nếu Delivery dùng REST thì hãy dùng httptest. Delivery phụ thuộc vào Usecase, do đó tôi lại tạo Usecase giả nhờ Mockery
func TestGetByID(t *testing.T) {
var mockArticle models.Article
err := faker.FakeData(&mockArticle)
assert.NoError(t, err)
mockUCase := new(mocks.ArticleUsecase)
num := int(mockArticle.ID)
mockUCase.On(“GetByID”, int64(num)).Return(&mockArticle, nil)
e := echo.New()
req, err := http.NewRequest(echo.GET, “/article/” +
strconv.Itoa(int(num)), strings.NewReader(“”))
assert.NoError(t, err)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetPath(“article/:id”)
c.SetParamNames(“id”)
c.SetParamValues(strconv.Itoa(num))
handler:= articleHttp.ArticleHandler{
AUsecase: mockUCase,
Helper: httpHelper.HttpHelper{}
}
handler.GetByID(c)
assert.Equal(t, http.StatusOK, rec.Code)
mockUCase.AssertCalled(t, “GetByID”, int64(num))
}
Khi các layer được hoàn thành và vượt qua bài kiểm thử, bạn có thể nhập (import) các lớp vào một file main.go.
package main
import (
"database/sql"
"fmt"
"net/url"
httpDeliver "github.com/bxcodec/go-clean-arch/article/delivery/http"
articleRepo "github.com/bxcodec/go-clean-arch/article/repository/mysql"
articleUcase "github.com/bxcodec/go-clean-arch/article/usecase"
cfg "github.com/bxcodec/go-clean-arch/config/env"
"github.com/bxcodec/go-clean-arch/config/middleware"
_ "github.com/go-sql-driver/mysql"
"github.com/labstack/echo"
)
var config cfg.Config
func init() {
config = cfg.NewViperConfig()
if config.GetBool(`debug`) {
fmt.Println("Service RUN on DEBUG mode")
}
}
func main() {
dbHost := config.GetString(`database.host`)
dbPort := config.GetString(`database.port`)
dbUser := config.GetString(`database.user`)
dbPass := config.GetString(`database.pass`)
dbName := config.GetString(`database.name`)
connection := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s", dbUser, dbPass, dbHost, dbPort, dbName)
val := url.Values{}
val.Add("parseTime", "1")
val.Add("loc", "Asia/Jakarta")
dsn := fmt.Sprintf("%s?%s", connection, val.Encode())
dbConn, err := sql.Open(`mysql`, dsn)
if err != nil && config.GetBool("debug") {
fmt.Println(err)
}
defer dbConn.Close()
e := echo.New()
middL := middleware.InitMiddleware()
e.Use(middL.CORS)
ar := articleRepo.NewMysqlArticleRepository(dbConn)
au := articleUcase.NewArticleUsecase(ar)
httpDeliver.NewArticleHttpHandler(e, au)
e.Start(config.GetString("server.address"))
}
https://github.com/bxcodec/go-clean-arch
Thư viện đã sử dụng trong dự án của tôi
Via Techmaster