用 Go 实现简洁架构(译文) | Go主题月

1,065

在阅读了uncle Bob 的简洁架构概念之后,我尝试用 Golang 实现它。这是我们公司 Kurio-App Berita Indonesia 使用的类似架构,没有太大的不同,相同的概念但文件夹结构略有不同。

您可以在这里查找示例项目 github.com/bxcodec/go-… 一篇关于 CRUD 管理的文章。

image.png

  • 免责声明:

    我不推荐这里使用任何库或框架。你可以用你自己的或者第三方的具有相同功能的东西来替换这里的任何东西。

基本

正如我们所知,在设计简洁的架构之前,约束条件是:

  1. 独立于框架。该体系结构并不依赖于某个功能丰富的软件库的存在。这允许您将这些框架用作工具,而不必将系统塞进它们有限的约束中。

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

  3. 独立于用户界面。用户界面可以很容易地更改,而无需更改系统的其余部分。例如,可以用控制台 UI 替换 Web UI,而无需更改业务规则。

  4. 独立于数据库。您可以将 Oracle 或 SQL Server 换成 Mongo、BigTable、CouchDB 或其他东西。您的业务规则未绑定到数据库。

  5. 独立于任何外部机构。事实上,你的业务规则根本就不了解外部世界。

更多:8thlight.com/blog/uncle-…

因此,基于这个约束,每一层都必须是独立的和可测试的。

如果 Uncle Bob 的架构,那么会有以下4层:

  • Entities
  • Usecase
  • Controller
  • Framework & Driver

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

  • Models
  • Repository
  • Usecase
  • Delivery

Models

与实体相同,将在所有层中使用。此层将存储任何对象的结构及其方法。例句: 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 数据库起作用。这里没有业务流程。只对数据库执行普通函数。

该层还负责选择应用程序中使用的数据库。可能是 Mysql,MongoDB,MariaDB,Postgresql 等等,都会在这里决定。 如果使用 ORM,该层将控制输入,并将其直接提供给 ORM 服务。

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

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

Usecase

这个层将充当业务流程处理程序。任何过程都会在这里处理。这个层将决定使用哪个存储库层。并有责任提供数据的交付。处理数据,进行计算,或者在这里完成任何操作。

Usecase 层将接受来自交付层的任何输入,这些输入已经被处理,然后处理输入可以存储到 DB 中,或者从 DB 中提取,等等。

Usecase 层依赖于Repository 层。

Delivery

此层将充当演示者。决定如何呈现数据。可以是 REST APIHTML 文件或 gRPC,无论交付类型如何。 该层还将接受用户的输入。清理输入并将其发送到Usecase 层。

对于我的示例项目,我使用 REST API 作为交付方法。

客户端将通过网络调用资源端点,Delivery 层将获取输入或请求,并将其发送到Usecase 层。

Delivery 层依赖于Usecase 层。

层间通信

Models 层外,每一层都通过接口进行通信。例如,Usecase 层需要 Repository 层,那么它们是如何通信的呢?Repository 层将提供一个接口作为他们的契约和通信。

Repository 层接口示例

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 层接口示例

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 层

    此层仅在任何结构中声明的任何函数/方法时进行测试。

    并且可以独立于其他层进行测试。

  • Repository 层

    为了测试这个层,更好的方法是进行集成测试。但你也可以为每个测试做模拟。我在用 github.com/DATA-DOG/go-sqlmock 作为模拟查询进程msyql的助手。

  • Usecase 层

    因为该层依赖于 Repository 层,意味着该层需要 Repository 层进行测试。所以我们必须基于之前定义的契约接口,做一个 mockery 模拟的 Repository,使用 mockery 进行模拟。

  • Delivery 层

    和用例一样,因为这个层依赖于 Usecase 层,这意味着我们需要用例层进行测试。而用例层也必须基于之前定义的契约接口,用 mockry 进行模拟

对于模拟,我使用 vektragolang 写的 mockery,在这里可以看到 github.com/vektra/mock…

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

}

mockry 将为我生成一个 Repository 层的模型。所以我不需要先完成我的 Repository 层。我可以先完成用例,即使我的 Repository 层还没有实现。

Delivery 层测试

Delivery 层测试将取决于您如何交付数据。如果使用 httprestapi,我们可以在 golang 中使用 httptest 的内置包。

因为这取决于 Usecase 层,所以我们需要一个 Usecase 层的模拟。和 Repository 一样,我还使用 mockry 来模拟我的用例,用于交付测试。

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

最终输出和合并

完成所有层后并已通过测试。你应该合并成一个 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.png

  • 这里的每一个库你都可以自己更改。因为简介架构的要点是:不管你的库是什么,但是你的架构是简洁的,而且可测试性也是独立的。

这就是我组织我的项目的方式,你可以争论,或者同意,或者也许改进的更好,只需留下评论和分享

示例项目

示例项目可以在这里看到 github.com/bxcodec/go-…

我的项目中用到的库:

Glide:用于包管理

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

关于简洁架构的进一步阅读:

本文第二部分:

如果你有问题,或者需要更多的解释,或者一些我在这里不能很好解释的事情,你可以通过我的 linkedin 问我或者给我发邮件

linkedin: www.linkedin.com/in/imantumo…

email: iman.tumorang@gmail.com

谢谢你

原文连接:medium.com/hackernoon/…