ASP .NET Core IOptionsMonitor Onchange
Karen Payne

Karen Payne @karenpayneoregon

About: Microsoft MVP, Microsoft TechNet author, Code magazine author, developer advocate. Have a passion for driving race cars.

Location:
Oregon, USA
Joined:
Jan 1, 2023

ASP .NET Core IOptionsMonitor Onchange

Publish Date: Apr 19
1 0

Introduction

Learn how to set up a listener for IOptionsMonitor, which allows an ASP.NET Core application to be run without restarting and provides pinpoint logic to assert that the monitored values are valid.

Using code provided coupled with ValidateOnStart as presented in this article there is less chance of runtime issues.

Some examples

  • Is a connection string valid?
  • Is a value in a specified range?
  • If a property has a valid value e.g. a guid or a date are valid

Requires

Understanding code presented

Some of the code presented may be new, depending on a developer's experience. The best way to move forward with the code presented is to study it, read Microsoft documentation, and set breakpoints to step through it to get a solid understanding of it, rather than copying and pasting code into a new or existing project.

  • Using GitHub Copilot to explain code is another resource for understanding unfamiliar code.
  • Using a browser developer tools console is also worth using to understand how JavaScript is used by setting breakpoints.

AI

  • GitHub Copilot and ChatGPT were used to assist in writing code.
  • JetBrains AI Assistant was used to create all documentation.

The tools were used not to figure out how to write something unknown but to save time. For example, writing documentation for a method without AI might take five or more minutes while AI does this in seconds. For code, the following would take perhaps ten minutes while writing a Copilot or ChatGPT prompt and response, five or fewer minutes.

// Configure Serilog
Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning)
    .MinimumLevel.Override("System", Serilog.Events.LogEventLevel.Warning)
    .MinimumLevel.Information()
    .WriteTo.Console()
    .CreateLogger();

builder.Host.UseSerilog();

// Load configuration with reload on change
builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);

// Register IOptionsMonitor with reloading enabled
builder.Services.Configure<AzureSettings>(builder.Configuration.GetSection("AzureSettings"));
builder.Services.Configure<AzureSettings>("TenantName", builder.Configuration.GetSection("TenantNameAzureSettings"));

// Register our services
builder.Services.AddSingleton<AzureService>();
builder.Services.AddScoped<SettingsService>();
Enter fullscreen mode Exit fullscreen mode

Using AI is a great way to save time besides helping with tasks that a developer does not know how to write code for.

Code overview

To simplify the learning process, all the code provided shows how to detect changes in the project’s appsettings.json file for specific class properties. After detection happens, use separate methods to determine if values are valid; for example, use TryParse to determine if a GUID is valid, or for a connection string, write code to validate that a connection string can be used to open a connection. If invalid values are detected, log the issue and determine if the application can continue running.

The first code sample is basic, while the second is a step up.

A NuGet package CompareNETObjects is used in one project to assist for detecting if multiple changes at one time.

Testing

Testing, running one of the projects, opening appsettings.json, changing a tracked item, and saving.

Diving in

For the first example, project AzueSettingsOptionsMonitorSample, we are monitoring for changes to TenantName and ConnectionString properties in the following model.

Backend code

public class AzureSettings
{
    public const string Settings = "AzureSettings";
    public bool UseAdal { get; set; }
    public string Tenant { get; set; }
    public string TenantName { get; set; }
    public string TenantId { get; set; }
    public string Audience { get; set; }
    public string ClientId { get; set; }
    public string GraphClientId { get; set; }
    public string GraphClientSecret { get; set; }
    public string SignUpSignInPolicyId { get; set; }
    public string AzureGraphVersion { get; set; }
    public string MicrosoftGraphVersion { get; set; }
    public string AadInstance { get; set; }
    public string ConnectionString { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

appsettings.json pointing to focused properties

The following code is in Program.cs configures the AzureSettings class with values from the application's configuration system.

builder.Services.Configure<AzureSettings>(builder.Configuration.GetSection(AzureSettings.Settings));
builder.Services.Configure<AzureSettings>(builder.Configuration.GetSection(nameof(AzureSettings)));
Enter fullscreen mode Exit fullscreen mode

Index page backend

In the constructor, dependency injection is used to access AzureSettings values in appsettings.json, followed by subscribing to OnChange for IOptionsMonitor.

An alternative to using a lambda statement for OnChange is to create a method while, as presented, it is easy to determine the code flow. In both cases, OnTenantNameChanged and OnConnectionStringChanged can still be used.

For a real application, both cases, OnTenantNameChanged and OnConnectionStringChanged, would include validation and code to determine if the application can continue or if any value will cause an uncontrolled runtime error.

The method OnGetTenantName is for demonstration to work with the JavaScript code in the front to display the changed tenant names in a span that checks for changes every five seconds.

public class IndexModel : PageModel
{
    private readonly IOptionsMonitor<AzureSettings> _azureSettings;

    private AzureSettings _azureSettingsIOptionsMonitor;

    [BindProperty]
    public required string TenantName { get; set; }


    public IndexModel(IOptionsMonitor<AzureSettings> azureSettings)
    {
        _azureSettings = azureSettings;
        _azureSettingsIOptionsMonitor = _azureSettings.CurrentValue;

        _azureSettings.OnChange(config =>
        {
            if (_azureSettingsIOptionsMonitor.TenantName != config.TenantName)
            {
                OnTenantNameChanged(config);
            }
            else if (_azureSettingsIOptionsMonitor.ConnectionString != config.ConnectionString)
            {
                OnConnectionStringChanged(config);
            }
        });
    }


    private void OnTenantNameChanged(AzureSettings azureSettings)
    {
        _azureSettingsIOptionsMonitor.TenantName = azureSettings.TenantName;
        TenantName = azureSettings.TenantName;
    }

      private void OnConnectionStringChanged(AzureSettings azureSettings)
    {
        _azureSettingsIOptionsMonitor.ConnectionString = azureSettings.ConnectionString;
    }
    public void OnGet()
    {
        TenantName = _azureSettingsIOptionsMonitor.TenantName;
    }

    [HttpGet]
    public IActionResult OnGetTenantName()
    {
        return new JsonResult(_azureSettings.CurrentValue.TenantName);
    }

}
Enter fullscreen mode Exit fullscreen mode

Frontend code

The code is here so that a developer can see the changes that were detected. In JavaScript, setInterval fetch relies on OnGetTenantName to get the new value of the tenant’s name.

For those who copy and paste code, if a page name is not index, change it to the correct page name. For index we could also use fetch('/?handler=TenantName').

@page
@model IndexModel
@{
    ViewData["Title"] = "Home page";
}

<div class="main-content">
    <div class="container">
        <h1 class="text-center">IOptionsMonitor &mdash; OnChange</h1>
    </div>

    <div class="container text-center mt-3">
        Tenant Name: <span id="tenantNameDisplay" class="fw-bold text-success">@Model.TenantName</span>
    </div>
</div>

<script>
    setInterval(() => {
        fetch('Index/?handler=TenantName')
            .then(res => res.json())
            .then(data => {
                document.getElementById('tenantNameDisplay').innerText = data;
            });
    }, 5000);
</script>
Enter fullscreen mode Exit fullscreen mode

Sample 2

This project (IOptionsMonitorAzureSettingsApp) used two distinct methods. Rather than explaining the code fully, the following lays out the logic for the two pages.

To demonstrate OnChange, both pages show any changes on the front end. Use JavaScript to check for changes in the app settings file.

Index page

Setup in Program.cs using model AzureSettings.

"AzureSettings": {
  "ConnectionString": "Data Source=Staging;Initial Catalog=Fleet;Integrated Security=True;Encrypt=False",
  "TenantId": "161e59c7-97ce-4e56-84bf-b9568bc3ff4r"
}
Enter fullscreen mode Exit fullscreen mode
builder.Services.Configure<AzureSettings>(builder.Configuration.GetSection("AzureSettings"));
builder.Services.Configure<AzureSettings>("TenantName", builder.Configuration.GetSection("TenantNameAzureSettings"));
Enter fullscreen mode Exit fullscreen mode

OnGet calls a method to load settings from appsettings.json using private static variables to persist values across requests.

The following JavaScript code polls the backend (C#) for changes in the front end, which is why static variables are used.

setInterval(() => {
    fetch('?handler=CheckForUpdate')
        .then(response => response.json())
        .then(data => {
            if (data.updated) {
                document.getElementById("changeNotification").innerText = data.message;
                setTimeout(() => location.reload(), 3000); // Refresh page 
            }
        });
}, 5000); // Check every 5 seconds
Enter fullscreen mode Exit fullscreen mode

In the code above ?handler=CheckForUpdate matches C# code OnGetCheckForUpdate. OnGetCheckForUpdate uses conventional if statements to determine if there are any changes since the last polling.

Note
The code presented can easily be used in other pages.

Index1 page

Setup in Program.cs using model AzureSettings1 reading Azure section in appsettings.json.

builder.Services.Configure<AzureSettings1>(builder.Configuration.GetSection("Azure"));
builder.Services.AddSingleton<SettingsMonitorService>();
builder.Services.AddHostedService<AzureWorker>();
Enter fullscreen mode Exit fullscreen mode

Note
AzureWorker inherits from BackgroundService.

This page has significantly less code on the back end. Changes are detected from JavaScript, which invokes a method in another page, Settings.cshtml.cs. The majority of the code resides in SettingsMonitorService and AzureWorker.

The important concept for this page is that a hash is used to determine if a value has changed in appsettings.json.

SettingsMonitorService.cs ComputeSnapshot

private string ComputeSnapshot(AzureSettings1 settings)
{
    var json = JsonSerializer.Serialize(settings, Options);
    using var sha = SHA256.Create();
    var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(json));
    return Convert.ToBase64String(hash);
}
Enter fullscreen mode Exit fullscreen mode

And for getting the hash in SettingsMonitorService

public string GetSnapshotHash() => _lastSnapshot;
Enter fullscreen mode Exit fullscreen mode

Which is exposed in Settings.cshtml.cs

public class SettingsModel(SettingsMonitorService monitor) : PageModel
{
    public IActionResult OnGet()
    {
        return new JsonResult(new
        {
            snapshotHash = monitor.GetSnapshotHash()
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Then in SettingsMonitorService.cs OnChange for AzureSettings1 model when triggered.

  • Get the current hash
  • Compare the current hash to the latter, if different we have a change
    • Invoke an Action with current values.
public event Action<AzureSettings1>? SettingsChanged;

public SettingsMonitorService(IOptionsMonitor<AzureSettings1> monitor)
{
    _current = monitor.CurrentValue;
    _lastSnapshot = ComputeSnapshot(_current);

    monitor.OnChange(updated =>
    {
        var newSnapshot = ComputeSnapshot(updated);

        // Only invoke if the snapshot really changed
        if (newSnapshot == _lastSnapshot) return;
        _current = updated;
        _lastSnapshot = newSnapshot;
        SettingsChanged?.Invoke(_current);
    });
}
Enter fullscreen mode Exit fullscreen mode

Back in the frontend, JavaScript polls for changes. When changes are detected, write a message for notifying that a value has change and update the value for connection string or tenant properties.

shows frontend code

Understanding the code presented

The best method to understand the code presented is:

  • Set breakpoints and step through the code. Microsoft Visual Studio has the best tools for debugging code.
  • If unfamiliar with services and BackgroundService, read Microsoft documentation.

Source code

Shows projects for above source code

Summary

Not every application needs change notifications, but some do when a change is made and it's not a good time to restart an application.

Comments 0 total

    Add comment