Skip to content

Adopt Bedrock client abstractions in SignalR client #11484

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Jun 27, 2019
Merged

Conversation

halter73
Copy link
Member

This add IConnectionFactory and HttpEndPoint to Connections.Abtractions and implements it with the SignalR .NET Client's HttpConnectionFactory.

So far I've been pretty conservative with these changes trying to break as few people as possible while still removing the old SignalR.Client.IConnectionFactory.

Going forward we might want to consider something like adding a HubConnection.StartAsync overload that takes an EndPoint.

Addresses #10872

@Eilon Eilon added the area-signalr Includes: SignalR clients and servers label Jun 24, 2019

namespace Microsoft.AspNetCore.Connections
{
public class HttpEndPoint : EndPoint
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this should be in abstractions or in the https.connections.common assembly.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I followed the pattern you used for FileHandleEndPoint down to the namespace and lack of virtual properties.

@@ -48,6 +52,12 @@ public HubConnection Build()
throw new InvalidOperationException($"Cannot create {nameof(HubConnection)} instance. An {nameof(IConnectionFactory)} was not configured.");
}

var endPoint = serviceProvider.GetService<EndPoint>();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a fan of having the EndPoint be directly in the container, it needs to hang off some option that gets passed to the HubConnection instead.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I figured you might say that. Do you want to add a new HubConnectionOptions type with and EndPoint property?

I was thinking making a HubConnection.StartAsync take an EndPoint might be even better if we're willing to make that hard of a break

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a pretty awful API though. It would be more flexible though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually what you have isn’t so bad it gets bad only when you add this to a bigger service collection.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could see value in an IOptions<HubConnectionOptions> given the earlier discussions re constructor breaking changes... might be a safer approach.


if (_httpConnectionOptions.Url != null && _httpConnectionOptions.Url != castedHttpEndPoint.Url)
{
throw new InvalidOperationException($"If {nameof(HttpConnectionOptions)}.{nameof(HttpConnectionOptions.Url)} was set, it must match the {nameof(HttpEndPoint)}.{nameof(HttpEndPoint.Url)} passed to {nameof(ConnectAsync)}.");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would this happen?

Copy link
Member Author

@halter73 halter73 Jun 24, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the IConnectionFactory gets resolved directly from the ServiceProvider anyone can call ConnectAsync with any EndPoint.

I'd be OK with having it silently override the option though. I was thinking maybe we do that and log a warning.

@@ -34,10 +34,12 @@ public static async Task<int> ExecuteAsync(string baseUrl)
baseUrl = string.IsNullOrEmpty(baseUrl) ? "http://localhost:5000/chat" : baseUrl;

Console.WriteLine($"Connecting to {baseUrl}...");
var connection = new HttpConnection(new Uri(baseUrl));

var connection = new HttpConnection(new HttpConnectionOptions() { Url = new Uri(baseUrl) }, TransferFormat.Text, loggerFactory: null);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the simplest way to construct the HttpConnection now? Can we add some defaults (like the TransferFormat) so this sort of code doesn't break:

https://github.com/Azure/azure-signalr/blob/7257fda52f219601f8efc34dd240728a9c4ea31c/src/Microsoft.Azure.SignalR.Common/ServiceConnections/ConnectionFactory.cs#L32-L44

@@ -181,8 +185,8 @@ public partial class HubConnection
/// <remarks>
/// The <see cref="IServiceProvider"/> used to initialize the connection will be disposed when the connection is disposed.
/// </remarks>
public HubConnection(IConnectionFactory connectionFactory, IHubProtocol protocol, IServiceProvider serviceProvider, ILoggerFactory loggerFactory, IRetryPolicy reconnectPolicy)
: this(connectionFactory, protocol, serviceProvider, loggerFactory)
public HubConnection(IConnectionFactory connectionFactory, IHubProtocol protocol, EndPoint endPoint, IServiceProvider serviceProvider, ILoggerFactory loggerFactory, IRetryPolicy reconnectPolicy)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume keeping the original constructors (to avoid the breaking change) is the entire point of this change.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Users shouldn't be constructing HubConnection. If it weren't for DI this would be internal. I'm kinda 🤷‍♂ on breaking changes in this kind of thing.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Funnily enough, HubConnection has always been difficult/impossible to construct manually. Now that the HubConnection needs to specify an EndPoint that previously was just an implementation detail of IConnectionFactory, HubConnection has to break at least a little.

The main thing I was worried about was keeping the HubConnectionBuilder backwards-compatible.

Copy link
Contributor

@jkotalik jkotalik Jun 27, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stupid question, can we make HubConnection internal? nvrm, didn't read comments closely enough above.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't make the class internal as it's the class users interact with to send and receive messages. We would like the constructors to be internal but like Andrew said we can't because of DI.

hubConnectionBuilder.Services.AddSingleton<IConnectionFactory, HttpConnectionFactory>();
return hubConnectionBuilder;
}

private class HttpConnectionOptionsDerivedHttpEndPoint : HttpEndPoint
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This name is interesting... maybe HttpEndPointWithConnectionOptions?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Meh, it's a private class. That's why I went with the overly-descriptive name.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's why I went with the overly-descriptive name.

False, you do this in general 😄

@@ -11,4 +11,7 @@
<Reference Include="Microsoft.AspNetCore.Http.Connections.Client" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Microsoft.AspNetCore.SignalR.Client.Tests" />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait this is a thing now? You don't need to put this in assembly attributes 😱

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It IS a thing! You still need to have the full public key for things that aren't built by the repo, but if it's built here you can just use the simple assembly name and the rest is automated!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I convert SignalR to use this and removed the AssemblyInfo files

HttpMessageHandlerFactory = _ => testHandler
}), NullLoggerFactory.Instance);
var factory = new HttpConnectionFactory(
Mock.Of<IHubProtocol>(p => p.TransferFormat == TransferFormat.Text),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please try to avoid Moq where possible. This should easily be replaceable by new JsonHubProtocol()

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered using new JsonHubProtocol() when I changed this, but decided to go with Mock.Of<IHubProtocol>(p => p.TransferFormat == TransferFormat.Text) because it's more self-documenting. It shows what the test is actually using the IHubProtocol for.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm mildly torn betweeen my preference to avoid Moq as much as possible and the fact that I do agree with @halter73 's self-documenting argument. I guess I'd consider just making a TestTextHubProtocol concrete class (that throws NotImplementedException except in the TransferFormat property) but I could go either way.

Copy link
Member Author

@halter73 halter73 Jun 24, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find the mock more self-documenting than the TestTextHubProtocol for something as trivial as this, because I don't have to scroll to TestTextHubProtocol to see what it's doing.

And when TestTextHubProtocol inevitably starts getting used by other tests that started life as a copy of this one, you won't be able to see the implementation for TestTextHubProtocol in the new PRs that use it. And when those tests inevitably add more requirements, you get more methods/properties in TestTextHubProtocol that don't throw a NotImplementedException, so you don't know what's really being called by any given test even if you can find the TestTextHubProtocol implementation.

All of this for what should be a one-liner.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm over Moq

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you suggesting I rewrite this? If so, can I at least get a reason? Even if it's something completely irrelevant like not working in environments that don't support IL emit.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No I don't really care, we've been gradually moving away from Moq as a team. Leave it the way it is if you want but generally I've been trying to avoid Moq as well when the situation is trivial. As we move towards support more AOT scenarios Moq won't work well (or it'll work but be really slow). Though that's a future thing, nothing to worry about yet 😄


private class HttpConnectionOptionsDerivedHttpEndPoint : HttpEndPoint
{
public HttpConnectionOptionsDerivedHttpEndPoint(IOptions<HttpConnectionOptions> httpConnectionOptions)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't we put the transferformat on HttpEndPoint and resolve it here through the IHubProtocol and avoid the IHubProtocol stuff you do in HttpConnectionFactory?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HttpEndPoint is not a solely SignalR concept. Other things that might want to connect to an HttpEndPoint probably don't care about SignalR's TransferFormat. The project bedrock API all deal with bytes/binary anyway.

We could create a SignalREndPoint that derives from HttpEndPoint to contain extra options, but at that point what options go into HttpConnectionOptions and what options go into SignalREndPoint?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TransferFormat is in Connection.Abstractions as well as it would be fine to add it to the HttpEndPoint

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But what does a Text TransferFormat mean to anyone other than SignalR? The ConnectionContext exposes Pipes which are all binary. The TransferFormat seems like a protocol concern, not a transport/endpoint concern.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When using the lower HttpConnection layer, it implies that the transport cannot do binary, e.g. server sent events.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're going to put a TransferFormat on HttpEndPoint, shouldn't we add an Encoding as well? In SignalR's case, TransferFormat.Text implies that all bytes sent over the pipe is valid UTF-8, but what if a non-SignalR EndPoint only accepts ASCII or something else? Do you set TransferFormat.Text on the Endpoint, but use an options object for the encoding? The TransferFormat and encoding should be configured together IMHO.

Also, I looked at the long polling transport and it just completely ignores the TransferFormat. SSE validates the TransferFormat at the beginning of StartAsync, but f assumes TransferFormat.Text from there on out. If the code calling the ITransport simply wrote valid UTF-8 to the IDuplexPipe, everything would work fine with SSE without a need for ever passing in a TransferFormat.

So it's really only the WebSocket transport that needs to know up front whether you're sending binary or text data, and if it weren't for the F12 browser tools being crappy with binary WebSockets, we'd probably just force WebSockets to binary all the time, even for text-based protocols.

Since this is the .NET-client, maybe forcing WebSocket to use the binary message mode even for text protocols is more reasonable. I'd also be OK with adding a TransferFormat to a WebSocketEndPoint which could b derived from HttpEndPoint.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're going to put a TransferFormat on HttpEndPoint, shouldn't we add an Encoding as well? In SignalR's case, TransferFormat.Text implies that all bytes sent over the pipe is valid UTF-8, but what if a non-SignalR EndPoint only accepts ASCII or something else? Do you set TransferFormat.Text on the Endpoint, but use an options object for the encoding? The TransferFormat and encoding should be configured together IMHO.

Sounds good add a TextEncoding property that should be used if TransferFormat is Text?

Copy link
Member Author

@halter73 halter73 Jun 25, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That raises the question of whether the Encoding is really a property of the HttpEndPoint. If Encoding is part and parcel of the HttpEndPoint, what configuration isn't. Why don't we consider the CloseTimeout part of the HttpEndPoint? Or IWebProxy? Or UseDefaultCredentials? I could see the argument for Headers or Cookies, but where's the line?

And why is TranferFormat and Encoding a thing specific to HttpEndPoints? Why don't other EndPoints have these properties? Why not the file handle EndPoint?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is similar to socket options for IPEndPoint, we had this discussion remember? Either we need to abandon Endpoint and have something that is more loose that allows passing binding specific information (like all of the useful properties you describe) or we say those come in via a side channel.

Copy link
Member Author

@halter73 halter73 Jun 25, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can specify any EndPoint you want if you supply an IConnectionFactory that supports it.
Just look at the sample project. It uses an IPEndPoint with HubConnection.

I'm fine with putting a bunch of SignalR specific stuff on an EndPoint type (though people will still likely get confused which options go where unless we put either all or none of the options on the EndPoint type). Just call it SignalREndPoint or something and move it out of the Connection.Abstractions assembly.

@@ -212,7 +211,7 @@ private async Task StartAsyncCore(TransferFormat transferFormat, CancellationTok

Log.Starting(_logger);

await SelectAndStartTransport(transferFormat, cancellationToken);
await SelectAndStartTransport(_transferFormat, cancellationToken);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All public API needs .ForceAsync()

Also, you've removed the logger scope

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm surprised no tests failed after removing the logger scope. It'd be nice if we tested the ForceAsync() stuff too.

@@ -181,8 +185,8 @@ public partial class HubConnection
/// <remarks>
/// The <see cref="IServiceProvider"/> used to initialize the connection will be disposed when the connection is disposed.
/// </remarks>
public HubConnection(IConnectionFactory connectionFactory, IHubProtocol protocol, IServiceProvider serviceProvider, ILoggerFactory loggerFactory, IRetryPolicy reconnectPolicy)
: this(connectionFactory, protocol, serviceProvider, loggerFactory)
public HubConnection(IConnectionFactory connectionFactory, IHubProtocol protocol, EndPoint endPoint, IServiceProvider serviceProvider, ILoggerFactory loggerFactory, IRetryPolicy reconnectPolicy)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Users shouldn't be constructing HubConnection. If it weren't for DI this would be internal. I'm kinda 🤷‍♂ on breaking changes in this kind of thing.

@@ -48,6 +52,12 @@ public HubConnection Build()
throw new InvalidOperationException($"Cannot create {nameof(HubConnection)} instance. An {nameof(IConnectionFactory)} was not configured.");
}

var endPoint = serviceProvider.GetService<EndPoint>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could see value in an IOptions<HubConnectionOptions> given the earlier discussions re constructor breaking changes... might be a safer approach.

HttpMessageHandlerFactory = _ => testHandler
}), NullLoggerFactory.Instance);
var factory = new HttpConnectionFactory(
Mock.Of<IHubProtocol>(p => p.TransferFormat == TransferFormat.Text),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm mildly torn betweeen my preference to avoid Moq as much as possible and the fact that I do agree with @halter73 's self-documenting argument. I guess I'd consider just making a TestTextHubProtocol concrete class (that throws NotImplementedException except in the TransferFormat property) but I could go either way.

/// <param name="protocol">The <see cref="IHubProtocol" /> used by the connection.</param>
/// <param name="loggerFactory">The logger factory.</param>
public HubConnection(IConnectionFactory connectionFactory, IHubProtocol protocol, ILoggerFactory loggerFactory)
public HubConnection(IConnectionFactory connectionFactory,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we format ctors like this any where in SignalR

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a first time for everything. Seriously though, I prefer this for DI-invoked ctors with a lot of arguments. In fact, it looks like we already do this in our HubConnectionHandler.

Copy link
Contributor

@mikaelm12 mikaelm12 Jun 24, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also prefer this for more than 3 args. I was angling more for consistency.

{
if (hubProtocol == null)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we gonna use throw expressions throughout?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would, but throw expressions don't look as nice once you start trying to pull out a specific property like IHubProtocol.TransferFormat and store that in the field instead of the entire argument. You end up needing a ternary expression or something. I assume this is the same reason we didn't use a throw expression for options and loggerFactory.

@@ -11,4 +11,7 @@
<Reference Include="Microsoft.AspNetCore.Http.Connections.Client" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Microsoft.AspNetCore.SignalR.Client.Tests" />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I convert SignalR to use this and removed the AssemblyInfo files

@halter73
Copy link
Member Author

⬆️📅

@@ -452,7 +451,7 @@ private async Task StartAsyncCore(CancellationToken cancellationToken)

private Task CloseAsync(ConnectionContext connection)
{
return _connectionFactory.DisposeAsync(connection);
return connection.DisposeAsync().AsTask();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this return ValueTask?

Copy link
Member

@BrennanConroy BrennanConroy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good

{
return connection.DisposeAsync().AsTask();
return new HttpConnectionOptions
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there anyway for us to make sure additions to the options get updated here too?

Like make a clone method on HttpConnectionOptions? Or a comment in that file mentioning this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see you use reflection in a test to go over all properties 😄


if (!(endPoint is UriEndPoint uriEndPoint))
{
throw new NotSupportedException($"The provided {nameof(EndPoint)} must be of type {nameof(UriEndPoint)}.");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
throw new NotSupportedException($"The provided {nameof(EndPoint)} must be of type {nameof(UriEndPoint)}.");
throw new NotSupportedException($"The provided {nameof(EndPoint)} must be of type '{nameof(UriEndPoint)}'.");

@halter73
Copy link
Member Author

/azp run AspNetCore-ci

@azure-pipelines
Copy link

Azure Pipelines successfully started running 1 pipeline(s).

@halter73 halter73 merged commit 30fe3a2 into master Jun 27, 2019
@ghost ghost deleted the halter73/10872 branch June 27, 2019 22:26
@davidfowl
Copy link
Member

👍

@analogrelay analogrelay added this to the 3.0.0-preview7 milestone Jun 28, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-signalr Includes: SignalR clients and servers
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants