В этом посте я покажу, как с помощью 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.
При последующих запусках сборки и публикации пакетов необходимо поднять версии публикуемых пакетов, чтобы избежать ошибок конфликта их версий:
В файле 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, что и было продемонстрировано в этом посте.
Комментарии (3)
Keva
29.11.2023 10:21Личный опыт использования gRPC - вынужденный и остро негативный.
Вынужденный - потому, что большая часть сервисов Mailion реализована на go, а поиск - чисто C++ - разработка.
Негативный - потому, что реализация gRPC, столь изящная и нативная для go, для C++ напоминает козу без сисек, с разноразмерными шинами вместо копыт и одним толстым х.. вместо рога на носу.
Примеры из Tutorial работают идеально и внушают уверенность в успехе. По мере использования оказывается, что наиболее значимые настройки не реализованы, а реализованные возможности не документированы совсем.
А уж когда находишь перехваченный аллокатор памяти, в случае её нехватки делающий abort(), приходит понимание всей глубины бездны индийского замысла.
А уж негарантированная доставка поставленного в очередь тега сколько крови попортила...
ggo
29.11.2023 10:21Публикация джарок, сгенеренных с proto-файлов - понятно.
А вы делали публикацию/импорт самих proto-файлов из java/kotlin проектов? Protobuf - он же как бы мультиплатформенный.
username-ka
Не совсем по теме, но может любопытно - как раз работаю над проектом, где получилось довольно удачно на мой вкус реализовать связку фронтенд/бэкэнд через gRPC.
gRPC недоступен наружу, а React-фронтенд сделан на Next.js с app router / server functions. То есть коммуникация фронтенд-gRPC-бэкэнд происходит исключительно через внутреннюю серверную подсеть и обычный Node.js, а коммуникация браузер-фронтенд идёт через встроенные в Next механизмы. Таким образом получается использовать "нормальный" javascript gRPC без всяких прокси-шмокси.
Получилось очень удобно и эффективно (быстро писать), и работает хорошо (быстро загружаются странички), и API проектировать сильно проще становится.