Summary
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).
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.
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);
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.
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:
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.
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.
Message flow for the 'RPC-one way' sample.
Server
The server implements a Math
API containing a single function:
class MathAPI
{
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.
Server.ListenAsync(8000, CancellationToken.None,
(c, wc) => c.Bind<MathAPI>(new MathAPI()))
.Wait(0);
Client
The client has to have a matching contract (interface):
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.
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.
var apis = await RPC.For<IMathAPI>();
int r = apis.CallAsync(x => x.Add(5, 3)).First();
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.
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.
interface IProgressAPI
{
void WriteProgress(float progress);
}
class TaskAPI
{
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.
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>();
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
.
class ProgressAPI
{
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.
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);
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.
var code = RPCJs.GenerateCallerWithDoc<TaskAPI>();
File.WriteAllText("TaskAPI.js", code);
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.
var api = new TaskAPI("ws://localhost:8001");
api.writeProgress = function (p)
{
console.log("Completed: " + p * 100 + "%");
return true;
}
api.connect(async () =>
{
var r = await api.longRunningTask(5, 3);
console.log("Result: " + r);
});
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).
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
.
class JpgBase64Converter : JsonConverter
{
private Type supportedType = typeof(Bgr<byte>[,]);
...
}
Finally, to register the converter, we just call RPC.AddConverter(...)
with an instance of an object converter.
RPC.AddConverter(new JpgBase64Converter());
...
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:
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.
RPC call being debugged while having an exception (.NET).
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.
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:
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:
Route.Add("/{route}", (rq, rp, args) => rp.AsText(args["route"]));
Server.ListenAsync(8000, CancellationToken.None, (c, wsContext) => c.Bind(...),
Route.OnHttpRequestAsync)
.Wait(0);
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 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.
class Startup
{
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
app.UseWebSockets();
app.MapWebSocketRPC("/taskService",
(httpCtx, c) => c.Bind<TaskAPI, IProgressAPI>(new TaskAPI()));
}
}
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