查询拦截-处理诊断侦听器

时间:2019-01-04 12:19:50

标签: c# entity-framework-core

我们使用DiagnosticListeners来修改EF Core生成的SQL命令文本。问题是我们的侦听器需要基于通过HttpRequests进入Api的某些用户特定数据来修改SQL命令。我们当前的解决方案非常笨拙,将来可能会引起问题。每次创建DbContext时,我们都会注册一个新的侦听器:

public class MyContext : DbContext
{
    private readonly HReCommandAdapter _adapter;


    public MyContext(DbContextOptions options) : base(options)
    {
        _adapter = new DbCommandAdapter();

        var listener = this.GetService<DiagnosticSource>();
        (listener as DiagnosticListener).SubscribeWithAdapter(_adapter);
    }   

    public override void Dispose()
    {
        _adapter.Dispose();
        base.Dispose();
    }

    //DbSets and stuff
}

简化的侦听器代码如下:

public class DbCommandAdapter : IDisposable
{
    private bool _hasExecuted = false;
    private Guid? _lastExecId = null;

    [DiagnosticName("Microsoft.EntityFrameworkCore.Database.Command.CommandExecuting")]
    public void OnCommandExecuting(DbCommand command, DbCommandMethod executeMethod, Guid commandId, Guid connectionId, bool async, DateTimeOffset startTime)
    {
        if (!_lastExecId.HasValue)
            _lastExecId = connectionId;

        if (_lastExecId != connectionId)
            return;

        //We are modifying command text here
    }

    [DiagnosticName("Microsoft.EntityFrameworkCore.Database.Command.CommandExecuted")]
    public void OnCommandExecuted(object result, bool async)
    {
    }

    [DiagnosticName("Microsoft.EntityFrameworkCore.Database.Command.CommandError")]
    public void OnCommandError(Exception exception, bool async)
    {
    }

    public void Dispose() { //No code in here }
} 

如您所见,我们当前的方法是使用connectionId,每次创建DbContext时,它们都会有所不同。这种骇人听闻的方法的原因是,即使每次处理DbContext.Dispose()都调用HttpRequest时,也不会处理侦听器实例。因此,connectionId基本上可以使侦听器与给定的DbContext实例之间具有1:1映射的错觉。

但是,发生的事情是,在api的整个生命周期中,侦听器实例的数量不断堆积,并且实例消失的唯一时间是应用程序池停止或回收。

是否有可能以某种方式处置这些侦听器实例?我也愿意采用其他方法来修改SQL命令(诊断侦听器是我们在EF Core中发现的唯一可行的侦听器)。

编辑: 我仅修改SELECT命令。我省略了详细信息,但是DbCommandAdapter的创建是根据用户特定的前缀创建的,该前缀根据尝试访问API的用户而有所不同。

例如,如果查询为:

SELECT FIELD1, FIELD2 FROM EMPLOYEES

,并且用户特定的前缀为USER_SOMENUMBER,则修改后的查询结束:

SELECT FIELD1, FIELD2 FROM USER_SOMENUMBER_EMPLOYEES

我知道这很脆弱,但是我们保证更改的表名的架构是相同的,因此不必担心。

2 个答案:

答案 0 :(得分:1)

如果您无法处置侦听器,为什么不合并它们并重用它们呢?当配置或构建非常昂贵时,合并是一种很好的软件模式。防止无限增长也是一种合理的用法。

以下仅为伪代码。它需要知道适配器事务何时完成,以便可以将其标记为可用并可以重用。它还假定更新的myDbContext将具有执行工作所需的功能。

public static class DbCommandAdapterPool
{
    private static ConcurrentBag<DbCommandAdapter> _pool = new ConcurrentBag<DbCommandAdapter>();

    public static DbCommandAdapter SubscribeAdapter(MyContext context)
    {
        var adapter = _pool.FirstOrDefault(a => a.IsAvailable);
        if (adapter == null)
        {
            adapter = new DbCommandAdapter(context);
            _pool.Add(adapter);
        }
        else adapter.Reuse(context);

        return adapter;
    }
}

public class MyContext : DbContext
{
    private readonly HReCommandAdapter _adapter;


    public MyContext(DbContextOptions options) : base(options)
    {
        //_adapter = new DbCommandAdapter();

        //var listener = this.GetService<DiagnosticSource>();
        //(listener as DiagnosticListener).SubscribeWithAdapter(_adapter);

        DbCommandAdapterPool.SubscribeAdapter(this);
    }

    public override void Dispose()
    {
        _adapter.Dispose();
        base.Dispose();
    }

    //DbSets and stuff
}

public class DbCommandAdapter : IDisposable
{
    private bool _hasExecuted = false;
    private Guid? _lastExecId = null;
    private MyContext _context;
    private DiagnosticListener _listener;//added for correlation

    public bool IsAvailable { get; } = false;//Not sure what constitutes a complete transaction.

    public DbCommandAdapter(MyContext context)
    {
        this._context = context;
        this._listener = context.GetService<DiagnosticSource>();
    }


    ...

    public void Reuse(MyContext context)
    {
        this.IsAvailable = false;
        this._context = context;
    }
}

注意::我自己还没有尝试过。 Ivan Stoev建议将对ICurrentDbContext的依赖项注入到CustomSqlServerQuerySqlGeneratorFactory中,然后在CustomSqlServerQuerySqlGenerator中可用。参见:Ef-Core - What regex can I use to replace table names with nolock ones in Db Interceptor

答案 1 :(得分:0)

如何在启动中一次创建订阅并将其保留到应用程序末尾呢?

问题是,订阅基于由ServiceProvider生成的DiagnosticSource对象(在我的情况下为EntityFramework)。

因此,每次在代码中创建MyContext时,都会向DiagnosticSource添加另一个适配器和另一个订阅。 适配器与订阅一起存在,并且订阅不被处置(将与DiagnosticSource一起处置,或者至少在处置Source时变得无用)。

因此,侦听器实例在api的整个生命周期中都不断堆积。

我建议在构建主机之后并运行它之前初始化一次订阅。 您需要获取稍后在应用程序中使用的DiagnosticSource,这就是为什么需要主机的原因。否则,订阅将在另一个DiagnosticSource对象上,并且永远不会被调用。

var host = new WebHostBuilder()
     .UseConfiguration(config)
      // ...
     .Build();

using (var subscription = InitializeSqlSubscription(host))
{
  host.Run();
}
private static IDisposable InitializeSqlSubscription(IWebHost host)
{
   // TODO: remove Workaround with .Net Core 3.1
   // we need a scope for the ServiceProvider
   using (var serviceScope = host.Services.CreateScope())
   {
      var services = serviceScope.ServiceProvider;

      try
      {
         var adapter = new DbCommandAdapter();

         // context needed to get DiagnosticSource (EntityFramework)
         var myContext = services.GetRequiredService<MyContext>();

         // DiagnosticSource is Singleton in ServiceProvider (i guess), and spanning across scopes
         // -> Disposal of scope has no effect on DiagnosticSource or its subscriptions
         var diagnosticSource = myContext.GetService<DiagnosticSource>();

         // adapter Object is referenced and kept alive by the subscription
         // DiagnosticListener is Disposable, but was created (and should be disposed) by ServiceProvider
         // subscription itself is returned and can be disposed at the end of the application lifetime
         return (diagnosticSource as DiagnosticListener).SubscribeWithAdapter(adapter);
      }
      catch (Exception ex)
      {
         var logger = services.GetRequiredService<ILogger<Startup>>();
         logger.LogError(ex, "An error occurred while initializing subscription");
         throw;
      }
   }
}