1. gRPCを選ぶ理由
1.1. 歴史
長らく、フロントエンドとバックエンドのやり取りには WebApi + JSON 方式を、バックエンドサービス間の呼び出しにも同様の方式(またはそれ以前の WCF + XML 方式)を使用してきました。WebApi + JSON は好ましい選択肢であり、重要な点として、どちらもプラットフォームに依存しないサードパーティ標準であり、十分にセマンティックでプログラマにとって使いやすく、異種(フロントエンド/バックエンド、多言語バックエンド)間のやり取りにおいて最適な選択肢です。しかし、バックエンドサービス体系の改善、特に後のマイクロサービスの台頭により、フロントエンド/バックエンド間のやり取りで当然のように認められていた WebApi + JSON が、バックエンド体系内ではやや不適切であることが判明しました。
- JSON の文字エンコード方式では転送データ量が大きくなりがちですが、バックエンドでは通常 JSON を直接操作する必要はなく、JSON をプラットフォーム固有の型に変換してから処理します。変換が必要ならば、データ量がより少なく、変換がより便利なフォーマットを選ぶべきではないでしょうか。
- 呼び出し元と呼び出し先は事前にデータ構造と呼び出しインターフェースを取り決める必要があり、少しでも変更があれば関連コード(Model クラスやメソッドシグネチャ)を手動で更新しなければなりません。契約をドキュメントに固定し、サービス提供者がそのドキュメントを保守し、呼び出し元がそのドキュメントに基づいて必要なコードを簡単に生成でき、ドキュメントが変更されたときにコードも自動更新できるようにできないでしょうか。
- [以前] WebApi が基づいていた Http[1.1] プロトコルは誕生から20年以上が経過しており、その定義するインタラクションモデルは今日では不十分です。業界はより効率的なプロトコルを必要としています。
1.2. 効率的な転送 - Http2.0
まず3つ目の問題について考えます。実は多くの大手企業は社内で既に取り組みを始めており、Alibaba がオープンソース化した Dubbo など、広く使われるフレームワークがいくつか生まれています。Dubbo は Http を直接捨てて Tcp ベースで実装されており、効率が顕著に向上しました。ただし、Dubbo は Java 環境に依存し、クロスプラットフォームでは使用できないため、検討対象外です。
もう一つの大手企業 Google も、社内で長年自社開発の Stubby フレームワークを使用していました。Dubbo と異なり、Stubby はクロスプラットフォームですが、Google は Stubby が標準に基づいておらず、社内インフラと密結合しているため、公開リリースには適さないと考えていました。
同時に Google は Http1.1 プロトコルの拡張にも取り組んでおり、そのプロジェクトは2012年に提案された SPDY 方式です。これは Http プロトコル層を最適化し、データストリームの多重化、リクエストの優先順位付け、HTTP ヘッダー圧縮などの機能を追加しました。Google によると、SPDY プロトコルの導入により、実験室テストではページ読み込み速度が従来より64%向上しました。この大きな改善により、人々は旧バージョンの Http プロトコルの問題を正面から見て解決するようになり、これが Http2.0 の誕生を直接加速させました。実際、Http2.0 は SPDY をプロトタイプとして議論・標準化され、もちろんさらに多くの改善と調整が加えられています。
Http2.0 の登場と普及に伴い、Stubby と同じ機能の多くが公共標準に含まれるようになり、Stubby が提供していなかった他の機能も含まれています。明らかに、この標準化を活用し、適用範囲を分散コンピューティングのラストマイルまで拡大し、モバイルデバイス(Android など)、IoT(モノのインターネット)、ブラウザがバックエンドサービスに接続できるように Stubby を再構築する時期が来ていました。
2015年3月、Google は Stubby の次世代バージョンを公開で構築し、業界と経験を共有し、関連する協力を行うことを決定しました。これが本記事の主役である gRPC です。
1.3. 効率的なエンコード - protobuf
1つ目の問題に戻ると、解決は比較的簡単です。単純な文字エンコードをより効率的なバイナリエンコード(例えば、数値10000を JSON エンコードすると5バイトですが、整数エンコードでは4バイト)に置き換え、さらに事前に合意されたエンコードアルゴリズムを追加して最終結果をよりコンパクトにします。一般的なプラットフォーム非依存のエンコードフォーマットには MessagePack や protobuf などがあります。ここでは protobuf を例に取ります。
protobuf は varint と負数を扱う ZigZag の2つのエンコード方式を採用し、数値フィールドの占有空間を大幅に削減します。また、フィールドタイプと識別子を規定し、TLV 方式を採用してフィールド名を小範囲の結果集合内の1つの項目にマッピングします(例えば、256フィールド未満のデータ本体の場合、フィールド名自体の長さに関わらず、各フィールド名は1バイトで識別可能)。同時に区切り文字を除去し、空のフィールドをフィルタリングできます(フィールドに値が代入されていない場合、そのフィールドはシリアライズ結果に現れません)。
1.4. 効率的なプログラミング - コード生成ツール
2つ目の問題には、実際には[各プラットフォーム向けの]コード生成ツール一式が必要です。生成されるコードは、クラス定義、オブジェクトのシリアライズ/デシリアライズ、サービスインターフェースの公開とリモート呼び出しなど、必要なテンプレートコードをカバーする必要があります。これにより、開発者はインターフェースドキュメントの保守とビジネスコードの実装(自然なインターフェース指向プログラミング)だけを担当すればよくなります。この時点で、protobuf を使用する gRPC が自然に目に留まります。なぜなら、現在主要なプログラミング言語とプラットフォーム(.NET、Java、Python、Go、C++、Node.js、Swift、Dart、Ruby、PHP を含む)向けに gRPC のツールとライブラリが用意されているからです。これらのツールとライブラリの提供により、gRPC は多様な言語とプラットフォームにわたって一貫して動作し、包括的な RPC ソリューションとなっています。
2. .NET における gRPC の使用
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);
}
// 基本型のフィールドが1つだけでも、新しい message を作成してラップする必要があるのはやや不便です。
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
次に、これをプロジェクトファイルに含めます。
<ItemGroup>
<Protobuf Include="Protos\greeter.proto" GrpcServices="Server" />
</ItemGroup>
コンパイルすると、Grpc.Tools が GreeterBase クラスと2つのモデルクラスを生成します。
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);
// コンテナにサービスを追加
builder.Services.AddGrpc();
var app = builder.Build();
// HTTP リクエストパイプラインを構成
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);
// 何らかの処理
// その後、例外を再度スローすることも選択できます
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 を使用してテストする場合、これらはサービスのクライアントであり、事前にサービス契約情報を知っておく必要があります。これには2つの方法があります。
- それらに .proto ファイルを提供する。これが理解しやすい方法で、サービスに関するすべての情報は .proto ファイルに定義されています。
- サーバーがサービス情報を取得できるインターフェースを公開する。
方法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>(); // デフォルトで1回作成され、GreeterClient インスタンス間で共有されます
//.AddInterceptor<ExceptionInterceptor>(InterceptorScope.Client); // 各 GreeterClient インスタンスが独自のインターセプターを持ちます
具体的な例外処理ロジックは例示しません。RpcException.Trailers を使用して例外のメタデータを取得できることに触れておきます。
また、例外処理に関しては、プロジェクトが通常の ASP.NET Core Web サービスの場合、従来の ActionFilterAttribute や IExceptionFilter などのインターセプターも同様に使用できます。実行時に例外が発生すれば、どちらも確実にキャッチできるからです。
2.3. 発展的な知識
本記事で触れていない .NET-gRPC の発展的な知識としては、単体テスト、サービス呼び出しの中止、負荷分散、ヘルスモニタリング などがあります。機会があればまた共有したいと思います。実は、これらについては Microsoft の公式ドキュメントでかなり包括的に説明されていますが、実際の操作で遭遇するすべての問題をカバーすることは難しいため、この記事を参考にしていただければ幸いです。ご指摘などあればぜひお寄せください。
3. 参考資料
- HTTP 2.0 の過去と現在
- .NET パフォーマンス最適化 - シリアライゼーションプロトコルを変更すべきとき
- MessagePack 簡析
- Varint エンコーディング
- Protobuf スカラーデータ型
本記事は転載です。
著者:莱布尼茨
原文タイトル:gRPC 入门与实操(.NET 篇)
原文リンク:https://www.cnblogs.com/newton/archive/2023/01/10/17033789.html