Pull to refresh

Чистая архитектура в Go-приложении. Часть 3

Reading time 8 min
Views 17K
От переводчика: данная статья написана Manuel Kiessling в сентябре 2012 года, как реализация статьи Дядюшки Боба о чистой архитектуре с учетом Go-специфики.



Это третья статья цикла об особенности реализации Чистой Архитектуры в Go. [Часть 1] [Часть 2]

На данный момент все, что должно было быть сказано о бизнесе и Сценариях — сказано. Давайте теперь посмотрим на слой интерфейсов. В то время как код внутренних слоев логически расположен вместе, код интерфейсов состоит из нескольких частей, которые существуют отдельно, поэтому мы разделим код на несколько файлов. Начнем с веб сервиса:

// $GOPATH/src/interfaces/webservice.go
package interfaces

import (
    "fmt"
    "io"
    "net/http"
    "strconv"
    "usecases"
)

type OrderInteractor interface {
    Items(userId, orderId int) ([]usecases.Item, error)
    Add(userId, orderId, itemId int) error
}

type WebserviceHandler struct {
    OrderInteractor OrderInteractor
}

func (handler WebserviceHandler) ShowOrder(res http.ResponseWriter, req *http.Request) {
    userId, _ := strconv.Atoi(req.FormValue("userId"))
    orderId, _ := strconv.Atoi(req.FormValue("orderId"))
    items, _ := handler.OrderInteractor.Items(userId, orderId)
    for _, item := range items {
        io.WriteString(res, fmt.Sprintf("item id: %d\n", item.Id))
        io.WriteString(res, fmt.Sprintf("item name: %v\n", item.Name))
        io.WriteString(res, fmt.Sprintf("item value: %f\n", item.Value))
    }
}


Мы не собираемся реализовывать все веб-сервисы здесь, поскольку все они выглядят более-менее одинаково. В реальном приложении, например, мы бы так же реализовали добавление товара в заказ, доступ администратора к заказу и т.д.

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

Следует в очередной раз отметить, что инъекции кода тут используются для обработки зависимостей. Обработка заказа в продакшене была бы через реальный usecases.OrderInteractor, но в случае тестирования этот объект легко мокается, что позволяет протестировать веб-сервис изолированно, что в первую очередь означает, что юнит-тесты будут тестировать именно поведение обработчиков веб-сервиса.

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

Тем не менее стоит сказать несколько слов о сессиях и куках. В первую очередь нужно отметить, что сессии и куки это сущности разных концептуальных уровней. Куки — это низкоуровневый механизм, который по сути работает с HTTP-заголовками. В то время как сессии — это в некотором роде абстракция, позволяющая в рамках разных запросов работать в контексте одного пользователя, что реализуется, например, через куки.

Пользователи же — абстракция еще более высокого уровня: «персона, которая взаимодействует с приложением» и делается это в том числе посредством сессий. И, наконец, есть клиент, сущность, которая работает в терминах бизнеса, посредством пользователя, который… ну вы поняли идею.

Я рекомендую делать это разделения по уровням абстракции явным образом и сразу, избегая тем самым проблем в будущем. Как пример такой ситуации — необходимость перевести механизм сессий с использования кук на клиентские SSL-сертификаты. При правильной абстракции вам потребуется добавить только библиотеку для работы с сертификатами на инфраструктурном слое и код интерфейса для работы с ними в слое Интерфейсов. И эти изменения не затронут ни пользователей ни клиентов.

Также на слое интерфейсов размещается код, который создает HTML-ответы на основании данных, которые приходят со слоя Сценариев. В реальном приложении, скорее всего это будет сделано с помощью некоего шаблонизатора, размещенного в слое Инфраструктуры.

Давайте теперь перейдем к последнему блоку — хранению. У нас уже есть работающий код слоя Домена, у нас реализован слой Сценариев отвечающий за доставку данных и мы реализовали интерфейс, который позволяет получить пользователям доступ к нашему приложению через веб. Теперь мы должны реализовать сохранение данных на диск.

Это делается путем реализации абстрактных репозиториев, интерфейсы которых мы видели на слое Домена и Сценариев. Делается это на слое Интерфейсов поскольку это интерфейс между БД (низкоуровневой реализацией хранения) и высокоуровневыми бизнес-сущностями.

Некоторые реализации репозиториев могут быть изолированы в зависимостях от слоя Интерфейсов и ниже, например при реализации кеширования объектов памяти или при реализации моков для юнит-тестирования. Однако большинство реализаций репозиториев должны взаимодействовать с внешними механизмами персистентного хранения (БД), скорее всего посредством некоторых библиотек и тут мы должны еще раз убедиться, что мы не нарушаем Правило Зависимостей, поскольку библиотеки у нас должны размещаться в слое Инфраструктуры.

Это не значит, что Репозиторий изолирован от БД! Репозиторий отлично представляет, что он передает в БД, но делает это в некоем высокоуровневом представлении. Получить данные из этой таблицы, положить данные в вон ту таблицу. Низкоуровневые же операции или «физические» такие как, установление соединения с БД, принятие слейва для чтения или мастера для записи, обработка таймаутов и тому подобные штуки — это инфраструктурные вопросы.

Другими словами, нашему Хранилищу нужно использовать некий высокоуровневый интерфейс, который бы скрывал все эти низкоуровневые штуки.

Давайте создадим такой интерфейс:
type DbHandler interface {
    Execute(statement string)
    Query(statement string) Row 
}

type Row interface {
    Scan(dest ...interface{})
    Next() bool
}


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

В слое Инфраструктуры мы реализуем некий связывающий код, который позволяет работать с БД через библиотеку для sqlite3 и реализует работу этого интерфейса. но сначала давайте закончим реализацию Репозитория:

// $GOPATH/src/interfaces/repositories.go
package interfaces

import (
    "domain"
    "fmt"
    "usecases"
)

type DbHandler interface {
    Execute(statement string)
    Query(statement string) Row
}

type Row interface {
    Scan(dest ...interface{})
    Next() bool
}

type DbRepo struct {
    dbHandlers map[string]DbHandler
    dbHandler  DbHandler
}

type DbUserRepo DbRepo
type DbCustomerRepo DbRepo
type DbOrderRepo DbRepo
type DbItemRepo DbRepo

func NewDbUserRepo(dbHandlers map[string]DbHandler) *DbUserRepo {
    dbUserRepo := new(DbUserRepo)
    dbUserRepo.dbHandlers = dbHandlers
    dbUserRepo.dbHandler = dbHandlers["DbUserRepo"]
    return dbUserRepo
}

func (repo *DbUserRepo) Store(user usecases.User) {
    isAdmin := "no"
    if user.IsAdmin {
        isAdmin = "yes"
    }
    repo.dbHandler.Execute(fmt.Sprintf(`INSERT INTO users (id, customer_id, is_admin)
                                        VALUES ('%d', '%d', '%v')`,
                                        user.Id, user.Customer.Id, isAdmin))
    customerRepo := NewDbCustomerRepo(repo.dbHandlers)
    customerRepo.Store(user.Customer)
}

func (repo *DbUserRepo) FindById(id int) usecases.User {
    row := repo.dbHandler.Query(fmt.Sprintf(`SELECT is_admin, customer_id
                                             FROM users WHERE id = '%d' LIMIT 1`,
                                             id))
    var isAdmin string
    var customerId int
    row.Next()
    row.Scan(&isAdmin, &customerId)
    customerRepo := NewDbCustomerRepo(repo.dbHandlers)
    u := usecases.User{Id: id, Customer: customerRepo.FindById(customerId)}
    u.IsAdmin = false
    if isAdmin == "yes" {
        u.IsAdmin = true
    }
    return u
}

func NewDbCustomerRepo(dbHandlers map[string]DbHandler) *DbCustomerRepo {
    dbCustomerRepo := new(DbCustomerRepo)
    dbCustomerRepo.dbHandlers = dbHandlers
    dbCustomerRepo.dbHandler = dbHandlers["DbCustomerRepo"]
    return dbCustomerRepo
}

func (repo *DbCustomerRepo) Store(customer domain.Customer) {
    repo.dbHandler.Execute(fmt.Sprintf(`INSERT INTO customers (id, name)
                                        VALUES ('%d', '%v')`,
                                        customer.Id, customer.Name))
}

func (repo *DbCustomerRepo) FindById(id int) domain.Customer {
    row := repo.dbHandler.Query(fmt.Sprintf(`SELECT name FROM customers
                                             WHERE id = '%d' LIMIT 1`,
                                             id))
    var name string
    row.Next()
    row.Scan(&name)
    return domain.Customer{Id: id, Name: name}
}

func NewDbOrderRepo(dbHandlers map[string]DbHandler) *DbOrderRepo {
    dbOrderRepo := new(DbOrderRepo)
    dbOrderRepo.dbHandlers = dbHandlers
    dbOrderRepo.dbHandler = dbHandlers["DbOrderRepo"]
    return dbOrderRepo
}

func (repo *DbOrderRepo) Store(order domain.Order) {
    repo.dbHandler.Execute(fmt.Sprintf(`INSERT INTO orders (id, customer_id)
                                        VALUES ('%d', '%v')`,
                                        order.Id, order.Customer.Id))
    for _, item := range order.Items {
        repo.dbHandler.Execute(fmt.Sprintf(`INSERT INTO items2orders (item_id, order_id)
                                            VALUES ('%d', '%d')`,
                                            item.Id, order.Id))
    }
}

func (repo *DbOrderRepo) FindById(id int) domain.Order {
    row := repo.dbHandler.Query(fmt.Sprintf(`SELECT customer_id FROM orders
                                             WHERE id = '%d' LIMIT 1`,
                                             id))
    var customerId int
    row.Next()
    row.Scan(&customerId)
    customerRepo := NewDbCustomerRepo(repo.dbHandlers)
    order := domain.Order{Id: id, Customer: customerRepo.FindById(customerId)}
    var itemId int
    itemRepo := NewDbItemRepo(repo.dbHandlers)
    row = repo.dbHandler.Query(fmt.Sprintf(`SELECT item_id FROM items2orders
                                            WHERE order_id = '%d'`,
                                            order.Id))
    for row.Next() {
        row.Scan(&itemId)
        order.Add(itemRepo.FindById(itemId))
    }
    return order
}

func NewDbItemRepo(dbHandlers map[string]DbHandler) *DbItemRepo {
    dbItemRepo := new(DbItemRepo)
    dbItemRepo.dbHandlers = dbHandlers
    dbItemRepo.dbHandler = dbHandlers["DbItemRepo"]
    return dbItemRepo
}

func (repo *DbItemRepo) Store(item domain.Item) {
    available := "no"
    if item.Available {
        available = "yes"
    }
    repo.dbHandler.Execute(fmt.Sprintf(`INSERT INTO items (id, name, value, available)
                                        VALUES ('%d', '%v', '%f', '%v')`,
                                        item.Id, item.Name, item.Value, available))
}

func (repo *DbItemRepo) FindById(id int) domain.Item {
    row := repo.dbHandler.Query(fmt.Sprintf(`SELECT name, value, available
                                             FROM items WHERE id = '%d' LIMIT 1`,
                                             id))
    var name string
    var value float64
    var available string
    row.Next()
    row.Scan(&name, &value, &available)
    item := domain.Item{Id: id, Name: name, Value: value}
    item.Available = false
    if available == "yes" {
        item.Available = true
    }
    return item
}


Я уже слышу от тебя: это ужасный код! :) Много дублирования, нет обработки ошибок и несколько других дурнопахнущих вещей. Но смысл этой статьи ни в объяснении стилистики кода, ни реализации шаблонов проектирования — это все про АРХИТЕКТУРУ приложения, поэтому код написан так, чтобы на его примере было проще объяснить и было проще читать эту статью. Этот код очень упрощен — его главная и единственная задача: быть простым и понятным.

Обратите внимание на dbHandlers map[string]DbHandler в каждом репозитории — здесь каждый репозиторий может использовать другой репозиторий без использования Dependency Injection — если какие-либо из репозиториев используют некую иную реализацию dbHandlers, то остальные репозитории не должны задумываться о том, кто и что использует. Этакая реализация DI для бедных.

Давайте разберем один из наиболее интересных методов — DbUserRepo.FindById(). Это хороший пример, чтобы показать, что в нашей архитектуре Интерфейсы — это все о преобразовании данных из одного слоя к другому. FindById читает записи из БД и создает по ним объекты для уровня Сценариев и Домена. Я сознательно сделал представление атрибута User.IsAdmin сложнее, чем это необходимо, сохраняя его в БД как поле типа varchar со значениями «да» и «нет». На уровне Сценариев это конечно представлено как булево значение. На этом разрыве представлений между слоями мы проиллюстрируем преодоление границ между слоями данных с разными представлениями.

Сущность User имеет атрибут Customer — это по сути отсылка к Домену. Репозиторий User просто использует репозиторий Customer, чтобы получить необходимые данные.

Легко представить как подобная архитектура может помочь нам когда наше приложение будет расти. Следуя Правилу Зависимостей мы сможем переработать реализацию хранения данных без того, чтобы перерабатывать сущности и слои. Например, мы могли бы решить, что данные объекта в БД можно хранить в нескольких таблицах, но разделение данных для сохранения и сборка для передачи объектов в приложение будет скрыта в репозитории и не повлияет на остальные слои.
Tags:
Hubs:
+5
Comments 11
Comments Comments 11

Articles