Raise async event in EF's DbContext.SaveChange

2019-07-12 13:47发布

I'm using EF Core, in an ASP.NET Core environment. My context is registered in my DI container as per-request.

I need to perform extra work before the context's SaveChanges() or SaveChangesAsync(), such as validation, auditing, dispatching notifications, etc. Some of that work is sync, and some is async.

So I want to raise a sync or async event to allow listeners do extra work, block until they are done (!), and then call the DbContext base class to actually save.

public class MyContext : DbContext
{

  // sync: ------------------------------

  // define sync event handler
  public event EventHandler<EventArgs> SavingChanges;

  // sync save
  public override int SaveChanges(bool acceptAllChangesOnSuccess)
  {
    // raise event for sync handlers to do work BEFORE the save
    var handler = SavingChanges;
    if (handler != null)
      handler(this, EventArgs.Empty);
    // all work done, now save
    return base.SaveChanges(acceptAllChangesOnSuccess);
  }

  // async: ------------------------------

  // define async event handler
  //public event /* ??? */ SavingChangesAsync;

  // async save
  public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default(CancellationToken))
  {
    // raise event for async handlers to do work BEFORE the save (block until they are done!)
    //await ???
    // all work done, now save
    return await base.SaveChangesAsync(acceptAllChangesOnSuccess,  cancellationToken);
  }

}

As you can see, it's easy for SaveChanges(), but how do I do it for SaveChangesAsync()?

3条回答
贪生不怕死
2楼-- · 2019-07-12 14:28

I'd suggest a modification of this async event handler

public AsyncEvent SavingChangesAsync;

usage

  // async save
  public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default(CancellationToken))
  {
    await SavingChangesAsync?.InvokeAsync(cancellationToken);
    return await base.SaveChangesAsync(acceptAllChangesOnSuccess,  cancellationToken);
  }

where

public class AsyncEvent
{
    private readonly List<Func<CancellationToken, Task>> invocationList;
    private readonly object locker;

    private AsyncEvent()
    {
        invocationList = new List<Func<CancellationToken, Task>>();
        locker = new object();
    }

    public static AsyncEvent operator +(
        AsyncEvent e, Func<CancellationToken, Task> callback)
    {
        if (callback == null) throw new NullReferenceException("callback is null");

        //Note: Thread safety issue- if two threads register to the same event (on the first time, i.e when it is null)
        //they could get a different instance, so whoever was first will be overridden.
        //A solution for that would be to switch to a public constructor and use it, but then we'll 'lose' the similar syntax to c# events             
        if (e == null) e = new AsyncEvent();

        lock (e.locker)
        {
            e.invocationList.Add(callback);
        }
        return e;
    }

    public static AsyncEvent operator -(
        AsyncEvent e, Func<CancellationToken, Task> callback)
    {
        if (callback == null) throw new NullReferenceException("callback is null");
        if (e == null) return null;

        lock (e.locker)
        {
            e.invocationList.Remove(callback);
        }
        return e;
    }

    public async Task InvokeAsync(CancellationToken cancellation)
    {
        List<Func<CancellationToken, Task>> tmpInvocationList;
        lock (locker)
        {
            tmpInvocationList = new List<Func<CancellationToken, Task>>(invocationList);
        }

        foreach (var callback in tmpInvocationList)
        {
            //Assuming we want a serial invocation, for a parallel invocation we can use Task.WhenAll instead
            await callback(cancellation);
        }
    }
}
查看更多
Summer. ? 凉城
3楼-- · 2019-07-12 14:29

So I want to raise a sync or async event to allow listeners do extra work, block until they are done (!), and then call the DbContext base class to actually save.

As you can see, it's easy for SaveChanges()

Not really... SaveChanges won't wait for any asynchronous handlers to complete. In general, blocking on async work isn't recommended; even in environments such as ASP.NET Core where you won't deadlock, it does impact your scalability. Since your MyContext allows asynchronous handlers, you'd probably want to override SaveChanges to just throw an exception. Or, you could choose to just block, and hope that users won't use asynchronous handlers with synchronous SaveChanges too much.

Regarding the implementation itself, there are a few approaches that I describe in my blog post on async events. My personal favorite is the deferral approach, which looks like this (using my Nito.AsyncEx.Oop library):

public class MyEventArgs: EventArgs, IDeferralSource
{
  internal DeferralManager DeferralManager { get; } = new DeferralManager();
  public IDisposable GetDeferral() => DeferralManager.DeferralSource.GetDeferral();
}

public class MyContext : DbContext
{
  public event EventHandler<MyEventArgs> SavingChanges;

  public override int SaveChanges(bool acceptAllChangesOnSuccess)
  {
    // You must decide to either throw or block here (see above).

    // Example code for blocking.
    var args = new MyEventArgs();
    SavingChanges?.Invoke(this, args);
    args.DeferralManager.WaitForDeferralsAsync().GetAwaiter().GetResult();

    return base.SaveChanges(acceptAllChangesOnSuccess);
  }

  public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default(CancellationToken))
  {
    var args = new MyEventArgs();
    SavingChanges?.Invoke(this, args);
    await args.DeferralManager.WaitForDeferralsAsync();

    return await base.SaveChangesAsync(acceptAllChangesOnSuccess,  cancellationToken);
  }
}

// Usage (synchronous handler):
myContext.SavingChanges += (sender, e) =>
{
  Thread.Sleep(1000); // Synchronous code
};

// Usage (asynchronous handler):
myContext.SavingChanges += async (sender, e) =>
{
  using (e.GetDeferral())
  {
    await Task.Delay(1000); // Asynchronous code
  }
};
查看更多
神经病院院长
4楼-- · 2019-07-12 14:36

There is a simpler way (based on this).

Declare a multicast delegate which returns a Task:

namespace MyProject
{
  public delegate Task AsyncEventHandler<TEventArgs>(object sender, TEventArgs e);
}

Update the context (I'm only showing async stuff, because sync stuff is unchanged):

public class MyContext : DbContext
{

  public event AsyncEventHandler<EventArgs> SavingChangesAsync;

  public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default(CancellationToken))
  {
    var delegates = SavingChangesAsync;
    if (delegates != null)
    {
      var tasks = delegates
        .GetInvocationList()
        .Select(d => ((AsyncEventHandler<EventArgs>)d)(this, EventArgs.Empty))
        .ToList();
      await Task.WhenAll(tasks);
    }
    return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
  }

}

The calling code looks like this:

context.SavingChanges += OnContextSavingChanges;
context.SavingChangesAsync += OnContextSavingChangesAsync;

public void OnContextSavingChanges(object sender, EventArgs e)
{
  someSyncMethod();
}

public async Task OnContextSavingChangesAsync(object sender, EventArgs e)
{
  await someAsyncMethod();
}

I'm not sure if this is a 100% safe way to do this. Async events are tricky. I tested with multiple subscribers, and it worked. My environment is ASP.NET Core, so I don't know if it works elsewhere.

I don't know how it compares with the other solution, or which is better, but this one is simpler and makes more sense to me.

EDIT: this works well if your handler doesn't change shared state. If it does, see the much more robust approach by @stephencleary above

查看更多
登录 后发表回答