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


image


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


В статью старался вставлять поменьше кода, вместо этого добавил ссылки на конкретные строчки кода на Github в надежде, что так будет удобнее увидеть картину в целом.


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


  • Выбрать менеджер пакетов
  • Выбрать фреймворк для создания API
  • Выбрать инструмент для Dependency Injection (DI)
  • Маршруты веб-запросов
  • Ответы в формате JSON/XML в соответствии с заголовками запроса
  • ORM
  • Миграции
  • Сделать базовые классы для слоев моделей Service->Repository->Entity
  • Базовый CRUD репозиторий
  • Базовый CRUD сервис
  • Базовый CRUD контроллер
  • Валидация запросов
  • Конфиги и переменные окружения
  • Консольные команды
  • Логирование
  • Интеграция логгера с Sentry или другой системой алертинга
  • Настройка алертинга для ошибок
  • Юнит-тесты с переопределением сервисов через DI
  • Процент и карта покрытия кода тестами
  • Swagger
  • Docker compose

Менеджер пакетов


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


Информация о пакетах и их версиях хранится в одном файлике vendor.json. Свой минус в этом подходе тоже есть. Если добавить пакет с его зависимостями, то вместе с информацией о пакете в файлик попадет информация и о его зависимостях. Файлик быстро разрастается и по нему уже нельзя четко определить, какие зависимости главные, а какие — производные.


В PHP-шном Composer или в npm в одном файлике описываются главные зависимости, а в lock файле автоматически записываются все основные и производные зависимости и их версии. Такой подход более удобен на мой взгляд. Но пока мне хватило реализации govendor.


Фреймворк


От фреймворка мне не так уж и много надо, удобный маршрутизатор, валидация запросов. Все это нашлось в популярном Gin. На нем и остановился.


Dependency Injection


С DI пришлось немного помучаться. Сначала выбрал Dig. И сначала все было отлично. Описал сервисы, Dig далее сам строит зависимости, удобно. Но потом оказалось, что сервисы нельзя переопределить, например, при тестировании. Поэтому в итоге пришел к тому, что взял простой сервис контейнер sarulabs/di.


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


Но в итоге, как в случае с Dig, так и в случае с сервис контейнером, пришлось тесты вынести в отдельный пакет. Иначе получается, что тесты запускаются отдельно по пакетам (go test model/service), но не запускаются сразу для всего приложения (go test ./...), из-за возникающих при этом циклических зависимостей.


Ответы в формате JSON/XML в соответствии с заголовками запроса


В Gin этого не нашел, поэтому просто добавил в базовый контроллер метод, который формирует ответ в зависимости от заголовка запроса.


func (c BaseController) response(context *gin.Context, obj interface{}, code int) {
  switch context.GetHeader("Accept") {
     case "application/xml":
        context.XML(code, obj)
     default:
        context.JSON(code, obj)
  }
}

ORM


С ORM долгих мук выбора не испытывал. Было из чего выбирать. Но по описанию функций понравился GORM, он же один из популярнейших на момент выбора. Есть поддержка наиболее часто используемых СУБД. По крайней мере PostgreSQL и MySQL там точно есть. В ней же есть и методы для управления схемой базы, которые можно использовать при создании миграций.


Миграции


Для миграций остановился на пакете gorm-goose. Ставлю отдельным пакетом глобально и запускаю им миграции. Сперва смутила такая реализация, так как соединение с базой приходится описывать в отдельном файле db/dbconf.yml. Но потом оказалось, что строку соединения в нем можно описать таким образом, чтобы значение бралось из переменной окружения.


development:
 driver: postgres
 open: $DB_URL

А это довольно удобно. По крайней мере с docker-compose не пришлось дублировать строку соединения.


Gorm-goose также поддерживает откаты миграций, что считаю очень полезным.


Базовый CRUD репозиторий


Я предпочитаю все, что обращается к ресурсам, выносить в отдельный слой репозитория. На мой взгляд, при таком подходе код бизнес-логики получается более чистым. Код бизнес-логики в таком случае знает только то, что ему нужно работать с данными, которые он берет из репозитория. А что происходит в репозитории, бизнес-логике не важно. Репозиторий может работать с реляционной базой, с KV-хранилищем, с диском, а может и с API другого сервиса. Код бизнес-логики во всех этих случаях будет одинаковым.


CRUD репозиторий реализует следующий интерфейс


type CrudRepositoryInterface interface {
  BaseRepositoryInterface
  GetModel() (entity.InterfaceEntity)
  Find(id uint) (entity.InterfaceEntity, error)
  List(parameters ListParametersInterface) (entity.InterfaceEntity, error)
  Create(item entity.InterfaceEntity) entity.InterfaceEntity
  Update(item entity.InterfaceEntity) entity.InterfaceEntity
  Delete(id uint) error
}

То есть реализует CRUD операции Create(), Find(), List(), Update(), Delete() и метод GetModel().


Насчет GetModel(). Есть базовый репозиторий CrudRepository, который реализует основные CRUD операции. В репозиториях, которые встраивают его к себе, достаточно указать с какой моделью они должны работать. Для этого метод GetModel() должен возвращать модель GORM. Далее пришлось с помощью рефлексии в CRUD методах использовать результат GetModel().


Например,


func (c CrudRepository) Find(id uint) (entity.InterfaceEntity, error) {
  item := reflect.New(reflect.TypeOf(c.GetModel()).Elem()).Interface()
  err := c.db.First(item, id).Error
  return item, err
}

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


Для того, чтобы в репозиториях, работающих с конкретными моделями, можно было реализовать свои правила для фильтрации списков в методе List(), сперва сделал реализацию позднего связывания, чтобы из метода List() вызывался метод, отвечающий за построение запроса на выборку. И этот метод можно было реализовать в конкретном репозитории. Сложно как-то отказываться от шаблонов мышления, которые сформировались при работе с другими языками. Но, взглянув на это свежим взглядом, и оценив “изящность” выбранного пути, потом все же переделал на подход, который ближе к Go. Для этого просто в CrudRepository через интерфейс объявлен построитель запросов, который уже используется в List().


listQueryBuilder ListQueryBuilderInterface

Получается довольно забавно. Ограничение языка на позднее связывание, которое сперва кажется недостатком, подталкивает к более четкому разделению кода.


Базовый CRUD сервис


Тут ничего интересного нет, так как в каркасе нет бизнес-логики. Просто проксируются вызовы CRUD методов в репозиторий.


В слое сервисов должна быть реализована бизнес-логика.


Базовый CRUD контроллер


В контроллере реализованы CRUD методы. В них обрабатываются параметры из запроса, передается управление соответствующему методу сервиса, и на основе ответа сервиса формируется ответ клиенту.


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


В гидраторе, который идет с CRUD контроллером, обрабатываются только параметры для пагинации. В конкретных контроллерах, в которые встраивается CRUD контроллер, можно переопределять гидратор.


Валидация запросов


Валидация выполняется средствами Gin. Например, при добавлении записи (метод Create()), достаточно продекорировать элементы структуры сущности


Name string  `binding:"required"`

Метод фреймворка ShouldBindJSON() заботится о проверке параметров запроса на соответствии требований, описанных в декораторе.


Конфиги и переменные окружения


Мне очень понравилась реализация Viper, особенно в связке с Cobra.


Чтение конфига я описал в main.go. Базовые параметры, которые не содержат секретов, описываются в файле base.env. Переопределить их можно в файле .env, который добавлен в .gitignore. В .env можно описывать секретные значения для окружения.


Более высокий приоритет имеют переменные окружения.


Консольные команды


Для описания консольных команд выбрал Cobra. Чем хорошо использовать Cobra вместе с Viper. Можем описать команду


serverCmd.PersistentFlags().StringVar(&serverPort, "port", defaultServerPort, "Server port")

И связать переменную окружения со значением параметра команды


viper.BindPFlag("SERVER_PORT", serverCmd.PersistentFlags().Lookup("port"))

По сути все приложение этого каркаса — консольное. Веб-сервер запускается одной из консольных команд server.


gin -i run server

Логирование


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


Интеграция логгера с системой алертинга


Я выбрал Sentry, так как с ней все оказалось совсем просто благодаря готовой интеграции с logrus: logrus_sentry. В конфиг вынес параметры с урлом к Sentry SENTRY_DSN и таймаут на отправку в Sentry SENTRY_TIMEOUT. Оказалось, что по умолчанию таймаут небольшой, если не ошибаюсь, 300 мс, и многие сообщения не доставлялись.


Настройка алертинга для ошибок


Обработку паников сделал отдельно для веб-сервера и для консольных команд.


Юнит-тесты с переопределением сервисов через DI


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


dic.InitBuilder()

И переопределить на заглушки описание лишь некоторых сервисом таким образом


dic.Builder.Set(di.Def{
  Name: dic.UserRepository,
  Build: func(ctn di.Container) (interface{}, error) {
     return NewUserRepositoryMock(), nil
  },
})

Далее можно строить контейнер и использовать нужные сервисы в тесте:


dic.Container = dic.Builder.Build()
userService := dic.Container.Get(dic.UserService).(service.UserServiceInterface)

Таким образом, будем тестировать userService, который вместо настоящего репозитория будет использовать предоставленную заглушку.


Процент и карта покрытия кода тестами
Меня полностью устроила штатная утилита go test.


Можно запускать тесты по отдельности


go test test/unit/user_service_test.go -v

Можно запустить все тесты разом


go test ./... -v

Можно построить карту покрытия и посчитать процент покрытия


go test ./... -v -coverpkg=./... -coverprofile=coverage.out

И посмотреть карту покрытия кода тестами в браузере


go tool cover -html=coverage.out

Swagger


Для Gin есть проект gin-swagger, который можно использовать и для генерации спецификации для Swagger и для генерации документации на ее основе. Но, как оказалось, для генерации спецификации на конкретные операции, необходимо указывать комментарии к конкретным функциям контроллера. Для меня это оказалось не очень удобно, так как я не хотел дублировать код CRUD операций в каждом контроллере. Вместо этого в конкретные контроллеры я просто встраиваю CRUD контроллер, как описано выше. Создавать функции-заглушки для этого тоже не очень хотелось.


Поэтому пришел к тому, что генерацию спецификации выполняю с помощью goswagger, потому что в таком случае операции можно описывать не привязываясь к конкретным функциям.


swagger generate spec -o doc/swagger.yml

Кстати, с goswagger можно было бы даже идти от обратного, и код веб-сервера генерировать на основе спецификации Swagger. Но при таком подходе возникали сложности с использованием ORM и я от этого в итоге отказался.


Генерация документации выполняется с помощью gin-swagger, для этого указывается заранее сгенерированный файл со спецификацией.


Docker compose


В каркас добавил описание двух контейнеров — для кода и для базы. При старте контейнера с кодом ждем когда полностью запустится контейнер с базой. И при каждом старте накатываем миграции при надобности. Параметры соединения с базой для выполнения миграций, описываются, как уже упоминал выше, в dbconf.yml, где удалось использовать переменную окружения для передачи настроек соединения с БД.


Спасибо за внимание. В процессе пришлось подстраиваться под особенности языка. Мне было бы интересно узнать мнение коллег, которые с Go провели больше времени. Наверняка какие то моменты можно было бы сделать более элегантно, поэтому буду рад полезной критике. Ссылка на каркас: https://github.com/zubroide/go-api-boilerplate

Комментарии (22)


  1. mrobespierre
    10.06.2019 08:35

    Дайте угадаю, Go не первый язык, а до него был C#? DI — это антипаттерн, ORM — тоже. GORM — два антипаттерна.


    1. mnv Автор
      10.06.2019 08:58

      Являются ли DI и ORM антипаттернами в конкретных проектах, зависит от того, что предлагаете использовать взамен и в каких проектах.


      1. TonyLorencio
        10.06.2019 09:13
        +1

        В таком DI слишком много завязано на магии и interface{}, что совсем не есть хорошо. Типобезопасности ноль. Если уж так хочется использовать DI, то лучше использовать что-то с кодогенерацией (и нормальными типами) и Intejection в Compile-time, например https://github.com/google/wire


        1. mnv Автор
          10.06.2019 09:21

          Посматривал на Wire, но после опыта с Dig взял вариант попроще. Но раз советуете, посмотрю на Wire внимательнее.


        1. hudson
          10.06.2019 21:20

          А «Hand-written service containers» в Go имеют смысл? (https://matthiasnoback.nl/2019/03/hand-written-service-containers/)


    1. NYMEZIDE
      10.06.2019 09:15

      DI — это антипаттерн

      Не надо быть настолько категоричным. Не все реализации DI — антипаттерны.


    1. xenm
      10.06.2019 13:35

      А что есть взамен DI?


    1. SadOcean
      10.06.2019 15:10

      Возможно вы имели в виду конкретные фреймворки для реализации DI контейнеров?
      DI можно сделать без фреймворков явно через конструкторы, с честным composition root


      1. TonyLorencio
        10.06.2019 18:00

        Я понимаю, что автор, и большинство комментирующих, включая меня, имели ввиду не сам DI, а именно DI container. Они зачастую ходят парой, и люди, говоря DI, часто подразумевают именно DI container.


        В Go обычно используется именно подход с ручным внедрением зависимостей при создании структур, а DI Container считается антипаттерном.


        1. mnv Автор
          11.06.2019 10:18

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


  1. TonyLorencio
    10.06.2019 09:21
    +1

    Непонятен выбор govendor, в то время как после него уже вышли более удобные и поддерживаемые альтернативы, ставшие стандартом де-факто:


    1) dep — официальный эксперимент, который все ещё поддерживается (однако, поддержку новых версионных импортов не завезли, многие новые библиотеки с мажорной версией >1 загрузить через него, как и через более старые инструменты, вообще нельзя)
    2) go modules (официальный инструмент в комплекте go 1.11+), позволяет в том числе и вендорить зависимости.


    Использование go-modules — задел на будущее


    1. DrAndyHunter
      10.06.2019 11:40

      > Использование go-modules — задел на будущее

      Поддерживаю, я сам новичек в go, и в своих проектах использую go mod, получается просто, ничего лишнего.

      Для логирования использую zap и самописный middleware для net/http хэндлеров.
      Для общения с базой встроенный database/sql

      Автор по-моему забыл про обработку фоновых задач.
      Я пробовал machinery, но что-то он уж слишком тяжел, сейчас рассматриваю work


    1. mnv Автор
      10.06.2019 19:28

      Пока смотрел сторонние библиотеки, обратил внимание, что почти во всех есть go.mod. Но вот попробовал использовать go modules и столкнулся с тем, что возникают проблемы при выборе версий пакетов


  1. esata
    10.06.2019 13:08

    Оффтоп, но все же:

    В качестве замены makefile в го проектах, можно использовать Gilbert — github.com/go-gilbert/gilbert


  1. pfihr
    10.06.2019 20:25
    +1

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


  1. kuftachev
    11.06.2019 01:15

    Советую посмотреть ozzo-{что-то}. Это независимые библиотеки от создателя Yii-фреймворка на PHP.
    Мы используем его:


    1. Логер, он реально крут, по моему мнения вне конкуренции на данный момент.
    2. Валидацию, все просто, удобно и хорошо группируются ошибки.
    3. Роутер, на самом деле используем не на 100%, он, не понимаю почему, построен по аналогии с express.js и другими подобными решениями под node.js с передачей запросов по цепочке обработчиков, что на самом деле не имеет смысла в Го, так как есть нормальный стек вызовов. Не используем его обработчик ошибок.

    На счёт DI, не знаю с каких пор он стал анти-паттерном? Вот IoC реализовать в Go я бы не решился. Может я что-то не правильно понимаю, но IoC — это когда контейнер сам управляет приложением и мы просто даём знать объект какого интерфейса нам нужен, а в DI мы можем и сами взять из контейнера зависимости.


    Может я путаюсь в этих терминах, но мы тоже решили оставить набрать типизацию, по возможности без всяких interface {}. Просто есть пакет container, которой создаёт все нужные зависимости уровня приложения, типа соединения с БД или логом, а потом в приложении каждый тип достает из контейнера то, что ему нужно.


  1. denaspireone
    11.06.2019 12:01

    Спасибо Вселенной, что я не участвую в подобных проектах:

    — govendor
    — Gin (он норм, вот только из-за его няшности возникают проблемы с зависимостями, прям удивительно, тащить фреймворк и страдать от его топорности. люди иммутабельны)
    — DI в рантайме
    — толстые CRUD-интерфейсы
    — Viper для вязкости конфига
    — logrus… не буду много говорить, потому что сильное имхо, пусть буит
    — лол втф «для юнит-тестов пришлось выделить отдельный пакет. <..> библиотека для создания сервис контейнера не позволяла переопределять сервисы»

    1 коммент вместо саммари:
    Теперь ждем от тебя статью, как ты быстро отказался от всего этого и начал уже программировать на Go.

    аве


    взято здесь -> t.me/oleg_log/1138


  1. MadHarper
    12.06.2019 20:30

    Отсутвие генериков мешает писать масштабные приложения? А нам то PHP-шникам никто об этом не сказал! Что делать, как теперь жить с этим знанием то?


    1. mnv Автор
      12.06.2019 20:36

      В PHP динамическая типизациия. Там это не очень то и нужно.


      1. MadHarper
        12.06.2019 20:58

        У Вас телега впереди лошади. Генерики пригодились бы и нам, и в 8-ой версии нам их обещают. Но вот что бы без них нельзя было писать сложные приложения — нонсенс.


        1. mnv Автор
          12.06.2019 21:12

          Конечно можно, и примеры есть. Docker, например. На PHP 4 без классов тоже были довольно масштабные приложения.
          Я о другом. Если в PHP без генериков можно спокойно обойтись, то в Go приходится либо пользоваться рефлексией, по сути отказываясь от статической типизации, либо использовать много где interface{}, либо копипастить, либо придумывать особые реализации.


          1. MadHarper
            12.06.2019 21:57

            Ок. Принято.