在阅读了uncle Bob 的简洁架构概念之后,我尝试用 Golang 实现它。这是我们公司 Kurio-App Berita Indonesia 使用的类似架构,没有太大的不同,相同的概念但文件夹结构略有不同。
您可以在这里查找示例项目 github.com/bxcodec/go-… 一篇关于 CRUD 管理的文章。
免责声明:
我不推荐这里使用任何库或框架。你可以用你自己的或者第三方的具有相同功能的东西来替换这里的任何东西。
正如我们所知,在设计简洁的架构之前,约束条件是:
独立于框架。该体系结构并不依赖于某个功能丰富的软件库的存在。这允许您将这些框架用作工具,而不必将系统塞进它们有限的约束中。
可测试。业务规则可以在没有 UI、数据库、Web 服务器或任何其他外部元素的情况下进行测试。
独立于用户界面。用户界面可以很容易地更改,而无需更改系统的其余部分。例如,可以用控制台 UI 替换 Web UI,而无需更改业务规则。
独立于数据库。您可以将 Oracle 或 SQL Server 换成 Mongo、BigTable、CouchDB 或其他东西。您的业务规则未绑定到数据库。
独立于任何外部机构。事实上,你的业务规则根本就不了解外部世界。
更多:8thlight.com/blog/uncle-…
因此,基于这个约束,每一层都必须是独立的和可测试的。
如果 Uncle Bob 的架构,那么会有以下4层:
在我的项目中,我也使用了4层:
与实体相同,将在所有层中使用。此层将存储任何对象的结构及其方法。例句: 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
层将存储任何数据库处理程序。查询或创建/插入任何数据库都将存储在这里。此层将仅对 CRUD 数据库起作用。这里没有业务流程。只对数据库执行普通函数。
该层还负责选择应用程序中使用的数据库。可能是 Mysql,MongoDB,MariaDB,Postgresql 等等,都会在这里决定。 如果使用 ORM,该层将控制输入,并将其直接提供给 ORM 服务。
如果调用微服务,将在这里处理。创建对其他服务的 HTTP 请求,并清理数据。这个层必须完全充当存储库。处理所有的数据 输入-输出 没有特定的逻辑发生。
Repository
层将依赖于连接的数据库或其他微服务(如果存在)。
这个层将充当业务流程处理程序。任何过程都会在这里处理。这个层将决定使用哪个存储库层。并有责任提供数据的交付。处理数据,进行计算,或者在这里完成任何操作。
Usecase
层将接受来自交付层的任何输入,这些输入已经被处理,然后处理输入可以存储到 DB 中,或者从 DB 中提取,等等。
Usecase
层依赖于Repository
层。
此层将充当演示者。决定如何呈现数据。可以是 REST API
、HTML
文件或 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 进行模拟
对于模拟,我使用 vektra
用 golang
写的 mockery
,在这里可以看到 github.com/vektra/mock…
如前所述,为了测试这个层,我使用了一个 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
层的示例测试,这取决于 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
层测试将取决于您如何交付数据。如果使用 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"))
}
您可以看到,每一层都与其依赖项合并为一层。
简言之,如果画在一个图表中,可以看到下面
这就是我组织我的项目的方式,你可以争论,或者同意,或者也许改进的更好,只需留下评论和分享
示例项目
示例项目可以在这里看到 github.com/bxcodec/go-…
我的项目中用到的库:
Glide:用于包管理
关于简洁架构的进一步阅读:
本文第二部分:
hackernoon.com/trying-clea…
8thlight.com/blog/uncle-…
简洁架构的另一个版本:
manuel.kiessling.net/2012/09/28/…
如果你有问题,或者需要更多的解释,或者一些我在这里不能很好解释的事情,你可以通过我的 linkedin 问我或者给我发邮件。
linkedin: www.linkedin.com/in/imantumo…
email: iman.tumorang@gmail.com
谢谢你
原文连接:medium.com/hackernoon/…