在 Golang 上使用整洁架构(Clean Architecture)

原文:https://medium.com/hackernoon/golang-clean-archithecture-efd6d7c43047

前言

阅读完 Uncle Bob 的整洁架构(Clean Architecture)后,我尝试在 Golang 中实现它。这与我们在 Kurio-App Berita Indonesi 公司中使用的架构类似,但是结构略有不同。其实,也没什么不同,只是相同的概念但文件夹结构不同而已。

你可以在这里 https://github.com/bxcodec/go-clean-arch(CRUD 管理文章的一个示例)中查找示例项目。

image

免责声明:

我不建议在此使用任何库或框架。你可以用自己的或具有相同功能的第三方库替换此处的任何内容。

基础

整洁架构的约束条件是:

  • 独立于框架。该体系结构不依赖于某些功能丰富的软件库的存在。这使你可以将这些框架用作工具,而不必将系统塞入有限的约束中。

  • 可测试的。可以在没有UI,数据库,Web 服务器或任何其他外部元素的情况下测试业务规则。

  • 独立于 UI。UI 可以轻松更改,而无需更改系统的其余部分。例如,可以在不更改业务规则的情况下用控制台 UI 替换 Web UI。

  • 独立于数据库。您可以将 OracleSQL Server 换成 MongoBigTableCouchDB 或其他东西。你的业务规则不绑定到数据库

  • 独立于任何外部机构。实际上,你的业务规则根本就不用了解外部的构成。

详情请参阅 https://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.html

注:原文中留的链接已失效,可以访问译文 https://zhuanlan.zhihu.com/p/64343082

因此,基于此约束,每一层都必须独立且可测试

Uncle Bob 的架构中包含 4 层:

  • Entities
  • Usecase
  • Controller
  • Framework & Driver

在我的项目中,也使用 4 层:

  • Models
  • Repository
  • Usecase
  • Delivery

Models

与 Entities 相同, Models 将被用在所有层.

Models 层将存储任何对象的 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"`
}

任何实体模型都将存储在此层。

Repository

Repository 将存储任何数据库处理程序。查询或创建/插入任何数据库将存储在此处。该层仅适用于 CRUD 数据库。这里没有业务流程发生。仅是对数据库的普通功能。

Repository 层还负责选择应用程序中将使用的数据库。可能是 MysqlMongoDBMariaDBPostgresql 等。

如果使用 ORM,则此层将控制输入,并将其直接提供给 ORM 服务

如果调用微服务,将在 Repository 层处理。创建对其他服务的 HTTP 请求,并清理数据。 Repository 层必须完全充当存储库。处理所有数据输入和输出没有发生特定的逻辑

Repository 层将取决于连接的数据库或其他微服务(如果存在)。

Usecase

Usecase 层将充当业务流程处理程序。任何过程都将在这里处理。Usecase 层将决定将使用哪个存储库层。并提供数据以供 Delivery 层使用。处理数据以进行计算等事项都将在 Usecase 层完成。

Usecase 层将接受来自 Delivery 层的任何已清理的输入,然后处理该输入,可存储到 DB 中或从 DB 中提取等。

Usecase 层将取决于 Repository 层。

Delivery

Delivery 层将充当演示者。决定如何呈现数据。可以采用 REST API 或 HTML File 或 gRPC 的形式。
Delivery 层还将接受用户的输入。清理输入并将其发送到 Usecase 层。

对于我的示例项目,我使用 REST API 作为交付方式。
客户端将通过网络调用资源终结点,Delivery 层将获取输入或请求,并将其发送到 Usecase 层。

Delivery 层将取决于 Usecase 层。

层与层之间的通信

除 Models 层外,每一层都将通过 interface 进行通信。例如,Usecase 层需要 Repository 层,那么它们如何通信?Repository 将提供一个 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 层必须实现此接口,以便供 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 层必须实现此接口。

测试每一层

众所周知,整洁意味着独立每个层都具备可测性,即使其他层不存在

  • Models

    Models 层测试在 Struct 中声明的函数/方法。并且可以轻松地进行测试并且独立于其他层。

  • Repository

    要测试 Repository 层,更好的方法是进行集成测试。但是你也可以为每个测试进行模拟。比如使用 github.com/DATA-DOG/go-sqlmock 来模拟 sql。

  • Usecase

    因为 Usecase 层依赖于 Repository 层,所以意味着 Usecase 层需要 Repository 层进行测试。因此,我们必须
    基于之前定义的协定接口,mock 一个 Repository 层。

  • Delivery

    与 Usecase 层相同,因为 Delivery 层取决于 Usecase 层,这意味着我们需要 Usecase 层进行测试。并且,需要基于之前定义的协定接口, mock 一个 Usecase 层。

注:在原文中使用的 mock 工具为 https://github.com/vektra/mockery。这里更推荐大家使用 golang 官方的 https://github.com/golang/mock

测试 Repository 层

为了测试 Repository 层,就像我之前说过的那样,使用 sql-mock 模拟我的查询过程。你可以像我在这里使用的那样使用github.com/DATA-DOG/go-sqlmock,或其他具有类似功能的库。

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 层

Usecase 层的测试,取决于 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 将为我生成一个 Repository 层。因此,我不需要先完成我的 Repository 层。我可以先完成 Usecase 层,甚至还没有实现我的 Repository 层

测试 Delivery 层

Delivery 层测试将取决于您如何传递数据。如果使用 http REST API,则可以在 golang 中为 httptest 使用内置测试包。

因为 Delivery 层取决于 Usecase 层,所以我们需要 mock Usecase 层。与 Repository 层相同,我也使用 Mockery 模拟用例,以进行 Delivery 层测试。

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

最终输出与合并

完成所有层并已通过测试之后。你应该在 root 项目的 main.go 中合并到一个系统中。
在这里,你将定义并创建环境的所有需求,并将所有层合并为一个层。

以我的 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"))
}

你可以看到,每一层及其相关性合并为一层。

结论

  • 一图概括

    image
  • 你在这里使用的每个库都可以自行更改。因为整洁架构的要点是:无论你的库是什么,但是你的架构都是整洁的,并且可以独立测试

  • 这就是我组织项目的方式,你可以争论或同意,或者可以改善它以使其变得更好,只要发表评论并分享一下即可。

示例代码

示例项目的代码地址 https://github.com/bxcodec/go-clean-arch

用于我的项目的库:

  • Glide:用于包管理
  • github.com/DATA-DOG/go-sqlmock
  • Testify:用于测试
  • Echo Labstack(Golang Web 框架)用于 Delivery 层
  • Viper:用于环境配置

进一步了解 Clean Architecture:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,377评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,390评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,967评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,344评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,441评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,492评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,497评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,274评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,732评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,008评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,184评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,837评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,520评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,156评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,407评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,056评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,074评论 2 352

推荐阅读更多精彩内容