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

    От переводчика: данная статья написана 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, чтобы получить необходимые данные.

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

    Ну, и что?
    Реклама
    Комментарии 11
    • –2
      SQL-инъекция детектед:
          repo.dbHandler.Execute(fmt.Sprintf(`INSERT INTO customers (id, name)
                                              VALUES ('%d', '%v')`,
                                              customer.Id, customer.Name))
      


      customer.Name это строка, никто кастомеру не помешает сделать имя
      Bobby'); DROP TABLE customers;--


      И самое интересное не показано:
      1) Как выбирается OrderInteractor\AdminOrderInteractor
      2) Как происходит обработка ошибок
      В прошлом посте львиная доля кода уделена этим аспектам, а в текущем про них забыли начисто.
      • 0
        Вы знаете, а мне нравится Ваш комментарий :))))

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

        И тут Ваш комментарий: Ю-ху-у-у-у!!! Я нашел дурнопахнущий код!!!

        Спасибо, Вы отлично подметили про SQL-инъекции!

        Если же говорить про то, что не показано:
        1) Это следует из логики кода, например в админской панели, видимо использовался бы AdminOrderInteractor, в интерфейсе пользователя — OrderInteractor
        2) Давайте поговорим об этом — что Вам осталось не ясно в этом аспекте?
        • –2
          мне бы было стыдно столь плохой код публиковать, независимо от контекста. Разе в Go нету способа по-умолчанию не допускать таких проблем?

          По основным вопросам
          1) То есть опять будет копипаста? Ведь интерфейсы OrderInteractor и AdminOrderInteractor совпадают. Тогда в чем вообще смысл был разбиения на сценарии и сервисы? Если одному методу сценария соответствует ровно один метод сервиса.
          2) Например как корректно обработать ошибку в слое «сценариев»? Сейчас код её игнорирует от слова вообще. Или вы не считаете что это важный архитектурный вопрос?
          • 0
            Правильно ли я понимаю, что для Вас «хорошая архитектура» === «хороший код»?
            • –1
              Неправильно. Для меня хорошая архитектура помогает писать хороший код.
              Архитектура сама по себе ценности не имеет. Если в приложении полно ошибок, уязвимостей, оно тормозит, то никакая хорошая архитектура не спасет. Поэтому архитектура нужна только для того, чтобы делать код лучше.

              В статье код — говно полное, в чем хорошесть архитектуры — большой вопрос. В прошлом посте автор признал, что речь не о хорошей архитектуре, а вообще какбы словарик без претензий на хорошесть.
              • 0
                В прошлом посте автор признал, что речь не о хорошей архитектуре, а вообще какбы словарик без претензий на хорошесть.

                А покажите где такое было?
                • –2
                  habrahabr.ru/post/270351/#comment_8653111
                  … Об этом статья. Она явно не для опытных разработчиков, просто базовые понятия логического разделения структур в коде.


                  Хотя может и не автор, я не вникал.
                  • 0
                    Вообще-то это мои слова, и имел ввиду я явно не «речь не о хорошей архитектуре, а вообще какбы словарик без претензий на хорошесть». Речь была именно об архитектуре минуя красивости кода. Если хотите конкретики, то представьте что статья написана в UML без строчки кода.
            • 0
              Разе в Go нету способа по-умолчанию не допускать таких проблем?

              из коробки вроде нет. Но можно юзать ормы: github.com/avelino/awesome-go#orm
              • 0
                И из коробки есть, если говорить о пакете database/sql
        • +1
          Для всех, кто прочёл все три части этой серии статей мной сделана простая визуализация кода, по возможности, вставленная в типичное графическое представление «чистой архитектуры». В вставленном в схему коде я заменил кое-что на многоточие, чтобы немного уменьшить его объем и вместить всё в формат А3.

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

          Самое читаемое