gRPC
gRPC 基于HTTP/2,相比 HTTP API 有更好的性能,并支持双向流式传输。
HTTP/2在单个 TCP 连接上多路复用多个 HTTP/2 调用。 多路复用可消除队头阻塞。
gRPC 支持通过流式传输进行实时通信,但不存在将消息广播到注册连接的概念。 例如,在聊天室方案中,应将新的聊天消息发送到聊天室中的所有客户端,这要求每个 gRPC 调用将新的聊天消息单独流式传输到客户端。 SignalR 是适用于此方案的框架。 SignalR 具有持久性连接的概念,并内置对广播消息的支持。
在 ASP.NET Core 堆栈中,默认情况下,创建的 gRPC 服务具有范围内的生存期。
实现 gRPC
gRPC 服务可以有不同类型的方法。 服务发送和接收消息的方式取决于所定义的方法的类型。 gRPC 方法类型如下:
- 一元
- 服务器流式处理
- 客户端流式处理
- 双向流式处理
服务器流式处理是由服务端将多个消息流式发给客户端
public override async Task StreamingFromServer(ExampleRequest request, IServerStreamWriterresponseStream, ServerCallContext context) { try { while (!context.CancellationToken.IsCancellationRequested) { await responseStream.WriteAsync(new ExampleResponse { Message = DateTime.Now.ToString() }); await Task.Delay(TimeSpan.FromSeconds(1), context.CancellationToken); } } catch (OperationCanceledException) { Console.WriteLine("cancel"); } }
var cts = new CancellationTokenSource(); using var channel = GrpcChannel.ForAddress("https://localhost:5001"); var client = new Example.ExampleClient(channel); using var call = client.StreamingFromServer(new ExampleRequest(), new CallOptions(cancellationToken: cts.Token)); Task.Delay(5000).ContinueWith(t => cts.Cancel()); try { await foreach (var response in call.ResponseStream.ReadAllAsync()) { Console.WriteLine(response.Message); } } catch (RpcException ex) { Console.WriteLine(ex.Message); }
客户端流式处理是由客户端将多个消息发给服务端
public override async TaskStreamingFromClient(IAsyncStreamReader requestStream, ServerCallContext context) { int cnt = 0; await foreach (var message in requestStream.ReadAllAsync()) { cnt++; } return new ExampleResponse { Message = cnt.ToString() }; }
var call = client.StreamingFromClient(); for (int i = 0; i < 3; i++) { await call.RequestStream.WriteAsync(new ExampleRequest()); await Task.Delay(1000); } await call.RequestStream.CompleteAsync(); var response = await call; Console.WriteLine($"Count: {response.Message}");
在双向流式处理方法中,客户端和服务可在任何时间互相发送消息
public override async Task StreamingBothWays(IAsyncStreamReaderrequestStream, IServerStreamWriter responseStream, ServerCallContext context) { await foreach (var message in requestStream.ReadAllAsync()) { await responseStream.WriteAsync(new ExampleResponse { Message = message.PageIndex.ToString() }); } }
当服务器已读取请求流且客户端已读取响应流时,双向调用正常完成。
var call = client.StreamingBothWays(); for (int i = 0; i < 5; i++) { Console.WriteLine("WriteAsync " + i); await call.RequestStream.WriteAsync(new ExampleRequest { PageIndex = i }); await Task.Delay(1000); } await call.RequestStream.CompleteAsync(); await Task.Run(async () => { await foreach (var response in call.ResponseStream.ReadAllAsync()) { Console.WriteLine("ReadAllAsync " + response.Message); } });
配置 TLS
gRPC 客户端传输层安全性 (TLS) 是在创建 gRPC 通道时配置的。 如果在调用服务时通道和服务的连接级别安全性不一致,gRPC 客户端就会抛出错误。
若要将 gRPC 通道配置为使用 TLS,请确保服务器地址以 https
开头。 例如,GrpcChannel.ForAddress("https://localhost:5001")
使用 HTTPS 协议。
在 .NET Core 3.1 或更高版本中,必须进行其他配置,才能使用 .NET 客户端调用不安全的 gRPC 服务。
在生产环境中,必须显式配置 TLS。 以下 appsettings.json 示例中提供了使用 TLS 进行保护的 HTTP/2 终结点:
{ "Kestrel": { "Endpoints": { "HttpsInlineCertFile": { "Url": "https://localhost:5001", "Protocols": "Http2", "Certificate": { "Path": "", "Password": " " } } } } }
或者,可以在 Program.cs 中配置 Kestrel 终结点:
public static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) .ConfigureWebHostDefaults(webBuilder => { webBuilder.ConfigureKestrel(options => { options.Listen(IPAddress.Any, 5001, listenOptions => { listenOptions.Protocols = HttpProtocols.Http2; listenOptions.UseHttps("", " "); }); }); webBuilder.UseStartup (); });
TLS 的用途不仅限于保护通信。 当终结点支持多个协议时,TLS 应用程序层协议协商 (ALPN) 握手可用于协商客户端与服务器之间的连接协议。 此协商确定连接是使用 HTTP/1.1 还是 HTTP/2。
客户端性能
通道及客户端性能和使用情况:
- 创建通道成本高昂。 重用 gRPC 调用的通道可提高性能。
- gRPC 客户端是使用通道创建的。 gRPC 客户端是轻型对象,无需缓存或重用。
- 可从一个通道创建多个 gRPC 客户端(包括不同类型的客户端)。
- 通道和从该通道创建的客户端可由多个线程安全使用。
- 从通道创建的客户端可同时进行多个调用。
GrpcChannel.ForAddress
不是创建 gRPC 客户端的唯一选项。 如果要从 ASP.NET Core 应用调用 gRPC 服务,请考虑 gRPC 客户端工厂集成。 gRPC 与 HttpClientFactory
集成是创建 gRPC 客户端的集中式操作备选方案。
HTTP/2 连接通常会限制一个连接上同时存在的最大并发流(活动 HTTP 请求)数。 默认情况下,大多数服务器将此限制设置为 100 个并发流。
gRPC 通道使用单个 HTTP/2 连接,并且并发调用在该连接上多路复用。 当活动调用数达到连接流限制时,其他调用会在客户端中排队。 排队调用等待活动调用完成后再发送。 由于此限制,具有高负载或长时间运行的流式处理 gRPC 调用的应用程序可能会因调用排队而出现性能问题。
.NET Core 3.1 应用有几种解决方法:
- 为具有高负载的应用的区域创建单独的 gRPC 通道。 例如,
Logger
gRPC 服务可能具有高负载。 使用单独的通道在应用中创建LoggerClient
。 - 使用 gRPC 通道池,例如创建 gRPC 通道列表。 每次需要 gRPC 通道时,使用
Random
从列表中选取一个通道。 使用Random
在多个连接上随机分配调用。
提升服务器上的最大并发流限制是解决此问题的另一种方法。 在 Kestrel 中,这是用 MaxStreamsPerConnection 配置的。
不建议提升最大并发流限制。 单个 HTTP/2 连接上的流过多会带来新的性能问题:
- 尝试写入连接的流之间发生线程争用。
- 连接数据包丢失导致在 TCP 层阻止所有调用。
由于 L4 负载均衡器是在连接级别运行的,它们不太适用于 gRPC。
有两种方法可以高效地对 gRPC 进行负载均衡:
- 客户端负载均衡
- L7(应用程序)代理负载均衡
客户端负载均衡的缺点是每个客户端必须跟踪它应该使用的可用终结点。
Lookaside 客户端负载均衡是一种将负载均衡状态存储在中心位置的技术。 客户端定期查询中心位置以获取在作出负载均衡决策时要使用的信息。
Grpc.Net.Client
当前不支持客户端负载均衡。 如果 .NET 中需要客户端负载均衡,则 Grpc.Core 是一个不错的选择。
L7(应用程序)代理的工作级别高于 L4(传输)代理。 L7 代理了解 HTTP/2,并且能够在多个终结点之间的一个 HTTP/2 连接上将多路复用的 gRPC 调用分发给代理。 使用代理比客户端负载均衡更简单,但会增加 gRPC 调用的额外延迟。
有很多 L7 代理可用。 一些选项包括:
- Envoy - 一种常用的开源代理。
- Linkerd - Kubernetes 服务网格。
- YARP:反向代理 - 用 .NET 编写的预览开源代理。
在高性能方案中,可使用 gRPC 双向流式处理取代一元 gRPC 调用。 双向流启动后,来回流式处理消息比使用多个一元 gRPC 调用发送消息更快。 流式处理消息作为现有 HTTP/2 请求上的数据发送,节省了为每个一元调用创建新的 HTTP/2 请求的开销。
使用流式处理的复杂性和限制:
- 流可能会因服务或连接错误而中断。 需要在出现错误时重启流的逻辑。
- 对于多线程处理,
RequestStream.WriteAsync
并不安全。 一次只能将一条消息写入流中。 通过单个流从多个线程发送消息需要制造者/使用者队列(如 Channel)来整理消息。 - gRPC 流式处理方法仅限于接收一种类型的消息并发送一种类型的消息。 例如,
rpc StreamingCall(stream RequestMessage) returns (stream ResponseMessage)
接收RequestMessage
并发送ResponseMessage
。 Protobuf 对使用Any
和oneof
支持未知消息或条件消息,可以解决此限制。
访问 gRPC 尾部
gRPC 调用可能会返回 gRPC 尾部。 gRPC 尾部用于提供有关调用的名称/值元数据。 尾部提供与 HTTP 头相似的功能,但在调用结尾获得。
gRPC 尾部可通过 GetTrailers()
进行访问,它会返回元数据的集合。 尾部是在响应完成后返回的,因此你必须等待收到所有响应消息,然后才能访问尾部。
一元和客户端流式调用必须等待出现 ResponseAsync
后才能调用 GetTrailers()
:
var client = new Greet.GreeterClient(channel); using var call = client.SayHelloAsync(new HelloRequest { Name = "World" }); var response = await call.ResponseAsync; Console.WriteLine("Greeting: " + response.Message); // Greeting: Hello World var trailers = call.GetTrailers(); var myValue = trailers.GetValue("my-trailer-name");
服务器和双向流式调用必须等到出现响应流,然后才能调用 GetTrailers()
:
var client = new Greet.GreeterClient(channel); using var call = client.SayHellos(new HelloRequest { Name = "World" }); await foreach (var response in call.ResponseStream.ReadAllAsync()) { Console.WriteLine("Greeting: " + response.Message); // "Greeting: Hello World" is written multiple times } var trailers = call.GetTrailers(); var myValue = trailers.GetValue("my-trailer-name");
gRPC 尾部也可通过 RpcException
进行访问。 服务可能会同时返回尾部和“异常”gRPC 状态。 在这种情况下,尾部是从 gRPC 客户端引起的异常中检索得到的:
var client = new Greet.GreeterClient(channel); string myValue = null; try { using var call = client.SayHelloAsync(new HelloRequest { Name = "World" }); var response = await call.ResponseAsync; Console.WriteLine("Greeting: " + response.Message); // Greeting: Hello World var trailers = call.GetTrailers(); myValue = trailers.GetValue("my-trailer-name"); } catch (RpcException ex) { var trailers = ex.Trailers; myValue = trailers.GetValue("my-trailer-name"); }
访问 gRPC 请求标头
请求消息并不是客户端将数据发送到 gRPC 服务的唯一方法。 标头值在使用 ServerCallContext.RequestHeaders
的服务中可用。
public override TaskUnaryCall(ExampleRequest request, ServerCallContext context) { var userAgent = context.RequestHeaders.GetValue("user-agent"); // ... return Task.FromResult(new ExampleResponse()); }
解析 gRPC 方法中的 HttpContext
gRPC API 提供对某些 HTTP/2 消息数据(如方法、主机、标头和尾部)的访问权限。 访问是通过传递到每个 gRPC 方法的 ServerCallContext
参数进行的:
ServerCallContext
不提供对所有 ASP.NET API 中 HttpContext
的完全访问权限。 GetHttpContext
扩展方法提供对在 ASP.NET API 中表示基础 HTTP/2 消息的 HttpContext
的完全访问权限:
public class GreeterService : Greeter.GreeterBase { public override TaskSayHello( HelloRequest request, ServerCallContext context) { var httpContext = context.GetHttpContext(); var clientCertificate = httpContext.Connection.ClientCertificate; return Task.FromResult(new HelloReply { Message = "Hello " + request.Name + " from " + clientCertificate.Issuer }); } }
配置截止时间
建议配置 gRPC 调用截止时间,因为它提供调用时间的上限。 它能阻止异常运行的服务持续运行并耗尽服务器资源。 截止时间对于构建可靠应用非常有效。
配置 CallOptions.Deadline
以设置 gRPC 调用的截止时间:
var client = new Greet.GreeterClient(channel); try { var response = await client.SayHelloAsync( new HelloRequest { Name = "World" }, deadline: DateTime.UtcNow.AddSeconds(5)); // Greeting: Hello World Console.WriteLine("Greeting: " + response.Message); } catch (RpcException ex) when (ex.StatusCode == StatusCode.DeadlineExceeded) { Console.WriteLine("Greeting timeout."); }
public override async TaskSayHello(HelloRequest request, ServerCallContext context) { var user = await _databaseContext.GetUserAsync(request.Name, context.CancellationToken); return new HelloReply { Message = "Hello " + user.DisplayName }; }
截止时间随 gRPC 调用发送到服务,并由客户端和服务独立跟踪。 gRPC 调用可能在一台计算机上完成,但当响应返回给客户端时,已超过了截止时间。
如果超过了截止时间,客户端和服务将有不同的行为:
- 客户端将立即中止基础的 HTTP 请求并引发
DeadlineExceeded
错误。 客户端应用可以选择捕获错误并向用户显示超时消息。 - 在服务器上,将中止正在执行的 HTTP 请求,并引发 ServerCallContext.CancellationToken。 尽管中止了 HTTP 请求,gRPC 调用仍将继续在服务器上运行,直到方法完成。 将取消令牌传递给异步方法,使其随调用一同被取消,这非常重要。 例如,向异步数据库查询和 HTTP 请求传递取消令牌。 传递取消令牌让取消的调用可以在服务器上快速完成,并为其他调用释放资源。
传播截止时间
从正在执行的 gRPC 服务进行 gRPC 调用时,应传播截止时间。
手动传播截止时间可能会很繁琐。 截止时间需要传递给每个调用,很容易不小心错过。 gRPC 客户端工厂提供自动解决方案。 指定 EnableCallContextPropagation
:
- 自动将截止时间和取消令牌传播到子调用。
- 这是确保复杂的嵌套 gRPC 场景始终传播截止时间和取消的一种极佳方式。
services .AddGrpcClient(o => { o.Address = new Uri("https://localhost:5001"); }) .EnableCallContextPropagation();
配置服务端选项
gRPC 服务在 Startup.cs 中使用 AddGrpc
进行配置。
选项 | 默认值 | 描述 |
---|---|---|
MaxSendMessageSize | null |
设置为 null 时,消息的大小不受限制。 |
MaxReceiveMessageSize | 4 MB | 增大此值可使服务器接收更大的消息,但可能会对内存消耗产生负面影响。 设置为 null 时,消息的大小不受限制。 |
EnableDetailedErrors | false |
如果为 true ,则当服务方法中引发异常时,会将详细异常消息返回到客户端。 默认值为 false 。 将 EnableDetailedErrors 设置为 true 可能会泄漏敏感信息。 |
CompressionProviders | gzip | 默认已配置提供程序支持 gzip 压缩。 |
ResponseCompressionAlgorithm | null |
压缩算法用于压缩从服务器发送的消息。 该算法必须与 CompressionProviders 中的压缩提供程序匹配。 若要使算法可压缩响应,客户端必须通过在 grpc-accept-encoding 标头中进行发送来指示它支持算法。 |
ResponseCompressionLevel | null |
用于压缩从服务器发送的消息的压缩级别。 |
拦截器 | None | 随每个 gRPC 调用一起运行的侦听器的集合。 侦听器按注册顺序运行。 全局配置的侦听器在为单个服务配置的侦听器之前运行。 有关 gRPC 侦听器的详细信息,请参阅 gRPC 侦听器与中间件。 |
IgnoreUnknownServices | false |
如果为 true ,则对未知服务和方法的调用不会返回 UNIMPLEMENTED 状态,并且请求会传递到 ASP.NET Core 中的下一个注册中间件。 |
用于单个服务的选项会替代 AddGrpc
中提供的全局选项,可以使用 AddServiceOptions
进行配置:
public void ConfigureServices(IServiceCollection services) { services.AddGrpc().AddServiceOptions(options => { options.MaxReceiveMessageSize = 2 * 1024 * 1024; // 2 MB options.MaxSendMessageSize = 5 * 1024 * 1024; // 5 MB }); }
配置客户端选项
gRPC 客户端配置在 GrpcChannelOptions
中进行设置。 下表描述了用于配置 gRPC 通道的选项:
选项 | 默认值 | 描述 |
---|---|---|
HttpHandler | 新实例 | 用于进行 gRPC 调用的 HttpMessageHandler 。 可以将客户端设置为配置自定义 HttpClientHandler ,或将附加处理程序添加到 gRPC 调用的 HTTP 管道。 如果未指定 HttpMessageHandler ,则会通过自动处置为通道创建新 HttpClientHandler 实例。 |
HttpClient | null |
用于进行 gRPC 调用的 HttpClient 。 此设置是 HttpHandler 的替代项。 |
DisposeHttpClient | false |
如果设置为 true 且指定了 HttpMessageHandler 或 HttpClient ,则在处置 GrpcChannel 时,将分别处置 HttpHandler 或 HttpClient 。 |
LoggerFactory | null |
客户端用于记录有关 gRPC 调用的信息的 LoggerFactory 。 可以通过依赖项注入来解析或使用 LoggerFactory.Create 来创建 LoggerFactory 实例。 有关配置日志记录的示例,请参阅 .NET 上 gRPC 中的日志记录和诊断。 |
MaxSendMessageSize | null |
设置为 null 时,消息的大小不受限制。 |
MaxReceiveMessageSize | 4 MB | 增大此值可使客户端接收更大的消息,但可能会对内存消耗产生负面影响。 设置为 null 时,消息的大小不受限制。 |
凭据 | null |
一个 ChannelCredentials 实例。 凭据用于将身份验证元数据添加到 gRPC 调用。 |
CompressionProviders | gzip | 默认已配置提供程序支持 gzip 压缩。 |
static async Task Main(string[] args) { var channel = GrpcChannel.ForAddress("https://localhost:5001", new GrpcChannelOptions { MaxReceiveMessageSize = 5 * 1024 * 1024, // 5 MB MaxSendMessageSize = 2 * 1024 * 1024 // 2 MB }); }
对调用 gRPC 服务的用户进行身份验证
gRPC 可与 ASP.NET Core 身份验证配合使用,将用户与每个调用关联。
设置身份验证后,可通过 ServerCallContext
使用 gRPC 服务方法访问用户。
public override TaskBuyTickets( BuyTicketsRequest request, ServerCallContext context) { var user = context.GetHttpContext().User; // ... access data from ClaimsPrincipal ... }
持有者令牌身份验证
客户端可提供用于身份验证的访问令牌。 服务器验证令牌并使用它来标识用户。
在服务器上,使用 JWT 持有者中间件配置持有者令牌身份验证。
在通道上配置 ChannelCredentials
是通过 gRPC 调用将令牌发送到服务的备用方法。 凭据在每次进行 gRPC 调用时运行,因而无需在多个位置编写代码用于自行传递令牌。
private static GrpcChannel CreateAuthenticatedChannel(string address) { var credentials = CallCredentials.FromInterceptor((context, metadata) => { if (!string.IsNullOrEmpty(_token)) { metadata.Add("Authorization", $"Bearer {_token}"); } return Task.CompletedTask; }); // SslCredentials is used here because this channel is using TLS. // CallCredentials can't be used with ChannelCredentials.Insecure on non-TLS channels. var channel = GrpcChannel.ForAddress(address, new GrpcChannelOptions { Credentials = ChannelCredentials.Create(new SslCredentials(), credentials) }); return channel; }
令牌可作为标头与调用一起发送:
public bool DoAuthenticatedCall( Ticketer.TicketerClient client, string token) { var headers = new Metadata(); headers.Add("Authorization", $"Bearer {token}"); var request = new BuyTicketsRequest { Count = 1 }; var response = await client.BuyTicketsAsync(request, headers); return response.Success; }
客户端证书身份验证
客户端还可以提供用于身份验证的客户端证书。 证书身份验证在 TLS 级别发生,远在到达 ASP.NET Core 之前。 当请求进入 ASP.NET Core 时,可借助客户端证书身份验证包将证书解析为 ClaimsPrincipal
。
public Ticketer.TicketerClient CreateClientWithCert( string baseAddress, X509Certificate2 certificate) { // Add client cert to the handler var handler = new HttpClientHandler(); handler.ClientCertificates.Add(certificate); // Create the gRPC channel var channel = GrpcChannel.ForAddress(baseAddress, new GrpcChannelOptions { HttpHandler = handler }); return new Ticketer.TicketerClient(channel); }
将 gRPC 客户端配置为使用身份验证取决于使用的身份验证机制。 之前的持有者令牌和客户端证书示例演示可将 gRPC 客户端配置为通过 gRPC 调用发送身份验证元数据的几种方法:
- 强类型 gRPC 客户端在内部使用
HttpClient
。 可在 HttpClientHandler 上配置身份验证,也可通过向HttpClient
添加自定义 HttpMessageHandler 实例进行配置。 - 每个 gRPC 调用都有一个可选的
CallOptions
参数。 可使用该选项的标头集合发送自定义标头。
建议由客户端证书保护的 gRPC 服务使用 Microsoft.AspNetCore.Authentication.Certificate 包。 ASP.NET Core 认证身份验证将对客户端证书执行其他验证,包括:
- 验证证书是否具有有效的增强型密钥使用 (EKU)
- 验证是否在其有效期内
- 检查证书吊销
授权用户访问服务和服务方法
默认情况下,未经身份验证的用户可以调用服务中的所有方法。 若要要求进行身份验证,请将 [Authorize]
特性应用于服务:
[Authorize] public class TicketerService : Ticketer.TicketerBase { }
服务端日志记录
gRPC 在 Grpc
类别下添加日志。 若要启用来自 gRPC 的详细日志,请通过在 Logging
中的 LogLevel
子节中添加以下项目,将 Grpc
前缀配置为 appsettings.json 文件中的 Debug
级别:
{ "Logging": { "LogLevel": { "Default": "Debug", "System": "Information", "Microsoft": "Information", "Grpc": "Debug" } } }
客户端日志记录
可以在创建客户端通道时设置 GrpcChannelOptions.LoggerFactory
属性。
var channel = GrpcChannel.ForAddress("https://localhost:5001", new GrpcChannelOptions { LoggerFactory = _loggerFactory });
启用客户端日志记录的另一种方法是使用 gRPC 客户端工厂创建客户端。 已向客户端工厂注册且解析自 DI 的 gRPC 客户端将自动使用应用的已配置日志记录。
如果应用未使用 DI,则可以使用 LoggerFactory.Create 创建新的 ILoggerFactory
实例。 若要访问此方法,请将 Microsoft.Extensions.Logging 包添加到应用。
var loggerFactory = LoggerFactory.Create(logging => { logging.AddConsole(); logging.SetMinimumLevel(LogLevel.Debug); }); var channel = GrpcChannel.ForAddress("https://localhost:5001", new GrpcChannelOptions { LoggerFactory = loggerFactory }); var client = Greeter.GreeterClient(channel);