Всем привет, я Максим Усатенко, .NET разработчик в компании TELEMART.UA и сегодня я поделюсь своим опытом создания gRPC сервиса на ASP.NET core 5.0, расскажу про трудности и нюансы, с которыми мне пришлось столкнуться во время реализации сервиса.
Для начала, давайте вкратце разберёмся, что такое gRPC и зачем он вообще нужен. Статей на эту тему уже много, и я не буду останавливаться подробно на деталях. Если вы ранее не слышали об этой технологии, то лучше сначала прочтите обзорные статьи. От себя могу порекомендовать статью Романа Махныка: «Фреймворк gRPC: как он упрощает задачи разработчика и почему лучше, чем REST API».
RPC — remote procedure call, технология вызова удаленных процедур, созданная более 30 лет назад. Основная идея состоит в том, чтоб вызывать код удаленного сервиса так, будто он расположен локально, и не думать о сетях и прочих низкоуровневых проблемах.
gRPC — фреймворк от Google, созданный в 2015 году и активно набирающий обороты в наши дни. Именно он вдохнул новую жизнь в RPC подход, который на тот момент уже считался пережитком прошлого и не имел особых перспектив. Основное преимущество gRPC — это протокол HTTP/2, который сам по себе быстрее, чем HTTP/1.1. Не стоит забывать и про бинарный обмен данными по протоколу protobuf, что также добавляет скорость взаимодействия по сети.
Также важно упомянуть двунаправленную передачу данных и стримы. Ранее для реализации этих фишек требовались Web-сокеты, в частности, в .NET есть мощный фреймворк SignalR, который с лихвой способен решить эти задачи, но сегодня речь пойдет не о нем.
Основная идея gRPC — .proto файл, в котором описан контракт взаимодействия клиента с сервером, на основании которого генерируются классы самого сервиса на C#. Синтаксис .proto файлов интуитивный и читаемый, написать его не составит проблем любому разработчику, он чем то напоминает язык C:
syntax = "proto3";
message HelloRequest {
string message = 1;
}
message HelloResponse {
string message = 1;
}
service MyRPCService {
rpc SayHello (HelloRequest) returns (HelloResponse) {}
}
В .NET существует два основных подхода в реализации gRPC:
public interface IHelloService
{
[OperationContract]
Task<HelloResponse> SayHelloAsync(HelloRequest request, CallContext context = default);
}
[DataContract]
public class HelloRequest
{
[DataMember(Order = 1)]
public string Message { get; set; }
}
[DataContract]
public class HelloResponse
{
[DataMember(Order = 1)]
public string ResponseMessage { get; set; }
}
Я попробовал оба варианта и выбрал второй, так как это более высокая абстракция. Клиент и сервер, в моем случае, написаны на С#, да и писать родные классы приятнее, чем учить синтаксис .proto файла. Фактически, мне вообще в итоге не пришлось знать .proto синтаксис.
Также стоит отметить: если у вас клиент и сервер описаны на разных языках, вам в любом случае придется использовать классический вариант с .proto файлом.
.UseKestrel(x =>
{
x.Listen(IPAddress.Loopback, 8700, listenOptions =>
{
listenOptions.Protocols = HttpProtocols.Http1AndHttp2;
listenOptions.UseHttps();
});
})
Первые грабли, на которые я наступил, — маленькое комьюнити. Если что-то не работает, очень трудно найти решение. Надеюсь, в будущем, с ростом популярности, с этим проблем не будет.
Многие гиганты, такие как Netflix, Twitter, Spotify уже приняли gRPC как стандарт своих внутренних сервисов, так как благодаря высокой пропускной способности это помогает им экономить бюджет на инфраструктуре, а на их объемах, это цифры с шестью нулями.
[Authorize]
public class GrpcService : IGrpcService
{
private readonly IMediator mediator;
public GrpcService(IMediator mediator)
{
this.mediator = mediator;
}
Task<HelloResponse> SayHelloAsync(HelloRequest request, CallContext context = default)
{
if (request is null)
{
throw new RpcException(new Status(StatusCode.InvalidArgument, "Request is null"), "Bad request");
}
HelloResponse response = await mediator.Send(request);
return response;
}
}
Написал я клиент следующим образом: фабрика создает объект самого сервиса, и через DI подключается в места, где нужен этот сервис. Можно было бы и напрямую, но из за асинхронного запроса токена решил всю эту кухню инкапсулировать следующим образом:
public class GrpcServiceFactory : IGrpcServiceFactory
{
private readonly IGrpcServiceSettings settings;
private readonly IAuthenticationManager authenticationManager;
public GrpcServiceFactory(
IGrpcServiceSettings settings,
IAuthenticationManager authenticationManager)
{
this.settings = settings;
this.authenticationManager = authenticationManager;
}
public async Task<IGrpcService> BuildAsync()
{
authenticationManager.ThrowIfNotAuthenticated();
await authenticationManager.RefreshTokensAsync();
CallCredentials credentials = CallCredentials.FromInterceptor((_, metadata) =>
{
if (!string.IsNullOrEmpty(authenticationManager.AccessToken))
{
metadata.Add("Authorization", $"Bearer {authenticationManager.AccessToken}");
metadata.Add("User-Agent", "my-agent");
}
return Task.CompletedTask;
});
GrpcChannel channel = GrpcChannel.ForAddress(
settings.BaseAddress,
new GrpcChannelOptions
{
Credentials = ChannelCredentials.Create(ChannelCredentials.SecureSsl, credentials)
});
return channel.CreateGrpcService<IGrpcService>();
}
}
Есть консольное приложение grpcurl для отправки запросов, и его обертка grpc UI, которая работает через рефлексию внутри сервиса и на основании .proto файла генерирует методы для дебага.
Консольное приложение достаточно мощное, но не совсем комфортное в использовании, да и не каждый тестировщик захочет в нем разбираться. А UI обертка, к сожалению, работает только напрямую с .proto файлом, и не умеет считывать атрибуты с библиотеки protobuf-net. Впрочем, если вы выбрали подход через написание .proto файла, то это достаточно хорошее решение.
Лично я выбрал вариант с логированием всех запросов и ответов на уровне Information. Не скажу, что этот вариант хороший, да и он фактически обязывает выключать уровень логирования information на продакшене, но зато в реализации это оказалось максимально быстро, да и тестировщик, при надобности, всегда сможет посмотреть что и куда улетело.
public class LoggerInterceptor : Interceptor
{
private const string MessageTemplate =
"{RequestMethod} request: {Request} response: {Response} status code: {StatusCode} in {Elapsed:0.0000} ms";
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(TRequest request, ServerCallContext context, UnaryServerMethod<TRequest, TResponse> continuation)
{
Stopwatch sw = Stopwatch.StartNew();
TResponse response = await base.UnaryServerHandler(request, context, continuation);
sw.Stop();
Log.Logger.Information(MessageTemplate,
context.Method,
JsonConvert.SerializeObject(request),
JsonConvert.SerializeObject(response),
context.Status.StatusCode,
sw.Elapsed.TotalMilliseconds);
return response;
}
}
Вывод: тестировать и дебажить возможно, но не удобно, трафик перехватить сложно, но с каждым днем появляется все больше интересных решений, и в скором времени с этим проблем не будет. Убедитесь в том, что ваш QA-отдел сможет должным образом протестировать сервис и готов разбираться в чем-то новом.
Повторюсь, будьте готовы к тому, что на ваши вопросы еще нет ответов, а на ошибки, с которыми вы столкнетесь, висят открытые issue на GitHub. Так может продлиться еще пару лет.
Стоит ли на него переходить?
Вопрос многогранный и спорный. Все таки фреймворк ещё сыроват и не совсем комфортен в разработке. Старый работающий REST менять однозначно не стоит, но если вы пишите новый сервис, и у вас есть возможность и желание попробовать что-то новое, то возможно gRPC — это именно то, что вам нужно. Также обратите внимание на производительность, и если ваша сеть — это узкое место во взаимодействии сервисов, то gRPC повысит пропускную способность и сможет решить вашу проблему.
Для начала, давайте вкратце разберёмся, что такое gRPC и зачем он вообще нужен. Статей на эту тему уже много, и я не буду останавливаться подробно на деталях. Если вы ранее не слышали об этой технологии, то лучше сначала прочтите обзорные статьи. От себя могу порекомендовать статью Романа Махныка: «Фреймворк gRPC: как он упрощает задачи разработчика и почему лучше, чем REST API».
RPC — remote procedure call, технология вызова удаленных процедур, созданная более 30 лет назад. Основная идея состоит в том, чтоб вызывать код удаленного сервиса так, будто он расположен локально, и не думать о сетях и прочих низкоуровневых проблемах.
gRPC — фреймворк от Google, созданный в 2015 году и активно набирающий обороты в наши дни. Именно он вдохнул новую жизнь в RPC подход, который на тот момент уже считался пережитком прошлого и не имел особых перспектив. Основное преимущество gRPC — это протокол HTTP/2, который сам по себе быстрее, чем HTTP/1.1. Не стоит забывать и про бинарный обмен данными по протоколу protobuf, что также добавляет скорость взаимодействия по сети.
Также важно упомянуть двунаправленную передачу данных и стримы. Ранее для реализации этих фишек требовались Web-сокеты, в частности, в .NET есть мощный фреймворк SignalR, который с лихвой способен решить эти задачи, но сегодня речь пойдет не о нем.
Почему я выбрал именно gRPC
Задача стояла написать микросервис и вынести в него несколько методов из старого монолита. Все остальные сервисы были REST, и намного проще было бы по готовому образцу сделать ещё один, но я всегда за то, чтоб код был написан на последних современных технологиях. Современные технологии интересно учить, приятно поддерживать и они предоставляют множество новых фич. Выбор был между GraphQL и gRPC, но в конечном счете я остановился на последнем, в виду сетевой скорости, а сервис планировался высоконагруженный.Основная идея gRPC — .proto файл, в котором описан контракт взаимодействия клиента с сервером, на основании которого генерируются классы самого сервиса на C#. Синтаксис .proto файлов интуитивный и читаемый, написать его не составит проблем любому разработчику, он чем то напоминает язык C:
syntax = "proto3";
message HelloRequest {
string message = 1;
}
message HelloResponse {
string message = 1;
}
service MyRPCService {
rpc SayHello (HelloRequest) returns (HelloResponse) {}
}
В .NET существует два основных подхода в реализации gRPC:
- Описание .proto файла и после реализация готового сгенерированного класса на C# (классический вариант). В качестве контракта выступает сам файл, который описывает взаимодействия клиента с сервером. В момент сборки проекта появляется класс сервиса с расширением .g.cs, а уже от этого сгенерированного базового класса необходимо реализовать свой собственный.
- Code-first подход и фреймворк protobuf-net. Декорируем атрибутами сервис и объекты взаимодействия (DTO), после чего .proto файл генерируется неявно с помощью рефлексии. В качестве контракта взаимодействия будет выступать интерфейс самого сервиса:
public interface IHelloService
{
[OperationContract]
Task<HelloResponse> SayHelloAsync(HelloRequest request, CallContext context = default);
}
[DataContract]
public class HelloRequest
{
[DataMember(Order = 1)]
public string Message { get; set; }
}
[DataContract]
public class HelloResponse
{
[DataMember(Order = 1)]
public string ResponseMessage { get; set; }
}
Я попробовал оба варианта и выбрал второй, так как это более высокая абстракция. Клиент и сервер, в моем случае, написаны на С#, да и писать родные классы приятнее, чем учить синтаксис .proto файла. Фактически, мне вообще в итоге не пришлось знать .proto синтаксис.
Также стоит отметить: если у вас клиент и сервер описаны на разных языках, вам в любом случае придется использовать классический вариант с .proto файлом.
Ход реализации сервиса
Порты
Первым делом принял решение сделать так, чтобы сервис поддерживал и gRPC и REST. Нужно это для случая отказа: если вдруг что то пойдёт не так, я должен иметь возможность быстро переключиться на годами отточенный REST. Изначально я поднял два порта: один обычный, а второй для gRPC с настройкой под HTTP/2. Так советовали многие и называли это best practice для gRPC, но в конце, перед релизом, пришлось отказаться от двух портов и пустить все по одному, так как я не смог настроить наш сервер IIS под использование двух портов на одном приложении. NGINX с этим справляется без проблем, но вот IIS... Возможно, у кого-то это получалось, пишите в коментариях, а я все больше смотрю в сторону переезда всей инфраструктуры на Linux..UseKestrel(x =>
{
x.Listen(IPAddress.Loopback, 8700, listenOptions =>
{
listenOptions.Protocols = HttpProtocols.Http1AndHttp2;
listenOptions.UseHttps();
});
})
Первые грабли, на которые я наступил, — маленькое комьюнити. Если что-то не работает, очень трудно найти решение. Надеюсь, в будущем, с ростом популярности, с этим проблем не будет.
Многие гиганты, такие как Netflix, Twitter, Spotify уже приняли gRPC как стандарт своих внутренних сервисов, так как благодаря высокой пропускной способности это помогает им экономить бюджет на инфраструктуре, а на их объемах, это цифры с шестью нулями.
Архитектура сервиса
Тут вообще все туманно. Я не нашёл ни одного примера как красиво и лаконично организовать код в сервисе. В итоге, всю бизнес логику вынес в хендлеры, а контроллеры и gRPC сервис вызывали эти хендлеры. Фактически, мой gRPC сервис стал контроллером со своей стороны. Это позволило не дублировать бизнес-логику и сделать что-то наподобие «Чистой архитектуры»:[Authorize]
public class GrpcService : IGrpcService
{
private readonly IMediator mediator;
public GrpcService(IMediator mediator)
{
this.mediator = mediator;
}
Task<HelloResponse> SayHelloAsync(HelloRequest request, CallContext context = default)
{
if (request is null)
{
throw new RpcException(new Status(StatusCode.InvalidArgument, "Request is null"), "Bad request");
}
HelloResponse response = await mediator.Send(request);
return response;
}
}
Безопасность
Долго останавливаться не буду, отмечу лишь то, что с ней проблем не было. Все, что поддерживает стандартный ASP.NET core в gRPC, реализовано из коробки: JWT токены, куки, заголовки, и прочие стандарты безопасности на ваш вкус.Клиент
С клиентом вышло все красиво и лаконично. С помощью интерфейса-контракта клиент сгенерировал методы запросов. Вызов методов действительно оправдал название фреймворка RPC — удаленный сервис вызывался так, будто это внутренние методы клиента из соседнего класса.Написал я клиент следующим образом: фабрика создает объект самого сервиса, и через DI подключается в места, где нужен этот сервис. Можно было бы и напрямую, но из за асинхронного запроса токена решил всю эту кухню инкапсулировать следующим образом:
public class GrpcServiceFactory : IGrpcServiceFactory
{
private readonly IGrpcServiceSettings settings;
private readonly IAuthenticationManager authenticationManager;
public GrpcServiceFactory(
IGrpcServiceSettings settings,
IAuthenticationManager authenticationManager)
{
this.settings = settings;
this.authenticationManager = authenticationManager;
}
public async Task<IGrpcService> BuildAsync()
{
authenticationManager.ThrowIfNotAuthenticated();
await authenticationManager.RefreshTokensAsync();
CallCredentials credentials = CallCredentials.FromInterceptor((_, metadata) =>
{
if (!string.IsNullOrEmpty(authenticationManager.AccessToken))
{
metadata.Add("Authorization", $"Bearer {authenticationManager.AccessToken}");
metadata.Add("User-Agent", "my-agent");
}
return Task.CompletedTask;
});
GrpcChannel channel = GrpcChannel.ForAddress(
settings.BaseAddress,
new GrpcChannelOptions
{
Credentials = ChannelCredentials.Create(ChannelCredentials.SecureSsl, credentials)
});
return channel.CreateGrpcService<IGrpcService>();
}
}
Тестирование
Тестировать gRPC — это боль , мучение и страдание. Нет нормальных решений по примеру Postman. Fiddler вообще ломает запросы, потому что он пытается превратить HTTP/2 в HTTP/1.1, следовательно, его вообще надо выключать при работе с gRPC. Обдумайте эту проблему сразу, перед тем как решитесь на gRPC.Есть консольное приложение grpcurl для отправки запросов, и его обертка grpc UI, которая работает через рефлексию внутри сервиса и на основании .proto файла генерирует методы для дебага.
Консольное приложение достаточно мощное, но не совсем комфортное в использовании, да и не каждый тестировщик захочет в нем разбираться. А UI обертка, к сожалению, работает только напрямую с .proto файлом, и не умеет считывать атрибуты с библиотеки protobuf-net. Впрочем, если вы выбрали подход через написание .proto файла, то это достаточно хорошее решение.
Лично я выбрал вариант с логированием всех запросов и ответов на уровне Information. Не скажу, что этот вариант хороший, да и он фактически обязывает выключать уровень логирования information на продакшене, но зато в реализации это оказалось максимально быстро, да и тестировщик, при надобности, всегда сможет посмотреть что и куда улетело.
public class LoggerInterceptor : Interceptor
{
private const string MessageTemplate =
"{RequestMethod} request: {Request} response: {Response} status code: {StatusCode} in {Elapsed:0.0000} ms";
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(TRequest request, ServerCallContext context, UnaryServerMethod<TRequest, TResponse> continuation)
{
Stopwatch sw = Stopwatch.StartNew();
TResponse response = await base.UnaryServerHandler(request, context, continuation);
sw.Stop();
Log.Logger.Information(MessageTemplate,
context.Method,
JsonConvert.SerializeObject(request),
JsonConvert.SerializeObject(response),
context.Status.StatusCode,
sw.Elapsed.TotalMilliseconds);
return response;
}
}
Вывод: тестировать и дебажить возможно, но не удобно, трафик перехватить сложно, но с каждым днем появляется все больше интересных решений, и в скором времени с этим проблем не будет. Убедитесь в том, что ваш QA-отдел сможет должным образом протестировать сервис и готов разбираться в чем-то новом.
Тонкости и нюансы
- При классической реализации с .proto файлом сталкиваешься сразу с проблемой разрастания самого файла, ведь несколько объектов запроса и сам метод могут занимать немало строк кода. Сразу появляется желание разделить на несколько файлов, как классы на C#. Импортирование файлов возможно (механизм похож на using), но лично у меня все время были проблемы со связями этих файлов, либо я не смог до конца вникнуть и разобраться, либо это действительно так. В любом случае надо на это обратить внимание, потому что руками прописывать связи в .csproj лично для меня выглядит как моветон.
- При code first реализации, порядок полей имеет значение, а decimal и double — это разные типы. Таких нюансов немало, и в идеале надо просто копировать класс с сервера на клиент или использовать через общую библиотеку, чтоб не сталкиваться с подобными проблемами.
- Убедитесь, что ваша инфраструктура поддерживает HTTP/2 и конкретно gRPC. После реализации задачи я уперся в то, что наш Windows Server 2016 просто не поддерживает сетевой функционал gRPC, так как его давно не обновляли, и пришлось на время отложить задачу до переезда на Linux. Рекомендую посоветоваться с DevOps. Если бы он был на моем проекте, то возможно, удалось бы избежать подобных проблем еще на этапе «фундамента» сервиса.
- На сегодняшний день браузеры не полностью поддерживают HTTP/2, следовательно и gRPC. Как известно, эта технология изначально затачивалась под внутрисервисное общение, и только с ростом ее популярности появилась потребность в использовании gRPC на веб фронте. Могу упомянуть gRPC Web — JS библиотеку для поддержки совместимости между HTTP/1.1 и HTTP/2. С помощью нее вы сможете поддерживать на клиентской стороне браузера коммуникацию с сервисом на gRPC, вот только надо ли это? Лично для меня этот функционал еще сырой и я подожду нативную поддержку HTTP/2 во всех основных браузерах.
Итог
gRPC — это новый и стремительно набирающий популярность фреймворк, который поддерживает очень много современных и интересных фишек. В будущем он будет вытеснять REST из мира серверной архитектуры, но этот процесс очень медлительный, и займёт не один десяток лет.Повторюсь, будьте готовы к тому, что на ваши вопросы еще нет ответов, а на ошибки, с которыми вы столкнетесь, висят открытые issue на GitHub. Так может продлиться еще пару лет.
Стоит ли на него переходить?
Вопрос многогранный и спорный. Все таки фреймворк ещё сыроват и не совсем комфортен в разработке. Старый работающий REST менять однозначно не стоит, но если вы пишите новый сервис, и у вас есть возможность и желание попробовать что-то новое, то возможно gRPC — это именно то, что вам нужно. Также обратите внимание на производительность, и если ваша сеть — это узкое место во взаимодействии сервисов, то gRPC повысит пропускную способность и сможет решить вашу проблему.
Реализуем .NET сервис на gRPC. Тонкости, о которых нужно знать
Максим Усатенко, .NET-розробник, розповідає про створення gRPC-сервісу на ASP.NET core 5.0, труднощі та нюанси, з якими йому довелося зіткнутися під час реалізації.
dou.ua