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

    14 декабря 2023 года

В этом посте я покажу, как с помощью 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

Подпись: После успешной сборки новые пакеты появятся в разделе 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, что и было продемонстрировано в этом посте.

 

Источник: Блог Росбанка на Хабре

Подписка на новости

АО «ТБанк» использует файлы «cookie», с целю персонализации сервисов и повышения удобства пользоватея веб-сайтом. «Cookie» представляют собой небольшие файлы, содержащие информацию о предыдущих посещениях веб-сайта. Если вы не хотите использовать файлы «cookie», измените настройки браузера