Core Concepts in Depth
The Builder Pattern: Fluent Configuration
In WitRPC, server and client configuration is done through two static classes: WitServerBuilder for servers and WitClientBuilder for clients. These classes use a fluent API (method chaining) to produce a clear and discoverable configuration sequence. Instead of passing a large configuration object to a constructor, you start with a builder and progressively apply settings using a series of .With...() methods.
This approach offers several advantages:
-
Readability: The configuration code reads like a step-by-step set of instructions, making it easy to see at a glance how the server or client is configured.
-
Discoverability: With an IDE's IntelliSense, developers can type
options.and immediately see a list of available configuration methods, which makes it easy to discover features without constantly checking documentation. -
Immutability and Safety: The builder pattern constructs a complete configuration object before the server or client is instantiated, reducing the risk of runtime errors due to misconfiguration.
A typical configuration chain might look like:
var server = WitServerBuilder.Build(options =>
{
options.WithService(new MyService()) // Set the service implementation
.WithTcp(8080) // Set the transport and port
.WithMessagePack() // Set the serializer
.WithEncryption() // Enable encryption
.WithAccessToken("MySecureToken") // Set a simple authorization token
.WithTimeout(TimeSpan.FromSeconds(10)) // Set the communication timeout
.WithLogger(myLoggerImplementation); // Register a custom logger
});
This fluent, declarative style is the standard (and only) way to configure WitRPC components. It provides a consistent and powerful way to set up your communication stack.
Transports: The Communication Backbone
Transports are the underlying mechanisms that move data between the client and server. WitRPC's pluggable transport architecture lets you choose the most appropriate channel for your application's needs. The choice of transport has significant implications for performance, security, and platform compatibility.
Named Pipes
Mechanism: Uses the Windows Named Pipes facility for communication. It is a highly optimized IPC mechanism.
Use Case: Ideal for high-performance, secure, and reliable communication between processes on the same Windows machine. (Not suitable for network communication.)
Configuration:
-
Server:
options.WithNamedPipe("MyPipeName", maxNumberOfClients: 10); -
Client:
options.WithNamedPipe("MyPipeName", serverName: ".");
Memory Mapped Files (MMF)
Mechanism: Uses a shared memory block (memory-mapped file) for communication, avoiding the kernel-level data copying overhead of other IPC methods.
Use Case: The ultimate choice for ultra-low-latency, high-throughput IPC on a single machine. Particularly effective for transferring large volumes of data between processes with minimal overhead. The inclusion of this transport was inspired by the high-performance IpcServiceFramework project.
Configuration:
-
Server:
options.WithMemoryMappedFile("MyMapName"); -
Client:
options.WithMemoryMappedFile("MyMapName");
TCP
Mechanism: Uses the standard Transmission Control Protocol (TCP) for network communication.
Use Case: The general-purpose choice for cross-platform network communication between different machines. Reliable and widely supported.
Configuration:
-
Server:
options.WithTcp(port: 9000); -
Client:
options.WithTcp("server-hostname-or-ip", port: 9000);
Secure TCP (TLS)
Mechanism: TCP layered with Transport Layer Security (TLS/SSL) for encryption.
Use Case: Essential for secure communication over untrusted networks (e.g., the Internet). Requires an SSL/TLS certificate on the server to establish the secure channel.
Configuration:
-
Server:
options.WithTcpSecure(port: 9001, certificate); -
Client:
options.WithTcpSecure("server-hostname", port: 9001);
WebSockets
Mechanism: Uses the WebSocket protocol, providing a full-duplex communication channel over a single TCP connection (often via HTTP ports).
Use Case: Ideal for communicating with web-based clients (e.g., a Blazor WebAssembly app) or scenarios that need to traverse firewalls and proxies (WebSocket traffic typically appears as standard HTTP/HTTPS traffic).
Configuration:
-
Server:
options.WithWebSocket("http://localhost:9002/my-service/"); -
Client:
options.WithWebSocket("ws://localhost:9002/my-service/");
Transport Comparison Matrix
| Transport | Scope | Platform | Performance | Primary Use Case |
|---|---|---|---|---|
| Named Pipe | Local (IPC) | Windows only | Very High | High-speed communication between processes on the same Windows machine. |
| Memory-Mapped File | Local (IPC) | Cross-Platform | Extremely High (ultra-low latency) | Highest performance IPC, especially for large data transfers on one machine. |
| TCP | Network | Cross-Platform | High | General-purpose, reliable network communication between different machines. |
| Secure TCP (TLS) | Network | Cross-Platform | High (with TLS overhead) | Secure communication over untrusted networks (e.g., the Internet). |
| WebSocket | Network | Cross-Platform | High | Communication with web clients (Blazor) or through firewalls/proxies (appears as HTTP/HTTPS). |
Serialization: Structuring Your Data
Serialization is the process of converting an in-memory object into a format that can be transmitted over a transport and then reconstructed on the receiving end. WitRPC allows you to choose from several popular serializers, each with different trade-offs between performance, payload size, and human readability. Common options include:
-
JSON (
.WithJson()): Uses System.Text.Json. It's human-readable (great for debugging) and the de facto standard for web APIs, but its performance and payload size are generally worse than binary formats. -
MessagePack (
.WithMessagePack()): A fast and compact binary format (often described as "JSON for binary"). It offers significantly better performance than JSON and is a good general-purpose choice when human readability isn't needed. -
MemoryPack (
.WithMemoryPack()): A modern, high-performance binary serializer designed for .NET. Often the fastest option, it minimizes memory allocations and maximizes speed-excellent for performance-critical applications. -
ProtoBuf (
.WithProtoBuf()): Google's Protocol Buffers, a mature binary format known for efficiency and strong schema evolution support. A solid choice for interoperable microservice environments.
Your choice of serializer should be guided by your application's needs. For internal high-performance services, MemoryPack or MessagePack are excellent choices. For services that need wide client compatibility or easy debugging, JSON is often more practical.
Securing Your Communications
WitRPC uses a two-layer security model, providing both confidentiality (encryption) and access control (authorization) out of the box.
Encryption
Enabling encryption in WitRPC (via .WithEncryption()) triggers an automatic cryptographic handshake to establish a secure session. This process is both secure and efficient:
-
Public Key Exchange: Upon connection, the client generates an RSA key pair and sends its public key to the server as part of an initialization request.
-
Symmetric Key Generation: The server receives the client's public key, then generates a cryptographically secure symmetric key and initialization vector (IV) for AES (a fast symmetric encryption algorithm).
-
Secure Key Transfer: The server encrypts the AES key and IV with the client's RSA public key and sends these back to the client. Only the client, which holds the corresponding private key, can decrypt them.
-
Session Encryption: The client decrypts the AES key using its RSA private key. From that point forward, all messages between the client and server are encrypted and decrypted using the fast AES algorithm.
This hybrid RSA/AES approach combines the security of asymmetric cryptography for the initial key exchange with the speed of symmetric cryptography for ongoing communication.
You can control encryption with the following options:
-
.WithEncryption(): Enable the default RSA/AES encryption. -
.WithoutEncryption(): Disable all built-in encryption. (Use this only on a fully trusted network or if another layer of security-such as a VPN-is in place.) -
.WithEncryptor(IEncryptor): Provide a custom encryption implementation. This is essential for environments with specific cryptographic constraints (such as Blazor WebAssembly).
Authorization
WitRPC provides a token-based authorization mechanism to control which clients can access server methods.
The flow works as follows:
-
Client-Side Token Provider: The client is configured with an implementation of
IAccessTokenProvider. This interface has a methodGetToken()that supplies the access token to be sent with each request. -
Server-Side Token Validator: The server is configured with an implementation of
IAccessTokenValidator. This interface has a methodValidate(string token)that checks whether a given token is valid. -
Per-Request Validation: For each remote call, the client's
IAccessTokenProviderprovides a token included in the request. The server extracts the token and uses itsIAccessTokenValidatorto validate it. If validation fails, the server immediately rejects the request with an Unauthorized error.
For simple scenarios, you can use the built-in .WithAccessToken("YourSharedSecret") on both the client and server builders. This sets up a basic provider and validator that expect a matching shared secret string. For more advanced scenarios (such as integrating with OAuth 2.0 or JWT), you can implement custom IAccessTokenProvider and IAccessTokenValidator classes.
Service Proxies: Dynamic vs. Static
When you call client.GetService<TService>(), WitRPC creates a proxy object on the client side that implements the interface TService. WitRPC supports two types of proxies-dynamic and static. The choice is not merely a matter of preference; it's a crucial decision dictated by the target environment and performance requirements.
Dynamic Proxies (Default)
By default, WitRPC uses the Castle.DynamicProxy library to generate proxy classes at runtime. When you call client.GetService<TService>(), the framework dynamically creates a class that implements TService and intercepts all interface method calls, forwarding them into the WitRPC communication pipeline.
Usage: Simply call client.GetService<TService>() to obtain a dynamic proxy instance.
Pros: Extremely convenient-no extra code or build steps are required. You just define your interface, and the framework handles the rest.
Cons: Relies on runtime code generation, which has two drawbacks. First, there is a small performance overhead when the proxy is initially created. More importantly, dynamic proxies are incompatible with certain environments that disallow runtime code generation (such as ahead-of-time compilation scenarios).
Static Proxies (Opt-in)
A static proxy is a class that you write manually at design time. This class explicitly implements the service interface and inherits from WitRPC's RequestInterceptor to dispatch method calls. For example:
// A static proxy implementing IMyService
public class MyStaticProxy : RequestInterceptor, IMyService
{
public MyStaticProxy(IClient client) : base(client) { }
// Implement each interface method:
public Task<string> DoSomethingAsync(int value)
{
// 'Call' (from RequestInterceptor) performs the remote invocation
return Call<string>(new object[] { value });
}
}
To use a static proxy, provide it when obtaining the service proxy:
var service = client.GetService<IMyService>(interceptor => new MyStaticProxy(interceptor));
Pros:
-
High performance: Eliminates the runtime overhead of dynamic proxy generation.
-
AOT/Trimming compatibility: Static proxies work with ahead-of-time (AOT) compilation and application trimming.
Cons: Requires writing boilerplate code for each method of the interface (which can be tedious for large interfaces).
When to Use a Static Proxy
You must use a static proxy in certain scenarios:
-
Blazor WebAssembly with AOT: If using ahead-of-time compilation in a Blazor WebAssembly app, the runtime cannot generate code dynamically. Dynamic proxies will fail, so a static proxy is the only option.
-
Native AOT: When publishing your application as a native AOT executable, all code must be known at compile time. Dynamic proxy generation is fundamentally incompatible with this model.
-
Performance-Critical Startup: Even in JIT-compiled environments, if minimizing startup time or avoiding the overhead of the first RPC call is critical, a static proxy can provide a measurable performance benefit.
In summary, while dynamic proxies offer convenience for development and standard server applications, static proxies are essential for modern high-performance or constrained .NET environments.
Error Handling
WitRPC defines a hierarchy of custom exceptions (all deriving from the base WitException) to allow fine-grained error handling. This enables a client application to react differently to various types of failures.
Key exception types include:
-
WitExceptionTransport- Indicates a problem at the transport layer (e.g., failure to connect, a broken pipe, or a network timeout). -
WitExceptionSerialization- Thrown when an error occurs during serialization or deserialization of a message or its parameters (for example, a data contract mismatch between client and server). -
WitExceptionEncryption- Indicates a failure in the encryption/decryption process (such as an invalid key or corrupted data). -
WitExceptionFault- A special exception that represents an error on the server side during remote method execution. The original server-side exception is serialized back to the client and re-thrown inside aWitExceptionFault. This allows the client to inspect the server's stack trace and error message for debugging.
A robust client application should catch these exceptions around remote calls. For example:
try
{
await client.ConnectAsync(...);
var service = client.GetService<IMyService>();
var result = await service.DoSomethingAsync();
}
catch (WitExceptionTransport ex)
{
// Handle connection or network issues
Console.WriteLine($"Transport error: {ex.Message}");
}
catch (WitExceptionFault ex)
{
// Handle errors that occurred on the server side
Console.WriteLine($"Server fault: {ex.Message}");
Console.WriteLine($"Server stack trace: {ex.ErrorDetails}");
}
catch (WitException ex)
{
// Handle any other WitRPC-related errors
Console.WriteLine($"Communication error: {ex.Message}");
}