No-nonsense gRPC guide for the C# developers, Part One: Basic Service


source code

What are we going to talk about?

gRPC is a high performance program-to-program communication framework highly suitable to efficiently connect services and, as such, to serve as the foundation for the micro-service architecture. I will not to bore you with the marketing speech, go for the details to the official site

A few disclaimers:

  • gRPC, at the moment, does not have the best in-browser support so, if your app lives in the html/javascript universe, the traditional Rest/JSON web services might be a better choice. However, if you want to implement fast and secure connections between programs written in C#, gRPC is your guy. As a nice bonus, you can connect to your C# gRPC services with the clients written in Java, Python, Go, Ruby, you name it. and vice versa!
  • Microsoft’s ASP.Net has its own implementation of gRPC, tightly coupled with the ASP.Net stack. We are not going to be talking about it but, instead, we will build our services with the traditional, interoperable, close-to-the-metal gRPC. When you google for “grpc c#” your may find some links pointing to the ASP.Net thing, so consider yourself warned.

What do you need to follow along?

You will need the latest .Net SDK installed, which is the version 5.0 at the moment of writing. Yo will also need a text editor. I use Visual Studio Code, but your favorite text editor will work fine too. Familiarity with the command line is assumed. You can work on Windows, Mac or Linux. To keep the examples as universal as possible, I will assume bash as the shell (for Windows, you can install git bash). If you are using different shell, it is not going to be hard to adapt the commands to it. In the second part, we will have to generate a bunch of security certificates. To keep the story as cross-platform as possible I will use the tools available with Docker, So you may want to have that installed to. While we are at it, I have a short crash course on Docker for the C# developers on my site, in case you want to refresh your knowledge. As an optional exercise we’ll have our C# services connected with the Python clients. If you want to do that too, you will need Python installed.

The Roadmap

We will do three parts:

  • in the first part, we’ll have a basic gRPC service and the client built in in C#.
  • in the second part, we will secure our service.
  • the last, third part, addresses the streaming support in gRPC, tremendously useful in numerous scenarios.

Let’s get to work!

The project setup.

Create a directory somewhere on your disk, I will call it grpc_csharp but you can call as you want.

mkdir grpc_csharp
cd grpc_csharp

Once in that directory, crate an empty dotnet solution:

dotnet new sln

We are going to have three projects, Service, Client and Shared. Service and Client are going to be console applications and Shared is going to be a classlib containing shared functionality between both of them:

dotnet new console -o Service
dotnet new console -o Client
dotnet new classlib -o Shared

Let’s add them to the solution and make Service and Client depend on Shared:

dotnet sln add Service
dotnet sln add Client
dotnet sln add Shared
dotnet add Service reference Shared
dotnet add Client reference Shared

In order to use gRPC, we are going to add three nuget packages to the Shared project. These are

  • Grpc.Core: essential gRPC functionality.
  • Grpc.Tools: gRPC integration with the dotnet project infrastructure
  • Google.Protobuf: the package necessary for gRPC to serialize the payload.
dotnet add Shared package Grpc.Core
dotnet add Shared package Grpc.Tools
dotnet add Shared package Google.Protobuf

Almost there, for the project setup. But before we continue, let’s do some minor tidying. For both Client and Service dotnet created Program.cs as the entry point; I find that confusing, so let’s rename these

mv Service/Program.cs Service/Service.cs
mv Client/Program.cs Client/Client.cs

Also, the Shared project contains the Class1.cs file which we won’t be using. Delete it

rm Shared/Class1.cs

Sanity check

Build the solution and run both the Client and the Service

dotnet build
dotnet run -p Client
dotnet run -p Service

In both cases, you should be greeted with the infamous “Hello World!” message. How exciting! :) If you got any errors at this point, please retrace your steps and make sure this sanity check passes. Once done, onto the writing the first proto file!

proto files

Let’s say you want to build a calculator micro-service. That is a service which takes two (integer) numbers, an operation, say a string “+”, “-”, “*” or “/”, performs that operation, and returns the result to the caller. How would that work? Obviously, the client needs to pack the parameters into some sort of message and send that message over the network to the service. The service would unpack the message, do the operation, pack the result to the replay message and send that back to the client where the client can unpack the result. In rest/json web service world, that would probably a json-formatted message. The gRPC does conceptually similar thing, but, instead of (slow) text based json format, it uses highly efficient binary format (called protocol buffers). Obviously to write the code which serializes parameters into some obscure binary format in C# would be a lot of work, so gRPC provides the tools which take the declarative description of messages and emit the necessary C# plumbing code automatically. To declare what we want in our messages, we use “proto” files. Let’s create one.

touch Shared/contracts.proto

Make the content of the file look like this:

syntax = "proto3";

option csharp_namespace = "Shared";

message CalculateRequest{
    int64 x = 1;
    int64 y = 2;
    string op = 3;
};

message CalculateReply{
    int64 result = 1;
}

As you see, the format of the file is not that complicated. We are declaring that we want two messages, CalculateRequest and CalculateReply, specify what fields do we want together with their types. the numbers “= 1”, " = 2" and so on indicate a “field number” and it has to be unique for each field per message. You can reuse the field numbers for other messages. “proto3” is the version of proto file syntax we are using. As we are asking to turn that proto file into the C# code, we can also specify what C# namespace we would want. That is pretty much all there is.

To trigger generation of the C# files from the proto file, add the following to the Shared/Shared.csproj, just below the ItemGroup containing nuget package references:

<ItemGroup>
    <Protobuf Include="contracts.proto"/>
</ItemGroup>

That would make sure that gRPC tools process our proto file and emit appropriate C# files. Let’s try it out:

dotnet build

If you navigate to the Shared/obj/Debug/net5.0/, you will notice the Contracts.cs file there. That is emitted by the gRPC tools based on our proto file. You can peek at it if you want but it is quite obfuscated, so you probably wouldn’t want to write such manually. Thanks for the Grpc.Tools package! Anyway, we have our messages defined, what about the service itself? Append the following to the contracts.proto

// new
service Svc {
    rpc Calculate (CalculateRequest) returns (CalculateReply);
}

This pretty much declares what we want: a service which has a Calculate method that accepts the CalculateRequest message and returns the CalculateReply message. Once you build the solution again, you may notice ContractsGrpc.cs file in the Shared/obj/Debug/net5.0/ directory, in addition to Contracts.cs. That contains generated gRPC C# code there for helping to implement the actual Service and actual Client.

For the reference, here is the complete contracts.proto

syntax = "proto3";

option csharp_namespace = "Shared";

message CalculateRequest{
    int64 x = 1;
    int64 y = 2;
    string op = 3;
};

message CalculateReply{
    int64 result = 1;
}

service Svc {
    rpc Calculate (CalculateRequest) returns (CalculateReply);
}

the Service Code.

We need to add a few necessary usings. As we are going to use the generated code which we asked to live in the Shared namespace, add this at the top of the file:

using System;
using System.Threading.Tasks;
using Grpc.Core;
using Shared;

We will keep all Service Code inn one file for now, so create a new class outside of the Program class

public class MyService : Svc.SvcBase
{
}

We are deriving it from the Svc.SvcBase (which resides in the generated ContractsGrpc.cs) to make sure that the gRPC plumbing will call it as appropriate. The Calculate method has the following signature:

public override Task<CalculateReply> Calculate(CalculateRequest request, ServerCallContext context)
{

}

Pretty straightforward: take the CalculateRequest and emit the CalculateReply. Also, we are returning actually Task<CalculateReply>, so it can be used in the async context. Implement this as you wish, here’s my implementation:

public override Task<CalculateReply> Calculate(CalculateRequest request, ServerCallContext context)
{
    long result = -1;
    switch (request.Op)
    {
        case "+":
            result = request.X + request.Y;
            break;
        case "-":
            result = request.X - request.Y;
            break;
        case "*":
            result = request.X * request.Y;
            break;
        case "/":
            if (request.Y != 0)
            {
                result = (long)request.X / request.Y;
            }
            break;
        default:
            break;
    }
    return Task.FromResult(new CalculateReply { Result = result });
}

for all error conditions (as unsupported operation or division by zero), I simply return -1.

Ok, the service code is ready, we need to actually start it to listen for the requests. For extra flexibility, we want the port number of our service passed as a command line argument. Note: in the guide we won’t care much about handling the error conditions, so in practice you will probably want to parse the command line properly. Anyway, the Main method would look like this:

static void Main(string[] args)
{
    int port = int.Parse(args[0]);
    var server = new Server
    {
        Services = { Svc.BindService(new MyService()) },
        Ports = { new ServerPort("0.0.0.0", port, ServerCredentials.Insecure) }
    };
    server.Start();
    Console.WriteLine($"Server listening at port {port}. Press any key to terminate");
    Console.ReadKey();
}

So,

  • capture the port from the command line argument
  • let the gRPC know that MyService will process the incoming messages and generate replays (bind the service).
  • tell that we want listen on all network interfaces (“0.0.0.0”) on the specified port
  • finally, start the service
  • wait for any key press to stop the service.

Try it out

dotnet run -p Service 9000

This should start the service patiently waiting for requests to crunch some numbers. If it does not work for any reason, retrace your steps and fix any error. For the reference, my Service.cs looks at the moment like this:

using System;
using System.Threading.Tasks;
using Grpc.Core;
using Shared;

namespace Service
{
    public class MyService : Svc.SvcBase
    {
        public override Task<CalculateReply> Calculate(CalculateRequest request, ServerCallContext context)
        {
            long result = -1;
            switch (request.Op)
            {
                case "+":
                    result = request.X + request.Y;
                    break;
                case "-":
                    result = request.X - request.Y;
                    break;
                case "*":
                    result = request.X * request.Y;
                    break;
                case "/":
                    if (request.Y != 0)
                    {
                        result = (long)request.X / request.Y;
                    }
                    break;
                default:
                    break;
            }
            return Task.FromResult(new CalculateReply { Result = result });
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            int port = int.Parse(args[0]);
            var server = new Server
            {
                Services = { Svc.BindService(new MyService()) },
                Ports = { new ServerPort("0.0.0.0", port, ServerCredentials.Insecure) }
            };
            server.Start();
            Console.WriteLine($"Server listening at port {port}. Press any key to terminate");
            Console.ReadKey();
        }
    }
}

The client

I want my client app to accept the following command line format:

dotnet run -p Client localhost 9000 25 + 17

that is to take the following arguments, in order:

  • the host where the service lives
  • the service port
  • first number (x)
  • operation
  • second number (y)

Change the Main in Client/Client.cs to capture these arguments:

static void Main(string[] args)
{
    string host = args[0];
    int port = int.Parse(args[1]);
    long x = long.Parse(args[2]);
    string op = args[3];
    long y = long.Parse(args[4]);
}

Don’t forget to specify usings:

using System;
using Grpc.Core;
using Shared;

The actual client code is not that complicated, thanks to the gRPC plumbing. We need to establish a connection (channel), create a client instance on top of that channel and make the call.

Append to the Main method the following

static void Main(string[] args)
{
// ...
// new
var channel = new Channel(
    host,
    port,
    ChannelCredentials.Insecure
    );
    var client = new Svc.SvcClient(channel);
    var reply = client.Calculate(new CalculateRequest {
        X = x, Y = y, Op = op });
    Console.WriteLine($"The calculated result is: {reply.Result}");

Try it out

start the service

dotnet run -p Service 9000

In a separate terminal window, invoke the client:

dotnet run -p Client localhost 9000 25 + 17

This should calculate the requested math operation. For the reference, here is the complete Client.cs:

using System;
using Grpc.Core;
using Shared;

namespace Client
{
    class Program
    {
        static void Main(string[] args)
        {
            string host = args[0];
            int port = int.Parse(args[1]);
            long x = long.Parse(args[2]);
            string op = args[3];
            long y = long.Parse(args[4]);
            var channel = new Channel(
    host,
    port,
    ChannelCredentials.Insecure
    );
            var client = new Svc.SvcClient(channel);
            var reply = client.Calculate(new CalculateRequest
            {
                X = x,
                Y = y,
                Op = op
            });
            Console.WriteLine($"The calculated result is: {reply.Result}");
        }
    }
}

If you have another machine, you can run the service on o one and the client on another. I tried the service running on Ubuntu Linux and the client on Mac, but any combination of Windows, Mac, Linux should work, provided they have the right version of .net installed.

Before we wrap up this section, The ChannelCredentials.Insecure in

var channel = new Channel(
    host,
    port,
    ChannelCredentials.Insecure
    );

might make you a little nervous, and rightfully so: our setup does not protect the messages traveling over the network in any way. The hackers might sniff what the numbers we are trying to multiply, and we do not want that! In the next part of the series we will remedy this problem.


See also