在 Golang 上使用整潔架構(gòu)(Clean Architecture)

原文: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)目。

image

免責(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ù)。您可以將 OracleSQL 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ù)??赡苁?MysqlMongoDB、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:

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容