Эффективное создание и деплой 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 файлы.
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") } } } }
- Плагины 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.
При последующих запусках сборки и публикации пакетов необходимо поднять версии публикуемых пакетов, чтобы избежать ошибок конфликта их версий:
- В файле build.gradle.kts необходимо обновить значение version.
- В файле package.json также обновить значение
version
.
Настройка прокси
В самом начале я упоминал о ключевой особенности: 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 необходимо:
- Выполнить команду npm login --registry=https://npm.pkg.github.com.
- Ввести имя аккаунта на GitHub и GITHUB_TOKEN на чтение пакетов.
- Выполнить 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, что и было продемонстрировано в этом посте.