Каркас API на Golang

    В процессе знакомства с 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

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 34

      +2

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

        0

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

          +1

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

            0

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

              0
              А «Hand-written service containers» в Go имеют смысл? (https://matthiasnoback.nl/2019/03/hand-written-service-containers/)
            0
            DI — это антипаттерн

            Не надо быть настолько категоричным. Не все реализации DI — антипаттерны.
              +2
              А что есть взамен DI?
                0
                Возможно вы имели в виду конкретные фреймворки для реализации DI контейнеров?
                DI можно сделать без фреймворков явно через конструкторы, с честным composition root
                  0

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


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

                    0

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

                  0
                  Раскрутите пожалуйста ваши аргументы про антипаттерны.
                  +3

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


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


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

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

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

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

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

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

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

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

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

                          0
                          Самый толковый коммент!
                          0

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


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

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


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

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

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

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

                            аве


                            взято здесь -> t.me/oleg_log/1138
                              0
                              Отсутвие генериков мешает писать масштабные приложения? А нам то PHP-шникам никто об этом не сказал! Что делать, как теперь жить с этим знанием то?
                                +1

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

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

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

                                      0
                                      Ок. Принято.
                                        0

                                        Самое большая проблема Go, что люди на нём пишут кто на Java, кто на PHP, кто ещё на чем-то!


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

                                          0

                                          Частично согласен, интерфейсы в Go хорошо подходят, когда надо описать поведение. Для описания данных они не подходят.
                                          Когда в Go реализуется, например, репозиторий, который должен работать с сущностями конкретного типа, хотелось бы использовать генерики вместо слишком общего interface{}.

                                  0
                                  Отличная статья про то как последовательно и педантично превратить проект в дичайший пипец из либ, зависимостей, антипаттернов и просто бреда сивой кобылы.

                                  Я лет 5 на ГО пишу, и от этой статьи у меня течет кровь из глаз.
                                    0

                                    Мне время от времени встречаются разработчики, которые тоже придерживаются мнения, что надо делать "по простому". Недавно вообще попался парень, который даже composer при разработке на PHP не использует. Но по факту код таких ребят оставляет желать лучшего. А истинная причина желания писать "по простому" в нежелании поработать над собой, чтобы научиться делать технологичный код.

                                      0
                                      Какой код? Технологический? гыгы

                                      Это такой код где для простейшего действия применяются огромные либы в концепции чем больше в ряд их составить тем круче? :)

                                      Не, вообще вот этот твой трепетный пыл неокрепшего ума на самом деле даже мил. Но в жизни все не так :)
                                        0

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


                                        Поделитесь примером, как на самом деле по вашему мнению надо проектировать код, мне не удалось найти вас на гитхабе.

                                          0
                                          Ну в комментах популярно объяснили что это не нормальный подход, а дич.
                                            0

                                            Значит код показать не можете. Ок.


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


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

                                              0
                                              Комменты у тебя такие же нудные и мутные как статья. фу нах
                                                0

                                                А я вам ставлю пять за умение мыслить по-гуманитарному. Удачи.

                                  Only users with full accounts can post comments. Log in, please.