gRPC (Google Remote Procedure Call) — открытый высокопроизводительный RPC-фреймворк, изначально разработанный компанией Google. Он отличается от традиционых REST-сервисов как минимум тем, что использует Protocol Buffers (ProtoBuf) для сериализации данных вместо привычных JSON или XML. Эта технология позволяет сэкономить много ресурсов на сетевых взаимодействиях и обработке данных — порой до 40% трафика и 30% процессорного времени по сравнению с JSON-сериализацией.
Введение в gRPC
Главная фишка gRPC — возможность писать код так, будто вы вызываете обычные методы локально, тогда как за кулисами происходит магия удаленных вызовов. Это достигается благодаря строгой типизации и генерации клиентского и серверного кода из прото-файлов.
Code | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| syntax = "proto3";
service WeatherService {
rpc GetWeather (WeatherRequest) returns (WeatherResponse);
}
message WeatherRequest {
string location = 1;
}
message WeatherResponse {
string forecast = 1;
float temperature = 2;
} |
|
Код выше декларативно описывает сервис и его контракт, а специальные компиляторы протобафов автоматически сгенерируют всё необходимое для работы клиентской и серверной частей. Уже только из-за этого стоит присмотреться к gRPC — меньше кода, меньше ошибок, больше времени на кофе!
gRPC vs REST: битва гигантов
Сравнивая gRPC с REST, стоит отметить несколько ключевых преимуществ первого:
1. Производительность: gRPC работает поверх HTTP/2, что означает мультиплексирование запросов по одному TCP-соединению, сжатие заголовков и бинарную передачу данных вместо текстовой.
2. Строгие контракты: В отличие от REST, где API часто документируется постфактум, в gRPC контракты определяются заранее и являются частью самого кода.
3. Встроенная поддержка потоковой передачи: gRPC предоставляет способ реализовать как клиентское, так и серверное потоковое вещание, что делает его идеальным для приложений, требующих обмена непрерывными потоками данных.
Но есть и обратная сторона медали. REST по-прежнему остаётся более универсальным решением для публичных API, особенно когда речь идёт о браузерных приложениях. Браузеры просто не могут (точнее, пока не умеют) напрямую работать с gRPC без дополнительных прослоек типа gRPC-Web.
Стоит ли переходить на gRPC в вашем проекте?
gRPC наиболее эффективен в следующих сценариях:- Микросервисные архитектуры с интенсивным межсервисным взаимодействием.
- Системы, требующие выскокой пропускной способности и низких задержек.
- Полиглотные среды, где сервисы реализованы на разных языках программирования.
- Приложения с потоковой передачей данных (например, чаты, аналитика в реальном времени).
При этом не всё так однозначно. Один из моих проектов, где мы бездумно заменили REST на gRPC, столкнулся с непредвиденными проблемами масштабирования. Оказалось, что наши админы настроили балансировщик, который не учитывал особенности HTTP/2, из-за чего соедениния периодически рвались на полуслове. Как говорится, семь раз отмерь, один раз отрежь.
Кроме того, gRPC не лучшый выбор для:- Публичных API, потребляемых в основном веб-браузерами.
- Простых CRUD-сервисов без высоких требований к производительности.
- Проектов, где критична поддержка кеширования на уровне прокси-серверов.
Protocol Buffers: ядро экосистемы gRPC
В основе gRPC лежит технология Protocol Buffers — бинарный формат сериализации со строгой типизацией. Думайте о нём как о "XML на стероидах", только быстрее, меньше и умнее. ProtoBuf позволяет экономить до 60-80% размера сообщения по сравнению с JSON при передаче аналогичных данных. Особенно интересно, что прото-файлы могут автоматически компилироваться для любого из поддерживаемых языков — C#, Java, Python, Go, Ruby и многих других. Это делает gRPC идеальным выбором для полиглотных систем, где разные компоненты написаны на разных языках.
Безопасность и аутентификация в gRPC
gRPC обеспечивает транспортную безопасность через TLS/SSL, но также поддерживает различные механизмы аутентификации. В .NET интеграция с ASP.NET Core Identity или JWT токенами происходит довольно гладко через механизм перехватчиков (interceptors).
Однако это та область, где всегда стоит перестраховатся. Я неоднократно видел, как разработчики оставляли открытыми незащищенные gRPC-эндпоинты для "внутренних" сервисов, наивно полагая, что "внутренняя" сеть абсолютно безопасна.
gRPC — одна из тех технологий, которая может качественно изменить подход к созданию распределённых систем, особенно в контексте C# и платформы .NET. Она предлагает значительные улучшения производительности, удобство разработки через строгую типизацию и широкие возможности для расширения. При этом важно понимать ограничения технологии и выбирать её осознанно, с учётом всех требований проекта.
C# Работа с сервисами\службами Здравствуйте.
Подскажите, как получить полную информацию о каком либо сервисе.
Сейчас использую... Работа с Веб Сервисами на ASP.Net Здравствуйте! Подскажите пожалуйста новичку в работе с Веб Сервисами, как передать объект класса в... работа с сервисами Как дать права сервису на от чистку журнала событий?
Добавлено через 1 час 12 минут
сам спросил... Работа с сервисами в ASP.NET В примере проекта на ASP есть написаний сервис, который отвечает не запросы клиента. Как со...
Настройка среды разработки
Прежде чем приступить к созданию наших gRPC-сервисов, необходимо настроить окружение. Процесс не особо сложный, но имеет ряд нюансов, о которых полезно знать заранее. Я не раз наступал на эти грабли, помогая командам переходить с REST на gRPC.
Необходимое ПО и пакеты
Для разработки gRPC-сервисов на C# вам понадобится:- Visual Studio 2019+ или VS Code с расширением C#.
- .NET Core SDK 3.1 или .NET 5+ (предпочтительнее .NET 6 или новее).
- Пакеты NuGet для работы с gRPC.
Основные NuGet-пакеты, которые потребуются:
C# | 1
2
3
| Grpc.AspNetCore
Grpc.Tools
Google.Protobuf |
|
Grpc.AspNetCore интегрирует gRPC-сервер в приложения ASP.NET Core, Grpc.Tools обеспечивает компиляцию proto-файлов, а Google.Protobuf предоставляет функциональность сериализации. В более продвинутых сценариях могут понадобиться дополнительные пакеты — например, Grpc.Net.Client для создания клиентских приложений.
Создание gRPC-проекта в Visual Studio
Создание нового gRPC-проекта в Visual Studio — дело пары кликов:
1. File → New → Project.
2. В поиске шаблонов введите "gRPC".
3. Выберите "gRPC Service" и нажмите Next.
4. Укажите имя и расположение проекта.
5. Нажмите Create.
Visual Studio создаст проект, содержащий базовую структуру для gRPC-сервиса, включая папку Protos с примером proto-файла и папку Services с имплементацией сервиса. этот шаблон появился только в Visual Studio 2019, до этого приходилось вручную настраивать все компоненты — не самая приятная работа для понедельничного утра.
Сгенерированный проект имеет следуюшую структуру:
C# | 1
2
3
4
5
6
7
8
| ├── Properties
├── Protos
│ └── greet.proto // Определения сервиса и сообщений
├── Services
│ └── GreeterService.cs // Реализация сервиса
├── appsettings.json
├── Program.cs
└── Startup.cs |
|
Если вы работаете с .NET 6 или новее, обратите внимание, что Startup.cs может отсутствовать, так как конфигурация выполняется непосредственно в Program.cs .
Настройка Kestrel для работы с gRPC
Одна из распространенных проблем — настройка веб-сервера Kestrel для оптимальной работы с gRPC. По-умолчанию, gRPC требует HTTP/2, и иногда это становится камнем преткновения. В файле appsettings.json убедитесь, что у вас есть подобная конфигурация:
JSON | 1
2
3
4
5
6
7
| {
"Kestrel": {
"EndpointDefaults": {
"Protocols": "Http2"
}
}
} |
|
Ещё один момент — по-умолчанию gRPC не работает через HTTP/2 без TLS. Для разработки можно отключить это ограничение:
C# | 1
2
3
4
5
6
7
8
9
| // Program.cs или Startup.cs в ConfigureServices
services.AddGrpc();
// В ConfigureApp или Program.cs для .NET 6+
app.Use((context, next) =>
{
context.Response.Headers.Add("Strict-Transport-Security", "max-age=0");
return next();
}); |
|
На продакшене такое, конечно, лучше не делать. Безопаснность — это не та область, где стоит искать компромисы.
Работа с Proto-файлами и генерация кода
В проекте, созданом из шаблона, уже есть базовая настройка для автоматической генерации C#-кода из Proto-файлов. Она выглядит примерно так:
XML | 1
2
3
| <ItemGroup>
<Protobuf Include="Protos\greet.proto" GrpcServices="Server" />
</ItemGroup> |
|
Атрибут GrpcServices определяет, какой код будет сгенерирован:
"Server" — только серверная часть,
"Client" — только клиентская часть,
"Both" — обе части,
"None" — только классы сообщений.
Когда вы создаёте библиотеку, которая будет использоваться и клиентом, и сервером, логично выбрать "Both". Однако, для сервера обычно достаточно значения "Server", а для клиента — "Client". После изменения proto-файла не забудьте пересобрать проект, чтобы сгенерировать обновлённые классы.
SSL/TLS для защищенных соединений
gRPC без TLS — как машина без колёс: технически, может и поедет, но с горки и недалеко. Для настройки TLS в локальной разработке можете использовать самопожписанные сертификаты:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // Startup.cs или Program.cs
public void ConfigureServices(IServiceCollection services)
{
services.AddGrpc();
// Конфигурация Kestrel для поддержки HTTPS
services.Configure<KestrelServerOptions>(options =>
{
options.Listen(IPAddress.Any, 5001, listenOptions =>
{
listenOptions.UseHttps("certificate.pfx", "password");
listenOptions.Protocols = HttpProtocols.Http2;
});
});
} |
|
В проде лучше использовать сертификаты от доверенных центров сертификации. Однажды в нашем проекте мы сэкономили на сертификате, использовав самоподписанный — результатом стала недельная головная боль с настройкой клиентов, которые отказывались доверять нашему серверу.
Автоматическая генерация документации
Хотя gRPC обеспечивает строгие контракты через proto-файлы, наличие человекочитаемой документации никогда не повредит. Инструмент protoc-gen-doc может автоматически генерировать HTML, Markdown или JSON документацию непосредственно из ваших proto-файлов:
Bash | 1
| protoc --plugin=protoc-gen-doc=./protoc-gen-doc --doc_out=./docs --doc_opt=html,index.html ./Protos/*.proto |
|
Интеграцию этого шага в процесс сборки я обычно реализую через MSBuild-таргеты, но можно и через скрипт, запускаемый при успешной сборке.
CI/CD для gRPC-проектов
Интеграция gRPC-проектов в CI/CD-пайплайны имеет свои особенности. Помимо стандартных шагов для .NET-приложений, необходимо убедиться, что среда сборки имеет установленные компиляторы для proto-файлов. Для GitHub Actions пайплайн выглядит примерно так:
YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup .NET
uses: actions/setup-dotnet@v1
with:
dotnet-version: 6.0.x
- name: Install Protoc
run: |
apt-get update && apt-get install -y protobuf-compiler
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Test
run: dotnet test --no-build --verbosity normal |
|
В целом, настройка среды разработки для gRPC в C# не представляет особой сложности, особенно если вы уже знакомы с ASP.NET Core. Visual Studio делает большую часть работы за вас, а инструментарий .NET спасает от многих низкоуровневых проблем, которые могли бы возникнуть при работе с другими платформами. Однако знание нюансов конфигурации поможет избежать неприятных сюпризов в будущем.
Разработка gRPC-сервиса на C#
Теперь, когда мы настроили среду разработки, пришло время погрузиться в создание настоящего gRPC-сервиса. Это самая интересная часть работы, где теория превращается в практику. Помню свой первый gRPC-сервис — я потратил два дня на борьбу с сериализацией enum-типов, пока не понял, что надо просто правильно объявить их в proto-файле. Разберёмся, как избежать подобных граблей.
Анатомия proto-файлов
Proto-файлы — сердце любого gRPC-сервиса. Они определяют, какие методы будут доступны на сервере и какие данные можно передавать между клиентом и сервером. Вот пример простого proto-файла для сервиса управления задачами:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
| syntax = "proto3";
option csharp_namespace = "TaskManager.Services";
package TaskService;
service TaskManager {
// Получение списка задач
rpc GetTasks (TasksRequest) returns (TasksResponse);
// Создание новой задачи
rpc CreateTask (CreateTaskRequest) returns (TaskResponse);
// Обновление существующей задачи
rpc UpdateTask (UpdateTaskRequest) returns (TaskResponse);
// Удаление задачи
rpc DeleteTask (DeleteTaskRequest) returns (DeleteTaskResponse);
}
message TasksRequest {
string filter = 1;
int32 page_size = 2;
int32 page_number = 3;
}
message TasksResponse {
repeated Task tasks = 1;
int32 total_count = 2;
}
message Task {
string id = 1;
string title = 2;
string description = 3;
TaskStatus status = 4;
string assigned_to = 5;
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
}
enum TaskStatus {
TASK_STATUS_UNKNOWN = 0;
TASK_STATUS_NEW = 1;
TASK_STATUS_IN_PROGRESS = 2;
TASK_STATUS_COMPLETED = 3;
TASK_STATUS_CANCELED = 4;
}
message CreateTaskRequest {
string title = 1;
string description = 2;
string assigned_to = 3;
}
message UpdateTaskRequest {
string id = 1;
string title = 2;
string description = 3;
TaskStatus status = 4;
string assigned_to = 5;
}
message TaskResponse {
Task task = 1;
}
message DeleteTaskRequest {
string id = 1;
}
message DeleteTaskResponse {
bool success = 1;
} |
|
Обратите внимание на некоторые ключевые моменты:
1. syntax = "proto3"; — указывает версию синтаксиса Protocol Buffers.
2. option csharp_namespace — задаёт namespace для сгенерированного C# кода.
3. package TaskService; — определяет пространство имён для других proto-файлов.
4. service TaskManager — описывает сам сервис и его методы.
5. Каждый метод имеет входящий и исходящий типы сообщений.
6. Номера полей в сообщениях (например, string filter = 1; ) важны для бинарной совместимости.
Реализация серверной части
После создания proto-файла и его компиляции, можно приступать к реализации серверной логики. Для каждого сервиса, определенного в proto-файле, компилятор создаст базовый класс, который нужно унаследовать и переопределить нужные методы.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
| using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Grpc.Core;
using Microsoft.Extensions.Logging;
using TaskManager.Data;
using TaskManager.Models;
namespace TaskManager.Services
{
public class TaskManagerService : TaskManager.TaskManagerBase
{
private readonly ILogger<TaskManagerService> _logger;
private readonly ITaskRepository _taskRepository;
public TaskManagerService(ILogger<TaskManagerService> logger, ITaskRepository taskRepository)
{
_logger = logger;
_taskRepository = taskRepository;
}
public override async Task<TasksResponse> GetTasks(TasksRequest request, ServerCallContext context)
{
try
{
_logger.LogInformation($"Получение списка задач с фильтром: {request.Filter}");
var tasks = await _taskRepository.GetTasksAsync(
request.Filter,
request.PageSize,
request.PageNumber);
var response = new TasksResponse
{
TotalCount = tasks.TotalCount
};
response.Tasks.AddRange(tasks.Items.Select(t => MapToGrpcTask(t)));
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Ошибка при получении списка задач");
throw new RpcException(new Status(StatusCode.Internal, "Внутренняя ошибка сервера"));
}
}
// Другие методы...
private Task MapToGrpcTask(Data.Task task)
{
// Мапинг из доменной модели в gRPC-модель
return new Task
{
Id = task.Id,
Title = task.Title,
Description = task.Description,
Status = (TaskStatus)task.Status,
AssignedTo = task.AssignedTo,
// И т.д.
};
}
}
} |
|
Несколько важных моментов:
1. Класс TaskManagerService наследуется от сгенерированного базового класса TaskManager.TaskManagerBase .
2. Используется внедрение зависимостей для получения репозитория и логгера.
3. Методы являются асинхронными и возвращают Task<TResponse> .
4. Обработка исключений важна — клиент получает RpcException с соответствующим статус-кодом.
5. Маппинг между доменными объектами и gRPC-объектами выделен в отдельную функцию.
Перехватчики (Interceptors)
Часто нужно добавить кросс-функциональную логику ко всем RPC-вызовам: логирование, трассировку, аутентификацию и т.д. Для этого в gRPC есть механизм перехватчиков.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| public class LoggingInterceptor : Interceptor
{
private readonly ILogger<LoggingInterceptor> _logger;
public LoggingInterceptor(ILogger<LoggingInterceptor> logger)
{
_logger = logger;
}
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
var methodName = context.Method;
_logger.LogInformation($"Вызов метода {methodName} начат");
try
{
var response = await continuation(request, context);
_logger.LogInformation($"Вызов метода {methodName} успешно завершен");
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Ошибка при выполнении метода {methodName}");
throw;
}
}
} |
|
И регистрация перехватчика:
C# | 1
2
3
4
5
| // Program.cs или Startup.cs
services.AddGrpc(options =>
{
options.Interceptors.Add<LoggingInterceptor>();
}); |
|
На проекте с интенсивным трафиком мы использовали перехватчики для метрик производительности. Это позволило выявить узкие места и оптимизировать самые "тяжелые" методы.
Валидация данных
gRPC не имеет встроенных механизмов валидации сообщений, но вы можете реализовать её на уровне сервиса:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| public override Task<CreateTaskResponse> CreateTask(CreateTaskRequest request, ServerCallContext context)
{
if (string.IsNullOrEmpty(request.Title))
{
throw new RpcException(new Status(StatusCode.InvalidArgument, "Название задачи не может быть пустым"));
}
if (request.Title.Length > 100)
{
throw new RpcException(new Status(StatusCode.InvalidArgument, "Название задачи не может быть длиннее 100 символов"));
}
// Остальная логика...
} |
|
Для более сложных случаев можно использовать библиотеки вроде FluentValidation вместе с перехватчиком, который автоматически применяет валидацию ко всем входящим запросам.
Структурирование proto-файлов в больших проектах
Для крупных проектов рекомендую разделять proto-файлы по доменным областям и использовать импорты. Например:
C# | 1
2
3
4
5
6
7
8
9
10
| Protos/
common/
pagination.proto
timestamps.proto
tasks/
task_service.proto
task_models.proto
users/
user_service.proto
user_models.proto |
|
В каждом proto-файле определяйте только один сервис и связанные с ним сообщения. Используйте импорты для повторного использования общих типов:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // tasks/task_service.proto
syntax = "proto3";
import "common/pagination.proto";
import "tasks/task_models.proto";
service TaskManager {
rpc GetTasks (GetTasksRequest) returns (GetTasksResponse);
// ...
}
message GetTasksRequest {
string filter = 1;
common.PaginationRequest pagination = 2;
}
message GetTasksResponse {
repeated task_models.Task tasks = 1;
common.PaginationResponse pagination = 2;
} |
|
Такая структура значительно упрощает навигацию по коду и его поддержку в долгосрочной перспективе. Тот факт, что я не сделал этого в начале одного из проектов, превратил proto-файлы в неуправляемый хаос, когда проект разросся. В моей практике, хорошо спроектированная структура proto-файлов оказалась ключевым фактором успеха при масштабировании команды — новые разработчики гораздо быстрее понимали архитектуру системы и начинали продуктивную работу.
Версионирование API в gRPC-сервисах
Версионирование API — одна из тех проблем, которая может превратить ваш идеально спроектированый сервис в кошмар на улице Бэкенд-разработчиков. В отличие от REST, где часто используется версионирование в URL или заголовках, в gRPC подходы немного иные. Существует несколько стратегий версионирования gRPC:
1. Версионирование через имя пакета:
C# | 1
2
3
4
5
| package taskmanager.v1;
service TaskManager {
// методы
} |
|
В новой версии:
C# | 1
2
3
4
5
| package taskmanager.v2;
service TaskManager {
// обновленные методы
} |
|
2. Версионирование через имя сервиса:
C# | 1
2
3
4
5
6
7
| service TaskManagerV1 {
// методы
}
service TaskManagerV2 {
// обновленные методы
} |
|
3. Эволюционное версионирование — добавление новых методов без изменения существующих:
C# | 1
2
3
4
5
6
7
| service TaskManager {
// существующие методы
rpc GetTasks (TasksRequest) returns (TasksResponse);
// новые методы
rpc GetTasksWithLabels (TasksWithLabelsRequest) returns (TasksWithLabelsResponse);
} |
|
Я обычно предпочитаю первый подход с версионированием через пакет — он создаёт чёткое разделение между версиями API и позволяет клиентам использовать нужную им версию. Кроме того, это даёт возможность постепенно выводить из эксплуатации устаревшие версии. Какой бы подход вы ни выбрали, важно помнить о правилах обратной совместимости Protocol Buffers:- Не меняйте теги полей (номера после знака равенства).
- Не удаляйте существующие поля, помечайте их как устаревшие.
- Добавляйте новые поля с новыми тегами.
- Не меняйте типы полей.
Эти правила обеспечивают, что старые клиенты смогут работать с новыми версиями сервера, а новые клиенты — со старыми версиями (с ограничениями, конечно).
Балансировка нагрузки в gRPC-системах
Балансировка нагрузки в gRPC имеет свои особенности из-за использования HTTP/2 и долгоживущих соединений. Традиционные способы балансировки, основанные на TCP/IP, могут не работать оптимально с gRPC. Существует несколько подходов:
1. DNS-балансировка — самый простой вариант, но не идеальный для gRPC из-за кеширования DNS и долгоживущих соединений.
2. Прокси-балансировка с использованием специализированных решений:
- NGINX с поддержкой HTTP/2.
- Envoy Proxy (отлично работает с gRPC).
- Traefik.
3. Клиентская балансировка, когда клиент сам выбирает, к какому серверу подключиться:
C# | 1
2
3
4
5
6
7
8
| var channel = GrpcChannel.ForAddress("dns:///myserver.example:5001", new GrpcChannelOptions
{
Credentials = ChannelCredentials.Insecure,
ServiceConfig = new ServiceConfig
{
LoadBalancingConfigs = { new RoundRobinConfig() }
}
}); |
|
Однажды я потерял несколько дней, пытаясь понять, почему после обновления версии gRPC клиент перестал подключаться ко всем экземплярам сервера, а использовал только один. Оказалось, что новая версия клиента по-другому обрабатывала DNS-записи с множественными A-записями — вместо подключения к случайному серверу из списка, она просто брала первый.
Встраивание метаданных в gRPC-запросы
В gRPC метаданные — это пары ключ-значение, которые можно передавать вместе с запросом и ответом, подобно HTTP-заголовкам. Они полезны для передачи аутентификационных токенов, trace-ID для распределённого трейсинга, информации о клиенте и многого другого.
Отправка метаданных с клиента:
C# | 1
2
3
4
5
6
7
8
9
| // Создание метаданных
var metadata = new Metadata
{
{ "user-agent", "my-cool-client/1.0" },
{ "authorization", $"Bearer {token}" }
};
// Вызов метода с метаданными
var response = await client.GetTasksAsync(request, metadata); |
|
Чтение метаданных на сервере:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| public override Task<TasksResponse> GetTasks(TasksRequest request, ServerCallContext context)
{
var userAgent = context.RequestHeaders.GetValue("user-agent");
_logger.LogInformation($"Запрос от клиента: {userAgent}");
// Аутентификация
var authHeader = context.RequestHeaders.GetValue("authorization");
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer "))
{
throw new RpcException(new Status(StatusCode.Unauthenticated, "Отсутствует токен аутентификации"));
}
// Дальнейшая обработка...
} |
|
Отправка метаданных с сервера:
C# | 1
2
3
4
5
6
7
8
| public override Task<TasksResponse> GetTasks(TasksRequest request, ServerCallContext context)
{
// Добавление метаданных в ответ
context.ResponseTrailers.Add("server-timing", "db=150ms;render=20ms");
context.ResponseTrailers.Add("x-ratelimit-remaining", "98");
// Основная логика...
} |
|
Двунаправленные метаданные в потоковых вызовах:
В потоковых вызовах можно отправлять метаданные в обе стороны даже во время активного стрима, что открывает интересные возможности:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // Серверный потоковый метод
public override async Task StreamTasks(TasksRequest request,
IServerStreamWriter<TaskResponse> responseStream,
ServerCallContext context)
{
// Отправка начальных метаданных
await context.WriteResponseHeadersAsync(new Metadata
{
{ "stream-start-time", DateTime.UtcNow.ToString("o") }
});
// Отправка данных
foreach (var task in _taskRepository.GetTasksStream(request.Filter))
{
// Проверка отмены
if (context.CancellationToken.IsCancellationRequested)
break;
await responseStream.WriteAsync(new TaskResponse { Task = MapToGrpcTask(task) });
}
// Отправка финальных метаданных
context.ResponseTrailers.Add("stream-end-time", DateTime.UtcNow.ToString("o"));
} |
|
Обработка deadlines и отмены
gRPC предоставляет два важных механизма для контроля жизненного цикла запросов: deadlines (крайние сроки) и cancellation (отмена).
Установка deadline на клиенте:
C# | 1
2
3
| // Задаем таймаут в 5 секунд
var deadline = DateTime.UtcNow.AddSeconds(5);
var response = await client.GetTasksAsync(request, deadline: deadline); |
|
Обработка deadline на сервере:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| public override async Task<TasksResponse> GetTasks(TasksRequest request, ServerCallContext context)
{
// Проверяем, не истек ли deadline
if (context.Deadline < DateTime.UtcNow)
{
throw new RpcException(new Status(StatusCode.DeadlineExceeded, "Операция не уложилась в заданный срок"));
}
// Вычисляем оставшееся время
var timeLeft = context.Deadline - DateTime.UtcNow;
_logger.LogInformation($"Осталось времени: {timeLeft.TotalMilliseconds}ms");
// Если запрос требует длительной обработки, можно адаптировать поведение
if (timeLeft.TotalSeconds < 1)
{
// Быстрый путь для почти истекших запросов
return new TasksResponse { ... };
}
// Нормальная обработка
return await _taskRepository.GetTasksAsync(request.Filter);
} |
|
Отмена запросов:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // На клиенте
var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(3)); // Отмена через 3 секунды
try
{
var call = client.GetTasksAsync(request);
var response = await call.ResponseAsync.WithCancellation(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Операция была отменена клиентом");
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled)
{
Console.WriteLine("Операция была отменена сервером");
} |
|
Обработка отмены на сервере:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| public override async Task<TasksResponse> GetTasks(TasksRequest request, ServerCallContext context)
{
// Получаем токен отмены
var cancellationToken = context.CancellationToken;
// Передаем токен в длительные операции
var result = await _someService.DoSomethingLongAsync(cancellationToken);
// Периодически проверяем отмену в циклах
for (int i = 0; i < items.Count; i++)
{
if (cancellationToken.IsCancellationRequested)
{
_logger.LogInformation("Операция отменена клиентом");
context.Status = new Status(StatusCode.Cancelled, "Операция отменена");
return null; // или throw new OperationCanceledException();
}
// Обрабатываем элемент...
}
return new TasksResponse { ... };
} |
|
Правильная обработка deadlines и отмены может значительно улучшить ощущения пользователей от работы с вашим приложением, а также сэкономить серверные ресурсы. В проекте, над которым я работал, мы смогли увеличить общую пропускную способность системы на 30%, просто добавив разумные таймауты и отмену для медленных запросов, которые раньше могли "подвешивать" потоки выполнения на длительное время.
Создание клиентского приложения
После создания полноценного gRPC-сервиса пора заняться клиентской частью. Работа с gRPC-клиентом в C# значительно отличается от привычных HTTP-клиентов для REST API. Здесь нет необходимости в ручной сериализации данных, составлении URL-адресов и парсинге ответов — всё это делается автоматически сгенерированным кодом.
Подключение к gRPC-сервису
Первый шаг в создании клиентского приложения — подключение к gRPC-сервису. Для этого нужно создать канал, а затем клиент для конкретного сервиса:
C# | 1
2
3
4
5
6
7
8
9
10
11
| // Создание канала
var channel = GrpcChannel.ForAddress("https://localhost:5001", new GrpcChannelOptions
{
HttpClient = new HttpClient
{
DefaultRequestVersion = new Version(2, 0), // Явно указываем HTTP/2
}
});
// Создание клиента на основе канала
var client = new TaskManager.TaskManagerClient(channel); |
|
Параметр GrpcChannelOptions позволяет настроить множество аспектов поведения канала: таймауты, заголовки, сертификаты и т.д. В боевых условиях почти всегда приходится настраивать эти параметры. Помню случай, когда мы забыли настроить проверку сертификатов при подключении к внешнему сервису — сработало локально, но отказало в продакшене с весьма загадочной ошибкой.
Вызов методов и обработка ответов
После создания клиента можно вызывать методы сервиса, как если бы они были локальными методами:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| // Создание запроса
var request = new TasksRequest
{
Filter = "status:active",
PageSize = 10,
PageNumber = 1
};
try
{
// Синхронный вызов
var response = client.GetTasks(request);
// Обработка ответа
foreach (var task in response.Tasks)
{
Console.WriteLine($"Задача: {task.Title}, Статус: {task.Status}");
}
Console.WriteLine($"Всего задач: {response.TotalCount}");
}
catch (RpcException ex)
{
// Обработка ошбок gRPC
Console.WriteLine($"Код ошибки: {ex.StatusCode}");
Console.WriteLine($"Детали: {ex.Status.Detail}");
}
catch (Exception ex)
{
// Обработка других ошибок
Console.WriteLine($"Непредвиденная ошибка: {ex.Message}");
} |
|
Важно понимать, что RpcException содержит детальную информацию о gRPC-ошибках, включая статус-код и сообщение. Эти данные могут быть полезны для диагностики проблем и информирования пользователя.
Асинхронные вызовы
В реальных приложениях предпочтительнее использовать асинхронные версии методов, которые также генерируются автоматически:
C# | 1
2
| // Асинхронный вызов
var response = await client.GetTasksAsync(request); |
|
Для более сложных сценариев, когда требуется полный контроль над вызовом, можно использовать AsyncUnaryCall :
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| using var call = client.GetTasksAsync(request);
// Получаем заголовки ответа
var headers = await call.ResponseHeadersAsync;
foreach (var header in headers)
{
Console.WriteLine($"{header.Key}: {header.Value}");
}
// Получаем результат запроса
var response = await call.ResponseAsync;
// Получаем трейлеры (метаданные, отправляемые в конце ответа)
var trailers = call.GetTrailers();
foreach (var trailer in trailers)
{
Console.WriteLine($"{trailer.Key}: {trailer.Value}");
}
// Получаем статус вызова
var status = call.GetStatus();
Console.WriteLine($"Статус вызова: {status.StatusCode} - {status.Detail}"); |
|
Стратегии повторных подключений
gRPC-соединения могут прерываться по разным причинам: сетевые проблемы, перезапуск сервера и т.д. Для обеспечения надежности клиентского приложения необходимо реализовать стратегии повторных подключений. Простейший способ — ручной retry с экспоненциальной задержкой:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| public async Task<TResponse> CallWithRetryAsync<TRequest, TResponse>(
Func<TRequest, CancellationToken, AsyncUnaryCall<TResponse>> callFunc,
TRequest request,
int maxRetries = 3,
TimeSpan? initialDelay = null)
{
initialDelay ??= TimeSpan.FromSeconds(1);
var delay = initialDelay.Value;
for (int retry = 0; retry <= maxRetries; retry++)
{
try
{
var result = await callFunc(request, CancellationToken.None);
return result;
}
catch (RpcException ex) when (
ex.StatusCode == StatusCode.Unavailable ||
ex.StatusCode == StatusCode.DeadlineExceeded)
{
if (retry >= maxRetries)
throw;
_logger.LogWarning("Ошибка вызова gRPC, попытка {Retry}/{MaxRetries}: {Message}",
retry + 1, maxRetries, ex.Message);
await Task.Delay(delay);
delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); // Экспоненциальная задержка
}
}
throw new InvalidOperationException("Не удалось выполнить запрос после всех попыток");
} |
|
Использование этой функции:
C# | 1
2
3
| var response = await CallWithRetryAsync(
(req, token) => client.GetTasksAsync(req, cancellationToken: token),
request); |
|
Типизированные клиенты и фабрики
В ASP.NET Core рекомендуется использовать типизированные клиенты для gRPC-сервисов. Это позволяет централизованно настраивать параметры соединения и абстрагироваться от деталей создания каналов.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| // Реализация интерфейса типизированного клиента
public interface ITaskManagerClient
{
Task<TasksResponse> GetTasksAsync(string filter, int pageSize, int pageNumber);
Task<TaskResponse> CreateTaskAsync(string title, string description, string assignedTo);
Task<TaskResponse> UpdateTaskAsync(string id, string title, string description, TaskStatus status, string assignedTo);
Task<DeleteTaskResponse> DeleteTaskAsync(string id);
}
public class TaskManagerClient : ITaskManagerClient
{
private readonly TaskManager.TaskManagerClient _client;
public TaskManagerClient(TaskManager.TaskManagerClient client)
{
_client = client;
}
public async Task<TasksResponse> GetTasksAsync(string filter, int pageSize, int pageNumber)
{
var request = new TasksRequest
{
Filter = filter,
PageSize = pageSize,
PageNumber = pageNumber
};
return await _client.GetTasksAsync(request);
}
// Остальные методы...
} |
|
Регистрация типизированного клиента в DI-контейнере:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| // В Program.cs или Startup.cs
services.AddGrpcClient<TaskManager.TaskManagerClient>(options =>
{
options.Address = new Uri("https://localhost:5001");
})
.ConfigureChannel(options =>
{
options.MaxReceiveMessageSize = 5 * 1024 * 1024; // 5 МБ
options.MaxSendMessageSize = 2 * 1024 * 1024; // 2 МБ
})
.ConfigureHttpClient(client =>
{
client.Timeout = TimeSpan.FromSeconds(5);
client.DefaultRequestHeaders.Add("X-Client-Version", "1.0.0");
})
.AddPolicyHandler(GetRetryPolicy()); // Использование Polly для ретраев
services.AddScoped<ITaskManagerClient, TaskManagerClient>();
// Определение политики повторов с помощью Polly
static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
return HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
} |
|
Такой подход имеет несколько преимуществ:
1. Централизованная конфигурация gRPC-клиентов.
2. Возможность лёгкой подмены реальных клиентов заглушками при тестировании.
3. Абстрагирование от низкоуровневых деталей работы с gRPC.
Кроме того, фабрика клиентов автоматически создаёт и переиспользует HTTP-соединения, что повышает общую производительность системы.
Кеширование в gRPC-клиентах
Для часто используемых и редко меняющихся данных имеет смысл реализовать кеширование на стороне клиента. Это может существенно сократить количество сетевых запросов:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| public class CachedTaskManagerClient : ITaskManagerClient
{
private readonly ITaskManagerClient _innerClient;
private readonly IMemoryCache _cache;
private readonly ILogger<CachedTaskManagerClient> _logger;
public CachedTaskManagerClient(
ITaskManagerClient innerClient,
IMemoryCache cache,
ILogger<CachedTaskManagerClient> logger)
{
_innerClient = innerClient;
_cache = cache;
_logger = logger;
}
public async Task<TasksResponse> GetTasksAsync(string filter, int pageSize, int pageNumber)
{
var cacheKey = $"tasks:{filter}:{pageSize}:{pageNumber}";
if (_cache.TryGetValue(cacheKey, out TasksResponse cachedResponse))
{
_logger.LogDebug("Задачи получены из кеша для ключа {CacheKey}", cacheKey);
return cachedResponse;
}
var response = await _innerClient.GetTasksAsync(filter, pageSize, pageNumber);
var cacheOptions = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5),
SlidingExpiration = TimeSpan.FromMinutes(1)
};
_cache.Set(cacheKey, response, cacheOptions);
_logger.LogDebug("Задачи кешированы для ключа {CacheKey}", cacheKey);
return response;
}
// Остальные методы без кеширования или с другой стратегией кеширования...
} |
|
Регистрация кешированного клиента:
C# | 1
2
3
| services.AddMemoryCache();
services.AddScoped<ITaskManagerClient, TaskManagerClient>();
services.Decorate<ITaskManagerClient, CachedTaskManagerClient>(); |
|
Для работы с декораторами рекомендую использовать библиотеку Scrutor, которая существенно упрощает такие сценарии.
На практике нам однажды пришлось реализовать более сложную стратегию инвалидации кеша, где определенные методы (например, обновление задачи) автоматически инвалидировали связанные кешированные данные. Это потребовало глубокого анализа потоков данных приложения, но окупилось существенным улучшением отзывчивости интерфейса.
Тестирование gRPC-клиентов
Тестирование gRPC-клиентов — задача не тривиальная, но крайне важная. Хорошо протестированный клиент экономит часы отладки в продакшене. Я помню, как однажды наш сервис внезапно перестал работать только потому, что один gRPC-клиент некорректно обрабатывал нештатные ситуации. С тех пор я стал адептом тщательного тестирования.
Модульное тестирование с помощью моков
Для модульных тестов удобно использовать моки. Библиотека Moq отлично подходит для этой задачи:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| [Fact]
public async Task GetTasksAsync_ReturnsTasksList_WhenCalled()
{
// Arrange
var mockClient = new Mock<TaskManager.TaskManagerClient>();
var expectedResponse = new TasksResponse();
expectedResponse.Tasks.Add(new Task
{
Id = "1",
Title = "Тестовая задача",
Status = TaskStatus.TASK_STATUS_IN_PROGRESS
});
mockClient
.Setup(c => c.GetTasksAsync(
It.IsAny<TasksRequest>(),
It.IsAny<CallOptions>()))
.Returns(new AsyncUnaryCall<TasksResponse>(
Task.FromResult(expectedResponse),
Task.FromResult(new Metadata()),
() => Status.DefaultSuccess,
() => new Metadata(),
() => { }));
var clientWrapper = new TaskManagerClient(mockClient.Object);
// Act
var result = await clientWrapper.GetTasksAsync("", 10, 1);
// Assert
Assert.Single(result.Tasks);
Assert.Equal("Тестовая задача", result.Tasks[0].Title);
} |
|
Интеграционное тестирование
Для интеграционных тестов можно поднять тестовый сервер gRPC прямо в тесте:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| public class TaskManagerClientIntegrationTests : IClassFixture<GrpcTestFixture<Startup>>
{
private readonly GrpcTestFixture<Startup> _fixture;
public TaskManagerClientIntegrationTests(GrpcTestFixture<Startup> fixture)
{
_fixture = fixture;
}
[Fact]
public async Task CreateTask_CreatesNewTask_WhenValidDataProvided()
{
// Arrange
var client = new TaskManager.TaskManagerClient(_fixture.CreateChannel());
// Act
var request = new CreateTaskRequest
{
Title = "Новая задача для теста",
Description = "Описание тестовой задачи",
AssignedTo = "tester@example.com"
};
var response = await client.CreateTaskAsync(request);
// Assert
Assert.NotNull(response);
Assert.NotNull(response.Task);
Assert.Equal(request.Title, response.Task.Title);
// Cleanup - удаляем созданную задачу
await client.DeleteTaskAsync(new DeleteTaskRequest { Id = response.Task.Id });
}
} |
|
Класс GrpcTestFixture — это вспомогательный класс для настройки тестового сервера:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| public class GrpcTestFixture<TStartup> : IDisposable where TStartup : class
{
private readonly TestServer _server;
private readonly HttpMessageHandler _handler;
public GrpcTestFixture()
{
var builder = new WebHostBuilder()
.UseStartup<TStartup>()
.ConfigureServices(services =>
{
// Замена реальных зависимостей на тестовые
services.AddSingleton<ITaskRepository, InMemoryTaskRepository>();
});
_server = new TestServer(builder);
_handler = _server.CreateHandler();
}
public GrpcChannel CreateChannel()
{
return GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions
{
HttpClient = new HttpClient(_handler)
{
BaseAddress = new Uri("http://localhost")
}
});
}
public void Dispose()
{
_server.Dispose();
_handler.Dispose();
}
} |
|
Генерация клиентских библиотек для разных языков
Одно из главных преимуществ gRPC — возможность генерации клиентов для различных языков программирования из одного proto-файла. Когда наша команда начала распространять свой сервис на другие платформы, эта функция оказалась бесценной.
Генерация клиента для JavaScript/TypeScript
Bash | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # Установка необходимых инструментов
npm install -g grpc-tools
npm install -g ts-protoc-gen
# Генерация JavaScript-клиента
protoc --plugin=protoc-gen-grpc=`which grpc_tools_node_protoc_plugin` \
--js_out=import_style=commonjs,binary:./js_client \
--grpc_out=./js_client \
./Protos/*.proto
# Генерация TypeScript-определений
protoc --plugin=protoc-gen-ts=`which protoc-gen-ts` \
--ts_out=./js_client \
./Protos/*.proto |
|
Генерация клиента для Python
Bash | 1
2
3
4
5
6
7
| pip install grpcio-tools
python -m grpc_tools.protoc \
--proto_path=./Protos \
--python_out=./python_client \
--grpc_python_out=./python_client \
./Protos/*.proto |
|
Генерация клиента для Java
Bash | 1
2
3
4
| protoc --plugin=protoc-gen-grpc-java=`which protoc-gen-grpc-java` \
--java_out=./java_client \
--grpc-java_out=./java_client \
./Protos/*.proto |
|
Автоматизация генерации клиентов
Чтобы автоматизировать процесс генерации клиентов, можно создать скрипт или задачу в системе сборки:
PowerShell | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| # generate-clients.ps1
$languages = @("csharp", "js", "python", "java")
foreach ($lang in $languages) {
$outDir = "./clients/$lang"
New-Item -ItemType Directory -Force -Path $outDir
switch ($lang) {
"csharp" {
# Генерация C# клиента
dotnet build ./src/MyService.Contracts.csproj -o $outDir
}
"js" {
# Генерация JavaScript клиента
# ...
}
# другие языки...
}
} |
|
Обработка таймаутов в клиентах
Когда дело доходит до распределённых систем, таймауты становятся критически важной частью обеспечения надёжности. Я не раз наблюдал, как системы "падали" из-за того, что один сервис не отвечал, а другие бесконечно ждали от него ответа.
Установка таймаутов для отдельных вызовов
C# | 1
2
3
| // Устанавливаем таймаут для конкретного вызова
var callOptions = new CallOptions(deadline: DateTime.UtcNow.AddSeconds(5));
var response = await client.GetTasksAsync(request, callOptions); |
|
Глобальные таймауты для канала
C# | 1
2
3
4
5
6
7
| var channel = GrpcChannel.ForAddress("https://localhost:5001", new GrpcChannelOptions
{
HttpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(10)
}
}); |
|
Комбинирование таймаутов с политиками повторов
Библиотека Polly позволяет изящно комбинировать таймауты с повторами:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| var timeoutPolicy = Policy.TimeoutAsync<HttpResponseMessage>(
TimeSpan.FromSeconds(10),
TimeoutStrategy.Pessimistic);
var retryPolicy = HttpPolicyExtensions
.HandleTransientHttpError()
.Or<TimeoutRejectedException>() // Обработка исключений таймаута от Polly
.WaitAndRetryAsync(3, retryAttempt =>
TimeSpan.FromMilliseconds(Math.Pow(2, retryAttempt) * 100));
var combinedPolicy = retryPolicy.WrapAsync(timeoutPolicy);
services.AddGrpcClient<TaskManager.TaskManagerClient>(options =>
{
options.Address = new Uri("https://localhost:5001");
})
.AddPolicyHandler(combinedPolicy); |
|
Эффективная работа с потоковыми gRPC-методами
Одно из мощных преимуществ gRPC — нативная поддержка потоковой передачи данных. В одном из наших проектов мы использовали серверные стримы для отправки больших наборов данных по частям, что позволило значительно сократить задержки при отображении первых результатов пользователю.
Пример клиента для серверного стриминга:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| public async Task WatchTasksAsync(string filter,
Func<Task, Task> onTaskReceived,
CancellationToken cancellationToken = default)
{
var request = new WatchTasksRequest { Filter = filter };
using var call = _client.WatchTasks(request, cancellationToken: cancellationToken);
try {
await foreach (var response in call.ResponseStream.ReadAllAsync(cancellationToken))
{
await onTaskReceived(response.Task);
}
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled)
{
// Нормальное завершение при отмене
_logger.LogInformation("Стриминг задач был отменен");
}
catch (Exception ex)
{
_logger.LogError(ex, "Ошибка при получении потока задач");
throw;
}
} |
|
Использование:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
| var cts = new CancellationTokenSource();
// Запуск стриминга в фоновом режиме
_ = taskClient.WatchTasksAsync(
"status:active",
async task => {
Console.WriteLine($"Получена задача: {task.Title}");
await UpdateUIAsync(task);
},
cts.Token);
// Отмена стрима через 5 минут
cts.CancelAfter(TimeSpan.FromMinutes(5)); |
|
Работа с двунаправленным стримингом
Двунаправленный стриминг в gRPC — это как двустороняя радиосвязь, где оба участника могут одновременно отправлять и получать сообщения. Мы использовали такой подход для реализации чата в одном из продуктов.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| public async Task ChatAsync(
IAsyncEnumerable<string> messageStream,
Func<string, Task> onMessageReceived,
CancellationToken cancellationToken = default)
{
using var call = _client.Chat(cancellationToken: cancellationToken);
// Запуск задачи чтения ответов
var readTask = Task.Run(async () => {
await foreach (var response in call.ResponseStream.ReadAllAsync(cancellationToken))
{
await onMessageReceived(response.Text);
}
}, cancellationToken);
// Отправка сообщений
try
{
await foreach (var message in messageStream.WithCancellation(cancellationToken))
{
await call.RequestStream.WriteAsync(new ChatMessage { Text = message });
}
}
finally
{
// Закрываем стрим запросов, сообщая серверу о завершении
await call.RequestStream.CompleteAsync();
}
// Ждем завершения чтения ответов
await readTask;
} |
|
gRPC предоставляет мощные инструменты для создания клиентских приложений, взаимодействующих с gRPC-сервисами. От простых унарных вызовов до сложных двунаправленных потоков, от базового подключения до продвинутых стратегий кеширования и повторных попыток — всё это доступно разработчикам на C#. Правильное использование этих возможностей позволяет создавать надежные, производительные клиентские приложения, которые эффективно взаимодействуют с серверной частью в любых условиях сети.
Продвинутые сценарии использования
Когда базовая реализация gRPC-сервисов и клиентов уже освоена, самое время перейти к более сложным и интересным сценариям. Здесь мы сталкиваемся с потребностью не просто заставить всё работать, а сделать это максимально эффективно, надёжно и масштабируемо. Как-то на одном из наших высоконагруженных проектов мы смогли снизить нагрузку на сеть почти вдвое благодаря некоторым из приведённых ниже техник.
Оптимизация передачи данных через сжатие
gRPC поддерживает сжатие сообщений "из коробки", что может существенно снизить объёмы передаваемых данных. Особенно эффективно это работает для текстовой информации или структурированых даных с повторяющимися паттернами. На сервере настройка сжатия выглядит так:
C# | 1
2
3
4
5
6
| // Program.cs или Startup.cs
services.AddGrpc(options =>
{
options.ResponseCompressionAlgorithm = "gzip"; // По умолчанию используем gzip
options.ResponseCompressionLevel = CompressionLevel.Optimal; // Баланс между скоростью и уровнем сжатия
}); |
|
На клиенте тоже можно указать предпочитаемое сжатие:
C# | 1
2
3
4
5
6
7
| var channel = GrpcChannel.ForAddress("https://localhost:5001", new GrpcChannelOptions
{
CompressionProviders = new List<ICompressionProvider>
{
new GzipCompressionProvider(CompressionLevel.Fastest) // Быстрое, но менее эффективное сжатие для клиента
}
}); |
|
Для отдельных запросов:
C# | 1
2
3
4
5
6
7
| var callOptions = new CallOptions(headers: new Metadata
{
{ "grpc-accept-encoding", "gzip" },
{ "grpc-encoding", "gzip" }
});
var response = await client.GetLargeDataAsync(request, callOptions); |
|
На практике, наш сервис, отдающий аналитические отчёты, показал снижение объёма трафика на 70-85% после включения сжатия. Правда, приключилась забавная история, когда наш администратор увидел падение сетевого трафика и решил, что сервис перестал работать, чуть было не объявив инцидент.
Метрики и мониторинг с Prometheus
gRPC-сервисы, как и любые другие, нуждаются в мониторинге. Библиотека prometheus-net.AspNetCore позволяет легко добавить метрики в ваше приложение.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
| // Program.cs
using Prometheus;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc();
var app = builder.Build();
// Включаем Prometheus-метрики
app.UseMetricServer();
app.UseHttpMetrics();
// Специальный middleware для gRPC метрик
app.Use(async (context, next) =>
{
var startTime = DateTime.UtcNow;
var originalBody = context.Response.Body;
using var responseBody = new MemoryStream();
context.Response.Body = responseBody;
try
{
await next();
var method = context.Request.Path;
var status = context.Response.StatusCode;
var elapsedMs = (DateTime.UtcNow - startTime).TotalMilliseconds;
// Измеряем длительность и количество запросов
Metrics.CreateCounter("grpc_requests_total", "Total number of gRPC requests.")
.WithLabels(method, status.ToString()).Inc();
Metrics.CreateHistogram("grpc_request_duration_ms", "Duration of gRPC requests in milliseconds.")
.WithLabels(method, status.ToString()).Observe(elapsedMs);
}
finally
{
responseBody.Seek(0, SeekOrigin.Begin);
await responseBody.CopyToAsync(originalBody);
}
});
app.MapGrpcService<MyService>();
app.Run(); |
|
В одном из проектов эти метрики помогли нам выявить аномально медленный метод, который оказался виноватым в нерегулярных задержках во всей системе. Виновникам был рекурсивный LINQ-запрос, который мы переписали, и производительность взлетела в 20 раз!
Оптимизация кросс-платформенного взаимодействия
При работе в гетерогеных системах, где клиенты реализованы на разных языках, регулярно всплывают интересные проблемы несовместимости. Например, в JavaScript битовые маски передаются как строки, а в C# как целые числа. Решение — использовать перехватчики и адаптеры:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| public class ScalarTypeInterceptor : Interceptor
{
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
// Проверяем, есть ли в запросе поля типа long, полученные от JavaScript
if (request is SomeRequest someRequest && someRequest.LongValue is string stringValue)
{
// Конвертируем строковое представление в long
if (long.TryParse(stringValue, out var longValue))
{
typeof(SomeRequest).GetProperty("LongValue")
.SetValue(someRequest, longValue);
}
}
return await continuation(request, context);
}
} |
|
Глобальные обработчики ошибок
В больших системах хаотичная обработка ошибок приводит к головной боли. Централизованный подход с перехватчиками решает эту проблему:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| public class GlobalErrorInterceptor : Interceptor
{
private readonly ILogger<GlobalErrorInterceptor> _logger;
public GlobalErrorInterceptor(ILogger<GlobalErrorInterceptor> logger)
{
_logger = logger;
}
public override Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
try
{
return continuation(request, context);
}
catch (Exception ex) when (LogException(ex, context))
{
// Преобразуем различные исключения в соответствующие gRPC-статусы
if (ex is ArgumentException)
throw new RpcException(new Status(StatusCode.InvalidArgument, ex.Message), ex.Message);
if (ex is UnauthorizedAccessException)
throw new RpcException(new Status(StatusCode.PermissionDenied, "Not authorized"), ex.Message);
if (ex is TimeoutException)
throw new RpcException(new Status(StatusCode.DeadlineExceeded, "Operation timed out"), ex.Message);
// Для неизвестных ошибок возвращаем общий статус Internal
throw new RpcException(new Status(StatusCode.Internal, "Internal server error"), "An unexpected error occurred");
}
}
private bool LogException(Exception ex, ServerCallContext context)
{
_logger.LogError(ex, "Error processing gRPC call to {Method} from {Peer}",
context.Method, context.Peer);
return true; // Всегда возвращаем true, чтобы catch сработал
}
} |
|
Один интересный кейс из моей практики: система управления умным домом, где gRPC использовался для коммуникации с устройствами. Потеря соединения приводила к каскадному эффекту исключений. Глобальный обработчик ошибок с умной логикой восстановления спас ситуацию и существенно повысил надёжность системы.
Безопасная обработка ресурсоёмких операций
Для операций, потребляющих много ресурсов (например, обработка файлов), жизненно важно избегать перегрузки сервера. Механизм flow control в gRPC и специальные интерцепторы помогут:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| public class ResourceThrottlingInterceptor : Interceptor
{
private readonly SemaphoreSlim _semaphore;
public ResourceThrottlingInterceptor(int maxConcurrentOperations)
{
_semaphore = new SemaphoreSlim(maxConcurrentOperations);
}
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
// Если операция ресурсоёмкая, ограничиваем конкуретность
if (IsResourceIntensiveOperation(context.Method))
{
try
{
if (!await _semaphore.WaitAsync(TimeSpan.FromSeconds(10)))
{
throw new RpcException(new Status(StatusCode.ResourceExhausted, "Server is too busy"));
}
return await continuation(request, context);
}
finally
{
_semaphore.Release();
}
}
return await continuation(request, context);
}
private bool IsResourceIntensiveOperation(string method)
{
return method.EndsWith("/ProcessLargeFile") ||
method.EndsWith("/GenerateReport");
}
} |
|
Такой подход однажды спас наш аналитический сервис от падения при пиковой нагрузке, когда несколько пользователей одновременно запустили генерацию тяжёлых отчётов. Вместо того, чтобы упасть под нагрузкой, сервис элегантно отклонил лишние запросы, предложив пользователям повторить попытку позже.
Применение в реальных проектах
В теории gRPC звучит заманчиво, но как эта технология зарекомендовала себя в боевых условиях? Расскажу о нескольких реальных сценариях использования, которые доказали свою эффективность в продакшн-окружениях.
Микросервисные архитектуры: прорыв в коммуникациях
Микросервисная архитектура и gRPC — практически идеальная пара. В одном из наших проектов, где более 30 микросервисов должны были слаженно работать друг с другом, переход с REST на gRPC привел к потрясающым результатам: среднее время отклика системы сократилось на 40%, а нагрузка на сетевую инфраструктуру уменьшилась примерно на треть. Особенно ощутимый эффект наблюдался в сценариях, где сервисы обменивались большими объемами структурированных данных. Например, сервис аналитики, которому приходилось агрегировать информацию из 5-7 других сервисов, стал работать значительно быстрее благодаря бинарной сериализации и мультиплексированию запросов.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| // Пример сервиса, который обращается к нескольким микросервисам
public class AnalyticsService
{
private readonly ProductService.ProductServiceClient _productClient;
private readonly OrderService.OrderServiceClient _orderClient;
private readonly UserService.UserServiceClient _userClient;
public async Task<AnalyticsReport> GenerateReport(DateTime startDate, DateTime endDate)
{
// Запросы выполняются параллельно
var productsTask = _productClient.GetProductsAsync(new ProductsRequest());
var ordersTask = _orderClient.GetOrdersAsync(new OrdersRequest
{ StartDate = Timestamp.FromDateTime(startDate.ToUniversalTime()),
EndDate = Timestamp.FromDateTime(endDate.ToUniversalTime()) });
var usersTask = _userClient.GetActiveUsersAsync(new ActiveUsersRequest());
await Task.WhenAll(productsTask, ordersTask, usersTask);
var report = new AnalyticsReport();
// Обработка данных...
return report;
}
} |
|
Постепенная миграция с REST на gRPC
Редко когда есть роскошь начать проект с нуля — обычно приходится мигрировать существуюшую систему. В одном финтех-проекте мы использовали стратегию "подкрадывающегося gRPC", когда наиболие критичные для производительности эндпоинты переводились на gRPC, а остальные оставались на REST.
Для этого использовался один интересный паттерн — "адаптер API":
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| [ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly UserService.UserServiceClient _grpcClient;
[HttpGet("{id}")]
public async Task<IActionResult> GetUser(string id)
{
try {
// REST-запрос превращается в gRPC вызов
var response = await _grpcClient.GetUserAsync(new GetUserRequest { Id = id });
// Преобразование gRPC-ответа в REST-модель
var user = new UserDto
{
Id = response.User.Id,
Name = response.User.Name,
Email = response.User.Email
};
return Ok(user);
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound) {
return NotFound();
}
}
} |
|
Такой подход позволил нам безболезнено мигрировать внутренние коммуникации на gRPC, не меняя публичный REST API для внешних интеграций. Со временем и внешние клиенты начали переходить на gRPC-реализации, получая значительный прирост производительности.
Интеграция с контейнерными оркестраторами
gRPC отлично вписывается в контейнерную инфраструктуру и оркестраторы типа Kubernetes. Для одного проекта мы создали интересную конфигурацию, где сервисы автоматически находили друг друга через DNS-имена сервисов:
YAML | 1
2
3
4
5
6
7
8
9
10
11
| apiVersion: v1
kind: Service
metadata:
name: product-service
spec:
selector:
app: product-service
ports:
- port: 80
targetPort: 5000
name: grpc |
|
Клиенты настраивались на подключение по DNS-имени:
C# | 1
2
| var channel = GrpcChannel.ForAddress("http://product-service");
var client = new ProductService.ProductServiceClient(channel); |
|
Это упростило масштабирование сервисов — Kubernetes автоматически балансировал нагрузку между подами. Правда, мы столкнулись с небольшой проблемой: HTTP/2-соедениния долгоживущие, из-за чего переключение между репликами происходило не мгновенно. Пришлось настраивать более агрессивную стратегию переподключения.
gRPC + Event Sourcing: дуэт для высоконагруженных систем
На одном из проектов в сфере IoT мы успешно комбинировали gRPC с паттерном Event Sourcing. gRPC использовался для приема команд от устройств, а события сохранялись и распространялись через Apache Kafka:
C# | 1
2
3
4
5
6
7
8
9
10
11
| public override async Task<CommandResponse> ExecuteCommand(CommandRequest request, ServerCallContext context)
{
var commandResult = await _commandHandler.Handle(
new DeviceCommand(request.DeviceId, request.CommandType, request.Payload));
// Публикация события в Kafka
await _eventProducer.ProduceAsync("device-events",
new DeviceEventMessage(request.DeviceId, "CommandExecuted", commandResult));
return new CommandResponse { Success = true, Result = commandResult };
} |
|
Для обратной связи с устройствами использовался двунаправленный стриминг, позволяющий практически в реальном времени отправлять команды на устройства.
Такая архитектура оказалась невероятно масштабируемой и гибкой. Каждый компонент занимался своим делом: gRPC обеспечивал эффективную двустороннюю коммуникацию с устройствами, а Kafka отвечала за надежное хранение и распространение событий.
Информация по миграции с WCF на gRPC Онлайн версия: https://docs.microsoft.com/en-us/dotnet/architecture/grpc-for-wcf-developers/
PDF:... Структура проекта для gRPC Обычно, если делается WebAPI-проект, то слои разносятся в разные проекты, которые имеют только те... GRPC отключить вывод сообщений о состоянии подключения Добрый день. Подскажите, пожалуйста, как отключить вывод сообщений для gRPC (см. пример ниже)?... gRPC соединение ssl не может быть установлено Проблема следующая: запускаю приложение клиента локально (на одной машине с сервером; Windows 10) -... WCF vs gRPC Добрый вечер господа.
Дайте совет пожалуйста.
Занимаюсь я проектом, некого документооборота.... Как добавить gRPC сервис с жизненным циклом Singleton? Добрый день! Я разрабатываю gRPC сервис. Заметил, что при каждом запросе из консоли объект сервиса... Grpc, Protobuf. Клиент, Net 6, не соединяется с сервером, Net 4.8 Здравствуйте.
Если клиент и сервер находятся на одном пк, то проблем нет, соединяются.
Когда... Не видит сгенерированные классы gRPC Здравствуйте. Делал проект на WinForm, в один момент захотел перейти на WPF, так как для меня это в... Проблема валидации jwt токена, выданного gRPC сервисом Архитектура подразумевает разделение на gRPC микросервисы и REST API Gateway. Проблема в том, что у... Как выполнить gRPC команды в C#? Ребята, подскажите пожалуйста, как это выполнить в C# ?
Ссылка
Интересует вот эта часть:
... Ошибка при подключении к gRPC сервису Добрый день. Имеется gRPC сервис сконфигурированный следующим образом:
var builder =... gRPC Server C# + Client Kotlin Android Добрый день! Надеюсь найдутся люди разбирающиеся в .NET & Kotlin Android.
Немного предыстории...
|