Skip to content
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

Ability to resolve the IServiceProvider used to lease an instance of a pooled DbContext #23559

Closed
koenbeuk opened this issue Dec 2, 2020 · 5 comments

Comments

@koenbeuk
Copy link

koenbeuk commented Dec 2, 2020

(Especially) with the newly released support for SaveChanges interceptors, we often want to communicate back with our application services. e.g. We may want to implement an audit handler that logs changes to an entity. Interceptors are great for this however since they are not able to directly use the same ServiceProvider that was used to obtain the DbContext, it will have to fall back on trickery, it can use the ApplicationServiceProvider which is already a concept, however this is likely not the ScopedServiceProvider used to resolve the DbContext.

It would be great if we can have a reference to the ServiceProvider used to obtain the instance of the DbContext

I've recently released an extension for EFCore called EntityFrameworkCore.Triggered, in which I added extension methods: AddTriggeredDbContext (with similar alternatives for pooled and factory) that on top of simply calling AddDbContext, also ensured that the current IServiceProvider is stored and retrievable later on. This works consistently well, but is tied to the Triggered projects implementation. I also prefer the simpler method of registration: services.AddDbContext<FooContext>(options => options.UseTriggers()) as its much more flexible. Unfortunately the latter approach does not offer an easy way of capturing the ServiceProvider used to obtain an instance of a DbContext.

Similar approaches that I'm supporting currently:

  • Using DI on the constructor on the DbContext to obtain the IServiceProvider (this does not work for pooled instances).
  • Integration with specific technologies (e.g. HttpContextAccessor could be used with ASP.NET Core to get access to the likely IServiceProvider that was used to obtain the instance of the DbContext).

Non of these are great and it would be much more fitting if we had something like: DbContext.OwnedApplicationServiceProvider

@ajcvickers
Copy link
Contributor

@koenbeuk It feels to me like this is mostly a duplicate of #13540. With regards to pooling, since the context instance has not been created from the current service provider, it would likely need to hook into this: #17086.

@koenbeuk
Copy link
Author

koenbeuk commented Dec 3, 2020

@ajcvickers I don't think #13540 will help in this case since this would enable application registered singleton services to be intermixed with EF Core's internal singleton services. Perhaps a small example will help in demonstrate what I'd like to enable with this issue:

var sp = new ServiceCollection()
      .AddDbContext<MyDbContext>() // this will register the SaveChangesInterceptor internally
      .AddScoped<ICurrentUserService, MySuperComplexCurrentUserService>()  
      .AddScoped<UserStatsService>()
      .BuildServiceProvider();

var scope = sp.CreateScope();
scope.ServiceProvider.GetService<ICurrentUserService>().SetUser(...something local);
var db = scope.ServiceProvider.GetService<MyDbContext>();
db.Orders.Add(new Order(...));
db.SaveChanges();

class MySaveChangesInterceptor : SaveChangesInterceptor {
    public MySaveChangesInterceptor(IOwnedServiceProviderAccessor serviceProviderAccessor) { ... }

    public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
    {
        var userStatsService = serviceProviderAccessor.GetServiceProvider().GetService<UserStatsService>();
        userStatsService.UpdateCurrentUserStats();
        return result;
    }
}

class UserStatsService {
    public UserSevice(ApplicationDbContext dbContext, ICurrentUserService currentUserService) { ... }
    public void UpdateCurrentUserStats() {
        var currentUserId = currentUserService.GetUserId(); // This would be a scoped service by itself that knows how to return the userId from lets say http request headers.
        var user = dbContext.Users.Find(userId);
        user.LastUpdateActivityDate = DateTime.UtcNow;
    }
}

This example is perhaps a bit superfluous however I have plenty of examples where we have a legit reason to interact with scoped application services. In this case, I need to get my hands on an instance of UserStatsService which needs to come from the same scoped ServiceProvider that was used to resolve the current DbContext instance. I introduced a type in EFCore called: IOwnedServiceProviderAccessor (placeholder name), which can give me that piece of information.

Here is an example of a Trigger (which sits on top of SaveChanges interception hence experiencing the same limitations) and its need for having access to the ScopedServiceProvider.

One way of doing this, is like so:

public static IServiceCollection AddMyDbContext<TContext>(this IServiceCollection serviceCollection) 
    where TContext : DbContext
{
    serviceCollection.TryAdd(ServiceDescriptor.Describe(
        serviceType: typeof(TContext),
        implementationFactory: serviceProvider => {
            var instance = ActivatorUtilities.CreateInstance(serviceProvider, contextType) as DbContext;
            instance.GetSevice<IOwnedServiceProviderAccessor>().SetServiceProvider(serviceProvider);
            return instance;
        },
        lifetime: contextLifetime));

    serviceCollection.AddDbContext<TContext>();

    return serviceCollection;
}

Assuming that IOwnedServiceProviderAccessor is a thing, we can implement similar patterns for Pools and Factories, as I've done here. Granted this is far from pretty.

Given the above, I dont think hooks as suggested in #17086 is relevant for this issue.

@ajcvickers
Copy link
Contributor

@koenbeuk Given that example, can you not do something like this:

public interface ICurrentUserService
{
    void UpdateCurrentUserStats();
}

public class MySuperComplexCurrentUserService : ICurrentUserService
{
    private static int _counter;
    private readonly int _service;
    
    public MySuperComplexCurrentUserService()
    {
        _service = ++_counter;
        Console.WriteLine($"Created MySuperComplexCurrentUserService {_service} ");
    }

    public void UpdateCurrentUserStats()
    {
        Console.WriteLine($"Calling MySuperComplexCurrentUserService service {_service}");
    }
}

public class MySaveChangesInterceptor : SaveChangesInterceptor
{
    public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
    {
        eventData.Context.GetService<ICurrentUserService>().UpdateCurrentUserStats();

        return result;
    }
}

public class Blog
{
    public int Id { get; set; }
}

public class SomeDbContext : DbContext
{
    public SomeDbContext(DbContextOptions<SomeDbContext> options) : base(options)
    {
    }

    public DbSet<Blog> Blogs { get; set; }
}

public class Program
{
    public static void Main()
    {
        var sp = new ServiceCollection()
            .AddDbContext<SomeDbContext>(b =>
            {
                b.UseSqlServer(Your.ConnectionString)
                    .AddInterceptors(new MySaveChangesInterceptor());
            })
            .AddScoped<ICurrentUserService, MySuperComplexCurrentUserService>()
            .BuildServiceProvider();

        for (int i = 0; i < 2; i++)
        {
            Console.WriteLine("Creating scope...");
            using (var scope = sp.CreateScope())
            {
                var context = scope.ServiceProvider.GetService<SomeDbContext>();

                Console.WriteLine("Calling SaveChanges...");
                context.Add(new Blog());
                context.SaveChanges();

                Console.WriteLine("Calling SaveChanges...");
                context.Add(new Blog());
                context.SaveChanges();
            }
            Console.WriteLine("Scope disposed.");
        }
    }
}

Output

Creating scope...
Calling SaveChanges...
Created MySuperComplexCurrentUserService 1 
Calling MySuperComplexCurrentUserService service 1
Calling SaveChanges...
Calling MySuperComplexCurrentUserService service 1
Scope disposed.
Creating scope...
Calling SaveChanges...
Created MySuperComplexCurrentUserService 2 
Calling MySuperComplexCurrentUserService service 2
Calling SaveChanges...
Calling MySuperComplexCurrentUserService service 2
Scope disposed.

As far as I can tell, this does what you want.

@koenbeuk
Copy link
Author

koenbeuk commented Dec 8, 2020

@ajcvickers Yes! that is working indeed, My original test was probably bugged. Thanks for taking the time and effort in working that one out.

So for a scoped DbContext, this is indeed working. For a singleton, Scoped services will obviously not work, however a pooled DbContext is somewhere in a gray area. I understand that its essentially a singleton and that ApplicationServiceProvider refers to the ServiceProvider that owns he actual DbContext, however its consumption is typically that of a scoped service. In other words: When I DI inject a DbContext then it should not matter if its registered as a scoped DbContext or a pooled DbContext (as this may be out of my control).

Coming back to my earlier point, If we can capture the ServiceProvider that was used to lease a DbContext and if we were to treat that as the ApplicationServiceProvider then from a DbContext consumer point of view, things should just work regardless how it was registered in the DI container.

@koenbeuk koenbeuk changed the title Ability to resolve the IServiceProvider used to obtain an instance of a DbContext Ability to resolve the IServiceProvider used to leasae an instance of a pooled DbContext Dec 8, 2020
@ajcvickers
Copy link
Contributor

@koenbeuk I think both this and #13540 are essentially asking for the same thing: to make it easier to access the application service provider in more scenarios. You've made some good points here, so I'm going to reference this issue from there and make the description of that issue more general.

@koenbeuk koenbeuk changed the title Ability to resolve the IServiceProvider used to leasae an instance of a pooled DbContext Ability to resolve the IServiceProvider used to lease an instance of a pooled DbContext Dec 9, 2020
@ajcvickers ajcvickers reopened this Oct 16, 2022
@ajcvickers ajcvickers closed this as not planned Won't fix, can't repro, duplicate, stale Oct 16, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants