Фреймворк gRPC: как он упрощает задачи разработчика и почему лучше, чем REST API

Kate

Administrator
Команда форума
Привет! Я — Роман Махнык, .NET developer в NIX. За моими плечами более трех лет участия в коммерческих проектах. На данный момент занимаюсь проектированием и разработкой веб-приложений с применением облачных технологий.

В этой статье я расскажу о достаточно новом фреймворке gRPC для API. Он очень перспективен с точки зрения роста производительности. В gRPC сложность коммуникации между сервисами сведена к минимуму, а отправка данных проходит по постоянному каналу без необходимости сериализации, роутов эндпоинтов и т. п.

Данный материал будет интересен как джунам, которые только знакомятся с API, так и более опытным специалистам, которые еще не успели поработать с gRPC.

Что объединяет Netflix, Docker, Spotify и Dropbox? То, что все они перешли в организации работы своих микросервисов на gRPC. Этот фреймворк компания Google презентовала в 2015 году. Несмотря на столь юный возраст, gRPC уже подвинул во многих крупных сервисах популярнейший REST API. Но сможет ли он вовсе заменить его? Чтобы ответить на этот вопрос, давайте сравним фреймворки. А также поговорим о балансировке нагрузки, мультиплексировании и HTTP/2. Кроме того, в конце статьи мы создадим простое gRPC-приложение

Почему все уходят от монолитов​

Как и у других фреймворков для API, задача gRPC — установить корректное, стабильное, быстрое и безопасное общение между сервисами. В нашем случае это небольшие веб-приложения, которые объединены в единую систему. Каждый из них несет ответственность за отдельную часть работы приложения. В качестве примера представим онлайн-магазин игр, где есть три таких сервиса. Один отвечает за предоставление игр для продажи, второй — за демонстрацию на экране достижений игрока, а третий — за генерацию скидок. Каждая из этих задач требует постоянного обращения к определенному сервису. Почему бы не сделать все это одним приложением с несколькими или даже общей базой данных? Именно в этом суть микросервисного подхода в построении архитектуры приложений.

Причин ухода от монолитов достаточно. Самые весомые такие:

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

Также можем поместить их на облачных сервисах, например, на AWS Lambda и Azure Function. При необходимости они автоматически масштабируются, а оплата производится за количество отработанного времени. В некоторых ситуациях это очень выгодный подход, но он требует особой архитектуры, под которую как раз и подходят микросервисы. Для монолита же придется арендовать как минимум целую виртуальную машину, где оплата будет фиксированной. Это также относится и к вопросу обслуживания. AWS Lambda и Azure Function обслуживаются Amazon и Microsoft соответственно, а за виртуалками придется следить нам.

Обновление. Монолиты сложно обслуживать и добавлять в них новый функционал. При обновлении одной, даже совсем незначительной части приложения, необходимо останавливать все процессы. С микросервисом же на паузу ставится именно обновляемая часть, а остальная система продолжает работать.

Тестирование. В монолите все части приложения находятся в одном месте. Чтобы протестировать их, придется запускать все. В микросервисном приложении мы можем проверять отработку контрактов для каждого из них, не обращая внимания на остальную часть системы.

С переходом на микросервисы в проекте появляется такая особенность, как распределение ответственности. Мы же не хотим, чтобы сервисы выполняли одну и ту же задачу по несколько раз, верно? Также нелогично постоянно дублировать код и писать функционал, который уже создан. Потому нам надо настроить каналы коммуникации между сервисами. Это можно сделать двумя способами:

  • Синхронное общение — один сервис дожидается ответа от другого.
  • Асинхронное общение — первый сервис кладет сообщение в очередь, второй — по возможности подбирает его, обрабатывает и выдает ответ.
Каждый из вариантов имеет свои преимущества, лучше работает в определенных условиях и при конкретных задачах, но может значительно проигрывать второму способу при других обстоятельствах. В этой статье мы остановимся на технологии синхронного общения. Двумя самыми популярными способами реализации такого общения являются REST и RPC.

В чем разница между REST и gRPC​

Чтобы лучше понять принципиальную разницу между этими фреймворками, рассмотрим, как они устроены. Для работы REST посредством самого обычного CRUD подхода обычно используется пять типов HTTP-запросов для данных какого-то ресурса:

  • GET: получение/чтение;
  • POST: добавление/запись;
  • PUT/PATCH: обновление/изменение;
  • DELETE: удаление.
Модель сетевого взаимодействия выглядит так: клиент и сервер объединены общей сетью, в нужный момент клиент отправляет HTTP-запрос, сервер обрабатывает его и возвращает HTTP-ответ. Пока сервер делает свое дело, клиент выделяет дополнительный поток, в котором и происходит ожидание.

1_AkP01pI.jpg


Если поменять HTTP-запросы на RPC-коллы (вызовы удаленных процедур), то мы получаем схожую схему. Разница заключается в том, как происходят отправка и получение сервером запроса и получение ответа клиентом.

2_9TqZSQE.jpg


RPC — это протокол вызова удаленных процедур, который одна программа может использовать, чтобы вызвать метод, функцию или процедуру в другой, доступной в сети. При этом не нужно досконально разбираться в сети. Как видим на схеме, у клиента и сервера есть дополнительный уровень в общении — клиентская и серверная заглушки. Они созданы для того, чтобы отправлять данные по нужному адресу. Соответственно, в самом коде мы об этом вообще не переживаем. Эти заглушки генерируются автоматически. Все, что нам остается сделать — вызвать нужную процедуру.

3_kL2qmM5.jpg


Новая и, пожалуй, наиболее действенная и перспективная реализация этой концепции — фреймворк gRPC. По сравнению с другими реализациями RPC, у него есть множество преимуществ:

  • Идиоматические клиентские библиотеки на более чем 10 языках. Мы можем использовать библиотеки на Java, C#, JavaScript, Python и т.д. Все это выглядит нативно — не так, словно мы используем какую-то инопланетную технологию.
  • Простая структура определения сервисов. Для этого используются .proto-файлы.
  • HTTP/2. Двунаправленная потоковая передача на основе HTTP/2
  • Трассировка. Позволяет мониторить вызовы процедур, что очень полезно для отладки приложения и анализа работы сервисов для их дальнейших оптимизаций и улучшений.
  • Health check. С помощью этого механизма можно быстро проверить, работоспособен ли сервис и готов ли он обрабатывать запросы. Это помогает для балансировки нагрузки.
  • Балансировка нагрузки. Эта особенность позволяет распределять нагрузку на несколько экземпляров серверного приложения, тем самым значительно упрощая вопрос масштабирования. Балансировка может производиться как на клиентской части (например, клиентское приложение поочередно отправляет запросы на разные серверы), так и на промежуточной (специальной прокси) посредством сервис-меша.
  • Подключение аутентификации. Ее отсутствие было главным недостатком прошлых реализаций протокола. Из-за такой уязвимости RPC рекомендовали только для внутреннего общения.
Сравнивая гугловскую технологию с REST, здесь тоже находим свои плюсы:

  • Работа с Protobuf. В REST для передачи данных применяется текстовый формат JSON, который не сжимается. Protobuf — это бинарный формат. Используя его, мы избегаем передачи лишних данных и нам не надо будет десериализовать после этого полученные сообщения.
  • Обработка HTTP-запросов. В случае с REST необходимо постоянно думать, какой статус-код может прийти, какие данные будут храниться и т.п. В gRPC мы прикладываем минимум усилий для вызова удаленных процедур и их определения.
  • Простота определения контрактов. В REST для описания интерфейсов и документации нужно использовать сторонние инструменты и библиотеки — такие, как OpenAPI или Swagger. В gRPC происходит простое определение контрактов в .proto-файлах.
  • HTTP/2. REST зачастую использует более старую версию данного протокола — HTTP/1.1.
Чем хорош HTTP/2? Среди важных преимуществ:

  • бинарный формат передачи данных (уменьшает размер сообщений и ускоряет работу);
  • экономия трафика (усовершенствованное сжатие HTTP-сообщений, в первую очередь хедеров);
  • возможность передавать потоки данных;
  • мультиплексирование (в HTTP 1.1 для передачи трех файлов надо установить три соединения, в каждом из которых будет запрашиваться и отправляться определенный файл. В HTTP/2 можно все передать по одному соединению);
  • приоритезация потоков.

Настройка соединения по gRPC​

Последовательность создания gRPC канала включает несколько этапов:

  1. Открытие сокетов.
  2. Установка TCP-соединения.
  3. Согласование TLS.
  4. Запуск HTTP/2-соединения.
  5. Выполнение вызова gRPC-процедуры.
Мы могли бы избежать первых четырех пунктов за счет того, что один раз устанавливаем соединение и просто пользуемся им — это называется Persistent Connection. Почему бы не работать с таким решением постоянно? Однако возникает вопрос, как настроить балансировку нагрузки, когда, допустим, нам понадобится несколько экземпляров сервиса — как их распределять? Вариантов несколько:

  • Балансировка нагрузки на стороне клиента. В этом случае клиентская библиотека знает о существовании нескольких инстансов данного сервиса. Например, она будет отправлять запросы на каждый из них в процентном соотношении.
  • Балансировка нагрузки через прокси. Если эти сервисы хостятся на каком-то оркестраторе (типа Kubernetes), он может решить, что инстансов слишком много, и убирает один или, наоборот, добавляет новый. В этом случае помогает балансировка нагрузки через прокси Service Mesh. Это может быть Linkerd, Istio, Consul и т.п. Клиент будет устанавливать одно постоянное соединение к Mesh, а уже он посмотрит, какие есть экземпляры сервиса, когда они появляются или исчезают и будет хендлить это. Соединение будет только к актуальным сервисам, а клиент об этом знать не будет — у него всегда один коннекшн.
Иногда gRPC сравнивают с WCF. Я не думаю, что это актуально, поскольку gRPC — это узконаправленный фреймворк, который хорошо решает одну задачу. WCF — более универсальный фреймворк, который поддерживает RPC, но также поддерживает REST, SOAP и т.п. К сожалению, WCF не является универсальным в плане поддерживаемых платформ, ведь пока он сильно привязан к .NET. В свою очередь gRPC может работать в любой среде, и писать его можно на любых языках из списка поддерживаемых.

Однако на данный момент gRPC не может полноценно функционировать в браузерах. Реализовать HTTP/2-общение в браузере невозможно, потому что нет такого контроля над каналом связи, какой может быть, допустим, в .NET-приложении. Поэтому Google предлагает альтернативу: использовать gRPC-прокси. То есть сам браузер будет отправлять запросы HTTP 1.1 на прокси, который будет мапить сообщение в вызов gRPC процедуры.

4_g8687EY.jpg


На картинке выше вы можете увидеть пример того, как огромное количество микросервисов Netflix таким образом связаны друг с другом — здесь их более 500! Это одна из тех компаний, которая выиграла от перехода на протокол gRPC и усилила производительность своих сервисов. Раньше им для каждого запроса приходилось устанавливать отдельное соединение. Это занимало миллисекунды, но в масштабах такой компании простой на сотнях коннекшенов постепенно складывается в секунды и минуты. Теперь же по одному соединению можно отправлять все запросы. Скорость передачи данных выросла в разы, ведь удается избежать тех самых первых четырех этапов установки соединения.

Создаем приложение gRPC​

А теперь обещанный бонус — попробуем создать простое приложение Hello gRPC.

В первую очередь отмечу, что Visual Studio уже имеет предустановленный темплейт для создания подобных сервисов. Нам достаточно зайти в «Создание нового проекта», написать «gRPC» — и перед нами появится шаблон дефолтного gRPC-приложения. При этом поддерживается две самые актуальные версии .NET: 5 и 3.1.

image_6884771141636453425457.png


У нас есть .proto-файл, в котором описан сервис и интерфейс. Мы видим, что у этого сервиса есть некая процедура SayHello, которая принимает объект Hello в реквест, описанный ниже, и возвращает в реплай, который в этом же файле и описывается. Уникальной опцией для .NET является csharp_namespace — необходимый для того, чтобы указать, куда генерировать те классы, которые будут использоваться в этом приложении.

syntax = "proto3";

option csharp_namespace = "GrpcExample";

package greet;

// The greeting service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply);
}

// The request message containing the user's name.
message HelloRequest {
string name = 1;
}

// The response message containing the greetings.
message HelloReply {
string message = 1;
}

Под каждым свойством отправляемых и получаемых сообщений есть нумерация свойств. Это нужно для понимания, в каком порядке будут передаваться данные (так как у нас все-таки бинарный формат), а также для поддержки предыдущих версий. Если на сервере мы обновили наш .proto-файл и добавили новое свойство или убрали старое, то клиент, на котором еще не успели обновить файл, просто проигнорирует новое свойство и будет читать старое — как пустое, которое удалили. Соответственно, не будет никаких ошибок.

Теперь рассмотрим регистрацию сервисов gRPC в .NET-приложении. Для этого понадобится одна строчка, которая добавит все необходимые сервисы к нам в проект.

public void ConfigureServices(IServiceCollection services)
{
services.AddGrpc();
}

Чуть ниже мы видим, что gRPC-сервисы мапятся точно так же, как и обычные контроллеры. Потому есть уже готовый Gtreeter-сервис, интерфейс которого определен в .proto-файле. Если мы попытаемся посмотреть код этого GreeterService, то увидим, что дефолтная реализация уже есть. Сам сервис наследуется от некого GreeterBase — и это как раз тот файл, в который генерируется различная информация и классы, связанные с отправкой и получением сообщений. Все, что мы делаем — наследуемся от этого уже созданного объекта и реализовываем функции так, как нам надо.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}

app.UseRouting();

app.UseEndpoints(endpoints =>
{
endpoints.MapGrpcService<GreeterService>();

endpoints.MapGet("/", async context =>
{
await context.Response.WriteAsync("Communication with gRPC endpoints must be made through a gRPC client.");
});
});
}

Что касается создания клиента под данный сервис, я предлагаю перейти к уже созданному. Здесь мы видим, что клиент WebApi — приложение, которое будет принимать HTTP-запросы и обращаться к gRPC сервису для выполнения на нем процедуры. Все, что поменялось в .proto-файле — namespace, в который будет генерироваться клиентский код.

syntax = "proto3";

option csharp_namespace = "WebApi";

package greet;

service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
}

message HelloRequest {
string name = 1;
}

message HelloReply {
string message = 1;
}

В самом проекте Protobuf-файлы регистрируются отдельно. То есть не как обычный файл, а указывается, что будет использоваться конкретный Protobuf и роль данного сервиса в этом контракте. В данном случае, роль будет клиентская. При этом в серверном проекте мы указываем, что используется такой же .proto-файл, но роль — сервер.

<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<Protobuf Include="Protos\greet.proto" GrpcServices="Client" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.39.0" />
<PackageReference Include="Grpc.Net.ClientFactory" Version="2.39.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="5.6.3" />
</ItemGroup>

</Project>

Для того, чтобы переиспользовать одно и то же соединение, мы регистрируем фабрику gRPC клиентов. Делается это просто: приводим AddGrpcClient, указываем, какому именно сервису клиент нужен, и добавляем адрес, по которому будет хоститься уже сам сервис (не этот, а gRPC сервер; клиент будет отдельно).

public void ConfigureServices(IServiceCollection services)
{
services.AddGrpcClient<Greeter.GreeterClient>(o =>
{
o.Address = new Uri("https://localhost:5001");
});

services.AddControllers();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "WebApi", Version = "v1" });
});
}

Теперь давайте попробуем использовать наш gRPC клиент. Мы можем в любой момент вытащить его в нужном нам объекте через Dependency Injection. После чего — обращаться к нему как к обычному объекту. В нашем случае я сделал SayHello эндпоинт. Это функция контроллера, поэтому принимать она будет запросы HTTP и для обработки будет обращаться уже к gRPC сервису.

[ApiController]
[Route("[controller]")]
public class GreetController : ControllerBase
{
private readonly Greeter.GreeterClient _client;

public GreetController(Greeter.GreeterClient client)
{
_client = client;
}

[HttpPost]
public async Task<IActionResult> SayHello([FromBody]HelloRequest request)
{
var result = await _client.SayHelloAsync(request);
var result2 = await _client.SayHelloAsync(request);
var result3 = await _client.SayHelloAsync(request);

return Ok(new { MessageSum = result.Message + result2.Message + result3.Message });
}
}

Далее запускаем gRPC сервис, убеждаемся, что он функционирует, и запускаем клиент.

5_FVT4fQk.png


С помощью Swagger сделаем отправку этого запроса, чтобы немного облегчить процедуру. И укажем какое-то имя как аргумент. gRPC сервис увидел вызов данной процедуры, обработал его, залоггировал, и в ответе мы получили ожидаемый результат — и на этом, собственно, все.

6_T5JmmI2.png


7_xgIW5Ek.png


Сейчас уже немало проектов использует gRPC, хотя, к сожалению, коммьюнити его поклонников пока не такое большое, как у REST API. Технология относительно новая, и многие пока не привыкли к ней. Но я уверен: с таким быстрым ростом популярности gRPC в последнее время сообщество разработчиков будет развиваться более активно. Ведь, как вы могли убедиться, gRPC действительно дает множество плюсов в решении сложных задач.

 
Сверху