dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.

Home Page:https://asp.net

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

"Minimal hosting" for ASP.NET Core applications

davidfowl opened this issue · comments

Summary

We want to introduce a new "direct hosting" model for ASP.NET Core applications. This is a more focused, low ceremony way of creating a web application.

Motivation and goals

Introducing a lower ceremony replacement for the WebHost to remove some of the ceremony in hosting ASP.NET Core applications. We've received lots of feedback over the years about how much ceremony it is to get a simple API up and running and we have a chance to improve that with the deprecation of the WebHost.

In scope

  • Build on the same primitives as ASP.NET Core
  • Take advantage of existing ASP.NET Core middleware and frameworks built on top
  • Ability to use existing extension methods on the IServiceCollection, IHostBuilder and IWebHostBuilder

Out of scope

  • Changing the DI registration model
  • Testability - While this is possible makes it very hard to reduce some of the ceremony

Risks / unknowns

  • Having multiple ways to build a web application.
  • Tools are broken
    • EF Core Tools (for example, migration) try to invoke Program.CreateHostBuilder() which no longer exists
    • Unit testing with Test Server

Strawman proposal

The idea is to reduce the number of concepts while keeping compatibility with the ecosystem we have today. Some core ideas in this new model is to:

  • Reduce the number of callbacks used to configure top level things
  • Expose the number of top level properties for things people commonly resolve in Startup.Configure. This allows them to avoid using the service locator pattern for IConfiguration, ILogger, IHostApplicationLifetime.
  • Merge the IApplicationBuilder, the IEndpointRouteBuilder and the IHost into a single object. This makes it easy to register middleware and routes without needed an additional level of lambda nesting (see the first point).
  • Merge the IConfigurationBuilder, IConfiguration, and IConfigurationRoot into a single Configuration type so that we can access configuration while it's being built. This is important since you often need configuration data as part of configuring services.
  • UseRouting and UseEndpoints are called automatically (if they haven't already been called) at the beginning and end of the pipeline.
public class WebApplicationBuilder
{
    public IWebHostEnvironment Environment { get; }
    public IServiceCollection Services { get; }
    public Configuration Configuration { get; }
    public ILoggingBuilder Logging { get; }

    // Ability to configure existing web host and host
    public ConfigureWebHostBuilder WebHost { get; }
    public ConfigureHostBuilder Host { get; }

    public WebApplication Build();
}

public class Configuration : IConfigurationRoot, IConfiguration, IConfigurationBuilder { }

// The .Build() methods are explicitly implemented interface method that throw NotSupportedExceptions
public class ConfigureHostBuilder : IHostBuilder { }
public class ConfigureWebHostBuilder : IWebHostBuilder { }

public class WebApplication : IHost, IApplicationBuilder, IEndpointRouteBuilder, IAsyncDisposable
{
    // Top level properties to access common services
    public ILogger Logger { get; }
    public IEnumerable<string> Addresses { get; }
    public IHostApplicationLifetime Lifetime { get; }
    public IServiceProvider Services { get; }
    public IConfiguration Configuration { get; }
    public IWebHostEnvironment Environment { get; }

    // Factory methods
    public static WebApplication Create(string[] args);
    public static WebApplication Create();
    public static WebApplicationBuilder CreateBuilder();
    public static WebApplicationBuilder CreateBuilder(string[] args);

    // Methods used to start the host
    public void Run(params string[] urls);
    public void Run();
    public Task RunAsync(params string[] urls);
    public Task RunAsync(CancellationToken cancellationToken = default);
    public Task StartAsync(CancellationToken cancellationToken = default);
    public Task StopAsync(CancellationToken cancellationToken = default);

    public void Dispose();
    public ValueTask DisposeAsync();
}

Examples

Hello World

using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;

var app = WebApplication.Create(args);

app.MapGet("/", async http =>
{
    await http.Response.WriteAsync("Hello World");
});

await app.RunAsync();

Hello MVC

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

var app = builder.Build();

app.MapControllers();

await app.RunAsync();

public class HomeController
{
    [HttpGet("/")]
    public string HelloWorld() => "Hello World";
}

Integrated with 3rd party ASP.NET Core based frameworks (Carter)

using Carter;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddCarter();

var app = builder.Build();

app.Listen("http://localhost:3000");

app.MapCarter();

await app.RunAsync();

public class HomeModule : CarterModule
{
    public HomeModule()
    {
        Get("/", async (req, res) => await res.WriteAsync("Hello from Carter!"));
    }
}

More complex, taking advantage of the existing ecosystem of extension methods

using System.Threading.Tasks;
using Autofac;
using Autofac.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Serilog;

var builder = WebApplication.CreateBuilder(args);

builder.Configuration.AddYamlFile("appsettings.yml", optional: true);

builder.Host.UseSerilog((context, configuration)
    => configuration
        .Enrich
        .FromLogContext()
        .WriteTo
        .Console()
    );

builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory());

builder.Host.ConfigureContainer<ContainerBuilder>(b =>
{
    // Register services using Autofac specific methods here
});

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}

app.UseRouting();

app.MapGet("/", async http =>
{
    await http.Response.WriteAsync("Hello World");
});

await app.RunAsync("http://localhost:3000");

cc @LadyNaggaga @halter73 @shirhatti

For MVC why not

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;

var app = WebApplication.CreateMvc(args);

await app.RunAsync();

public class HomeController
{
    [HttpGet("/")]
    public string HelloWorld() => "Hello World";
}

where CreateMvc does the necessary steps?
The app could be configured further.

Looking through "beginner eyes":

  • Why do I need to create a "builder" and what is a builder?
  • Why do I need to get "services" and add "controllers" which I need to map then?

I don't think it scales well. You need to undo CreateMvc or create more concepts if you want to do anything (like adding other features, or setting configuration options, or configuring pretty much anything). The key to this is not just making it simple to start but simple to grow up. We don't want to fork the framework concepts of ideas or make something that doesn't work with the existing ecosystem. That's why its super important to design something right in the middle that achieves the code brevity and reduces the concept count at the same time.

Now we could consider including AddControllers and MapControllers in Create or CreateBuilder. My only problem with that is that MVC scans assemblies by default which I think is unintuitive for this scenario.

reduces the concept count at the same time

The current proposal for MVC adds builder, services and controllers.
This should be abstracted away.

undo CreateMvc or create more concepts if you want to do anything (like adding other features, or setting configuration options, or configuring pretty much anything)

Good point. So maybe make CreateMvc as sugar-extension for fast start, when only MVC-controllers are needed?

One of the goals is to reduce "unlearning". That is, the amount of code you need to undo once you need to do something additive. Also The MVC name doesn't mean much, o don't think it needs to be in the method. We'll experiment with what on by default would mean

We'll have to figure out what to do with tools like EF migrations and WebApplicationFactory that depend on the current Program.CreateHostBuilder pattern.

We'll have to figure out what to do with tools like EF migrations and WebApplicationFactory that depend on the current Program.CreateHostBuilder pattern.

I'm not sure those are in scope (that's why testing is out of scope in the description above), or better put, we have to make a tradeoff somewhere and I'm ok if that's what needs to be traded off.

Would we change the templates to use this new pattern? It would be problematic if EF migrations didn't work in the templates.

Also, even for non-template scenarios, we should at least update the migration tool to recognize the new scenario enough to give a better error message.

Would we change the templates to use this new pattern? It would be problematic if EF migrations didn't work in the templates.

Undecided, I would like to but there are obviously problems beyond EF migrations (and other tools like it).

Also, even for non-template scenarios, we should at least update the migration tool to recognize the new scenario enough to give a better error message.

I think it does this today, if not, it should since that method can be easily removed.

Thanks for contacting us.
We're moving this issue to the Next sprint planning milestone for future evaluation / consideration. We will evaluate the request when we are planning the work for the next milestone. To learn more about what to expect next and how this issue will be handled you can read more about our triage process here.

API Review:

  • public IWebHostBuilder Server { get; } -> WebHost
  • Configuration - Should this move to the runtime repo? Maybe add IConfigurationBuilder.Peek()
  • Missing IAsyncDisposable.DisposeAsync

Configuration - Should this move to the runtime repo? Maybe add IConfigurationBuilder.Peek()

What does this mean?

public class Configuration : IConfigurationRoot, IConfiguration, IConfigurationBuilder { }

That type doesn't have any ASP.NET dependencies and should live in the Extensions.Configuration packages.

That type doesn't have any ASP.NET dependencies and should live in the Extensions.Configuration packages.

What does that have to do with Peek()

Oh that was just an off hand comment I made, wasn't suggesting a name at the time, but just guessing what this mega type would be doing, its basically letting you 'Peek' at config values while still building the config. That seems like it could be generally useful so I can see why it was was mentioned in the same line as possible extensions enhancements.

Made some API tweaks based on discussion today with @DamianEdwards

@maryamariyan @ericstj @eerhardt We should discussion this new merged IConfigurationBuilder/IConfiguration type and where it belongs.

We could investigate testability via something injected via a startup hook potentially.

Actually could we potentially have tools like dotnet ef use a startup hook to run the app in a mode that allows them to do the introspection they need, rather than have the app run normally or use the horribad Program.CreateHostBuilder pattern we do today?

@davidfowl imagining this as a combination of ConfigurationRoot and ConfigurationBuilder types that exist today? I guess we'd want to see an API proposal in dotnet/runtime with full API. When would the type call Build on sources / Load on the providers?

Actually could we potentially have tools like dotnet ef use a startup hook to run the app in a mode that allows them to do the introspection they need, rather than have the app run normally or use the horribad Program.CreateHostBuilder pattern we do today?

The issue is communicating the instance of the service provider to the startup hook. We could use a diagnostic source or something like that.

The issue is communicating the instance of the service provider to the startup hook. We could use a diagnostic source or something like that.

Just an FYI - startup hooks and diagnostic sources are not linker-friendly. Not sure how important it is in this scenario, but I just wanted to call it out.

@davidfowl imagining this as a combination of ConfigurationRoot and ConfigurationBuilder types that exist today? I guess we'd want to see an API proposal in dotnet/runtime with full API. When would the type call Build on sources / Load on the providers?

I can write one but I want to have a little design meeting with the team first. It's kinda crazy 😃

app.MapGet("/", async http => // Can this be an HttpRequest not an HttpContext?
{
    await http.Response.WriteAsync("Hello World"); // Instead of mutating something, can this expect an HttpResponse?
});

@charlesroddie this issue has nothing to do with the request handling part of the code, it's about describing a new host API with the existing request handling APIs.

That said, the RequestDelegate is a well established primitive in the stack. We can always add new method overloads but I'd be wary about introducing a new core primitive type like a different response/request. The existing response doesn't fit this model but higher level abstractions can be built on top.

Some ideas as a user of the current builder APIs.

For a point of reference, here is what I need to do to turn the existing APIs into modular host options in F#. This is the (under-parameterized) code to focus on.

        createBuilder ()
        |> setConfig basePath
        |> setLogger
        |> setKestrel
        |> setJwtAuth
        |> setCorsPolicy
        |> setAppRoute (App.routes settings logger)
        |> build

The C# equivalent would be a fluent builder interface. The current scheme uses builders, but with some unnecessary pain points:

  • Figuring out which namespace to install/open to get the correct UseX extension
    • Examples frequently use a builder extension without showing the open, or the open differs from the package name.
    • I guess more of a packaging concern
  • Finding the right method overload for my needs is trial and error and/or online research
    • Alternatively, knowing which I-thing to pull out of DI is similar pain
  • AddX vs UseX
    • in some cases both are required and UseX is easy to miss
    • Inconsistent experience. Most CORS config is in UseX, but in AddX for JWT auth
  • Sub-builders frequently require invoking methods
    • Worse, the order of these matter and can be incorrect for purpose
    • Where possible, the configuration should just be data, plus the occasional lambda clause 🎅
    • Required method calls could be just data to the user (the method's parameters), and internally use the command pattern
    • If correct ordering is needed, it can be determined at command execution (host build) time
  • Group related options into a record rather than lambda option-setting or a builder method per property
    • The provided record would get merged into the feature default (like lambda options do)
    • Configuration examples should avoid positional record syntax. Labels make things clearer.

The builders that we have now are mostly data disguised as method calls and are sometimes abused as the latter. Probably the simplest way to improve them is remove the disguise. And allow different features to be well isolated from each other. I always dreaded the Startup class because, it was such a mix of concerns that it was hard to maintain over time. (Also I didn't like it because of reflection-based startup... It's well worth the line of code to know where startup is called and have the ability to run code after it ends.)

The best case scenario would be the ability to copy and paste feature config data, tweak some values, and have that feature running. No special method sequencing. No lambda procedures. Just data. And being able to mix and match config for different features without rework. After that, some marketing-focused shortcut overloads for common scenarios can be made for people to ooh and aah over.

Hopefully these are helpful comments and appropriate for the discussion. I can delete if not.

public sealed class WebApplicationBuilder
{
    public IWebHostEnvironment Environment { get; }
    public IServiceCollection Services { get; }
    public Configuration Configuration { get; }
    public ILoggingBuilder Logging { get; }

    // Ability to configure existing web host and host
    public ConfigureWebHostBuilder WebHost { get; }
    public ConfigureHostBuilder Host { get; }

    public WebApplication Build();
}

public sealed class Configuration : IConfigurationRoot, IConfigurationBuilder { }

// The .Build() methods are explicitly implemented interface method that throw NotSupportedExceptions
public sealed class ConfigureHostBuilder : IHostBuilder { }
public sealed class ConfigureWebHostBuilder : IWebHostBuilder { }

public sealed class WebApplication : IHost, IApplicationBuilder, IEndpointRouteBuilder, IAsyncDisposable
{
    // Top level properties to access common services
    public ILogger Logger { get; }
-    public IEnumerable<string> Addresses { get; }
+    public ICollection<string> Urls { get; }
    public IHostApplicationLifetime Lifetime { get; }
    public IServiceProvider Services { get; }
    public IConfiguration Configuration { get; }
    public IWebHostEnvironment Environment { get; }

    // Factory methods
    public static WebApplication Create(string[]? args = null);
-    public static WebApplication Create();
-    public static WebApplicationBuilder CreateBuilder();
    public static WebApplicationBuilder CreateBuilder(string[]? args = null);

    // Methods used to start the host
-    public void Run();
-    public void Run(params string[] urls);
-    public Task RunAsync(params string[] urls);
-    public Task RunAsync(CancellationToken cancellationToken = default);
+   public void Run(string? url = nul);
+   public Task RunAsync(string? url = null);
    public Task StartAsync(CancellationToken cancellationToken = default);
    public Task StopAsync(CancellationToken cancellationToken = default);

-    public void Dispose();
    public ValueTask DisposeAsync();
}

Aren't these overloads ambiguous because of the params? Why do you need the second one? And why does Run only have the string overload?

    public Task RunAsync(params string[] urls);
+   public Task RunAsync(string? url = null);

We meant to remove the params overload in favor of the single argument one. I updated the comment to reflect that.

Interesting choice, that precludes doing http and https together with this API. What's the fallback, UseAddresses?

Interesting choice, that precludes doing http and https together with this API. What's the fallback, UseAddresses?

Old API?

var builder = WebApplication.Create();
builder.WebHost.UseUrls(...);
var app = builder.Build();

Interesting choice, that precludes doing http and https together with this API. What's the fallback, UseAddresses?

My guess is that we want to wait for feedback to see how common it is to specify the URL?

Btw, would we consider adding Urls as an option on WebApplicationBuilder? It's one of the few properties that is available for read on WebApplication but cannot be set by the builder.

WebApplicationBuilder
{
+   public ICollection<string> Urls { get; }
}

I was thinking about changing the type of WebApplication.Addresses to be an ICollection<string> so it would also work in the WebApplication.Create() case and wouldn't add a new property.

public sealed class WebApplication : IHost, IApplicationBuilder, IEndpointRouteBuilder, IAsyncDisposable
{
-    public IEnumerable<string> Addresses { get; }
+    public ICollection<string> Addresses { get; }
}

Should I make that change for preview4?

My guess is that we want to wait for feedback to see how common it is to specify the URL?

The quickest way to test that would be to remove the parameter from Run/Async and see if setting the url(s) is a common problem.

You need to be able to set a URL.

You need to be able to set a URL.

Sure, but is Run the right place for that?

That was inspired by Listen(..) on nodejs' server API.

I think we should definitely collect feedback on passing the URL directly to RunAsync. I'm going to make the Addresses change to ICollection<string> because it just works, but I think people are going to prefer to pass it as an argument. We might want to go back to accepting a params array depending on the feedback.

hmm just made me wonder, if I can both attach middleware and routing with WebApplication, isn't there a conflict between two Map methods taking a string and request delegates? One maps a middleware based on path, one maps a routing endpoint?

I also have additional question, what will be the role of generic host and web host then? used for non aspnetcore or very advanced cases, or intended to be deprecated, or what? I mean is it considered an old api or an advanced api?
I was wondering about things like what if defaults don't fit my needs for some reason? Can I make only additive changes to these defaults or also override them all together, especially sources of configuration and logging providers? With generic host I could definitely create an empty one and build it fully from scratch if I felt a need to do it, without having to explicitly remove defaults I didn't like...

hmm just made me wonder, if I can both attach middleware and routing with WebApplication, isn't there a conflict between two Map methods taking a string and request delegates? One maps a middleware based on path, one maps a routing endpoint?

This is a good point but we don't have that conflict today. I'd imagine if you added methods that conflicted, the compiler would tell you it's ambiguous.

I also have additional question, what will be the role of generic host and web host then? used for non aspnetcore or very advanced cases, or intended to be deprecated, or what? I mean is it considered an old api or an advanced api?

WebHostBuilder/WebHost is on a path to deprecation, but the IWebHostBuilder will stay around forever since all of the APIs hang off it.

The generic host is also here to stay and will be the target for non web APIs. We haven't figured out how to message when to use one over the other as yet. The new host is built on top of the generic host internally so we don't have plans to replace that. I think it'll come down to the preference of calling UseStartup or not.

With generic host I could definitely create an empty one and build it fully from scratch if I felt a need to do it, without having to explicitly remove defaults I didn't like...

#32485

IMO it starts to be a bit messy :) but I admit the proposed api is pretty nice. I often have problems with when to use what, at least in case of webhost vs generic host it was more clear because it was obvious the latter replaces the former effectively.
Anyway thanks for the link.

if there is no conflict when it goes to Map method, so how do you do Map? or is it something that is not useful? I mean the non routing Map one.

Pff my bad. It seems there is no conflict, so my only concern is that there are two... well more than two, like... 5? methods named Map accessible from the new WebApplication, and some of them add a routing entry and others add a middleware in that exact place of pipeline. So possible confusion when someone looks up all extension methods :) but at least it's not going to yell at me for ambiguous methods.

You need to be able to set a URL.

What would be the solution for multi-tenant solutions? Some of these don't have preconfigured URLs, they are configured at runtime by being read from a data-store or something.

I'm not sure what that has to do with configuring the hosting URLs.

My bad, hosting URLs are for Kestrel and WebListener, nothing to do with multi-tenancy.