用最少的程式碼模擬gRPC四種訊息交換模式

Artech發表於2022-11-21

我們知道,建立在HTTP2/3之上的gRPC具有四種基本的通訊模式或者訊息交換模式(MEP: Message Exchange Pattern),即Unary、Server Stream、Client Stream和Bidirectional Stream。本篇文章透過4個簡單的例項演示它們在.NET平臺上的實現原理,原始碼從這裡檢視。

目錄
一、定義ProtoBuf訊息
二、請求/響應的讀寫
三、Unary
四、Server Stream
五、Client Stream
六、Bidirectional Stream

一、定義ProtoBuf訊息

我們選擇簡單的“Hello World”場景進行演示:客戶端請求指定一個或者多個名字,回覆以“Hello, {Name}!”。為此我們在一個ASP.NET Core應用中定義瞭如下兩個ProtoBuf訊息HelloRequest和HelloReply,生成兩個同名的訊息型別。

syntax = "proto3";

message HelloRequest {
  string names = 1;
}

message HelloReply {
  string message = 1;
}

二、請求/響應的讀寫

gRPC框架的核心莫過於在服務端針對請求訊息的讀取和對響應訊息的寫入;以及在客戶端針對請求訊息的寫入和對響應訊息的讀取。這四個核心功能被實現在如下這兩個擴充套件方法中。如下面的程式碼片段所示,擴充套件方法WriteMessageAsync將指定的ProtoBuf訊息寫入PipeWriter物件中。為了確保訊息能夠被準確的讀取,我們利用前置的四個位元組儲存了訊息的位元組數。

public static class ReadWriteExtensions
{
    public static ValueTask<FlushResult> WriteMessageAsync(this PipeWriter writer, IMessage message)
    {
        var length = message.CalculateSize();
        var span = writer.GetSpan(4+length);
        BitConverter.GetBytes(length).CopyTo(span);
        message.WriteTo(span.Slice(4, length));
        writer.Advance(4 + length);
        return writer.FlushAsync();
    }

    public static async Task ReadAndProcessAsync<TMessage>(this PipeReader reader, MessageParser<TMessage> parser, Func<TMessage, Task> handler) 
where TMessage:IMessage<TMessage> { while(true) { var result = await reader.ReadAsync(); var buffer = result.Buffer; while (TryReadMessage(ref buffer, out var message)) { await handler(message!); } reader.AdvanceTo(buffer.Start, buffer.End); if(result.IsCompleted) { break; } } bool TryReadMessage(ref ReadOnlySequence<byte> buffer, out TMessage? message) { if(buffer.Length < 4) { message = default; return false; } Span<byte> lengthBytes = stackalloc byte[4]; buffer.Slice(0,4).CopyTo(lengthBytes); var length = BinaryPrimitives.ReadInt32LittleEndian(lengthBytes); if (buffer.Length < length + 4) { message = default; return false; } message = parser.ParseFrom(buffer.Slice(4, length)); buffer = buffer.Slice(length + 4); return true; } } }

ReadAndProcessAsync擴充套件方法從指定的PipeReader物件中讀取指定型別的ProtoBuf訊息,並利用指定處理器(一個Func<TMessage, Task>委託)對它進行處理。由於寫入時指定了訊息的位元組數,所以我們可以將承載訊息的位元組“精準地”讀出來,並利用指定的MessageParser<TMessage>對其進行序列化。

三、Unary

我們知道正常的gRPC開發需要將包含一個或者多個操作的服務定義在ProtoBuf檔案中,並利用它生成一個基類,我們透過繼承這個基類並重寫操作對應方法。對於ASP.NET Core gRPC來說,服務操作對應的方法最終會轉換成對應的終結點並以路由的形式進行註冊。這個過程其實並不複雜,但不是本篇文章關注的終結點。本文會直接註冊四個對應的路由終結點來演示四個基本的訊息交換模式。

Unary呼叫最為簡單,就是簡單的Request/Reply模式。在如下的程式碼中,我們註冊了一個針對請求路徑“/unary”的路由,對應的處理方法為如下所示的HandleUnaryCallAsync。該方法直接呼叫上面定義的ReadAndProcessAsync擴充套件方法將請求訊息(HelloRequest)從請求的BodyReader中讀取出來,並生成一個對應的HelloReply訊息予以應答。後者利用上面的WriteMessageAsync擴充套件方法寫入響應的BodyWriter。

using GrpcService; using System.IO.Pipelines; using System.Net; var app = WebApplication.Create(); app.MapPost("/unary", HandleUnaryCallAsync); await app.StartAsync();

await UnaryCallAsync();

static async Task HandleUnaryCallAsync(HttpContext httpContext) { var reader = httpContext.Request.BodyReader; var write = httpContext.Response.BodyWriter; await reader.ReadAndProcessAsync(HelloRequest.Parser, async hello => { var reply = new HelloReply { Message = $"Hello, {hello.Names}!" }; await write.WriteMessageAsync(reply); }); } static async Task UnaryCallAsync() { using (var httpClient = new HttpClient()) { var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost:5000/unary") { Version = HttpVersion.Version20, VersionPolicy = HttpVersionPolicy.RequestVersionExact, Content = new MessageContent(new HelloRequest { Names = "foobar" }) }; var reply = await httpClient.SendAsync(request); await PipeReader.Create(await reply.Content.ReadAsStreamAsync()).ReadAndProcessAsync(HelloReply.Parser, reply => { Console.WriteLine(reply.Message); return Task.CompletedTask; }); } }

UnaryCallAsync模擬了客戶端針對Unary服務操作的呼叫,具體的呼叫由我們熟悉的HttpClient物件完成。如程式碼片段所示,我們針對路由地址建立了一個HttpRequestMessage物件,並對其HTTP版本進行了設定(2.0),代表請求主體內容的HttpContent是一個MessageContent物件,具體的定義如下。MessageContent將代表ProtoBuf訊息的IMessage物件作為主體內容,在重寫的SerializeToStreamAsync,我們呼叫上面定義的WriteMessageAsync擴充套件方法將指定的IMessage物件寫入輸出流中。

public class MessageContent : HttpContent
{
    private readonly IMessage _message;
    public MessageContent(IMessage message) => _message = message;
    protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context)
    =>await PipeWriter.Create(stream).WriteMessageAsync(_message);
    protected override bool TryComputeLength(out long length)
    {
        length = -1;
        return false;
    }
}

建立的HttpRequestMessage物件利用HttpClient傳送出去後,我們得到對應的HttpResponseMessage物件,並呼叫ReadAndProcessAsync擴充套件方法將主體內容讀取出來並反序列化成HelloReply物件,其承載的問候訊息將以如下的形式輸出到控制檯上。

image

四、Server Stream

Server Stream這種訊息交換模式意味著服務端可以將內容以流的形式響應給客戶端。作為模擬,客戶端會攜帶一個名字列表(“foo,bar,baz,qux”),服務端以流的形式針對每個名字回覆一個問候訊息,具體的實現體現在針對請求路徑“/serverstream”的路由處理方法HandleServerStreamCallAsync上。和上面一樣,HandleServerStreamCallAsync方法利用我們定義的ReadAndProcessAsync方法讀取作為請求的HelloRequest物件,並針對其攜帶的每一個名氣生成一個HelloReply物件,後者最終透過我們定義的WriteMessageAsync方法予以響應。為了體驗“流”的效果,我們新增了1秒的時間間隔。

using GrpcService; using System.IO.Pipelines; using System.Net; var app = WebApplication.Create(); app.MapPost("/unary", HandleUnaryCallAsync); app.MapPost("/serverstream", HandleServerStreamCallAsync); await app.StartAsync();


await ServerStreamCallAsync();

static async Task HandleServerStreamCallAsync(HttpContext httpContext) { var reader = httpContext.Request.BodyReader; var write = httpContext.Response.BodyWriter; await reader.ReadAndProcessAsync(HelloRequest.Parser, async hello => { var names = hello.Names.Split(','); foreach (var name in names) { var reply = new HelloReply { Message = $"Hello, {name}!" }; await write.WriteMessageAsync(reply); await Task.Delay(1000); } }); }

static async Task ServerStreamCallAsync() { using (var httpClient = new HttpClient()) { var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost:5000/serverstream") { Version = HttpVersion.Version20, VersionPolicy = HttpVersionPolicy.RequestVersionExact, Content = new MessageContent(new HelloRequest { Names = "foo,bar,baz,qux" }) }; var reply = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); await PipeReader.Create(await reply.Content.ReadAsStreamAsync()).ReadAndProcessAsync(HelloReply.Parser, reply => { Console.WriteLine($"[{DateTimeOffset.Now}]{reply.Message}"); return Task.CompletedTask; }); } }

模擬客戶端呼叫的ServerStreamCallAsync方法在生成一個攜帶多個名字的HttpRequestMessage物件,並利用HttpClient將其傳送出去。由於服務端是以流的形式對請求進行響應的,所以我們在呼叫SendAsync方法是將HttpCompletionOption.ResponseHeadersRead列舉作為第二個引數,這樣我們才能在收到響應頭部之後得到代表響應訊息的HttpResponseMessage物件。這樣的響應將會攜帶4個問候訊息,我們同樣利用ReadAndProcessAsync方法將讀取並以如下的形式輸出到控制檯上。

image

五、Client Stream

Client Stream與Server Stream正好相反,客戶端會以流的形式將請求內容提交給服務端進行處理。由於我們以HttpClient來模擬客戶端,所以我們只能從HttpRequestMessage上作文章。具體來說,我們需要自定義一個HttpContent型別,讓它以“客戶端流”的形式相對方傳送內容。這個自定義的HttpContent就是如下這個ClientStreamContent<TMessage>型別。如程式碼片段所示,ClientStreamContent<TMessage>是對一個ClientStreamWriter<TMessage>物件的封裝,客戶端程式利用後者以流的形式向服務端輸出TMessage物件承載的內容。對於ClientStreamWriter<TMessage>方法來說,作為輸出流的Stream物件是在ClientStreamContent<TMessage>重寫的SerializeToStreamAsync方法中指定的。WriteAsync方法利用我們定義的WriteMessageAsync擴充套件方法實現了針對ProtoBuf訊息的輸出。客戶端透過呼叫Complete方法決定客戶端流是否終結,ClientStreamContent<TMessage>重寫的SerializeToStreamAsync透過WaitAsync進行等待。

public class ClientStreamContent<TMessage> : HttpContent where TMessage:IMessage<TMessage> { private readonly ClientStreamWriter<TMessage> _writer; public ClientStreamContent(ClientStreamWriter<TMessage> writer)=> _writer = writer; protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => _writer.SetOutputStream(stream).WaitAsync(); protected override bool TryComputeLength(out long length) => (length = -1) != -1; }

public class ClientStreamWriter<TMessage> where TMessage: IMessage<TMessage> { private readonly TaskCompletionSource<Stream> _streamSetSource = new(); private readonly TaskCompletionSource _streamEndSuource = new(); public ClientStreamWriter<TMessage> SetOutputStream(Stream outputStream) { _streamSetSource.SetResult(outputStream); return this; } public async Task WriteAsync(TMessage message) { var stream = await _streamSetSource.Task; await PipeWriter.Create(stream).WriteMessageAsync(message); } public void Complete()=> _streamEndSuource.SetResult(); public Task WaitAsync() => _streamEndSuource.Task; }

針對Client Stream的模擬體現在針對路徑“/clientstream”的路由處理方法HandleClientStreamCallAsync。這個方法沒有什麼特別之處,它進行時呼叫ReadAndProcessAsync方法將HelloRequest訊息讀取出來,並將生成的問候語直接輸出到本地(服務端)控制檯上而已。

using GrpcService; using System.IO.Pipelines; using System.Net; var app = WebApplication.Create(); app.MapPost("/unary", HandleUnaryCallAsync); app.MapPost("/serverstream", HandleServerStreamCallAsync); app.MapPost("/clientstream", HandleClientStreamCallAsync); await app.StartAsync();

await ClientStreamCallAsync(); static async Task HandleClientStreamCallAsync(HttpContext httpContext) { var reader = httpContext.Request.BodyReader; var write = httpContext.Response.BodyWriter; await reader.ReadAndProcessAsync(HelloRequest.Parser, async hello => { var names = hello.Names.Split(','); foreach (var name in names) { Console.WriteLine($"[{DateTimeOffset.Now}]Hello, {name}!"); } }); } static async Task ClientStreamCallAsync() { using (var httpClient = new HttpClient()) { var writer = new ClientStreamWriter<HelloRequest>(); var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost:5000/clientstream") { Version = HttpVersion.Version20, VersionPolicy = HttpVersionPolicy.RequestVersionExact, Content = new ClientStreamContent<HelloRequest>(writer) }; _ = httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); foreach (var name in new string[] {"foo","bar","baz","qux" }) { await writer.WriteAsync(new HelloRequest { Names = name}); await Task.Delay(1000); } writer.Complete(); } }

在用於模擬Client Stream呼叫的ClientStreamCallAsync方法中,我們首先建立了一個ClientStreamWriter<HelloRequest>物件,並利用它建立了對應的ClientStreamContent<HelloRequest>物件,後者將作為HttpRequestMessage訊息的主體內容。在呼叫HttpClient的SendAsync方法後,我們並沒有作任何等待(否則程式將卡在這裡),而是利用ClientStreamWriter<HelloRequest>物件以流的形式傳送了四個請求。服務端在接收到每個請求後,會將對應的問候語以如下的形式輸出到控制檯上。

image

六、Bidirectional Stream

Bidirectional Stream將連線作為真正的“雙工通道”。這次我們不再註冊額外的路由,而是直接利用前面模擬Unary的路由終結點來演示雙向通訊。在如下所示的客戶端模擬方法BidirectionalStreamCallAsync中,我們採用上面的方式以流的形式傳送了4個HelloRequest。

using GrpcService; using System.IO.Pipelines; using System.Net; var app = WebApplication.Create(); app.MapPost("/unary", HandleUnaryCallAsync); app.MapPost("/serverstream", HandleServerStreamCallAsync); app.MapPost("/clientstream", HandleClientStreamCallAsync); await app.StartAsync();

await BidirectionalStreamCallAsync(); static async Task BidirectionalStreamCallAsync() { using (var httpClient = new HttpClient()) { var writer = new ClientStreamWriter<HelloRequest>(); var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost:5000/unary") { Version = HttpVersion.Version20, VersionPolicy = HttpVersionPolicy.RequestVersionExact, Content = new ClientStreamContent<HelloRequest>(writer) }; var task = httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); _ = Task.Run(async () => { var response = await task; await PipeReader.Create(await response.Content.ReadAsStreamAsync()).ReadAndProcessAsync(HelloReply.Parser, reply => { Console.WriteLine($"[{DateTimeOffset.Now}]{reply.Message}"); return Task.CompletedTask; }); }); foreach (var name in new string[] { "foo", "bar", "baz", "qux" }) { await writer.WriteAsync(new HelloRequest { Names = name }); await Task.Delay(1000); } writer.Complete(); } }

於此同時,我們在得到表示響應訊息的HttpResponseMessage後,呼叫ReadAndProcessAsync方法將作為響應的問候語以如下的方式輸出到控制檯上。

image

相關文章