company_banner

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



    Здравствуй, 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

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



    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):



    Нам необходимо описать файлы шаблонов (`*.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.
    Mail.ru Group
    Строим Интернет

    Похожие публикации

    Комментарии 16

      +1
      Ничего не понял, но очень интересно. Собсно куча вопросов, по порядку начну:

      ```
      > goswagger generate server \
      --with-context -f ./swagger-api/swagger.yml \
      --name example1
      ```
      Флажок разве не депрекейтед? github.com/go-swagger/go-swagger/pull/1806
        0
        Спасибо за замечание, видимо при переезде с 0.21 на 0.24 осталось.
        Поправим.
        +1
        Вот только уже давно есть третья версия спецификации (open API), которую go-swagger, увы, не планирует реализовывать. Видимо, как и последующие.
        github.com/go-swagger/go-swagger/issues/1122#issuecomment-353590960
          –1
          это верно, но как сказано в комментарии по вашей ссылке — для большинства задач второй версии хватает.
          Варианты перехода на 3ю версию есть, но еще не дошли до конкретного решения.
            0
            В целом жить можно, да. Главное сильно не привязываться к таким решениям, чтобы потом не было головной боли с переездами. Как сказали сами разработчики в конце этого топика — их всего пару человек и это их хобби-проект.
              0
              Ну как бы есть, сырое, но есть. github.com/deepmap/oapi-codegen
            0
            Продолжу))

            >> Уметь конфигурировать приложение: передавать настройки подключения к БД, указывать порт HTTP-соединений и прочее.

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

            go run ./cmd/… --port 3000

            А вообще, что-бы с флагами поработать в го, люди используют что-то из этого viper/cobra или сами пишут. Вот можно почитать github.com/go-swagger/go-swagger/issues/762#issuecomment-269373779

            Но я так и не увидел ни разу реализации как красиво это заюзать в проекте.
              0
              Мы указываем параметры через env — такой подход принят давно и не только для go-сервисов, поэтому и не работаем с передаваемыми аргументами.
              0
              А что у вас за goswagger? У меня просто swagger.
                0
                Верно, по-умолчанию — swagger, но для того, чтобы либа не конфликтовала с текущей версией (если установлена), я в доке по установке указал goswagger: github.com/delivery-club/go-swagger-example
                0
                А не могли бы объяснить, зачем в вашей апишке шаблоны? Для меня это что-то новое, я конечно поковыряюсь ближе к вечеру, что-бы понять…

                upd: А, они используют таки viper сами… век живи, век учись.
                  0
                  Зачем в самом сервисе шаблоны? или зачем такой подход в целом?
                    0
                    Ну да, и шаблоны и подход. Я просто создаю отдельный пакет, в вашем случае в restapi/configure_example1.go:

                    ...
                    myapp.ConfigureAPI(api) // Вот так это выглядит
                    return setupGlobalMiddleware(api.Serve(setupMiddlewares))
                    }
                    


                    И в этом пакете и подключение к базе и возврат middleware.Responder происходит, вот как образец github.com/MarlikAlmighty/library/blob/master/library/library.go
                      0
                      зачем сами шаблоны — как я понял, вы используете стандартные шаблоны, нас они не устраивают, поэтому мы используем свои. При этом мы не коммитим основную часть сгенерированного кода (по ряду причин), поэтому шаблоны храним в самом сервисе. Так же это добавляет удобство при редактировании api — у нас автоматически создаются файлы хэндлеров с заглушками, меняются / добавляются модели api — такая рутинная работа делается автоматически.

                      Что касается в целом подхода — собственно об этом статья:)
                      Основные причины — поддержка актуальности контрактов api, единый подход подходам в разработке сервисов, автоматизация рутинных задач.
                  +1
                  Мммм, я понял что вы делаете, и это круто. Спасибо, день потрачен с пользой. Пишите ещё, подписался.
                    0
                    Для решения новых задач мы можем переопределить некоторые шаблоны
                    — всё, что у вас там написано далее в этой главе, прямо скажем — ошарашивает. Абсолютно непонятно ни каким образом решение «этих задач» связано с кастомными шаблонами, ни как вы их решили в итоге. Вот этот код, который вы привели после «Опишем шаблон функции и заглушки», он наверное очень простой и его теоретически легко понять. Но выглядит он ужасно, и ни какого желания в нём разбираться нет. Дело в том, что я уже достаточно давно в go-swagger-е, но для решения «этих задач» никогда не пользовался такой сомнительной с моей т.з. фичей, как кастомные шаблоны.

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

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