tutorial,

在 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 管理文章的一个示例)中查找示例项目。

免责声明:

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

基础

整洁架构的约束条件是:

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

  • 可测试的。可以在没有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。

示例:

1
2
3
4
5
6
7
8
9
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 示例:

1
2
3
4
5
6
7
8
9
10
11
12
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 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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,或其他具有类似功能的库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 层。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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 层测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
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

用于我的项目的库:

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

进一步了解 Clean Architecture:


CatchZeng
Written by CatchZeng Follow
AI (Machine Learning) and DevOps enthusiast.