Achtergrond taken met queue vanaf ASP.NET Core 2.1

Vanaf versie 2.1 kan je achtergrond taken uitvoeren op drie verschillende manieren:

  • Een timer gebaseerde achtergrond taak, die op bepaalde tijden werk uitvoert
  • Een scoped service achtergrond taak, het verschil is dat je hier dependency injection kan gebruiken
  • Een queued (rij) achtergrond taak, die ik hier verder bespreek.

Waarom een taak op een achtergrond rij zetten

Vaak heb je bepaalde taken die door de gebruiker geïnitieerd worden, maar die lang duren, en je liever wil loskoppelen van het gebruikers interface. Of anders gezegd, waar je de gebruiker niet op wil laten wachten.

Je moet dan van te voren bedenken wat je doet als er iets fout gaat, de gebruiker wacht niet op het resultaat, dus die kan je ook niet makkelijk informeren. Alternatieven zijn bijvoorbeeld:

  • Je informeert de support organisatie zodat die het probleem kan herstellen
  • Je informeert de gebruiker achteraf, bijvoorbeeld door iets in de database te zetten, dat ergens op een pagina wordt gezet,
  • Je informeert de gebruiker in real-time met SignalR (communicatie rechtstreeks naar de browser)

Hoe dan ook, als je iets in de achtergrond uitvoert (bijvoorbeeld het versturen van een of meer mails), dan kan je niet meteen in de response van de pagina een fout boodschap opnemen.

Maar als je je dat goed realiseert, is het toch handig om je gebruikersinterface te versnellen door de lang lopende zaken er uit te halen.

Je kan dan op ieder moment dat je wil een async task op de queue zetten die dan op de achtergrond wordt uitgevoerd. Daarover later meer.

Je begint met het opnemen van de volgende twee klassen in je project, deze code is van Microsoft:

BackgroundTaskQueue.cs

 //this implements the queue, using QueueBackgroundWorkItem(task) a workitem
    //is placed on the queue,and then processed
    public interface IBackgroundTaskQueue
    {
        void QueueBackgroundWorkItem(Func<CancellationToken, Task> workItem);

        Task<Func<CancellationToken, Task>> DequeueAsync(
            CancellationToken cancellationToken);
    }

    public class BackgroundTaskQueue : IBackgroundTaskQueue
    {
        private ConcurrentQueue<Func<CancellationToken, Task>> _workItems = 
            new ConcurrentQueue<Func<CancellationToken, Task>>();
        private SemaphoreSlim _signal = new SemaphoreSlim(0);

        public void QueueBackgroundWorkItem(
            Func<CancellationToken, Task> workItem)
        {
            if (workItem == null)
            {
                throw new ArgumentNullException(nameof(workItem));
            }

            _workItems.Enqueue(workItem);
            _signal.Release();
        }

        public async Task<Func<CancellationToken, Task>> DequeueAsync(
            CancellationToken cancellationToken)
        {
            await _signal.WaitAsync(cancellationToken);
            _workItems.TryDequeue(out var workItem);

            return workItem;
        }
    }

QueuedHostService.cs

public class QueuedHostedService : BackgroundService
{
    private readonly ILogger _logger;

    public QueuedHostedService(IBackgroundTaskQueue taskQueue, 
        ILoggerFactory loggerFactory)
    {
        TaskQueue = taskQueue;
        _logger = loggerFactory.CreateLogger<QueuedHostedService>();
    }

    public IBackgroundTaskQueue TaskQueue { get; }

    protected async override Task ExecuteAsync(
        CancellationToken cancellationToken)
    {
        _logger.LogInformation("Queued Hosted Service is starting.");

        while (!cancellationToken.IsCancellationRequested)
        {
            var workItem = await TaskQueue.DequeueAsync(cancellationToken);

            try
            {
                await workItem(cancellationToken);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, 
                   $"Error occurred executing {nameof(workItem)}.");
            }
        }

        _logger.LogInformation("Queued Hosted Service is stopping.");
    }
}

Vervolgens registreer je beide in ConfigureServices:

//add the hosted background service and queue
services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
services.AddHostedService<QueuedHostedService>();

En nu kan je aan de slag met je background taken!

In je controller zorg je dat de dependency injection de queue klaarzet: voeg IBackgroundTaskQueue queue toe als een van de parameters in de controller constructor, en bewaar de queue in een private variabele:

private readonly IBackgroundTaskQueue _queue;

Nu kan je langlopende taken in de queue zetten:

_queue.QueueBackgroundWorkItem(async token =>
{
    await EmailHandler.SendFinishEmail(tblSession, testId, clientName, examiner);
});

Als je op deze manier iets op de queue zet, gaat de code meteen verder. De gebruiker hoeft dus niet te wachten tot het is afgehandeld.

Het workitem dat je op de queue hebt gezet wordt meteen uitgevoerd door een andere process thread. Als er meerdere workitems tegelijk (door verschillende gebruikers, of snel achterelkaar) op de queue gezet worden, dan handelt de background queue ze een voor een af, in volgorde waarop je ze op de queue hebt gezet. Kortom, ze worden één voor één uitgevoerd in plaats van allemaal tegelijk.

Cannot access a disposed object

Als je deze fout krijgt, heb je een parameter gebruikt die ondertussen is disposed. Bij mij was dat _AppSettings. Die wordt geïnjecteerd in mijn controller, maar weer opgeruimd als de controller klaar is. Als je achtergrond taak dan nog loopt krijg je netjes deze fout.

Ik heb daarom de configuratie parameters in een static variabele gezet, dan kan ik er altijd bij. Het goede nieuws is dat je rustig parameters kan meegeven aan je background task, maar het slechte nieuws is dat je wel moet opletten of die waarden niet disposed zijn. Dus geen geïnjecteerde instances doorgeven, een ook niet HTTPContext, tenzij je de controller laat wachten op het achtergrond resultaat, zodat je zeker bent dat de geïnjecteerde services nog bestaan.

Het is ook belangrijk te realiseren dat je geen headers aan de response kan toevoegen nadat de HttpResponse is gestart.

Commentaar? Ik hoor het graag.

Upgrade naar ASP.NET Core 2.2 niet zo eenvoudig als het lijkt

De upgrade van ASP.NET Core 2.1 naar 2.2 lijkt heel eenvoudig volgens de documentatie hier: https://docs.microsoft.com/en-us/aspnet/core/migration/21-to-22?view=aspnetcore-2.2&tabs=visual-studio

Dat ziet er uit als een uurtje werk, maar het bleek tegen te vallen. Naast de stappen in het genoemde document zijn er nog twee dingen waar het bij mij op fout ging.

Een van de nieuwe features van ASP.NET Core 2.2 is In-process hosting. IIS functioneert dan niet langer als proxy naar Kestrel, maar het ASP.NET Core process wordt binnen IIS uitgevoerd. Dat zou in theorie een veel grotere performance opleveren, maar dat zie je waarschijnlijk alleen bij een hoge belasting.

Maar goed, ik was daarmee aan de slag gegaan, om bij het starten van de site een 500 error te krijgen.

ANCM In-Process Handler Load Failure

HTTP Error 500.0 ANCM In-Process Handler Load Failure
HTTP Error 500.0 ANCM In-Process Handler Load Failure

Microsoft adviseert om een ASP.NET Core 2.2 site als 32 bits te deployen, maar als je hem als 64 bits of Any CPU deployed krijg je deze fout. Na uren zoeken vond ik dat je dan in de application pool moet uitzetten dat hij 32 bits applicaties ondersteunt.

Application Pool

Die True moet dus op False staan als je de app als 64 bits deployed.

 

ASP.NET Core 2.2 kan de configuratie bestanden niet vinden.

Gauw verder, helaas wil de site nu nog steeds niet starten. Na inspectie van de logs blijkt dat hij de configuratie files niet kan vinden.

Het blijkt een fout in Versie 2.2. De current directory stond standaard op de directory waar de project dll staat, maar nu is hij ergens in Inetpub van IIS.

De oplossing is deze class in je project in te voeren:

https://github.com/aspnet/Docs/blob/master/aspnetcore/host-and-deploy/aspnet-core-module/samples_snapshot/2.x/CurrentDirectoryHelpers.cs

Vervolgens zet je in Program.cs bij Main de currentdirectory door deze class aan te roepen:

public static void Main(string[] args)
{

CurrentDirectoryHelper.SetCurrentDirectory();

CreateWebHostBuilder(args).Build().Run();

}

Daarmee kan de currentdirectory weer normaal gevonden worden.

Hopelijk heb je hier wat aan, laat dan een kort commentaar achter!

Pieter

User Data toevoegen aan ASP.NET Core Identity

De ASP.NET Core Identity user heeft een aantal standaard velden, zoals email, naam, en telefoonnummer. Maar wat als je daar meta data aan wil toevoegen? Bijvoorbeeld IsManager, of AfdelingsNummer.

Je kan dat op twee manieren doen. 1) met claims, en 2) met database velden (met dynamische claims)

  • Met Claims kan je willekeurige data toevoegen aan een gebruiker, deze kan je vooral gebruiken bij autorisatie, maar met enige volhardendheid ook in de rest van je code
  • Informatie in extra database velden kan je zowel als claims gebruiken bij autorisatie, maar ook makkelijk de inhoud zetten en afvragen in je code.

1) Data in Claims

Iedere claim heeft een claimType, een value en een userid (de Id van de user in AspNetUsers) en tenslotte een unieke Id van de regel. De conventie is om de claimType uniek te maken, bijvoorbeeld met je domein naam:

public const string AfdelingsNummer = "http://mydomain.nl/afdelingsnummer";

Als je dan claims van verschillende bronnen hebt, dan zijn ze in ieder geval uniek. Voor de rest maakt het niets uit wat de claimType van de claim is.

Je kan een claim toevoegen:

await userManager.AddClaimAsync(user, new Claim(claimType, value));

en je kan een claim verwijderen:

await userManager.RemoveClaimAsync(user, new Claim(claimType, value));

Als je een claim voor de tweede keer toevoegt, krijg je twee claims. Je kan een bestaande claim wijzigen met ReplaceClaimAsync.

Toevoegen als de claim nog niet bestaat of wijzigen als hij wel bestaat gaat niet in een operatie, je moet eerst zoeken of de claim er is, en dan toevoegen of wijzigen.

Claims worden in de tabel AspNetUserClaims bewaard. De UserId is de FK naar de AspNetUsers tabel. Je kan een user niet verwijderen als er nog claims open staan.

Met GetUsersForClaimAsync  kan je een lijst van users krijgen met een bepaalde claim (claimType en value). Je kan niet zoeken op iedereen die een claimType heeft ongeacht de value.

De volgende extensie method verwijdert een gebruiker effectief:

 public static class UserManagerExt
    { //extend userManager
        public static async Task DeleteUserAsync(this UserManager<MyUser> userManager, MyUser user)
        {
            //Gets list of Roles associated with current user
            var rolesForUser = await userManager.GetRolesAsync(user);
            if (rolesForUser.Any())
                await userManager.RemoveFromRolesAsync(user, rolesForUser);
            var claimsForUser = await userManager.GetClaimsAsync(user);
            if (claimsForUser.Any())
                await userManager.RemoveClaimsAsync(user, claimsForUser);
            //Delete User
            await userManager.DeleteAsync(user);
        }
    }
//Je kan deze method zo gebruiken:
await userManager.DeleteUserAsync(user);

Claims in Cookies

Alle claims worden bij het inloggen in een encrypted cookie opgeslagen.

var myClaimvalue= User.FindFirstValue(claimType); //geeft de value (string) van de eerste claim van het type claimType, en null als er geen gevonden wordt.

Let op de hoofdletter bij User, dit is de ClaimsPrincipal en die is standaard aanwezig voor ingelogde gebruikers. Je hoeft dus niet de claim uit de database te vissen, hij is direct beschikbaar. Alle claims staan in de ClaimsPrincipal User, ook de rollen zijn als claim hierin opgeslagen.

Er zijn wat speciale functies om met de claims te werken:

bool User.IsInRole(rolename)  //gebruikt de claim cookie voor de rol

bool User.HasClaim(claimType) //true als de claim aanwezig is

Claim User.FindFirst(claimType) //geeft de hele claim terug in plaats van alleen de waarde.

IEnumerable Claim User.FindAll(c=>c.Type=claimType) 
// Zoals hier geschreven is het hetzelfde als FindFirst, alleen wordt hier een IEnumerable 
// met alle claims gegeven. Je kan op alle claim eigenschappen selecteren in de predicate.

De Id van de user staat als claim in de ClaimsPrincipal:

//de Id claim wordt gebruikt om de Id (guid) op te halen van de ingelogde gebruiker:
var userId = _userManager.GetUserId(User)
//het kan ook zo:
var userid =User.FindFirstValue(ClaimTypes.NameIdentifier);

Policies

Vervolgens kan je de claim gebruiken in de toegangsrechten:

public void ConfigureServices(IServiceCollection services)
{
    services.AddAuthorization(options =>
    {
         //only managers allowed
         options.AddPolicy("ManagersOnly", policy =>
         {
//MyClaimTypes is een public class met public const string properties, 
//zodat ik zeker weet dat ik overal dezelfde spelling gebruik
               policy.RequireClaim(MyClaimTypes.IsManager, "true"); 
//MyRoles, net als MyClaimTypes een public class
               policy.RequireRole(MyRoles.Examiner);  
//RequireAuthenticatedUser moet er vreemd genoeg bij, 
//omdat de default policy niet meer geldt als je een custom policy gebruik
               policy.RequireAuthenticatedUser(); 
          });
    
     });
}

Vervolgens gebruik je de policy op Controller niveau of op Action niveau:

 [Authorize(Policy = "ManagersOnly")]

Door het standaard gebruik van cookies voor de claims, betekent dit dat iedere claim heen en weer naar de browser wordt gestuurd, bij ieder request. Dat kan oplopen, maar zal voor de meeste toepassingen geen probleem zijn. (Bij HTTPS/2 wordt het ook nog gecompressed). Het voordeel is wel dat je de claim voor authenticatie kan gebruiken en dat je de waarde zonder database call kan ophalen (na inloggen).

Als je zelf ook queries wil doen op de AspNetUsers tabel is de claims tabel wel erg lastig. Je kan dan beter data in de AspNetUsers tabel hebben zoals hier onder beschreven. Maar voor het bewaren van authorisatie gegevens of informatie die je alleen wil kunnen terughalen werkt het prima.

2) Data in de AspNetUsers tabel

Je kan ook de AspNetUsers tabel uitbreiden. Ik ga hier niet in op EF Migrations, omdat ik die nooit gebruik. Maar als je het wel gebruikt gaat de uitbreiding van de database min of meer automatisch.

Je begint met een class waarmee je de IdentityUser uitbreidt:

public class MyUser : IdentityUser
{ //expands on Identity user
    public bool IsManager { getset; }
    public int DepartmentId {getset; }
}

Vervolgens gebruik je deze class overall waar je een IdentityUser specificeert, om te beginnen in ConfigureServices:

services.AddIdentity<MyUser , IdentityRole>();

De AspNetUser tabel moet nu in dit voorbeeld een kolom IsManager en een kolom DepartmentId met een geschikt type krijgen. Als je dit handmatig doet (zoals ik), zorg er dan voor dat er geen null waardes in de kolom komen. Specificeer een default waarde 0, en vul de kolom voor de bestaande gebruikers, anders krijg je een null reference error.

Vervolgens kan je deze waardes gebruiken zoals je de overige velden van de user gebruikt, alleen moet je overal waar je eerst UserManager<IndentityUser> had staan het veranderen naar de gebruikte class naam, in dit voorbeeld MyUser:

UserManager<MyUser>.

Nu kan je makkelijk je waardes aanpassen vanuit de code, bijvoorbeeld om de bool IsManager op false te zetten:

var user = await _userManager.GetUserAsync(User);
if (demoted) {
    user.IsManager = false;
     await _userManager.UpdateAsync(user);
   }

In het bovenstaande voorbeeld hebben beide asynchrone methods onderliggende database operaties.

Het zou natuurlijk mooi zijn als je een of meer van je custom velden als claims kan gebruiken, net als eerder voor snelle toegang vanuit een cookie of in een authorization policy. Dat kan ook!

Gebruiken van AspNetUsers velden in claims en autorisatie.

Bij het inloggen haalt de SignInManager de claims en rollen op en zet die in de ClaimsPrincipal, die als cookie wordt bewaard. Ook de LastName en de Id van de user wordt in de ClaimsPrincipal als claims gezet. Intern wordt

IUserClaimsPrincipalFactory<TUser>

gebruikt om de ClaimsPrincipal te maken. Door een eigen implementatie te erven van deze factory kan je zelf claims toevoegen tijdens de login. Je kan hier standaard velden van User toevoegen (bijvoorbeeld ClaimTypes.Email ), maar ook je eigen velden:

public class AppClaimsPrincipalFactory : UserClaimsPrincipalFactory<MyUser, IdentityRole>
{
    public AppClaimsPrincipalFactory(
        UserManager<ApplicationUser> userManager
        , RoleManager<IdentityRole> roleManager
        , IOptions<IdentityOptions> optionsAccessor)
    : base(userManager, roleManager, optionsAccessor)
    { }
    public async override Task<ClaimsPrincipal> CreateAsync(ApplicationUser user)
    {
        var principal = await base.CreateAsync(user);
        if (!string.IsNullOrWhiteSpace(user.FirstName))
        {
            ((ClaimsIdentity)principal.Identity).AddClaims(new[] {
        new Claim(ClaimTypes.Email, user.Email)
    });
        }
         ((ClaimsIdentity)principal.Identity).AddClaims(new[] {
         new Claim(MyClaimTypes.IsManager, user.IsManager),
        new Claim(MyClaimTypes.DepartmentId , user.DepartmentId )
        }
        return principal;
    }
}

Vervolgens moet je deze implementatie registreren in de ConfigureServices in startup.cs, na de Identity Service:

public void ConfigureServices(IServiceCollection services)
{
    // [...]
    services.AddDbContext<SecurityDbContext>(options => options.UseSqlServer(Configuration.GetConnectionString("Security")));
    services.AddIdentity<MyUser, IdentityRole>()
        .AddEntityFrameworkStores<SecurityDbContext>()
        .AddDefaultTokenProviders();
        
    services.AddScoped<Microsoft.AspNetCore.Identity.IUserClaimsPrincipalFactory<MyUser>, AppClaimsPrincipalFactory>();
    // [...]
}

Nu worden alle claims die je in jouw implementatie hebt toegevoegd beschikbaar in de ClaimPrincipal in ieder Controller, zonder verdere database operatie:

var eMail  = User.FindFirstValue(ClaimTypes.Email);
var isManager = User.FindFirstValue(MyClaimTypes.IsManager);
var departmentId = User.FindFirstValue(MyClaimTypes.DepartmentId);

Je kunt ieder van deze claims ook gebruiken in autorisatie policies, zoals eerder beschreven.