[Design proposal] Strongly-typed SignalR hub proxies and callback handlers #32534
Comments
Thanks for contacting us. We're moving this issue to the |
My main issue with this approach is that you are forced to define all methods on the client side. We've seen many cases where some clients don't register for all methods. And you can't just have them throw
Source-generated proxies is my favorite. It should work everywhere (in some environments you might need to generate the code before using it but that shouldn't be too hard).
I'd say this is a P2 concern. We don't support this today, but if we add support for it, then we'll update the proxy to handle it as well.
As fast as possible
I don't think a different implementation for different platforms is good. We already feel the burden for that sort of thing by having different clients for different languages. And it looks like we'll already be needing a different proxy generation for .NET vs. Typescript. We should be targeting everything .NET Core runs on. i.e Xamarin, WPF, Console, WASM, etc.
What versioning are you envisioning? So far we haven't made any breaking changes to the protocol or main APIs and aren't planning on doing that. New APIs will not be used by old proxy generators which should be fine. Although if possible we can try to make new proxy generation work on older clients.
I currently like the first option as it allows use of both the strongly typed connection, as well as the less-typed connection. Although I guess the type returned from the builder could include a property with the less-typed connection. |
That makes sense. I'm OK with restricting registration to a single "callback provider". However are you saying you don't like I was envisioning that
Agreed. I plan to focus on a detailed design based on source generation unless there is strong opposition.
With source generation, it is just one extra vcall. So yes :)
Yes. Are you envisioning .NET source-generators would generate Typescript source or some other more Typescript-native feature would be utilized? I don't use Typescript so I'd definitely want more feedback on how we could approach that.
Versioning of the interfaces defined by users. When I make changes to
Same. I think for a first pass also it reduces complexity. |
No, I'm saying that
I'm not sure yet. I believe there are .NET libraries that can produce JS or TS, so we might want to try and structure the source-generator in a way that we can possibly plug in other language generators in the future.
Yeah, not sure what we can do there :) |
Would you like to propose what the API will look like and we can start iterating on it? |
Another proposalI have published a library called "TypedSignalR.Client" which is Source Generator to create a strongly typed SignalR Client. So this proposal has an implementation(slightly different from here). I think my proposal can address some of the current requirements.
SummaryThe work flow for developers is as follows.
Detail and ExampleFirst, suppose we have the following interface or class definition. This interface and class definition are assumed to be shared in the server and client projects by project reference etc. public class UserDefine
{
public Guid RandomId { get; set; }
public DateTime Datetime { get; set; }
}
// The return type of the client-side method must be Task.
// This is because Hub<T> will cause a runtime error if a non-Task type is returned.
public interface IClientContract
{
// Of course, user defined type is OK.
Task SomeClientMethod1(string user, string message, UserDefine userDefine);
Task SomeClientMethod2();
}
// The return type of the method on the hub-side must be Task or Task <T>.
public interface IHubContract
{
Task<string> SomeHubMethod1(string user, string message);
Task SomeHubMethod2();
} And suppose that it is implemented on the server as follows. using Microsoft.AspNetCore.SignalR;
public class SomeHub : Hub<IClientContract>, IHubContract
{
public async Task<string> SomeHubMethod1(string user, string message)
{
await this.Clients.All.SomeClientMethod1(user, message, new UserDefineClass());
return "OK!";
}
public async Task SomeHubMethod2()
{
await this.Clients.Caller.SomeClientMethod2();
}
} Under these assumptions, on the client side, annotate the using TypedSignalR.Client;
[HubClientBase(typeof(IHubContract), typeof(IClientContract))]
partial class ClientBase
{
} By annotating the HubClientBaseAttribute, the following code will be generated (simplified here). partial abstract class ClientBase : IHubClient<IHubContract>, IClientContract, IAsyncDisposable
{
private class HubInvoker : IHubContract
{
private readonly HubConnection _connection;
public HubInvoker(HubConnection connection)
{
_connection = connection;
}
public Task<string> SomeHubMethod1(string user,string message)
{
return _connection.InvokeAsync<string>(nameof(SomeHubMethod1), user, message);
}
public Task SomeHubMethod2()
{
return _connection.InvokeAsync(nameof(SomeHubMethod2));
}
} // class HubInvoker
public HubConnection Connection { get; }
public IHubContract Hub { get; }
protected List<IDisposable> disposableList = new();
public ClientBase(HubConnection connection)
{
Connection = connection;
Hub = new HubInvoker(connection);
Connection.Closed += OnClosed;
Connection.Reconnected += OnReconnected;
Connection.Reconnecting += OnReconnecting;
var d1 = Connection.On<string, string, UserDefineClass>(nameof(SomeClientMethod1), SomeClientMethod1);
var d2 = Connection.On(nameof(SomeClientMethod2), SomeClientMethod2);
disposableList.Add(d1);
disposableList.Add(d2);
}
public virtual Task SomeClientMethod1(string user,string message, UserDefineClass userDefine) => Task.CompletedTask;
public virtual Task SomeClientMethod2() => Task.CompletedTask;
public async ValueTask DisposeAsync()
{
Connection.Closed -= OnClosed;
Connection.Reconnected -= OnReconnected;
Connection.Reconnecting -= OnReconnecting;
await Connection.DisposeAsync();
foreach(var d in disposableList)
{
d.Dispose();
}
}
public virtual Task OnClosed(Exception e) => Task.CompletedTask;
public virtual Task OnReconnected(string connectionId) => Task.CompletedTask;
public virtual Task OnReconnecting(Exception e) => Task.CompletedTask;
} // class ClientBase
The generated code is inherited and used. The usability is very similar to the case of inheriting
Also, the definition of the function is not a callback, but writing feeling similar to Hub-side. class HubClient : ClientBase
{
public HubClient(HubConnection connection, string arg) : base(connection)
{
}
// override and impl
public override async Task SomeClientMethod1(string user, string message, UserDefineClass userDefine)
{
await this.Hub.SomeHubMethod1(string user, string message);
Console.WriteLine("Call SomeClientMethod1!");
return Task.CompletedTask;
}
// We don't have to implement all the methods !
// public override Task SomeClientMethod2()
// I think it is very comfortable to write SignalR event as follows.
public override Task OnClosed(Exception e)
{
Console.WriteLine($"[On Closed!]");
return Task.CompletedTask;
}
// Of course, we don't have to define all events.
// public override Task OnReconnecting(Exception e)
}
Since the base class comes with an implementation, developers can selectively override it, not all functions need to be implemented. It's also easy to use. HubConnection connection = ...;
var client = new HubClient(connection, "some parameter");
await client.Connection.StartAsync();
// Invoke hub methods
var response = await client.Hub.SomeHubMethod1("user", "message");
Console.WriteLine(response);
await client.Connection.StopAsync();
await client.DisposeAsync(); Personally, I don't hete writing public static T Build<T>(this IHubConnectionBuilder source, Func<HubConnection,T> factoryMethod)
{
HubConnection connection = source.Build();
return factory.Invoke(connection);
}
static void Main(string[] args)
{
var client = new HubConnectionBuilder()
.WithUrl("https://~~~")
.Build(connection => new HubClient(connection, "some parameter"));
} |
I'd suggest the following basic API surface. // Get strongly typed hub proxy
public T GetProxy<T>(this HubConnection conn);
// Register callback method and get a disposable which can unregister callback
public IDisposable RegisterCallback<T1, T2, ..>(this HubConnection conn, string name, Action<T1, T2, ..> callback);
// Register callback provider (i.e. a class providing the callback methods defined in TInterface) and get a disposable which can unregister callbacks
public IDisposable RegisterCallbackProvider<TInterface, TImplementation>(this HubConnection conn, TImplementation callbackProvider) where TImplementation : TInterface; This keeps it simple to grab a proxy as well as register single and/or multiple callbacks. I'm not very certain of the single callback registration because it is not a use case I had before. I'm a bit worried that it may be not much better than what SignalR already offers. |
@nenoNaninu It appears what you are proposing is one layer higher than what I've proposed here. Your proposal essentially wraps proxy acquisition and callback registration behind a single base client class which seem to serve almost as a replacement for My intent is a bit different. I'd like to see the essential strong typing features implemented without imposing conditions on implementations within end-user codebase (i.e. not require inheritance of an abstract class) and then such abstractions as yours could be added on top very easily. |
Now that I think about it, this is just
Should this be taking the
This should return the proxy type right? We also want to see what the proxy type looks like. |
Here is a more concrete proxy example. Given following interface in a shared/common project/library: public interface IMyHub
{
Task DoSomething();
Task<int> DoSomethingElse(float arg);
IAsyncEnumerable<int> StreamBidirectional(IAsyncEnumerable<float> clientToServer, [EnumeratorCancellation] CancellationToken token);
} Generated proxy would be: (types would be written in fully qualified form but just simplifying for showing..) public sealed class GeneratedIMyHub : IMyHub
{
private readonly HubConnection conn;
public GeneratedIMyHub(HubConnection connection)
{
this.conn = connection;
}
public Task DoSomething()
{
return this.conn.InvokeAsync("DoSomething");
}
public Task<int> DoSomethingElse(float arg)
{
return this.conn.InvokeAsync<int>("DoSomethingElse", arg);
}
public IAsyncEnumerable<int> StreamBidirectional(IAsyncEnumerable<float> clientToServer, CancellationToken token)
{
return this.conn.StreamAsync<int>("StreamBidrectional", clientToServer, token);
}
} Methods with
In the case of public IDisposable RegisterCallbackProvider<TInterface, TImplementation>(this HubConnection conn, TImplementation callbackProvider) where TImplementation : TInterface;
public IDisposable RegisterCallbackProvider<TClient>(this HubConnection conn, TClient callbackProvider); Underlying code would look like: public IDisposable RegisterCallbackProvider<TInterface, TImplementation>(this HubConnection conn, TImplementation callbackProvider) where TImplementation : TInterface
{
var intfType = typeof(TInterface);
var regs = new List<IDisposable>();
var methods = intfType.GetMethods(BindingFlags.Instance | BindingFlags.Public);
foreach (var method in methods)
{
var reg = conn.On(method.Name, method.GetParameters().Select(a => a.ParameterType).ToArray(),
objects =>
{
method.Invoke(spoke, objects);
return Task.CompletedTask;
});
regs.Add(reg);
}
return MakeDisposableOf(regs);
}
public IDisposable RegisterCallbackProvider<TClient>(this HubConnection conn, TClient callbackProvider)
{
return RegisterCallbackProvider<TClient, TClient>(conn, callbackProvider);
} As a more concrete example, we can have the following interface in our shared/common project: public interface IMyClient
{
void SomeCallback(float arg);
} In client project, user can utilize a callback provider like: public class MyClient : IMyClient
{
public void SomeCallback(float arg) { .. }
}
var registration = hubConnection.RegisterCallbackProvider<IMyClient, MyClient>(new MyClient());
// or hubConnection.RegisterCallbackProvider(new MyClient());
registration.Dispose(); There aren't many restrictions on what could be a callback provider. For example, if the user doesn't have a |
Couple notes:
IDisposable RegisterCallbackProvider<TInterface>(TInterface impl)
{
var disposableCollection;
// for loop is just for pseudo code purposes, this would be source generated to have individual calls for each .On callback
foreach (method)
{
disposableCollection.Add(conn.On("blah", (param1) => impl.blah(param1)));
}
return disposableCollection;
}
|
@mehmetakbulut - would you mind reaching out with your email or a good way to schedule you for a teams meeting? The team likes your approach and discussed some ideas we have around it. We'd like to iterate on our design ideas and the discussion we had, so if you could provide a way for us to get in touch we'd like to schedule a meeting with you. You could also DM me on twitter at @bradygaster if that is easier (ping me so I can follow so we can DM if that works). |
@bradygaster I just sent you an email from my personal. |
Try a similar method for For testing: |
Initial source generator work has been merged! Thanks a ton @mehmetakbulut! To use the source generator:
[AttributeUsage(AttributeTargets.Method)]
internal class HubServerProxyAttribute : Attribute
{
}
[AttributeUsage(AttributeTargets.Method)]
internal class HubClientProxyAttribute : Attribute
{
}
internal static partial class MyCustomExtensions
{
[HubClientProxy]
public static partial IDisposable AnyNameYouWant<T>(this HubConnection connection, T provider);
}
internal static partial class MyCustomExtensions
{
[HubServerProxy]
public static partial T AnotherName<T>(this HubConnection connection);
}
public interface IServerHub
{
Task SendMessage(string message);
Task<int> Echo(int i);
}
public interface IClient
{
Task ReceiveMessage(string message);
}
public class Client : IClient
{
// Equivalent to HubConnection.On("ReceiveMessage", (message) => {});
Task ReceiveMessage(string message)
{
return Task.CompletedTask;
}
}
HubConnection connection = new HubConnectionBuilder().WithUrl("...").Build();
var stronglyTypedConnection = connection.AnotherName<IServerHub>();
var registrations = connection.AnyNameYouWant<IClient>(new Client());
await stronglyTypedConnection.SendMessage("Hello world");
var echo = await stronglyTypedConnection.Echo(10); Follow up source generator work:
|
Summary
This proposal aims to provide end-to-end strong typing for all interactions between SignalR clients and SignalR hubs.
Motivation and goals
In scope
ChannelReader<>
andIAsyncEnumerable<>
as the underlying streamOut of scope
Risks / unknowns
HubMethodName
) can break strongly-typed clients depending on the particular implementation.Examples
Client to Server Calls
Let's say there is an interface
IMyHub
as defined below which is implemented by an ASP.NET Core application asMyHub : SignalR.Hub, IMyHub
.A developer currently needs to consume it as below.
Instead, developer could be making strongly-typed calls.
We can either have such a proxy be acquired from a hub connection or a builder.
Acquisiton from a hub connection is simpler while builder provides more room for extension in future.
Server to Client Call Handlers
One can similarly define an interface
IMyClient
as below which can then be used inHub<IMyClient>
on server-end and implemented by any consumer to provide callbacks.A developer currently needs to provide such callbacks as below.
Instead, developer could be registering callback in a strongly-typed manner.
Multiple callback providers can be registered against the same hub connection so different callbacks can be provided by different classes. However this does mean overlap is possible. We'd want to decide how to handle this and whether to impose restrictions.
Detailed design
To be decided.
Some alternatives for client-to-server call implementation are:
Source-generated proxies utilizes C# 9 / .NET 5 source generator feature.
Pros:
Cons:
Dynamic proxies utilizes
Reflection.Emit
to dynamically generate proxy.Pros:
Cons:
Expressive proxies utilizes expressions to emulate a strongly-typed user experience.
Pros:
Cons:
await hubConnection.AsExpressive<IMyHub>().InvokeAsync(hub => hub.Do())
)Server-to-client calls can be registered with just reflection (no
Reflection.Emit
) which is simple enough and would work on practically any platform. Other alternatives are possible such as source generation as well.The text was updated successfully, but these errors were encountered: