I recently wrote about TaskCompletionSource, a little-known tool in .NET that is great for transforming arbitrary asynchrony into the Task-Based Asynchronous Pattern. That means you can hide the whole thing behind a simple and elegant async
/await
.
In this article, we’ll see this in practice as we implement the Remote Procedure Call (RPC) pattern in RabbitMQ. This is a fancy way of saying request/response, except that it all happens asynchronously! That’s right. No blocking.
The source code for this article is in the RabbitMqRpc folder at the Gigi Labs BitBucket Repository.
The RabbitMQ.Client NuGet package is necessary to make this code work. The client is written using an asynchronous Main()
method, which requires at least C# 7.1 to compile.
RabbitMQ RPC Overview
You can think of RPC as request/response communication. We have a client asking a server to process some input and return the output in its response. However, this all happens asynchronously. The client sends the request on a request queue and forgets about it, rather than waiting for the response. Eventually, the server will (hopefully) process the request and send a response message back on a response queue.
The request and response can be matched on the client side by attaching a CorellationId to both the request and the response.
In this context, we don’t really talk about publishers and consumers, as is typical when talking about messaging frameworks. That’s because in order to make this work, both the client and the server must have both a publisher and a consumer.
Client: Main Program
For our client application, we’ll have the following main program code. We will implement an RpcClient that will hide the request/response plumbing behind a simple Task that we then await
:
static async Task Main(string[] args)
{
Console.Title = "RabbitMQ RPC Client";
using (var rpcClient = new RpcClient())
{
Console.WriteLine("Press ENTER or Ctrl+C to exit.");
while (true)
{
string message = null;
Console.Write("Enter a message to send: ");
using (var colour = new ScopedConsoleColour(ConsoleColor.Blue))
message = Console.ReadLine();
if (string.IsNullOrWhiteSpace(message))
break;
else
{
var response = await rpcClient.SendAsync(message);
Console.Write("Response was: ");
using (var colour = new ScopedConsoleColour(ConsoleColor.Green))
Console.WriteLine(response);
}
}
}
}
The program continuously asks for input, and sends that input as the request message. The server will process this message and return a response. Note that we are using the ScopedConsoleColour class from my “Scope Bound Resource Management in C#” article to colour certain sections of the output. Here is a taste of what it will look like:
While this console application only allows us to send one request at a time, the underlying approach is really powerful with APIs that can concurrently serve a multitude of clients. It is asynchronous and can scale pretty well, yet the consuming code sees none of the underlying complexity.
Client: Request Sending
The heart of this abstraction is the RpcClient class. In the constructor, we set up the typical plumbing: create a connection, channel, queues, and a consumer.
public class RpcClient : IDisposable
{
private bool disposed = false;
private IConnection connection;
private IModel channel;
private EventingBasicConsumer consumer;
private ConcurrentDictionary<string,
TaskCompletionSource<string>> pendingMessages;
private const string requestQueueName = "requestqueue";
private const string responseQueueName = "responsequeue";
private const string exchangeName = ""; // default exchange
public RpcClient()
{
var factory = new ConnectionFactory() { HostName = "localhost" };
this.connection = factory.CreateConnection();
this.channel = connection.CreateModel();
this.channel.QueueDeclare(requestQueueName, true, false, false, null);
this.channel.QueueDeclare(responseQueueName, true, false, false, null);
this.consumer = new EventingBasicConsumer(this.channel);
this.consumer.Received += Consumer_Received;
this.channel.BasicConsume(responseQueueName, true, consumer);
this.pendingMessages = new ConcurrentDictionary<string,
TaskCompletionSource<string>>();
}
// ...
}
A few other things to notice here:
- We are keeping a dictionary that allow us to match responses with the requests that generated them, based on a CorrelationId. We have already seen this approach in “TaskCompletionSource by Example“.
- This class implements IDisposable, as it has several resources that need to be cleaned up. While I don’t show the code for this for brevity’s sake, you can find it in the source code.
- We are not using exchanges here, so using an empty string for the exchange name allows us to use the default exchange and publish directly to the queue.
The SendAsync() method, which we saw being used in the main program, is implemented as follows:
public Task<string> SendAsync(string message)
{
var tcs = new TaskCompletionSource<string>();
var correlationId = Guid.NewGuid().ToString();
this.pendingMessages[correlationId] = tcs;
this.Publish(message, correlationId);
return tcs.Task;
}
Here, we are generating GUID to use as a CorrelationId, and we are adding an entry in the dictionary for this request. This dictionary maps the CorrelationId to a corresponding TaskCompletionSource. When the response arrives, it will set the result on this TaskCompletionSource, which enables the underlying task to complete. We return this underlying task, and that’s what the main program awaits. The main program will not be able to continue until the response is received.
In this method, we are also calling a private Publish()
method, which takes care of the details of publishing to the request queue on RabbitMQ:
private void Publish(string message, string correlationId)
{
var props = this.channel.CreateBasicProperties();
props.CorrelationId = correlationId;
props.ReplyTo = responseQueueName;
byte[] messageBytes = Encoding.UTF8.GetBytes(message);
this.channel.BasicPublish(exchangeName, requestQueueName, props, messageBytes);
using (var colour = new ScopedConsoleColour(ConsoleColor.Yellow))
Console.WriteLine($"Sent: {message} with CorrelationId {correlationId}");
}
While this publishing code is for the most part pretty standard, we are using two particular properties that are especially suited for the RPC pattern. The first is CorrelationId, where we store the CorrelationId we generated earlier, and which the server will copy and send back as part of the response, enabling this whole orchestration. The second is the ReplyTo property, which is used to indicate to the server on which queue it should send the response. We don’t need it for this simple example since we are always using the same response queue, but this property enables the server to dynamically route responses where they are needed.
Server
The request eventually reaches a server which has a consumer waiting on the request queue. Its Main()
method is mostly plumbing that enables this consumer to work:
private static IModel channel;
static void Main(string[] args)
{
Console.Title = "RabbitMQ RPC Server";
var factory = new ConnectionFactory() { HostName = "localhost" };
using (var connection = factory.CreateConnection())
{
using (channel = connection.CreateModel())
{
const string requestQueueName = "requestqueue";
channel.QueueDeclare(requestQueueName, true, false, false, null);
// consumer
var consumer = new EventingBasicConsumer(channel);
consumer.Received += Consumer_Received;
channel.BasicConsume(requestQueueName, true, consumer);
Console.WriteLine("Waiting for messages...");
Console.WriteLine("Press ENTER to exit.");
Console.WriteLine();
Console.ReadLine();
}
}
}
When a message is received, the Consumer_Received
event handler processes the message:
private static void Consumer_Received(object sender, BasicDeliverEventArgs e)
{
var requestMessage = Encoding.UTF8.GetString(e.Body);
var correlationId = e.BasicProperties.CorrelationId;
string responseQueueName = e.BasicProperties.ReplyTo;
Console.WriteLine($"Received: {requestMessage} with CorrelationId {correlationId}");
var responseMessage = Reverse(requestMessage);
Publish(responseMessage, correlationId, responseQueueName);
}
In this example, the server’s job is to reverse whatever messages it receives. Thus, each response will contain the same message as in the corresponding request, but backwards. This reversal code is taken from this Stack Overflow answer. Although trivial to implement, this serves as a reminder that there’s no need to reinvent the wheel if somebody already implemented the same thing (and quite well, at that) before you.
public static string Reverse(string s)
{
char[] charArray = s.ToCharArray();
Array.Reverse(charArray);
return new string(charArray);
}
Having computed the reverse of the request message, and extracted both the CorrelationId and ReplyTo properties, these are all passed to the Publish()
method which sends back the response:
private static void Publish(string responseMessage, string correlationId,
string responseQueueName)
{
byte[] responseMessageBytes = Encoding.UTF8.GetBytes(responseMessage);
const string exchangeName = ""; // default exchange
var responseProps = channel.CreateBasicProperties();
responseProps.CorrelationId = correlationId;
channel.BasicPublish(exchangeName, responseQueueName, responseProps, responseMessageBytes);
Console.WriteLine($"Sent: {responseMessage} with CorrelationId {correlationId}");
Console.WriteLine();
}
The response is sent back on the queue specified in the ReplyTo property of the request message. The response is also given the same CorrelationId as the request; that way the client will know that this response is for that particular request.
Client: Response Handling
When the response arrives, the client’s own consumer event handler will run to process it:
private void Consumer_Received(object sender, BasicDeliverEventArgs e)
{
var correlationId = e.BasicProperties.CorrelationId;
var message = Encoding.UTF8.GetString(e.Body);
using (var colour = new ScopedConsoleColour(ConsoleColor.Yellow))
Console.WriteLine($"Received: {message} with CorrelationId {correlationId}");
this.pendingMessages.TryRemove(correlationId, out var tcs);
if (tcs != null)
tcs.SetResult(message);
}
The client extracts the CorrelationId from the response, and uses it to get the TaskCompletionSource for the corresponding request. If the TaskCompletionSource is found, then its result is set to the content of the response. This causes the underlying task to complete, and thus the caller awaiting that task will be able to resume and work with the result.
If the TaskCompletionSource is not found, then we ignore the response, and there is a reason for this:
“You may ask, why should we ignore unknown messages in the callback queue, rather than failing with an error? It’s due to a possibility of a race condition on the server side. Although unlikely, it is possible that the RPC server will die just after sending us the answer, but before sending an acknowledgment message for the request. If that happens, the restarted RPC server will process the request again. That’s why on the client we must handle the duplicate responses gracefully, and the RPC should ideally be idempotent.” — RabbitMQ RPC tutorial
Demo
If we run both the client and server, we can enter messages in the client, one by one. The client publishes each message on the request queue and waits for the response, at which point it allows the main program to continue by setting the result of that request’s TaskCompletionSource.
Summary
What we have seen in this article is the same material I had explained in “TaskCompletionSource by Example“, but with a real application to RabbitMQ.
A TaskCompletionSource has an underlying Task that can represent a pending request. By giving each request an ID, you can keep track of it as the corresponding response should carry the same ID. A mapping between request IDs and TaskCompletionSource can easily be kept in a dictionary. When a response arrives, its corresponding entry in the dictionary can be found, and the Task can be completed. Any client code awaiting this Task may then resume.