在 Golang 中尝试简洁架构

fredvence · 2018-05-07 22:23:03 · 10566 次点击 · 预计阅读时间 11 分钟 · 大约8小时之前 开始浏览    
这是一个创建于 2018-05-07 22:23:03 的文章,其中的信息可能已经有所发展或是发生改变。

(独立性,可测试性的和简洁性)

在阅读了 Bob 叔叔的 Clean Architecture Concept 之后,我尝试在 Golang 中实现它。我们公司也有使用相似的架构,Kurio - App Berita Indonesia, 但是结构有点不同。并不是太不同, 相同的概念,但是文件目录结构不同。

你可以在这里找到一个示例项目https://github.com/bxcodec/go-clean-arch,这是一个 CRUD 管理示例文章

  • 免责声明:

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

基础

在设计简洁架构之前我们需要了解如下约束:

  1. 独立于框架。该架构不会依赖于某些功能强大的软件库存在。这可以让你使用这样的框架作为工具,而不是让你的系统陷入到框架的限制的约束中。

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

  3. 独立于 UI 。在无需改变系统的其他部分情况下, UI 可以轻松的改变。例如,在没有改变业务规则的情况下,Web UI 可以替换为控制台 UI。

  4. 独立于数据库。你可以用 Mongo, BigTable, CouchDB 或者其他数据库来替换 Oracle 或 SQL Server,你的业务规则不要绑定到数据库。

  5. 独立于外部媒介。 实际上,你的业务规则可以简单到根本不去了解外部世界。

更多详见: https://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.html

所以, 基于这些约束,每一层都必须是独立的和可测试的。

如 Bob 叔叔的架构有 4 层:

  • 实体层( Entities )
  • 用例层( Usecase )
  • 控制层( Controller )
  • 框架和驱动层( Framework & Driver )

在我的项目里,我也使用了 4 层架构:

  • 模型层( Models )
  • 仓库层( Repository )
  • 用例层 ( Usecase )
  • 表现层( Delivery )

模型层( Models )

与实体( Entities )一样, 模型会在每一层中使用,在这一层中将存储对象的结构和它的方法。例如: 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 )

这层将会扮演业务流程处理器的角色。任何流程都将在这里处理。该层将决定哪个仓库层被使用。并且负责提供数据给服务以便交付。处理数据进行计算或者在这里完成任何事。

用例层将接收来自传递层的所有经过处理的输入,然后将处理的输入存储到数据库中, 或者从数据库中获取数据等。

用例层将依赖于仓库层。

表现层( Delivery )

这一层将作为表现者。决定数据如何呈现。任何传递类型都可以作为是 REST API, 或者是 HTML 文件,或者是 gRPC

这一层将接收来自用户的输入, 并清理数据然后传递给用例层。

对于我的示例项目, 我使用 REST API 作为表现方式。客户端将通过网络调用资源节点, 表现层将获取到输入或请求,然后将它传递给用例层。

该层依赖于用例层。

层与层之间的通信

除了模型层, 每一层都需要通过接口进行通信。例如,用例( 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 )必须实现这个接口,以便用例层( 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)
}

与用例层相同, 表现层将会使用这个约定接口。 并且用例层必须实现该接口。

测试

我们知道, 简洁就意味着独立。 甚至在其他层还不存在的情况下,每一层都具有可测试性。

  • 模型( Models )层

    该层仅测试任意结构声明的函数或方法。 这可以独立于其他层,轻松的进行测试。

  • 仓库( Repository )层

    为了测试该层,更好的方式是进行集成测试,但你也可以为每一个测试进行模拟测试, 我使用 github.com/DATA-DOG/go-sqlmock 作为我的工具来模拟查询过程 mysql

  • 用例( Usecase )层

    因为该层依赖于仓库层, 意味着该层需要仓库层来支持测试。所以我们根据之前定义的契约接口制作一个模拟的仓库( Repository )模型。

  • 表现( Delivery )层

    与用例层相同,因为该层依赖于用例层,意味着该层需要用例层来支持测试。基于之前定义的契约接口, 也需要对用例层进行模拟。

对于模拟,我使用 vektra 的 golang的模拟库: https://github.com/vektra/mockery

仓库层(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)测试

用于用例层的示例测试,依赖于仓库层。

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)层, 我可以先完成用例(Usecase),即使我的仓库(Repository)层尚未实现。

表现层( Delivery )测试

表现层测试依赖于你如何传递的数据。如果使用 http REST API, 我们可以使用 golang 中的内置包 httptest。

因为该层依赖于用例( Usecase )层, 所以 我们需要模拟 Usecase,与仓库层相同,我使用 Mockery 模拟我的 Usecase 来进行表现层( 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))
}

最终输出与合并

完成所有层的编码并通过测试之后。你应该在的根项目的 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"))
}

你可以看见,每一层都与它的依赖关系合并在一起了。

结论

总之,如果画在一张图上,就如下图所示:

  • 在这里使用的每一个库都可以由你自己修改。因为简洁架构的重点在于:你使用的库不重要, 关键是你的架构是简洁的,可测试的并且是独立的。

  • 我项目就是这样组织的。通过评论和分享, 你可以讨论或者赞成,当然能改善它就更好了。

示例项目

示例项目可以在这里看见: https://github.com/bxcodec/go-clean-arch

我的项目中使用到的库:

  • Glide :包管理工具

  • go-sqlmock from github.com/DATA-DOG/go-sqlmock

  • Testify : 测试库

  • Echo Labstack (Golang Web 框架)用于 表现层

  • Viper :环境配置

进一步阅读简洁架构 :

如果你任何问题,或者需要更多的解释,或者我在这里没有解释清楚的。你可以通过我的LinkedIn或者email联系我。谢谢。


via: https://hackernoon.com/golang-clean-archithecture-efd6d7c43047

作者:Iman Tumorang  译者:fredvence  校对:polaris1119

本文由 GCTT 原创编译,Go语言中文网 荣誉推出


有疑问加站长微信联系(非本文作者))

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

10566 次点击  ∙  5 赞  
加入收藏 微博
被以下专栏收入,发现更多相似内容
3 回复  |  直到 2022-07-28 11:58:28
CrueltyKing
CrueltyKing · #1 · 5年之前

赞一个

zoloadang007
zoloadang007 · #2 · 3年之前

对于新手小白的我来说很有收获,随着技术演变,在一些专有词汇方面也有很多改变,但整体是可以对应起来理解的;例如作者提到的仓库层和用例层,个人理解现在很多在架构设计的时候对应的是 Services 层和 APIS(Controller)层,都同理可解读。

pobearm
pobearm · #3 · 3年之前

我也有写过类似的项目. 几个问题很纠结:

1) 按领域驱动来说, usecase层代码要真正体现对业务逻辑的表达, 需要通过领域实体对业务进行建模. 那么领域实体, 和仓储层的实体(数据表), 不应该是一个东西. 对于简单业务来说, 领域实体与仓储层的实体 非常类似, 多写一次很麻烦, 对象间赋值也很麻烦. 但是, 对于稍微复杂业务, 共用实体也在实践中导致很多问题, 比如, 实体上不不断的被添加很多扩展的属性(成员). 来表达业务的状态, 或者为了拼凑对象让接口层刚好返回API的需要的数据. 时间一长, 变得混乱.

2) 对于聚合查询, 其实从可以API层, 直接访问仓储层, 跳过usecase层.

3) usecase层, 跟仓储的db对象是解耦的, 那么如何在业务中表达事务? 事务也是业务逻辑的重要组成部分. 我的做法是建立事务的抽象接口. 这是一个重要的问题.

4) api层的独立测试, api层逻辑主要是出入参的校验, 转换, 组装, 写测试用例实际的投入产出比不高. 把api层+usecae层一起测试, 实践中更实用. 所以, 可以不对usecase层做接口定义. 当然, 对多个usecase间有依赖的情况, 没有接口, 难实现usecase的独立测试, 也是个纠结点.

5) 一些动态参数的传递, 比如,列表的查询条件, 其实用map会方便很多. 不论用不用ORM框架, 仓储层都要出现这种逻辑代码, if xx条件有值 then + 'and xx=?' , 最终生成动态的where sql语句. 这种参数个人觉得map比struct方面很多, 当然牺牲参数校验的便利性.

总之, 分层清晰了, 解耦了, 代码就啰嗦了, 写起来就狗屎了. 加个参数, 代码改一大圈.

添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传