Have you ever tried building services with gRPC before? If not, you are in luck (or not)! Let’s explore how to do it with dotnet.
For the longest time, I have been avoiding any form of RPC because of the warnings and complexity that I have read and heard about. For example, some of my colleagues shared the nightmare that they had to endure coordinating RPC usage between multiple teams for a service that was rapidly changing. But, a lot has changed since those papers were written and the stories were told, both in technology and language. Are the concerns still valid, specifically with gRPC and C#? I would like to find out.
We will start from the beginning because I need to get my bearings around gRPC first before I could do anything else.
The Plan
I was using dotnet 8 at the time of writing, so things might be a tad different when you are reading this. You could refer to the docs and tutorial from Microsoft for more up-to-date info.
There are a few things I would like to learn from this initial run, namely:
What is the experience of creating a new API? This includes writing the contract/models and generating code.
How are the tools for grokking the API during development? e.g. something like Postman, cURL or Httpie.
For that, I would like to create a simple in-memory DB service. It will be exposing a C# Dictionary
via the API with some super simple requirements:
Set
operation with a stringkey
andvalue
parameters to add to theDictionary
. Overrides any existing ones.Get
operation with a stringkey
. Returns stringvalue
ifkey
exists, otherwise, empty.
Installation
The project name would be MemDB
(very creative, I know) and run the following:
> dotnet new grpc --name MemDB
You should see the following structure generated:
The Protos directory is where all the gRPC contracts goes into. And if you open the MemDB.csproj
file, you should see this:
<ItemGroup>
<Protobuf Include="Protos\greet.proto" GrpcServices="Server" />
</ItemGroup>
That is required to auto-generate the gRPC files. Let’s go into the MemDB
folder and run dotnet buil
`. You should see a couple of files generated under the obj
folder:
GreetGrpc.cs
contains the base class that is extended by the concrete implementation of the service within GreeterService.cs
. Before we could verify everything is working as expected, a few things needed to be installed. Run the following to enable gRPC reflection to your service:
> dotnet add package Grpc.AspNetCore.Server.Reflection --version 2.63.0
This is required for interacting with the gRPC service using tools like gRPC UI and gRPCurl. Otherwise, you’ll have to pass the .proto
files to the tools for reference. Go to gRPCurl for installation instruction for your OS.
Modify the Program.cs
so that it looks something like below:
using MemDB.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc();
// Add this line
builder.Services.AddGrpcReflection();
var app = builder.Build();
// Configure the HTTP request pipeline.
app.MapGrpcService<GreeterService>();
app.MapGet("/", () => "Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909");
// Add this line
app.MapGrpcReflectionService();
app.Run();
From within MemDB
folder, start the project by dotnet run
. You should see something like below (the port address is set at Properties/launchSettings.json
)
Now, let’s grok your gRPC service with gRPCurl. First, let’s see what are the services available:
> grpcurl --plaintext localhost:5086 list
greet.Greeter
grpc.reflection.v1alpha.ServerReflection
To introspect the operations available for the service greet.Greeter
, run the following:
> grpcurl --plaintext localhost:5086 describe greet.Greeter
greet.Greeter is a service:
service Greeter {
rpc SayHello ( .greet.HelloRequest ) returns ( .greet.HelloReply );
}
If you want to know the structure of the HelloRequest
message:
> grpcurl --plaintext localhost:5086 describe greet.HelloRequest
greet.HelloRequest is a message:
message HelloRequest {
string name = 1;
}
Of course, you could just have a look at the .proto
file or GreeterService.cs
to get the same info. But, this might come in handy someday. I think it is an opportunity to play around with the available tools.
To make a request to the service and see how the payload and response look like, just run something like below:
> grpcurl --plaintext -d '{"name":"world"}' localhost:5086 greet.Greeter.SayHello
{
"message": "Hello world"
}
Creating our first service, MemDB
All the above were auto-generated. Now, let’s get our hands dirty and write a simple service to get a handle on how things work. A refresher on the requirements:
Set
operation with a stringkey
andvalue
parameters to add to theDictionary
. Overrides any existing ones.Get
operation with a stringkey
. Returns stringvalue
ifkey
exists, otherwise, nothing.
First, create a record.proto
file under Protos/
It should look something like below:
syntax = "proto3";
option csharp_namespace = "MemDB";
package record;
service Record {
rpc Set (SetRequest) returns (SetResponse);
rpc Get (GetRequest) returns (GetResponse);
}
message SetRequest {
string key = 1;
string value = 2;
}
message SetResponse {
bool success = 1;
}
message GetRequest {
string key = 1;
}
message GetResponse {
string value = 1;
}
Notice how the .proto
file describes the:
Record service
Get and Set operation for said service
input and output message for both operations
Reading the .proto
file, you get an idea of the contract for the request. If you are familiar with OpenAPI, you’ll notice how succinct the format is, without the status codes and verbosity that comes with JSON. It looks and feels for humans to consume, rather than machine, which is nice. But, the missing info also means a lot of the other stuff is handled within the client and server configurations or code.
Next, add record.proto
to your MemDB.cproj
.
<ItemGroup>
<Protobuf Include="Protos\greet.proto" GrpcServices="Server" />
<!-- add this line -->
<Protobuf Include="Protos\record.proto" GrpcServices="Server" />
</ItemGroup>
Run dotnet build
from within MemDB
and you should see a couple of new files under obj
.
The basic setup for the gRPC service is done. Now, we have to write the data model and very basic logic. Nothing fancy, just a class with dictionary as its storage. Here are the interface, concrete implementation and the service class.
// within MemDB/Interfaces folder
namespace MemDB.Interfaces;
public interface IRecordDB
{
bool Set(string key, string value);
string Get(string key);
}
// within MemDB/Models folder
using System.Collections.Concurrent;
using MemDB.Interfaces;
namespace MemDB.Models;
public class DictionaryDB : IRecordDB
{
protected ConcurrentDictionary<string, string> DB { get; set; } = new ConcurrentDictionary<string, string>();
public string Get(string key)
{
if (this.DB.TryGetValue(key, out string? result))
{
return result;
}
return "";
}
public bool Set(string key, string value)
{
return this.DB.TryAdd(key, value);
}
}
// within MemDB/Services folder
using Grpc.Core;
using MemDB.Interfaces;
namespace MemDB.Services;
public class RecordService : Record.RecordBase
{
protected IRecordDB Record { get; set; }
public RecordService(IRecordDB record)
{
this.Record = record;
}
public override Task<GetResponse> Get(GetRequest request, ServerCallContext context)
{
return Task.FromResult(new GetResponse
{
Value = this.Record.Get(request.Key)
});
}
public override Task<SetResponse> Set(SetRequest request, ServerCallContext context)
{
this.Record.Set(request.Key, request.Value);
return Task.FromResult(new SetResponse
{
Success = true
});
}
}
Some notable things about the RecordService
, it is extending the autogenerated Record.RecordBase
file and the overridden methods have two parameters, request
which matches the message structure within the record.proto
file and context
which contains info about the request, e.g. request metadata and authentication.
Once those are done, modify the Program.cs
file to reflect these changes:
using MemDB.Interfaces;
using MemDB.Models;
// add this
using MemDB.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc();
builder.Services.AddGrpcReflection();
// add this
builder.Services.AddSingleton<IRecordDB, DictionaryDB>();
var app = builder.Build();
// Configure the HTTP request pipeline.
app.MapGrpcService<GreeterService>();
// add this
app.MapGrpcService<RecordService>();
app.MapGet("/", () => "Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909");
app.MapGrpcReflectionService();
app.Run();
dotnet build
then dotnet run
. Now, let’s introspect the service again.
> grpcurl --plaintext localhost:5086 list
greet.Greeter
grpc.reflection.v1alpha.ServerReflection
record.Record
> grpcurl --plaintext localhost:5086 describe record.Record
record.Record is a service:
service Record {
rpc Get ( .record.GetRequest ) returns ( .record.GetResponse );
rpc Set ( .record.SetRequest ) returns ( .record.SetResponse );
}
> grpcurl --plaintext localhost:5086 describe record.GetRequest
record.GetRequest is a message:
message GetRequest {
string key = 1;
}
> grpcurl --plaintext localhost:5086 describe record.SetRequest
record.SetRequest is a message:
message SetRequest {
string key = 1;
string value = 2;
}
We have verified that the new service is up and running and all the contracts appear to be as expected. Next, let’s verify the logic. Some things I would like to check:
1. Get
a missing key returns empty.
2. Set
a key and then Get
it back, works as expected.
> grpcurl --plaintext -d '{"key":"key1"}' localhost:5086 record.Record.Get
{}
> grpcurl --plaintext -d '{"key":"key1","value":"value1"}' localhost:5086 record.Record.Set
{
"success": true
}
> grpcurl --plaintext -d '{"key":"key1"}' localhost:5086 record.Record.Get
{
"value": "value1"
}
There we have it, our first gRPC service. Nothing fancy. My goal was just to explore and get a taste of the process. So far, the experience has been pleasant. Link to working code in Github.
p/s: Writing post with code in Substack is horrendous.