grpc入門與實操(.net篇)

grpc入門與實操(.net篇)

長久以來,我們在前後端交互時使用webapi + json方式,後端服務之間調用同樣如此

最后更新 2023/1/11 下午8:47
莱布尼茨
预计阅读 13 分钟
分类
.NET
标签
.NET C# Web API

1. 為什麼選擇 grpc

1.1.歷史

长久以来,我们在前后端交互时使用WebApi + JSON方式,后端服务之间调用同样如此(或者更久远之前的WCF + XML方式)。WebApi + JSON 是优选的,很重要的一点是它们两者都是平台无关的三方标准,且足够语义化,便于程序员使用,在异构(前后端、多语言后端)交互场景下是不二选择。然而,在后端服务体系改进特别是后来微服务兴起后,我们发现,前后端交互理所当然认可的 WebApi + JSON 在后端体系内显得有点不太合适:

  1. json 字符編碼方式使得傳輸數據量較大,而後端一般並不需要直接操作 json,都會將 json 轉為平台專有類型後再處理;既然需要轉換,為什麼不選擇一個數據量更小,轉換更方便的格式呢?
  2. 調用雙方要事先約定數據結構和調用接口,稍有變動就要手動更新相關代碼(model 類和方法簽名);是否可以將約定固化為文檔,服務提供者維護該文檔,調用方根據該文檔可以方便地生成自己需要的代碼,在文檔變化時代碼也可以自動更新?
  3. [之前] webapi 基於的 http[1.1] 協議已經誕生 20 多年,其定義的交互模式在今日已經捉襟見肘;業界需要一個更有效率的協議。

1.2.高效傳輸-http2.0

我们先来说第 3 个问题,其实很多大厂内部早已开始着手处理,并诞生了一些应用广泛的框架,如阿里开源的Dubbo,直接抛弃了 Http 改为基于 Tcp 实现,效率得到明显提升,不过 Dubbo 依赖 Java 环境,无法跨平台使用,不在我们考虑范围。

另一个大厂 Google,内部也在长期使用自研的Stubby框架,与 Dubbo 不同的是,Studdy 是跨平台的,但是 Google 认为 Studdy 不基于任何标准,而且与其内部基础设施紧密耦合,并不适合公开发布。

同時 google 也在對 http1.1 協議進行增強,該項目是 2012 年提出的 spdy 方案,其優化了 http 協議層,新增的功能包括數據流的多路復用、請求優先級以及 http 報頭壓縮。google 表示,引入 spdy 協議後,在實驗室測試中頁面加載速度比原先快 64%。巨大的提升讓大家開始從正面看待和解決老版本 http 協議的問題,這也直接加速了 http2.0 的誕生。實際上,http2.0 是以 spdy 為原型進行討論和標準化的,當然也做了更多的改進和調整。

隨著 http2.0 的出現和普及,許多與 stubby 相同的功能已經出現在公共標準中,包括 stubby 未提供的其他功能。很明顯,是時候重做 stubby 以利用這種標準化,並將其適用範圍擴展到分布式計算的最後一英里,支持行動裝置(如安卓)、物聯網(iot)、和瀏覽器連接到後端服務。

2015 年 3 月,Google 决定在公开场合构建下一版 Stubby,以便与业界分享经验,并进行相关合作,也就是本文的主角gRPC

1.3.高效編碼-protobuf

回头来看第 1 个问题,解决起来相对比较简单,无非是将傻瓜式字符编码转为更有效的二进制编码(比如数字 10000 JSON 编码后是 5 个字节,按整型编码就是 4 个字节),同时加上些事先约定的编码算法使得最终结果更紧凑。常见的平台无关的编码格式有MessagePackprotobuf等,我们以 protobuf 为例。

protobuf 采用 varint 和 处理负数的 ZigZag 两种编码方式使得数值字段占用空间大大减少;同时它约定了字段类型和标识,采用 TLV 方式,将字段名映射为小范围结果集中的一项(比如对于不超过 256 个字段的数据体来说,不管字段名本身的长度多少,每个字段名都只要 1 个字节就能标识),同时移除了分隔符,并且可以过滤空字段(若字段没有被赋值,那么该字段不会出现在序列化结果中)。

1.4.高效編程-代碼生成工具

第 2 个问题呢,其实需要的就是[每个平台]一套代码生成工具。生成的代码需要覆盖类的定义、对象的序列化/反序列化、服务接口的暴露和远程调用等等必要的模板代码,如此,开发人员只需要负责接口文档的维护和业务代码的实现(很自然的面向接口编程:))。此时,采用 protobuf 的gRPC自然而然的映入眼帘,因为对于目前所有主要的编程语言和平台,都有 gRPC 工具和库,包括 .NET、Java、Python、Go、C++、Node.js、Swift、Dart、Ruby 以及 PHP。可以说,这些工具和库的提供,使得 gRPC 可以跨多种语言和平台一致地工作,成为一个全面的 RPC 解决方案。

2. grpc 在 .net 中的使用

ASP.NET Core 3.0 开始,支持gRPC作为 .NET 平台中的“一等公民”。

2.1.服務端

在 VS 中新建ASP.NET Core gRPC 服务,会发现在项目文件中自动引入了Microsoft.NET.Sdk.Web类库,很明显,gRPC 服务仍然是 Web 服务,毕竟它走的是 Http 协议。同时还引入了Grpc.AspNetCore类库,该类库引用了几个子类库需要了解下:

  • Google.Protobuf:包含 protobuf 预定义 message 类型在 C# 中的实现;
  • Grpc.Tools:上面讲到的代码生成工具,编译时使用,运行时不需要,因此依赖项标记为 PrivateAssets="All";
  • Grpc.AspNetCore.Server:服务端专用;
  • Grpc.Net.ClientFactory:客户端专用,如果只是提供服务的话,那么该类库可以移除。

定義接口文件:

syntax = "proto3";

// 指定自动生成的类所在的命名空间,如果不指定则以下面的 package 为命名空间,这主要便于本项目内部的模块划分
option csharp_namespace = "Demo.Grpc";

// 对外提供服务的命名空间
package TestDemo;

// 服务
service Greeter {
  // 接口
  rpc SayHello (HelloRequest) returns (HelloReply);
}

// 不太好的一点是就算只有一个基础类型字段,也要新建一个 message 进行包装
message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

然後把它包含到項目文件中:

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

編譯一下,grpc.tools 將幫我們生成 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; }
}

這裡的 sayhello 是個空實現,我們新建一個實現類並填充業務邏輯,比如:

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

最後將服務添加到路由管道,對外暴露:

using Demo.Grpc.Services;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddGrpc();

var app = builder.Build();

// Configure the HTTP request pipeline.
app.MapGrpcService<GreeterService>();

app.Run();

2.1.1. protobuf-net.Grpc

如果觉得写 .proto 文件太别扭,希望可以按传统方式写接口,那么社区项目protobuf-net.Grpc值得尝试,使用它可以它通过特性批注的 .NET 类型来定义应用的 gRPC 服务和消息。

首先我們不需要再引用 grpc.aspnetcore,而是改為引用 protobuf-net.grpc 庫。同樣也不需要寫 .proto 文件,而是直接寫接口類:

using ProtoBuf.Grpc;
using System.Runtime.Serialization;
using System.ServiceModel;
using System.Threading.Tasks;

namespace Demo.Grpc;

[DataContract]
public class HelloReply
{
    [DataMember(Order = 1)]
    public string Message { get; set; }
}

[DataContract]
public class HelloRequest
{
    [DataMember(Order = 1)]
    public string Name { get; set; }
}

[ServiceContract(Name = "TestDemo.GreeterService")]
public interface IGreeterService
{
    [OperationContract]
    Task<HelloReply> SayHelloAsync(HelloRequest request, CallContext context = default);
}

注意其中特性的修飾。

寫完實現類後,在 program.cs 中註冊即可,此處不再贅述。

使用 protobuf-net.Grpc,我们不需要写 .proto 文件,但是调用方特别是其他平台的调用方,需要 .proto 文件来生成相应的客户端,难道我们还要另外再写一份吗?别急,我们可以引入protobuf-net.Grpc.AspNetCore.Reflection,它引用的protobuf-net.Grpc.Reflection提供了根据 C# 接口生成 .proto 文件的方法;同时使用它还便于客户端测试,同Grpc.AspNetCore.Server.Reflection的作用一样,下文会讲到。

2.1.2.異常處理

net 為 grpc 提供了攔截器機制,可新建一個攔截器統一處理業務異常,比如:

public class GrpcGlobalExceptionInterceptor : Interceptor
{
    private readonly ILogger<GrpcGlobalExceptionInterceptor> _logger;

    public GrpcGlobalExceptionInterceptor(ILogger<GrpcGlobalExceptionInterceptor> logger)
    {
        _logger = logger;
    }

    public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
        TRequest request,
        ServerCallContext context,
        UnaryServerMethod<TRequest, TResponse> continuation)
    {
        try
        {
            return await continuation(request, context);
        }
        catch (Exception ex)
        {
            _logger.LogError(new EventId(ex.HResult), ex, ex.Message);

            // do something

            // then you can choose throw the exception again
            throw ex;
        }
    }
}

上述代码在处理完异常后重新抛出,旨在让客户端接收处理该异常,然而,实际上客户端是无法接收到该异常信息的,除非服务端抛出的是RpcException;同时,为使客户端得到正确的 HttpStatusCode(默认是 200,即使客户端得到是 RpcException),需要显式给HttpContext.Response.StatusCode赋值,如下:

// ...

catch(Exception ex)
{
    var httpContext = context.GetHttpContext();
    httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;

    // 注意,RpcException 的 StatusCode 和 Http 的 StatusCode 不是一一对应的
    throw new RpcException(new Status(StatusCode.XXX, "some messages"));
}

// ...

我们可以在构造 RpcException 对象时传递Metadata,用于携带额外的数据到客户端,如果需要传递复杂对象,那么要先按约定序列化成字节数组。

攔截器邏輯完成後,需要在服務注入時設置如下:

builder.Services.AddGrpc(options =>
{
    options.Interceptors.Add<GrpcGlobalExceptionInterceptor>();
});

2.1.3.測試

服务端完成后,如果要借助Postman或者gRPCurl测试,那么它们其实就是调用服务的客户端,要让它们事先知道服务约定信息,有两种方法:

  1. 給它們提供 .proto 文件,這個很好理解,關於服務的所有信息就定義在 .proto 文件中;
  2. 服務端暴露一個可以獲取服務信息的接口。

如果要用方法 2,那么要先引入Grpc.AspNetCore.Server.Reflection类库,然后在 Program.cs 中注册接口:

// ...
builder.Services.AddGrpcReflection();

var app = builder.Build();

// ...

IWebHostEnvironment env = app.Environment;

if (env.IsDevelopment())
{
    app.MapGrpcReflectionService();
}

2.2.客戶端

客戶端不需要 grpc.aspnetcore.server,所以我們直接引用 google.protobuf、grpc.tools、grpc.net.clientfactory。

將服務端提供的 .proto 文件添加到項目中,並在項目文件中包含:

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

注意,如果只需要服務端提供的部分接口,那麼 .proto 文件中只保留必要的接口即可,真正做到按需索取:)。

我們還可以更改 .proto 文件中 message 的欄位名(只要不改動欄位類型和順序),不會影響服務的調用。這也直接反映了 protobuf 不是按欄位名而是事先定義的欄位標識編碼的。

由此,假如我们有多个 .proto 文件,使用到了相同结构的 message,无所谓字段名是否相同,我们都可以将这些 message 抽离为单独的一个 .proto 文件,然后其他的 .proto 文件使用import "Protos/xxx.proto";引入它。

編譯一下,然後在 program.cs 中註冊服務客戶端:

// .proto 文件中的 package
using TestDemo;

// 这里注入的服务是 Transient 模式
builder.Services.AddGrpcClient<Greeter.GreeterClient>(o =>
{
    o.Address = new Uri("https://localhost:5001");
});

如此,其他地方就可以愉快地使用客戶端調用遠程服務了。

同服務端一樣,我們可以給客戶端配置統一的攔截器。如果服務端返回上文提到的 rpcexception,客戶端得到後是直接拋出的(就像是本地異常),我們可以新建一個專門的異常攔截器處理 rpcexception 異常。

builder.Services
    .AddGrpcClient<Greeter.GreeterClient>(o =>
    {
        o.Address = new Uri("https://localhost:5001");
    })
    .AddInterceptor<ExceptionInterceptor>();  // 默认创建一次,并在 GreeterClient 实例之间共享
    //.AddInterceptor<ExceptionInterceptor>(InterceptorScope.Client); // 每个 GreeterClient 实例拥有自己的拦截器

具体的异常处理逻辑就不举例了。提一下,通过 RpcException.Trailers 可以获取异常的 metadata 数据。

另外,对于异常处理来说,如果项目是普通的 ASP.NET Core Web 服务,那么使用原先的 ActionFilterAttributeIExceptionFilter等拦截器也是一样的,因为既然运行时出现了异常,这两者肯定也能捕获到。

2.3.進階知識

本文未涉及的 .NET-gRPC 的进阶知识诸如单元测试服务调用中止负载均衡健康监控等,以后有机会再与大家分享。其实这方面微软官方文档已经讲解得相当全面了,但也难以覆盖在实操过程中遇到的所有问题,所以有此文以飨读者,还望不吝指教。

3. 參考資料

本文來自轉載。

作者:萊布尼茨

原文標題:grpc 入門與實操(.net 篇)

原文連結:https://www.cnblogs.com/newton/archive/2023/01/10/17033789.html

Keep Exploring

延伸阅读

更多文章
同分类 / 同标签 2024/1/19

基於 .net 的 fluentvalidation 驗證教程

fluentvalidation 是一個基於 .net 開發的驗證框架,開源免費,而且優雅,支持鏈式操作,易於理解,功能完善,還是可與 mvc5、webapi2 和 asp.net core 深度集成,組件內提供十幾種常用驗證器,可擴展性好,支持自定義驗證器,支持本地化多語言。

继续阅读