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

繼續探索

延伸閱讀

更多文章
同分類 / 同標籤 2024/1/19

基於 .NET 的 FluentValidation 驗證教學

FluentValidation 是一個基於 .NET 開發的驗證框架,開源免費,而且優雅,支援鏈式操作,易於理解,功能完善,還可與 MVC5、WebApi2 和 ASP.NET CORE 深度整合,組件內提供十幾種常用驗證器,可擴展性好,支援自訂驗證器,支援本地化多語言。

繼續閱讀