简体   繁体   中英

Log configuration changes in ASP.NET Core

I want to log when configuration is changed.

I do this in Program.cs or Startup.cs :

ChangeToken.OnChange(
  () => configuration.GetReloadToken(),
  state => logger.Information("Configuration reloaded"),
  (object)null
);

But I get double change reports, so it needs to be debounced. The advice is to do this :

ChangeToken.OnChange(
  () => configuration.GetReloadToken(),
  state => { Thread.Sleep(2000); logger.Information("Configuration reloaded"); },
  (object)null
);

I'm using 2000 here as I'm not sure what's a reasonable value.

I've found that sometimes I still get multiple change detections, separated by 2000 milliseconds. So the debounce doesn't work for me, just causes a delay between reported changes. If I set a high value then I only get one report, but that isn't ideal (and conceals the problem).

So I'd like to know:

  • Is this really debouncing, or just queueing reported changes?
  • I've used values from 1000 to 5000 to varying success. What are others using?
  • Is the sleep issued to the server's main thread? I hope not!

The multiple change detection issue discussed here (and at least a dozen other issues in multiple repos) is something they refuse to address using a built-in mechanism.

The MS docs use a file hashing approach, but I think that debouncing is better.

My solution uses async (avoids async-in-sync which could blow up something accidentally) and a hosted service that debounces change detections.

Debouncer.cs :

public sealed class Debouncer : IDisposable {

  public Debouncer(TimeSpan? delay) => _delay = delay ?? TimeSpan.FromSeconds(2);

  private readonly TimeSpan _delay;
  private CancellationTokenSource? previousCancellationToken = null;

  public async Task Debounce(Action action) {
    _ = action ?? throw new ArgumentNullException(nameof(action));
    Cancel();
    previousCancellationToken = new CancellationTokenSource();
    try {
      await Task.Delay(_delay, previousCancellationToken.Token);
      await Task.Run(action, previousCancellationToken.Token);
    }
    catch (TaskCanceledException) { }    // can swallow exception as nothing more to do if task cancelled
  }

  public void Cancel() {
    if (previousCancellationToken != null) {
      previousCancellationToken.Cancel();
      previousCancellationToken.Dispose();
    }
  }

  public void Dispose() => Cancel();

}

ConfigWatcher.cs :

public sealed class ConfigWatcher : IHostedService, IDisposable {

  public ConfigWatcher(IServiceScopeFactory scopeFactory, ILogger<ConfigWatcher> logger) {
    _scopeFactory = scopeFactory;
    _logger = logger;
  }

  private readonly IServiceScopeFactory _scopeFactory;
  private readonly ILogger<ConfigWatcher> _logger;

  private readonly Debouncer _debouncer = new(TimeSpan.FromSeconds(2));

  private void OnConfigurationReloaded() {
    _logger.LogInformation("Configuration reloaded");
    // ... can do more stuff here, e.g. validate config
  }

  public Task StartAsync(CancellationToken cancellationToken) {
    ChangeToken.OnChange(
      () => {                                                 // resolve config from scope rather than ctor injection, in case it changes (this hosted service is a singleton)
        using var scope = _scopeFactory.CreateScope();
        var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
        return configuration.GetReloadToken();
      },
      async () => await _debouncer.Debounce(OnConfigurationReloaded)
    );
    return Task.CompletedTask;
  }

  public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;

  public void Dispose() => _debouncer.Dispose();

}

Startup.cs :

services.AddHostedService<ConfigWatcher>();        // registered as singleton

Hopefully, someone else can answer your questions, but I did run into this issue and found this Gist by cocowalla . The code provided by cocowalla debounces instead of just waiting. It successfully deduplicated the change callback for me. Cocowalla also includes an extension method so you can simply call OnChange on the IConfiguration .

Here's a sample:

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Primitives;
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    public static async Task Main(string[] args)
    {
        var configuration = new ConfigurationBuilder()
            .SetBasePath(Directory.GetCurrentDirectory())
            .AddJsonFile(path: "appsettings.json", optional: false, reloadOnChange: true)
            .Build();

        configuration.OnChange(() => Console.WriteLine("configuration changed"));

        while (true)
        {
            await Task.Delay(1000);
        }
    }
}

public class Debouncer : IDisposable
{
    private readonly CancellationTokenSource cts = new CancellationTokenSource();
    private readonly TimeSpan waitTime;
    private int counter;

    public Debouncer(TimeSpan? waitTime = null)
    {
        this.waitTime = waitTime ?? TimeSpan.FromSeconds(3);
    }

    public void Debouce(Action action)
    {
        var current = Interlocked.Increment(ref this.counter);

        Task.Delay(this.waitTime).ContinueWith(task =>
        {
                // Is this the last task that was queued?
                if (current == this.counter && !this.cts.IsCancellationRequested)
                action();

            task.Dispose();
        }, this.cts.Token);
    }

    public void Dispose()
    {
        this.cts.Cancel();
    }
}

public static class IConfigurationExtensions
{
    /// <summary>
    /// Perform an action when configuration changes. Note this requires config sources to be added with
    /// `reloadOnChange` enabled
    /// </summary>
    /// <param name="config">Configuration to watch for changes</param>
    /// <param name="action">Action to perform when <paramref name="config"/> is changed</param>
    public static void OnChange(this IConfiguration config, Action action)
    {
        // IConfiguration's change detection is based on FileSystemWatcher, which will fire multiple change
        // events for each change - Microsoft's code is buggy in that it doesn't bother to debounce/dedupe
        // https://github.com/aspnet/AspNetCore/issues/2542
        var debouncer = new Debouncer(TimeSpan.FromSeconds(3));

        ChangeToken.OnChange<object>(config.GetReloadToken, _ => debouncer.Debouce(action), null);
    }
}

In the sample, the debounce delay is 3 seconds, for my small json file, the debounce delay stops deduplicating around 230 milliseconds.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM