Привет! Я — Роман Махнык, .NET developer в NIX. Уже четвертый год я занимаюсь разработкой коммерческих проектов, а сейчас проектирую разные приложения на основе облачных решений.
В своей статье я буду описывать фреймворк gRPC для API. Он достаточно свежий, но уже зарекомендовал себя как перспективное решение. Суть gRPC в максимально упрощенной коммуникации сервисов. Также важно, что в gRPC данные отправляются по постоянному каналу без сериализации и роутов эндпоинтов.
Если вы только начинаете работу с API или хотите лучше разобраться именно в gRPC — эта статья для вас.
REST и gRPC: ключевые отличия
Чтобы лучше понять принципиальную разницу между этими фреймворками, рассмотрим, как они устроены. Для работы REST посредством самого обычного CRUD подхода обычно используется пять типов HTTP-запросов для данных какого-то ресурса:
GET: получение/чтение;
POST: добавление/запись;
PUT/PATCH: обновление/изменение;
DELETE: удаление.
Модель сетевого взаимодействия выглядит так: клиент и сервер объединены общей сетью, в нужный момент клиент отправляет HTTP-запрос, сервер обрабатывает его и возвращает HTTP-ответ. Пока сервер делает свое дело, клиент выделяет дополнительный поток, в котором и происходит ожидание.
Если поменять HTTP-запросы на RPC-коллы (вызовы удаленных процедур), то мы получаем схожую схему. Разница заключается в том, как происходят отправка и получение сервером запроса и получение ответа клиентом.
RPC — это протокол вызова удаленных процедур, который одна программа может использовать, чтобы вызвать метод, функцию или процедуру в другой, доступной в сети. При этом не нужно досконально разбираться в сети. Как видим на схеме, у клиента и сервера есть дополнительный уровень в общении — клиентская и серверная заглушки. Они созданы для того, чтобы отправлять данные по нужному адресу. Соответственно, в самом коде мы об этом вообще не переживаем. Эти заглушки генерируются автоматически. Все, что нам остается сделать — вызвать нужную процедуру.
Новая и, пожалуй, наиболее действенная и перспективная реализация этой концепции — фреймворк 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 канала включает несколько этапов:
Открытие сокетов.
Установка TCP-соединения.
Согласование TLS.
Запуск HTTP/2-соединения.
Выполнение вызова 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 процедуры.
На картинке выше вы можете увидеть пример того, как огромное количество микросервисов Netflix таким образом связаны друг с другом — здесь их более 500! Это одна из тех компаний, которая выиграла от перехода на протокол gRPC и усилила производительность своих сервисов. Раньше им для каждого запроса приходилось устанавливать отдельное соединение. Это занимало миллисекунды, но в масштабах такой компании простой на сотнях коннекшенов постепенно складывается в секунды и минуты. Теперь же по одному соединению можно отправлять все запросы. Скорость передачи данных выросла в разы, ведь удается избежать тех самых первых четырех этапов установки соединения.
Как создать приложение gRPC
А теперь обещанный бонус — попробуем создать простое приложение Hello gRPC.
В первую очередь отмечу, что Visual Studio уже имеет предустановленный шаблон для создания подобных сервисов. Нам достаточно зайти в «Создание нового проекта», написать «gRPC» — и перед нами появится шаблон дефолтного gRPC-приложения. При этом поддерживается две самые актуальные версии .NET: 5 и 3.1.
У нас есть .proto-файл, в котором описан сервис и интерфейс. Мы видим, что у этого сервиса есть некая процедура SayHello, которая принимает объект Hello в реквест, описанный ниже, и возвращает в реплай, который в этом же файле и описывается. Уникальной опцией для .NET является csharp_namespace
, необходимый для того, чтобы указать, куда генерировать те классы, которые будут использоваться в этом приложении.
syntax = “proto3” ;
option csharp_namespace = “Grpcservice2” ;
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-файл и добавили новое свойство или убрали старое, то клиент, на котором еще не успели обновить файл, просто проигнорирует новое свойство и будет читать старое — как пустое, которое удалили. Соответственно, не будет никаких ошибок.
syntax = “proto3” ;
option csharp_namespace = “GrpcService2” ;
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;
}
Теперь рассмотрим регистрацию сервисов gRPC в .NET-приложении. Для этого понадобится одна строчка, которая добавит все необходимые сервисы к нам в проект.
using System. Collections.Generic ;
using System.Linq;
using System. Threading. Tasks ;
namespace GrpcService2
{
public class Startup
{
// This method gets called by the runtime. Use this method to add services to the cont
// For more information on how to configure your application, visit https://go.microsoft
public void ConfigureServices (IServiceCollection services)
{
services. AddGrpc ()
}
// This method gets called by the runtime. Use this method to configure the HTTP reque
public void Configure (IApplicationBuilder app, IWebHostEnvironment env)
{
if (env. IsDevelopment ())
{
Чуть ниже мы видим, что gRPC-сервисы мапятся точно так же, как и обычные контроллеры. Потому есть уже готовый Gtreeter-сервис, интерфейс которого определен в .proto-файле. Если мы попытаемся посмотреть код этого GreeterService, то увидим, что дефолтная реализация уже есть. Сам сервис наследуется от некого GreeterBase — и это как раз тот файл, в который генерируется различная информация и классы, связанные с отправкой и получением сообщений. Все, что мы делаем — наследуемся от этого уже созданного объекта и реализовываем функции так, как нам надо.
// This method gets called by the runtime. Use this method to configure the HTTP reque
public void Configure (IApplicationBuilder app, IWebHostEnvironment env)
{
if (env. IsDevelopment ())
app. UseDeveloperExceptionPage();
}
app. UseRouting ();
app. UseEndpoints (endpoints =>
{
endpoints MapGrpcService<GreeteService>();
endpoints. MapGet ( “ / ”, async context =>);
{
await context. Response. WriteAsyns (“Communication with gRPC endpoints must
{ );
} );
}
}
}
Что касается создания клиента под данный сервис, я предлагаю перейти к уже созданному. Здесь мы видим, что клиент WebApi — приложение, которое будет принимать HTTP-запросы и обращаться к gRPC сервису для выполнения на нем процедуры. Все, что поменялось в .proto-файле — namespace, в который будет генерироваться клиентский код.
syntax = “proto3”;
option csharp_namespace = “WebApi”;
package greet;
service Greeter {
prc SayHello (HelloRequest) returns (HelloReply) ;
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string name = 1;
}
В самом проекте Protobuf-файлы регистрируются отдельно. То есть не как обычный файл, а указывается, что будет использоваться конкретный Protobuf и роль данного сервиса в этом контракте. В данном случае, роль будет клиентская. При этом в серверном проекте мы указываем, что используется такой же .proto-файл, но роль — сервер.
<Project Sdk = “Microsoft.NET.Sdk.Web”>
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Protobuf Include = “Protos\greet.photo” GrpcServices=”Client” />
</ItemGroup>
</ItemGroup>
<PackageReference Include= “Grpc. AspNetCore” Version= “2.39.0” />
<PackageReference Include= “Grpc. Net. ClientFactory” Version= “2.39.0” />
<PackageReference Include= “Swashbuncle. AspNetCore” Version= “5.6.3” />
</ItemGroup>
</Project>
Для того, чтобы переиспользовать одно и то же соединение, мы регистрируем фабрику gRPC клиентов. Делается это просто: приводим AddGrpcClient, указываем, какому именно сервису клиент нужен, и добавляем адрес, по которому будет хоститься уже сам сервис (не этот, а gRPC сервер; клиент будет отдельно).
public IConfiguration Configuration { get; }
public void ConfigureServices (IServiceCollection services)
{
services. AddGrpcClient<Greeter. GreeterClient>(0 =>
o.Address = new Uri ( “https://localhost :500 “) ;
} ) ;
services. AddControllers () ;
services. AddSwaggerGen(c =>
{
c.SwaggerDoc ( “v1”, new OpenApiInfo { Title = “WebApi”, Version = “v1” } ) ;
} )
}
public void Configure (IApplicationbuilder app, IWebhostEnvironment env)
Теперь давайте попробуем использовать наш gRPC клиент. Мы можем в любой момент вытащить его в нужном нам объекте через Dependency Injection. После чего — обращаться к нему как к обычному объекту. В нашем случае я сделал SayHello эндпоинт. Это функция контроллера, поэтому принимать она будет запросы HTTP и для обработки будет обращаться уже к gRPC сервису.
{Route ( “ [controller] “ ) ]
public class GreetController : СontrollerBase
{
private readonly Greeter. GreeterClients _client;
public GreetController (Greeter. GreeterClient client)
{
client = client;
}
[HttpPost]
public asyns Task <IActionResult> SayHello ([FromBody] HelloRequest request)
{
var result = await _client. SayHello (request) ;
return Ok ( new { MessageSum = result. Message ] );
}
}
}
Далее запускаем gRPC сервис, убеждаемся, что он функционирует, и запускаем клиент. С помощью Swagger сделаем отправку этого запроса, чтобы немного облегчить процедуру. И укажем какое-то имя как аргумент. gRPC сервис увидел вызов данной процедуры, обработал его, залоггировал, и в ответе мы получили ожидаемый результат — и на этом, собственно, все.
Сейчас уже немало проектов использует gRPC, хотя, к сожалению, коммьюнити его поклонников пока не такое большое, как у REST API. Технология относительно новая, и многие пока не привыкли к ней. Но я уверен: с таким быстрым ростом популярности gRPC в последнее время сообщество разработчиков будет развиваться более активно. Ведь, как вы могли убедиться, gRPC действительно дает множество плюсов в решении сложных задач.
Комментарии (25)
taluyev
09.12.2021 11:51+4Преимущества REST: простота, ориентация на человека. За это любят REST. Бинарные протоколы и до этого существовали, например Hessian. Более сложные технологии до этого существовали, например "Веб Сервисы". Чтобы работать с рест не обязательно использовать "Стороннее программное обеспечение", но есть инфраструктура, делающая REST "больше чем протокол". Простота, не зависимость, открытость для людей и машин - главная идея REST.
ertaquo
09.12.2021 12:35+12Извините, но по части аргументации местами написана откровенная ахинея. Да и код вам бы подправить, а то какая-то кривая копипаста, без стилевого оформления и с левыми "1 reference".
Реальный плюс GRPC всего один: использование одного HTTP/2 соединения для отправки множества запросов. Это ускоряет отправку запросов и получение ответов. Для REST это также никто не запрещает делать, но в дикой природе гораздо чаще встречается HTTP/1, который гораздо легче разбирать.
protobuf - формат неплохой, но не самый лучший. Например, по задумке авторов поля не могут быть nullable, что не всегда удобно. Существуют только примитивные типы, которые нужно вручную преобразовывать в нужный (та же дата/время, например). Да, в JSON типов еще меньше, но при этом в большинстве REST-фреймворков существует встроенная сериализация/десериализация для многих стандартных типов. В protobuf - нифига, бери строковое значение из поля и парси вручную.
Генерация кода... Да, можно сгенерировать код клиентской библиотеки и серверный бойлерплейт для многих языков. Но этот код зачастую сложен для понимания и анализа, в связи с чем IDE не всегда может адекватно сформировать подсказки по методам и полям. И чем это отличается от генерации кода для OpenAPI? Это притом, что OpenAPI часто генерируется автоматически на основе аннотаций в коде, и в нем можно указать гораздо больше различных данных: описания методов и полей, авторизация для методов, валидация передаваемых данных и т. п.
Обработка ошибок в GRPC тоже выглядит странновато. Если метод кидает исключение - не факт, что GRPC его отловит и вернет ошибку. Вместо этого нужно перехватывать исключения вручную и возвращать ошибки специальным способом - причем разным для каждого языка (то есть нельзя писать серверное приложение на Python, а потом с этим же опытом прийти в Go, GRPC API будет чувствительно различаться).
Все остальное - трассировка, health check, балансировка нагрузки, аутентификация и т. п. - либо не зависит от выбора "GRPC или REST", либо банально проще и удобнее в REST. Например, во многих REST-фреймворках есть интеграция с Prometheus, а для health check не нужно заводить отдельный класс. Балансировка нагрузки и аутентификация относятся скорее к API gateway (nginx/krakend/kong/tyc etc.) и к service mesh (istio etc.), а авторизация не доставляет проблем ни там, ни там.
Вполне возможно, что я где-то не прав, сужу чисто по личному опыту.
Ordos
09.12.2021 13:44+2Например, по задумке авторов поля не могут быть nullable, что не всегда удобно.
Это не совсем так, там есть набор well known types, которые можно использовать вроде бы без необходимости их как-то специально подключать. Для них генератор правильно создаёт nullable типы.
ertaquo
09.12.2021 14:12Пардон, поправка: тривиальные типы (типа string, int и т. п.) не могут быть nullable. Вложенные сообщения - могут. Для этого в well-known types как раз и сделаны типы наподобие Int64Value. Скорее всего, авторы в этом плане ориентировались на Go, где такая же логика и аналогичные типы для работы с sql.
Просто это сделано не слишком логично, учитывая наличие ключевых слов required и optional - благо от этого решили отказаться в третьей версии protobuf.
Virviil
09.12.2021 18:03Что значит nullable?
В proto3 спецификации все поля - optional, то есть могут либо быть либо отсутствовать. Чем это отличается от nullable?
ultrinfaern
09.12.2021 18:53+2Тем что отсутсвие поя и null это разные вещи.
Например рассмотрим метод PUT, который должен обновлять только переданные поля. Поэтому когда поля нет - мы его не трогаем, а когда оно есть мы его обновляем. И null это тоже значение, на которое нужно поменять.
А под капотом это может быть критично, например, пользователь воспользовался услугой, но не определился с параметрами, и не пользовался вообще.
ЗЫ: Если проще - пустая строка и null это тоже разные вещи, правда у некоторых это тоже не так. :)
rinat_crone
09.12.2021 13:48+2Местами и у Вас ахинея написана, не накидывайтесь уж так категорично на автора :)
ertaquo
09.12.2021 14:14+1Не спорю, легко могу ошибаться, и буду действительно благодарен, если объясните, в чем именно.
Myateznik
09.12.2021 17:04+3Относительно тривиальных типов (string, int, etc.) в третьей версии protobuf при указании optional можно получить nullable (в Golang будет сгенерировано в *string, *int, etc.). Можно ещё чуть костыляво делать через oneof, если вдруг генераторы не поддерживают optional (по типу Twirp).
А вообще действительно protobuf немного не логичный в этом плане т.к. в 3-ей версии все поля считаются опциональными, но есть ещё флаг optional, который влияет на генерируемый код. Возможно относительно скоро станет по лучше т.к. в исходниках уже появилось упоминание 4-ой версии.
Относительно самого gRPC он на самом деле не прибит к protobuf жёстко, можно поставить кодек хоть под Apache Thrift, хоть JSON, хоть MsgPack, да любой формат в целом.
Да и к транспорту по сути gRPC тоже не прибит. Например, не потоковые вызовы можно на прямую пробросить с HTTP/1.* просто подменив номер версии на HTTP/2 (это в Golang). Или можно переложить gRPC на Quic/HTTP/3 (да кода готового нет и его нужно написать).
Тем не менее - самое главное чтобы клиент знал особенности сервера. А gRPC по сути описывает формат взаимодействия (обычные/потоковые методы), это только набор соглашений.
Относительно REST у gRPC есть grpc-gateway и расширения в protobuf с которыми можно реализовать по мимо gRPC ещё и REST (как и делает сам Google). Для этого даже есть генератор схемы OpenAPI из proto файлов.
Это всё к тому, что gRPC обычно рассматривают с одной стороны. По факту даже все фичи кодогенерации к gRPC на прямую не имеют отношения, это скорее часть Protocol Buffers, чем gRPC как таковой.
В любом случае для применения той или иной технологии нужно взвешивать все за и против. Тот же GraphQL тоже нужно уметь и знать как использовать и главное где использовать.
P.S.: Так если посмотреть HTTP по своей сути тоже можно отнести к RPC, только с ограниченным набором методов GET/POST/etc. и с упором на адрес ресурса.
ProstakovAlexey
09.12.2021 14:34Мне кажется технология интересная, пробовал ее для одного проекта (на коленке, буквально 1-2 дня всего), но не решились дальше применять из-за проблем:
1. не понятно как смотреть, что передается по сети. REST можно посмотреть по F12, с помощью инструментов типа tcpflow.
2. не понятно как быть с фронтом. Может ли фронт на angular или react получать/отправлять gRPC.
Как решаются такие проблемы? Какие есть инструменты?bat
09.12.2021 15:37+4На фронт лучше гонять json, который там нативен. grpc, мне кажется больше применим для межсервисного взаимодействия.
ProstakovAlexey
09.12.2021 17:07Спасибо. Значит правильно, что не связались с ним. Ресурсов делать 2 вида синхронного взаимодействия, да при этом еще и брокер имеется, точно нету.
Conung_ViC
09.12.2021 15:29+1Я правильно понял, что в качестве аргумента ЗА gRPC автор предлагает использовать новый язык описания API в отдельном файле, вместо обрамления методов аннотациями и авто-генерирования swagger документации по коду на лету?
eaa
10.12.2021 00:05Именно. Когда у вас несколько клиентов на нескольких языках, то очевидно, что сгенерировать клиентские либы удобно из одного файла protobuf.
Dmitry3A
09.12.2021 17:59+1Интересно есть плагин чтобы из браузера просматривать запросы/ответы в удобоваримом виде?
Плюс для простых случаев, для REST можно через какой-нибудь API прокси делать интересные вещи без изменений бэкенда, а с протобуфером конечно сильно сложнее по сравнению с json.
Для манипулирования данными гораздо интереснее конечно что-то типа odata/graphql.
Ну и самый большой недостаток, людей знающих как это всё заставить взлететь сильно меньше, так что если это не твой личный проект, то десять раз подумаешь прежде чем вкорячивать подобное.
KislyFan
10.12.2021 01:43+3Почему-то никто не заострил внимание, что gPRC, это не фреймворк, а технология. И автор рассматривает конкретную имплементацию этой технологии для dotnet core.
Devoter
12.12.2021 02:04REST - не фреймворк. То есть сравнивается набор правил с фреймворком.
Про сериализацию - вообще молчу, это прям какая-то магия для меня - передача данных по сети без сериализации. Если хотите сказать, что этим вопросом занимается сгенерированный protobuf код вместо разработчика, то так и надо говорить.
С каких пор JSON стал прибит гвоздями к REST? Что мне мешает использовать XML, YAML, да даже тот же protobuf или, вовсе, свой собственный формат? Про то, что JSON ещё как сжимается выше уже упомянули.
Как видим на схеме, у клиента и сервера есть дополнительный уровень в общении — клиентская и серверная заглушки.
А где схема-то?
Про балансировку и остальное уже сказали до меня.
vilgeforce
"текстовый формат JSON, который не сжимается" - ORLY? Если вы имели в виду, что сжатие не предусмотрено самим REST - так и надо было писать. Потому что тот же HTTP, поверх которого сделан REST, сжатие поддерживает.