Привет! Меня зовут Данил, я бэкенд разработчик.

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

Что затронуто в данной статье:

В этой статье я бы хотел поделиться, удобным и зарекомендовавшим себя во времени работе в продакшене способом управления gRPC спецификациями сервисов.

В микросервисной архитектуре, по мере возрастания проекта и сервисов, непрерывно общающихся между собой, а вместе с тем с обширным стэком языков, таких как go, python, java, вы неизбежно начнете испытывать сложности с ручной генерацией контрактов, управлениями зависимостями и т. д. Это требует автоматизированного решения, которое выполняло бы:

  • генерацию gRPC кода под нужные языка

  • генерацию автодокументации

  • публикация сгенерированных пакетов

Решение

Какие условия или требования могут подвести вас к использованию gRPC в качестве основого транспорта, промимо его производительности:

  • Contracts-first спецификация сервисов

  • Однозначный способ объявления клиентов под все используемые языки

  • Версионность и сохранение обратной совместимости между клиентом и сервером по мере их развития

Итак, при contracts-first подоходе встает вопрос, как и где хранить сами .proto файлы. Можно предложить несколько вариантов:

  • единый монорепозиторий под контракты

  • репозиторий контрактов под каждый сервис

  • копирование .proto файлов в каждый сервис

Взяв во внимание очевидные минусы 2 и 3 подходов, связанные с минимальной целостностью и возможностью переиспользования, первый подход в т. ч. выигрывает тем, что можно объявить непосредственно контракты, а затем уже писать сервисы как реализацию.

Структура проекта следующая - директория с файлами-спецификациями сервисов .proto, разделенные по доменам - назовем их контексты:

proto/
	domain_a/
		v1/
    		service_a.proto
        	version.txt
	domain_b/
		v1/
			service_b.proto
			version.txt

Пример .proto спецификации сервиса:

syntax = "proto3";

package sample_service.foo;

import "google/api/annotations.proto";
import "google/protobuf/wrappers.proto";

// FooService is an example RPC service.
service FooService {
  // CreateFoo is an example method.
  rpc CreateFoo(CreateFooRequest) returns (CreateFooResponse) {
    option (google.api.http) = {
      post: "/create-foo"
      body: "*"
    };
  }
  // GetFoo is an example getter method.
  rpc GetFoo(GetFooRequest) returns (GetFooResponse) {
    option (google.api.http) = {
      get: "/get-foo/{id}"
    };
  }
}


// version.txt:
// 1.0.0

Текущий подход позволяет:

  • Делать обратно совместимые изменения контрактов.

  • Придерживаться SemVer семантики

  • При обратно несовместимых изменениях, поднять мажорную версию и задеплоить новый контракт

Структура проекта. Скрипты на python выбраны из-за его простоты использования:

make.py
scripts/
	builders/
    	go.py
		python.py
		java.py
		...
    publishers/
        go.py
		python.py
		java.py
		...

make.py - входная точка скриптов автоматизации.

Usage:
  make.py [command] [options]

Commands:
  format          Форматирует proto-контракты.
  lint            Проверяет контракты на ошибки стиля и валидность.
  build           Собирает контракты в указанных целях и контекстах.
  publish         Публикует собранные контракты.

Options:
  -s, --source    Определяет, для каких языков генерировать код контрактов.
  -d, --domain   Определяет, для каких контекстов генерировать код контрактов.
  -r, --release   Используется при релизе контракта.

builders и publisher - скрипты для сборки и публикации пакетов

Под каждый язык, нужна своя реализация BaseBuilder:

class Builder(ABC):
    @abstractmethod
    def pre_build(self): 
        ...

    @abstractmethod
    def post_build(self): 
        ...

    @abstractmethod

    def build_domain(self, domain: Domain): 
        ...

В pre_build и post_build может находиться логика по предварительному созданию необходимой структуры для инициализации пакета и последующей очистки, т.к это может принципиально отличаться от выбора языка.

В основу реализации build_domain берется какой-то из инструментов сборки - например, prototool или buf , и команды по сборке.

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

BasePublisher:

class Publisher(ABC):
    @abstractmethod
    def publish(self): ...

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

  1. Golang. В этом языке его создатели позаботились о простоте использования зависимостей в Go Modules. Для публикации пакета вам нужен публичный репозиторий Github или Gitlab, где создание версионных тэгов полностью соответствует подходу Go Modules

  2. Python. В Python стандартом для управления зависимостями является использование PyPI (Python Package Index). Для публикации пакета разработчики часто используют такие инструменты, как setuptools или poetry для подготовки пакета и twine для его загрузки. Если требуется хранение пакетов в частном хранилище, применяются решения вроде Nexus или Artifactory.

  3. Java. Управление зависимостями и публикация в Java осуществляется с помощью Maven или Gradle. Основное хранилище для Java-библиотек — Maven Central, а для внутренних проектов часто используются те же Nexus или Artifactory.

Пример CI/CD

.gitlab-ci.yml

workflow:
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
      variables:
        MAKE_FLAGS: "--release"

stages:
  - lint
  - build
  - test
  - publish
  
lint:
  stage: lint
  script:
    - python3 make.py lint

build:
  stage: build
  script:
    - python3 make.py --source ${SOURCE} --debug
  artifacts:
    paths:
      - generated/proto/${SOURCE}
    expire_in: 1 day

publish:
  stage: publish
  script:
    - python3 make.py publish --source ${SOURCE} --debug
  needs:
    - build

Github Actions

name: CI/CD Pipeline

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - "**"

jobs:
  lint:
    steps:
      - Install dependencies
      - Lint proto files:
        script: python3 make.py lint

build:
  needs: lint
  steps:
    - Install dependencies
    - Build proto files:
      script: python3 make.py build --debug
    - Upload artifacts:
      path: generated/proto/${{ github.event.repository.name }}

publish:
  needs: build
  steps:
    - Install dependencies
    - Publish proto files:
      script: python3 make.py publish --debug

Визуализация текущей схемы пайплайна
Визуализация текущей схемы пайплайна

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

Версионирование

Диаграмма иллюстрирует процесс обновления версий контрактов:

  • из ветки master формируется релизная версия (например, 1.0.0 → 1.0.1),

  • из веток для разработки фичей — альфа-версии с временными метками

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

Пример использования зависимостей

Golang

Установка пакета

go get gitlab.yourdomain.com/contracts/generated/go/<context_name>@latest

Так будет выглядеть go.mod файл:

module yourproject

go 1.23

require (
    gitlab.yourdomain.com/contracts/generated/go/<context_name> v1.2.3
)

Python

Для Python зависимости указываются в файле pyproject.toml (при использовании Poetry) или requirements.txt.

pyproject.toml

[tool.poetry.dependencies]
python = "^3.10"
<context-name> = { version = "1.2.3", source = "https://nexus.yourdomain.com/repository/pypi/simple/" }

requirements.txt

<context-name>==1.2.3 
--extra-index-url https://nexus.yourdomain.com/repository/pypi/simple/

Java

Java использует Gradle для управления зависимостями. Пример для Gradle (build.gradle):

dependencies {
    implementation 'com.yourdomain.contracts.generated:<context-name>:1.2.3'
}

repositories {
    maven {
        url "https://nexus.yourdomain.com/repository/maven-releases/"
    }
}

Выводы

Мы обозначили проблемы, с которыми приходится сталкиваться при управлении .proto файлами и спецификациями сервисов в быстрорастущей микросервисной архитектуре. Рассмотрели важные вопросы: где хранить код контрактов, как управлять ими, и способы публикации пакетов контрактов.

Было предложено решение по автоматизации рутинной работы, упрощении жизни разработчиков и в том числе для самодокументации.

Здесь также есть пространство для улучшения, например, автоматическое обновление зависимостей с помощью Dependabot, или добавление контрактных тестов в отдельную стадию CI/CD

В заключение, если вы хотите значительно сократить время, затрачиваемое командами на интеграцию сервисов, стоит потратить время на реализацию собственного решения по автоматизации на начальном этапе. Это не только ускорит работу, но и упростит внедрение новых разработчиков и команд.

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


  1. dos
    10.12.2024 07:57

    Не смотрели buf ? Там и сборка контрактов хорошо организована, есть контроль совместимости версий контрактов и в конце концов есть линтинг.


  1. ptr128
    10.12.2024 07:57

    Мы просто собираем контракты в пакеты, публикуемые в локальный репозиторий вместе с утилитой на C#, позволяющей на основании proto и конфигурационного файла в json генерировать исходный код на нескольких языках для разных случаев. Вплоть до генерации метаданных для MS SQL и PostgreSQL.

    Пакеты собираются под .NET, но возможности MSBuild позволяют использовать их в любых языках.

    Необходимость именно пакетов обусловлена тем, что в одной ветке git репозитория контрактов могут содержаться контракты не того сочетания версий, которое требуется конкретному микросервису. А через пакеты легко привязываются контракты разных версий.