simpleinjector / SimpleInjector

An easy, flexible, and fast Dependency Injection library that promotes best practice to steer developers towards the pit of success.
https://simpleinjector.org
MIT License
1.21k stars 154 forks source link

Support For Multiple Web Hosts in a Single Process? #741

Closed Aluminus closed 5 years ago

Aluminus commented 5 years ago

I am in the process of migrating some web services from ASP.NET to ASP.NET Core. So far, everything is fine, and Simple Injector is great, except when it comes to integration testing. To make things easier for my fellow developers on the team, I've built integration tests that host all the necessary web services in a single process so that all of the services can be inspected and debugged together as they communicate. Up until Simple Injector 4.6, this worked beautifully.

Simple Injector 4.6 adds IServiceCollection.AddSimpleInjector extension, which can only be called once per container. To simplify managing registrations, our solution has only one container, into which each project registers the types it provides. This works well since our services are using shared libraries, it eliminates a lot of duplication. The restriction to call AddSimpleInjector only once per container now means that we can only host one web service per process, breaking our previous integration test setup.

(See #496 which is about hosting multiple containers. We want to avoid this as much as possible. In the early days of working with Simple Injector, our project had multiple containers, and it was a configuration nightmare.)

Our test setup code might look like this:

Authentication.DependencyInjection.Register(Container, DependencySettings.IntegrationTestSettings);
OtherHost.DependencyInjection.Register(Container, DependencySettings.IntegrationTestSettings);

var AuthenticationHost = WebHost.BuildWebHost(new AuthenticationSettings(...));
AuthenticationHost.Start();
var OtherHost = WebHost.BuildWebHost(new OtherSettings(...));           
OtherHost.Start(); 

Container.Verify();

In each host's Startup class, Simple Injector is integrated as explained in the integration help documentation. In each project's DependencyInjection.Register method, the project registers the types it owns, and configures them based on the dependency settings we specify (in this case integration testing).

Questions:

  1. Is it possible to get around the restriction of calling AddSimpleInjector only once? (It's probably not even advisable even if it was possible, I guess)
  2. Is there a sane way to manage multiple containers if the project relies heavily on shared libraries? We need to avoid things like duplicate registrations of course.
  3. Should I instead create a special Startup class for integration testing that is aware of this new restriction in Simple Injector?
  4. Should I use a different strategy to do registrations in integration tests than in production? I wanted all the code to be the same, even though the environment is different. That's the main motivation for using dependency injection in the first place.
  5. I cannot verify the container until after the services have started, because the process of starting a web service modifies the container. Is this good practice? (Maybe I should move this to a new issue)

Thank you so much for your work on Simple Injector. It has greatly improved the quality of our code.

Aluminus commented 5 years ago

(For the record, I am not against the new SimpleInjector API per se, it just caught me by surprise on this issue.) Let's say I have a web host for a service called Dashboard. It's using ASP.NET WebAPI2. It's configure method might look like this:

public override void Configure(IAppBuilder app)
{
    // I think this was the old style of setting up the scope, before the integration package handled it
    app.Use(async (context, next) =>   
    {
       using (AsyncScopedLifestyle.BeginScope(Container)) 
    {
        await next();
    }
    });
   HttpConfiguration config = new HttpConfiguration();
   // Let's pretend some more http config stuff happens here
   config.DependencyResolver = new SimpleInjectorWebApiDependencyResolver(Container);
  config.EnsureInitialized();
  app.UseWebApi(config);
}

I have another file that handles host startup and configuration related things. It looks like this:

public IDisposable BeginDashboard()
{
   Dashboard.StartupOptions startupOptions = new Dashboard.StartupOptions();
   // Some service startup options are set here, SimpleInjector not involved yet
   StartOptions startOptions = new StartOptions();
   startOptions.Urls.Add(BaseAddress);
  WebHost = WebApp.Start(startOptions, (app) => new Dashboard.Startup(startupOptions).Configure(app));
  return WebHost;
}

Each web service I want to host in ASP.NET WebAPI2 follows this pattern. When I want to start all the services in my test, I handle the registrations for all the libraries (I'm using Specflow to manage these tests, not sure if that is relevant):

[Binding]
public static TestHooks()
{
  Dashboard.Register(Container, IntegrationTestSettings)
  OtherService.Register(Container, IntegrationTestSettings)
  // etc.
  Container.Verify();
}

I create the hosts, then start the services together:

[BeforeTestRun]
public static void BeforeTestRun()
{
  DashboardHost.BeginDashboard();
  OtherHost.BeginOther();
  // etc.
}

When my test runner starts the tests, the services are configured, the hosts are created, the services start, and they are ready to use for testing. SimpleInjector does not seem to complain about this process. All of the references to "Container" above refer to the same container.

dotnetjunkie commented 5 years ago

That's an interesting idea to host everything in process. I can certainly see how this would simplify debugging (as debugging a dozen of services from their own Visual Studio instance is hellish. And I know, because this is something I do for my current client, almost on a daily basis).

Is it possible to get around the restriction of calling AddSimpleInjector only once? (It's probably not even advisable even if it was possible, I guess)

No, there isn't. This is a limitation that both exists in the Simple Injector integration and in ASP.NET Core itself. When integrating with ASP.NET Core, the Simple Injector Container couples itself with ASP.NET Core's built-in container. This allows Simple Injector to resolve framework components from the built-in container and inject it into application components—a technique called cross wiring.

A Simple Injector container, however, can only be coupled with a single framework container. When coupling it with multiple framework container instances, Simple Injector would not understand from which framework container it should resolve the framework registrations.

This problem would be solvable if you could reuse the same framework container for all the hosts you run in a single app domain, but this is practically impossible. In ASP.NET Core, each hosts gets its own container instance—its own isolated bubble of dependencies. As much of ASP.NET Core's configuration is done using the container, reusing the same framework container instance throughout multiple hosts could easily, silently and accidentally override configuration settings of another host.

This means that when working with ASP.NET Core, you will always have isolated bubbles. Generally this is perfectly fine. In normal cases you will only have one host, one bubble, and therefore, only one container.

As a matter of fact, from a verifiability perspective, I would suggest you use the one-container-per-host approach, as this simulates production the most. You don't want the configuration of one host to influence the next. That could lead to hard to spot bugs, which only appear in your integration test and never in production.

Is there a sane way to manage multiple containers if the project relies heavily on shared libraries? We need to avoid things like duplicate registrations of course.

To be honest, I find it hard to picture a situation where working with multiple containers is hard to manage for you right now? I would expect each host to have its own Startup class. As you have lots of reused registrations, you probably moved this to reusable libraries. This means that each Startup class would call your custom AddMyComponanyLib() extension method to add the registrations to its own container instance.

The only difficulty is overriding certain registrations for your integration tests. But it should not be hard to expose a Startup-specific container instance to your integration tests and wrap them in a collection. This allows you to iterate the collection of containers to replace a certain dependency. For instance, you likely want to replace the SmsMessageSender with a fake one, to prevent costs and people getting bogus text messages. Iterating the container collection can be wrapped in a helper method, to prevent having lots of code duplication.

Should I instead create a special Startup class for integration testing that is aware of this new restriction in Simple Injector?

I have no experiences with this, but you might have to do this to allow the Container instance to be exposed to allow the integration tests to replace certain registrations.

Should I use a different strategy to do registrations in integration tests than in production? I wanted all the code to be the same, even though the environment is different. That's the main motivation for using dependency injection in the first place.

As a single Container instance won't work, you probably should, but I don't think the complete strategy must be changed.

I cannot verify the container until after the services have started, because the process of starting a web service modifies the container. Is this good practice? (Maybe I should move this to a new issue)

With multiple containers, it means multiple calls to Verify(). But I find it most obvious to not verify the containers at all in that integration test. Instead, move verification to the unit tests of the particular service. That's where the developer of the service expect something to break when he changes the configuration of that particular service. When all individual configurations are correct, you don't have to check this again in your all-in-process integration test.

I hope this makes sense.