4

I have added a Timer to Startup class of an ASP.Net Core application. It fires on some time period and do operations like logging a sample text. I need it to be able to do database driven operations like adding a record to a table. So I try to get AppDbContext from DI but it is always null. Please see the code:

    public class Scheduler
{
    static Timer _timer;
    static bool _isStarted;
    static ILogger<Scheduler> _logger;
    const int dueTimeMin = 1;
    const int periodMin = 1;

    public static void Start(IServiceProvider serviceProvider)
    {
        if (_isStarted)
            throw new Exception("Currently is started");

        _logger = (ILogger<Scheduler>)serviceProvider.GetService(typeof(ILogger<Scheduler>));

        var autoEvent = new AutoResetEvent(false);
        var operationClass = new OperationClass(serviceProvider);
        _timer = new Timer(operationClass.DoOperation, autoEvent, dueTimeMin * 60 * 1000, periodMin * 60 * 1000);
        _isStarted = true;
        _logger.LogInformation("Scheduler started");            
    }
}

public class OperationClass
{
    IServiceProvider _serviceProvider;
    ILogger<OperationClass> _logger;
    AppDbContext _appDbContext;

    public OperationClass(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
        _logger = (ILogger<OperationClass>)serviceProvider.GetService(typeof(ILogger<OperationClass>));
        _appDbContext = (AppDbContext)_serviceProvider.GetService(typeof(AppDbContext));
    }

    public void DoOperation(Object stateInfo)
    {
        try     
        {
            _logger.LogInformation("Timer elapsed.");

            if (_appDbContext == null)
                throw new Exception("appDbContext is null");

            _appDbContext.PlayNows.Add(new PlayNow
            {
                DateTime = DateTime.Now
            });

            _appDbContext.SaveChanges();
        }
        catch (Exception exception)
        {
            _logger.LogError($"Error in DoOperation: {exception.Message}");
        }
    }
}

And here it is the code from Startup:

        public Startup(IHostingEnvironment env, IServiceProvider serviceProvider)
    {
        var builder = new ConfigurationBuilder()
            .SetBasePath(env.ContentRootPath)
            .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
            .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
            .AddEnvironmentVariables();
        Configuration = builder.Build();

        AppHelper.InitializeMapper();
        Scheduler.Start(serviceProvider);
    }

I guess I am calling Scheduler.Start in a wrong place. Seems that AppDbContext is not ready yet.

What is the correct place to call Scheduler.Start?

Steven
  • 166,672
  • 24
  • 332
  • 435
Afshar Mohebi
  • 10,479
  • 17
  • 82
  • 126
  • 1
    Yes you are calling it too early in the startup flow. The service provider wont be properly configured till after `ConfigureServices` has been called. So I suggest you look there after you have added the db context. I would also suggest making the scheduler a service with explicit dependencies and have it in the service collection. – Nkosi Jul 16 '17 at 11:48

2 Answers2

6

When you are running code on a background thread, you should always begin a new 'scope' for your DI container on that background thread and resolve from that scope.

So what you should do is:

  • Create a new scope inside the event
  • Resolve OperationClass from that scope
  • Inside OperationClass only rely on constructor injection; not on Service Location.

Your code should look something like this:

public class Scheduler
{
    static Timer _timer;
    const int dueTimeMin = 1;
    const int periodMin = 1;

    public static void Start(IServiceScopeFactory scopeFactory)
    {
        if (scopeFactory == null) throw new ArgumentNullException("scopeFactory");
        _timer = new Timer(_ =>
        {
            using (var scope = new scopeFactory.CreateScope())
            {
                scope.GetRequiredService<OperationClass>().DoOperation();
            }
        }, new AutoResetEvent(false), dueTimeMin * 60 * 1000, periodMin * 60 * 1000);
    }
}

Here Start depends on IServiceScopeFactory. IServiceScopeFactory can be resolved from the IServiceProvider.

Your OperationClass will becomes something like the following:

public class OperationClass
{
    private readonly ILogger<OperationClass> _logger;
    private readonly AppDbContext _appDbContext;

    public OperationClass(ILogger<OperationClass> logger, AppDbContext appDbContext)
    {
        if (logger == null) throw new ArgumentNullException(nameof(logger));
        if (appDbContext == null) throw new ArgumentNullException(nameof(appDbContext));

        _logger = logger;
        _appDbContext = appDbContext;
    }

    public void DoOperation()
    {
        try     
        {
            _logger.LogInformation("DoOperation.");

            _appDbContext.PlayNows.Add(new PlayNow
            {
                DateTime = DateTime.Now
            });

            _appDbContext.SaveChanges();
        }
        catch (Exception exception)
        {
            _logger.LogError($"Error in DoOperation: {exception}");
        }
    }
}

Although not documentation particular to the .NET Core Container, this documentation provides a more detailed information about how to work with a DI Container in a multi-threaded application.

Steven
  • 166,672
  • 24
  • 332
  • 435
  • thank for your descriptive answer. Where should I call ` Scheduler.Start()`? – Afshar Mohebi Jul 16 '17 at 12:32
  • @Afshar: You should call `Start` somewhere in your [Composition Root](http://blog.ploeh.dk/2011/07/28/CompositionRoot/). Your `Startup` method seems an obious place. – Steven Jul 16 '17 at 12:50
3

You should call it inside ConfigureServices after your AppDbContext has been resolved, in the code you are calling it before DI registrations. also you can use services.BuildServiceProvider() to create an IServiceProvider containing services from the provided DI:

public Startup(IHostingEnvironment env)
{
    var builder = new ConfigurationBuilder()
        .SetBasePath(env.ContentRootPath)
        .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
        .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
        .AddEnvironmentVariables();
    Configuration = builder.Build();

    MmHelper.InitializeMapper();
}

public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<AppDbContext>();

    services.AddIdentity<User, IdentityRole>()
        .AddEntityFrameworkStores<AppDbContext>()
        .AddDefaultTokenProviders();

    // ....

    Scheduler.Start(services.BuildServiceProvider());
}
Sirwan Afifi
  • 10,654
  • 14
  • 63
  • 110