Reputation: 9265
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()
?
Upvotes: 2
Views: 3755
Reputation: 11
For my case worked a little tweak to the @grokky answer. I had to not run the event handlers in parallel (as pointed out by @Stephen Cleary) so i ran it in the for each loop fashion instead of going for the Task.WhenAll
.
public delegate Task AsyncEventHandler(object sender, EventArgs e);
public abstract class DbContextBase:DbContext
{
public event AsyncEventHandler SavingChangesAsync;
public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
await OnSavingChangesAsync(acceptAllChangesOnSuccess);
return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
private async Task OnSavingChangesAsync(bool acceptAllChangesOnSuccess)
{
if (SavingChangesAsync != null)
{
var asyncEventHandlers = SavingChangesAsync.GetInvocationList().Cast<AsyncEventHandler>();
foreach (AsyncEventHandler asyncEventHandler in asyncEventHandlers)
{
await asyncEventHandler.Invoke(this, new SavingChangesEventArgs(acceptAllChangesOnSuccess));
}
}
}
}
Upvotes: 0
Reputation: 9265
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
Upvotes: 1
Reputation: 456507
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
}
};
Upvotes: 2
Reputation:
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);
}
}
}
Upvotes: 0