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.

Indepdent, Testable, and Clean ~ Độc lập, kiểm thử được, và trong sáng

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.

Code ứng dụng demo

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.

Căn bản

Kiến trúc sạch (Clean Architecture) có những ràng buộc như sau:

  1. Independent of Frameworks: Độc lập với khung mẫu (framework). Kiến ứng dụng của bạn cần không phụ thuộc vào tồn tại của số thư viện quá chi tiết - phức tạp. Điều này giúp bạn dùng thư viện - khung mẫu chỉ như là công cụ, thay vì trói hệ thống của bạn bởi những hạn chế của những thư viện này.
  2. Testable: Kiểm thử được. Các quy luật nghiệp vụ cần phải được kiểm thử hoàn toàn độc lập không phụ thuộc - không cần đến giao diện, cơ sở dữ liệu, ứng dụng web hay các thành phần khác không thực sự quyết định đến nội dung quy luật.
  3. Independent of UI: Giao diện thường thay đổi dễ dàng và không thay đổi phần còn lại của hệ thống. Giao diện web nên hoặc sẵn sàng được thay thế bởi giao diện console, mà không ảnh hưởng đến quy luật nghiệp vụ. Ví dụ thay vì dùng nhập dữ liệu bằng tay vào form trên web hãy dùng curl để post dữ liệu.
  4. Independent of Database: độc lập với cơ sở dữ liệu. Bạn có thể thay Oracle bằng SQL Server hoặc Mongo, Bigtable, CouchDB hoặc những công nghệ khác. Quy luật nghiệp vụ không nên gắn vào cơ sở dữ liệu. Đây là ý kiến cá nhân của tôi, việc độc lập hoàn toàn với cơ sở dữ liệu đối với ứng dụng monolithic là hết sức khó khăn. Bởi database là linh hồn của mô hình client server monolithic. WordPress, Joomla là những CMS nổi tiếng chỉ có thể chạy trên MySQL mà không hỗ trợ các database khác.
  5. Independent of any external agency: quy luật nghiệp vụ độc lập với các dịch vụ, đối tác bên ngoài. Ví dụ nghiệp vụ không nên phụ thuộc vào gửi email qua Gmail hay MailGun, việc xuất báo cáo ra dạng CSV hay PDF, hay lưu trữ trên DropBox hay Amazon S3.

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:

  1. Entities
  2. Usecase
  3. Controller
  4. Framework & Driver

Trong dự án của tôi, tôi dùng 4 lớp (layer):

  1. Models
  2. Repository
  3. Usecase
  4. Delivery

Để các bạn dễ hình dung, tôi chuyển diagram tác gia minh hoạ lên đầu

Models

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

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

Usecase

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:

  • UseCase nhận dữ liệu từ Delivery sau khi Delivery đã lọc (sanitized).
  • UseCase xử lý theo quy luật nghiệp vụ
  • Kết quả có thể chuyển sang Respository để lưu vào cơ sở dữ liệu hoặc đẩy sang microservice khác
  • Kết quả cũng có thể trả về Delivery để hiện thị ra cho user

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:

  • REST API: định dạng JSON hoặc XML
  • Web page
  • GraphQL
  • gRPC

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ỗ !

Truyền tin giữa các lớp (communications between layers)

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)
}

Kiểm thử từng lớp

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:

  • Models Layer: chỉ cần kiểm thử khi có những hàm, phương thức khai báo trong struct
  • Repository Layer: nên sử dụng integration testing. Nhưng bạn có thể sử dụng mocking cho từng bài test. Bạn có thể tham khảo go-sqlmock giả lập hành vi của cơ sở dữ liệu mà không cần cơ sở dữ liệu thực thụ. Ý kiến của người dịch bài : hiện này docker rất phổ biến việc tạo mới một container database trong mỗi lần kiểm thử rất dễ dàng, do đó hãy dùng cơ sở dữ liệu thật không ảnh hưởng đến tốc độ khởi động bài test, mà phản ảnh kết quả chân thực
  • UseCase: Do nó phụ thuộc vào repository layer, để kiểm thử hoặc phải có repository layer hoặc tạo giả lập của Repository bằng mockery dựa theo giao diện giao kèo định nghĩa trước đó. 
  • Deliver phụ thuộc vào UseCase. Do đó để kiểm thử lại phải làm giả UseCase.

Để 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ử Repository

Để 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 Test

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"))

}

Kiểm thử Delivery

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))
}

Đầu ra cuối cùng và lắp ghép

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

Mã nguồn dự án mẫu

https://github.com/bxcodec/go-clean-arch

Thư viện đã sử dụng trong dự án của tôi