Strongly typed proxies
A client may of course issue loosely typed RPC requests, like this:
using JsonRpc jsonRpc = JsonRpc.Attach(rpcStream);
int sum = await jsonRpc.InvokeAsync<int>("AddAsync", 2, 5);
But it can provide a superior experience to use strongly-typed proxies.
When a JSON-RPC server's API is expressed as a .NET interface, StreamJsonRpc can create a proxy that implements that interface to expose a strongly-typed client for your service. These proxies can be created using either the static Attach<T>(Stream) method or the instance Attach<T>() method (or their overloads).
This changes the above example to something like this:
static async Task WithProxies(Stream rpcStream)
{
ICalculator calc = JsonRpc.Attach<ICalculator>(rpcStream);
using (calc as IDisposable)
{
int sum = await calc.AddAsync(2, 5);
}
}
[JsonRpcContract]
internal partial interface ICalculator
{
Task<int> AddAsync(int a, int b);
}
Proxy traits
Generated proxies have the following traits:
- They default to passing arguments using positional arguments, with an option to send requests with named arguments instead.
- Methods are sent as ordinary requests (not notifications) when they return some form of Task or ValueTask.
They are sent as notifications if they return
void
. - Events on the interface are raised locally when a notification with the same name is received from the other party.
- They implement IJsonRpcClientProxy, allowing access back to the JsonRpc instance that created them.
- They implement IDisposable. If the proxy is created using a static Attach<T>(Stream) overload, disposal of the proxy is forwarded to Dispose().
RPC interfaces
A proxy can only be generated for an interface that meets these requirements:
- No properties.
- No generic methods.
- All methods return
void
, Task, Task<TResult>, ValueTask, ValueTask<TResult>, or IAsyncEnumerable<T>. - All events are typed with EventHandler or EventHandler<TEventArgs>. The JSON-RPC contract for raising such events is that the request contain exactly one argument, which supplies the value for the
T
in EventHandler<TEventArgs>. - Methods may accept a CancellationToken as the last parameter.
The RPC interface may derive from IDisposable and is encouraged to do so as it encourages folks who hold proxies to dispose of them and thereby close the JSON-RPC connection.
Applying the JsonRpcContractAttribute to all RPC interfaces is strongly encouraged, as it has two significant benefits:
- Enables analyzers to report warnings due to violations of the above rules at compile-time instead of throwing at runtime.
- Enables source generated proxies to be used at runtime instead of proxies created dynamically at runtime, leading to faster startup and NativeAOT compatibility.
Server-side concerns
On the server side, these same methods may be simple and naturally synchronous.
Returning values from the server wrapped in a Task may seem unnatural.
The server need not itself explicitly implement the interface -- it could implement the same method signatures as are found on the interface except return void
(or whatever your T
is in your Task<TResult> method signature on the interface) and it would be just fine.
class Calculator
{
public int Add(int a, int b) => a + b;
}
Of course implementing the interface may make it easier to maintain a consistent contract between client and server. In which case, you can declare your server methods to also return Task<TResult> values and implement synchronous methods to return values using Task.FromResult.
class Calculator : ICalculator
{
public Task<int> AddAsync(int a, int b) => Task.FromResult(a + b);
}
Client-side concerns
Sometimes a client may need to block its caller until a response to a JSON-RPC request comes back. The proxy maintains the same async-only contract that is exposed by the JsonRpc class itself. Learn more about sending requests, particularly under the heading about async responses.
Dynamic proxies
The following concerns are related specifically to dynamically generated proxies and do not apply to source generated proxies.
AssemblyLoadContext considerations
When in a .NET process with multiple AssemblyLoadContext (ALC) instances, you should consider whether StreamJsonRpc is loaded in an ALC that can load all the types required by the proxy interface.
By default, StreamJsonRpc will generate dynamic proxies in the ALC that the (first) interface requested for the proxy is loaded within. This is usually the right choice because the interface should be in an ALC that can resolve all the interface's type references. When you request a proxy that implements multiple interfaces, and if those interfaces are loaded in different ALCs, you may need to control which ALC the proxy is generated in. The need to control this may manifest as an MissingMethodException or InvalidCastException due to types loading into multiple ALC instances.
In such cases, you may control the ALC used to generate the proxy by surrounding your proxy request with a call to EnterContextualReflection (and disposal of its result).
For example, you might use the following code when StreamJsonRpc is loaded into a different ALC from your own code:
// Whatever ALC can resolve *all* type references in *all* proxy interfaces.
AssemblyLoadContext alc = AssemblyLoadContext.GetLoadContext(MethodBase.GetCurrentMethod()!.DeclaringType!.Assembly);
IFoo proxy;
using (AssemblyLoadContext.EnterContextualReflection(alc))
{
proxy = (IFoo)jsonRpc.Attach([typeof(IFoo), typeof(IFoo2)]);
}
This initializes the proxy
local variable with a proxy that will be able to load all types that your own AssemblyLoadContext can load.