В этом посте я покажу, как с помощью 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, что и было продемонстрировано в этом посте.

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


  1. username-ka
    29.11.2023 10:21

    Не совсем по теме, но может любопытно - как раз работаю над проектом, где получилось довольно удачно на мой вкус реализовать связку фронтенд/бэкэнд через gRPC.

    gRPC недоступен наружу, а React-фронтенд сделан на Next.js с app router / server functions. То есть коммуникация фронтенд-gRPC-бэкэнд происходит исключительно через внутреннюю серверную подсеть и обычный Node.js, а коммуникация браузер-фронтенд идёт через встроенные в Next механизмы. Таким образом получается использовать "нормальный" javascript gRPC без всяких прокси-шмокси.

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


  1. Keva
    29.11.2023 10:21

    Личный опыт использования gRPC - вынужденный и остро негативный.

    Вынужденный - потому, что большая часть сервисов Mailion реализована на go, а поиск - чисто C++ - разработка.

    Негативный - потому, что реализация gRPC, столь изящная и нативная для go, для C++ напоминает козу без сисек, с разноразмерными шинами вместо копыт и одним толстым х.. вместо рога на носу.

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

    А уж когда находишь перехваченный аллокатор памяти, в случае её нехватки делающий abort(), приходит понимание всей глубины бездны индийского замысла.

    А уж негарантированная доставка поставленного в очередь тега сколько крови попортила...


  1. ggo
    29.11.2023 10:21

    Публикация джарок, сгенеренных с proto-файлов - понятно.

    А вы делали публикацию/импорт самих proto-файлов из java/kotlin проектов? Protobuf - он же как бы мультиплатформенный.