Refactor server-side blazor startup to allow Azure SignalR. Fixes #1227 (#1396)

* Move startup action config into AddServerSideBlazor, so that UseServerSideBlazor is reduced to trivial shorthand that can become optional

* Make BlazorHub public so developers can use it with UseAzureSignalR

* Move BlazorHub to Microsoft.AspNetCore.Blazor.Server namespace for easier consumption

* Add notes

* Have E2E tests validate that devs don't have to call UseServerSideBlazor

* Add forgotten tweak

* CR: Document that BlazorHub methods are not intended for application use.

* CR: Rename extension method to UseSignalRWithBlazorHub

* CR: TryAdd
This commit is contained in:
Steve Sanderson 2018-09-11 09:55:27 +01:00 committed by GitHub
parent d4cbb86f46
commit 6ff3674b16
8 changed files with 163 additions and 192 deletions

View File

@ -3,40 +3,59 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Blazor.Server.Circuits;
using Microsoft.AspNetCore.Blazor.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Microsoft.AspNetCore.Blazor.Server.Circuits
namespace Microsoft.AspNetCore.Blazor.Server
{
internal class BlazorHub : Hub
/// <summary>
/// A SignalR hub that accepts connections to a Server-Side Blazor app.
/// </summary>
public sealed class BlazorHub : Hub
{
private static readonly object CircuitKey = new object();
private readonly CircuitFactory _circuitFactory;
private readonly ILogger _logger;
/// <summary>
/// Intended for framework use only. Applications should not instantiate
/// this class directly.
/// </summary>
public BlazorHub(
ILogger<BlazorHub> logger,
CircuitFactory circuitFactory)
IServiceProvider services)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_circuitFactory = circuitFactory ?? throw new ArgumentNullException(nameof(circuitFactory));
_circuitFactory = services.GetRequiredService<CircuitFactory>();
}
public CircuitHost CircuitHost
/// <summary>
/// Gets the default endpoint path for incoming connections.
/// </summary>
public static PathString DefaultPath => "/_blazor";
private CircuitHost CircuitHost
{
get => (CircuitHost)Context.Items[CircuitKey];
set => Context.Items[CircuitKey] = value;
}
/// <summary>
/// Intended for framework use only. Applications should not call this method directly.
/// </summary>
public override Task OnDisconnectedAsync(Exception exception)
{
CircuitHost.Dispose();
return base.OnDisconnectedAsync(exception);
}
/// <summary>
/// Intended for framework use only. Applications should not call this method directly.
/// </summary>
public async Task StartCircuit(string uriAbsolute, string baseUriAbsolute)
{
var circuitHost = _circuitFactory.CreateCircuitHost(Context.GetHttpContext(), Clients.Caller);
@ -50,7 +69,10 @@ namespace Microsoft.AspNetCore.Blazor.Server.Circuits
await circuitHost.InitializeAsync();
CircuitHost = circuitHost;
}
/// <summary>
/// Intended for framework use only. Applications should not call this method directly.
/// </summary>
public void BeginInvokeDotNetFromJS(string callId, string assemblyName, string methodIdentifier, long dotNetObjectId, string argsJson)
{
EnsureCircuitHost().BeginInvokeDotNetFromJS(callId, assemblyName, methodIdentifier, dotNetObjectId, argsJson);

View File

@ -24,12 +24,13 @@ namespace Microsoft.AspNetCore.Builder
/// Configures the middleware pipeline to work with Blazor.
/// </summary>
/// <typeparam name="TProgram">Any type from the client app project. This is used to identify the client app assembly.</typeparam>
/// <param name="app"></param>
public static void UseBlazor<TProgram>(
/// <param name="app">The <see cref="IApplicationBuilder"/>.</param>
/// <returns>The <see cref="IApplicationBuilder"/>.</returns>
public static IApplicationBuilder UseBlazor<TProgram>(
this IApplicationBuilder app)
{
var clientAssemblyInServerBinDir = typeof(TProgram).Assembly;
app.UseBlazor(new BlazorOptions
return app.UseBlazor(new BlazorOptions
{
ClientAssemblyPath = clientAssemblyInServerBinDir.Location,
});
@ -38,9 +39,10 @@ namespace Microsoft.AspNetCore.Builder
/// <summary>
/// Configures the middleware pipeline to work with Blazor.
/// </summary>
/// <param name="app"></param>
/// <param name="options"></param>
public static void UseBlazor(
/// <param name="app">The <see cref="IApplicationBuilder"/>.</param>
/// <param name="options">Options to configure the middleware.</param>
/// <returns>The <see cref="IApplicationBuilder"/>.</returns>
public static IApplicationBuilder UseBlazor(
this IApplicationBuilder app,
BlazorOptions options)
{
@ -108,6 +110,8 @@ namespace Microsoft.AspNetCore.Builder
spa.Options.DefaultPageStaticFileOptions = indexHtmlStaticFileOptions;
});
});
return app;
}
private static string FindIndexHtmlFile(BlazorConfig config)

View File

@ -2,15 +2,14 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using Microsoft.AspNetCore.Blazor.Builder;
using Microsoft.AspNetCore.Blazor.Hosting;
using Microsoft.AspNetCore.Blazor.Server.Circuits;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Blazor.Server;
using Microsoft.AspNetCore.Http;
namespace Microsoft.AspNetCore.Builder
{
/// <summary>
/// Extension methods to configure an <see cref="IApplicationBuilder"/> for Server-Side Blazor.
/// These are just shorthand for combining UseSignalR with UseBlazor.
/// </summary>
public static class ServerSideBlazorApplicationBuilderExtensions
{
@ -20,94 +19,58 @@ namespace Microsoft.AspNetCore.Builder
/// <param name="builder">The <see cref="IApplicationBuilder"/>.</param>
/// <typeparam name="TStartup">A Blazor startup type.</typeparam>
/// <returns>The <see cref="IApplicationBuilder"/>.</returns>
public static IApplicationBuilder UseServerSideBlazor<TStartup>(this IApplicationBuilder builder)
public static IApplicationBuilder UseServerSideBlazor<TStartup>(
this IApplicationBuilder builder)
{
if (builder == null)
{
throw new ArgumentNullException(nameof(builder));
}
return UseServerSideBlazor(builder, typeof(TStartup));
// WARNING: Don't add extra setup logic here. It's important for
// UseServerSideBlazor just to be shorthand for UseSignalR+UseBlazor,
// so that people who want to call those two manually instead can
// also do so. That's needed for people using Azure SignalR.
// TODO: Also allow configuring the endpoint path.
return UseSignalRWithBlazorHub(builder, BlazorHub.DefaultPath)
.UseBlazor<TStartup>();
}
/// <summary>
/// Registers Server-Side Blazor in the pipeline.
/// </summary>
/// <param name="builder">The <see cref="IApplicationBuilder"/>.</param>
/// <param name="startupType">A Blazor startup type.</param>
/// <returns>The <see cref="IApplicationBuilder"/>.</returns>
public static IApplicationBuilder UseServerSideBlazor(
this IApplicationBuilder builder,
Type startupType)
{
if (builder == null)
{
throw new ArgumentNullException(nameof(builder));
}
if (startupType == null)
{
throw new ArgumentNullException(nameof(startupType));
}
var startup = builder.ApplicationServices.GetRequiredService(startupType);
var wrapper = new ConventionBasedStartup(startup);
Action<IBlazorApplicationBuilder> configure = (b) =>
{
wrapper.Configure(b, b.Services);
};
UseServerSideBlazorCore(builder, configure);
builder.UseBlazor(new BlazorOptions()
{
ClientAssemblyPath = startupType.Assembly.Location,
});
return builder;
}
/// <summary>
/// Registers middleware for Server-Side Blazor.
/// </summary>
/// <param name="builder">The <see cref="IApplicationBuilder"/>.</param>
/// <param name="options">A <see cref="BlazorOptions"/> instance used to configure the Blazor file provider.</param>
/// <param name="startupAction">A delegate used to configure the renderer.</param>
/// <returns>The <see cref="IApplicationBuilder"/>.</returns>
public static IApplicationBuilder UseServerSideBlazor(
this IApplicationBuilder builder,
BlazorOptions options,
Action<IBlazorApplicationBuilder> startupAction)
BlazorOptions options)
{
if (builder == null)
{
throw new ArgumentNullException(nameof(builder));
}
if (startupAction == null)
if (options == null)
{
throw new ArgumentNullException(nameof(startupAction));
throw new ArgumentNullException(nameof(options));
}
UseServerSideBlazorCore(builder, startupAction);
// WARNING: Don't add extra setup logic here. It's important for
// UseServerSideBlazor just to be shorthand for UseSignalR+UseBlazor,
// so that people who want to call those two manually instead can
// also do so. That's needed for people using Azure SignalR.
builder.UseBlazor(options);
return builder;
// TODO: Also allow configuring the endpoint path.
return UseSignalRWithBlazorHub(builder, BlazorHub.DefaultPath)
.UseBlazor(options);
}
private static IApplicationBuilder UseServerSideBlazorCore(
IApplicationBuilder builder,
Action<IBlazorApplicationBuilder> configure)
private static IApplicationBuilder UseSignalRWithBlazorHub(
IApplicationBuilder builder, PathString path)
{
var endpoint = "/_blazor";
var factory = (DefaultCircuitFactory)builder.ApplicationServices.GetRequiredService<CircuitFactory>();
factory.StartupActions.Add(endpoint, configure);
builder.UseSignalR(route => route.MapHub<BlazorHub>(endpoint));
return builder;
return builder.UseSignalR(route => route.MapHub<BlazorHub>(BlazorHub.DefaultPath));
}
}
}

View File

@ -2,32 +2,36 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Blazor.Browser.Rendering;
using Microsoft.AspNetCore.Blazor.Builder;
using Microsoft.AspNetCore.Blazor.Rendering;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Microsoft.AspNetCore.Blazor.Server.Circuits
{
internal class DefaultCircuitFactory : CircuitFactory
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly DefaultCircuitFactoryOptions _options;
public DefaultCircuitFactory(IServiceScopeFactory scopeFactory)
public DefaultCircuitFactory(
IServiceScopeFactory scopeFactory,
IOptions<DefaultCircuitFactoryOptions> options)
{
if (options == null)
{
throw new ArgumentNullException(nameof(options));
}
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
StartupActions = new Dictionary<PathString, Action<IBlazorApplicationBuilder>>();
_options = options.Value;
}
public Dictionary<PathString, Action<IBlazorApplicationBuilder>> StartupActions { get; }
public override CircuitHost CreateCircuitHost(HttpContext httpContext, IClientProxy client)
{
if (!StartupActions.TryGetValue(httpContext.Request.Path, out var config))
if (!_options.StartupActions.TryGetValue(httpContext.Request.Path, out var config))
{
var message = $"Could not find a Blazor startup action for request path {httpContext.Request.Path}";
throw new InvalidOperationException(message);

View File

@ -0,0 +1,22 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Blazor.Builder;
using Microsoft.AspNetCore.Http;
namespace Microsoft.AspNetCore.Blazor.Server.Circuits
{
internal class DefaultCircuitFactoryOptions
{
// During the DI configuration phase, we use Configure<DefaultCircuitFactoryOptions>(...)
// callbacks to build up this dictionary mapping paths to startup actions
public Dictionary<PathString, Action<IBlazorApplicationBuilder>> StartupActions { get; }
public DefaultCircuitFactoryOptions()
{
StartupActions = new Dictionary<PathString, Action<IBlazorApplicationBuilder>>();
}
}
}

View File

@ -2,10 +2,11 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using Microsoft.AspNetCore.Blazor;
using Microsoft.AspNetCore.Blazor.Hosting;
using Microsoft.AspNetCore.Blazor.Server;
using Microsoft.AspNetCore.Blazor.Server.Circuits;
using Microsoft.AspNetCore.Blazor.Services;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.JSInterop;
namespace Microsoft.Extensions.DependencyInjection
@ -20,32 +21,15 @@ namespace Microsoft.Extensions.DependencyInjection
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/>.</param>
/// <returns>The <see cref="IServiceCollection"/>.</returns>
public static IServiceCollection AddServerSideBlazor(this IServiceCollection services)
{
if (services == null)
{
throw new ArgumentNullException(nameof(services));
}
return AddServerSideBlazor(services, null, null);
}
/// <summary>
/// Adds Server-Side Blazor services to the service collection.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/>.</param>
/// <param name="configure">A delegate to configure the <see cref="ServerSideBlazorOptions"/>.</param>
/// <returns>The <see cref="IServiceCollection"/>.</returns>
public static IServiceCollection AddServerSideBlazor(
this IServiceCollection services,
Action<ServerSideBlazorOptions> configure)
this IServiceCollection services)
{
if (services == null)
{
throw new ArgumentNullException(nameof(services));
}
return AddServerSideBlazorCore(services, null, configure);
return AddServerSideBlazor(services, null);
}
/// <summary>
@ -54,7 +38,9 @@ namespace Microsoft.Extensions.DependencyInjection
/// <param name="services">The <see cref="IServiceCollection"/>.</param>
/// <param name="startupType">A Blazor startup type.</param>
/// <returns>The <see cref="IServiceCollection"/>.</returns>
public static IServiceCollection AddServerSideBlazor(this IServiceCollection services, Type startupType)
public static IServiceCollection AddServerSideBlazor(
this IServiceCollection services,
Type startupType)
{
if (services == null)
{
@ -66,7 +52,7 @@ namespace Microsoft.Extensions.DependencyInjection
throw new ArgumentNullException(nameof(startupType));
}
return AddServerSideBlazorCore(services, startupType, null);
return AddServerSideBlazorCore(services, startupType);
}
/// <summary>
@ -75,101 +61,75 @@ namespace Microsoft.Extensions.DependencyInjection
/// <param name="services">The <see cref="IServiceCollection"/>.</param>
/// <typeparam name="TStartup">A Blazor startup type.</typeparam>
/// <returns>The <see cref="IServiceCollection"/>.</returns>
public static IServiceCollection AddServerSideBlazor<TStartup>(this IServiceCollection services)
{
if (services == null)
{
throw new ArgumentNullException(nameof(services));
}
return AddServerSideBlazorCore(services, typeof(TStartup), null);
}
/// <summary>
/// Adds Server-Side Blazor services to the service collection.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/>.</param>
/// <param name="startupType">A Blazor startup type.</param>
/// <param name="configure">A delegate to configure the <see cref="ServerSideBlazorOptions"/>.</param>
/// <returns>The <see cref="IServiceCollection"/>.</returns>
public static IServiceCollection AddServerSideBlazor(
this IServiceCollection services,
Type startupType,
Action<ServerSideBlazorOptions> configure)
{
if (services == null)
{
throw new ArgumentNullException(nameof(services));
}
if (startupType == null)
{
throw new ArgumentNullException(nameof(startupType));
}
return AddServerSideBlazorCore(services, startupType, configure);
}
/// <summary>
/// Adds Server-Side Blazor services to the service collection.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/>.</param>
/// <param name="configure">A delegate to configure the <see cref="ServerSideBlazorOptions"/>.</param>
/// <typeparam name="TStartup">A Blazor startup type.</typeparam>
/// <returns>The <see cref="IServiceCollection"/>.</returns>
public static IServiceCollection AddServerSideBlazor<TStartup>(
this IServiceCollection services,
Action<ServerSideBlazorOptions> configure)
this IServiceCollection services)
{
if (services == null)
{
throw new ArgumentNullException(nameof(services));
}
return AddServerSideBlazorCore(services, typeof(TStartup), configure);
return AddServerSideBlazorCore(services, typeof(TStartup));
}
private static IServiceCollection AddServerSideBlazorCore(
IServiceCollection services,
Type startupType,
Action<ServerSideBlazorOptions> configure)
Type startupType)
{
services.AddSingleton<CircuitFactory, DefaultCircuitFactory>();
services.AddScoped<ICircuitAccessor, DefaultCircuitAccessor>();
services.AddScoped<Circuit>(s => s.GetRequiredService<ICircuitAccessor>().Circuit);
services.AddScoped<IJSRuntimeAccessor, DefaultJSRuntimeAccessor>();
services.AddScoped<IJSRuntime>(s => s.GetRequiredService<IJSRuntimeAccessor>().JSRuntime);
services.AddScoped<IUriHelper, RemoteUriHelper>();
services.AddSignalR().AddMessagePackProtocol(options =>
{
// TODO: Enable compression, either by having SignalR use
// LZ4MessagePackSerializer instead of MessagePackSerializer,
// or perhaps by compressing the RenderBatch bytes ourselves
// and then using https://github.com/nodeca/pako in JS to decompress.
options.FormatterResolvers.Insert(0, new RenderBatchFormatterResolver());
});
AddStandardServerSideBlazorServices(services);
if (startupType != null)
{
// Make sure we only create a single instance of the startup type. We can register
// it in the services so we can retrieve it later when creating the middlware.
// Call TStartup's ConfigureServices method immediately
var startup = Activator.CreateInstance(startupType);
services.AddSingleton(startupType, startup);
// We don't need to reuse the wrapper, it's not stateful.
var wrapper = new ConventionBasedStartup(startup);
wrapper.ConfigureServices(services);
}
if (configure != null)
{
services.Configure(configure);
// Configure the circuit factory to call a startup action when each
// incoming connection is established. The startup action is "call
// TStartup's Configure method".
services.Configure<DefaultCircuitFactoryOptions>(circuitFactoryOptions =>
{
var endpoint = BlazorHub.DefaultPath; // TODO: allow configuring this
if (circuitFactoryOptions.StartupActions.ContainsKey(endpoint))
{
throw new InvalidOperationException(
"Multiple Server Side Blazor entries are configured to use " +
$"the same endpoint '{endpoint}'.");
}
circuitFactoryOptions.StartupActions.Add(endpoint, builder =>
{
wrapper.Configure(builder, builder.Services);
});
});
}
return services;
}
private static void AddStandardServerSideBlazorServices(IServiceCollection services)
{
// Here we add a bunch of services that don't vary in any way based on the
// user's configuration. So even if the user has multiple independent server-side
// Blazor entrypoints, this lot is the same and repeated registrations are a no-op.
services.TryAddSingleton<CircuitFactory, DefaultCircuitFactory>();
services.TryAddScoped<ICircuitAccessor, DefaultCircuitAccessor>();
services.TryAddScoped<Circuit>(s => s.GetRequiredService<ICircuitAccessor>().Circuit);
services.TryAddScoped<IJSRuntimeAccessor, DefaultJSRuntimeAccessor>();
services.TryAddScoped<IJSRuntime>(s => s.GetRequiredService<IJSRuntimeAccessor>().JSRuntime);
services.TryAddScoped<IUriHelper, RemoteUriHelper>();
// We've discussed with the SignalR team and believe it's OK to have repeated
// calls to AddSignalR (making the nonfirst ones no-ops). If we want to change
// this in the future, we could change AddServerSideBlazor to be an extension
// method on ISignalRServerBuilder so the developer always has to chain it onto
// their own AddSignalR call. For now we're keeping it like this because it's
// simpler for developers in common cases.
services.AddSignalR().AddMessagePackProtocol(options =>
{
options.FormatterResolvers.Insert(0, new RenderBatchFormatterResolver());
});
}
}
}

View File

@ -1,12 +0,0 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
namespace Microsoft.AspNetCore.Blazor
{
/// <summary>
/// Options for Server-Side Blazor.
/// </summary>
public class ServerSideBlazorOptions
{
}
}

View File

@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Blazor.Server;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
@ -39,7 +40,14 @@ namespace TestServer
// Mount the server-side Blazor app on /subdir
app.Map("/subdir", subdirApp =>
{
subdirApp.UseServerSideBlazor<BasicTestApp.Startup>();
// The following two lines are equivalent to:
// subdirApp.UseServerSideBlazor<BasicTestApp.Startup>();
// However it's expressed using UseSignalR+UseBlazor as a way of checking that
// we're not relying on any extra magic inside UseServerSideBlazor, since it's
// important that people can set up these bits of middleware manually (e.g., to
// swap in UseAzureSignalR instead of UseSignalR).
subdirApp.UseSignalR(route => route.MapHub<BlazorHub>(BlazorHub.DefaultPath));
subdirApp.UseBlazor<BasicTestApp.Startup>();
});
}