.NET gRPC: Developing High-Performance and Scalable Services (Sample Project)

.NET gRPC: Developing High-Performance and Scalable Services (Sample Project)

For Project Code > GitHub

What is gRPC?

gRPC (gRPC Remote Procedure Call) is an open-source remote procedure call (RPC) framework developed by Google. It is designed to enable fast, low-latency, and high-performance communication between client and server applications.

gRPC is a framework built on the HTTP/2 protocol that facilitates easy communication between various programming languages. It uses a highly efficient and portable format called Protocol Buffers (Protobuf) for defining and serializing messages.

gRPC offers four different communication models:

gRPC Communication Models

Unary: The client sends a message, and the server receives a response.

Server Streaming: The client sends a message, and the server sends multiple responses over time.

Client Streaming: The client sends multiple messages to the server over time, and the server responds with a single reply after receiving all the messages.

Bidirectional Streaming: Both the client and the server continuously send and receive messages to and from each other.

gRPC, today, microservice architectures, cloud-based applications, and systems requiring high performance continue to be a popular choice. gRPC, in particular, benefits from the advantages offered by HTTP/2, providing faster, more secure, and efficient communication. Major companies like Netflix, Square and Dropbox have chosen gRPC to build their high-performance microservice architectures.

After this brief introduction to gRPC, we can now start developing a sample application using the .NET 8 framework.

First, let's create a solution named GrpcExample and two Web API projects under that solution named Server and Client, as shown in the image below:

In this project, we will configure the Server application as the "Server" for our gRPC service, and the Client application as the "Client" for our gRPC project.

Let’s start with the Server application;

First, let's add the following NuGet packages to our project.

  1. Grpc.AspNetCore: This package contains the necessary components to host a gRPC service in ASP.NET Core applications.
  2. Google.Protobuf: This is the package required to define and process Protocol Buffers (Protobuf) messages.
  3. Grpc.Tools: It provides the tools required to compile Protocol Buffers (.proto) files. This package is only used during development and is not required at runtime.

Here is the content of our Server.csproj file:

    <Project Sdk="Microsoft.NET.Sdk.Web">  
      
      <PropertyGroup>  
        <TargetFramework>net8.0</TargetFramework>  
        <Nullable>enable</Nullable>  
        <ImplicitUsings>enable</ImplicitUsings>  
      </PropertyGroup>  
      
      <ItemGroup>  
        <PackageReference Include="Google.Protobuf" Version="3.27.1" />  
        <PackageReference Include="Grpc.AspNetCore" Version="2.63.0" />  
        <PackageReference Include="Grpc.Tools" Version="2.64.0">  
          <PrivateAssets>all</PrivateAssets>  
          <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>  
        </PackageReference>  
      </ItemGroup>  
      
    </Project>

In our Server project, let's create a folder named "Protos." Under this folder, we will create a file named greet.proto. The content of the greet.proto file will be as follows:

    syntax = "proto3"; /*We specified that we will be using Protobuf version 3.*/  
    option csharp_namespace = "Server"; /*We specified that the generated C# classes will reside in the namespace named **Server**.*/  
    package server; /* This line specifies that the protobuf file is within a package named **server**.  
It helps organize the code and prevent name collisions. */  
    import "google/protobuf/wrappers.proto"; /* We imported this file to use the google.protobuf.StringValue type. */  
      
    service Greeter { /* We created a gRPC service named Greeter.*/  
     rpc SayHelloUnary(HelloRequest) returns (HelloReply); /* Unary RPC method */  
     rpc SayHelloServerStream(HelloRequest) returns (stream HelloReply); /* Server Stream RPC method */  
     rpc SayHelloClientStream(stream HelloRequest) returns (HelloReply); /* Client Stream RPC method */  
     rpc SayHelloBidirectionalStream(stream HelloRequest) returns (stream HelloReply); /* Bi-directional Stream RPC method */  
    }  
      
    message HelloRequest {  
     string first_name = 1;  
     string last_name = 2;  
     google.protobuf.StringValue mother_name = 3; /*nullable string*/  
    }  
      
    message HelloReply {  
     string message = 1;  
    }

I tried to explain each line within our Protobuf file. As you may have noticed, in each message, we assigned a field number to each parameter (property). It is important that field numbers in Protobuf messages are sequential and unique.

However, we are not strictly bound to a specific order. We could have defined the field numbers as shown below.

    message HelloRequest {  
     string first_name = 1;  
     string last_name = 3;  
     google.protobuf.StringValue mother_name = 4; /*nullable string*/  
    }

This does not affect how Protobuf works, because each field number is unique and will be correctly used during the data serialization and deserialization processes.

Let's discuss the advantages and disadvantages of leaving gaps between field numbers, as shown in the example above.

Advantages:

  • Flexibility: Defining field numbers with gaps, like in the 1, 3, 4 example, allows for future additions. For instance, you can later add a field with number 2.
  • Compatibility: Having unique and consistent field numbers helps maintain both backward and forward compatibility.

Disadvantages:

  • Disorder: Non-sequential field numbers can make the message harder to read and manage.
  • Unused Fields: Skipping field numbers can leave unnecessary gaps in the message structure, though this usually doesn’t cause any issues.

We have briefly covered the field numbers in Protobuf messages. While it is ultimately up to you to evaluate based on the advantages and disadvantages, it is generally better to follow best practices.

Now, let’s continue writing our code..

After creating the "greet.proto" Protobuf file, let's update our .csproj file as follows to ensure that the file is compiled into C# classes and remains in the directory when our project is deployed.

    <Project Sdk="Microsoft.NET.Sdk.Web">  
      
      <PropertyGroup>  
        <TargetFramework>net8.0</TargetFramework>  
        <Nullable>enable</Nullable>  
        <ImplicitUsings>enable</ImplicitUsings>  
      </PropertyGroup>  
      
      <ItemGroup>  
        <PackageReference Include="Google.Protobuf" Version="3.27.1" />  
        <PackageReference Include="Grpc.AspNetCore" Version="2.63.0" />  
        <PackageReference Include="Grpc.Tools" Version="2.64.0">  
          <PrivateAssets>all</PrivateAssets>  
          <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>  
        </PackageReference>  
      </ItemGroup>  
      
      <ItemGroup>  
        <Protobuf Include="Protos\greet.proto" ProtoRoot="Protos\" GrpcServices="Server" />  
      </ItemGroup>  
      
      <ItemGroup>  
        <None Update="Protos\**\*.*">  
          <CopyToOutputDirectory>Always</CopyToOutputDirectory>  
        </None>  
      </ItemGroup>  
      
    </Project>

From this point on, when we compile our Server project, the "Grpc.Tools" package will generate the C# classes on our behalf.

Let’s create a class named “GreetGrpcService.cs” and inherit from the “Greeter.GreeterBase” base class, which was generated by the "Grpc.Tools" package.

Now, by overriding the "rpc" methods we created in the Protobuf file, we can configure their content as shown below:

    using Grpc.Core;  
      
    namespace Server.Services;  
      
    public class GreetGrpcService : Greeter.GreeterBase // The GreetGrpcService class, which is derived from the Greeter.GreeterBase class.
    {  
        // The method that performs the Unary gRPC call.
        public override Task<HelloReply> SayHelloUnary(HelloRequest request, ServerCallContext context)  
        {  
            // It creates and returns a HelloReply message. It concatenates the first and last name from the client request and returns it as the message.
            return Task.FromResult(new HelloReply  
            {  
                Message = request.FirstName + " " + request.LastName + " from Unary"  
            });  
        }  
      
        // The method that performs the Server Streaming gRPC call.
        public override async Task SayHelloServerStream(HelloRequest request, IServerStreamWriter<HelloReply> responseStream, ServerCallContext context)  
        {  
            int i = 0; // It starts the forecast counter.
            while (!context.CancellationToken.IsCancellationRequested && i < 10) // The loop continues as long as it is not canceled and the counter is less than 10.
            {  
                // In each loop iteration, it sends a response message to the server.
                await responseStream.WriteAsync(new HelloReply  
                {  
                    Message = request.FirstName + " " + request.LastName + " from Server Streaming"  
                });  
                i++; // The counter is incremented.
            }  
        }  
      
        // The method that performs the Client Streaming gRPC call.
        public override async Task<HelloReply> SayHelloClientStream(IAsyncStreamReader<HelloRequest> requestStream, ServerCallContext context)  
        {  
            var names = new List<string>(); // It creates a list to store the first and last names received from the client.
            await foreach (var message in requestStream.ReadAllAsync()) //It reads all the messages received from the client.
            {  
                // It takes the first and last name from each message and adds them to the list.
                names.Add(message.FirstName + " " + message.LastName);  
            }  
            // It concatenates all the names and returns them as the response.
            return new HelloReply  
            {  
                Message = string.Join(", ", names) + " from Client Streaming"  
            };  
        }  
      
        // The method that performs the Bidirectional Streaming gRPC call.
        public override async Task SayHelloBidirectionalStream(IAsyncStreamReader<HelloRequest> requestStream, IServerStreamWriter<HelloReply> responseStream,  
            ServerCallContext context)  
        {  
            await foreach (var message in requestStream.ReadAllAsync()) // It reads all the messages received from the client.
            {  
                // For each message, it sends a response message to the client.
                await responseStream.WriteAsync(new HelloReply  
                {  
                    Message = message.FirstName + " " + message.LastName + " from Bidirectional Streaming"  
                });  
            }  
        }  
    }

We have completed our example rpc methods. Now, let's configure the necessary settings in the "Program.cs" file.

The final version of our "Program.cs" file will be as follows: Here is the English translation of the provided C# comments:

    using System.Net;  
    using Microsoft.AspNetCore.Server.Kestrel.Core;  
    using Server.Services;  
      
    var builder = WebApplication.CreateBuilder(args);  
      
    //Configuration for Kestrel  
    builder.WebHost.ConfigureKestrel((context, options) =>  
    {  
        options.Listen(IPAddress.Any, 5858); //For HTTP traffic
          
        options.Listen(IPAddress.Any, 5859, listenOptions => //It will listen for gRPC traffic.
        {  
            listenOptions.Protocols = HttpProtocols.Http2; //We will use the HTTP/2 protocol for gRPC.
           // listenOptions.UseHttps("certificate.pfx", "password"); //If HTTPS configuration is needed.  
        });  
    });  
    // Add services to the container.  
    builder.Services.AddGrpc(); //Adds gRPC services to the DI (Dependency Injection) container.  
      
    var app = builder.Build();  
      
    // Configure the HTTP request pipeline.  
    app.MapGrpcService<GreetGrpcService>(); //Adds the gRPC service named GreetGrpcService to the HTTP request pipeline. This service will handle gRPC requests.  
      
    app.MapGet("/protos/greet-protobuf", async httpContext => ///Using the /greet-protobuf endpoint, we will serve our Protobuf file to clients.  
    { //We can make this part more generic to serve all proto files under a specific folder. I'll leave that part to your imagination. :)  
        httpContext.Response.ContentType = "text/plain;charset=utf-8";  
        using var fs = File.OpenRead("Protos/greet.proto");  
        using var sr = new StreamReader(fs, System.Text.Encoding.UTF8);  
        while (!sr.EndOfStream)  
        {  
           var  protoLine = await sr.ReadLineAsync();  
           if(protoLine != "/*>>" || protoLine != "<<*/")  
           {  
               await httpContext.Response.WriteAsync(protoLine);  
           }  
             
        }  
    });  
    app.Run();  

We have thus completed our sample gRPC Server application.

Now, let's continue with coding our Client application.

To simply explain the topology we will establish here, we will code a RestAPI for each of our gRPC methods. From these REST APIs, we will send requests to our gRPC server. If we draw a simple diagram, this example will work as shown below:

Now we can start coding our "client" application.

First, let's add the necessary NuGet packages for our "client" application.

  1. Grpc.Net.Client: This package is used to create and manage gRPC clients.
  2. Grpc.Net.ClientFactory: This factory is used to create and configure gRPC clients in ASP.NET Core applications. It allows you to register and configure gRPC clients with Dependency Injection (DI). It works similarly to HttpClientFactory and enables you to manage client configurations from a central location.
  3. Google.Protobuf: This package is necessary for defining and handling Protocol Buffers (Protobuf) messages.
  4. Grpc.Tools: Provides tools necessary to compile Protocol Buffers files (.proto). This package is only used during development and is not required at runtime.

After loading our packages, our "Client.csproj" file will look like this:

    <Project Sdk="Microsoft.NET.Sdk.Web">  
      
      <PropertyGroup>  
        <TargetFramework>net8.0</TargetFramework>  
        <Nullable>enable</Nullable>  
        <ImplicitUsings>enable</ImplicitUsings>  
      </PropertyGroup>  
      
      <ItemGroup>  
        <PackageReference Include="Google.Protobuf" Version="3.27.2" />  
        <PackageReference Include="Grpc.Net.Client" Version="2.63.0" />  
        <PackageReference Include="Grpc.Net.ClientFactory" Version="2.63.0" />  
        <PackageReference Include="Grpc.Tools" Version="2.64.0">  
          <PrivateAssets>all</PrivateAssets>  
          <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>  
        </PackageReference>  
      </ItemGroup>  
      
    </Project>

Now, let's import our previously published protobuf file from the following address:

http://localhost:5858/protos/greet-protobuf

Steps for this process:

Right-click on Connected Services ⇒ Manage Connected Services

Click on the arrow.

Select gRPC and click Next.

Select the URL from the screen and enter the Protobuf address for the server in production.

And we got success!

With the help of our gRPC NuGet packages, Visual Studio has generated our GrpcClient class.

Now we can prepare the endpoints where we will call our RPC methods. Let’s create a static class called "GreetMinimalApi.cs" under the Apis folder for these endpoints using .Net Minimal APIs and use the "Greeter.GreeterClient" class generated by the "Grpc.Tools" package as follows:

    using Grpc.Core;  
    using Microsoft.AspNetCore.Mvc;  
    using Server;  
      
    namespace Client.Apis;  
      
    public static class GreetMinimalApi  
    {  
        // Extension method to add Minimal API endpoints.  
        public static void UseGreetEndPoints(this IEndpointRouteBuilder routes)  
        {  
            // Organizes all greet endpoints by grouping them.  
            var group = routes.MapGroup("greet");  
      
            // GET endpoint that performs a Unary gRPC call.  
            group.MapGet("unary", UnaryExample).WithName("unary").WithOpenApi();  
      
            // GET endpoint that performs a Server Streaming gRPC call.  
            group.MapGet("serverstream", ServerStreamExample).WithName("serverstream").WithOpenApi();  
      
            // GET endpoint that performs a Client Streaming gRPC call.  
            group.MapGet("clientstream", ClientStreamExample).WithName("clientstream").WithOpenApi();  
      
            // GET endpoint that performs a Bidirectional Streaming gRPC call.  
            group.MapGet("bidirectionalstream", BidirectionalStreamExample).WithName("bidirectionalstream").WithOpenApi();  
        }  
      
        // Example of a Unary gRPC call. It takes the client's first and last name, sends it to the server, and returns the response.  
        private static async Task<string> UnaryExample([FromServices] Greeter.GreeterClient grpClient, [FromQuery] string name, [FromQuery] string surname)  
        {  
            // Sends a HelloRequest message to the server and receives a response.  
            var grpcResponse = await grpClient.SayHelloUnaryAsync(new HelloRequest { FirstName = name, LastName = surname });  
            // Returns the response message.  
            return grpcResponse.Message;  
        }  
      
        // Example of a Server Streaming gRPC call. It takes the client's first and last name, sends it to the server, and receives multiple responses.  
        private static async Task<string> ServerStreamExample([FromServices] Greeter.GreeterClient grpClient, [FromQuery] string name, [FromQuery] string surname)  
        {  
            // Sends a HelloRequest message to the server and receives a response stream.  
            var grpcResponse = grpClient.SayHelloServerStream(new HelloRequest { FirstName = name, LastName = surname });  
            var response = "";  
            // Reads and combines all messages from the response stream.  
            await foreach (var message in grpcResponse.ResponseStream.ReadAllAsync())  
            {  
                response += message.Message + "\n";  
            }  
            // Returns all messages.  
            return response;  
        }  
      
        // Example of a Client Streaming gRPC call. It takes the client's first and last name, sends multiple messages to the server, and receives a response.  
        private static async Task<string> ClientStreamExample([FromServices] Greeter.GreeterClient grpClient, [FromQuery] string name, [FromQuery] string surname)  
        {  
            // Initiates a message stream to the server.  
            var grpcResponse = grpClient.SayHelloClientStream();  
            // Sends messages from the client to the server.  
            await grpcResponse.RequestStream.WriteAsync(new HelloRequest { FirstName = name, LastName = surname });  
            await grpcResponse.RequestStream.CompleteAsync();  
            // Returns the response from the server.  
            return (await grpcResponse).Message;  
        }  
      
        // Example of a Bidirectional Streaming gRPC call. It takes the client's first and last name, continuously sends and receives messages from both client and server.  
        private static async Task<string> BidirectionalStreamExample([FromServices] Greeter.GreeterClient grpClient, [FromQuery] string name, [FromQuery] string surname)  
        {  
            // Initiates a bidirectional message stream with the server.  
            var grpcResponse = grpClient.SayHelloBidirectionalStream();  
            // Sends messages from the client to the server.  
            await grpcResponse.RequestStream.WriteAsync(new HelloRequest { FirstName = name, LastName = surname });  
            await grpcResponse.RequestStream.CompleteAsync();  
            var response = "";  
            // Reads and combines all messages from the server.  
            await foreach (var message in grpcResponse.ResponseStream.ReadAllAsync())  
            {  
                response += message.Message + "\n";  
            }  
            // Returns all messages.  
            return response;  
        }  
    }

Now let’s reorganize the "Program.cs" class of our "client" project for the functionality of the endpoints, Swagger, and ClientFactory:

    var builder = WebApplication.CreateBuilder(args); // Creates the web application builder object.  
      
    builder.Services.AddEndpointsApiExplorer(); // Adds services to explore and document API endpoints.  
    builder.Services.AddSwaggerGen(c =>  
    {  
        // Configures Swagger/OpenAPI documentation.  
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" });  
    });  
      
    builder.Services.AddGrpcClient<Greeter.GreeterClient>(o => o.Address = new Uri("http://localhost:5859"));   
    // Adds the gRPC client as a service and configures the server address for this client.  
      
    var app = builder.Build(); // Builds the application object.  
      
    app.UseSwagger(); // Enables Swagger middleware.  
    app.UseSwaggerUI

(); // Enables the Swagger user interface.  
    app.UseGreetEndPoints(); // Configures the gRPC endpoints from the GreetMinimalApi class.  
    app.Run(); // Runs the application.

And thus, we have also completed our client project.

I leave it to you to run the projects and test them on the SwaggerUI screen above 😃

You can download the code from the following address:

https://github.com/erdemkayagentr/GrpcExample

In my next post, I will try to explain Duende Identity Server — CIBA (Client Initiated Backchannel Authentication) with a sample project.

See you in the next article...

0 Yorum

YORUMUNUZU BIRAKIN