Попытка реализовать Чистую Архитектуру на Golang

Попытка реализовать Чистую Архитектуру на Golang

Practical.DEV

После прочтения Концепции Чистой Архитектуры дяди Боба, я пытался применить ее к Golang. Эта архитектура аналогична той, которую мы использовали в нашей компании, Kurio-App Berita Indonesia, но отличающаяся структурой. Концепция та же, но структура папок другая.

Вы можете посмотреть пример проекта тут: https://github.com/bxcodec/go-clean-arch.

  • Оговорка: я не рекомендую именно те библиотеки или фреймворки, которые использую здесь. Вы можете заменить их своими собственным или любыми другими, которые имеют те же функции.

Основы

Как мы знаем, существуют ограничения при проектировании Чистой Архитектуры:

1. Независимость от фреймворков. Архитектура не зависит от наличия некоторой библиотеки загруженного функционалом программного обеспечения. Это позволяет использовать подобные фреймворки в качестве инструментов, а не подгонять вашу систему под их рамки.

2. Тестируемость. Бизнес-правила можно протестировать без пользовательского интерфейса, базы данных, веб-сервера или любого другого внешнего элемента.

3. Независимость от пользовательского интерфейса. Пользовательский интерфейс может легко меняться без модификаций основной части системы. Например, веб-интерфейс можно заменить консолью, не трогая бизнес-правила.

4. Независимость от базы данных. Вы можете поменять Oracle или SQL Server на Mongo, BigTable, CouchDB или что-то еще. Бизнес-правила не привязаны к базе.

5. Независимость от любого внешнего агента. Ваши бизнес-правила на самом деле просто ничего не знают о внешнем мире.

Больше на https://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.html

Итак, основываясь на этих ограничениях, получается, что каждый слой должен быть независимым и тестируемым.

В случае Архитектуры дяди Боба есть 4 слоя:

• Сущности (Entities)

• Вариант использования (Usecase)

• Контроллер

• Фреймворк и Драйвер

В моем проекте их тоже 4:

• Модели

• Репозиторий

• Вариант использования (Usecase)

• Доставка (Delivery)

Модели

То же самое, что и сущности, они будут использоваться во всех слоях. Слой модели будет хранить структуру любого объекта и его метод. Например: Статья, Студент, Книга.

Пример структуры:

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"`
}

Любые сущности или модель будут храниться на этом слое.

Репозиторий

Репозиторий будет хранить любой обработчик базы данных: запрос или создание/вставка в любую базу данных. Этот слой будет действовать только для CRUD (сокр. от англ. create, read, update, delete — создать, прочесть, обновить, удалить) в базе данных. Здесь не происходит никаких бизнес-процессов, только простая функция к базе данных.

Этот уровень также несет ответственность за выбор базы данных, которая будет использоваться в приложении: Mysql, MongoDB, MariaDB, Postgresql или что-то еще.

При использовании объектно-реляционного отображения (англ. Object-Relational Mapping, ORM) этот слой будет управлять входными данными и передавать их непосредственно сервисам ORM.

Если происходит вызов микросервисов, то он будет обработан на этом слое. Создайте HTTP-запрос к другим службам и «очистите» данные (прим. имеется ввиду удаление любой некорректной или не разрешенной информации, например, небезопасных тегов). Этот слой должен полностью выступать в качестве репозитория. При обработке всех данных ввода-вывода не происходит никакой конкретной логики.

Данный слой репозитория будет зависеть от подключенной БД или других микросервисов, если они существуют.

Вариант использования (Usecase)

Этот слой будет выступать в качестве обработчика всех бизнес-процессов. Здесь решается, какой слой репозитория будет использоваться. И он будет обязан предоставить данные на доставку. Обработка данных, расчет или что-то подобное будет сделано на этом слое.

Слой варианта использования принимает любые входящие уже «очищенные» данные от слоя доставки. Затем обработанные данные сохраняются в БД или извлекаются из БД.

Данный слой зависит от слоя репозитория.

Поставка

Этот слой будет выступать в качестве предъявителя. Решите, как будут представлены данные, независимо от типа доставки: как REST API, HTML-файл или gRPC. Слой также будет принимать входные данные от пользователя. «Очистите» полученные данные и отправьте их на слой варианта использования.

Для моего примера в качестве метода доставки я использую REST API. Клиент вызовет конечную точку ресурса по сети, а слой доставки получит ввод или запрос и отправит его на слой варианта использования.

Слой доставки будет зависеть от слоя Usecase.

Связь между слоями

За исключением моделей, каждый слой будет взаимодействовать с другими через интерфейс. Например, слою варианта использования нужен слой репозитория. Так как они взаимодействуют? Репозиторий предоставляет интерфейс, который является их соглашением и связью.

Пример интерфейса репозитория:

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:

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 должен реализовать этот интерфейс.

Тестирование каждого слоя

Как мы знаем, чистая архитектура значит независимая. Каждый слой тестируется даже если другие еще не существуют.

• Слой Моделей. Этот слой тестируется только в том случае, если какая-либо функция или метод объявлен в любой из структур. Тестируется легко и независимо от других слоев.

• Репозиторий. Чтобы протестировать этот слой, лучше всего выполнить интеграционное тестирование. Но вы также можете сделать макеты-пустышки (mock) для каждого теста. Я использую go-sqlmock в качестве помощника для имитации процесса запроса msyql.

• Вариант использования (Usecase). Поскольку этот слой зависит от слоя репозитория, он нуждается в нем для тестирования. Поэтому мы должны сделать макет репозитория, который имитирует деятельность, основываясь на определенном заранее интерфейсе.

• Доставка. Та же ситуация что и со слоем варианта использования. Из-за того, что слой доставки зависит от него, нам нужен слой варианта использования для тестирования. И варианта использования также должен имитировать, основываясь на интерфейсе, определенном ранее.

Для имитаций я использую макет для golang от vektra, который можно посмотреть здесь vektra/mockery .

Тестирование репозитория

Как я уже говорил, чтобы проверить этот слой, я использую sql-макет для имитации моего процесса запроса. Вы можете использовать тот же, что и я: 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"))

}

Макет создаст для меня имитацию слоя репозитория. Поэтому мне не нужно сначала заканчивать слой репозитория. Я могу работать, дописывая свой слой варианта использования первым, даже мой слой репозитория еще не реализован.

Тестирование доставки

Тестирование доставки будет зависеть от того, как вы доставляете данные. При использовании http REST API мы можем использовать httptest – встроенный пакет для httptest в golang.

Из-за того, что слой зависит от слоя варианта использования, нам нужен его макет. Так же как с репозиторием, я использую имитацию варианта использования для проверки доставки.

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 из github.com/DATA-DOG/go-sqlmock
  • Testify : для тестирования
  • Echo Labstack (веб-фреймворк Golang) для слоя доставки
  • Viper : для настройки окружения

Оригинал - hackernoon

Report Page