Эффективное создание и деплой gRPC API с помощью GitHub Actions и Packages для проекта на Kotlin и React

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

В этом посте я покажу, как с помощью GitHub Actions легко реализовать генерацию и публикацию gRPC API пакетов в GitHub Packages, в реестрах Apache Maven и npm. Если вы хотите освоить GitHub Packages для своих проектов и научиться генерировать gRPC API для сервисов на Kotlin/Java и gRPC-web клиентов — добро пожаловать под кат.

Введение

Во время подготовки к докладу JPoint у меня возникла идея создать тестовый стенд, включающий веб-клиент и бэкенд-приложение. Этот стенд позволил бы наглядно демонстрировать эффективность различных стратегий выполнения SQL-запросов с пагинацией. Для взаимодействия между клиентом и бэкендом я решил использовать gRPC. Мне показалась интересной такая реализация взаимодействия между сервером и веб-клиентом, а также генерация и публикация gRPC API пакетов с помощью GitHub Actions и GitHub Packages. Поэтому я решил поделиться этим опытом с читателями Хабра.

Выбор технологического стека и особенности

Для реализации я использовал Kotlin, однако все представленные здесь примеры также могут быть адаптированы для Java.

Я выбрал Spring и Kotlin для бэкенда, а также React с TypeScript для клиентской части, что облегчило реализацию нужного функционала. Выбранный для взаимодействия gRPC — это открытый фреймворк Google, который позволяет вызывать удаленные процедуры (RPC) между клиентом и сервером, используя Protocol Buffers как язык описания интерфейса. gRPC имеет ряд преимуществ:

  • высокая производительность благодаря использованию бинарного протокола HTTP/2;

  • строгая типизация Protocol Buffers, которая отлично подходит для дизайна API и снижает вероятность ошибок при взаимодействии сервисов;

  • .proto файлы с описанием структуры данных и API могут быть скомпилированы в код для различных языков программирования.

Для клиентской части, работающей в браузере, я использовал gRPC-web, поскольку браузеры не поддерживают обычный gRPC. gRPC-web позволяет взаимодействовать с обычными gRPC-сервисами из браузера. Пока что gRPC-web поддерживает только два режима взаимодействия: унарные вызовы и server-side стриминг.

Автоматизация

Мне показалось неудобным вручную создавать и подключать сгенерированные gRPC API пакеты. Поэтому я решил автоматизировать этот процесс через GitHub Actions и GitHub Packages. Дополнительно, чтобы гарантировать обратную совместимость изменений в API и следование официальному style guide от Google для .proto файлов, я внедрил проверки с помощью protolock и protolint.

О выборе GitHub Packages

Привлекательность использования GitHub Packages совместно с GitHub Actions, по моему мнению, заключается в следующем:

  • простота настройки и деплоя пакетов в GitHub Packages в сравнении с Maven Central;

  • централизация всех ресурсов проекта (код, CI/CD пайплайны, пакеты) на GitHub, что упрощает управление проектом;

  • бесплатный тариф, включающий приватное хранение репозиториев и пакетов, а также их деплой.

Все эти преимущества делают GitHub Actions и Packages хорошим решением, особенно для Pet-проектов. Тем не менее есть и недостаток: в отличие от Maven Central, для скачивания опубликованных пакетов требуется GitHub-аккаунт и токен с правами на чтение пакетов. Как получить этот токен, я опишу ниже. 

Реализация

Для начала создадим Gradle-проект с использованием Kotlin DSL и добавим .proto файлы.

Proto-файл API проекта:

syntax = "proto3";

package com.arvgord.api.grpc.bankdemo.v1;

import "bankdemo/v1/messages/client_list_item.proto";
import "bankdemo/v1/messages/extracting_strategy.proto";
import "bankdemo/v1/messages/page_request.proto";
import "google/protobuf/wrappers.proto";

// Get client list request
message GetClientListRequest {
 // Current page
 PageRequest page_request = 1;
 // Extracting strategy
 ExtractingStrategy extracting_strategy = 2;
}

// Get client list response
message GetClientListResponse {
 // Clients
 repeated ClientListItem clients = 1;
 // Total number of clients
 google.protobuf.Int64Value total_clients = 2;
 // Total number of pages
 google.protobuf.Int32Value total_pages = 3;
}

// Service BankDemo
service BankDemo {
 // Get client list
 rpc GetClientList(GetClientListRequest) returns (GetClientListResponse);
}

Пример .proto файла сообщения PageRequest:

syntax = "proto3";

package com.arvgord.api.grpc.bankdemo.v1;

import "google/protobuf/wrappers.proto";

// Page
message PageRequest {
 // Number of clients on page
 google.protobuf.Int32Value page = 1;
 // Page size
 google.protobuf.Int32Value size = 2;
}

Настройка зависимостей проекта

Для управления версиями плагинов и библиотек проекта добавим в корень проекта файл gradle.properties с версиями зависимостей:

kotlinVersion=1.9.10
protobufPluginVersion=0.9.4
protobufKotlinVersion=3.24.4
grpcProtobufVersion=1.58.0
grpcKotlinVersion=1.4.0

Настроим файл settings.gradle.kts, в котором укажем название проекта:

rootProject.name = "bank-demo-api"

А также настроим менеджмент плагинов:

pluginManagement {
   val kotlinVersion: String by settings
   val protobufPluginVersion: String by settings
   plugins {
       kotlin("jvm") version kotlinVersion
       id("com.google.protobuf") version protobufPluginVersion
   }
   repositories {
       gradlePluginPortal()
   }
}

Версии плагинов, которые определены в settings.gradle.kts с помощью переменных, указанных в gradle.properties, автоматически используются в build.gradle.kts. Что избавляет от необходимости указывать их вручную.

Настроим файл build.gradle.kts. Добавим плагины:

plugins {
   kotlin("jvm")
   id("com.google.protobuf")
   id("maven-publish")
}

Эти плагины необходимы для компиляции проекта, .proto файлов и публикации пакетов в Apache Maven registry GitHub.

Добавим группу и версию библиотеки API, которые будут необходимы для публикации пакета:

group = "com.arvgord"
version = "0.0.1"

Название проекта name для публикации пакета будет взято из файла settings.gradle.kts. В итоге после публикации пакет будет выглядеть так: com.arvgord:bank-demo-api:0.0.1.

Укажем зависимости проекта, необходимые для добавления поддержки gRPC и Protocol Buffers для Kotlin:

dependencies {
   implementation("io.grpc:grpc-kotlin-stub:${property("grpcKotlinVersion")}")
   implementation("io.grpc:grpc-protobuf:${property("grpcProtobufVersion")}")
   implementation("com.google.protobuf:protobuf-kotlin:${property("protobufKotlinVersion")}")
}

Настройка protobuf плагина

Настроим плагин protobuf, чтобы генерировать код на основе .proto файлов:

protobuf {
   protoc {
       artifact = "com.google.protobuf:protoc:${property("protobufKotlinVersion")}"
   }
   plugins {
       id("grpc") {
           artifact = "io.grpc:protoc-gen-grpc-java:${property("grpcProtobufVersion")}"
       }
       id("grpckt") {
           artifact = "io.grpc:protoc-gen-grpc-kotlin:${property("grpcKotlinVersion")}:jdk8@jar"
       }
       id("protoc-gen-js") {
           path = projectDir.path.plus("/tools/protoc-gen-js-3.21.2-linux-x86_64")
       }
       id("protoc-gen-grpc-web") {
           path = projectDir.path.plus("/tools/protoc-gen-grpc-web-1.4.2-linux-x86_64")
       }
   }
   generateProtoTasks {
       all().forEach {
           it.plugins {
               id("grpc")
               id("grpckt")
               id("protoc-gen-js") {
                   option("import_style=commonjs,binary")
               }
               id("protoc-gen-grpc-web") {
                   option("import_style=commonjs+dts,mode=grpcweb")
               }
           }
           it.builtins {
               id("kotlin")
           }
       }
   }
}

Рассмотрим секцию plugins: 

  • Плагины grpc и grpckt используются для генерации Java-кода, необходимого для сериализации/десериализации данных, а также создания серверного и клиентского gRPC кода на Kotlin. 

  • Плагин protoc-gen-js необходим для генерации JavaScript кода на основе .proto файлов для сериализации/десериализации данных. Необходимо загрузить плагин и указать его расположение.

  • Плагин protoc-gen-grpc-web позволяет генерировать код вызывающий gRPC-сервисы из веб-приложений. Этот плагин также необходимо загрузить и указать путь к расположению.

В секции generateProtoTasks определим задачи для генерации кода на основе .proto файлов. protoc-gen-grpc-web плагин позволяет генерировать как JS, так и TypeScript код. Так как мне необходимо было генерировать TypeScript код для вызова gRPC сервисов в options protoc-gen-js и protoc-gen-grpc-web, я использовал настройки import_style=commonjs,binary и import_style=commonjs+dts,mode=grpcweb. Вы можете использовать другие настройки.

Настройка публикации Maven-артефакта

Файл build.gradle.kts имеет следующие настройки:

publishing {
   repositories {
       maven {
           name = "GitHubPackages"
           url = uri("https://maven.pkg.github.com/arvgord/bank-demo-api")
           credentials {
               username = System.getenv("GITHUB_ACTOR")
               password = System.getenv("GITHUB_TOKEN")
           }
       }
   }
   publications {
       create<MavenPublication>("maven") {
           from(components["kotlin"])
       }
   }
}

В URL репозитория (https://maven.pkg.github.com/OWNER/REPOSITORY), куда планируется опубликовать пакет, необходимо заменить OWNER на имя вашего аккаунта на GitHub и REPOSITORY на имя вашего репозитория. В качестве username и password используются переменные среды GITHUB_ACTOR и GITHUB_TOKEN. Они будут автоматически подставлены при выполнении в GitHub Actions.

Настройка публикации npm-пакета

Для публикации npm-пакета я решил использовать отдельную директорию npm_package в корне проекта, содержащую только файл package.json с конфигурацией публикации. В build.gradle.kts необходимо добавить задачу для копирования сгенерированных TypeScript и JS файлов в директорию npm_package:

tasks.register<Copy>("buildAndCopy") {
   from(
       projectDir.path.plus("/build/generated/source/proto/main/protoc-gen-js"),
       projectDir.path.plus("/build/generated/source/proto/main/protoc-gen-grpc-web")
   )
   into(projectDir.path.plus("/npm_package/"))
}

Далее приступим к настройке файла package.json, содержащего конфигурацию для публикации npm-пакета:

{
 "name": "@arvgord/bank-demo-api",
 "version": "0.0.1",
 "description": "Generated typescript files for gRPC-web bank-demo-client application",
 "repository": {
   "type": "git",
   "url": "https://github.com/arvgord/bank-demo-api.git"
 },
 "dependencies": {
   "grpc-web": "^1.4.2",
   "google-protobuf": "^3.21.2"
 }
}

В этом файле:

  • name определяет пространство имен и уникальное имя пакета;

  • version указывает текущую версию пакета;

  • description предоставляет краткое описание содержимого и предназначения пакета;

  • repository указывает местоположение репозитория пакета;

  • dependencies содержит список зависимостей, необходимых для работы пакета.

Настройка Action для публикации в GitHub Packages

Для файлов GitHub Actions необходимо в корне проекта создать директории .github/workflows.

Создадим файл конфигурации для публикации пакетов publish_packages.yml в директории .github/workflows:

name: Publish bank-demo-api packages
on:
  workflow_dispatch:

jobs:
 publish:
   runs-on: ubuntu-latest
   steps:
     - uses: actions/checkout@v4
     - uses: actions/setup-java@v3
       with:
         java-version: '8'
         distribution: 'corretto'
     - name: Build packages
       run: ./gradlew buildAndCopy
     - name: Publish Kotlin gRPC API
       run: ./gradlew publish
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
     - uses: actions/setup-node@v3
       with:
         node-version: '20.x'
         registry-url: 'https://npm.pkg.github.com'
         scope: '@arvgord'
     - name: Publish bank-demo-client gRPC API
       run: |
         cd ./npm_package
         npm i
         npm publish
       env:
         NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Разберем содержимое этого файла:

  • workflow_dispatch: позволяет запускать workflow вручную из интерфейса GitHub в разделе Actions.

  • actions/checkout@v4: клонирование кода репозитория.

  • setup-java@v3: установка Java 8 версии.

  • ./gradlew buildAndCopy: сборка пакетов.

  • ./gradlew publish: с помощью команды происходит публикация Kotlin пакета в GitHub Apache Maven registry. Для аутентификации используется GITHUB_TOKEN. GITHUB_ACTOR подставляется в build.gradle.kts автоматически т.к. является стандартной переменной окружения.

  • actions/setup-node@v3: настройка окружения Node.js версии 20.x для последующей публикации npm пакета.

  • Publish bank-demo-client gRPC API: происходит переход в директорию npm_package, установка зависимостей и публикация npm-пакета с использованием NODE_AUTH_TOKEN.

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

  1. В файле build.gradle.kts необходимо обновить значение version.

  2. В файле package.json также обновить значение version.

Ручной запуск публикации пакета из раздела Actions
Ручной запуск публикации пакета из раздела Actions
После успешной сборки новые пакеты появятся в разделе Packages
После успешной сборки новые пакеты появятся в разделе Packages

Настройка прокси

В самом начале я упоминал о ключевой особенности: gRPC-web клиенты не способны напрямую связываться с обычными gRPC-сервисами. Чтобы обеспечить взаимодействие, требуется проксирование. В проекте я применяю envoy прокси. Настройки были реализованы на основе примера, доступного в репозитории gRPC-web и выглядят следующим образом:

admin:
 access_log_path: /tmp/admin_access.log
 address:
   socket_address: { address: 0.0.0.0, port_value: 9901 }


static_resources:
 listeners:
   - name: listener_0
     address:
       socket_address: { address: 0.0.0.0, port_value: 8080 }
     filter_chains:
       - filters:
           - name: envoy.filters.network.http_connection_manager
             typed_config:
               "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
               codec_type: auto
               stat_prefix: ingress_http
               route_config:
                 name: local_route
                 virtual_hosts:
                   - name: local_service
                     domains: ["*"]
                     routes:
                       - match: { prefix: "/" }
                         route:
                           cluster: echo_service
                           timeout: 0s
                           max_stream_duration:
                             grpc_timeout_header_max: 0s
                     cors:
                       allow_origin_string_match:
                         - prefix: "*"
                       allow_methods: GET, PUT, DELETE, POST
                       allow_headers: keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
                       max_age: "1728000"
                       expose_headers: custom-header-1,grpc-status,grpc-message
               http_filters:
                 - name: envoy.filters.http.grpc_web
                   typed_config:
                     "@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb
                 - name: envoy.filters.http.cors
                   typed_config:
                     "@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
                 - name: envoy.filters.http.router
                   typed_config:
                     "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
 clusters:
   - name: echo_service
     connect_timeout: 0.25s
     type: logical_dns
     http2_protocol_options: {}
     lb_policy: round_robin
     load_assignment:
       cluster_name: cluster_0
       endpoints:
         - lb_endpoints:
             - endpoint:
                 address:
                   socket_address:
                     address: 172.17.0.1
                     port_value: 6565

Подключение API

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

Настроим подключение к GitHub Apache maven registry на бэкенде для проекта, который будет использовать опубликованный пакет:

repositories {
   mavenCentral()
   maven {
       url = uri("https://maven.pkg.github.com/arvgord/bank-demo-api")
       credentials {
           username = project.findProperty("gpr.user") as String? ?:
("GITHUB_ACTOR")
           password = project.findProperty("gpr.key") as String? ?:
("GITHUB_TOKEN")
       }
   }
}

В URL https://maven.pkg.github.com/OWNER/REPOSITORY необходимо заменить OWNER на имя аккаунта на GitHub и REPOSITORY на имя репозитория, откуда планируете скачивать пакет. В системных переменных необходимо задать токен на чтение GITHUB_TOKEN.

Вот как происходит вызов gRPC API на бэкенде подключенного пакета:

package com.arvgord.bankdemoserver.controller.grpc.cartesianissue.v1

import com.arvgord.api.grpc.bankdemo.v1.BankDemoGrpcKt
import com.arvgord.api.grpc.bankdemo.v1.BankDemoOuterClass.GetClientListRequest
import com.arvgord.api.grpc.bankdemo.v1.BankDemoOuterClass.GetClientListResponse
import io.grpc.Status
import io.grpc.StatusException
import org.lognet.springboot.grpc.GRpcService
import com.arvgord.bankdemoserver.controller.grpc.cartesianissue.v1.adapter.BankDemoAdapter

@GRpcService
class BankDemoCartesianIssueController(
   private val adapter: BankDemoAdapter
) : BankDemoGrpcKt.BankDemoCoroutineImplBase() {
  
   override suspend fun getClientList(request: GetClientListRequest): GetClientListResponse =
       try {
           adapter.getClientList(request)
       } catch (e: Exception) {
           throw StatusException(Status.INTERNAL.withDescription(e.message))
       }
}

Для подключения к npm GitHub registry необходимо:

  1. Выполнить команду npm login --registry=https://npm.pkg.github.com.

  2. Ввести имя аккаунта на GitHub и GITHUB_TOKEN на чтение пакетов.

  3. Выполнить npm i в вашем проекте.

Так выглядит вызов gRPC API на React-клиенте:

import {useEffect, useState} from 'react';
import {GetClientListRequest, GetClientListResponse} from "@arvgord/bank-demo-api/bankdemo/v1/api/business/bank_demo_pb";
import {PageRequest} from "@arvgord/bank-demo-api/bankdemo/v1/messages/page_request_pb";
import {Int32Value} from "google-protobuf/google/protobuf/wrappers_pb";
import {ExtractingStrategy} from "@arvgord/bank-demo-api/bankdemo/v1/messages/extracting_strategy_pb";
import {BankDemoPromiseClient} from "@arvgord/bank-demo-api/bankdemo/v1/api/business/bank_demo_grpc_web_pb";

export function useGetList(page: number, size: number, strategy: ExtractingStrategy) {
   const [response, setResponse] = useState(new GetClientListResponse().toObject())
   const [error, setError] = useState()

   useEffect(() => {
       if (!page && !size && !strategy) return
       const service = new BankDemoPromiseClient('http://localhost:8080', null, null)
       const request = new GetClientListRequest()
       const pageRequest = new PageRequest()
       pageRequest.setPage(new Int32Value().setValue(page))
       pageRequest.setSize(new Int32Value().setValue(size))
       request.setPageRequest(pageRequest)
       request.setExtractingStrategy(strategy)
       service.getClientList(request, {})
           .then(result => result.toObject())
           .then(setResponse)
           .catch(setError)
   }, [page, size, strategy]);

   return {
       response,
       error
   };
}

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

Исходный код описанных примеров вы найдете в проекте на GitHub, как и пример подключения API к клиенту и бэкенду.

Заключение

gRPC — это мощный фреймворк для создания эффективных и надежных API. На основе .proto файлов вы можете одновременно генерировать как серверный код, так и код для веб-клиентов. Генерация и публикация gRPC API пакетов значительно упрощается с использованием GitHub Actions и GitHub Packages, что и было продемонстрировано в этом посте.

Источник: https://habr.com/ru/companies/rosbank/articles/776962/


Интересные статьи

Интересные статьи

Мариус Бакке (Marius Bakke) несколько лет занимается разработкой Guix и недавно начал вести свой блог. Мы перевели рассказ о том, почему Мариус увлёкся разработкой собственной ОС и как с её помощью мо...
В этой статье я хочу поделиться недавно открытым для себя инструментарием, позволяющим создавать кроссплатформенные автотесты для приложений на QT.
Со стартом активного импортозамещения еще несколько лет назад было трудно поверить, что оно сможет быть красивым, качественным и современным. Западные системы создавались и развивались много лет, а ро...
Привет, Хабр! Разрешите поделиться своим велосипедом. Речь пойдет о минималистичном менеджере состояний React, интерфейс которого состоит из одной функции — createShared(). GitHub репозиторий прое...
Эта статья – попытка сформулировать особенности работы с французами и шведами, которые я открывал для себя на протяжении 10 лет совместной работы. Я не претендую на их универсальность, а всего лишь де...