我们知道,建立在HTTP2/3之上的gRPC具有四种基本的通信模式或者消息交换模式(MEP: Message Exchange Pattern),即Unary、Server Stream、Client Stream和Bidirectional Stream。本篇文章通过4个简单的实例演示它们在.NET平台上的实现原理,源代码从这里查看。
目录
一、定义ProtoBuf消息
二、请求/响应的读写
三、Unary
四、Server Stream
五、Client Stream
六、Bidirectional Stream
我们选择简单的“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 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(this PipeReader reader, MessageParser parser, Func handler)
where TMessage:IMessage
{
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
我们知道正常的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对象,其承载的问候消息将以如下的形式输出到控制台上。

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方法将读取并以如下的形式输出到控制台上。

Client Stream与Server Stream正好相反,客户端会以流的形式将请求内容提交给服务端进行处理。由于我们以HttpClient来模拟客户端,所以我们只能从HttpRequestMessage上作文章。具体来说,我们需要自定义一个HttpContent类型,让它以“客户端流”的形式相对方发送内容。这个自定义的HttpContent就是如下这个ClientStreamContent
public class ClientStreamContent
public class ClientStreamWriter
针对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
在用于模拟Client Stream调用的ClientStreamCallAsync方法中,我们首先创建了一个ClientStreamWriter

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
于此同时,我们在得到表示响应消息的HttpResponseMessage后,调用ReadAndProcessAsync方法将作为响应的问候语以如下的方式输出到控制台上。
