Implementing the Request-Response pattern using C# with JSON-RPC and WebSockets

The Request-Response pattern seems somewhat simple on the surface; send a request, then wait for and receive a response that matches up with the original request.

However, implementing this pattern in an efficient manner is something that is easy to get wrong.

I’ve found that much of the material online regarding this topic tends to be either much too abstract, with no concrete examples provided, or it is much too verbose with examples that are very hard to follow along with or relate to in a meaningful way.

In this article, I will walk you through a working .NET solution using C# to implement the Request-Response pattern. I will use a realistic scenario to help convey the solution in a relatable way.

Example scenario

I’m going to show you how a specific RPC (Remote Procedure Call) protocol (JSON-RPC) and a transport protocol (WebSockets) can be used together to provide a realistic demonstration of the Request-Response pattern.

With JSON-RPC being an RPC protocol and WebSockets being a transport protocol, they are completely separate concepts. However, as you’ll see shortly, they can work together rather wonderfully, given their particular attributes.

Our mission is to implement the Request-Response pattern such that we can transmit a JSON-RPC request message over a WebSocket connection and subsequently receive the JSON-RPC response message, matching it up to the waiting request.

The key to matching up a request with a response is to define a unique ‘Correlation ID’ (i.e. Request ID) which is used to join the messages together. We’ll see how this works in more detail whenever we reach the code walkthrough section.

Protocols

Before diving into the code, let’s take a look at the protocols we’ll be using in the code samples.

JSON-RPC

JSON-RPC is a very light-weight RPC protocol that uses JSON to describe the format of messages which are transmitted between client and server. Being transport-agnostic, it can be used for a wide variety of purposes and works great when it is transmitted over WebSockets, thanks to its small payload.

For our solution, we’ll be using version 2.0 of the JSON-RPC protocol.

Here’s an overview of JSON-RPC for reference, taken from the official specification webpage.

JSON-RPC is a stateless, light-weight remote procedure call (RPC) protocol… It is transport agnostic in that the concepts can be used within the same process, over sockets, over http, or in many various message passing environments. It uses JSON (RFC 4627) as data format.

It is designed to be simple!

JSON-RPC is a natural evolution of the XML-RPC protocol.

JSON-RPC has been a standard for some time now and as it is based on JSON you can create JSON schemas to document any APIs that you define.

WebSockets

WebSockets is a transport protocol that allows two-way (full-duplex) communication between a client and a server. It has been stable since 2011 and opens up lots of new possibilities compared to the traditional HTTP client to server approach where requests are always initiated from the client.

Here’s the abstract from the official IETF RFC webpage.

The WebSocket Protocol enables two-way communication between a client running untrusted code in a controlled environment to a remote host that has opted-in to communications from that code. The security model used for this is the origin-based security model commonly used by web browsers. The protocol consists of an opening handshake followed by basic message framing, layered over TCP. The goal of this technology is to provide a mechanism for browser-based applications that need two-way communication with servers that does not rely on opening multiple HTTP connections (e.g., using XMLHttpRequest or <iframe>s and long polling).

WebSockets is a very efficient protocol and can offer millisecond responses thanks to the fact that the connection is persistent (i.e. remains open) and the compact message format that is employed.

Code walkthrough

Let’s move on and look at the C# code which is required to implement the Request-Response pattern using JSON-RPC and WebSockets.

Note that a fully working version of all of the code I’m about to share with you can be found on my GitHub repository.

NuGet packages

I’m going to make use of two popular NuGet packages, namely JSON-RPC.NET and WebSocketSharp.

JSON-RPC.NET is a high-performance JSON-RPC server framework and it includes classes that we can use when serializing and deserializing JSON-RPC messages on the client.

WebSocketSharp is a WebSocket server and has client components too. It allows us to use WebSockets without requiring the client or server to be on a specific version of Windows which includes WebSocket protocol support.

Note that I’m also making use of Serilog for logging.

Solution overview

The code I’m going to show you focuses on a custom JsonRpcClient class which uses a WebSocket connection to send requests and receive responses.

The JsonRpcClient class uses a dictionary to track the Correlation/Request ID for each request and the associated pending response. The dictionary uses a string key to hold the Request ID and it uses a TaskCompletionSource with a string result property for the response value.

A naive approach to waiting for the response would be to use a loop and sleep for a small period of time on each loop iteration until the response has been received. However, this would be both ugly and horribly inefficient. TaskCompletionSource offers us a much cleaner and efficient solution.

With TaskCompletionSource we can store a reference to a Task. We can choose when to start the Task and we can subsequently mark the Task as completed. This gives us a lot of control, allowing us to send a request and then wait for the server to respond before completing the Task.

If you haven’t come across TaskCompletionSource before, hopefully, this concept will make more sense after you see the code which uses it in action further below.

You can read more about TaskCompletionSource in the Microsoft Docs.

Setting up the client

The JsonRpcClient class mentioned in the previous section is just a regular public class and its constructor accepts a WebSocket instance, as per the sample code below.

/// <summary>
/// Constructor.
/// </summary>
/// <param name="webSocket">The WebSocket channel to use</param>
public JsonRpcClient(WebSocket webSocket)
{
    _webSocket            = webSocket;
    _webSocket.OnMessage += ProcessMessage;
}

The constructor assigns the WebSocket parameter value to a private readonly field and wires up an event handler for the OnMessage event.

The OnMessage event will be fired by the WebSocketSharp framework every time the server sends a message to the client. This will allow us to receive the responses relating to the requests we’ll be making to the server.

We’ll look at how the ProcessMessage method works further below.

Creating requests

In order to send a JSON-RPC request to the server, we need a way of constructing the JSON-RPC request object which will be serialized and transmitted over the WebSocket connection.

To achieve this, we can write a CreateRequest method as per the code below.

/// <summary>
/// Creates a JSON-RPC request for the specified method and parameters.
/// </summary>
/// <param name="method">The method name</param>
/// <param name="parameters">The list of parameters to pass to the method</param>
/// <returns><see cref="JsonRequest"/></returns>
public JsonRequest CreateRequest(string method, object parameters)
{
    // Get the next available Request ID.
    int nextRequestId = Interlocked.Increment(ref _requestId);
 
    if (nextRequestId > MaximumRequestId)
    {
        // Reset the Request ID to 0 and start again.
        Interlocked.Exchange(ref _requestId, 0);
 
        nextRequestId = Interlocked.Increment(ref _requestId);
    }
 
    // Create and return the Request object.
    var request = new JsonRequest(method, parameters, nextRequestId);
 
    return request;
}

JSON-RPC.NET contains a JsonRequest class that we can make use of.

Before creating and returning the JsonRequest object, the Increment method of the Interlocked class is used to get the next available Request ID.

Note that _requestId is a private static instance field. By using the Interlocked class methods we can access and update the Request ID safely from multiple threads.

Response tracking

The JsonRpcClient class contains a Responses Dictionary named _responses, as follows.

/// <summary>
/// Used to keep track of server responses.
/// </summary>
private static readonly ConcurrentDictionary<string, TaskCompletionSource<string>> _responses
    = new ConcurrentDictionary<string, TaskCompletionSource<string>>();

Although in practice an integer number is typically used for the Request ID when using JSON-RPC, the dictionary key is set to a string in order to keep things generic. The JSON-RPC specification allows numbers or strings to be used as a Request ID.

The type of the TaskCompletionSource result value is also a string. It is intended to hold the JSON string received from the server which will be deserialized into a response object.

Note that the Responses Dictionary is a ConcurrentDictionary which allows it to be accessed safely from multiple threads without requiring explicit locking.

Sending requests

Here’s the complete code for the SendRequest method which sends the specified JSON-RPC request to the server over the WebSocket connection then waits for and returns the response to the caller.

/// <summary>
/// Sends the specified request to the WebSocket server and gets the response.
/// </summary>
/// <typeparam name="TResult">The type of the expected result object</typeparam> /// <param name="request">The JSON-RPC request to send</param> /// <param name="timeout">The timeout (in milliseconds) for the request</param>
/// <returns>The response result</returns> public TResult SendRequest<TResult>(JsonRequest request, int timeout = 30000) {     var tcs       = new TaskCompletionSource<string>();     var requestId = request.Id;     try     {         string requestString = JsonConvert.SerializeObject(request);         // Add the Request details to the Responses dictionary so that we have            // an entry to match up against whenever the response is received.         _responses.TryAdd(Convert.ToString(requestId), tcs);         // Send the request to the server.         Log.Verbose($"Sending request: {requestString}");         _webSocket.Send(requestString);         Log.Verbose("Finished sending request");         var task = tcs.Task;         // Wait here until either the response has been received,         // or we have reached the timeout limit.         Task.WaitAll(new Task[] { task }, timeout);         if (task.IsCompleted)         {             // Parse the result, now that the response has been received.             JsonResponse response = JsonConvert.DeserializeObject<JsonResponse>(task.Result);             string responseString = JsonConvert.SerializeObject(response);             Log.Verbose($"Received response: {responseString}");             // Throw an Exception if there was an error.             if (response.Error != null) throw response.Error;             // Return the result.             return JsonConvert.DeserializeObject<TResult>(                 Convert.ToString(response.Result),                 new JsonSerializerSettings                  {                      Error = (sender, args) => args.ErrorContext.Handled = true                 });         }         else // Timeout response.         {             Log.Error($"Client timeout of {timeout} milliseconds has expired, throwing TimeoutException");             throw new TimeoutException();         }     }     catch (Exception ex)     {         Log.Error(ex, "An error occurred.");         throw;     }     finally     {         // Remove the request/response entry in the 'finally' block to avoid leaking memory.         _responses.TryRemove(Convert.ToString(requestId), out tcs);     } }

There’s quite a bit going on in this method and this is the key area of the code which implements the Request-Response pattern, so let’s break it down into manageable chunks.

First of all, we set up a TaskCompletionSource which is used to track the state of our request and which will ultimately hold the response which is returned to the caller.

Next, we serialize the JsonRequest object to a JSON string so that we can send it across the wire.

Before sending the request to the server, we add a new item to our Responses Dictionary. We use the Request ID from the JsonRequest object which was passed in as the key. A reference to the TaskCompletionSource we have created is used as the value.

The JSON-RPC message is then sent to the WebSocket server as a string.

After sending the request to the server, the WaitAll method of the Task class is used to wait until either the Task has completed or the specified timeout value has been reached.

If the Task has completed, the code deserializes the Result of the Task into a JsonResponse object.

If the response contains an error, an exception is thrown (JSON-RPC.NET maps JSON-RPC errors nicely into a JsonRpcException for us).

Otherwise, the Result property of the JsonResponse object is deserialized into the type of object expected by the caller and is returned to the same.

If the Task didn’t complete, a TimeoutException is thrown.

Lastly, in the finally block, the request/response entry is removed from the Responses Dictionary to avoid leaking memory which is, of course, a crucial consideration!

Processing responses

When the WaitAll method of the Task class has been called in the SendRequest method, the code is waiting at this point until the Task which is held within the TaskCompletionSource object in the Responses Dictionary has been marked as completed.

Meanwhile, the server will hopefully send the client a response over the WebSocket and this will be processed by the ProcessMessage method which is shown below.

/// <summary>
/// Processes messages received over the WebSocket connection.
/// </summary>
/// <param name="sender">The sender (WebSocket)</param>
/// <param name="e">The Message Event Arguments</param>
private void ProcessMessage(object sender, MessageEventArgs e)
{
    // Check for Pings.
    if (e.IsPing)
    {
        Log.Verbose("Received Ping");
        return;
    }
 
    Log.Debug("Processing message");
 
    // Log when the message is Binary.
    if (e.IsBinary)
    {
        Log.Verbose("Message Type is Binary");
    }
 
    Log.Verbose($"Message Data: {e.Data}");
 
    // Parse the response from the server.
    JsonResponse response = JsonConvert.DeserializeObject<JsonResponse>(
        e.Data,
        new JsonSerializerSettings 
        { 
            Error = (sender, args) => args.ErrorContext.Handled = true 
        });
 
    // Check for an error.
    if (response.Error != null)
    {
        // Log the error details.
        Log.Error("Error Message: " + response.Error.message);
        Log.Error("Error Code: "    + response.Error.code);
        Log.Verbose("Error Data: "  + response.Error.data);
    }
 
    // Set the response result.
    if (_responses.TryGetValue(Convert.ToString(response.Id), out TaskCompletionSource<string> tcs))
    {
        tcs.TrySetResult(e.Data);
    }
    else
    {
        Log.Error("Unexpected response received. ID: " + response.Id);
    }
 
    Log.Debug("Finished processing message");
}

This is the method that gets executed whenever the OnMessage event is fired by WebSocketSharp.

The first few lines of the method checks for ‘Pings’ and logs some information regarding the message. In the case of a ping being received, the code simply logs a message and returns, as a ping message is simply used to check that the WebSocket connection is still alive.

The Data property of the MessageEventArgs parameter value contains the message data as a string. The code deserializes the data into a JsonResponse object, then checks and logs error details, if there are any.

Lastly, the code checks if the Responses Dictionary contains a matching Request ID and if it does the result of the TaskCompletionSource is set. This will mark the related Task as completed and will allow the SendRequest method to continue.

Proxies

Now that we’ve looked at the internals of the JsonRpcClient class, let’s see how we can use some more specific abstractions for calling server JSON-RPC methods.

In order to make the implementation more useable, we can create a ‘proxy’ class that accepts a JsonRpcClient instance. Below I have included what the constructor for the TodoServicesProxy class looks like.

/// <summary>
/// Constructor.
/// </summary>
/// <param name="client">The JSON-RPC client</param>
public TodoServicesProxy(JsonRpcClient client)
{
    _client = client;
}

The JsonRpcClient parameter value which is passed into the constructor is assigned to a private readonly field.

We can now implement an instance method that abstracts the API request to the server and hides all of the underlying implementation details from the caller.

/// <summary>
/// Gets a collection of Todos.
/// </summary>
/// <param name="userId">The ID of the User to get Todos for (optional)</param>
/// <returns>A collection of all available Todos</returns>
public IEnumerable<Todo> GetTodos(int? userId = null)
{
    Log.Debug($"Getting Todos");
 
    var request  = _client.CreateRequest("getTodos"new { userId });
    var response = _client.SendRequest<IEnumerable<Todo>>(request);
 
    Log.Debug($"Found {response.Count()} Todos");
 
    return response;
}

Here we can see how JsonRpcClient makes it really easy to create and send a request to the server and get the appropriate type of response object back.

In the GetTodos method we simply create the request by calling the CreateRequest method with the appropriate parameters, then we send the request using the SendRequest method and return the response.

Demo API call

Let’s wrap up the code walkthrough with a demonstration of all of the pieces working together.

// Connect to the WebSocket server.
using var webSocket = new WebSocket("ws://localhost:4649/json-rpc");
webSocket.Connect();
 
// Create the JSON-RPC client.
var client = new JsonRpcClient(webSocket);
 
// Send the request and get the response.
var proxy = new TodoServicesProxy(client);
var todos = proxy.GetTodos(userId: 2);

After connecting to the WebSocket server and creating the JsonRpcClient instance, we can new up a proxy class e.g. TodoServicesProxy and call one of the available RPC methods.

This approach works really well for abstracting away the underlying complexities of matching up requests with responses and allows the caller to make API calls in much the same way that they would for local methods.

Summary

In summary, we’ve discussed the fundamentals of the Request-Response pattern and how to implement it using C#.

We’ve used JSON-RPC in combination with WebSockets to demonstrate a realistic scenario which I trust you found interesting to follow along with.

I hope that you have found this article insightful and perhaps you’ll find a real-world use case for using JSON-RPC and WebSockets together.

The complete code from this article can be found on my GitHub repository. As a bonus, I have also implemented a WebSocket JSON-RPC server which can be used when testing the solution.

Comments

This site uses Akismet to reduce spam. Learn how your comment data is processed.