原文:https://medium.com/hackernoon/golang-clean-archithecture-efd6d7c43047
前言
閱讀完 Uncle Bob 的整潔架構(gòu)(Clean Architecture)后,我嘗試在 Golang 中實(shí)現(xiàn)它。這與我們?cè)?Kurio-App Berita Indonesi 公司中使用的架構(gòu)類似,但是結(jié)構(gòu)略有不同。其實(shí),也沒什么不同,只是相同的概念但文件夾結(jié)構(gòu)不同而已。
你可以在這里 https://github.com/bxcodec/go-clean-arch(CRUD 管理文章的一個(gè)示例)中查找示例項(xiàng)目。

免責(zé)聲明:
我不建議在此使用任何庫(kù)或框架。你可以用自己的或具有相同功能的第三方庫(kù)替換此處的任何內(nèi)容。
基礎(chǔ)
整潔架構(gòu)的約束條件是:
獨(dú)立于框架。該體系結(jié)構(gòu)不依賴于某些功能豐富的軟件庫(kù)的存在。這使你可以將這些框架用作工具,而不必將系統(tǒng)塞入有限的約束中。
可測(cè)試的??梢栽?strong>沒有UI,數(shù)據(jù)庫(kù),Web 服務(wù)器或任何其他外部元素的情況下測(cè)試業(yè)務(wù)規(guī)則。
獨(dú)立于 UI。UI 可以輕松更改,而無(wú)需更改系統(tǒng)的其余部分。例如,可以在不更改業(yè)務(wù)規(guī)則的情況下用控制臺(tái) UI 替換 Web UI。
獨(dú)立于數(shù)據(jù)庫(kù)。您可以將
Oracle或SQL Server換成Mongo,BigTable,CouchDB或其他東西。你的業(yè)務(wù)規(guī)則不綁定到數(shù)據(jù)庫(kù)。獨(dú)立于任何外部機(jī)構(gòu)。實(shí)際上,你的業(yè)務(wù)規(guī)則根本就不用了解外部的構(gòu)成。
詳情請(qǐng)參閱 https://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.html
注:原文中留的鏈接已失效,可以訪問譯文 https://zhuanlan.zhihu.com/p/64343082。
因此,基于此約束,每一層都必須獨(dú)立且可測(cè)試。
Uncle Bob 的架構(gòu)中包含 4 層:
- Entities
- Usecase
- Controller
- Framework & Driver
在我的項(xiàng)目中,也使用 4 層:
- Models
- Repository
- Usecase
- Delivery
Models
與 Entities 相同, Models 將被用在所有層.
Models 層將存儲(chǔ)任何對(duì)象的 Struct 及其方法。示例:Article, Student, Book。
示例:
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"`
}
任何實(shí)體或模型都將存儲(chǔ)在此層。
Repository
Repository 將存儲(chǔ)任何數(shù)據(jù)庫(kù)處理程序。查詢或創(chuàng)建/插入任何數(shù)據(jù)庫(kù)將存儲(chǔ)在此處。該層僅適用于 CRUD 數(shù)據(jù)庫(kù)。這里沒有業(yè)務(wù)流程發(fā)生。僅是對(duì)數(shù)據(jù)庫(kù)的普通功能。
Repository 層還負(fù)責(zé)選擇應(yīng)用程序中將使用的數(shù)據(jù)庫(kù)??赡苁?Mysql、MongoDB、MariaDB、Postgresql 等。
如果使用 ORM,則此層將控制輸入,并將其直接提供給 ORM 服務(wù)。
如果調(diào)用微服務(wù),將在 Repository 層處理。創(chuàng)建對(duì)其他服務(wù)的 HTTP 請(qǐng)求,并清理數(shù)據(jù)。 Repository 層必須完全充當(dāng)存儲(chǔ)庫(kù)。處理所有數(shù)據(jù)輸入和輸出沒有發(fā)生特定的邏輯。
Repository 層將取決于連接的數(shù)據(jù)庫(kù)或其他微服務(wù)(如果存在)。
Usecase
Usecase 層將充當(dāng)業(yè)務(wù)流程處理程序。任何過程都將在這里處理。Usecase 層將決定將使用哪個(gè)存儲(chǔ)庫(kù)層。并提供數(shù)據(jù)以供 Delivery 層使用。處理數(shù)據(jù)以進(jìn)行計(jì)算等事項(xiàng)都將在 Usecase 層完成。
Usecase 層將接受來(lái)自 Delivery 層的任何已清理的輸入,然后處理該輸入,可存儲(chǔ)到 DB 中或從 DB 中提取等。
Usecase 層將取決于 Repository 層。
Delivery
Delivery 層將充當(dāng)演示者。決定如何呈現(xiàn)數(shù)據(jù)。可以采用 REST API 或 HTML File 或 gRPC 的形式。
Delivery 層還將接受用戶的輸入。清理輸入并將其發(fā)送到 Usecase 層。
對(duì)于我的示例項(xiàng)目,我使用 REST API 作為交付方式。
客戶端將通過網(wǎng)絡(luò)調(diào)用資源終結(jié)點(diǎn),Delivery 層將獲取輸入或請(qǐng)求,并將其發(fā)送到 Usecase 層。
Delivery 層將取決于 Usecase 層。
層與層之間的通信
除 Models 層外,每一層都將通過 interface 進(jìn)行通信。例如,Usecase 層需要 Repository 層,那么它們?nèi)绾瓮ㄐ??Repository 將提供一個(gè) interface ,使其成為他們的”合同“和通訊方式。
Repository Interface 示例:
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)
}
Usecase 層將使用此“合同”與 Repository 層通信,并且 Repository 層必須實(shí)現(xiàn)此接口,以便供 Usecase 層使用。
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)
}
與 Usecase 層相同,Delivery 層將使用此”合同“接口。并且 Usecase 層必須實(shí)現(xiàn)此接口。
測(cè)試每一層
眾所周知,整潔意味著獨(dú)立。每個(gè)層都具備可測(cè)性,即使其他層不存在。
-
Models
Models 層僅測(cè)試在 Struct 中聲明的函數(shù)/方法。并且可以輕松地進(jìn)行測(cè)試并且獨(dú)立于其他層。
-
Repository
要測(cè)試 Repository 層,更好的方法是進(jìn)行集成測(cè)試。但是你也可以為每個(gè)測(cè)試進(jìn)行模擬。比如使用 github.com/DATA-DOG/go-sqlmock 來(lái)模擬 sql。
-
Usecase
因?yàn)?Usecase 層依賴于 Repository 層,所以意味著 Usecase 層需要 Repository 層進(jìn)行測(cè)試。因此,我們必須
基于之前定義的協(xié)定接口,mock 一個(gè) Repository 層。 -
Delivery
與 Usecase 層相同,因?yàn)?Delivery 層取決于 Usecase 層,這意味著我們需要 Usecase 層進(jìn)行測(cè)試。并且,需要基于之前定義的協(xié)定接口, mock 一個(gè) Usecase 層。
注:在原文中使用的 mock 工具為 https://github.com/vektra/mockery。這里更推薦大家使用 golang 官方的 https://github.com/golang/mock。
測(cè)試 Repository 層
為了測(cè)試 Repository 層,就像我之前說過的那樣,使用 sql-mock 模擬我的查詢過程。你可以像我在這里使用的那樣使用github.com/DATA-DOG/go-sqlmock,或其他具有類似功能的庫(kù)。
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)
}
測(cè)試 Usecase 層
Usecase 層的測(cè)試,取決于 Repository 層。
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"))
}
Mockery 將為我生成一個(gè) Repository 層。因此,我不需要先完成我的 Repository 層。我可以先完成 Usecase 層,甚至還沒有實(shí)現(xiàn)我的 Repository 層。
測(cè)試 Delivery 層
Delivery 層測(cè)試將取決于您如何傳遞數(shù)據(jù)。如果使用 http REST API,則可以在 golang 中為 httptest 使用內(nèi)置測(cè)試包。
因?yàn)?Delivery 層取決于 Usecase 層,所以我們需要 mock Usecase 層。與 Repository 層相同,我也使用 Mockery 模擬用例,以進(jìn)行 Delivery 層測(cè)試。
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))
}
最終輸出與合并
完成所有層并已通過測(cè)試之后。你應(yīng)該在 root 項(xiàng)目的 main.go 中合并到一個(gè)系統(tǒng)中。
在這里,你將定義并創(chuàng)建環(huán)境的所有需求,并將所有層合并為一個(gè)層。
以我的 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"))
}
你可以看到,每一層及其相關(guān)性合并為一層。
結(jié)論
-
一圖概括
image 你在這里使用的每個(gè)庫(kù)都可以自行更改。因?yàn)檎麧嵓軜?gòu)的要點(diǎn)是:無(wú)論你的庫(kù)是什么,但是你的架構(gòu)都是整潔的,并且可以獨(dú)立測(cè)試
這就是我組織項(xiàng)目的方式,你可以爭(zhēng)論或同意,或者可以改善它以使其變得更好,只要發(fā)表評(píng)論并分享一下即可。
示例代碼
示例項(xiàng)目的代碼地址 https://github.com/bxcodec/go-clean-arch
用于我的項(xiàng)目的庫(kù):
- Glide:用于包管理
- github.com/DATA-DOG/go-sqlmock
- Testify:用于測(cè)試
- Echo Labstack(Golang Web 框架)用于 Delivery 層
- Viper:用于環(huán)境配置
進(jìn)一步了解 Clean Architecture:
