Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / ASP.NET / ASP.NET-Core

Introducing Lightweight WebSocket RPC Library for .NET

5.00/5 (20 votes)
14 Jan 2018CPOL8 min read 62.7K  
WebSocket RPC library for .NET with auto JavaScript client code generation, supporting ASP.NET Core.
Image 1

Summary

Introduction

This article presents the lightweight WebSocket RPC library with capabilities of establishing raw connections, full-duplex RPCs and auto-generating JavaScript client code. The library is designed to be:

  • lightweight
    The only dependency is JSON.NET library used for serialization/deserialization.

  • simple
    There are only two relevant methods: Bind for binding object/interface onto connection, and CallAsync for invoking RPCs.

In addition, it can:

  • use 3rd party assemblies as API(s)
    Implemented API, if used only for RPC, does not have to know anything about the library. The library just binds a connection to a desired object.

  • generate JavaScript code automatically (WebsocketRPC.JS package)
    The JavaScript WebSocket client code is automatically generated (with JsDoc comments) from an existing .NET interface (API contract).

Making a Connection

A connection where user defined or RPC messages are sent/received is instantiated by a WebSocket server or a client. Both, the server and the client function, have common arguments: address, cancelation token and a connection callback. A connection has async events (Open, Close, Receive, Error) which enable proper exception handling in case of an error.

In the code snippet below, the server sends a message, the client displays it and closes a connection.

C#
Server.ListenAsync(8000, CancellationToken.None, (c, wsContext) => 
{
  c.OnOpen    += ()     => c.SendAsync("Hello world");
  c.OnError   += err    => Task(() => Console.WriteLine("Error: " + err.Message));
})
.Wait(0);
C#
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
*/

If standalone server/client is used, as in the sample above, each connection is associated with its own long-running task, so all CPU cores are used in a case whereby heavy-processing is required.

Making a Remote Procedure Call

The RPC is initialized by associating an object/interface with a connection. There are two base methods that make the most crucial part of the entire WebSocketRPC library's API:

  1. Bind<TObj>(TObj obj) - for binding a local object to a connection (incoming calls). The call creates a local invoker instance responsible for interpreting incoming text messages and an object method invocation.

    Bind<TInterface>() - for binding an interface to a connection (outgoing calls). The call creates a remote invoker instance responsible for converting type-safe code into a text message sent to a remote caller.

  2. CallAsync<TInterface>(...) - for calling a remote function. The call resides in the static RPC class.

The examples on how to use those two group of functions are shown in the following sample code-snippets.

RPC - One Way

Let us start with a one way RPC connection: client calling server functions. Both parts (applications) are implemented using .NET. The message flow is shown below.

Image 2

Message flow for the 'RPC-one way' sample.

Server

The server implements a Math API containing a single function:

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

In the main method, we start the server and wait for a new connection. When a new connection is available, the connection is associated with a created API object using Bind(new MathAPI()) call. Having single shared API instance is also possible.

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

Client

The client has to have a matching contract (interface):

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

We connect to a server using a client. A newly created connection is associated with the IMathAPI interface using Bind<IMathAPI>() call.

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

When connection is opened, the remote function is called using static RPC class which contains all binders. First, all connections associated with IMathAPI interface are selected using For<TInterface>() call and then called using CallAsync<IMathAPI>(...) call. Since we have only once client, thus only one connection, selector First() selects the result.

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

RPC - Two Way

Two way binding represents mutual client-server RPC calls (e.g. client calls a server's' API method, and server calls client's progress-update method). The schematic is shown by the image below.

Image 3

Message flow for the 'RPC-two way' sample.

Server

The server's TaskAPI has a function which during its execution updates progress and reports it only to clients which called the method.

C#
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;
  }
}

The RPC.For(...) selector selects only those connections which are associated with IProgressAPI and with this object. Such selection, filters out clients which implement the IProgressAPI but did not make the actual call. Therefore, only clients which made the call will receive progress update.

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

When the server is started and a new connection is opened, the connection is bound to both the local (object) and remote (interface) API using Bind<TObj, TInterface>(TObj obj) call. The call is just a shorthand for: Bind<TObj>(TObj obj); Bind<TInterface>();

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

Client

Client implements the IProgressAPI and has an interface matching the server TaskAPI.

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

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

A connection is established using the Client class and bound in a very similar way as in the previous sample. The main difference is that the client also implements its own ProgressAPI, so we have a two-way binding.

C#
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

There are many scenarios where a client will not be implemented in .NET, but in JavaScript. The library enables you to create a client from a declared interface or a class. The created API will also have JsDoc comments generated from XML .NET comments if they exist - XML file generation must be enabled first.

Server

Let us use the same server implementation as in the two-way binding sample, but this time the client will be written in JavaScript. The GenerateCallerWithDoc<T> function generates JavaScript code and all that is left, is to save the code to a file.

C#
//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);

Image 4

Auto-generated JavaScript client code for the 'RPC-two way' sample.

Client

The generated API, containing the WebSocket RPC code, is first instantiated. In order to implement IProgressAPI interface, the API instance is just extended by the required function writeProgress. The final step is calling the connect(onConnect) function and calling the remote API function.

C#
//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

So far, only simple object types have been used as arguments or return values. In real scenarios, that might not be the case.

Let us imagine we have an image processing API. A defined function ProcessImage returns 2D RGB image array (Bgr<byte>[,] - DotImaging framework).

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

In order to transmit image data, 2D RGB array is converted to base-64 JPG image. The serialization mechanism is built around JSON.NET, so all we need is to write a simple data converter: JpgBase64Converter.

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

Finally, to register the converter, we just call RPC.AddConverter(...) with an instance of an object converter.

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

Settings

The settings can be divided into two groups: connection settings and RPC settings. All the settings are the static members of their respective classes.

Connection settings:

  • MaxMessageSize

    Maximum supported message size in bytes. In case the amount is exceeded, the connection is closed with the message-to-big reason.

  • Encoding

    Message text encoding/decoding. All the messages must be transferred using this encoding in order to be properly decoded.

RPC settings:

  • RpcTerminationDelay

    The maximum amount of time in which a remote procedure must finish, otherwise OperationCancelledException is thrown.

Exception Handling

If an exception occurs in a remote procedure, an exception will also be thrown on a consumer side. This way, debugging experience is similar to debugging a local code.

Image 5

RPC call being debugged while having an exception (.NET).

Image 6

RPC call being debugged while having an exception (JavaScript).

If the code is run without the debugger attached, an exception will finally be available in OnError event. All the events are async-based functions for the reason of better exception management.

If logging is required, just use some of the provided connection's events, which can be defined multiple times.

HTTP Support

In many cases, WebSocket server/client requests HTTP capabilities as well. The server and the client implementation have a HTTP request handler implemented as a single parameter as shown below:

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

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

Although, it may be used out of the box, to keep things simple, one can use SimpleHTTP library available as a NuGet package. An example is shown below:

C#
//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

To enable secure (HTTPS/WSS) connection for the standalone server, a certificate setup for HttpListener is applied. A Windows-based approach will be explained, because the generic OS support is not yet ready at the time of writing - Jan 2018. The current status may be seen in: Github Issue Tracker - .NET Core.

Windows-based solution includes importing a certificate into the local certificate storage and making the appropriate HTTPS reservation using netsh utility. The library includes two scripts, located in the Script map of a repository of the SimpleHTTP library. The first script generates a test certificate, and the other imports the certificate into the store and makes the HTTPS reservation. Steps on how to do it manually (the non-script way) are given inside the Richard Astbury's blog post.

ASP.NET Core Support

ASP.NET support is provided by the WebSocketRPC.AspCore NuGet package. The initialization is done in a startup class in the Configure method. First, the socket support is initialized by calling UseWebSockets(). Finally, MapWebSocketRPC(...) extension method is called for each API we might have. The call associates a route with an API connection. The call may be used multiple times for each API we might have.

C#
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

This article presented the lightweight WebSocket RPC library with capabilities of establishing raw connections, full-duplex RPCs and auto-generating JavaScript client code. The main goal was to show a simple concept of RPC binding and to demonstrate the capability of the library through samples. The full samples are in the repository just waiting to be tested and put to good use. :)

History

  • 11th January 2018 - First version released

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)