1. 程式人生 > >EntityFramework Core 3.x上下文建構函式可以注入例項呢?

EntityFramework Core 3.x上下文建構函式可以注入例項呢?

前言

今天討論的話題來自一位微信好友遇到問題後請求我的幫助,當然他的意圖並不是本文標題,只是我將其根本原因進行了一個概括,接下來我們一起來探索標題的問號最終的答案是怎樣的呢?

上下文建構函式是否可以注入例項?

老規矩,首先我們定義如下上下文

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沒丟擲異常而認為一切正常,瞎貓碰上死耗子,正是恰好碰到注入的例項為單例而繞過了異常的出現,所以上下建構函式可以注入例項嗎,答案是不一定,若為例項池肯定不行,希望通過本文的詳細描述能給需要在上下文建構函式中注入例項的童鞋一點力所能及的幫助,探究其問題的本質才能有所成長,感謝您的閱讀。