Introduction to Lightweight WebSocket RPC Library for .NET

In this article, I will be giving you a complete introduction to Lightweight WebSocket RPC Library for .NET. An article describing an RPC library for JavaSocket that establishes raw connections establishes full-duplex RPCs, and generates JavaScript client code. Among the functions of the library are:

  • lightweight
    Serialization and deserialization are handled by JSON.NET.
  • simple
    Bind binds an object or interface to a connection, while CallAsync invokes an RPC.

Furthermore, it can:

  • Assemblies from third parties can be used as APIs
    A library doesn’t have to know about the API it uses if it’s used only for RPC. A connection is simply binded to a desired object by the library.
  • Code generation for WebsocketRPC.JS (WebsocketRPC.JS package)
    An existing .NET API contract provides the foundation for automatic generation of JavaScript WebSocket client code (with JsDoc comments).

Establishing a connection

A WebSocket server or client creates a connection for sending/receiving messages. Each function has a few common arguments: an address, a cancellation token, and a callback function for connecting. If there is an error, proper exception handling is enabled by async events (Open, Close, Receive, Error).

A message is sent from the server, displayed on the client, and the connection is closed.

Server.ListenAsync(8000, CancellationToken.None, (c, wsContext) => 
{
  c.OnOpen    += ()     => c.SendAsync("Hello world");
  c.OnError   += err    => Task(() => Console.WriteLine("Error: " + err.Message));
})
.Wait(0);
Client.ConnectAsync("ws://localhost:8000/", CancellationToken.None, c => 
{
  c.OnOpen    += ()        => Task(() => Console.WriteLine("Opened"));
  c.OnClose   += (s, d)    => Task(() => Console.WriteLine("Closed: " + s));
  c.OnError   += err       => Task(() => Console.WriteLine("Error: " + err.Message));
  c.OnReceive += async msg => { Console.WriteLine(msg); await c.CloseAsync() };
})
.Wait(0);

/*
 Output: 
   Opened
   Hello world
   Closed: NormalClosure
*/

As in the example above, a standalone server/client works by assigning each connection to a long-running task, so when heavy processing is required, all CPU cores are utilized.

Calling a remote procedure

An object/interface is associated with a connection when the RPC is initialized. The WebSocketRPC API is based on two basic methods:

  1. Bind<TObj>(TObj obj) - used to bind local objects to connections. An object method invocation is invoked and a local invoker instance is created.A binding function for a TInterface - is for bind an interface to an outgoing connection. An invoker instance is created to convert type-safe code into a text message that can be sent remotely.
  2. This method calls a remote function through an async process CallAsync<TInterface>(...). This method is located in the static RPC class.

Using those two groups of functions is illustrated in the following code snippets.

One Way RPC:

The RPC connection is one way: the client calls the server. The two parts (applications) are both written in .NET. Below is a flowchart of the message.

Lightweight WebSocket RPC Library for .NET
A sample RPC-one-way message flow.

Server

A single function is implemented by the server’s Math API:

class MathAPI //:IMathAPI
{
    public int Add(int a, int b)
    {
        return a + b;
    }
}

A new connection is awaited in the main method. New connections are related to API objects by binding to Bind(new MathAPI()). An API instance that is shared can also be created.

Server.ListenAsync(8000, CancellationToken.None, 
                   (c, wc) => c.Bind<MathAPI>(new MathAPI()))
      .Wait(0);

Client

An interface (contract) must match the client’s:

interface IMathAPI
{
    int Add(int a, int b);
}

Clients are used to connect to servers. When a new connection is created, it is associated with the IMathAPI interface with the Bind<IMathAPI>() call.

Client.ConnectAsync("ws://localhost:8000/", CancellationToken.None, 
                    (c, ws) => c.Bind<IMathAPI>())
      .Wait(0);

A static RPC class containing all bindings is used to call the remote function when the connection is opened. In order to call all IMathAPI interface connections, the first step is to select all IMathAPI connections and then use the IMathAPI>(…) call. Selector First() chooses the result since there is only one client, so there is only one connection.

var apis = await RPC.For<IMathAPI>();   
int r = apis.CallAsync(x => x.Add(5, 3)).First(); //r = 8

Two Way RPC:

Clients and servers communicate with each other via two-way binding (e.g., clients call the API method on the server, while servers call the progress-update method on the client).The image below shows the schematic.

Lightweight WebSocket RPC Library for .NET
This sample shows the flow of messages for the ‘RPC-two-way’ protocol.

Server

Clients that call a server’s TaskAPI method during execution are notified of progress updates.

interface IProgressAPI
{
  void WriteProgress(float progress);
}

class TaskAPI //:ITaskAPI
{
  public async Task<int> LongRunningTask(int a, int b)
  {
    for (var p = 0; p <= 100; p += 5)
    {
       await Task.Delay(250);
       await RPC.For<IProgressAPI>(this).CallAsync(x => x.WriteProgress((float)p / 100));
    }
		
    return a + b;
  }
}

Only connections connected to this object and to IProgressAPI are selected by the RPC.For(…) selector. By selecting such clients, we filter out those that don’t actually make the call but implement the IProgressAPI. Therefore, progress updates will be sent only to clients who called.

await RPC.For<IRemoteAPI>(this).CallAsync(x => x.WriteProgress((float)p / 100));

An opened connection to the server is bound to both the local (object) and remote (interface) APIs once it has been started. There is nothing more to this call than Bind<TObj>(TObj obj); Bind<TInterface>();

Server.ListenAsync(8000, CancellationToken.None, 
                  (c, wc) => c.Bind<TaskAPI, IProgressAPI>(new TaskAPI()))
      .Wait(0);

Client

IProgressAPI is implemented by the client and is matched by the TaskAPI implemented by the server.

class ProgressAPI //:IProgressAPI
{
  void WriteProgress(float progress)
  {
      Console.Write("Completed: " + progress * 100 + "%\r");
  }
}

interface ITaskAPI
{
  Task<int> LongRunningTask(int a, int b);
}

It is very similar to the previous sample in terms of setting up a connection using the Client class and binding it. Clients also implement their own ProgressAPI, so we have two-way communication.

Client.ConnectAsync("ws://localhost:8000/", CancellationToken.None, 
                   (c, wc) => c.Bind<ProgressAPI, ITaskAPI>(new ProgressAPI()))
      .Wait(0);
...
var r = RPC.For<ITaskAPI>().CallAsync(x => LongRunningTask(5, 3)).First();
Console.WriteLine("Result: " + r);


/*
 Output:
   Completed: 0%
   Completed: 5%
     ...
   Completed: 100%
   Result: 8
*/

JavaScript Client:

JavaScript is often used as a client instead of .NET in many scenarios. A client can be created from an interface or a class declared in the library. In order to generate JsDoc comments from .NET XML comments, it is necessary to enable XML file generation first.

Server

As in the two-way binding example, we’ll use the same server implementation, but we’ll write the client in JavaScript. The GenerateCallerWithDoc<T> function creates JavaScript code; you just have to save it.

//the server code is the same as in the previous sample

//generate JavaScript client (file)
var code = RPCJs.GenerateCallerWithDoc<TaskAPI>();
File.WriteAllText("TaskAPI.js", code);
Lightweight WebSocket RPC Library for .NET
‘RPC-two-way’ sample JavaScript client code generated automatically.

Client

It is first necessary to instantiate the generated API, which contains the RPC code for WebSockets. By simply adding the writeProgress function to the API instance, IProgressAPI can be implemented. In the final step, a connection to the remote API will be made and the function connect(onConnect) is called.

//init API
var api = new TaskAPI("ws://localhost:8001");

//implement the interface by extending the 'TaskAPI' object
api.writeProgress = function (p)
{
  console.log("Completed: " + p * 100 + "%");
  return true;
}

//connect and excecute (when connection is opened)
api.connect(async () => 
{
  var r = await api.longRunningTask(5, 3);
  console.log("Result: " + r);
});

Serialization

Parameters and return values have only been used for simple object types to date. Obviously, this isn’t true in every situation.

Imagine a processing API for images. It returns a 2D RGB image array (Bgr<byte>[,] – DotImaging framework).

public class ImageProcessingAPI
{
    public Bgr<byte>[,] ProcessImage(Uri imgUri)
    {..}
}

During transmission, base-64 JPG images are created from 2D RGB arrays. We need only build a simple data converter: JpgBase64Converter, since the serialization mechanism is built on JSON.NET.

class JpgBase64Converter : JsonConverter
{
    private Type supportedType = typeof(Bgr<byte>[,]);
    ...
    //omitted for the simplicity and the lack of importance
}

The converter can be registered for use by calling RPC.AddConverter(…) with its object converter instance.

RPC.AddConverter(new JpgBase64Converter());
...
//the rest of the Client/Server code

Settings

Two types of settings have been described: connection settings and RPC settings. Each of the settings is a static member of the class in which it is found.

Setting up a connection:

  • MaxMessageSizeIn bytes, the maximum size of a message supported. Message-to-big errors are logged when the amount is exceeded.
  • EncodingDecoding and encoding of message text. In order to decode a message properly, all messages must use this encoding.

RPC settings:

  • RpcTerminationDelayAn OperationCancelledException will be thrown if a remote procedure does not complete within the maximum amount of time.

Handling Exceptions:

The consumer will also throw an exception if an exception occurs in the remote procedure.In this way, debugging is similar to debugging a local program.

Debugging a .NET RPC call with an exception.
Exception being thrown during RPC call debugging (JavaScript).

Code will throw an exception if the debugger isn’t attached to it during execution. Async-based functions are used for all events for better exception management.

Several events can be defined for the provided connection if logging is desired.

HTTP Support

HTTP capabilities are also often requested by WebSocket servers and clients. HTTP request handlers are implemented as a single parameter in both the server and client implementations, as shown below:

static async Task ListenAsync(
                      ..., 
                      Func<HttpListenerRequest, HttpListenerResponse, Task> onHttpRequestAsync, 
                      ...) 
{...}   

static async Task ConnectAsync(
                    ..., 
                    Func<HttpListenerRequest, HttpListenerResponse, Task> onHttpRequestAsync, 
                    ...) 
{...}

SimpleHTTP library is available as a NuGet package and can be used out of the box. The following example illustrates what we mean:

//add GET route which returns the requested route as a text. (SimpleHTTP library)
Route.Add("/{route}", (rq, rp, args) => rp.AsText(args["route"]));    

//assign 'OnHttpRequestAsync' to the HTTP handler action.
Server.ListenAsync(8000, CancellationToken.None, (c, wsContext) => c.Bind(...), 
                   Route.OnHttpRequestAsync)
.Wait(0);

HTTPS/WSS Support

HttpListener is configured with a certificate to enable secure (HTTPS/WSS) connections. Since the generic OS support has not yet been completed at the time of writing – January 2018 – a Windows-based approach will be explained. .NET Core issue tracker on Github has the latest status.

An HTTPS reservation can be made using netsh and a certificate can be imported into the local certificate storage on Windows. Within the repository of the SimpleHTTP library, there are two scripts that are part of the Script map. As a first step, a test certificate is generated from the first script, which is then imported into the store, and a HTTPS reservation is made according to the second script. Richard Astbury’s blog post provides instructions for how to do it manually (non-script).

Support of ASP.NET Core

A NuGet package named WebSocketRPC.AspCore provides ASP.NET support. Initialization is performed in the Configure method of a startup class. UseWebSockets() is called first to initialize the socket support. For each API we might have, we call the MapWebSocketRPC(…) extension method. Routes are associated with API connections through this call. If we have more than one API, we can use the call multiple times.

class Startup
{
    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        //the MVC initialization, etc.

        //initialize web-sockets
        app.UseWebSockets();
        //define route for a new connection and bind the API
        app.MapWebSocketRPC("/taskService", 
                            (httpCtx, c) => c.Bind<TaskAPI, IProgressAPI>(new TaskAPI()));
    }
}

Conclusion

A lightweight RPC library for WebSockets, that supports raw connections, full-duplex APIs, and auto-generation of JScript client code, is described in this article. By using samples, we were able to demonstrate the capabilities of the library and show a simple concept of RPC binding. In the repository, you’ll find all the samples ready for you to test out.

Leave a Comment