Go-swagger как основа взаимодействия микросервисов

g-rksggos7bnecfq1uzs_qgq9kc.png

Здравствуй, NickName! Если ты программист и работаешь с микросервисной архитектурой, то представь, что тебе нужно настроить взаимодействие твоего сервиса А с каким-то новым и ещё неизвестным тебе сервисом Б. Что ты будешь делать в первую очередь?

Если задать такой вопрос 100 программистам из разных компаний, скорее всего, мы получим 100 разных ответов. Кто-то описывает контракты в swagger, кто-то в gRPC просто делает клиенты к своим сервисам без описания контракта. А кто-то и вовсе хранит JSON в гуглодоке: D. В большинстве компаний складывается свой подход к межсервисному взаимодействию на основании каких-либо исторических факторов, компетенций, стека технологий и прочего. Я хочу рассказать, как сервисы в Delivery Club общаются друг с другом и почему мы сделали именно такой выбор. И главное — как мы обеспечиваем актуальность документации с течением времени. Будет много кода!
Ещё раз привет! Меня зовут Сергей Попов, я тим-лид команды, отвечающей за поисковую выдачу ресторанов в приложениях и на сайте Delivery Club, а также активный участник нашей внутренней гильдии разработки на Go (возможно, мы об этом ещё расскажем, но не сейчас).

Сразу оговорюсь, речь пойдет, в основном, про сервисы, написанные на Go. Генерирование кода для PHP-сервисов мы ещё не реализовали, хотя достигаем там единообразия в подходах другим способом.

К чему, в итоге, мы хотели прийти:

  1. Обеспечить актуальность контрактов сервисов. Это должно ускорить внедрение новых сервисов и упростить коммуникацию между командами.
  2. Прийти к единому способу взаимодействия по HTTP между сервисами (пока не будем рассматривать взаимодействия через очереди и event streaming).
  3. Стандартизировать подход к работе с контрактами сервисов.
  4. Использовать единое хранилище контрактов, чтобы не искать доки по всяким конфлюенсам.
  5. В идеале, генерировать клиенты под разные платформы.


Из всего перечисленного на ум приходит Protobuf как единый способ описания контрактов. Он имеет хороший инструментарий и может генерировать клиенты под разные платформы (наш п.5). Но есть и явные недостатки: для многих gRPC остается чем-то новым и неизведанным, а это сильно усложнило бы его внедрение. Ещё одним важным фактором было то, что в компании давно принят подход «specification first», и документация уже существовала на все сервисы в виде swagger или RAML-описания.

Go-swagger


Так совпало, что в то же время мы начали адаптацию Go в компании. Поэтому следующим нашим кандидатом на рассмотрение оказался go-swagger — инструмент, который позволяет генерировать клиентов и серверный код из swagger-спецификации. Из очевидных недостатков — он генерирует код только для Go. На самом деле, там используется гошное кодогенерирование, и go-swagger позволяет гибко работать с шаблонам, так что, теоретически, его можно использовать для генерирования кода на PHP, но мы ещё не пробовали.

Go-swagger — это не только про генерирование транспортного слоя. Фактически он генерирует каркас приложения, и тут я бы хотел немного упомянуть о культуре разработки в DC. У нас есть Inner Source, а это значит, что любой разработчик из любой команды может создать pull request в любой сервис, который у нас есть. Чтобы такая схема работала, мы стараемся стандартизировать подходы в разработке: используем общую терминологию, единый подход к логированию, метрикам, работе с зависимостями и, конечно же, к структуре проекта.

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

Первые шаги


Итак, go-swagger оказался интересным кандидатом, который, кажется, может покрыть большинство наших хотелок требований.

Примечание: весь дальнейший код актуален для версии 0.24.0, инструкцию по установке можно посмотреть в нашем репозитории с примерами, а на официальном сайте есть инструкция по установке актуальной версии.

Давайте посмотрим, что он умеет. Возьмём swagger-спеку и сгенерируем сервис:

> goswagger generate server \
    --with-context -f ./swagger-api/swagger.yml \
    --name example1


Получилось у нас следующее:

ruq1bsc2lj2spzodshfjzwsqag0.png

Makefile и go.mod я уже сделал сам.

Фактически у нас получился сервис, который обрабатывает запросы, описанные в swagger.

> go run cmd/example1-server/main.go
2020/02/17 11:04:24 Serving example service at http://127.0.0.1:54586
 
 
 
> curl http://localhost:54586/hello -i
HTTP/1.1 501 Not Implemented
Content-Type: application/json
Date: Sat, 15 Feb 2020 18:14:59 GMT
Content-Length: 58
Connection: close
 
"operation hello HelloWorld has not yet been implemented"


Второй шаг. Разбираемся с шаблонизацией


Очевидно, что сгенерированный нами код далёк от того, что мы хотим видеть в эксплуатации.

Что мы хотим от структуры нашего приложения:

  • Уметь конфигурировать приложение: передавать настройки подключения к БД, указывать порт HTTP-соединений и прочее.
  • Выделить объект приложения, который будет хранить состояние приложения, подключение к БД и прочее.
  • Сделать хэндлеры функциями нашего приложения, это должно упростить работу с кодом.
  • Инициализировать зависимости в main-файле (в нашем примере этого не будет, но мы всё равно этого хотим.


Для решения новых задач мы можем переопределить некоторые шаблоны. Для этого опишем следующие файлы, как это сделал я (Github):

7wbb2huawsrvkcpckwkmqeyf5us.png

Нам необходимо описать файлы шаблонов (`*.gotmpl`) и файл для конфигурации (`*.yml`) генерирования нашего сервиса.

Далее по порядку разберем те шаблоны, которые сделал я. Глубоко погружаться в работу с ними не буду, потому что документация go-swagger достаточно подробная, например, вот описание файла конфигурации. Отмечу только, что используется Go-шаблонизация, и если у вас уже есть в этом опыт или приходилось описывать HELM-конфигурации, то разобраться не составит труда.

Конфигурирование приложения


config.gotmpl содержит простую структуру с одним параметром — портом, который будет слушать приложение для входящих HTTP-запросов. Также я сделал функцию InitConfig, которая будет считывать переменные окружения и заполнять эту структуру. Вызывать буду из main.go, поэтому InitConfig сделал публичной функцией.

package config
 
import (
    "github.com/pkg/errors"
    "github.com/vrischmann/envconfig"
)
 
// Config struct
type Config struct {
    HTTPBindPort int `envconfig:"default=8001"`
}
 
// InitConfig func
func InitConfig(prefix string) (*Config, error) {
    config := &Config{}
    if err := envconfig.InitWithPrefix(config, prefix); err != nil {
        return nil, errors.Wrap(err, "init config failed")
    }
 
    return config, nil
}


Чтобы этот шаблон использовался при генерировании кода, его нужно указать в YML-конфиге:

layout:
  application:
    - name: cfgPackage
      source: serverConfig
      target: "./internal/config/"
      file_name: "config.go"
      skip_exists: false


Немного расскажу про параметры:

  • name — несёт чисто информативную функцию и на генерирование не влияет.
  • source — фактически путь до файла шаблона в camelCase, т.е. serverConfig равносильно ./server/config.gotmpl.
  • target — директория, куда будет сохранен сгенерированный код. Здесь можно использовать шаблонизацию для динамического формирования пути (пример).
  • file_name — название сгенерированного файла, здесь также можно использовать шаблонизацию.
  • skip_exists — признак того, что файл будет сгенерирован только один раз и не будет перезаписывать существующий. Для нас это важно, потому что файл конфига будет меняться по мере роста приложения и не должен зависеть от генерируемого кода.


В конфиге кодогенерирования нужно указывать все файлы, а не только те, которые мы хотим переопределить. Для файлов, которые мы не меняем, в значении source указываем asset:<путь до шаблона>, например, как здесь: asset:serverConfigureapi. Кстати, если интересно посмотреть оригинальные шаблоны, то они здесь.

Объект приложения и хэндлеры


Объект приложения для хранения состояния, подключений БД и прочего я описывать не буду, всё аналогично только что сделанному конфигу. А вот с хэндлерами всё немного интереснее. Наша ключевая цель состоит в том, чтобы при добавлении URL в спецификацию у нас в отдельном файле создалась функция с заглушкой, и самое главное, чтобы наш сервер вызывал эту функцию для обработки запроса.

Опишем шаблон функции и заглушки:

package app
 
import (
    api{{ pascalize .Package }} "{{.GenCommon.TargetImportPath}}/{{ .RootPackage }}/operations/{{ .Package }}"
    "github.com/go-openapi/runtime/middleware"
)
 
func (srv *Service){{ pascalize .Name }}Handler(params api{{ pascalize .Package }}.{{ pascalize .Name }}Params{{ if .Authorized }}, principal api{{ .Package }}.{{ if not ( eq .Principal "interface{}" ) }}*{{ end }}{{ .Principal }}{{ end }}) middleware.Responder {
    return middleware.NotImplemented("operation {{ .Package }} {{ pascalize .Name }} has not yet been implemented")
}


Немного разберём пример:

  • pascalize — приводит строку с CamelCase (описание остальных функции здесь).
  • .RootPackage — пакет сгенерированного веб-сервера.
  • .Package — название пакета в сгенерированном коде, в котором описаны все необходимые структуры для HTTP-запросов и ответов, т.е. структуры. Например, структура для тела запроса или структура ответа.
  • .Name — название хэндлера. Оно берётся из operationID в спецификации, если указано. Я рекомендую всегда указывать operationID для более очевидного результата.


Конфиг для хэндлера следующий:

layout:
  operations:
    - name: handlerFns
      source: serverHandler
      target: "./internal/app"
      file_name: "{{ (snakize (pascalize .Name)) }}.go"
      skip_exists: true


Как видите, код хэндлеров не будет перезаписываться (skip_exists: true), а название файла будет генерироваться из названия хэндлера.

Окей, функция с заглушкой есть, но веб-сервер ещё не знает, что эти функции нужно использовать для обработки запросов. Я исправил это в main.go (весь код приводить не буду, полную версию можно найти здесь):

package main
 
{{ $name := .Name }}
{{ $operations := .Operations }}
import (
    "fmt"
    "log"
 
    "github.com/delivery-club/go-swagger-example/{{ dasherize .Name }}/internal/generated/restapi"
    "github.com/delivery-club/go-swagger-example/{{ dasherize .Name }}/internal/generated/restapi/operations"
    {{range $index, $op := .Operations}}
        {{ $found := false }}
        {{ range $i, $sop := $operations }}
            {{ if and (gt $i $index ) (eq $op.Package $sop.Package)}}
                {{ $found = true }}
            {{end}}
        {{end}}
        {{ if not $found }}
        api{{ pascalize $op.Package }} "{{$op.GenCommon.TargetImportPath}}/{{ $op.RootPackage }}/operations/{{ $op.Package }}"
        {{end}}
    {{end}}
 
    "github.com/go-openapi/loads"
    "github.com/vrischmann/envconfig"
 
    "github.com/delivery-club/go-swagger-example/{{ dasherize .Name }}/internal/app"
)
 
func main() {
    ...
    api := operations.New{{ pascalize .Name }}API(swaggerSpec)
 
    {{range .Operations}}
    api.{{ pascalize .Package }}{{ pascalize .Name }}Handler = api{{ pascalize .Package }}.{{ pascalize .Name }}HandlerFunc(srv.{{ pascalize .Name }}Handler)
    {{- end}}
    ...
}


Код в импорте выглядит сложным, хотя на самом деле здесь просто Go-шаблонизация и структуры из репозитория go-swagger. А в функции main мы просто присваиваем хэндлерам наши сгенерированные функции.

Осталось сгенерировать код с указанием нашей конфигурации:

> goswagger generate server \
        -f ./swagger-api/swagger.yml \
        -t ./internal/generated -C ./swagger-templates/default-server.yml \
        --template-dir ./swagger-templates/templates \
        --name example2


Финальный результат можно посмотреть в нашем репозитории.

Что мы получили:

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


Третий шаг. Генерирование клиентов


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

Для проектов на Go принято складывать публичные пакеты в ./pkg, мы сделаем так же: положим клиент для нашего сервиса в pkg, а сам код сгенерируем следующим образом:

> goswagger generate client -f ./swagger-api/swagger.yml -t ./pkg/example3


Пример сгенерированного кода здесь.

Теперь все потребители нашего сервиса могут импортировать себе этот клиент, например, по тэгу (для моего примера тэг будет example3/pkg/example3/v0.0.1).

Шаблоны клиентов можно настраивать, чтобы, например, прокидывать open tracing id из контекста в заголовок.

Выводы


Естественно, наша внутренняя реализация отличается от приведенного здесь кода, в основном, за счёт использования внутренних пакетов и подходов к CI (запуск различных тестов и линтеров). В сгенерированном коде из коробки настроен сбор технических метрик, работа с конфигами и логирование. Мы стандартизировали все общие инструменты. За счёт этого мы упростили разработку в целом и выпуск новых сервисов в частности, обеспечили более быстрое прохождение чек-листа сервиса перед деплоем на прод.

Давайте проверим, получилось ли достигнуть первоначальных целей:

  1. Обеспечить актуальность описанных для сервисов контрактов, это должно ускорить внедрение новых сервисов и упростить коммуникацию между командами — Да.
  2. Прийти к единому способу взаимодействия по HTTP между сервисами (пока не будем рассматривать взаимодействия через очереди и event streaming) — Да.
  3. Стандартизировать подход к работе с контрактами сервисов, т.к. мы давно пришли к подходу Inner Source в разработке сервисов — Да.
  4. Использовать единое хранилище контрактов, чтобы не искать документацию по всяким конфлюенсам — Да (фактически — Bitbucket).
  5. В идеале, генерировать клиенты под разные платформы — Нет (на самом деле, не пробовали, шаблонизация не ограничивает в этом плане).
  6. Внедрить стандартную структуру сервиса на Go — Да (дополнительный результат).


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

P.S. Если этот материал понравится, то в дальнейшем подготовим статью про стандартную архитектуру наших сервисов, расскажем, какими принципами мы пользуемся при разработке сервисов на Go.

© Habrahabr.ru