Sviluppare app ASP.NET Core MVC

Suggerimento

Questo contenuto è un estratto dell'eBook Progettare applicazioni Web moderne con ASP.NET Core e Azure, disponibile in .NET Docs o come PDF scaricabile gratuitamente che può essere letto offline.

Anteprima copertina e-book Progettare applicazioni Web moderne con ASP.NET Core e Azure.

"Non è importante ottenere il risultato desiderato al primo tentativo. L'importante è ottenerlo all'ultimo". - Andrew Hunt e David Thomas

ASP.NET Core è un framework multipiattaforma open source per la compilazione di moderne applicazioni Web ottimizzate per il cloud. Le app ASP.NET Core sono leggere e modulari, con supporto incorporato per l'inserimento di dipendenze, e offrono maggiore testabilità e gestibilità. In associazione a MVC, che supporta la compilazione di API Web moderne oltre che di app basate su visualizzazione, ASP.NET Core è un framework potente per la compilazione di applicazioni Web aziendali.

MVC e Razor Pages

ASP.NET Core MVC offre molte funzionalità utili per la compilazione di API e app basate sul Web. Il termine MVC è l'acronimo di "Model-View-Controller", un modello UI che suddivide le responsabilità della risposta alle richieste degli utenti in più sezioni. Oltre a seguire questo modello è possibile implementare funzionalità nelle app ASP.NET Core come Razor Pages.

Le Razor Pages sono integrate in ASP.NET Core MVC e usano le stesse funzionalità di routing, associazione di modelli, filtri, autorizzazione e così via. Tuttavia, anziché avere cartelle e file separati per controller, modelli, viste e altri elementi e usare il routing basato sugli attributi, le Razor Pages si trovano in un'unica cartella ("/Pages"), eseguono il routing sulla base della loro posizione relativa in tale cartella e gestiscono le richieste con gestori anziché con azioni del controller. Di conseguenza, quando si usano le Razor Pages, tutti i file e le classi necessari sono in genere raggruppati nella stessa posizione, anziché distribuiti in tutto il progetto Web.

Su GitHub sono disponibili altre informazioni su come vengono applicati MVC, Razor Pages e i modelli correlati nell'applicazione di esempio eShopOnWeb.

Quando si crea una nuova app ASP.NET Core è importante avere determinato il tipo di app che si vuole creare. Quando si crea un nuovo progetto, nell'IDE o usando il comando dotnet new dell'interfaccia della riga di comando, è possibile scegliere tra diversi modelli. I modelli di progetto più comuni sono Vuoto, API Web, App Web e App Web (Model-View-Controller). Questa scelta può essere effettuata solo quando si crea un progetto, ma non è una decisione irrevocabile. Il progetto API Web usa controller MVC standard, ma per impostazione predefinita non dispone di una cartella Views. Allo stesso modo, il modello App Web predefinito usa Razor Pages e pertanto non dispone di una cartella Views. È possibile aggiungere una cartella Views a questi progetti in un secondo momento per supportare il comportamento basato sulle visualizzazioni. I progetti API Web e MVC non includono una cartella Pages per impostazione predefinita, ma è possibile aggiungerne una in un secondo momento per supportare i comportamenti basati su Razor Pages. Considerare questi tre modelli come origini del supporto per tre tipi diversi di interazione dell'utente predefinita: dati (API Web), basata sulle pagine e basata sulle visualizzazioni. Se si vuole è tuttavia possibile combinare e associare alcuni o tutti questi modelli in un unico progetto.

Perché Razor Pages?

Razor Pages è l'approccio predefinito per le nuove applicazioni Web in Visual Studio. Razor Pages offre una modalità più semplice per la creazione di funzionalità dell'applicazione basate sulle pagine, ad esempio moduli non SPA. Con i controller e le visualizzazioni capitava spesso di avere applicazioni con controller molto grandi, che funzionavano con molte dipendenze e modelli di visualizzazione diversi e restituivano molte visualizzazioni diverse. Questo comportava un grado di complessità maggiore e spesso produceva controller non conformi al principio di singola responsabilità o al principio aperto/chiuso. Razor Pages risolve questo problema incapsulando la logica lato server di una determinata "pagina" logica in un'applicazione Web con markup Razor. Una pagina Razor Pages che non include logica lato server può essere costituita solo da un file Razor (ad esempio "Index.cshtml"). Tuttavia la maggior parte delle pagine Razor non elementari include una classe modello pagina associata, che per convenzione ha lo stesso nome del file Razor seguito dall'estensione "cs", ad esempio "Index.cshtml.cs".

Un modello di pagina Razor Pages combina le responsabilità di un controller MVC e di un elemento ViewModel. Anziché gestire le richieste con metodi di azione del controller, vengono eseguiti gestori modello di pagina come "OnGet()" che eseguono il rendering della pagina associata per impostazione predefinita. Razor Pages semplifica il processo di creazione di pagine singole in un'app ASP.NET Core, pur garantendo tutte le funzionalità architettoniche di ASP.NET Core MVC. Si tratta di una scelta predefinita ottimale per nuove funzionalità basate sulle pagine.

Quando usare MVC

In caso di compilazione di API Web, il modello MVC è preferibile all'uso di Razor Pages. Se il progetto esporrà solo gli endpoint API Web, la scelta migliore è il modello di progetto API Web. Altrimenti, è possibile aggiungere facilmente controller ed endpoint API associati a qualsiasi app ASP.NET Core. Usare l'approccio MVC basato su viste quando si vuole eseguire con il minimo sforzo la migrazione di un'applicazione esistente da ASP.NET MVC 5 o versioni precedenti ad ASP.NET Core MVC. Dopo aver eseguito la migrazione iniziale, è possibile valutare se è opportuno adottare Razor Pages per le nuove funzionalità o per la migrazione globale. Per altre informazioni sulla conversione di app .NET 4.x in .NET 8, vedere l’eBook Conversione di app di ASP.NET esistenti in ASP.NET Core.

Sia che si scelga di crearla con Razor Pages o con le viste MVC, l'app avrà prestazioni simili e supporterà l'inserimento delle dipendenze, i filtri, l'associazione di modelli, la convalida e così via.

Mapping delle richieste alle risposte

Fondamentalmente, le app ASP.NET Core mappano le richieste in ingresso alle risposte in uscita. Questo mapping viene eseguito tramite middleware e le semplici app ASP.NET Core e i microservizi possono essere costituiti esclusivamente da middleware personalizzato. Quando si usa ASP.NET Core MVC, è possibile lavorare a un livello avanzato con route, controller e azioni. Ogni richiesta in ingresso viene confrontata con la tabella di routing dell'applicazione e se viene trovata una route corrispondente, viene chiamato il metodo di azione associato (appartenente a un controller) per gestire la richiesta. Se non viene trovata alcuna route corrispondente, viene chiamato un gestore degli errori (in questo caso, viene restituito un risultato NotFound).

Le app ASP.NET Core MVC possono usare route convenzionali, route di attributi o entrambi i tipi di route. Le route convenzionali sono definite nel codice tramite la specifica delle convenzioni di routing usando una sintassi simile a quella dell'esempio seguente:

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");
});

In questo esempio è stata aggiunta una route denominata "default" alla tabella di routing. Definisce un modello di route con segnaposto per controller, action e id. Per i segnaposto controller e action è specificato il valore predefinito, rispettivamente Home e Index, mentre il segnaposto id è facoltativo poiché è applicato un "?". La convenzione definita in questo caso stabilisce che la prima parte di una richiesta deve corrispondere al nome del controller, la seconda parte all'azione e, se necessaria, una terza parte al parametro dell'ID. Le route convenzionali vengono in genere definite in un'unica posizione per l'applicazione, ad esempio in Program.cs, dove viene configurata la pipeline del middleware delle richieste.

Le route di attributi vengono applicate a controller e azioni direttamente anziché essere specificate a livello globale. Questo approccio offre il vantaggio di renderle più facilmente individuabili durante la ricerca di un metodo specifico, ma significa che le informazioni di routing non vengono mantenute in un'unica posizione nell'applicazione. Con le route di attributi, è possibile specificare in modo semplice più route per una determinata azione nonché combinare le route tra i controller e le azioni. Ad esempio:

[Route("Home")]
public class HomeController : Controller
{
    [Route("")] // Combines to define the route template "Home"
    [Route("Index")] // Combines to define route template "Home/Index"
    [Route("/")] // Does not combine, defines the route template ""
    public IActionResult Index() {}
}

Le route possono essere specificate in [HttpGet] e attributi simili, evitando la necessità di aggiungere attributi [Route] separati. Le route di attributi possono anche usare i token per ridurre la necessità di ripetere i nomi di controller o azioni, come illustrato di seguito:

[Route("[controller]")]
public class ProductsController : Controller
{
    [Route("")] // Matches 'Products'
    [Route("Index")] // Matches 'Products/Index'
    public IActionResult Index() {}
}

Le Razor Pages non usano il routing degli attributi. È possibile specificare informazioni aggiuntive sul modello di route per una pagina Razor nel contesto della direttiva @page corrispondente:

@page "{id:int}"

Nell'esempio precedente, la pagina in questione corrisponderà a una route con un parametro id intero. Ad esempio, la pagina Products.cshtml nella radice di /Pages risponderà alle richieste in questo modo:

/Products/123

Dopo aver individuato una determinata richiesta corrispondente a una route e prima della chiamata al metodo di azione, ASP.NET Core MVC eseguirà l'associazione del modello e la convalida del modello nella richiesta. L'associazione del modello converte i dati HTTP in ingresso nei tipi .NET specificati come parametri del metodo di azione da chiamare. Ad esempio, se il metodo di azione prevede un parametro int id, l'associazione del modello tenterà di usare questo parametro in base a un valore specificato come parte della richiesta. A tale scopo, l'associazione del modello cerca i valori in un form pubblicato, i valori nella route e i valori di stringa di query. Se viene trovato un valore id, questo viene convertito in un intero prima di essere passato nel metodo di azione.

Dopo l'associazione del modello ma prima della chiamata al metodo di azione, viene eseguita la convalida del modello. La convalida del modello usa gli attributi facoltativi del tipo di modello e contribuisce a garantire che l'oggetto di modello specificato sia conforme a determinati requisiti dei dati. Alcuni valori possono essere specificati come obbligatori o limitati a una determinata lunghezza o a un determinato intervallo numerico e così via. Se vengono specificati gli attributi di convalida ma il modello non è conforme ai requisiti, la proprietà ModelState.IsValid avrà valore false e il set di regole della convalida non riuscita sarà disponibile per l'invio al client che effettua la richiesta.

Se viene usata la convalida del modello, è necessario verificare sempre che il modello sia valido prima di eseguire i comandi di modifica dello stato per garantire che l'app non sia danneggiata da dati non validi. È possibile usare un filtro per evitare di aggiungere il codice necessario per questa convalida in ogni azione. I filtri di ASP.NET Core MVC consentono di intercettare i gruppi di richieste in modo che sia possibile applicare alla base di destinazione i criteri e gli aspetti comuni. I filtri possono essere applicati alle singole azioni, a interi controller o a livello globale per un'applicazione.

Per le API Web, ASP.NET Core MVC supporta la negoziazione del contenuto consentendo alle richieste di specificare la formattazione delle risposte. In base alle intestazioni specificate nella richiesta, le azioni che restituiscono dati formattano la risposta nel formato XML, JSON o un altro formato supportato. Questa funzionalità consente a più client con requisiti diversi di formattazione dei dati di usare la stessa API.

Per i progetti API Web può essere utile usare l'attributo [ApiController], applicabile a singoli controller, a una classe controller di base o all'intero assembly. Questo attributo aggiunge il controllo di convalida modello automatico e qualsiasi azione con un modello non valido restituisce BadRequest con i dettagli degli errori di convalida. L'attributo richiede anche che tutte le azioni abbiano una route di attributo (anziché usare una route convenzionale) e restituisce informazioni ProblemDetails più dettagliate in caso di errori.

Tenere i controller sotto controllo

Per le applicazioni basate su pagine, le Razor Pages fanno un ottimo lavoro impedendo ai controller di diventare troppo grandi. A ogni singola pagina sono assegnati file e classi dedicati esclusivamente ai relativi gestori. Prima dell'introduzione delle Razor Pages, molte applicazioni basate sulle viste avevano classi controller di grandi dimensioni responsabili di molte azioni e viste diverse. Queste classi finivano naturalmente per avere molte responsabilità e dipendenze, cosa che ne rendeva più difficile la gestione. Se si rileva che i controller basati su viste stanno diventando troppo grandi, è consigliabile effettuarne il refactoring per usare le Razor Pages o introdurre uno schema come un mediator.

Lo schema progettuale Mediator viene usato per ridurre l'accoppiamento tra le classi, consentendo al tempo stesso la comunicazione tra di esse. Nelle applicazioni ASP.NET Core MVC, questo schema viene spesso usato per suddividere i controller in parti più piccole usando i gestori per eseguire il lavoro dei metodi di azione. A questo scopo viene spesso usato il noto pacchetto NuGet MediatR. In genere, i controller includono numerosi metodi di azione diversi, ognuno dei quali può richiedere determinate dipendenze. Il set di tutte le dipendenze richieste da qualsiasi azione deve essere passato al costruttore del controller. Quando si usa MediatR, l'unica dipendenza di un controller è in genere un'istanza del mediator. Ogni azione usa quindi l'istanza del mediator per inviare un messaggio, che viene elaborato da un gestore. Il gestore è specifico di una singola azione e pertanto ha bisogno solo delle dipendenze richieste da tale azione. Di seguito è riportato un esempio di controller che usa MediatR:

public class OrderController : Controller
{
    private readonly IMediator _mediator;

    public OrderController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpGet]
    public async Task<IActionResult> MyOrders()
    {
        var viewModel = await _mediator.Send(new GetMyOrders(User.Identity.Name));
        return View(viewModel);
    }
    // other actions implemented similarly
}

Nell'azione MyOrders, la chiamata a Send per inviare un messaggio GetMyOrders viene gestita da questa classe:

public class GetMyOrdersHandler : IRequestHandler<GetMyOrders, IEnumerable<OrderViewModel>>
{
    private readonly IOrderRepository _orderRepository;
    public GetMyOrdersHandler(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }

  public async Task<IEnumerable<OrderViewModel>> Handle(GetMyOrders request, CancellationToken cancellationToken)
    {
        var specification = new CustomerOrdersWithItemsSpecification(request.UserName);
        var orders = await _orderRepository.ListAsync(specification);
        return orders.Select(o => new OrderViewModel
            {
                OrderDate = o.OrderDate,
                OrderItems = o.OrderItems?.Select(oi => new OrderItemViewModel()
                  {
                    PictureUrl = oi.ItemOrdered.PictureUri,
                    ProductId = oi.ItemOrdered.CatalogItemId,
                    ProductName = oi.ItemOrdered.ProductName,
                    UnitPrice = oi.UnitPrice,
                    Units = oi.Units
                  }).ToList(),
                OrderNumber = o.Id,
                ShippingAddress = o.ShipToAddress,
                Total = o.Total()
        });
    }
}

Il risultato finale di questo approccio è una notevole riduzione delle dimensioni dei controller, che ora sono incentrati principalmente sul routing e sull'associazione di modelli, mentre i singoli gestori sono responsabili delle attività specifiche richieste da un determinato endpoint. Si può ottenere questo risultato anche senza MediatR, usando il pacchetto NuGet ApiEndpoints, che tenta di portare ai controller API gli stessi vantaggi offerti dalle Razor Pages ai controller basati su viste.

Riferimenti - Mapping delle richieste alle risposte

Uso delle dipendenze

ASP.NET Core include il supporto incorporato e usa internamente una tecnica chiamata inserimento di dipendenze. L'inserimento di dipendenze è una tecnica che abilita l'accoppiamento libero tra parti diverse di un'applicazione. L'accoppiamento libero può essere utile perché rende più semplice isolare le parti dell'applicazione per il test o la sostituzione. Rende anche meno probabile che una modifica in una parte dell'applicazione abbia un impatto imprevisto in un'altra posizione nell'applicazione. L'inserimento di dipendenze si basa sul principio di inversione della dipendenza e rappresenta spesso la chiave per ottenere il principio di apertura/chiusura. Quando si valuta il funzionamento dell'applicazione con le dipendenze, prestare attenzione al codice static cling e ricordare la frase "New is Glue".

Lo static cling si verifica quando le classi chiamano metodi statici o accedono a proprietà statiche, un'operazione che comporta effetti collaterali o dipendenze nell'infrastruttura. Ad esempio, se è presente un metodo che chiama un metodo statico che a sua volta scrive in un database, il metodo è strettamente accoppiato al database. Qualsiasi elemento che interrompe la chiamata al database interromperà il metodo. Eseguire i test di questi tipi di metodi è notoriamente difficile poiché i test richiedono librerie di simulazione commerciali per simulare le chiamate statiche oppure possono essere eseguiti solo con un database di prova. Le chiamate statiche che non hanno alcuna dipendenza dall'infrastruttura, in particolare le chiamate che sono completamente senza stato, possono essere eseguite senza problemi e non hanno alcun effetto sull'accoppiamento o la testabilità, al di là dell'accoppiamento del codice alla chiamata statica stessa.

Sebbene conoscano i rischi dello static cling e dello stato globale, molti sviluppatori eseguono comunque uno stretto accoppiamento del codice a implementazioni specifiche attraverso la creazione diretta delle istanze. "New is glue" vuole essere un promemoria di questo accoppiamento e non una condanna generale dell'uso della parola chiave new. Come con le chiamate a metodi statici, le nuove istanze dei tipi che non hanno dipendenze esterne in genere non accoppiano strettamente il codice ai dettagli di implementazione o rendono più difficile il test. Tuttavia, ogni volta che viene creata un'istanza di una classe, fermarsi a considerare se è consigliabile impostare come hardcoded l'istanza specifica in quella determinata posizione oppure richiedere l'istanza come dipendenza.

Dichiarare le dipendenze

ASP.NET Core si basa su metodi e classi che dichiarano le relative dipendenze richiedendole come argomenti. Le applicazioni ASP.NET vengono in genere configurate in Program.cs o in una classe Startup.

Nota

La configurazione delle app completamente in Program.cs è l'approccio predefinito per le app .NET 6 (e versioni successive) e Visual Studio 2022. I modelli di progetto sono stati aggiornati per semplificare l’uso di questo nuovo approccio. I progetti ASP.NET Core possono continuare a usare una classe Startup, se si preferisce.

Configurare i servizi in Program.cs

Per le app molto semplici, è possibile collegare le dipendenze direttamente nel file Program.cs usando la classe WebApplicationBuilder. Dopo aver aggiunto tutti i servizi necessari, viene usato il generatore per creare l'app.

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorPages();

var app = builder.Build();

Configurare i servizi in Startup.cs

Startup.cs è configurato per supportare l'inserimento delle dipendenze in diversi punti. Se si usa una classe Startup, è possibile assegnarle un costruttore che potrà richiedere le dipendenze attraverso di essa, come nell’esempio seguente:

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

La classe Startup è particolare perché non ha requisiti di tipo espliciti. Non eredita da una speciale classe di base Startup, né implementa alcuna interfaccia specifica. È possibile assegnare o meno alla classe un costruttore ed è possibile specificare il numero di parametri desiderato nel costruttore. Quando viene avviato l'host Web configurato per l'applicazione, l'host chiama la classe Startup (se è stato specificato di usarne una) e usa l'inserimento delle dipendenze per popolare le dipendenze richieste dalla classe Startup. Naturalmente, se si richiedono parametri che non sono configurati nel contenitore dei servizi usato da ASP.NET Core, viene generata un'eccezione, ma se le richieste si limitano alle dipendenze riconosciute dal contenitore, sarà possibile richiedere qualsiasi elemento.

L'inserimento di dipendenze è incorporato nelle app ASP.NET Core sin dall'inizio, ovvero dalla creazione dell'istanza di Startup. Non viene interrotto per la classe Startup. È possibile richiedere le dipendenze anche nel metodo Configure:

public void Configure(IApplicationBuilder app,
    IHostingEnvironment env,
    ILoggerFactory loggerFactory)
{

}

Il metodo ConfigureServices rappresenta l'eccezione a questo comportamento, in quanto deve accettare un solo parametro di tipo IServiceCollection. Non ha veramente bisogno di supportare l'inserimento delle dipendenze, poiché da un lato è responsabile dell'aggiunta di oggetti al contenitore dei servizi e dall'altro ha accesso a tutti i servizi attualmente configurati tramite il parametro IServiceCollection. Per questo motivo, è possibile usare le dipendenze definite nella raccolta di servizi di ASP.NET Core in ogni parte della classe Startup, richiedendo il servizio necessario come parametro o usando IServiceCollection in ConfigureServices.

Nota

Se è necessario garantire che determinati servizi siano disponibili per la classe Startup, è possibile configurarli usando IWebHostBuilder e il relativo metodo ConfigureServices all'interno della chiamata a CreateDefaultBuilder.

La classe Startup costituisce un modello per la struttura delle altre parti dell'applicazione ASP.NET Core, dai controller al middleware e dai filtri ai servizi. In ogni caso, è necessario seguire il principio delle dipendenze esplicite richiedendo le dipendenze anziché crearle direttamente e usando l'inserimento di dipendenze in tutta l'applicazione. Prestare attenzione alla posizione e alla modalità di creazione diretta delle istanze delle implementazioni, in particolare dei servizi e degli oggetti che usano l'infrastruttura o presentano effetti collaterali. Preferire l'uso di astrazioni definite nell'applicazione e passate come argomenti anziché l'uso di riferimenti hardcoded a tipi di implementazione specifici.

Creazione della struttura dell'applicazione

Le applicazioni monolitiche hanno in genere un singolo punto di ingresso. In un'applicazione Web ASP.NET Core il punto di ingresso sarà il progetto Web ASP.NET Core. Tuttavia, ciò non significa che la soluzione deve essere costituita da un singolo progetto. È utile suddividere l'applicazione in livelli diversi per riflettere la separazione delle competenze. Dopo la suddivisione in livelli, è utile andare oltre le cartelle per separare i progetti per ottenere un miglior incapsulamento. L'approccio migliore per raggiungere questi obiettivi con un'applicazione ASP.NET Core è una variante dell'architettura "pulita" descritta nel capitolo 5. Seguendo questo approccio, la soluzione dell'applicazione comprenderà librerie distinte per interfaccia utente, infrastruttura e ApplicationCore.

Oltre a questi progetti, sono inclusi anche i progetti di test separati (i test sono descritti nel capitolo 9).

Il modello a oggetti e le interfacce dell'applicazione devono trovarsi nel progetto ApplicationCore. Questo progetto avrà il minor numero possibile di dipendenze (e nessuna relativa ad aspetti specifici dell'infrastruttura) e gli altri progetti nella soluzione faranno riferimento ad esso. Le entità di business che devono essere mantenute sono definite nel progetto ApplicationCore, analogamente ai servizi che non dipendono direttamente dall'infrastruttura.

I dettagli di implementazione, ad esempio la modalità di persistenza o di invio delle notifiche a un utente, si trovano nel progetto Infrastructure. Il progetto farà riferimento a pacchetti specifici dell'implementazione, ad esempio Entity Framework Core, ma non deve esporre informazioni dettagliate su queste implementazioni all'esterno del progetto. I servizi e i repository dell'infrastruttura devono implementare interfacce definite nel progetto ApplicationCore e le relative implementazioni della persistenza sono responsabili del recupero e dell'archiviazione delle entità definite in ApplicationCore.

Il progetto dell'interfaccia utente ASP.NET Core è responsabile di tutte le competenze a livello dell'interfaccia utente, ma non deve includere la logica di business o i dettagli dell'infrastruttura. Di fatto, il progetto non deve includere alcuna dipendenza nel progetto Infrastructure in modo che non venga inserita accidentalmente alcuna dipendenza tra i due progetti. Questo obiettivo può essere raggiunto usando un contenitore DI di terze parti come Autofac, che consente di definire le regole DI nelle classi Module in ogni progetto.

Un altro approccio per disaccoppiare l'applicazione dai dettagli di implementazione consiste nel fare in modo che l'applicazione chiami i microservizi, forse distribuiti in singoli contenitori Docker. Ciò offre una separazione delle competenze e un disaccoppiamento maggiori rispetto all'utilizzo di DI tra due progetti ma comporta una maggiore complessità.

Organizzazione basata sulle funzionalità

Per impostazione predefinita, le applicazioni ASP.NET Core organizzano la struttura delle cartelle in modo da includere controller e visualizzazioni e spesso modelli di visualizzazione. Il codice lato client per supportare queste strutture lato server è in genere archiviato separatamente nella cartella wwwroot. Questa organizzazione, tuttavia, può creare problemi nelle applicazioni di grandi dimensioni poiché l'utilizzo di qualsiasi funzionalità specificata richiede spesso il passaggio da una cartella all'altra. Questa operazione diventa ancora più complessa con un numero più elevato di file e sottocartelle in ogni cartella richiedendo un maggior esplorazione in Esplora soluzioni. Per risolvere questo problema è possibile organizzare il codice dell'applicazione per funzionalità anziché per tipo di file. Questo tipo di organizzazione viene solitamente chiamato cartelle di funzionalità o sezioni di funzionalità. Vedere anche l’articolo relativo alle sezioni verticali.

A tale scopo, ASP.NET Core MVC supporta le aree. Usando le aree è possibile creare set separati di cartelle di controller e visualizzazioni, inclusi i modelli associati, in ogni cartella Area. La figure 7-1 illustra un esempio di struttura di cartelle con aree.

Esempio di organizzazione di aree

Figura 7-1. Esempio di organizzazione di aree

Quando si usano le aree, è necessario usare gli attributi per decorare i controller con il nome dell'area a cui appartengono:

[Area("Catalog")]
public class HomeController
{}

È anche necessario aggiungere il supporto delle aree alle route:

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllerRoute(name: "areaRoute", pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}");
    endpoints.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");
});

Oltre al supporto incorporato per le aree, è possibile usare anche la propria struttura di cartelle e le convenzioni al posto di attributi e route personalizzate. Ciò consentirebbe di avere cartelle di funzionalità che non includano cartelle separate per le visualizzazioni, i controller e così via, creando in questo modo una gerarchia più piana e rendendo più semplice visualizzare tutti i file correlati in un'unica posizione per ogni funzionalità. Per le API, le cartelle possono essere usate per sostituire i controller e ogni cartella può contenere tutti gli endpoint API e gli oggetti DTO associati.

ASP.NET Core usa tipi convenzione predefiniti per controllare il comportamento. È possibile modificare o sostituire queste convenzioni. Ad esempio, è possibile creare una convenzione che ottiene automaticamente il nome della funzionalità per un controller specifico in base al relativo spazio dei nomi (che in genere è correlato alla cartella in cui si trova il controller):

public class FeatureConvention : IControllerModelConvention
{
    public void Apply(ControllerModel controller)
    {
        controller.Properties.Add("feature",
        GetFeatureName(controller.ControllerType));
    }

    private string GetFeatureName(TypeInfo controllerType)
    {
        string[] tokens = controllerType.FullName.Split('.');
        if (!tokens.Any(t => t == "Features")) return "";
        string featureName = tokens
            .SkipWhile(t => !t.Equals("features", StringComparison.CurrentCultureIgnoreCase))
            .Skip(1)
            .Take(1)
            .FirstOrDefault();
        return featureName;
    }
}

Specificare quindi questa convenzione come opzione quando si aggiunge il supporto per MVC all'applicazione in ConfigureServices (o in Program.cs):

// ConfigureServices
services.AddMvc(o => o.Conventions.Add(new FeatureConvention()));

// Program.cs
builder.Services.AddMvc(o => o.Conventions.Add(new FeatureConvention()));

ASP.NET Core MVC usa una convenzione anche per individuare le visualizzazioni. Questa convenzione può essere sostituita con una convenzione personalizzata in modo che le visualizzazioni vengano inserite nelle cartelle delle funzionalità (usando il nome della funzionalità specificato da FeatureConvention). Per altre informazioni su questo approccio e per scaricare un esempio funzionante, vedere l'articolo di MSDN Magazine Sezioni di funzionalità per ASP.NET Core MVC.

API e applicazioni Blazor

Se l'applicazione include un set di API Web, che devono essere protette, è opportuno configurare queste API come progetto distinto dalla vista o dall'applicazione Razor Pages. La separazione delle API, in particolare quelle pubbliche, dall'applicazione Web sul lato server offre diversi vantaggi. Queste applicazioni hanno spesso caratteristiche di distribuzione e caricamento uniche. È anche molto probabile che adottino meccanismi diversi per la sicurezza: ad esempio, le applicazioni basate su form standard sfrutteranno l'autenticazione basata su cookie, mentre le API useranno più probabilmente l'autenticazione basata su token.

Inoltre, le applicazioni Blazor, sia che usino Blazor Server o BlazorWebAssembly, devono essere compilate come progetti distinti. Le applicazioni hanno caratteristiche di runtime e modelli di sicurezza diversi. Condividono probabilmente tipi comuni con l'applicazione Web lato server (o il progetto API), pertanto questi tipi devono essere definiti in un progetto condiviso comune.

L'aggiunta di un'interfaccia amministrativa BlazorWebAssembly a eShopOnWeb ha richiesto l'aggiunta di diversi nuovi progetti, tra cui il progetto BlazorWebAssembly stesso, BlazorAdmin. Nel progetto PublicApi viene definito un nuovo set di endpoint API pubblici, usati da BlazorAdmin e configurati per l'uso dell'autenticazione basata su token. Alcuni tipi condivisi usati da entrambi questi progetti vengono conservati in un nuovo progetto BlazorShared.

Ci si potrebbe chiedere a cosa serve aggiungere un progetto BlazorShared distinto quando esiste già un progetto ApplicationCore comune che può essere usato per condividere qualsiasi tipo richiesto da PublicApi e BlazorAdmin. La risposta è che questo progetto include tutta la logica di business dell'applicazione, quindi è molto più grande del necessario ed è anche molto più probabile che debba essere mantenuto sicuro sul server. Tenere presente che qualsiasi libreria a cui BlazorAdmin fa riferimento viene scaricata nei browser degli utenti quando caricano l'applicazione Blazor.

A seconda che si usi il modello back-end per front-end, le API utilizzate dall'app BlazorWebAssembly potrebbero non condividere i propri tipi al 100% con Blazor. In particolare, un'API pubblica destinata a essere utilizzata da molti client diversi potrebbe definire i propri tipi di richiesta e risultato, anziché condividerli in un progetto condiviso specifico del client. Nell'app di esempio eShopOnWeb si presuppone che il progetto PublicApi ospiti di fatto un'API pubblica, quindi non tutti i suoi tipi di richiesta e risposta provengono dal progetto BlazorShared.

Problematiche trasversali

Con l'aumento delle dimensioni delle applicazioni, diventa sempre più importante evitare i problemi di cross-cutting per eliminare la duplicazione e mantenere la coerenza. Alcuni esempi di problemi di cross-cutting nelle applicazioni ASP.NET Core sono, tra gli altri, l'autenticazione, le regole di convalida del modello, la memorizzazione nella cache dell'output e la gestione degli errori. I filtri di ASP.NET Core MVC consentono di eseguire codice prima o dopo determinate fasi della pipeline di elaborazione della richiesta. Ad esempio, un filtro può essere eseguito prima e dopo l'associazione di modelli, prima e dopo un'azione o prima e dopo il risultato di un'azione. È anche possibile usare un filtro di autorizzazione per controllare l'accesso alla parte rimanente della pipeline. La figura 7-2 mostra l'esecuzione della richiesta attraverso i filtri, se configurati.

La richiesta passa attraverso i filtri autorizzazione, i filtri risorse, l'associazione di modelli, i filtri azione, l'esecuzione dell'azione e la conversione del risultato dell'azione, i filtri eccezioni, i filtri risultato e l'esecuzione del risultato. All'uscita della pipeline, la richiesta viene elaborata solo dai filtri risultato e dai filtri risorse prima di diventare una risposta inviata al client.

Figura 7-2. Esecuzione della richiesta attraverso i filtri e pipeline della richiesta.

I filtri vengono in genere implementati come attributi, quindi è possibile applicarli a controller o azioni (o persino a livello globale). Quando vengono aggiunti in questo modo, i filtri specificati a livello di azione sostituiscono o sono basati sui filtri specificati a livello di controller che a loro volta sostituiscono i filtri globali. È possibile ad esempio usare l'attributo [Route] per creare route tra i controller e le azioni. Analogamente, l'autorizzazione può essere configurata a livello di controller e quindi sostituita da singole azioni, come illustrato nell'esempio seguente:

[Authorize]
public class AccountController : Controller
{
    [AllowAnonymous] // overrides the Authorize attribute
    public async Task<IActionResult> Login() {}
    public async Task<IActionResult> ForgotPassword() {}
}

Il primo metodo, Login, usa il filtro [AllowAnonymous] (attributo) per eseguire l'override del filtro Authorize impostato a livello di controller. L'azione ForgotPassword (e qualsiasi altra azione nella classe che non ha un attributo AllowAnonymous) necessiterà di una richiesta autenticata.

È possibile usare i filtri per eliminare la duplicazione come criteri di gestione degli errori comuni delle API. Ad esempio, un criterio di API tipico consiste nel restituire una risposta NotFound alle richieste che fanno riferimento a chiavi non esistenti e una risposta BadRequest nei casi in cui la convalida del modello ha esito negativo. L'esempio seguente illustrate questi due criteri:

[HttpPut("{id}")]
public async Task<IActionResult> Put(int id, [FromBody]Author author)
{
    if ((await _authorRepository.ListAsync()).All(a => a.Id != id))
    {
        return NotFound(id);
    }
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }
    author.Id = id;
    await _authorRepository.UpdateAsync(author);
    return Ok();
}

Evitare che i metodi di azione diventino troppo pieni con codice condizionale di questo tipo. Eseguire invece il pull dei criteri nei filtri che possono essere applicati quando necessario. In questo esempio la convalida del modello, che dovrebbe verificarsi ogni volta che viene inviato un comando all'API, può essere sostituita dall'attributo seguente:

public class ValidateModelAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        if (!context.ModelState.IsValid)
        {
            context.Result = new BadRequestObjectResult(context.ModelState);
        }
    }
}

È possibile aggiungere ValidateModelAttribute al progetto come dipendenza NuGet includendo il pacchetto Ardalis.ValidateModel. Per le API è possibile usare l'attributo ApiController per applicare questo comportamento senza la necessità di un filtro ValidateModel separato.

Analogamente, è possibile usare un filtro per verificare l'esistenza di un record e restituire un errore 404 prima che venga eseguita l'azione, eliminando la necessità di eseguire questi controlli nell'azione. Dopo aver estratto le convenzioni comuni e aver organizzato la soluzione per separare il codice dell'infrastruttura e la logica di business dall'interfaccia utente, i metodi delle azioni MVC dovrebbero essere estremamente snelli:

[HttpPut("{id}")]
[ValidateAuthorExists]
public async Task<IActionResult> Put(int id, [FromBody]Author author)
{
    await _authorRepository.UpdateAsync(author);
    return Ok();
}

Per altre informazioni sull'implementazione di filtri e per scaricare un esempio funzionante, vedere l'articolo di MSDN Magazine Filtri reali di ASP.NET Core MVC.

Se si riscontra una serie di risposte comuni da parte delle API, basate su scenari comuni come errori di convalida (richiesta non valida), risorse non trovate ed errori del server, può essere opportuno usare un'astrazione result. Questa astrazione verrebbe restituita dai servizi utilizzati dagli endpoint API e l'endpoint o l'azione del controller userebbe un filtro per convertirli in IActionResults.

Riferimenti - Creazione della struttura delle applicazioni

Sicurezza

La protezione delle applicazioni Web è un argomento molto ampio con numerose considerazioni. A un livello di base, per garantire la sicurezza è necessario conoscere l'identità dell'utente da cui proviene una determinata richiesta e assicurarsi che la richiesta abbia accesso solo alle risorse necessarie. L'autenticazione è il processo di confronto delle credenziali specificate con una richiesta con quelle di un archivio di dati attendibili per verificare se la richiesta deve essere trattata come proveniente da un'entità nota. L'autorizzazione è il processo di limitazione dell'accesso a determinate risorse in base all'identità utente. Un terzo aspetto relativo alla sicurezza è la protezione delle richieste dall'intercettazione da terze parti per cui è necessario assicurarsi che l'applicazione usi SSL.

Identità

ASP.NET Core Identity è un sistema di appartenenza che è possibile usare per supportare la funzionalità di accesso per l'applicazione. Include il supporto degli account utente locali e il supporto dei provider di accesso esterni come l'account Microsoft, Twitter, Facebook, Google e altri ancora. Oltre a ASP.NET Core Identity, l'applicazione può usare l'autenticazione di Windows o un provider di identità di terze parti come Identity Server.

ASP.NET Core Identity è incluso nei nuovi modelli di progetto se l'opzione Account utente individuali è selezionata. Questo modello include il supporto per la registrazione, l'accesso, gli accessi esterni, le password dimenticate e funzionalità aggiuntive.

Selezionare Account utente individuali per preconfigurare Identity

Figura 7-3. Selezionare Account utente individuali per preconfigurare Identity.

Il supporto di Identity è configurato in Program.cs o Startup e include la configurazione dei servizi e del middleware.

Configurare Identity in Program.cs

In Program.cs configurare i servizi dall'istanza di WebHostBuilder e quindi, dopo aver creato l'app, configurarne il middleware. Gli aspetti principali da notare sono la chiamata a AddDefaultIdentity per i servizi necessari e le chiamate a UseAuthentication e UseAuthorization che aggiungono il middleware necessario.

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddRazorPages();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseMigrationsEndPoint();
}
else
{
  app.UseExceptionHandler("/Error");
  // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
  app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

app.MapRazorPages();

app.Run();

Configurare Identity all'avvio dell'app

// Add framework services.
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddDefaultTokenProviders();
builder.Services.AddMvc();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseMigrationsEndPoint();
}
else
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

app.MapRazorPages();

È importante che UseAuthentication e UseAuthorization appaiano prima di MapRazorPages. Durante la configurazione dei servizi di gestione delle identità, si noterà una chiamata a AddDefaultTokenProviders. La chiamata non è correlata ai token che possono essere usati per proteggere le connessioni Web, ma fa riferimento ai provider che creano prompt che è possibile inviare agli utenti tramite SMS o posta elettronica per la conferma dell'identità.

Per altre informazioni sulla configurazione dell'autenticazione a due fattori e l'abilitazione di provider di accesso esterni, vedere la documentazione ufficiale di ASP.NET Core.

Autenticazione

L'autenticazione è il processo che determina l’identità degli utenti che accedono al sistema. Se si usa ASP.NET Core Identity e i metodi di configurazione illustrati nella sezione precedente, verranno configurate automaticamente nell'applicazione alcune impostazioni predefinite di autenticazione. Tuttavia, è possibile configurare queste impostazioni predefinite anche manualmente o eseguire l'override di quelle impostate da AddIdentity. Se si usa Identity, viene configurata l'autenticazione basata su cookie come schema predefinito.

L'autenticazione basata sul Web prevede in genere fino a cinque azioni che possono essere eseguite durante l'autenticazione di un client di un sistema. Si tratta di:

  • Eseguire l'autenticazione. Usa le informazioni fornite dal client per creare un'identità da usare all'interno dell'applicazione.
  • Richiesta. Questa azione viene usata per richiedere al client di identificarsi.
  • Divieto. Informa il client che non gli è consentito eseguire un'azione.
  • Accesso. Rende persistente il client esistente in qualche modo.
  • Disconnessione. Rimuove il client dallo stato di persistenza.

Esistono diverse tecniche comuni per l'esecuzione della procedura di autenticazione nelle applicazioni Web. Queste tecniche sono dette schemi. Uno schema specifico definirà le azioni per alcune o per tutte le opzioni precedenti. Alcuni schemi supportano solo un subset di azioni, quindi potrebbe essere necessario uno schema distinto per eseguire le azioni non supportate. Ad esempio, lo schema OpenId-Connect (OIDC) non supporta l'accesso o la disconnessione, ma viene comunemente configurato per l'uso dell'autenticazione cookie per questa persistenza.

Nell'applicazione ASP.NET Core è possibile configurare uno schema DefaultAuthenticateScheme e schemi specifici facoltativi per ognuna delle azioni descritte sopra, Ad esempio, DefaultChallengeScheme e DefaultForbidScheme. La chiamata a AddIdentity configura diversi aspetti dell'applicazione e aggiunge molti servizi necessari. Include anche questa chiamata per configurare lo schema di autenticazione:

builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme;
    options.DefaultChallengeScheme = IdentityConstants.ApplicationScheme;
    options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
});

Per impostazione predefinita, questi schemi usano i cookie per la persistenza e il reindirizzamento alle pagine di accesso per l'autenticazione. Sono schemi adatti per le applicazioni Web che interagiscono con gli utenti tramite Web browser, ma non sono consigliati per le API. Le API usano in genere un'altra forma di autenticazione, ad esempio i token di connessione JWT.

Le API Web vengono utilizzate dal codice, ad esempio HttpClient nelle applicazioni .NET e tipi equivalenti in altri framework. Questi client prevedono di ricevere una risposta utilizzabile da una chiamata API oppure un codice di stato che indica eventualmente quale problema si è verificato. Questi client non interagiscono tramite un browser e non eseguono il rendering né interagiscono con il codice HTML che potrebbe essere restituito da un'API. Di conseguenza, non è appropriato per gli endpoint API reindirizzare i client a pagine di accesso non autenticate. È più appropriato usare un altro schema.

Per configurare l'autenticazione per le API, si potrebbe ad esempio usare il codice seguente, usato dal progetto PublicApi nell'applicazione di riferimento eShopOnWeb:

builder.Services
    .AddAuthentication(config =>
    {
      config.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
    })
    .AddJwtBearer(config =>
    {
        config.RequireHttpsMetadata = false;
        config.SaveToken = true;
        config.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(key),
            ValidateIssuer = false,
            ValidateAudience = false
        };
    });

Sebbene sia possibile configurare più schemi di autenticazione diversi all'interno di un singolo progetto, è molto più semplice configurare un unico schema predefinito. Per questo e per altri motivi, l'applicazione di riferimento eShopOnWeb separa le API nel proprio progetto, PublicApi, dal progetto Web principale che include le viste dell'applicazione e le Razor Pages.

Autenticazione nelle app Blazor

Le applicazioni Blazor Server possono sfruttare le stesse funzionalità di autenticazione di qualsiasi altra applicazione ASP.NET Core. Le applicazioni BlazorWebAssembly, tuttavia, non possono usare i provider di identità e autenticazione predefiniti, poiché vengono eseguite nel browser. Le applicazioni BlazorWebAssembly possono archiviare lo stato di autenticazione utente in locale e possono accedere alle attestazioni per determinare le azioni che gli utenti dovrebbero essere in grado di eseguire. Tuttavia, tutti i controlli di autenticazione e autorizzazione devono essere eseguiti sul server indipendentemente da qualsiasi logica implementata all'interno dell'app BlazorWebAssembly, poiché gli utenti possono facilmente aggirare l'app e interagire direttamente con le API.

Riferimenti - Autenticazione

Autorizzazione

La forma di autorizzazione più semplice prevede la limitazione dell'accesso per gli utenti anonimi. Questa funzionalità può essere ottenuta applicando l'attributo [Authorize] a determinati controller o azioni. Se vengono usati i ruoli, è possibile estendere ulteriormente l'attributo per limitare l'accesso agli utenti che appartengono a determinati ruoli, come illustrato di seguito:

[Authorize(Roles = "HRManager,Finance")]
public class SalaryController : Controller
{

}

In questo caso, gli utenti che appartengono al ruolo HRManager o Finance o a entrambi i ruoli hanno accesso a SalaryController. Per richiedere l'appartenenza di un utente a più ruoli e non a un solo ruolo, è possibile applicare l'attributo più volte specificando ogni volta il ruolo richiesto.

La specifica di determinati set di ruoli come stringhe in diversi controller e azioni può causare una ripetizione indesiderata. Occorre definire almeno le costanti per questi valori letterali stringa e usare le costanti ovunque sia necessario specificare la stringa. È anche possibile configurare criteri di autorizzazione che incapsulano regole di autorizzazione e quindi specificare i criteri anziché i singoli ruoli durante l'applicazione dell'attributo [Authorize]:

[Authorize(Policy = "CanViewPrivateReport")]
public IActionResult ExecutiveSalaryReport()
{
    return View();
}

Questa modalità di utilizzo dei criteri consente di suddividere i tipi di azione limitati dai ruoli o dalle regole specifiche applicate. In seguito, se si crea un nuovo ruolo che necessita dell'accesso a determinate risorse, sarà sufficiente aggiornare i criteri anziché aggiornare ogni elenco di ruoli in ogni attributo [Authorize].

Richieste di rimborso

Le attestazioni sono coppie nome-valore che rappresentano le proprietà di un utente autenticato. Ad esempio, è possibile archiviare il numero di dipendente degli utenti come attestazione. Le attestazioni possono quindi essere usate come parte dei criteri di autorizzazione. Si potrebbe creare un criterio denominato "EmployeeOnly" che richiede l'esistenza di un'attestazione denominata "EmployeeNumber", come illustrato nell'esempio seguente:

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();
    services.AddAuthorization(options =>
    {
        options.AddPolicy("EmployeeOnly", policy => policy.RequireClaim("EmployeeNumber"));
    });
}

Questo criterio può quindi essere usato con l'attributo [Authorize] per proteggere qualsiasi controller e/o azione, come descritto in precedenza.

Protezione delle API Web

La maggior parte delle API Web deve implementare un sistema di autenticazione basato su token. L'autenticazione del token è progettata per essere scalabile e senza stato. In un sistema di autenticazione basato su token, è necessario che il client esegua prima l'autenticazione nel provider di autenticazione. Se l'autenticazione ha esito positivo, per il client viene emesso un token che è costituito semplicemente da una stringa di caratteri crittograficamente significativa. Il formato più comune per i token è il token JSON Web o JWT (spesso pronunciato "giot"). Per inviare una richiesta a un'API, il client aggiunge il token come intestazione della richiesta. Il server convalida il token individuato nell'intestazione della richiesta prima di eseguire la richiesta. La figura 7-4 illustra questo processo.

TokenAuth

Figura 7-4. Autenticazione basata su token per le API Web.

È possibile creare un servizio di autenticazione personalizzato, integrarlo con Azure AD e OAuth oppure implementare un servizio usando uno strumento open source quale IdentityServer.

I token JWT possono incorporare attestazioni relative all'utente, che possono essere lette nel client o nel server. Si può usare uno strumento come jwt.io per visualizzare il contenuto di un token JWT. Evitare di archiviare dati sensibili come password o chiavi nei token JTW, perché i relativi contenuti possono essere letti facilmente.

Quando si usano token JWT con applicazioni a pagina singola o BlazorWebAssembly, è necessario archiviare il token in una posizione nel client e quindi aggiungerlo a ogni chiamata API. Questa attività viene in genere eseguita come intestazione, come illustrato nel codice seguente:

// AuthService.cs in BlazorAdmin project of eShopOnWeb
private async Task SetAuthorizationHeader()
{
      var token = await GetToken();
      _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}

Dopo aver chiamato il metodo riportato sopra, le richieste effettuate con _httpClientavranno il token incorporato nelle intestazioni della richiesta, consentendo all'API lato server di autenticare e autorizzare la richiesta.

Sicurezza personalizzata

Attenzione

Come regola generale, evitare di implementare soluzioni di sicurezza personalizzate.

Prestare particolare attenzione alle implementazioni personalizzate della crittografia, dell'appartenenza degli utenti o del sistema di generazione di token. Esistono molte alternative commerciali e open source che nella maggior parte dei casi offrono una sicurezza migliore rispetto a quella di un'implementazione personalizzata.

Riferimenti - Sicurezza

Comunicazione con i client

Oltre a visualizzare le pagine e a rispondere alle richieste di dati tramite le API Web, le app ASP.NET Core possono comunicare direttamente con i client connessi. Questa comunicazione in uscita può usare diverse tecnologie di trasporto, tra cui la più comune sono i WebSocket. ASP.NET ASP.NET Core SignalR è una libreria che semplifica l'aggiunta della funzionalità di comunicazione da server a client in tempo reale alle applicazioni. SignalR supporta diverse tecnologie di trasporto, inclusi i WebSocket, ed elimina molti dei dettagli di implementazione specificati dallo sviluppatore.

La comunicazione con il client in tempo reale, con l'utilizzo diretto di WebSocket o altre tecniche, è utile in diversi scenari di applicazioni. Alcuni esempi includono:

  • Applicazioni live chat room

  • Applicazioni di monitoraggio

  • Aggiornamenti dello stato di processo

  • Notifications

  • Applicazioni di moduli interattivi

Quando si definisce la comunicazione con i client nelle applicazioni, vengono in genere usati due componenti:

  • Gestione della connessione sul lato server (hub SignalR, WebSocketManager, WebSocketHandler)

  • Libreria lato client

I client non sono costituiti solo da browser. Anche le app per dispositivi mobili, le app della console e altre app native possono comunicare tramite SignalR/WebSocket. Il semplice programma seguente ripete tutto il contenuto inviato a un'applicazione chat alla console, come parte di un'applicazione di esempio WebSocketManager:

public class Program
{
    private static Connection _connection;
    public static void Main(string[] args)
    {
        StartConnectionAsync();
        _connection.On("receiveMessage", (arguments) =>
        {
            Console.WriteLine($"{arguments[0]} said: {arguments[1]}");
        });
        Console.ReadLine();
        StopConnectionAsync();
    }

    public static async Task StartConnectionAsync()
    {
        _connection = new Connection();
        await _connection.StartConnectionAsync("ws://localhost:65110/chat");
    }

    public static async Task StopConnectionAsync()
    {
        await _connection.StopConnectionAsync();
    }
}

Esaminare i modi in cui le applicazioni comunicano direttamente con le applicazioni client e considerare se la comunicazione in tempo reale migliorerebbe l'esperienza utente dell'app.

Riferimenti - Comunicazione con i client

Quando usare la progettazione basata su domini

La progettazione basata su domini (Domain-Driven Design, DDD) è un approccio semplice alla creazione di software che pone l'attenzione sul dominio aziendale. Evidenzia in particolare la comunicazione e l'interazione con gli esperti dei domini aziendali che possono indicare agli sviluppatori come funziona il sistema nel mondo reale. Ad esempio, se si crea un sistema per la gestione dei titoli azionari, l'esperto del dominio potrebbe essere un agente di cambio. La progettazione basata su domini (DDD) è indirizzata alla soluzione di problemi aziendali estesi e complessi e spesso non è adatta ad applicazioni più semplici e di piccole dimensioni poiché l'investimento nella conoscenza e nella modellazione del dominio potrebbe essere eccessivo.

Quando si compila un software seguendo un approccio DDD, è necessario che il team, inclusi gli utenti e i collaboratori non tecnici, sviluppi un linguaggio universale per l'area dei problemi. Ciò significa che è necessario usare la stessa terminologia per il concetto del mondo reale modellato, l'equivalente software ed eventuali strutture esistenti per il mantenimento del concetto, ad esempio le tabelle di database. Di conseguenza, i concetti descritti in linguaggio universale devono costituire la base del modello di dominio.

Il modello di dominio comprende oggetti che interagiscono tra loro per rappresentare il comportamento del sistema. Gli oggetti sono suddivisi nelle categorie seguenti:

  • Entità che rappresentano gli oggetti con un thread di identità. Le entità sono in genere salvate in modo permanente con una chiave con cui possono essere recuperate in seguito.

  • Aggregazioni che rappresentano gruppi di oggetti che devono essere salvati in modo permanente come unità.

  • Oggetti valore che rappresentano i concetti che possono essere confrontati mediante la somma dei valori delle relative proprietà. Ad esempio, DateRange costituito da una data di inizio e di fine.

  • Eventi del dominio che rappresentano operazioni eseguite all'interno del sistema di interesse ad altre parti del sistema.

Un modello di dominio DDD deve incapsulare un comportamento complesso all'interno del modello stesso. Le entità, in particolare, non devono essere semplici raccolte di proprietà. Quando il modello di dominio non include il comportamento e rappresenta solo lo stato del sistema viene definito un modello anemico non adatto per la progettazione DDD.

Oltre a questi tipi di modello, la progettazione DDD usa in genere un'ampia gamma di modelli:

  • Repository, per fornire l'astrazione dei dettagli di persistenza.

  • Factory, per incapsulare la creazione di oggetti complessi.

  • Servizi, per incapsulare un comportamento complesso e/o i dettagli di implementazione dell'infrastruttura.

  • Comando, per separare l'invio dei comandi e l'esecuzione del comando.

  • Specifica, per incapsulare i dettagli di query.

Con la progettazione DDD è inoltre consigliabile usare l'architettura "pulita" descritta in precedenza che offre l'accoppiamento libero, l'incapsulamento e un codice che può essere facilmente verificato tramite unit test.

Quando applicare la progettazione basata su domini (DDD)

Il modello DDD è particolarmente adatto alle applicazioni di grandi dimensioni con elevata complessità di business (non solo tecnica). L'applicazione deve richiedere la competenza degli esperti di dominio. Deve inoltre essere presente un comportamento significativo nel modello di dominio stesso, che rappresenta le regole business e le interazioni e non si limita all'archiviazione e al recupero dello stato corrente dei diversi record dagli archivi dati.

Quando non applicare la progettazione basata su domini (DDD)

La progettazione DDD prevede investimenti nella modellazione, nell'architettura e nella comunicazione non sempre garantiti per le applicazioni di piccole dimensioni o le applicazioni CRUD (Create/Read/Update/Delete). Se si sceglie un approccio basato sulla progettazione DDD per l'applicazione e si scopre che il dominio ha un modello anemico senza comportamento, potrebbe essere necessario cambiare approccio. È possibile che l'applicazione non richieda la progettazione DDD oppure potrebbe essere necessario ricevere assistenza per effettuare il refactoring dell'applicazione in modo da incapsulare la logica di business nel modello di dominio anziché nel database o nell'interfaccia utente.

Un approccio ibrido potrebbe essere quello di usare la progettazione DDD solo per le aree transazionali o più complesse dell'applicazione e di non usarla per le parti più semplici CRUD o di sola lettura dell'applicazione. Ad esempio, non sono necessari i vincoli di un'aggregazione se si eseguono query sui dati per visualizzare un report o i dati di un dashboard. Per questo tipo di requisiti è possibile avere un modello di lettura più semplice separato.

Riferimenti - Progettazione basata su domini (DDD)

Distribuzione

Il processo di distribuzione di un'applicazione ASP.NET Core prevede alcuni passaggi, indipendentemente dalla posizione in cui verrà ospitata. Il primo passaggio consiste nel pubblicare l'applicazione, operazione che può essere eseguita con il comando dotnet publish dell'interfaccia della riga di comando. Questo passaggio compila l'applicazione e inserisce tutti i file necessari per eseguire l'applicazione in una cartella designata. Per le distribuzioni da Visual Studio, questo passaggio viene eseguito automaticamente. La cartella di publish contiene i file con estensione exe e dll dell'applicazione e le relative dipendenze. Un'applicazione indipendente includerà anche una versione del runtime .NET. Le applicazioni ASP.NET Core includeranno anche i file di configurazione, gli asset client statici e le visualizzazioni MVC.

Le applicazioni ASP.NET Core sono applicazioni console che devono essere avviate all'avvio del server e riavviate in caso di arresto anomalo dell'applicazione o del server. È possibile usare un'utilità di gestione dei processi per automatizzare questo processo. Le utilità di gestione dei processi più comuni per ASP.NET Core sono Nginx e Apache in Linux e IIS o Windows Service in Windows.

Oltre a un'utilità di gestione dei processi, le applicazioni ASP.NET Core possono usare un server proxy inverso. Un server proxy inverso riceve le richieste HTTP da Internet e le inoltra a Kestrel dopo alcune operazioni di gestione preliminari. I server proxy inversi forniscono un livello di sicurezza per l'applicazione. Inoltre, poiché Kestrel non supporta l'hosting di più applicazioni sulla stessa porta, non è possibile usare tecniche come le intestazioni host per abilitare l'hosting di più applicazioni sulla stessa porta e sullo stesso indirizzo IP.

Kestrel in Internet

Figura 7-5. ASP.NET ospitato in Kestrel con server proxy inverso

Un proxy inverso può risultare utile anche in uno scenario in cui è necessario proteggere più applicazioni con SSL e HTTPS. In questo caso è necessario configurare SSL solo per il proxy inverso. La comunicazione tra il server proxy inverso e Kestrel può avvenire su HTTP, come illustrato nella figura 7-6.

ASP.NET ospitato in un server proxy inverso con protezione HTTPS

Figura 7-6. ASP.NET ospitato in un server proxy inverso con protezione HTTPS

Un approccio sempre più diffuso consiste nell'ospitare l'applicazione ASP.NET Core in un contenitore Docker che può essere ospitato in locale o distribuito in Azure per l'hosting basato su cloud. Il contenitore Docker può contenere il codice dell'applicazione in esecuzione su Kestrel e può essere distribuito in un server proxy inverso, come illustrato in precedenza.

Se l'applicazione è ospitata in Azure, è possibile usare il gateway applicazione di Microsoft Azure come appliance virtuale dedicata per offrire servizi diversi. Oltre a svolgere la funzione di proxy inverso per le singole applicazioni, il gateway applicazione può offrire anche le funzionalità seguenti:

  • Bilanciamento del carico HTTP

  • Ripartizione del carico di lavoro SSL (SSL sono in Internet)

  • SSL end-to-end

  • Routing multisito (consolidare fino a 20 siti in un unico gateway applicazione)

  • Web application firewall

  • Supporto di WebSocket

  • Diagnostica avanzata

Per altre informazioni sulle opzioni di distribuzione di Azure, vedere il capitolo 10.

Riferimenti - Distribuzione