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

Прежде, чем продолжить, немного добавлю про свой стек: Java, Spring Framework и все-все-все из этой "истории".

Возвращаясь к первому приседания с API-first...

Старый подход

Раньше я использовал распространенный подход, в котором в проекте было 2 модуля - API контракты и само приложение. Примерно так:

crud-service-api (API контракты), crud-service-app (приложение)
crud-service-api (API контракты), crud-service-app (приложение)

Где-то В API контракте (crud-service-api в данном случае) лежал файл со спецификацией (openapi.yaml) и в Gradle (у вас может быть Maven) был подключен OpenAPI generator, который генерировал классы для приложения и jar, затем этот jar загружался в maven-репозиторий.

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

И в чем тут же неудобство?

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

Возможно минусов этого подхода больше, пока писал статью, вспомнил только эти.

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

Новый подход

Идея сводилась к простому: есть моно-репозиторий, в нем по папкам разложены API контракты и общие модели, сборка и публикация артефактов производится оттуда же. Так и вышло.

Структура моно-репозитория
Структура папки common
Структура папки common
Структура папки crud-service-api
Структура папки crud-service-api

Вот примерно такая простая структура у меня получилась. Приводить пример структуры notification-service-api не стал, потому что она идентична структуре crud-service-api.

Давайте немного пробежимся по CI файлам.

1) Корневой .gitlab-ci.yml:

stages:
  - trigger

.trigger:
  stage: trigger
  trigger:
    strategy: depend

crud-service-api:
  extends: .trigger
  trigger:
    include: crud-service-api/.gitlab-ci.yml
  rules:
    - changes:
        - crud-service-api/*

notification-service-api:
  extends: .trigger
  trigger:
    include: notification-service-api/.gitlab-ci.yml
  rules:
    - changes:
        - notification-service-api/*

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

2) Дополнительный .api-first.gitlab-ci.yml:

stages:
  - validate
  - prepare
  - generate
  - publish

variables:
  OPENAPI_GENERATOR: registry.gitlab.com/dmitrii-demchenko/infrastructure/custom-docker-images/openapi-generator-cli:1.0.0
  OPENAPI_GENERATOR_CLI: java -jar /opt/openapi-generator-cli.jar

# Stage: validate
validate:
  stage: validate
  image: ${OPENAPI_GENERATOR}
  script:
    - cp ${OPENAPI_SPEC_PATH}/openapi.yaml openapi.yaml
    - ${OPENAPI_GENERATOR_CLI} validate -i openapi.yaml
  rules:
    - if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"
      when: always
    - if: $CI_COMMIT_BRANCH == "main"
      when: always
  artifacts:
    paths:
      - openapi.yaml
  tags:
    - docker

# Stage: prepare
.prepare:
  stage: prepare
  before_script:
    - mkdir -p configs/shared
    - |
      cat > configs/shared/common.yaml <<EOF
      inputSpec: openapi.yaml
      EOF
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: on_success
  artifacts:
    paths:
      - openapi.yaml
      - configs
    expire_in: 1w
  needs:
    - job: validate
  tags:
    - docker

prepare:spring-cloud-interface:
  extends: .prepare
  script:
    - |
      cat > configs/shared/spring-cloud-interface.yaml <<EOF
      ${SPRING_CLOUD_INTERFACE_INCLUDE_CONFIG}
      EOF
    - |
      cat > configs/spring-cloud-interface.yaml <<EOF
      '!include': 'shared/common.yaml'
      outputDir: generated/spring-cloud-interface
      generatorName: spring
      '!include': 'shared/spring-cloud-interface.yaml'
      additionalProperties:
        artifactId                    : ${JAR_ARTIFACT_ID}
        groupId                       : ${JAR_GROUP_ID}
        apiPackage                    : ${JAR_API_PACKAGE}
        modelPackage                  : ${JAR_MODEL_PACKAGE}
        library                       : spring-cloud
        dateLibrary                   : java8
        useSpringBoot3                : true
        useTags                       : true
        interfaceOnly                 : true
        openApiNullable               : false
        documentationProvider         : none
        hideGenerationTimestamp       : true
        additionalModelTypeAnnotations: '@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true) @lombok.Getter @lombok.Setter @lombok.AllArgsConstructor @lombok.NoArgsConstructor'
      EOF

# Stage: generate
generate:
  stage: generate
  image: ${OPENAPI_GENERATOR}
  script:
    # - *script-clone-common
    - ${OPENAPI_GENERATOR_CLI} batch configs/*.yaml
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: on_success
  artifacts:
    paths:
      - generated
    expire_in: 1w
  needs:
    - job: prepare:spring-cloud-interface
      artifacts: true
  tags:
    - docker

# Stage: publish
.publish:
  stage: publish
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual
  allow_failure: true
  tags:
    - docker

.publish:maven:
  extends: .publish
  image: maven:3-eclipse-temurin-21-alpine
  variables:
    MAVEN_OPTS: "-Dmaven.repo.local=${CI_PROJECT_DIR}/.m2/repository"
    MAVEN_CLI_OPTS: "-Dmaven.test.skip=true -s settings.xml"
  script:
    - echo ${MAVEN_SETTINGS_XML} > settings.xml
    - mvn deploy ${MAVEN_CLI_OPTS} -DaltDeploymentRepository=${MAVEN_SERVER_ID}::${MAVEN_SANDBOX_URL}
  cache:
    paths:
      - '.m2/repository'

publish:spring-cloud-interface:
  extends: .publish:maven
  before_script:
    - cd generated/spring-cloud-interface
  needs:
    - job: generate
      artifacts: true

Этот файл можно назвать основным, так как в нем описаны основные джобы.

3) Контрактный (на примере crud-service-api/.gitlab-ci.yml):

include:
  - local: .api-first.gitlab-ci.yml

variables:
  OPENAPI_SPEC_PATH: crud-service-api
  JAR_GROUP_ID: com.example
  JAR_ARTIFACT_ID: crud-service-api
  JAR_API_PACKAGE: com.example.crud.api
  JAR_MODEL_PACKAGE: com.example.crud.api.model
  SPRING_CLOUD_INTERFACE_INCLUDE_CONFIG: |-
    typeMappings:
      'OrderPageDto': 'Page<OrderDto>'
    importMappings:
      'Page': 'org.springframework.data.domain.Page'
      'Pageable': 'org.springframework.data.domain.Pageable'
      'Page<OrderDto>': 'org.springframework.data.domain.Page'

В этом же файле уже идет передача конечных значения переменным, предопределенным в файле выше.

Да, чуть не забыл...

Что за образ такой registry.gitlab.com/dmitrii-demchenko/infrastructure/custom-docker-images/openapi-generator-cli:1.0.0?

Для удобства сборки контрактов я сделал небольшой образ с openapi-generator-cli на борту.

Dockerfile:

FROM eclipse-temurin:21-jre-alpine

ARG VERSION=7.10.0
ARG OPENAPI_URL=https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/${VERSION}/openapi-generator-cli-${VERSION}.jar

ADD ${OPENAPI_URL} /opt/openapi-generator-cli.jar

И еще у меня есть переменная MAVEN_SETTINGS_XML которой нигде не задано значение. Это постоянная переменная, которая добавлена в Gitlab CI/CI variables в настройках репозитория и содержит креды к maven-репозиторию. И выглядит она вот так:

<settings xmlns="http://maven.apache.org/SETTINGS/1.1.0">
    <servers>
        <server>
            <id>maven-sandbox</id>
            <username>${env.MAVEN_SANDBOX_USERNAME}</username>
            <password>${env.MAVEN_SANDBOX_PASSWORD}</password>
        </server>
    </servers>
</settings>

Запускаем первый пайплайн (на самом деле не первый, но первый удачный, который не стыдно показать вам):

И получаем загруженный артефакт в любой удобный для вас maven-репозиторий.

Артефакты в репозитории
Артефакты в репозитории

Итоги

Как я и хотел, я получил удобный (по крайней мере для себя и своих разработчиков) удобный способ хранения, разработки и распространения API контрактов. Он так же отлично ложится на подход API-first.

Уверен, в этом подходе есть вещи, который можно улучшить и я продолжаю исследовать этот вопрос.

PS: не буду вдаваться в подробности CLI команд openapi генератора, с документацией этого инструмента и тем, как я настраивал параметры для сборки контракта, можно ознакомиться по ссылкам:

https://openapi-generator.tech/docs/usage#batch

https://openapi-generator.tech/docs/generators/spring/

В планах сделать что-то похожее для асинхронных контрактов (Kafka, RabbitMQ, и тд).

Вопросы, критика и предложения в комментарии.

Спасибо за внимание.

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