第四章 RPC 调用

发布时间 2023-12-18 13:04:16作者: 誉尚学教育

通过以上案例我们发现,Http请求调用服务实例属实过于麻烦。其实对于请求同一个服务,很多步骤都是相同的,例如:服务名,地址,httpClient 创建步骤等。

RPC的出现,就是为了解决这一问题。

RPC: 即我们常说的远程过程调用,就是像调用本地方法一样调用远程方法,通信协议大多采用二进制方式。

常用的RPC框架有(标粗的是准备讲解的):

  • gRPC

    gRPC是一个现代的开源高性能远程过程调用(RPC)框架,可以在任何环境中运行。它可以有效地连接数据中心内和跨数据中心的服务,支持负载均衡、跟踪、健康检查和身份验证。它也适用于分布式计算,将设备、移动应用程序和浏览器连接到后端服务---这是官方给的说明

  • openFeign

    简化HttpClient调用过程,让net core开发变得更简单。

  • Thrift

    Thrift是一种接口描述语言和二进制通讯协议,它被用来定义和创建跨语言的服务。它被当作一个远程过程调用(RPC)框架来使用,是由Facebook为“大规模跨语言服务开发”而开发的。它通过一个代码生成引擎联合了一个软件栈,来创建不同程度的、无缝的跨平台高效服务,可以使用C#、C++、Cappuccino、Cocoa、Delphi、Erlang、Go、Haskell、Java、Node.js、OCaml、Perl、PHP、Python、Ruby和Smalltalk。

  • Shuttler.net

    Shuttler.Net是一个高性能分布式框架,如果你在使用老去的remoting,webservices分布式架构,或在使用新生的wcf,那么你也可以尝试下Shuttler.Net。 如果你想开发自己的IM服务端和客户端(或游戏大厅),你也可以使用Shuttler.Net,只需你制定报文协议即可,其他传输层Shuttler帮你搞定。Shuttler.Net核心组件Artery正如她的名字一样:脉,基于Tcp的应用栈,可以帮你传输任何能量,使你想唱就唱。

    主要功能点包括:

    1. 分布式RPC,目前支持Tcp和Http(类REST风格)双通道(见Demo:TcpRpcTest和HttpRpcTest): 可以多个RpcServer端和多个RpcClient端,其中client通过HashingAlgorithm根据Key计算出server。

    2. 分布式缓存系统(Memcached),包括MemcachedServer和MemcachedClient(见Demo:MemcachedTest): 可以多个MemcachedServer端和多个MemcachedClient端,其中client通过HashingAlgorithm根据Key计算出server。

    3. IM协议栈,使用Shuttler.Net的Artery组件可以轻松实现一个IMServer端和IMClient端(见Demo:IMTest): IMTest中实现IM的登录密码校验,通讯协议自己定义即可,协议Demo见Shuttler_Artery_Protocol。

 

1. gRPC 详细教程

官网:https://grpc.io

gRPC 是一种与语言无关的高性能远程过程调用 (RPC) 框架。

gRPC 的主要优点是:

  • 现代高性能轻量级 RPC 框架。

  • 协定优先 API 开发,默认使用协议缓冲区,允许与语言无关的实现。

  • 可用于多种语言的工具,以生成强类型服务器和客户端。

  • 支持客户端、服务器和双向流式处理调用。

  • 使用 Protobuf 二进制序列化减少对网络的使用。

这些优点使 gRPC 适用于:

  • 效率至关重要的轻量级微服务。

  • 需要多种语言用于开发的 Polyglot 系统。

  • 需要处理流式处理请求或响应的点对点实时服务。

警告

若要将 ASP.NET Core gRPC用于 Azure 应用服务或 IIS,则该 gRPC 具有额外的要求。 有关可以在何处使用 gRPC 的详细信息,请参阅 .NET 支持的平台上的 gRPC

 

1. 快速入门

本教程演示了如何创建 .NET Core gRPC客户端和 ASP.NET Core gRPC 服务器。 最后会生成与 gRPC Greeter 服务进行通信的 gRPC 客户端。

在本教程中,你将了解:

  • 创建 gRPC 服务器。

  • 创建 gRPC 客户端。

  • 使用 gRPC Greeter 服务测试 gRPC 客户端。

 

创建gRPC服务

  • 启动 Visual Studio 2022 并选择“创建新项目”。

  • 在“创建新项目”对话框中,搜索 gRPC。 选择“ASP.NET Core gRPC 服务”,并选择“下一步” 。

  • 在“配置新项目”对话框中,为“项目名称”输入 GrpcGreeter。 将项目命名为“GrpcGreeter”非常重要,这样在复制和粘贴代码时命名空间就会匹配。

  • 选择“下一页”。

  • 在“其他信息”对话框中,选择“.NET 6.0 (长期支持)”,然后选择“创建。

 

项目文件

GrpcGreeter 项目文件:

  • Protos/greet.proto

    .proto 文件中定义服务和消息

  • Services 文件夹:包含 Greeter 服务的实现。

  • appSettings.json:包含配置数据,如 Kestrel 使用的协议。

  • Program.cs,其中包含:
    • gRPC 服务的入口点。

    • 配置应用行为的代码。

 

创建gRPC 控制台客户端

  • 打开 Visual Studio 的第二个实例并选择“创建新项目”。

  • 在“创建新项目”对话框中,选择“控制台应用程序”,然后选择“下一步” 。

  • 在“项目名称”文本框中,输入“GrpcGreeterClient”,然后选择“下一步” 。

  • 在“其他信息”对话框中,选择“.NET 6.0 (长期支持)”,然后选择“创建”。

需要安装的包

Grpc.Net.Client 2.50.0
Google.Protobuf 3.22.0
Grpc.Tools 2.50.0

 

添加 proto文件

  • 在 gRPC 客户端项目中创建 Protos 文件夹。

  • 从 gRPC Greeter 服务将 Protos\greet.proto 文件复制到 gRPC 客户端项目中的 Protos 文件夹 。

  • greet.proto 文件中的命名空间更新为项目的命名空间:

    option csharp_namespace = "GrpcGreeterClient";

     

  • 编辑 GrpcGreeterClient.csproj 项目文件:

  • 添加具有引用 greet.proto 文件的 <Protobuf> 元素的项组:

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

     

 

创建 Greeter 客户端

  • 构建客户端项目,用于在 GrpcGreeterClient 命名空间中创建类型。

GrpcGreeterClient 类型是由生成进程自动生成的。 工具包 Grpc.Tools 基于 greet.proto 文件生成以下文件:

  • GrpcGreeterClient\obj\Debug\[TARGET_FRAMEWORK]\Protos\Greet.cs:用于填充、序列化和检索请求和响应消息类型的协议缓冲区代码。

  • GrpcGreeterClient\obj\Debug\[TARGET_FRAMEWORK]\Protos\GreetGrpc.cs:包含生成的客户端类。

  • 使用以下代码更新 gRPC 客户端 Program.cs 文件。

    using System.Threading.Tasks;
    using Grpc.Net.Client;
    using GrpcGreeterClient;
    
    // 服务端的地址
    using var channel = GrpcChannel.ForAddress("https://localhost:7042");
    var client = new Greeter.GreeterClient(channel);
    var reply = await client.SayHelloAsync(
                      new HelloRequest { Name = "GreeterClient" });
    Console.WriteLine("Greeting: " + reply.Message);
    Console.WriteLine("Press any key to exit...");
    Console.ReadKey();

     

    本文中的代码需要 ASP.NET Core HTTPS 开发证书来保护 gRPC 服务。 如果 .NET gRPC 客户端失败并显示消息 The remote certificate is invalid according to the validation procedure.The SSL connection could not be established.,则开发证书不受信任。 要解决此问题,请参阅使用不受信任/无效的证书调用 gRPC 服务

 

2. proto 文件

gRPC 使用协定优先方法进行 API 开发。 默认情况下,协议缓冲区 (protobuf) 用作接口定义语言 (IDL)。 .proto 文件包含:

  • gRPC 服务的定义。

  • 在客户端与服务器之间发送的消息。

 

 

.proto 添加到 C# 应用

通过将 .proto 文件添加到 <Protobuf> 项组中,可将该文件包含在项目中:

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

 

默认情况下,<Protobuf> 引用将生成具体的客户端和服务基类。 可使用引用元素的 GrpcServices 特性来限制 C# 资产生成。 有效 GrpcServices 选项如下:

Proto GrpcServices 选项

  • Both(如果不存在,则为默认值)

  • Server

  • Client

  • None

 

.proto 工具支持

需要工具包 Grpc.Tools 才能从 .proto 文件生成 C# 资产。 生成的资产(文件):

  • 在每次生成项目时按需生成。

  • 不会添加到项目中或是签入到源代码管理中。

  • 是包含在 obj 目录中的生成工件。

服务器和客户端项目都需要此包。 Grpc.AspNetCore 元包中包含对 Grpc.Tools 的引用。 服务器项目可以使用 Visual Studio 中的包管理器或通过将 <PackageReference> 添加到项目文件来添加 Grpc.AspNetCore

<PackageReference Include="Grpc.AspNetCore" Version="2.40.0" />

 

客户端项目应直接引用 Grpc.Tools 以及使用 gRPC 客户端所需的其他包。 运行时不需要工具包,因此依赖项标记为 PrivateAssets="All"

<PackageReference Include="Google.Protobuf" Version="3.18.0" />
<PackageReference Include="Grpc.Net.Client" Version="2.40.0" />
<PackageReference Include="Grpc.Tools" Version="2.40.0">
  <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
  <PrivateAssets>all</PrivateAssets>
</PackageReference>

 

生成的 C# 代码

工具包会生成表示在所包含 .proto 文件中定义的消息的 C# 类型。

对于服务器端资产,会生成抽象服务基类型。 基类型包含 .proto 文件中所含的所有 gRPC 调用的定义。 创建一个派生自此基类型并为 gRPC 调用实现逻辑的具体服务实现。 对于 greet.proto(前面所述的示例),会生成一个包含虚拟 SayHello 方法的抽象 GreeterBase 类型。 具体实现 GreeterService 会替代该方法,并实现处理 gRPC 调用的逻辑。

public class GreeterService : Greeter.GreeterBase
{
    private readonly ILogger<GreeterService> _logger;
    public GreeterService(ILogger<GreeterService> logger)
    {
        _logger = logger;
    }

    public override Task<HelloReply> SayHello(HelloRequest request, ServerCallContext context)
    {
        return Task.FromResult(new HelloReply
        {
            Message = "Hello " + request.Name
        });
    }
}

 

对于客户端资产,会生成一个具体客户端类型。 .proto 文件中的 gRPC 调用会转换为具体类型中的方法,可以进行调用。 对于 greet.proto(前面所述的示例),会生成一个 GreeterClient 类型。 调用 GreeterClient.SayHelloAsync 以发起对服务器的 gRPC 调用。

// 端口号必须与gRPC 服务的端口一致
using var channel = GrpcChannel.ForAddress("https://localhost:7042");
var client = new Greeter.GreeterClient(channel);
var reply = await client.SayHelloAsync(
                  new HelloRequest { Name = "GreeterClient" });
Console.WriteLine("Greeting: " + reply.Message);
Console.WriteLine("Press any key to exit...");
Console.ReadKey();

 

默认情况下,会为 <Protobuf> 项组中包含的每个 .proto 文件都生成服务器和客户端资产。 若要确保服务器项目中仅生成服务器资产,请将 GrpcServices 属性设置为 Server

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

 

同样,该属性在客户端项目中设置为 Client

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

 

 

3. ProtoBuf Message

消息是 Protobuf 中的主要数据传输对象, 它们在概念上类似于 .NET 类.

syntax = "proto3";

option csharp_namespace = "Contoso.Messages";

message Person {
    int32 id = 1;
    string first_name = 2 // FirstName
    string last_name = 3;
}

 

前面的消息定义将三个字段指定为名称/值对。 与 .NET 类型上的属性类似,每个字段都有名称和类型。 字段类型可以是 Protobuf 标量值类型(如 int32),也可以是其他消息。

Protobuf 样式命名风格 建议使用 underscore_separated_names 作为字段名称。 为 .NET 应用创建的新 Protobuf 消息应遵循 Protobuf 样式准则。 .NET 工具会自动生成使用 .NET 命名标准的 .NET 类型。 例如,first_name Protobuf 字段生成 FirstName .NET 属性。

包括名称,消息定义中的每个字段都有一个唯一的编号。 消息序列化为 Protobuf 时,字段编号用于标识字段。 序列化一个小编号比序列化整个字段名称要快。 因为字段编号标识字段,所以在更改编号时务必小心。

生成应用时,Protobuf 工具将从 .proto 文件生成 .NET 类型。 Person 消息生成 .NET 类:

public class Person
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

 

标量值类型

Protobuf 支持一系列本机标量值类型。 下表列出了全部本机标量值类型及其等效 C# 类型:

Protobuf 类型C# 类型
double double
float float
int32 int
int64 long
uint32 uint
uint64 ulong
sint32 int
sint64 long
fixed32 uint
fixed64 ulong
sfixed32 int
sfixed64 long
bool bool
string string
bytes ByteString

标量值始终具有默认值,并且该默认值不能设置为 null。 此约束包括 stringByteString,它们都属于 C# 类。 string 默认为空字符串值,ByteString 默认为空字节值。 尝试将它们设置为 null 会引发错误。

日期和时间

本机标量类型不提供与 .NET 的 DateTimeOffsetDateTimeTimeSpan 等效的日期和时间值。 可使用 Protobuf 的一些“已知类型”扩展来指定这些类型。 这些扩展为受支持平台中的复杂字段类型提供代码生成和运行时支持。

下表显示日期和时间类型:

.NET 类型Protobuf 已知类型
DateTimeOffset google.protobuf.Timestamp
DateTime google.protobuf.Timestamp
TimeSpan google.protobuf.Duration
syntax = "proto3";

import "google/protobuf/duration.proto";  
import "google/protobuf/timestamp.proto";

message Meeting {
    string subject = 1;
    google.protobuf.Timestamp start = 2;
    google.protobuf.Duration duration = 3;
}  

 

C# 类中生成的属性不是 .NET 日期和时间类型。 属性使用 Google.Protobuf.WellKnownTypes 命名空间中的 TimestampDuration 类。 这些类提供在 DateTimeOffsetDateTimeTimeSpan 之间进行转换的方法。


Meeting meeting = new Meeting()
{
    Start = DateTime.Now.ToTimestamp(),
    Duration = Duration.FromTimeSpan(TimeSpan.FromDays(1))
};

DateTime startTime = meeting.Start.ToDateTime();
DateTimeOffset time = meeting.Start.ToDateTimeOffset();
TimeSpan? duration = meeting.Duration?.ToTimeSpan();

 

备注

Timestamp 类型适用于 UTC 时间。 DateTimeOffset 值的偏移量始终为零,并且 DateTime.Kind 属性始终为 DateTimeKind.Utc

可为 null 的类型

C# 的 Protobuf 代码生成使用本机类型,如 int 表示 int32。 因此这些值始终包括在内,不能为 null

对于需要显式 null 的值(例如在 C# 代码中使用 int?),Protobuf 的“已知类型”包括编译为可以为 null 的 C# 类型的包装器。 若要使用它们,请将 wrappers.proto 导入到 .proto 文件中,如以下代码所示:

syntax = "proto3";

import "google/protobuf/wrappers.proto";

message Person {
    // ...
    google.protobuf.Int32Value age = 5;
}

 

wrappers.proto 类型不会在生成的属性中公开。 Protobuf 会自动将它们映射到 C# 消息中相应的可为 null 的 .NET 类型。 例如,google.protobuf.Int32Value 字段生成 int? 属性。 引用类型属性(如 stringByteString )保持不变,但可以向它们分配 null,这不会引发错误。

下表完整列出了包装器类型以及它们的等效 C# 类型:

C# 类型已知类型包装器
bool? google.protobuf.BoolValue
double? google.protobuf.DoubleValue
float? google.protobuf.FloatValue
int? google.protobuf.Int32Value
long? google.protobuf.Int64Value
uint? google.protobuf.UInt32Value
ulong? google.protobuf.UInt64Value
string google.protobuf.StringValue
ByteString google.protobuf.BytesValue

 

字节

Protobuf 支持标量值类型为 bytes 的二进制有效负载。 C# 中生成的属性使用 ByteString 作为属性类型。

使用 ByteString.CopyFrom(byte[] data) 从字节数组创建新实例:

var data = await File.ReadAllBytesAsync(path);

var payload = new PayloadResponse();
payload.Data = ByteString.CopyFrom(data);

 

使用 ByteString.SpanByteString.Memory 直接访问 ByteString 数据。 或调用 ByteString.ToByteArray() 将实例转换回字节数组:

var payload = await client.GetPayload(new PayloadRequest());

await File.WriteAllBytesAsync(path, payload.Data.ToByteArray());

 

集合

列表

Protobuf 中,在字段上使用 repeated 前缀关键字指定列表。 以下示例演示如何创建列表:

message Person {
    // ...
    repeated string roles = 8;
}

 

在生成的代码中,repeated 字段由 Google.Protobuf.Collections.RepeatedField<T> 泛型类型表示。

public class Person
{
    // ...
    public RepeatedField<string> Roles { get; }
}

 

RepeatedField<T> 实现了 IList。 因此你可使用 LINQ 查询,或者将其转换为数组或列表。 RepeatedField<T> 属性没有公共 setter。 项应添加到现有集合中。

var person = new Person();

person.Roles.Add("user");

var roles = new [] { "admin", "manager" };
person.Roles.Add(roles);

var list = roles.ToList();

 

 

字典

.NET IDictionary 类型在 Protobuf 中使用 map<key_type, value_type> 表示。

ProtoBuf复制

message Person {
    // ...
    map<string, string> attributes = 9;
}

 

在生成的 .NET 代码中,map 字段由 Google.Protobuf.Collections.MapField<TKey, TValue> 泛型类型表示。 MapField<TKey, TValue> 实现了 IDictionary。 与 repeated 属性一样,map 属性没有公共 setter。 项应添加到现有集合中。

var person = new Person();

// 添加一项
person.Attributes["created_by"] = "James";

// 添加多项
var attributes = new Dictionary<string, string>
{
    ["last_modified"] = DateTime.UtcNow.ToString()
};
person.Attributes.Add(attributes);

 

 

 

 

 

4. 创建 gRPC 服务和方法

本文档介绍如何以 C# 创建 gRPC 服务和方法。 包括:

  • 如何在 .proto 文件中定义服务和方法。

  • 使用 gRPC C# 工具生成的代码。

  • 实现 gRPC 服务和方法。

 

创建新的 gRPC 服务

设置appsetting.json
"Kestrel": {
    "EndpointDefaults": {
      "Protocols": "Http2"
    }
  }

 

或者Program.cs 中配置如下代码:

// Gprc 需要 Http2.0
builder.WebHost.UseKestrel(p =>
{
    p.ConfigureEndpointDefaults(opt =>
    {
        opt.Protocols = HttpProtocols.Http2;
    });
});
 

 

引用包:

Grpc.AspNetCore  2.50.0

 

 

服务和消息是在 .proto 文件中定义的。 然后,C# 工具从 .proto 文件生成代码。 对于服务器端资产,将为每个服务生成一个抽象基类型,同时为所有消息生成类。

以下 .proto 文件:

  • 定义 Greeter 服务。

  • Greeter 服务定义 SayHello 调用。

  • SayHello 发送 HelloRequest 消息并接收 HelloReply 消息

syntax = "proto3";

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

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

 

C# 工具生成 C# GreeterBase 基类型:

public abstract partial class GreeterBase
{
    public virtual Task<HelloReply> SayHello(HelloRequest request, ServerCallContext context)
    {
        throw new RpcException(new Status(StatusCode.Unimplemented, ""));
    }
}

public class HelloRequest
{
    public string Name { get; set; }
}

public class HelloReply
{
    public string Message { get; set; }
}

 

默认情况下,生成的 GreeterBase 不执行任何操作。 它的虚拟 SayHello 方法会将 UNIMPLEMENTED 错误返回到调用它的任何客户端。 为了使服务有用,应用必须创建 GreeterBase 的具体实现:

public class GreeterService : GreeterBase
{
    public override Task<HelloReply> SayHello(HelloRequest request, ServerCallContext context)
    {
        return Task.FromResult(new HelloReply { Message = $"Hello {request.Name}" });
    }
}

 

ServerCallContext 提供服务器端调用的上下文。

服务实现已注册到应用。 如果服务由 ASP.NET Core gRPC 托管,则应使用 MapGrpcService 方法将其添加到路由管道。

app.MapGrpcService<GreeterService>();

 

 

实现 gRPC 方法

gRPC 服务可以有不同类型的方法。 服务发送和接收消息的方式取决于所定义的方法的类型。 gRPC 方法类型如下:

  • 一元

  • 服务器流式处理

  • 客户端流式处理

  • 双向流式处理

流式处理调用是使用 stream 关键字在 .proto 文件中指定的。 stream 可以放置在调用的请求消息和/或响应消息中。

syntax = "proto3";
import "google/protobuf/empty.proto"; // 无参包

service ExampleService {
    // 无参方法
   rpc GetPerson1(google.protobuf.Empty) returns (PersonResponse);

  // 一元
  rpc UnaryCall (ExampleRequest) returns (ExampleResponse);

  // 服务器流式处理
  rpc StreamingFromServer (ExampleRequest) returns (stream ExampleResponse);

  // 客户端流式处理
  rpc StreamingFromClient (stream ExampleRequest) returns (ExampleResponse);

  // 双向流式处理
  rpc StreamingBothWays (stream ExampleRequest) returns (stream ExampleResponse);
}

 

每个调用类型都有不同的方法签名。 在具体实现中替代从抽象基本服务类型生成的方法,可确保使用正确的参数和返回类型。

 

一元方法

一元方法将请求消息作为参数,并返回响应。 返回响应时,一元调用完成。

public override Task<ExampleResponse> UnaryCall(ExampleRequest request,
    ServerCallContext context)
{
    var response = new ExampleResponse();
    return Task.FromResult(response);
}

 

一元调用与 Web API 控制器上的操作最为相似。 gRPC 方法与操作的一个重要区别是,gRPC 方法无法将请求的某些部分绑定到不同的方法参数。 对于传入请求数据,gRPC 方法始终有一个消息参数。 通过在请求消息中设置多个值字段,仍可以将多个值发送到 gRPC 服务:

message ExampleRequest {
    int32 pageIndex = 1;
    int32 pageSize = 2;
    bool isDescending = 3;
}
 

 

服务器流式处理方法

服务器流式处理方法将请求消息作为参数。 由于可以将多个消息流式传输回调用方,因此可使用 responseStream.WriteAsync 发送响应消息。 当方法返回时,服务器流式处理调用完成。

public override async Task StreamingFromServer(ExampleRequest request,
    IServerStreamWriter<ExampleResponse> responseStream, ServerCallContext context)
{
    for (var i = 0; i < 5; i++)
    {
        await responseStream.WriteAsync(new ExampleResponse());
        await Task.Delay(TimeSpan.FromSeconds(1));
    }
}

 

服务器流式处理方法启动后,客户端无法发送其他消息或数据。 某些流式处理方法设计为永久运行。 对于连续流式处理方法,客户端可以在不再需要调用时将其取消。 当发生取消时,客户端会将信号发送到服务器,并引发 ServerCallContext.CancellationToken。 应在服务器上通过异步方法使用 CancellationToken 标记,以实现以下目的:

  • 所有异步工作都与流式处理调用一起取消。

  • 该方法快速退出。

public override async Task StreamingFromServer(ExampleRequest request,
    IServerStreamWriter<ExampleResponse> responseStream, ServerCallContext context)
{
    while (!context.CancellationToken.IsCancellationRequested)
    {
        await responseStream.WriteAsync(new ExampleResponse());
        await Task.Delay(TimeSpan.FromSeconds(1), context.CancellationToken);
    }
}

 

 

客户端流式处理方法

客户端流式处理方法在该方法没有接收消息的情况下启动。 requestStream 参数用于从客户端读取消息。 返回响应消息时,客户端流式处理调用完成:

public override async Task<ExampleResponse> StreamingFromClient(
    IAsyncStreamReader<ExampleRequest> requestStream, ServerCallContext context)
{
    while (await requestStream.MoveNext())
    {
        var message = requestStream.Current;
        // ...
    }
    return new ExampleResponse();
}

 

如果使用 C# 8 或更高版本,则可使用 await foreach 语法来读取消息。 IAsyncStreamReader<T>.ReadAllAsync() 扩展方法读取请求数据流中的所有消息:

public override async Task<ExampleResponse> StreamingFromClient(
    IAsyncStreamReader<ExampleRequest> requestStream, ServerCallContext context)
{
    await foreach (var message in requestStream.ReadAllAsync())
    {
        // ...
    }
    return new ExampleResponse();
}

 

 

双向流式处理方法

双向流式处理方法在该方法没有接收到消息的情况下启动。 requestStream 参数用于从客户端读取消息。 该方法可选择使用 responseStream.WriteAsync 发送消息。 当方法返回时,双向流式处理调用完成:

public override async Task StreamingBothWays(IAsyncStreamReader<ExampleRequest> requestStream,
    IServerStreamWriter<ExampleResponse> responseStream, ServerCallContext context)
{
    await foreach (var message in requestStream.ReadAllAsync())
    {
        await responseStream.WriteAsync(new ExampleResponse());
    }
}

 

前面的代码:

  • 发送每个请求的响应。

  • 是双向流式处理的基本用法。

可以支持更复杂的方案,例如同时读取请求和发送响应:

public override async Task StreamingBothWays(IAsyncStreamReader<ExampleRequest> requestStream,
    IServerStreamWriter<ExampleResponse> responseStream, ServerCallContext context)
{
    // 读取后台任务中的请求。
    var readTask = Task.Run(async () =>
    {
        await foreach (var message in requestStream.ReadAllAsync())
        {
            // Process request.
        }
    });
    
    // 发送响应,直到客户端发出完成的信号。 
    while (!readTask.IsCompleted)
    {
        await responseStream.WriteAsync(new ExampleResponse());
        await Task.Delay(TimeSpan.FromSeconds(1), context.CancellationToken);
    }
}

 

在双向流式处理方法中,客户端和服务可在任何时间互相发送消息。 双向方法的最佳实现根据需求而有所不同。

 

访问 gRPC 请求标头

请求消息并不是客户端将数据发送到 gRPC 服务的唯一方法。 标头值在使用 ServerCallContext.RequestHeaders 的服务中可用。

public override Task<ExampleResponse> UnaryCall(ExampleRequest request, ServerCallContext context)
{
    var userAgent = context.RequestHeaders.GetValue("user-agent");
    // ...

    return Task.FromResult(new ExampleResponse());
}

 

 

多线程处理

实现使用多个线程的 gRPC 流式处理方法有一些重要的注意事项。

IAsyncStreamReader<TMessage>IServerStreamWriter<TMessage> 一次只能由一个线程使用。 对于流式处理 gRPC 方法,多个线程无法使用 requestStream.MoveNext() 同时读取新消息。 多个线程无法使用 responseStream.WriteAsync(message) 同时写入新消息。

多个线程能够与 gRPC 方法实现交互的一种安全方法是将生成方-使用者模式与 System.Threading.Channels 配合使用。

public override async Task DownloadResults(DataRequest request,
    IServerStreamWriter<DataResult> responseStream, ServerCallContext context)
{
    var channel = Channel.CreateBounded<DataResult>(new BoundedChannelOptions(capacity: 5));

    var consumerTask = Task.Run(async () =>
    {
        // 从通道中消费消息并写入响应流
        await foreach (var message in channel.Reader.ReadAllAsync())
        {
            await responseStream.WriteAsync(message);
        }
    });

    var dataChunks = request.Value.Chunk(size: 10);

    // 从多个线程向通道写入消息
    await Task.WhenAll(dataChunks.Select(
        async c =>
        {
            var message = new DataResult { BytesProcessed = c.Length };
            await channel.Writer.WriteAsync(message);
        }));

    // 完成写作,等待消费者完成
    channel.Writer.Complete();
    await consumerTask;
}

 

备注

双向流式处理方法采用 IAsyncStreamReader<TMessage>IServerStreamWriter<TMessage> 作为自变量。 在彼此独立的线程上使用这些类型是安全的。

 

5. gRPC 客户端

.NET 客户端调用 gRPC

Grpc.Net.Client NuGet 包提供了 .NET gRPC 客户端库。 本文档介绍如何执行以下操作:

  • 配置 gRPC 客户端以调用 gRPC 服务。

  • 对一元、服务器流式处理、客户端流式处理和双向流式处理方法进行 gRPC 调用。

配置 gRPC 客户端

需要安装的包:

Grpc.Net.Client 2.50.0
Google.Protobuf 3.20.0
Grpc.Tools 2.50.0

 

 

gRPC 客户端是从 .proto 文件生成的具体客户端类型。 具体 gRPC 客户端具有转换为 .proto 文件中 gRPC 服务的方法。 例如,名为 Greeter 的服务生成 GreeterClient 类型(包含调用服务的方法)。

gRPC 客户端是通过通道创建的。 首先使用 GrpcChannel.ForAddress 创建一个通道,然后使用该通道创建 gRPC 客户端:

var channel = GrpcChannel.ForAddress("https://localhost:5001");
var client = new Greet.GreeterClient(channel);

 

通道表示与 gRPC 服务的长期连接。 创建通道后,进行配置,使其具有与调用服务相关的选项。 例如,可在 GrpcChannelOptions 上指定用于调用的 HttpClient、发收和接收消息的最大大小以及记录日志,并将其与 GrpcChannel.ForAddress 一起使用。 有关选项的完整列表,请参阅 客户端配置选项

var channel = GrpcChannel.ForAddress("https://localhost:5001");

var greeterClient = new Greet.GreeterClient(channel);
var counterClient = new Count.CounterClient(channel);

 

配置 TLS

gRPC 客户端必须使用与被调用服务相同的连接级别安全性。 gRPC 客户端传输层安全性 (TLS) 是在创建 gRPC 通道时配置的。 如果在调用服务时通道和服务的连接级别安全性不一致,gRPC 客户端就会抛出错误。

若要将 gRPC 通道配置为使用 TLS,请确保服务器地址以 https 开头。 例如,GrpcChannel.ForAddress("https://localhost:5001") 使用 HTTPS 协议。 gRPC 通道自动协商由 TLS 保护的连接,并使用安全连接进行 gRPC 调用。

 

若要调用不安全的 gRPC 服务,请确保服务器地址以 http 开头。 例如,GrpcChannel.ForAddress("http://localhost:5000") 使用 HTTP 协议。 在 .NET Core 3.1 中,必须进行其他配置,才能使用 .NET 客户端调用不安全的 gRPC 服务。

// Net Core 3.1 支持HTTP
AppContext.SetSwitch(
    "System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);

var channel = GrpcChannel.ForAddress("http://localhost:5000");
var client = new Greet.GreeterClient(channel);

 

客户端性能

通道及客户端性能和使用情况:

  • 创建通道成本高昂。 重用 gRPC 调用的通道可提高性能。

  • gRPC 客户端是使用通道创建的。 gRPC 客户端是轻型对象,无需缓存或重用。

  • 可从一个通道创建多个 gRPC 客户端(包括不同类型的客户端)。

  • 通道和从该通道创建的客户端可由多个线程安全使用。

  • 从通道创建的客户端可同时进行多个调用。

GrpcChannel.ForAddress 不是创建 gRPC 客户端的唯一选项。 如果要从 ASP.NET Core 应用调用 gRPC 服务,请考虑 gRPC 客户端工厂集成。 gRPC 与 HttpClientFactory 集成是创建 gRPC 客户端的集中式操作备选方案。

 

一元调用

一元调用从客户端发送请求消息开始。 服务结束后,返回响应消息。

var client = new Greet.GreeterClient(channel);
var response = await client.SayHelloAsync(new HelloRequest { Name = "World" });

Console.WriteLine("Greeting: " + response.Message);
// Greeting: Hello World

 

.proto 文件中的每个一元服务方法将在用于调用方法的具体 gRPC 客户端类型上产生两个 .NET 方法:异步方法和阻塞方法。 例如,GreeterClient 具有两种调用 SayHello 的方法:

  • GreeterClient.SayHelloAsync - 以异步方式调用 Greeter.SayHello 服务。 敬请期待。

  • GreeterClient.SayHello - 调用 Greeter.SayHello 服务并阻塞,直至结束。 不要在异步代码中使用。

 

服务器流式处理调用

服务器流式处理调用从客户端发送请求消息开始。 ResponseStream.MoveNext() 读取从服务流式处理的消息。 ResponseStream.MoveNext() 返回 false 时,服务器流式处理调用已完成。

var client = new Greet.GreeterClient(channel);
using var call = client.SayHellos(new HelloRequest { Name = "World" });

while (await call.ResponseStream.MoveNext())
{
    Console.WriteLine("Greeting: " + call.ResponseStream.Current.Message);
    // "Greeting: Hello World" is written multiple times
}

 

如果使用 C# 8 或更高版本,则可使用 await foreach 语法来读取消息。 IAsyncStreamReader<T>.ReadAllAsync() 扩展方法读取响应数据流中的所有消息:

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
}

 

 

客户端流式处理调用

客户端无需发送消息即可开始客户端流式处理调用 。 客户端可选择使用 RequestStream.WriteAsync 发送消息。 客户端发送完消息后,应调用 RequestStream.CompleteAsync() 来通知服务。 服务返回响应消息时,调用完成。

var client = new Counter.CounterClient(channel);
using var call = client.AccumulateCount();

for (var i = 0; i < 3; i++)
{
    await call.RequestStream.WriteAsync(new CounterRequest { Count = 1 });
}
// 客户端发完消息,通知服务端返回数据
await call.RequestStream.CompleteAsync();

var response = await call;
Console.WriteLine($"Count: {response.Count}");
// Count: 3
 

 

双向流式处理调用

客户端无需发送消息即可开始双向流式处理调用 。 客户端可选择使用 RequestStream.WriteAsync 发送消息。 使用 ResponseStream.MoveNext()ResponseStream.ReadAllAsync() 可访问从服务流式处理的消息。 ResponseStream 没有更多消息时,双向流式处理调用完成。

var client = new Echo.EchoClient(channel);
using var call = client.Echo();

Console.WriteLine("Starting background task to receive messages");
var readTask = Task.Run(async () =>
{
    await foreach (var response in call.ResponseStream.ReadAllAsync())
    {
        Console.WriteLine(response.Message);
        // Echo messages sent to the service
    }
});

Console.WriteLine("Starting to send messages");
Console.WriteLine("Type a message to echo then press enter.");
while (true)
{
    var result = Console.ReadLine();
    if (string.IsNullOrEmpty(result))
    {
        break;
    }

    await call.RequestStream.WriteAsync(new EchoMessage { Message = result });
}

Console.WriteLine("Disconnecting");
await call.RequestStream.CompleteAsync();
await readTask;

 

为获得最佳性能并避免客户端和服务中出现不必要的错误,请尝试正常完成双向流式调用。 当服务器已读取请求流且客户端已读取响应流时,双向调用正常完成。 前面的示例调用就是一个正常结束的双向调用。 在调用中,客户端:

  1. 通过调用 EchoClient.Echo 启动新的双向流式调用。

  2. 使用 ResponseStream.ReadAllAsync() 创建用于从服务中读取消息的后台任务。

  3. 使用 RequestStream.WriteAsync 将消息发送到服务器。

  4. 使用 RequestStream.CompleteAsync() 通知服务器它已发送消息。

  5. 等待直到后台任务已读取所有传入消息。

双向流式处理调用期间,客户端和服务可在任何时间互相发送消息。 与双向调用交互的最佳客户端逻辑因服务逻辑而异。

 

访问 gRPC 标头

gRPC 调用返回响应头。 HTTP 响应头传递与返回的消息不相关的调用的名称/值元数据。

标头可通过 ResponseHeadersAsync 进行访问,它会返回元数据的集合。 标头通常随响应消息一起返回;因此,必须等待它们返回。

var client = new Greet.GreeterClient(channel);
using var call = client.SayHelloAsync(new HelloRequest { Name = "World" });

var headers = await call.ResponseHeadersAsync;
var myValue = headers.GetValue("my-trailer-name");

var response = await call.ResponseAsync;

 

使用 ResponseHeadersAsync 时:

  • 必须等待 ResponseHeadersAsync 的结果才能获取标头集合。

  • 无需在 ResponseAsync(或流式处理时的响应流)之前访问。 如果已返回响应,则 ResponseHeadersAsync 立即返回标头。

  • 如果存在连接或服务器错误,并且 gRPC 调用未返回标头,将引发异常。

 

配置截止时间

建议配置 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.");
}

 

 

客户端工厂

gRPC 与 HttpClientFactory 的集成提供了一种创建 gRPC 客户端的集中方式。 它可用作配置独立 gRPC 客户端实例(上一节内容)的替代方法。 Grpc.Net.ClientFactory NuGet 包中提供了工厂集成。

工厂具有以下优势:

  • 提供了用于配置逻辑 gRPC 客户端实例的中心位置。

  • 可管理基础 HttpClientMessageHandler 的生存期。

  • 在 ASP.NET Core gRPC 服务中自动传播截止时间和取消。

 

注册 gRPC 客户端

若要注册 gRPC 客户端,可在 Program.cs 中的应用入口点处的 WebApplicationBuilder 的实例中使用通用的 AddGrpcClient 扩展方法,并指定 gRPC 类型化客户端类和服务地址:

builder.Services.AddGrpcClient<UserService.UserServiceClient>(p =>
{
    p.Address = new Uri("http://localhost:5227");
});

 

gRPC 客户端类型通过依赖项注入 (DI) 注册为暂时性。 现在可以在由 DI 创建的类型中直接注入和使用客户端。 ASP.NET Core MVC 控制器、SignalR 中心和 gRPC 服务是可以自动注入 gRPC 客户端的位置:

[Route("[controller]/[action]")]
[ApiController]
public class UserController:ControllerBase
{
    private readonly UserService.UserServiceClient _userClient;


    public UserController(UserService.UserServiceClient userClient)
    {
        _userClient = userClient;
    }

    [HttpGet]
    public IActionResult GetUserList()
    {
        return Ok(_userClient.GetUserList(new UserRequest()));
    }
}
 

 

调用凭据

使用 AddCallCredentials 方法将身份验证标头添加到 gRPC 调用, 此方法在 Grpc.Net.ClientFactory 版本 2.46.0 以上版本中可用。

builder.Services.AddHttpContextAccessor();

// 客户端配置JWT
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(p =>
    {
        // ...
    });
builder.Services.AddAuthorization();

builder.Services
    .AddGrpcClient<Greeter.GreeterClient>(o =>
    {
        o.Address = new Uri("https://localhost:5001");
    })
     // 添加调用凭据
    .AddCallCredentials(async (context, metadata) =>
    {
        var serviceProvider = builder.Services.BuildServiceProvider();
        var httpContextAccessor = serviceProvider.GetService<IHttpContextAccessor>();
        // 获取客户端的token
        var accessToken = await httpContextAccessor.HttpContext.GetTokenAsync("access_token");
        if (string.IsNullOrWhiteSpace(accessToken))
        {
            // 如果客户端没有拿到token ,直接从授权中心拿Token(这一步通常用不上)
            var channel = GrpcChannel.ForAddress("http://localhost:5015");
            accessToken = new TokenService.TokenServiceClient(channel).GetToken(new()
            {
                UserName = "admin",
                Pwd = "123"
            }).AccessToken;
            channel.Dispose();
        }
        
        if (!string.IsNullOrEmpty(accessToken))
        {
            metadata.Add("Authorization", $"Bearer {accessToken}");
        }
   }).ConfigureChannel(o => o.UnsafeUseInsecureChannelCallCredentials = true);


var app = builder.Build();
// ...

app.UseAuthentication();
app.UseAuthorization();

// ...

 

有关配置调用凭据的详细信息,请参gRPC身份验证和授权

 

截止时间与取消

截止时间和取消功能是 gRPC 客户端用来中止进行中调用的功能。 本文介绍截止时间和取消功能非常重要的原因,以及如何在 .NET gRPC 应用中使用它们。

截止时间

截止时间功能让 gRPC 客户端可以指定等待调用完成的时间。 超过截止时间时,将取消调用。 设定一个截止时间非常重要,因为它将提供调用可运行的最长时间。 它能阻止异常运行的服务持续运行并耗尽服务器资源。 截止时间对于构建可靠应用非常有效,应该进行配置。

截止时间配置:

  • 在进行调用时,使用 CallOptions.Deadline 配置截止时间。

  • 没有截止时间默认值。 gRPC 调用没有时间限制,除非指定了截止时间。

  • 截止时间指的是超过截止时间的 UTC 时间。 例如,DateTime.UtcNow.AddSeconds(5) 是从现在起 5 秒的截止时间。

  • 如果使用的是过去或当前的时间,则调用将立即超过截止时间。

  • 截止时间随 gRPC 调用发送到服务,并由客户端和服务独立跟踪。 gRPC 调用可能在一台计算机上完成,但当响应返回给客户端时,已超过了截止时间。

如果超过了截止时间,客户端和服务将有不同的行为:

  • 客户端将立即中止基础的 HTTP 请求并引发 DeadlineExceeded 错误。 客户端应用可以选择捕获错误并向用户显示超时消息。

  • 在服务器上,将中止正在执行的 HTTP 请求,并引发 ServerCallContext.CancellationToken。 尽管中止了 HTTP 请求,gRPC 调用仍将继续在服务器上运行,直到方法完成。 将取消令牌传递给异步方法,使其随调用一同被取消,这非常重要。 例如,向异步数据库查询和 HTTP 请求传递取消令牌。 传递取消令牌让取消的调用可以在服务器上快速完成,并为其他调用释放资源。

配置 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.");
}

 

在 gRPC 服务中使用 ServerCallContext.CancellationToken

public override async Task<HelloReply> SayHello(HelloRequest request,
    ServerCallContext context)
{
    var user = await _databaseContext.GetUserAsync(request.Name,
        context.CancellationToken);

    return new HelloReply { Message = "Hello " + user.DisplayName };
}

 

截止时间和重试

当 gRPC 调用配置了故障处理和截止日期时,截止日期会跟踪 gRPC 调用的所有重试时间。 如果超过了截止时间,gRPC 调用会立即中止底层 HTTP 请求,跳过任何剩余的重试,并引发 DeadlineExceeded 错误。

 

传播截止时间

需要安装包:

Grpc.AspNetCore.Server.ClientFactory  2.50.0

 

 

从正在执行的 gRPC 服务进行 gRPC 调用时,应传播截止时间。 例如:

  1. 客户端应用调用带有截止时间的 FrontendService.GetUser

  2. FrontendService 调用 UserService.GetUser。 客户端指定的截止时间应随新的 gRPC 调用进行指定。

  3. UserService.GetUser 接收截止时间。 如果超过了客户端应用的截止时间,将正确超时。

调用上下文将使用 ServerCallContext.Deadline 提供截止时间:

public override async Task<UserResponse> GetUser(UserRequest request,
    ServerCallContext context)
{
    var client = new User.UserServiceClient(_channel);
    var response = await client.GetUserAsync(
        new UserRequest { Id = request.Id },
        deadline: context.Deadline);

    return response;
}

 

手动传播截止时间可能会很繁琐。 截止时间需要传递给每个调用,很容易不小心错过。 gRPC 客户端工厂提供自动解决方案。 指定 EnableCallContextPropagation(需要安装包: Grpc.AspNetCore.Server.ClientFactory ):

  • 自动将截止时间和取消令牌传播到子调用。

  • 如果子调用指定较早的截止时间,则不传播截止时间。 例如,如果子调用使用 CallOptions.Deadline 指定新的 5 秒截止时间,则不使用传播的 10 秒截止时间。 当多个截止时间可用时,使用最早的截止时间。

  • 这是确保复杂的嵌套 gRPC 场景始终传播截止时间和取消的一种极佳方式。

services
    .AddGrpcClient<User.UserServiceClient>(o =>
    {
        o.Address = new Uri("https://localhost:5001");
    })
    .EnableCallContextPropagation();

 

默认情况下,如果客户端在 gRPC 调用的上下文之外使用,EnableCallContextPropagation 将引发错误。 此错误旨在提醒你没有要传播的调用上下文。 如果要在调用上下文之外使用客户端,请使用 SuppressContextNotFoundErrors 在配置客户端时禁止显示该错误:

builder.Services
    .AddGrpcClient<Greeter.GreeterClient>(o =>
    {
        o.Address = new Uri("https://localhost:5001");
    })
    .EnableCallContextPropagation(o => o.SuppressContextNotFoundErrors = true);

 

 

故障处理与重试

gRPC 重试是一项功能,允许 gRPC 客户端自动重试失败的调用。 本文介绍如何配置重试策略,以便在 .NET 中创建可复原的容错 gRPC 应用。

gRPC 重试需要 Grpc.Net.Client 2.36.0 或更高版本。

 

暂时性故障处理

暂时性故障可能会中断 gRPC 调用。 暂时性故障包括:

  • 暂时失去网络连接。

  • 服务暂时不可用。

  • 由于服务器负载导致超时。

gRPC 调用中断时,客户端会引发 RpcException 并提供有关错误的详细信息。 客户端应用必须捕获异常并选择如何处理错误。

var client = new Greeter.GreeterClient(channel);
try
{
    var response = await client.SayHelloAsync(
        new HelloRequest { Name = ".NET" });

    Console.WriteLine("From server: " + response.Message);
}
catch (RpcException ex)
{
    // Write logic to inspect the error and retry
    // if the error is from a transient fault.
}

 

在整个应用中复制重试逻辑是非常冗长的代码,容易出错。 幸运的是,.NET gRPC 客户端拥有自动重试的内置支持。

 

配置 gRPC 重试策略

重试策略在创建 gRPC 通道时配置一次:

var defaultMethodConfig = new MethodConfig
{
    Names = { MethodName.Default },
    RetryPolicy = new RetryPolicy
    {
        MaxAttempts = 5,
        InitialBackoff = TimeSpan.FromSeconds(1),
        MaxBackoff = TimeSpan.FromSeconds(5),
        BackoffMultiplier = 1.5,
        RetryableStatusCodes = { StatusCode.Unavailable }
    }
};

var channel = GrpcChannel.ForAddress("https://localhost:5001", new GrpcChannelOptions
{
    ServiceConfig = new ServiceConfig { MethodConfigs = { defaultMethodConfig } }
});

 

上述代码:

  • 创建一个 MethodConfig。 重试策略可以按方法配置,而方法可以使用 Names 属性进行匹配。 此方法配置有 MethodName.Default,因此它将应用于此通道调用的所有 gRPC 方法。

  • 配置重试策略。 此策略将指示客户端自动重试状态代码为 Unavailable 的失败 gRPC 调用。

  • 通过设置 GrpcChannelOptions.ServiceConfig,将创建的通道配置为使用该重试策略。

随着该通道一起创建的 gRPC 客户端将自动重试失败的调用:

var client = new Greeter.GreeterClient(channel);
var response = await client.SayHelloAsync(
    new HelloRequest { Name = ".NET" });

Console.WriteLine("From server: " + response.Message);

 

gRPC 重试选项

下表描述了用于配置 gRPC 重试策略的选项:

选项描述
MaxAttempts 最大调用尝试次数,包括原始尝试。 此值受 GrpcChannelOptions.MaxRetryAttempts(默认值为 5)的限制。 必须为该选项提供值,且值必须大于 1。
InitialBackoff 重试尝试之间的初始退避延迟。 介于 0 与当前退避之间的随机延迟确定何时进行下一次重试尝试。 每次尝试后,当前退避将乘以 BackoffMultiplier。 必须为该选项提供值,且值必须大于 0。
MaxBackoff 最大退避会限制指数退避增长的上限。 必须为该选项提供值,且值必须大于 0。
BackoffMultiplier 每次重试尝试后,退避将乘以该值,并将在乘数大于 1 的情况下以指数方式增加。 必须为该选项提供值,且值必须大于 0。
RetryableStatusCodes 状态代码的集合。 具有匹配状态的失败 gRPC 调用将自动重试。 有关状态代码的更多信息,请参阅状态代码及其在 gRPC 中的用法。 至少需要提供一个可重试的状态代码。
重试何时有效

满足以下条件时,将重试调用:

  • 失败状态代码与 RetryableStatusCodes 中的值匹配。

  • 之前的尝试次数小于 MaxAttempts

  • 此调用未提交。

  • 尚未超过截止时间。

在以下两种情况下,将提交 gRPC 调用:

  • 客户端收到响应头。 调用 ServerCallContext.WriteResponseHeadersAsync 或将第一个消息写入服务器响应流时,服务器会发送响应头。

  • 客户端的传出消息(如果是流式处理则为消息)已超出客户端的最大缓冲区大小。 MaxRetryBufferSizeMaxRetryBufferPerCallSizegPRC 配置这一小节中进行配置。

无论状态代码是什么或以前的尝试次数是多少,提交的调用都无法重试。

 

流式处理调用

流式处理调用可以与 gRPC 重试一起使用,但在将它们一起使用时,务必注意以下事项:

  • 服务器流式处理、双向流式处理: 在已收到第一个消息后,从服务器返回多个消息的流式处理 RPC 无法重试。 应用必须添加额外的逻辑才能手动重新建立服务器和双向流式传输调用。

  • 客户端流式处理、双向流式处理: 传出消息超出客户端的最大缓冲区大小时,向服务器发送多个消息的流式处理 RPC 无法重试。 可通过配置增加最大缓冲区大小。

 

客户端负载均衡

客户端负载均衡功能允许 gRPC 客户端以最佳方式在可用服务器之间分配负载。 本文介绍了如何配置客户端负载均衡,以在 .NET 中创建可缩放的高性能 gRPC 应用。

使用客户端负载均衡需要具备以下组件:

  • .NET 5 或更高版本。

  • Grpc.Net.Client 版本 2.45.0 或更高版本。

 

配置客户端负载均衡

客户端负载均衡是在创建通道时配置的。 使用负载均衡时需要考虑两个组件:

  • 解析程序,用于解析通道的地址。 解析程序支持从外部源获取地址。 这也被称为服务发现。

  • 负载均衡器,用于创建连接,并选取 gRPC 调用将使用的地址。

解析程序和负载均衡器的内置实现包含在 Grpc.Net.Client 中。 也可以通过编写自定义解析程序和负载均衡器来扩展负载均衡。

地址、连接和其他负载均衡状态存储在 GrpcChannel 实例中。 在进行 gRPC 调用时,必须重用通道,以使负载均衡正常工作。

 

配置解析程序

解析程序是使用创建通道时所用的地址配置的。

Scheme类型说明
dns DnsResolverFactory 通过查询 DNS 地址记录的主机名来解析地址。
static StaticResolverFactory 解析应用已指定的地址。 如果应用已经知道它调用的地址,则建议使用。

通道不会直接调用与解析程序匹配的 URI。 而是创建一个匹配的解析程序,用它来解析地址。

例如,使用 GrpcChannel.ForAddress("dns:///my-example-host", new GrpcChannelOptions { Credentials = ChannelCredentials.Insecure })

  • dns 方案映射到 DnsResolverFactory。 为通道创建 DNS 解析程序的一个新实例。

  • 解析程序对 my-example-host 进行 DNS 查询,并获得两个结果:localhost:80localhost:81

  • 负载均衡器使用 localhost:80localhost:81 创建连接并进行 gRPC 调用。

DnsResolverFactory

DnsResolverFactory 创建一个解析程序,旨在从外部源获取地址。 DNS 解析通常用于对具有 Kubernetes 无外设服务的 Pod 实例进行负载均衡。

var channel = GrpcChannel.ForAddress(
    "dns:///my-example-host",
    new GrpcChannelOptions { Credentials = ChannelCredentials.Insecure });
var client = new Greet.GreeterClient(channel);

var response = await client.SayHelloAsync(new HelloRequest { Name = "world" });

 

前面的代码:

  • 使用地址 dns:///my-example-host 配置已创建的通道。 dns 方案映射到 DnsResolverFactory

  • 不指定负载均衡器。 通道默认选取第一个负载均衡器。

  • 启动 gRPC 调用SayHello

    • DNS 解析程序为主机名 my-example-host 获取地址。

    • 选取第一个负载均衡器以尝试连接到一个已解析的地址。

    • 调用被发送到通道成功连接到的第一个地址。

 

DNS 地址缓存

在使用负载均衡时,性能非常重要。 通过缓存地址,从 gRPC 调用中消除了解析地址的延迟。 进行第一次 gRPC 调用时,将调用解析程序,后续调用将使用缓存。

如果连接中断,则会自动刷新地址。 如果是在运行时更改地址,那么刷新非常重要。 例如,在 Kubernetes 中,重启的 Pod 会触发 DNS 解析程序刷新并获取 Pod 的新地址。

默认情况下,如果连接中断,则会刷新 DNS 解析程序。 DNS 解析程序还可以根据需要定期刷新。 这对于快速检测新的 pod 实例很有用。

services.AddSingleton<ResolverFactory>(
sp => new DnsResolverFactory(refreshInterval: TimeSpan.FromSeconds(30)));

上面的代码创建具有刷新间隔的 DnsResolverFactory,并将其注册到依赖关系注入。

 

StaticResolverFactory

静态解析程序由 StaticResolverFactory 提供。 此解析程序:

  • 不调用外部源。 相反,客户端应用会配置地址。

  • 适用于应用已经知道它调用的地址的情况。

var factory = new StaticResolverFactory(addr => new[]
{
    new BalancerAddress("localhost", 80),
    new BalancerAddress("localhost", 81)
});

var services = new ServiceCollection();
services.AddSingleton<ResolverFactory>(factory);

var channel = GrpcChannel.ForAddress(
    "static:///my-example-host",
    new GrpcChannelOptions
    {
        Credentials = ChannelCredentials.Insecure,
        ServiceProvider = services.BuildServiceProvider()
    });
var client = new Greet.GreeterClient(channel);

 

上述代码:

  • 创建一个 StaticResolverFactory。 此工厂知道两个地址:localhost:80localhost:81

  • 使用依赖项注入 (DI) 来注册工厂。

  • 使用以下内容配置已创建的通道:

    • 地址 static:///my-example-hoststatic 方案映射到静态解析程序。

    • 使用 DI 服务提供程序设置 GrpcChannelOptions.ServiceProvider

本示例为 DI 创建了一个新的 ServiceCollection。 假设一个应用已设置 DI,比如一个 ASP.NET Core 网站。 在这种情况下,类型应被注册到现有的 DI 实例。 GrpcChannelOptions.ServiceProvider 是通过从 DI 获取 IServiceProvider 来配置的。

 

配置负载均衡器

负载均衡器是使用 ServiceConfig.LoadBalancingConfigs 集合在 service config 中指定的。 两个负载均衡器都是内置的,映射到负载均衡器配置名称:

名称类型说明
pick_first PickFirstLoadBalancerFactory 尝试连接到地址,直到成功建立连接。 gRPC 调用都是针对第一次成功连接进行的。
round_robin RoundRobinLoadBalancerFactory 尝试连接到所有地址。 gRPC 调用是使用轮循机制逻辑分布在所有成功的连接上的。

service config 是“service configuration”的缩写形式,用 ServiceConfig 类型表示。 有几种方法可以让通道获取配置了负载均衡器的 service config

  • 当使用 GrpcChannelOptions.ServiceConfig 创建通道时,应用可以指定 service config

  • 或者,解析程序可以为通道解析 service config。 此功能允许外部源指定其调用方应如何执行负载均衡。 解析程序是否支持解析 service config 取决于解析程序实现。 使用 GrpcChannelOptions.DisableResolverServiceConfig 禁用此功能。

  • 如果未提供 service config,或者 service config 没有配置负载均衡器,则通道默认为 PickFirstLoadBalancerFactory

var channel = GrpcChannel.ForAddress(
    "dns:///my-example-host",
    new GrpcChannelOptions
    {
        Credentials = ChannelCredentials.Insecure,
        ServiceConfig = new ServiceConfig { LoadBalancingConfigs = { new RoundRobinConfig() } }
    });
var client = new Greet.GreeterClient(channel);

var response = await client.SayHelloAsync(new HelloRequest { Name = "world" });

 

前面的代码:

  • service config 中指定了 RoundRobinLoadBalancerFactory

  • 启动 gRPC 调用SayHello

    • DnsResolverFactory 创建一个解析程序,用于为主机名 my-example-host 获取地址。

    • 轮循机制负载均衡器尝试连接到所有已解析的地址。

    • gRPC 调用使用轮循机制逻辑均匀分布。

 

配置通道凭据

通道必须知道是否使用传输安全性发送了 gRPC 调用。 httphttps 不再是地址的一部分,方案现在指定一个解析程序,使得在使用负载均衡时必须对通道选项配置 Credentials

  • ChannelCredentials.SecureSsl - gRPC 调用使用ChannelCredentials.SecureSsl 进行保护。 等同于 https 地址。

  • ChannelCredentials.Insecure - gRPC 调用不使用传输安全性。 等同于 http 地址。

var channel = GrpcChannel.ForAddress(
    "dns:///my-example-host",
    new GrpcChannelOptions { Credentials = ChannelCredentials.Insecure });
var client = new Greet.GreeterClient(channel);

var response = await client.SayHelloAsync(new HelloRequest { Name = "world" });

 

 

6. gRPC JSON 转码

gRPC 是一种高性能远程过程调用 (RPC) 框架。 gRPC 使用 HTTP/2、流式传输、Protobuf 和消息协定来创建高性能的实时服务。

gRPC 有一个限制,即不是所有平台都可以使用它。 浏览器并不完全支持 HTTP/2,这使得 REST API 和 JSON 成为将数据引入浏览器应用的主要方式。 尽管 gRPC 带来了很多好处,REST API 和 JSON 在新式应用中仍发挥着重要作用。 构建 gRPC 和 JSON Web API 给应用开发增加了不必要的开销。

gRPC JSON 转码是为 gRPC 服务创建 RESTful JSON API 的 ASP.NET Core 的扩展。 配置转码后,应用可以使用熟悉的 HTTP 调用 gRPC 服务:

  • HTTP 谓词

  • URL 参数绑定

  • JSON 请求/响应

gRPC 仍然可以用来调用服务。

使用步骤

  1. 将包引用添加到 Microsoft.AspNetCore.Grpc.JsonTranscoding

  2. 通过添加 AddJsonTranscoding,在服务器启动代码中注册转码:在 Program.cs 文件中,将 builder.Services.AddGrpc(); 更改为 builder.Services.AddGrpc().AddJsonTranscoding();

  3. 在包含 .csproj 文件的项目目录中创建目录结构 /google/api

  4. google/api/http.protogoogle/api/annotations.proto 文件添加到 /google/api 目录中。

  5. 用 HTTP 绑定和路由在 .proto 文件中注释 gRPC 方法:

syntax = "proto3";

option csharp_namespace = "GrpcServiceTranscoding";
import "google/api/annotations.proto";

package greet;

// 定义Greeter服务
service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {
    option (google.api.http) = {
      get: "/v1/greeter/{name}"
    };
  }
}

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

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

 

SayHello gRPC 方法现在可以作为 gRPC 和 JSON Web API 调用:

  • 请求: GET /v1/greeter/world

  • 响应: { "message": "Hello world" }

HTTP 协议

.NET SDK 中包含的 ASP.NET Core gRPC 服务模板创建仅针对 HTTP/2 配置的应用。 当应用仅支持传统的 gRPC over HTTP/2 时,HTTP/2 是很好的默认设置。 但是,转码同时适用于 HTTP/1.1 和 HTTP/2。 某些平台(如 UWP 或 Unity)无法使用 HTTP/2。 若要支持所有客户端应用,请将服务器配置为启用 HTTP/1.1 和 HTTP/2。

更新 appsettings.json 中的默认协议:

{
  "Kestrel": {
    "EndpointDefaults": {
      "Protocols": "Http1AndHttp2"
    }
  }
}
或者,在启动代码中配置 Kestrel 终结点

builder.WebHost.UseKestrel(p =>
{
    p.ConfigureEndpointDefaults(opt =>
    {
        opt.Protocols = HttpProtocols.Http1AndHttp2;
    });
});

 

HTTP 规则

gRPC JSON 转码从 gRPC 方法创建 RESTful JSON Web API。 它使用用于自定义如何将 RESTful API 映射到 gRPC 方法的注释和选项。

gRPC 方法必须在支持转码之前使用 HTTP 规则进行注释。 HTTP 规则包括有关将 gRPC 方法作为 RESTful API 调用的信息,例如 HTTP 方法和路由。

import "google/api/annotations.proto";

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {
    option (google.api.http) = {
      get: "/v1/greeter/{name}"
    };
  }
}

 

HTTP 规则:

HTTP 方法

通过将路由设置为匹配的 HTTP 方法字段名称来指定 HTTP 方法:

  • get

  • put

  • post

  • delete

  • patch

custom 字段适用于其他 HTTP 方法。

在以下示例中,CreateAddress 方法使用指定的路由映射到 POST

service Address {
  rpc CreateAddress (CreateAddressRequest) returns (CreateAddressReply) {
    option (google.api.http) = {
      post: "/v1/address",
      body: "*"
    };
  }
}

 

生成Swagger

OpenAPI (Swagger) 是一个与语言无关的规范,用于描述 REST API。 gRPC JSON 转码支持从转码的 RESTful API 生成 OpenAPI。 Microsoft.AspNetCore.Grpc.Swagger 包:

  • 将 gRPC JSON 转码与 Swashbuckle 集成。

  • .NET 7 中的实验性是允许我们探索提供 OpenAPI 支持的最佳方式。

若要使用 gRPC JSON 转码启用 OpenAPI,请执行以下操作:

  1. 将包引用添加到 Microsoft.AspNetCore.Grpc.Swagger。 版本必须为 0.3.0-xxx 或更高版本。

  2. 在启动时配置 Swashbuckle。 AddGrpcSwagger 方法将 Swashbuckle 配置为包含 gRPC 终结点。

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc().AddJsonTranscoding(); // JSON 转码
builder.Services.AddGrpcSwagger();
builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1",
        new OpenApiInfo { Title = "gRPC transcoding", Version = "v1" });
});

var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI(c =>
{
    c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1");
});
app.MapGrpcService<GreeterService>();

app.Run();

 

Swagger 文档注释

根据 .proto 协定中的注释生成 OpenAPI 说明,如以下示例所示:

ProtoBuf复制

// My amazing greeter service.
service Greeter {
  // Sends a greeting.
  rpc SayHello (HelloRequest) returns (HelloReply) {
    option (google.api.http) = {
      get: "/v1/greeter/{name}"
    };
  }
}

message HelloRequest {
  // Name to say hello to.
  string name = 1;
}
message HelloReply {
  // Hello reply message.
  string message = 1;
}

 

若要启用 gRPC OpenAPI 注释,请执行以下操作:

  1. 使用 <GenerateDocumentationFile>true</GenerateDocumentationFile> 启用服务器项目中的 XML 文档文件。

  2. 配置 AddSwaggerGen 以读取生成的 XML 文件。 将 XML 文件路径传递到 IncludeXmlCommentsIncludeGrpcXmlComments,如以下示例所示:

builder.Services.AddSwaggerGen(c =>
{

    var filePath = Path.Combine(AppContext.BaseDirectory, 
        $"{Assembly.GetExecutingAssembly().GetName().Name}.xml");
    c.IncludeXmlComments(filePath);
    c.IncludeGrpcXmlComments(filePath, includeControllerXmlComments: true);

}).AddGrpcSwagger();

 

若要确认 Swashbuckle 为 RESTful gRPC 服务生成带说明的 OpenAPI ,请启动应用并导航到 Swagger UI 页面:

 

 

7. gRPC 配置

配置服务选项

gRPC 服务在 Startup.cs 中使用 AddGrpc 进行配置。 配置选项位于 Grpc.AspNetCore.Server 包中。

下表描述了用于配置 gRPC 服务的选项:

选项默认值说明
MaxSendMessageSize null 可以从服务器发送的最大消息大小(以字节为单位)。 尝试发送超过配置的最大消息大小的消息会导致异常。 设置为 null时,消息的大小不受限制。
MaxReceiveMessageSize 4 MB 可以由服务器接收的最大消息大小(以字节为单位)。 如果服务器收到的消息超过此限制,则会引发异常。 增大此值可使服务器接收更大的消息,但可能会对内存消耗产生负面影响。 设置为 null时,消息的大小不受限制。
EnableDetailedErrors false 如果为 true,则当服务方法中引发异常时,会将详细异常消息返回到客户端。 默认值为 false。 将 EnableDetailedErrors 设置为 true 可能会泄漏敏感信息。
CompressionProviders gzip 用于压缩和解压缩消息的压缩提供程序的集合。 可以创建自定义压缩提供程序并将其添加到集合中。 默认已配置提供程序支持 gzip 压缩。
ResponseCompressionAlgorithm null 压缩算法用于压缩从服务器发送的消息。 该算法必须与 CompressionProviders 中的压缩提供程序匹配。 若要使算法可压缩响应,客户端必须通过在 grpc-accept-encoding 标头中进行发送来指示它支持算法。
ResponseCompressionLevel null 用于压缩从服务器发送的消息的压缩级别。
Interceptors None 随每个 gRPC 调用一起运行的侦听器的集合。 侦听器按注册顺序运行。 全局配置的侦听器在为单个服务配置的侦听器之前运行。 侦听器默认为每个请求设置生存期。 将调用侦听器构造函数,并从依赖关系注入 (DI) 解析参数。 还可以向 DI 注册侦听器类型,以重写其创建方式及其生存期。 与 ASP.NET Core 中间件相比,侦听器会提供类似的功能。 有关详细信息,请参阅 gRPC 侦听器与中间件
IgnoreUnknownServices false 如果为 true,则对未知服务和方法的调用不会返回 UNIMPLEMENTED 状态,并且请求会传递到 ASP.NET Core 中的下一个注册中间件。

可以通过在 Startup.ConfigureServices 中向 AddGrpc 调用提供选项委托,为所有服务配置选项:

public void ConfigureServices(IServiceCollection services)
{
    services.AddGrpc(options =>
    {
        options.EnableDetailedErrors = true;
        options.MaxReceiveMessageSize = 2 * 1024 * 1024; // 2 MB
        options.MaxSendMessageSize = 5 * 1024 * 1024; // 5 MB
    });
}

 

用于单个服务的选项会替代 AddGrpc 中提供的全局选项,可以使用 AddServiceOptions<TService> 进行配置:

public void ConfigureServices(IServiceCollection services)
{
    services.AddGrpc().AddServiceOptions<MyService>(options =>
    {
        options.MaxReceiveMessageSize = 2 * 1024 * 1024; // 2 MB
        options.MaxSendMessageSize = 5 * 1024 * 1024; // 5 MB
    });
}

 

服务侦听器默认为每个请求设置生存期。 使用 DI 注册侦听器类型会覆盖创建侦听器的方式及其生存期。

public void ConfigureServices(IServiceCollection services)
{
    services.AddGrpc(options =>
    {
        options.Interceptors.Add<LoggingInterceptor>();
    });
    services.AddSingleton<LoggingInterceptor>();
}

 

配置客户端选项

gRPC 客户端配置在 GrpcChannelOptions 中进行设置。 配置选项位于 Grpc.Net.Client 包中。

下表描述了用于配置 gRPC 通道的选项:

选项默认值说明
HttpHandler 新实例 用于进行 gRPC 调用的 HttpMessageHandler。 可以将客户端设置为配置自定义 HttpClientHandler,或将附加处理程序添加到 gRPC 调用的 HTTP 管道。 如果未指定 HttpMessageHandler,则会通过自动处置为通道创建新 HttpClientHandler 实例。
HttpClient null 用于进行 gRPC 调用的 HttpClient。 此设置是 HttpHandler 的替代项。
DisposeHttpClient false 如果设置为 true 且指定了 HttpMessageHandlerHttpClient,则在处置 GrpcChannel 时,将分别处置 HttpHandlerHttpClient
LoggerFactory null 客户端用于记录有关 gRPC 调用的信息的 LoggerFactory。 可以通过依赖项注入来解析或使用 LoggerFactory.Create 来创建 LoggerFactory 实例。
MaxSendMessageSize null 可以从客户端发送的最大消息大小(以字节为单位)。 尝试发送超过配置的最大消息大小的消息会导致异常。 设置为 null时,消息的大小不受限制。
MaxReceiveMessageSize 4 MB 可以由客户端接收的最大消息大小(以字节为单位)。 如果客户端收到的消息超过此限制,则会引发异常。 增大此值可使客户端接收更大的消息,但可能会对内存消耗产生负面影响。 设置为 null时,消息的大小不受限制。
Credentials null 一个 ChannelCredentials 实例。 凭据用于将身份验证元数据添加到 gRPC 调用。
CompressionProviders gzip 用于压缩和解压缩消息的压缩提供程序的集合。 可以创建自定义压缩提供程序并将其添加到集合中。 默认已配置提供程序支持 gzip 压缩。
ThrowOperationCanceledOnCancellation false 如果设置为 true,则在取消调用或超过其截止时间时,客户端将引发 OperationCanceledException
UnsafeUseInsecureChannelCallCredentials false 如果设置为 true,则 CallCredentials 应用于不安全通道发出的 gRPC 调用。 通过不安全的连接发送身份验证标头具有安全隐患,不应在生产环境中执行。
MaxRetryAttempts 5 最大重试次数。 该值限制服务配置中指定的任何重试和 hedging 尝试值。单独设置该值不会启用重试。 重试在服务配置中启用,可以使用 ServiceConfig 来启用。 null 值会删除最大重试次数限制。 有关重试的详细信息,请参阅故障处理与重试
MaxRetryBufferSize 16 MB 在重试或 hedging 调用时,可用于存储发送的消息的最大缓冲区大小(以字节为单位)。 如果超出了缓冲区限制,则不会再进行重试,并且仅保留一个 hedging 调用,其他 hedging 调用将会取消。 此限制将应用于通过通道进行的所有调用。 值 null 移除最大重试缓冲区大小限制。
MaxRetryBufferPerCallSize 1 MB 在重试或 hedging 调用时,可用于存储发送的消息的最大缓冲区大小(以字节为单位)。 如果超出了缓冲区限制,则不会再进行重试,并且仅保留一个 hedging 调用,其他 hedging 调用将会取消。 此限制将应用于一个调用。 值 null 移除每个调用的最大重试缓冲区大小限制。
ServiceConfig null gRPC 通道的服务配置。 服务配置可以用于配置 gRPC 重试。

下面的代码:

  • 设置通道上发送和接收的最大消息大小。

  • 创建客户端。

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
    });
    var client = new Greeter.GreeterClient(channel);

    var reply = await client.SayHelloAsync(
                      new HelloRequest { Name = "GreeterClient" });
    Console.WriteLine("Greeting: " + reply.Message);
}

 

请注意,未使用 GrpcChannelOptions 配置客户端侦听器。 相反,客户端侦听器是使用带有通道的 Intercept 扩展方法配置的。 此扩展方法位于 Grpc.Core.Interceptors 命名空间中。

static async Task Main(string[] args)
{
    var channel = GrpcChannel.ForAddress("https://localhost:5001");
    var callInvoker = channel.Intercept(new LoggingInterceptor());
    var client = new Greeter.GreeterClient(callInvoker);

    var reply = await client.SayHelloAsync(
                      new HelloRequest { Name = "GreeterClient" });
    Console.WriteLine("Greeting: " + reply.Message);
}

 

 

8. 身份验证和授权

gRPC 可与 ASP.NET Core 身份验证配合使用,将用户与每个调用关联。

以下是使用 gRPC 和 ASP.NET Core 身份验证的 Program.cs 的示例:

app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

app.MapGrpcService<GreeterService>();

 

备注

注册 ASP.NET Core 身份验证中间件的顺序很重要。 始终在 UseRouting 之后和 UseEndpoints 之前调用 UseAuthenticationUseAuthorization

应用在调用期间使用的身份验证机制需要进行配置。 身份验证配置已添加到 Program.cs 中,并因应用使用的身份验证机制而异。

设置身份验证后,可通过 ServerCallContext 使用 gRPC 服务方法访问用户。

 

public override Task<BuyTicketsResponse> BuyTickets(
    BuyTicketsRequest request, ServerCallContext context)
{
    var user = context.GetHttpContext().User;

    // ... access data from ClaimsPrincipal ...
}

 

 

持有者令牌身份验证

客户端可提供用于身份验证的访问令牌。 服务器验证令牌并使用它来标识用户。

在服务器上,使用 JWT 持有者中间件配置持有者令牌身份验证。

在 .NET gRPC 客户端中,令牌可通过 Metadata 集合与调用一起发送。 Metadata 集合中的条目以 HTTP 标头的形式与 gRPC 调用一起发送:

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;
}

 

在通道上配置 ChannelCredentials 是通过 gRPC 调用将令牌发送到服务的备用方法。 ChannelCredentials 可包含 CallCredentials,这使得能够自动设置 MetadataCallCredentials 在每次进行 gRPC 调用时运行,因而无需在多个位置编写代码用于自行传递令牌。

备注

仅当通道通过 TLS 进行保护时,才应用 CallCredentials。 通过不安全的连接发送身份验证标头具有安全隐患,不应在生产环境中执行。 应用可以配置通道以忽略此行为,并通过在通道上设置 CallCredentials 始终使用 UnsafeUseInsecureChannelCallCredentials

以下示例中的凭据将通道配置为随每个 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;
    });

    var channel = GrpcChannel.ForAddress(address, new GrpcChannelOptions
    {
        Credentials = ChannelCredentials.Create(new SslCredentials(), credentials)
    });
    return channel;
}

 

 

gRPC 客户端工厂的持有者令牌

gRPC 客户端工厂可以创建使用 AddCallCredentials 发送持有者令牌的客户端。 此方法在 Grpc.Net.ClientFactory 版本 2.46.0 或更高版本中可用。

传递给 AddCallCredentials 的委托针对每个 gRPC 调用执行:

builder.Services
    .AddGrpcClient<Greeter.GreeterClient>(o =>
    {
        o.Address = new Uri("http://localhost:5001");
    })
    .AddCallCredentials(async (context, metadata) =>
    {
        var serviceProvider = builder.Services.BuildServiceProvider();
        var httpContextAccessor = serviceProvider.GetService<IHttpContextAccessor>();
        // 获取客户端的token
        var accessToken = await httpContextAccessor.HttpContext.GetTokenAsync("access_token");
        if (!string.IsNullOrEmpty(accessToken))
        {
            metadata.Add("Authorization", $"Bearer {accessToken}");
        }
    }).ConfigureChannel(o => o.UnsafeUseInsecureChannelCallCredentials = true);

 

依赖项注入 (DI) 可以与 AddCallCredentials 结合使用。 重载将 IServiceProvider 传递给委托,该委托可用于获取使用范围内服务和暂时性服务从 DI 构建的服务

请考虑具有以下特征的应用:

  • 用于获取持有者令牌的用户定义的 ITokenProvider。 在具有作用域生存期的 DI 中注册 ITokenProvider

  • gRPC 客户端工厂配置为创建注入到 gRPC 服务和 Web API 控制器中的客户端。

  • gRPC 调用应使用 ITokenProvider 获取持有者令牌。

public interface ITokenProvider
{
    Task<string> GetTokenAsync();
}

public class AppTokenProvider : ITokenProvider
{
    private string _token;

    public async Task<string> GetTokenAsync()
    {
        if (_token == null)
        {
            // App code to resolve the token here.
        }

        return _token;
    }
}
builder.Services.AddScoped<ITokenProvider, AppTokenProvider>();

builder.Services
    .AddGrpcClient<Greeter.GreeterClient>(o =>
    {
        o.Address = new Uri("https://localhost:5001");
    })
    .AddCallCredentials(async (context, metadata, serviceProvider) =>
    {
        var provider = serviceProvider.GetRequiredService<ITokenProvider>();
        var token = await provider.GetTokenAsync();
        metadata.Add("Authorization", $"Bearer {token}");
    }));

 

前面的代码:

  • 定义 ITokenProviderAppTokenProvider。 这些类型处理解析 gRPC 调用的身份验证令牌。

  • 在范围内的生存期内向 DI 注册 AppTokenProvider 类型。 AppTokenProvider 缓存令牌,以便只需要范围内的第一个调用来计算它。

  • 向客户端工厂注册 GreeterClient 类型。

  • 为此客户端配置 AddCallCredentials。 每次发出调用并将 ITokenProvider 返回的令牌添加到元数据时,都会执行委托。

 

客户端证书身份验证

客户端还可以提供用于身份验证的客户端证书。 证书身份验证在 TLS 级别发生,远在到达 ASP.NET Core 之前。 当请求进入 ASP.NET Core 时,可借助客户端证书身份验证包将证书解析为 ClaimsPrincipal

备注

将服务器配置为接受客户端证书。 有关在 Kestrel、IIS 和 Azure 中接受客户端证书的信息,请参阅在 ASP.NET Core 中配置证书身份验证

在 .NET gRPC 客户端中,客户端证书已添加到 HttpClientHandler 中,后者之后用于创建 gRPC 客户端:

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);
}

 

授权用户访问服务

默认情况下,未经身份验证的用户可以调用服务中的所有方法。 若要要求进行身份验证,请将 [Authorize]特性应用于服务:

[Authorize]
public class TicketerService : Ticketer.TicketerBase
{
}

 

可使用 [Authorize] 特性的构造函数参数和属性将访问权限仅限于匹配特定授权策略的用户。 例如,如果有一个名为 MyAuthorizationPolicy 的自定义授权策略,请使用以下代码确保仅匹配该策略的用户才能访问服务:

[Authorize("MyAuthorizationPolicy")]
public class TicketerService : Ticketer.TicketerBase
{
}

 

各个服务方法也可以应用 [Authorize] 特性。 如果当前用户与同时应用于方法和类的策略不匹配,则会向调用方返回错误:

[Authorize]
public class TicketerService : Ticketer.TicketerBase
{
    public override Task<AvailableTicketsResponse> GetAvailableTickets(
        Empty request, ServerCallContext context)
    {
        // ... buy tickets for the current user ...
    }

    [Authorize("Administrators")]
    public override Task<BuyTicketsResponse> RefundTickets(
        BuyTicketsRequest request, ServerCallContext context)
    {
        // ... refund tickets (something only Administrators can do) ..
    }
}
 

 

2. Feign 组件

github 文档:SummerBoot/README.zh-cn.md at master · TripleView/SummerBoot · GitHub

需要安装包:

SummerBoot 2.0.2 

SummerBoot 是将SpringBoot的先进理念与C#的简洁优雅合二为一,声明式编程,专注于”做什么”而不是”如何去做”。在更高层面写代码,更关心的是目标,而不是底层算法实现的过程,SummerBoot,致力于打造一个人性化框架,让.net开发变得更简单优雅。

SummerBoot 框架中内容较多,我们重点使用框架中提供的Feign组件。

个人觉得比gRPC 使用更简单一点,个人还是比较推荐

 

1. Feign 简介

Feign 是一种声明式服务调用组件 , 我们只需要声明一个接口并通过注解进行简单的配置(类似于 Dao 接口上面的 Mapper 注解一样)即可实现对 HTTP 接口的绑定。 通过 Feign,我们可以像调用本地方法一样来调用远程服务,而完全感觉不到这是在进行远程调用。

它(Feign)能做什么?

  • 自定义拦截器(AOP)

  • 封装了Http远程调用过程

  • 微服务接入Nacos

  • 可结合Polly做降级处理

  • 结合JWT做授权与鉴权

 

请求方式

方式特性(注解)
HttpGet [GetMapping]
HttpPut [PutMapping]
HttpDelete [DeleteMapping]
HttpPost [PostMapping]

 

2. 如何使用

封装Http调用

feign底层基于httpClient。

1.注册服务

builder.Services.AddSummerBoot();
builder.Services.AddSummerBootFeign();

 

2.定义接口

[FeignClient(Url = "http://localhost:5061")]
public interface UserClient
{
    [GetMapping("/User/GetUserList")]
    Task<List<UserInfo>> GetUserList();
}

 

提示

Feign 组件会由FeignProxyBuilder类自动为接口生成实现代理类,注意,此版本中接口必须定义为Task<> 异步方法。

定义一个接口,并且在接口上添加FeignClient注解,FeignClient注解里可以自定义http接口url的公共部分-url(整个接口请求的url由FeignClient里的url加上方法里的path组成),是否忽略远程接口的https证书校验-IsIgnoreHttpsCertificateValidate,接口超时时间-Timeout(单位s),自定义拦截器-InterceptorType。

[FeignClient(
    Url = "http://localhost:5001/home"
    , IsIgnoreHttpsCertificateValidate = true
    , InterceptorType = typeof(MyRequestInterceptor)
    ,Timeout = 100)
]
public interface ITestFeign
{
   [GetMapping("/query")]
   Task<Test> TestQuery([Query] Test tt);
    
   [GetMapping("/addTest")]
   Task<Test> AddTest([Body] TestBo bo);
   
}

 

[Body] 请求体中支持Json与Form两种格式,默认为Json, 如果需要设置为Form提交,则修改为:

[PostMapping("/User/AddUser")]
Task<List<UserInfo>> AddUser([Body(BodySerializationKind.Form)] UserInfo bo);

 

3.服务调用

[Route("[controller]/[action]")]
[ApiController]
public class RpcController:ControllerBase
{
    private readonly IUserClient _userClient;

    public RpcController( IUserClient userClient)
    {
        _userClient = userClient;
    }

    
    [HttpGet]
    public async Task<List<UserInfo>> GetUserList()
    {
        return await _userClient.GetUserList();
    }
}

 

 

读取配置

同时,url和path可以通过读取配置获取,配置项通过${}包裹,配置的json如下:

{
  "configurationTest": {
    "url": "http://localhost:5001/home",
    "path": "/query"
  }
}

 

接口如下:

[FeignClient(Url = "${configurationTest:url}")]
public interface ITestFeignWithConfiguration
{
        [GetMapping("${configurationTest:path}")]
        Task<Test> TestQuery([Query] Test tt);
}
 

 

3. 设置请求头(header)

接口上可以选择添加Headers注解,代表这个接口下所有http请求都带上注解里的请求头。Headers的参数为变长的string类型的参数,同时Headers也可以添加在方法上,代表该方法调用的时候,会加该请求头,接口上的Headers参数可与方法上的Headers参数互相叠加,同时headers里可以使用变量,变量的占位符为{{}},如

[FeignClient(Url = "http://localhost:5001/home", IsIgnoreHttpsCertificateValidate = true, InterceptorType = typeof(MyRequestInterceptor),Timeout = 100)]
[Headers("a:a","b:b")]
public interface ITestFeign
{
    [GetMapping("/testGet")]
    Task<Test> TestAsync();
    
    [GetMapping("/testGetWithHeaders")]
    [Headers("c:c")]
    Task<Test> TestWithHeadersAsync();
    
    //header替换
    [Headers("a:{{methodName}}")]
    [PostMapping("/abc")]
    Task<Test> TestHeaderAsync(string methodName);
}

await TestFeign.TestAsync()
>>> get, http://localhost:5001/home/testGet,header为 "a:a" 和 "b:b"

await TestFeign.TestWithHeadersAsync()
>>> get, http://localhost:5001/home/testGetWithHeaders,header为 "a:a" ,"b:b"和 "c:c"

await TestFeign.TestHeaderAsync("abc");
>>> post, http://localhost:5001/home/abc,同时请求头为 "a:abc"
[Headers("a:a","b:b")]

冒号左边是键名称,右边是键值

 

 

4. 自定义拦截器

自定义拦截器对接口下的所有方法均生效,拦截器的应用场景主要是在请求前做一些操作,比如请求第三方业务接口前,需要先登录第三方系统,那么就可以在拦截器里先请求第三方登录接口,获取到凭证以后,放到header里,拦截器需要实现IRequestInterceptor接口,例子如下

//先定义一个用来登录的loginFeign客户端
 [FeignClient(Url = "http://localhost:5001/login", IsIgnoreHttpsCertificateValidate = true,Timeout = 100)]
    public interface ILoginFeign
    {
        [PostMapping("/login")]
        Task<LoginResultDto> LoginAsync([Body()] LoginDto loginDto );
    }
    
//接着自定义登录拦截器
public class LoginInterceptor : IRequestInterceptor
{

    private readonly ILoginFeign loginFeign;
    private readonly IConfiguration configuration;

    public LoginInterceptor(ILoginFeign loginFeign, IConfiguration configuration)
    {
        this.loginFeign = loginFeign;
        this.configuration = configuration;
    }
    

    public async Task ApplyAsync(RequestTemplate requestTemplate)
    {
        var username = configuration.GetSection("username").Value;
        var password = configuration.GetSection("password").Value;

        var loginResultDto = await this.loginFeign.LoginAsync(new LoginDto(){Name = username,Password = password});
        if (loginResultDto != null)
        {
            requestTemplate.Headers.Add("Authorization", new List<string>() { "Bearer "+loginResultDto.Token });
        }

        await Task.CompletedTask;
    }
}

//定义访问业务接口的testFegn客户端,在客户端上定义拦截器为loginInterceptor
[FeignClient(Url = "http://localhost:5001/home", IsIgnoreHttpsCertificateValidate = true, InterceptorType = typeof(LoginInterceptor),Timeout = 100)]
public interface ITestFeign
{
    [GetMapping("/testGet")]
    Task<Test> TestAsync();
}

await TestFeign.TestAsync();
>>> get to http://localhost:5001/home/testGet,header为 "Authorization:Bearer abc"
 

 

忽略拦截器

有时候我们接口中的某些方法,是不需要拦截器的,那么就可以在方法上添加注解IgnoreInterceptor,那么该方法发起的请求,就会忽略拦截器,如

//定义访问业务接口的testFegn客户端,在客户端上定义拦截器为loginInterceptor
[FeignClient(Url = "http://localhost:5001/home", IsIgnoreHttpsCertificateValidate = true, InterceptorType = typeof(LoginInterceptor),Timeout = 100)]
public interface ITestFeign
{
    [GetMapping("/testGet")]
    [IgnoreInterceptor]
    Task<Test> TestAsync();
}

await TestFeign.TestAsync();
>>> get to http://localhost:5001/home/testGet,没有header

 

 

5. 微服务-接入nacos

目前Feign组件只支持Nacos。

1.配置文件里添加nacos配置

注意:这里的nacos 配置与原生的nacos配置不太一样,SummberBoot会集成Nacos服务注册,所以若考虑接入SummberBoot中的Nacos,则原生的Nacos服务注入则应去掉。

 "nacos": {
    //--------使用nacos则serviceAddress和namespaceId必填------
    //nacos服务地址,如http://172.16.189.242:8848
    "serviceAddress": "http://172.16.189.242:8848/",
    //命名空间id,如832e754e-e845-47db-8acc-46ae3819b638或者public
    "namespaceId": "dfd8de72-e5ec-4595-91d4-49382f500edf",

    //--------如果只是访问nacos中的微服务,则仅配置lbStrategy即可,defaultNacosGroupName和defaultNacosNamespaceId选填------
       //客户端负载均衡算法,一个服务下有多个实例,lbStrategy用来挑选服务下的实例,默认为Random(随机),也可以选择WeightRandom(根据服务权重加权后再随机)
       "lbStrategy": "Random",
       //defaultNacosGroupName,选填,为FeignClient注解中NacosGroupName的默认值,为空则默认为DEFAULT_GROUP
       "defaultNacosGroupName": "",
       //defaultNacosNamespaceId,选填,为FeignClient注解中NacosNamespaceId的默认值,为空则默认为public
       "defaultNacosNamespaceId": "",

     //--------如果需要使用nacos配置中心,则ConfigurationOption必填,允许监听多个配置------
    "configurationOption": [
      {
        "namespaceId": "f3dfa56a-a72c-4035-9612-1f9a8ca6f1d2",
        //配置的分组
        "groupName": "DEFAULT_GROUP",
        //配置的dataId,
        "dataId": "def"
      },
      {
        "namespaceId": "public",
        //配置的分组
        "groupName": "DEFAULT_GROUP",
        //配置的dataId,
        "dataId": "abc"
      }
    ],


    //-------如果是要将本应用注册为服务实例,则全部参数均需配置--------------

    //是否要把应用注册为服务实例
    "registerInstance": true,

    //要注册的服务名
    "serviceName": "test",
    //服务的分组名
    "groupName": "DEFAULT_GROUP",
    //权重,一个服务下有多个实例,权重越高,访问到该实例的概率越大,比如有些实例所在的服务器配置高,那么权重就可以大一些,多引流到该实例,与上面的参数lbStrategy设置为WeightRandom搭配使用
    "weight": 1,
    //本应用对外的网络协议,http或https
    "protocol": "http",
    //本应用对外的端口号,比如5000
    "port": 5000

  }

 

2.接入nacos服务中心

// 抛弃原生Nacos注册
// builder.Services.AddNacosAspNet(builder.Configuration);
builder.Services.AddSummerBoot();
builder.Services.AddSummerBootFeign(p =>
{
    p.AddNacos(builder.Configuration); // Feign组件接入Nacos
});

 

3.定义接口

namespace NetCloud.RpcClient
{
    [FeignClient(
            ServiceName = "NetCloud.Nacos.UserService"
            , NacosNamespaceId = "NetCloud"
            , NacosGroupName = "DEFAULT_GROUP"
            , MicroServiceMode = true
        )
    ]
    
    public interface IUserServiceClient
    {
        [GetMapping("/User/GetUserList")]
        Task<List<UserInfo>> GetUserList();
    }
}

 

同时ServiceName,NacosGroupName,NacosNamespaceId也支持从配置文件中读取,如

{
    "ServiceName": "NetCloud.Nacos.UserService",
    "NacosGroupName": "DEFAULT_GROUP",
    "NacosNamespaceId": "NetCloud"
}
[FeignClient( ServiceName = "${ServiceName}", MicroServiceMode = true,NacosGroupName = "${NacosGroupName}", NacosNamespaceId = "${NacosNamespaceId}")]
public interface IFeignService
{
        [GetMapping("/home/index")]
        Task<string> TestGet();
}
 

 

视频配套链接:课程简介 (cctalk.com)