1. Why Choose gRPC
1.1. History
For a long time, we have used the WebApi + JSON approach for frontend-backend interaction, and for backend service-to-service calls as well (or the earlier WCF + XML approach). WebApi + JSON is a preferred choice, importantly because both are platform-independent third-party standards that are sufficiently semantic and convenient for programmers, making them the go-to choice in heterogeneous (frontend-backend, multi-language backend) interaction scenarios. However, as the backend service architecture improved, especially with the rise of microservices, we found that the WebApi + JSON model, which is naturally suitable for frontend-backend interaction, seemed somewhat inappropriate within the backend system:
- JSON’s character encoding results in larger data transmission volumes. Backend systems generally do not operate on JSON directly; they convert JSON to platform-specific types before processing. Since conversion is necessary, why not choose a format with smaller data size and more convenient conversion?
- Both calling parties need to pre-agree on data structures and call interfaces. Any minor change requires manually updating related code (Model classes and method signatures). Could these agreements be solidified into a document that the service provider maintains, and the caller uses to generate required code conveniently, with automatic code updates when the document changes?
- [Previously] The HTTP/1.1 protocol underlying WebApi has been around for over 20 years, and its defined interaction patterns are now inadequate. The industry needs a more efficient protocol.
1.2. Efficient Transmission – HTTP/2.0
Let’s first address the third issue. In fact, many large companies have already started working on this internally, leading to some widely used frameworks, such as Alibaba’s open-source Dubbo. Dubbo directly abandons HTTP and is based on TCP, resulting in significant efficiency improvements. However, Dubbo relies on the Java environment and cannot be used cross-platform, so it is not in our consideration.
Another big company, Google, has long used its self-developed Stubby framework internally. Unlike Dubbo, Stubby is cross-platform. However, Google considered Stubby not based on any standard and tightly coupled with its internal infrastructure, making it unsuitable for public release.
At the same time, Google was enhancing the HTTP/1.1 protocol with the SPDY project proposed in 2012. SPDY optimized the HTTP protocol layer, adding features such as multiplexing of data streams, request prioritization, and HTTP header compression. Google stated that with the SPDY protocol, page loading speeds in lab tests were 64% faster than before. This huge improvement prompted the industry to positively address and solve the problems of the older HTTP protocol, which directly accelerated the birth of HTTP/2.0. In fact, HTTP/2.0 was discussed and standardized using SPDY as a prototype, with additional improvements and adjustments.
With the emergence and popularity of HTTP/2.0, many features identical to those in Stubby have appeared in public standards, including others that Stubby did not provide. Clearly, it was time to redo Stubby to leverage this standardization and extend its applicability to the last mile of distributed computing, supporting mobile devices (e.g., Android), the Internet of Things (IoT), and browser connections to backend services.
In March 2015, Google decided to build the next version of Stubby in public to share experiences with the industry and collaborate, which became the subject of this article: gRPC.
1.3. Efficient Encoding – Protobuf
Looking back at the first problem, the solution is relatively simple: replace the naive character encoding with more efficient binary encoding (e.g., the number 10000 is 5 bytes after JSON encoding, but 4 bytes as an integer), and add some pre-agreed encoding algorithms to make the final result more compact. Common platform-independent encoding formats include MessagePack and protobuf. We take protobuf as an example.
Protobuf uses varint encoding and ZigZag encoding for negative numbers, greatly reducing the space occupied by numeric fields. It also defines field types and identifiers, using a TLV (Type-Length-Value) approach, mapping field names to items in a small result set (e.g., for a data body with no more than 256 fields, each field name requires only 1 byte to identify, regardless of the original name length), removes separators, and can filter out empty fields (if a field is not assigned a value, it does not appear in the serialization result).
1.4. Efficient Programming – Code Generation Tools
For the second problem, what is needed is a set of code generation tools for each platform. The generated code must cover class definitions, object serialization/deserialization, exposure of service interfaces, remote calls, and other necessary boilerplate code. In this way, developers only need to maintain the interface document and implement business logic (natural interface-oriented programming :)). At this point, gRPC using protobuf naturally comes into view, because for all major programming languages and platforms, there are gRPC tools and libraries, including .NET, Java, Python, Go, C++, Node.js, Swift, Dart, Ruby, and PHP. The availability of these tools and libraries allows gRPC to work consistently across multiple languages and platforms, making it a comprehensive RPC solution.
2. Using gRPC in .NET
Starting from ASP.NET Core 3.0, gRPC is supported as a first-class citizen in the .NET platform.
2.1. Server Side
Create a new ASP.NET Core gRPC Service in VS, and you will find that the project file automatically references the Microsoft.NET.Sdk.Web library. Clearly, the gRPC service is still a web service, since it runs over HTTP. It also references the Grpc.AspNetCore library, which itself references several sub-libraries worth understanding:
Google.Protobuf: Contains protobuf predefined message types implemented in C#.Grpc.Tools: The code generation tool mentioned above; used at compile time, not needed at runtime, so the dependency is marked as PrivateAssets="All".Grpc.AspNetCore.Server: Specific to the server side.Grpc.Net.ClientFactory: Specific to the client side. If you are only providing a service, this library can be removed.
Define the interface file:
syntax = "proto3";
// Specify the namespace for auto-generated classes; if not specified, the following package is used as namespace. This helps with module partitioning within the project.
option csharp_namespace = "Demo.Grpc";
// Namespace for externally exposed services
package TestDemo;
// Service
service Greeter {
// Interface
rpc SayHello (HelloRequest) returns (HelloReply);
}
// A downside is that even for a single basic type field, you need to wrap it in a new message
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
Then include it in the project file:
<ItemGroup>
<Protobuf Include="Protos\greeter.proto" GrpcServices="Server" />
</ItemGroup>
Compile, and Grpc.Tools will generate the GreeterBase class and the two model classes:
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; }
}
The SayHello here is an empty implementation. Create a new implementation class and fill in the business logic, for example:
public class GreeterService : GreeterBase
{
public override Task<HelloReply> SayHello(HelloRequest request, ServerCallContext context)
{
return Task.FromResult(new HelloReply { Message = $"Hello {request.Name}" });
}
}
Finally, add the service to the routing pipeline and expose it:
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
If writing .proto files feels awkward and you prefer the traditional way of writing interfaces, the community project protobuf-net.Grpc is worth trying. It allows defining gRPC services and messages using attribute-annotated .NET types.
First, instead of referencing Grpc.AspNetCore, reference the protobuf-net.Grpc library. You also don't need to write .proto files; just write interface classes:
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);
}
Note the attribute decorations.
After writing the implementation class, register it in Program.cs — we won’t go into detail here.
With protobuf-net.Grpc, we don’t need to write .proto files. However, callers (especially on other platforms) need the .proto file to generate corresponding clients. Do we have to write it separately? Don’t worry, we can introduce protobuf-net.Grpc.AspNetCore.Reflection, which references protobuf-net.Grpc.Reflection and provides methods to generate .proto files from C# interfaces. It also helps with client testing, similar to the role of Grpc.AspNetCore.Server.Reflection, which will be discussed below.
2.1.2. Exception Handling
.NET provides an interceptor mechanism for gRPC. You can create an interceptor to uniformly handle business exceptions, for example:
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;
}
}
}
The above code re-throws the exception after handling it, intending for the client to receive and handle it. However, the client cannot actually receive the exception information unless the server throws an RpcException. Also, to ensure the client gets the correct HttpStatusCode (default is 200, even if the client receives an RpcException), you need to explicitly assign a value to HttpContext.Response.StatusCode, as follows:
// ...
catch(Exception ex)
{
var httpContext = context.GetHttpContext();
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
// Note: RpcException's StatusCode does not map one-to-one with HTTP StatusCode
throw new RpcException(new Status(StatusCode.XXX, "some messages"));
}
// ...
You can pass Metadata when constructing the RpcException object to carry additional data to the client. If you need to pass a complex object, serialize it into a byte array according to the convention.
After completing the interceptor logic, configure it during service injection as follows:
builder.Services.AddGrpc(options =>
{
options.Interceptors.Add<GrpcGlobalExceptionInterceptor>();
});
2.1.3. Testing
Once the server is complete, if you want to test using Postman or gRPCurl, they are essentially clients calling the service. For them to know the service contract information in advance, there are two methods:
- Provide them with the
.protofile — all service information is defined in it. - Expose an interface on the server that can retrieve service information.
If using method 2, first reference the Grpc.AspNetCore.Server.Reflection library and register the interface in Program.cs:
// ...
builder.Services.AddGrpcReflection();
var app = builder.Build();
// ...
IWebHostEnvironment env = app.Environment;
if (env.IsDevelopment())
{
app.MapGrpcReflectionService();
}
2.2. Client Side
The client does not need Grpc.AspNetCore.Server, so we directly reference Google.Protobuf, Grpc.Tools, and Grpc.Net.ClientFactory.
Add the .proto file provided by the server to the project and include it in the project file:
<ItemGroup>
<Protobuf Include="Protos\greeter.proto" GrpcServices="Client" />
</ItemGroup>
Note: If only part of the server’s interfaces are needed, you can keep only the necessary interfaces in the .proto file — truly on-demand :).
You can also change the field names in the .proto file (as long as you don’t change the field types and order), and it will not affect service calls. This directly reflects that protobuf encodes based on pre-defined field identifiers, not field names.
Thus, if you have multiple .proto files that use the same structure of messages, regardless of whether the field names are the same, you can extract those messages into a separate .proto file and import it in other .proto files using import "Protos/xxx.proto";.
Compile, then register the service client in Program.cs:
// package from .proto file
using TestDemo;
// The injected service is Transient mode
builder.Services.AddGrpcClient<Greeter.GreeterClient>(o =>
{
o.Address = new Uri("https://localhost:5001");
});
Now other parts can happily call the remote service using the client.
Similar to the server, you can configure a unified interceptor for the client. If the server returns the previously mentioned RpcException, the client will throw it directly (like a local exception). You can create a dedicated exception interceptor to handle RpcException.
builder.Services
.AddGrpcClient<Greeter.GreeterClient>(o =>
{
o.Address = new Uri("https://localhost:5001");
})
.AddInterceptor<ExceptionInterceptor>(); // Created once by default and shared among GreeterClient instances
//.AddInterceptor<ExceptionInterceptor>(InterceptorScope.Client); // Each GreeterClient instance has its own interceptor
We won’t provide an example of the specific exception handling logic. One tip: you can obtain the exception’s metadata through RpcException.Trailers.
Additionally, for exception handling, if the project is a regular ASP.NET Core Web service, using the original ActionFilterAttribute, IExceptionFilter, etc., interceptors is also fine, because if an exception occurs at runtime, these will catch it as well.
2.3. Advanced Knowledge
Advanced topics in .NET-gRPC not covered in this article, such as unit testing, service call cancellation, load balancing, health monitoring, etc., will be shared in future posts if there’s an opportunity. Actually, Microsoft’s official documentation already explains these quite comprehensively, but it still cannot cover all issues encountered in practice. Hence this article is provided for readers’ benefit, and we welcome your corrections.
3. References
- The Past and Present of HTTP 2.0
- .NET Performance Optimization: Time to Change the Serialization Protocol
- Brief Analysis of MessagePack
- Varint Encoding
- Protobuf Scalar Data Types
This article is reproduced.
Author: Leibniz
Original title: Getting Started and Hands-on with gRPC (.NET Edition)
Original link: https://www.cnblogs.com/newton/archive/2023/01/10/17033789.html