分享一个干净简洁的 Golang 项目架构

在Golang上尝试进行简洁的项目架构

独立、可测试和简洁

Iman Tumorang

2017年7月7日 6分钟阅读时间

看完uncle Bob的简洁项目架构概念后(译者注:uncle Bob The Clean Architecture链接),我想在Golang中实现。这是一个类似于我们公司使用的架构,Kurio - App Berita Indonesia,但结构有点不同。不是特别不同,但在文件夹结构上有不同。

你可以在这里找一个项目样本https://github.com/bxcodec/go-clean-arch,一个样例的CRUD管理项目。

Go

  • 免责声明 :
    我不是要推荐这里使用的任何库或框架。你可以用你自己的或者第三方具有相同功能的东西来代替这里的任何东西。

基本

我们知道在设计干净的体系结构之前的约束是:

  1. 独立于框架. 该体系结构不依赖于某些功能丰富的软件库的存在. 这允许您使用这样的框架作为工具, 而不必将您的系统塞进它们的有限约束中.
  2. 可测试的. 业务规则可以在没有 UI, 数据库, Web 服务器或任何其他外部元素的情况下进行测试.
  3. 独立于用户界面. 用户界面可以很容易地更改, 而不必更改系统的其他部分. 例如, Web 用户界面可以替换为控制台用户界面, 而不必更改业务规则.
  4. 独立于数据库. 您可以将 Oracle 或 SQL Server 换成 Mongo, BigTable, CouchDB 或其他东西. 您的业务规则未绑定到数据库.
  5. 独立于任何外部机构. 实际上, 您的业务规则根本不了解外部世界.

更多可参阅 https://8thlight.com/blog/uncle-bob/2012/0...

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

假设 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

Repository 层将存储任何数据库处理程序. 查询, 或创建/插入任何数据库都将存储在此处. 此层将仅对数据库的 CRUD 起作用. 这里没有业务流程. 只有简单的数据库功能.

这一层也有责任选择在应用程序中使用什么数据库. 可以是Mysql, MongoDB, MariaDB, Postgresql, 不管是什么, 由这一层决定.

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

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

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

Usecase

此层将充当业务流程处理程序. 任何程序都会在这里处理. 该层将决定使用哪个存储库层. 并有责任提供数据为交付服务. 处理进行计算的数据或在此执行任何操作.

Usecase 层将接受来自传递层的任何输入, 这些输入已经经过处理, 然后可以存储到数据库中, 或从数据库中提取等.

此 Usecase 层依赖 Repository 层.

Delivery

此层将充当演示者. 决定如何显示数据. 可以是 REST API, 或者 HTML 文件, 或者 gRPC, 不管传递类型是什么.
这个层也将接受用户的输入. 处理输入并将其发送到 Usecase 层.

对于我的示例项目, 我使用 REST API 作为传递方法.
客户端将通过网络调用资源端点, Delivery 层将获取输入或请求, 并将其发送到用例层.

此 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 使用。

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 作为模拟查询 mysql 过程的助手.
  • Usecase
    因为这一层依赖于 Repository 层, 意味着这一层需要 Repository 层来进行测试. 所以我们必须根据之前定义的契约接口, 制作一个用 mockery 模拟的 Repository.
  • Delivery
    与 Usecase 层一样, 因为这层依赖 Usecase 层, 意味着我们需要 Usecase 层进行测试. 而且 Usecase 层也必须使用 mockery 进行模拟, 基于前面定义的契约接口

对于模拟, 我使用 vektra 用 golang 写的 mockery 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 测试

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 层的模型. 所以我不需要先完成我的存储库层. 即使我的存储库层还没有实现, 我也可以先完成我的 Usecase 层.

Delivery 层测试

Delivery 测试将取决于您如何交付数据. 如果使用 http REST API, 我们可以在 golang 使用内置包 httptest 进行 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"))
}

你可以看到, 每一层都与其依赖层合并为一个层.

总结:

  • 简而言之, 如果画个图来呈现, 如下

Go

  • 这里使用的每个库都可以自己更改. 因为干净架构的要点是: 不管你的库是什么, 但是你的架构是干净的, 并且可以独立测试
  • 这就是我组织我的项目的方式, 你可以争论, 或者同意, 或者改进它, 使之更好, 只需留下一个注释, 并与大家分享这个示例项目

示例项目

示例项目可以看下这个 https://github.com/bxcodec/go-clean-arch

我的项目中使用的一些库

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

关于干净架构的进一步阅读:

如果您有啥问题, 或需要更多的解释, 或者本文没有更好的解释, 您可以通过我的 linkedin 或 电子邮件 向我提问. 谢谢

本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。

原文地址:https://medium.com/hackernoon/golang-cle...

译文地址:https://learnku.com/go/t/43569

本文为协同翻译文章,如您发现瑕疵请点击「改进」按钮提交优化建议
讨论数量: 1

厉害,学习了 :+1:

3年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!