Pull to refresh

Каркас API на Golang

Reading time 8 min
Views 27K

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

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+3
Comments 34
Comments Comments 34

Articles