Modular RPCs is a transparent communication layer between two CLR/.NET programs, optionally over a network.
It uses a Roslyn source generator or dynamic code generation to create stubs for send methods that write data to binary streams and send it over whatever medium is required.
It is currently in a semi-stable preview state, but has the majority of the features needed to be effective and is pretty well tested. However, the code-base is very complex so there may still be bugs that haven't come to light yet. I use the library in a few of my projects so it has some use 'in the field'.
- Dynamic IL code generation (TypeBuilders, DynamicMethods) for scenerios where a source generator can't be used.
- Use
virtualinstead ofpartialfor send methods.
- Use
- Remote-cancellation using a
CancellationToken. - Optimized 'Raw' send/receive methods that can directly send binary data without the overhead of serialization.
- Define custom serializers for any type.
- Built-in
IServiceProvidersupport for fetching instances of types. - Exception handling.
- Return values and exception handling using
RpcTask[<>].- An exception in a receive method will be rethrown by the send method if awaited.
- Multi-cast broadcasts
- Doesn't support return values.
- First-class support for Unity components through the DanielWillett.ModularRpcs.Unity package.
- Roslyn incremental source generator.
- Legacy support all the way back to .NET Standard 2.0.
- .NET Framework 4.6.1+, .NET Core 2.0+, .NET 5+, Unity 2018.1+, Mono 5.4+
- Source generators for Roslyn 3.11, 4.0, 4.4, and 4.8. Source-gen should support Unity version 2020.2 and later.
- Supports return types of any awaitable type (or regular values), not just
Task<>.- Doesn't apply to awaitables that use extension members.
- Supports null values, nullable value types, collections, strings (UTF-8), and provides optimizations for primtive types.
- Separate optimized workflows for working with Streams or raw binary data to avoid unnecessary data copying.
- Static functions.
- Automatic type serialization/deserialization.
- Support for other formats like JSON.
- Dictionary serialization.
Third party packages can be made fairly easily to add support for other transport modes. Take a look at some of the existing packages in this repository for an example.
- Loopback
- Communicate with objects in the same process (app domain).
- Web Sockets
- Communicate over the
ws://orwss://protocols.
- Communicate over the
- Named Pipes
- Communicate with objects usually on the same computer. Uses the Named Pipes API for interprocess communication.
All the following types can be serialized/parsed individually or in an enumerable.
Enum and nullable arrays are not supported by default.
- bool, char, double, float, int, long, uint, ulong, short, ushort, sbyte, byte, nint, nuint
- Half (.NET 5+)
- Int128, UInt128 (.NET 7+)
- decimal
- DateTimeOffset
- DateTime
- TimeSpan
- Guid
- String (any encoding, UTF8 by default)
- All enums
- Nullable value types of any supported value type
- Collections of any supported value type.
- Vector2, Vector3, Vector4
- Bounds
- Color, Color32
- Quaternion
- Matrix4x4
- Plane
- Ray, Ray2D
- Rect
- Resolution
| Property | Description | Default |
|---|---|---|
| DisableModularRPCsSourceGenerator | Disables source generation features. | False |
Install via NuGet.
<ItemGroup>
<PackageReference Include="DanielWillett.ModularRpcs" Version="*" />
<!-- One of (or a third-party transport mode) -->
<PackageReference Include="DanielWillett.ModularRpcs.NamedPipes" Version="*" />
<PackageReference Include="DanielWillett.ModularRpcs.WebSockets" Version="*" />
<!-- If using UnityEngine -->
<PackageReference Include="DanielWillett.ModularRpcs.Unity" Version="*" />
<!-- ReflectionTools v3 is used by ModularRPCs, recommended to update to v4 -->
<PackageReference Include="DanielWillett.ReflectionTools" Version="4.0.0" />
</ItemGroup>The following example registers the necessary services on a server-like process using extension methods and sets up the server using the Named Pipes transport mode.
IServiceCollection collection = new ServiceCollection()
.AddLogging(l => l.AddConsole())
// register logging for ReflectionTools package
.AddReflectionTools(isStaticDefault: true)
// register all ModRPC services for a server.
.AddModularRpcs(
isServer: true,
// optional
(services, config, parsers, parserFactories) =>
{
// set serialization config properties
config.StringEncoding = Encoding.ASCII;
config.MaximumGlobalArraySize = 256;
config.MaximumStringLength = 8192;
config.MaximumArraySizes[typeof(byte)] = 16384;
// or
services.GetRequiredService<IConfiguration>()
.GetSection("ModularRPCs")
.Bind(config);
// register type parsers
// note: clients need to have the same parsers as the server
parsers[typeof(Point)] = new PointParser();
parsers[typeof(Size)] = new SizeParser();
parsers[typeof(Vector)] = new VectorParser();
// register IArrayBinaryTypeParser parsers
parsers.AddManySerializer<Point>(
new PointParser.Many(config)
);
// register IBinaryParserFactory implementations
parserFactories.Add(new DictionaryParserFactory(config));
},
// optional (singleton or scoped?)
scoped: false
)
// adds a service that will be initialized as an RPC object
.AddRpcService<IPostDispatchService, PostDispatchService>();
/// <summary>
/// Hosts the named pipes server using ModularRPCs.
/// </summary>
internal class ModularRpcsServer : IHostedService
{
private readonly IServiceProvider _serviceProvider;
private NamedPipeEndpoint? _endpoint;
public ModularRpcsServer(IServiceProvider serviceProvider)
{
// if you'd prefer to not inject the service provider,
// you can supply the services needed to NamedPipeEndpoint.CreateServerAsync instead
_serviceProvider = serviceProvider;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
const string pipeName = "Company.Product.RPCs";
_endpoint = NamedPipeEndpoint.AsServer(_serviceProvider, pipeName);
await _endpoint.CreateServerAsync(cancellationToken);
// server is ready to receive clients
}
public async Task StopAsync(CancellationToken cancellationToken)
{
if (_endpoint != null)
{
// CloseServerAsync is the same as disposing
await _endpoint.CloseServerAsync(cancellationToken);
_endpoint = null;
}
}
}The following example registers the necessary services on a client-like process using extension methods and sets up the client to connect via a Named Pipe.
IServiceCollection collection = new ServiceCollection()
.AddLogging(l => l.AddConsole())
// register logging for ReflectionTools package
.AddReflectionTools(isStaticDefault: true)
// register all ModRPC services for a client.
// for optional configuration, see server example
// note: clients need to have the same parsers as the server
.AddModularRpcs(isServer: false)
// adds a service that will be initialized as an RPC object
.AddRpcService<PostEmailNotificationService>();
/// <summary>
/// Hosts a connection the server using Named Pipes.
/// </summary>
internal class ModularRpcsClient : IHostedService
{
private readonly IServiceProvider _serviceProvider;
private NamedPipeEndpoint? _endpoint;
private NamedPipeClientsideRemoteRpcConnection? _connection;
public ModularRpcsClient(IServiceProvider serviceProvider)
{
// if you'd prefer to not inject the service provider,
// you can supply the services needed to NamedPipeEndpoint.RequestConnectionAsync instead
_serviceProvider = serviceProvider;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
const string pipeName = "Company.Product.RPCs";
_endpoint = NamedPipeEndpoint.AsClient(_serviceProvider, pipeName);
_connection = await _endpoint.RequestConnectionAsync(TimeSpan.FromSeconds(15d), cancellationToken); // 15s timeout
// client is connected
}
public async Task StopAsync(CancellationToken cancellationToken)
{
if (_connection != null)
{
await _connection.CloseAsync(cancellationToken);
_connection.Dispose();
_connection = null;
}
}
}The following example shows a scenerio where a client in a game can define a RPC linked to a specific animal (using IRpcObject<>).
When done with an IRpcObject, you must call the Release extension method on it.
(this is not required on Unity components)
[GenerateRpcSource]
public partial class Animal : IRpcObject<int>
{
// part of IRpcObject
public int Identifier { get; }
public Animal(int instanceId)
{
Identifier = instanceId;
}
/// <summary>
/// Sends a request to the server to interact with this animal.
/// </summary>
/// <returns>The result of the interaction</returns>
[RpcSend(nameof(ReceiveJump)]
public partial RpcTask<string> SendInteractRequestAsync(
Vector3 interactPoint, // value parameter
CancellationToken token = default // injected parameter
);
[RpcReceive]
private async Task<string> HandleInteractRequest(
Vector3 interactPoint, // value parameter (mapped by order)
IModularRpcLocalConnection client, // injected parameter
CancellationToken token // injected parameter
)
{
// check to make sure the client should be allowed to interact (proximity check perhaps)
if (!CanClientInteract(client, interactPoint))
throw new InvalidOperationException();
// perform the interaction (omitted for brevity)
InteractionResult r = await InteractAsync(interactPoint, token);
return r.Message;
}
}The following example shows one service in one process broadcasting an event to all clients in the IRpcConnectionLifetime.
namespace Company.Services;
[GenerateRpcSource]
public partial class PostDispatchService : IPostDispatchService
{
public PostDispatchService(/* ... */)
{
// ...
}
public async Task HandlePostReceived(Post post)
{
BroadcastNewPost(post.Id).IgnoreNoConnections();
}
[RpcSend]
private void BroadcastNewPost(uint postPk);
// or (not really any reason to do it this way)
[RpcSend, RpcFireAndForget]
private RpcBroadcastTask BroadcastNewPost(uint postPk);
}
[GenerateRpcSource]
// set default target type assembly-qualified name, can also be supplied in RpcReceive
// can use typeof if the assembly is loaded.
[RpcDefaultTargetType("Company.Services.PostDispatchService, CompanyAssembly")]
public partial class PostEmailNotificationService
{
// listen for PostDispatchService.BroadcastNewPost to be sent.
[RpcReceive("BroadcastNewPost")]
private async Task ReceiveNewPost(uint postPk)
{
// query post info from DB and send email
}
}The following example defines a custom data type that implements IRpcSerializable, which allows a type to define their own serialization callbacks.
Defining a type this way automatically adds the binary parser to the serializer and also adds support for collections and nullable values of this type.
Reference types must define a public parameterless constructor if another constructor is present. Value types always start as their default value (zero'd) and will not call any constructors.
[RpcSerializable(
minimumSize: sizeof(int) + sizeof(char) + SerializationHelper.MinimumStringSize,
// isFixedSize indicates whether or not all instances of this type will be the exact same size.
// this allows for significant performance boosts for fixed types
isFixedSize: false
)]
public struct CustomDataType : IRpcSerializable
{
public int Int32;
public string String;
public char Character;
// calculates the size of this object.
// must return the exact size that will be written to in Write
public int GetSize(IRpcSerializer serializer)
{
return sizeof(int) + sizeof(char) + serializer.GetSize(String);
}
// writes this object's data to the binary buffer
// returns the number of bytes written for error-checking purposes.
public int Write(Span<byte> writeTo, IRpcSerializer serializer)
{
int w = sizeof(int) + sizeof(char);
Unsafe.WriteUnaligned(ref writeTo[0], Int32);
Unsafe.WriteUnaligned(ref writeTo[4], Character);
w += serializer.WriteObject(String, writeTo.Slice(6));
return w;
}
// reads this object's data from the binary buffer
// returns the number of bytes read for error-checking purposes.
public int Read(Span<byte> readFrom, IRpcSerializer serializer)
{
int r = sizeof(int) + sizeof(char);
Int32 = Unsafe.ReadUnaligned<int>(ref readFrom[0]);
Character = Unsafe.ReadUnaligned<char>(ref readFrom[4]);
String = serializer.ReadObject<string>(readFrom.Slice(6), out int bytesRead);
r += bytesRead;
return r;
}
}The following example shows an advanced case where it may be beneficial to send raw binary data without the overhead of serialization.
See documentation for Raw for a list of supported byte collection types.
public async Task SendSomeBinary()
{
int ovhSize = ProxyGenerator.Instance.CalculateOverheadSize(SendBinary, out int idStartIndex);
int size = 32 + ovhSize;
byte[] buffer = new byte[size];
// Uncomment if IRpcObject, WriteIdentifier is an extension method
// this.WriteIdentifier(buffer + idStartIndex);
for (int i = 0; i < 32; i++)
buffer[i + ovhSize] = (byte)i;
// the array isn't reused, so canTakeOwnership is true
// if it was stack-allocated or part of a static buffer, this should be false
await SendBinary(buffer, size, true);
}
/// <param name="data">The binary data to send.</param>
/// <param name="size">Number of bytes to read (optional).</param>
/// <param name="canTakeOwnership">Whether or not the data can be accessed if there's a context change (like if a method is awaited).</param>
[RpcSend(nameof(ReceiveBinary), Raw = true]
private partial async RpcTask SendBinary(byte[] data, int size, bool canTakeOwnership);
// params mean the same thing as above.
// canTakeOwnership may vary with different transport implementations
// if canTakeOwnership is false, data must be copied to a new buffer BEFORE awaiting
[RpcReceive(Raw = true)]
private void ReceiveBinary(byte[] data, bool canTakeOwnership)
{
if (!canTakeOwnership)
{
byte[] newArray = new byte[data.Length];
Buffer.BlockCopy(data, 0, newArray, 0, data.Length);
data = newArray;
}
await Task.Delay(5000);
for (int i = 0; i < 32; i++)
Console.WriteLine(data[i]);
}The following example shows how to initialize ModularRPCs without a service provider and create a UnityEngine component that can send/receive RPC messages.
The code generater has to create an OnDestory method, so Unity components can implement IExplicitFinalizerRpcObject if they also need to run code when the component is destroyed. This is only for source-generated types.
Dynamically generated types must make Start and OnDestroy virtual if they're defined.
/* server-side initialization without a service provider */
// optionally add logging
void LogCallback(Type src, LogSeverity severity, Exception? exception, string? message)
{
Debug.Log($"[{src.Name}][{severity}] {message}{(exception == null ? string.Empty : Environment.NewLine + exception)}";
}
ProxyGenerator.Instance.SetLogger(LogCallback);
ServerRpcConnectionLifetime lifetime = new ServerRpcConnectionLifetime();
// or ClientRpcConnectionLifetime lifetime = new ClientRpcConnectionLifetime();
DefaultSerializer serializer = new DefaultSerializer(/* optional config */);
RpcRouter router = new RpcRouter(serializer, lifetime, ProxyGenerator.Instance);
// optionally add logging
serializer.SetLogger(LogCallback);
router.SetLogger(LogCallback);
/* object creation */
int instanceId = _nextInstanceId++;
GameObject gameObject = new GameObject("Animal");
Animal animal = gameObject.AddRpcComponent<Animal>(router);