EntityFramework Core 3.x上下文构造函数可以注入实例呢?

时间:2024-02-01 18:02:44

前言

今天讨论的话题来自一位微信好友遇到问题后请求我的帮助,当然他的意图并不是本文标题,只是我将其根本原因进行了一个概括,接下来我们一起来探索标题的问号最终的答案是怎样的呢?

上下文构造函数是否可以注入实例?

老规矩,首先我们定义如下上下文

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

    }
}

接下来在Web应用程序中如下注入该上下文实例,然后我们就可以开心的玩耍了

services.AddDbContext<EFCoreDbContext>(options =>
{
    options.UseSqlServer(@"Server=.;Database=EFCoreTest;Trusted_Connection=True;");
});

问题来了,这位童鞋说,我想要在上述上下文中注入一个实例,当时听到这种情况还比较惊讶,什么情况下才会在上下文构造函数中注入实例呢?我们先不关心这个问题,那还不好说,和正常在ASP.NET Core中使用不就完事了么,实践是检验真理的唯一标准,我们来试试,定义如下接口:

public interface IHello
{
    string Say();
}

public class Hello : IHello
{
    public string Say()
    {
        return "Hello World";
    }
}

接下来则是注入该接口,如下:

services.AddScoped<IHello, Hello>();

然后就来到上下文构造函数中使用该接口,我们搞个方法来测试下看看,如下:

public class EFCoreDbContext : DbContext
{
    private readonly IHello _hello;
    public EFCoreDbContext(DbContextOptions<EFCoreDbContext> options,
        IHello hello) : base(options)
    {
        _hello = hello;
    }

    public string Print()
    {
        return _hello.Say();
    }
}

最后我们在控制器中使用上下文并调用上述方法,看看是否可行

[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    private readonly EFCoreDbContext _context;

    public WeatherForecastController(EFCoreDbContext context)
    {
        _context = context;
    }

    [HttpGet]
    public string Get()
    {
        return _context.Print();
    }
}

呀,没毛病啊,自我感觉甚是良好,莫慌,这位童鞋说这样操作没问题啊,但是我想将上下文注入为实例池的方式,结果却不行,会抛出异常,到底啥异常啊,如下我们修改成实例池的方式瞧瞧:

services.AddDbContextPool<EFCoreDbContext>(options =>
{
    options.UseSqlServer(@"Server=.;Database=EFCoreTest;Trusted_Connection=True;");
});

大意为因为该上下文没有只有单个参数是DbContextOptions的构造函数,所以该上下文不能被池化,说明构造函数只能有一个包含DbContextOptions的参数,否则报错,我们还是看看源码中到底是如何实例化实例池的呢?

public DbContextPool([NotNull] DbContextOptions options)
{
    _maxSize = options.FindExtension<CoreOptionsExtension>()?.MaxPoolSize ?? DefaultPoolSize;

    options.Freeze();

    _activator = CreateActivator(options);

    if (_activator == null)
    {

        //这里抛出上述异常信息
        throw new InvalidOperationException(
            CoreStrings.PoolingContextCtorError(typeof(TContext).ShortDisplayName()));
    }
}
private static Func<TContext> CreateActivator(DbContextOptions options)
{
    var constructors
        = typeof(TContext).GetTypeInfo().DeclaredConstructors
            .Where(c => !c.IsStatic && c.IsPublic)
            .ToArray();

    if (constructors.Length == 1)
    {
        var parameters = constructors[0].GetParameters();

        if (parameters.Length == 1
            && (parameters[0].ParameterType == typeof(DbContextOptions)
                || parameters[0].ParameterType == typeof(DbContextOptions<TContext>)))
        {
            return
                Expression.Lambda<Func<TContext>>(
                        Expression.New(constructors[0], Expression.Constant(options)))
                    .Compile();
        }
    }

    return null;
}

上述对于实例池是通过表达式来构建的实例池,但是在此之前会做一步验证构造函数参数只能有一个且为DbContextOptions,否则将抛出异常,为何要如此设计呢?我们再来看看在调用上下文实例池到底做了什么呢?如下我只列举出关键信息:

public static IServiceCollection AddDbContextPool<TContextService, TContextImplementation>(
            [NotNull] this IServiceCollection serviceCollection,
            [NotNull] Action<IServiceProvider, DbContextOptionsBuilder> optionsAction,
            int poolSize = 128)
            where TContextImplementation : DbContext, TContextService
            where TContextService : class
{

    AddCoreServices<TContextImplementation>(
        serviceCollection,
        (sp, ob) =>
        {
            ......
        },
        ServiceLifetime.Singleton);
        
    ......    
        
}

原来在调用实例池时,添加的所以内部服务都是单例,所以我们可以大胆得出结论:在注入上下文实例池时,添加的内部核心服务是单例,而我们注入的实例可能为其他类型,所以EntityFramework Core做了限定,构造函数只能包含DbContextOptions。那么我们在上下文中怎样才能使用我们注入的实例呢?其实EntityFramework Core考虑到有这样的需求,所以给出了对应解决方案,在上下文中存在GetService方法,是不是很熟悉,不过需要我们导入命名空间【Microsoft.EntityFrameworkCore.Infrastructure】,直接在对应方法中获取注入的实例,这样就绕过了上下文构造函数,如下:

public string Print()
{
    return this.GetService<IHello>().Say();
}

哎呀,本以为找到了良药,结果又报错了,这是为何呢?要是我们将注入的实例修改为单例结果将是好使的,我已经亲自验证过,这里就不再浪费篇幅,根本原因在哪里呢?此时我们再来看看上述GetService的实现是怎样的呢?

public static TService GetService<TService>([CanBeNull] IInfrastructure<IServiceProvider> accessor)
{
    object service = null;

    if (accessor != null)
    {
        var internalServiceProvider = accessor.Instance;

        service = internalServiceProvider.GetService(typeof(TService))
            ?? internalServiceProvider.GetService<IDbContextOptions>()
                ?.Extensions.OfType<CoreOptionsExtension>().FirstOrDefault()
                ?.ApplicationServiceProvider
                ?.GetService(typeof(TService));

        if (service == null)
        {
            throw new InvalidOperationException(
                CoreStrings.NoProviderConfiguredFailedToResolveService(typeof(TService).DisplayName()));
        }
    }

    return (TService)service;
}

是否有种恍然大悟的感觉,这里做了判断,因为在注入上下文实例池时,也注入了核心服务且为单例,但是我们在startup中注入的实例有可能不是单例,比如为scope时,此时会将我们注入的实例通过GetService获取时作为内部服务,所以会出现无法解析的情况并抛出异常,所以为了解决这个问题,我们必须明确告诉EF Core对于哪些ServiceProvider使用内部服务,除此之外,将通过上述ApplicationServiceProvider来获取而不包括内部服务,将内部服务和外部服务做一个明确的区分即可,在EntityFramework Core中对于内部服务的注册,已经通过扩展方法进行了封装,我们只需手动调用即可,最终解决方案如下:

//手动注册针对SQL Server的内部服务
services.AddEntityFrameworkSqlServer();

//内部服务使用对应ServiceProvider
services.AddDbContextPool<EFCoreDbContext>((serviceProvider, options) =>
{
    options.UseInternalServiceProvider(serviceProvider);
    options.UseSqlServer(@"Server=.;Database=EFCoreTest;Trusted_Connection=True;");
});

services.AddScoped<IHello, Hello>();

总结

本文是以3.x版本演示,对于2.x版本也同样适用,所以不要认为直接通过GetService没抛出异常而认为一切正常,瞎猫碰上死耗子,正是恰好碰到注入的实例为单例而绕过了异常的出现,所以上下构造函数可以注入实例吗,答案是不一定,若为实例池肯定不行,希望通过本文的详细描述能给需要在上下文构造函数中注入实例的童鞋一点力所能及的帮助,探究其问题的本质才能有所成长,感谢您的阅读。