28 novembre 2021 14:00
Nuovo mini sito per fare pratica con .NET 6 e affini
TL;DR
Come spesso accade (meteo-altoadige.it, geo-italy.it), per impratichirmi su una nuova uscente tecnologia (.NET 6 in questo caso) e soprattutto per non arrugginirmi nel campo della programmazione full-stack, ho dedicato parecchie sere e qualche week end alla costruzione di questo nuovo mini-sito:
Qui il diario di qualche esperienza fatta durante lo sviluppo:
.NET 6 e VS2022
Ho iniziato a lavorarci qualche sera verso settembre scaricando le prime preview di entrambi e sono rimasto molto colpito dal fatto che per tutto l’iter di sviluppo siano stati ambienti comunque stabili e senza grosse breaking-changes tra una release e l’altra
Bye Bye startup.cs
La prima grossa novità sta nell’eliminazione dello startup.cs e nella semplificazione della gestione dei middleware. Prima era abbastanza noioso doversi ricordare in quale dei due file dovevi mettere le cose; ad esempio la gestione dei Log che io faccio con Seq e Serilog adesso per lo meno è tutta in un posto solo.
Minimal Api
Una delle più grosse novità di .net sono le Minimal Api, un modo nuovo e più “semplice” di creare servizi REST senza dover usare la classica modalità dei Controller dei siti Asp.Net Core WebApi. Dicono lo abbiano fatto per venire incontro a devs che arrivano da node.js o che si approcciano a .NET ex-novo. Sicuramente per il progettino piccolo o se la solution ha bisogno sporadicamente di api è una figata (come in questo mio caso), devo invece ancora capire bene come potrebbe essere sfruttato e usato a dovere in un’architettura complessa dove le API sono la fonte primaria di dati verso il client (es un’applicazione Blazor Web Assembly).
Rimango sempre un po’ perplesso dall’andazzo che sta prendendo C# e .net in generale a prediligere la compattezza del codice versus la sua leggibilità. È vero che io (oltre a essere anziano e diventare quindi più conservatore) non programmo più 8 ore al giorno e quindi semplificare alcuni pattern per chi li scrive 100 volte al dì è un vantaggio, ma si arriva ad alcune esasperazioni che non mi trovano molto d’accordo. Qui comunque un esempio del contenuto del program.cs del progetto api.
var app = builder.Build();
app.MapGet("/", () => "Minimal API World for mtb.rizzetto.com!");
app.MapGet("/tours/{id}", async (int id, IMtbTourService svc) =>
await svc.GetAsync(id)
is MtbTour tour
? Results.Ok(tour)
: Results.NotFound());
File Scoped Namespaces
Da così
namespace RizSoft.RizzettoCom.Mtb.UI.Web.Pages
{
public class ArticlesModel : PageModel
{
…
}
}
A così
namespace RizSoft.RizzettoCom.Mtb.UI.Web.Pages;
public class ArticlesModel : PageModel
{
…
}
Togliere due { } e sostituirle con un ; ed evitare un’indentazione di tutto il codice; ok carino, ma adesso datemi un tool che lo faccia automaticamente su tutto il progetto!
Implicit global using directives (aka Global Using)
Più comodo invece avere un posto centralizzato per le using più comuni per non doverle andare a mettere in ogni singolo file (idea non nuova visto che già ci faceva con il file _imports.razor per le blazor/razor pages).
Da notare che vale solo per i progetti C# only (quindi le nostre class library di models, services, dal, biz, ecc.) o per le console application che adesso diventano veramente scarne (C# 10 toglie anche la classe Program, il metodo Main, ecc.).
Come abilitarlo (di default sui progetti .Net 6 è attivo ma non sui porting)? Intanto settare la direttiva <ImplicitUsings>a enable nel file .csproj (probabilmente si fa anche dalla UI delle Properties del progetto) e poi è possibile mettere le using che vogliamo globalizzare o sempre dentro il csproj, strada che io preferisco, oppure con la notazione global:: dentro il primo file che ci capita (e non mi ricorderei mai il file che ho battezzato come "Master")
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Using Include="Microsoft.EntityFrameworkCore" />
<Using Include="RizSoft.RizzettoCom.Mtb.Data" />
<Using Include="RizSoft.RizzettoCom.Mtb.Models" />
</ItemGroup>
oppure
global using Microsoft.EntityFrameworkCore;
global using RizSoft.RizzettoCom.Mtb.Data;
global using RizSoft.RizzettoCom.Mtb.Models;
Css Isolation
La possibilità di definire un CSS che vale solo per una determinata pagina mi è venuta molto utile visto che molti contenuti provenivano da fonti diverse e con tag e classi che nel sito di origine avevano stili non consoni. È stato sufficiente quindi creare unoo stylesheet con lo stesso nome della razor page (VS lo raggruppa correttamente come fa con il .cs) et voilà... es. una definizione di H2 completamente diversa da quella globale viene applicata solo a quella pagina.
Nota: non so perché ma io ho dovuto linkare un file css "virtuale" nel _Layout, file che si deve chiamare come il nome progetto. Questa cosa non dovrebbe servire nella RTM ma probabilmente a me si era incartato qualcosa proveniente dalle preview o RC
Hot Reload
Un'altra novità di cui si è sentito tantissimo parlare. Sia per Blazor che per gli Asp.Net website è ora disponibile una comoda feature che velocizza molto lo sviluppo. Si edita il codice, si salva e la pagina (dopo la compilazione) si autoaggiorna (occhio solo se avete una macchina lenta o un progetto grosso, in quel caso non conviene attivarlo sul File Save).
Se devo essere sincero ogni tanto mi ha fatto cilecca, ma quando va -spesso- è una figata :-) Soprattutto in Blazor è comodissimo che tenga lo stato della pagina nel momento del cambiamento. Esempio, classica pagina demo del counter, clicco 4 volte, faccio una modifica, la pagina si ricarica con in cambiamenti (di stile, di html, di logica c#) ma il contatore resta a 4 e non riparte da 0! Pretty Impressive!
Così come mi meraviglia sempre il reload bollente se editiamo un css. In questo caso infatti NON occorre neppure salvare! Appena faccio un cambiamento ad una classe viene subito applicato alla app nel browser.
Partial View vs View Component vs Blazor Component
Come sappiamo se abbiamo parti di UI che vogliamo riutilizzare su più pagine abbiamo diverse opzioni.
Le Partial View non hanno un loro codebehind e quindi se devono lavorare con i dati usano il Model passato dalla View chiamante. Possono quindi andare bene per semplici pezzi di html dove renderizzano l'oggetto o sue proprietà passate.
Es. una partial che renderizza una lista di tour (che ho in 3 pagine diverse) potrebbe essere
<partial name="_ToursList" model="@Model.MtbTours" />
oppure una che crea uno slider/header passando una stringa fissa (sintassi non proprio semplice da ricordare):
<partial name="_HeaderSlider" model="@("header_tourtop.jpg")" />
Lo "svantaggio" se vogliamo è che non possiamo renderlo autosufficiente, andando ad esempio sul Db a recuperare qualche informazione, ma dobbiamo sempre farlo da una view e poi passargli i dati.
In questo caso molto meglio usare un View Component che ha il suo code-behind col suo model analogamente ad una View o Razor Page. Qui un esempio di VC che mi sono fatto per recuperare e renderizzare un blocco di "contenuto" che gestisco in un mio backoffice.
<vc:page-intro id="TRAVEL"></vc:page-intro>
NB: qui sotto ho messo sia l'attributo di decorazione e la classe da cui ereditare...basta uno dei due metodi per dire al motore di ASP.NET che quello è un view component.
[ViewComponent]
public class PageIntro: ViewComponent
{
private readonly IContentService _contentService;
public PageIntro(IContentService contentService)
{
_contentService= contentService;
}
public async Task<IViewComponentResult> InvokeAsync(string slug)
{
var intro = await _contentService.GetIntroAsync(slug);
return View(intro);
}
}
La cosa più "difficile" è capire cosa sia il KebabCase (!) termine che ho letto per la prima volta e che indica, come si vede nell'esempio, che se il nome del nostro VC è MyComponent, il tag helper diventa minuscolo e con un "-" quando trova la maiuscola (quindi my-component)
Ricordarsi infine di aggiungere al file _ViewImports.cshtml l'addTagHelper del nostro namespace.
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, RizSoft.RizzettoCom.Mtb.UI.Web
Infine i Blazor Component che all'inizio prima di scoprire i VC e di leggere questo post avevo iniziato ad usare al loro posto. Si tratta in pratica di scrivere (o nel mio caso di riutilizzare uno già scritto) un razor component così come faremo in un progetto Blazor Server Side o Web Assembly e poi di chiamarlo nella nostra View con questa sintassi
<component type="typeof(RizSoft.RizzettoCom.Mtb.UI.Web.Components.TourFinder)" param-RegionId="BZ" render-mode="Server" />
Il renderMode può essere Static, Server o ServerPrerendered esattamente come abbiamo nelle nostre Blazor Apps; il nome del parametro del nostro component deve essere preceduto da "param-". Infine, se usiamo le razor pages invece che MVC, come si vede sopra aggiungere l'addTagHelper di MVC.
Perché alla fine bisogna usarli cum grano salis? Innanzittutto ricordiamoci che se scriviamo un componente ServerSide non possono usare il DbContext nello stesso modo dei siti asp.net core (hanno bisogno di un DbContextFactory e di Scope e Lifetime diversi) e poi bisogna fare i conti con il Disconnected State di SignalR. Per quelli client only WebAssembly invece potrebbe essere comodo soprattutto se come me si è ignoranti di Angular, Vue, React, ecc.
Le cose ovviamente si possono usare insieme...in questa pagina ad esempio li uso tutti e 3:
EF Core 6
Ovviamente il mio progetto usa un'architettura 3-tier (models, services, ui) e visto che i dati molto spesso esistevano già, così come i loro database, giocoforza ho usato l'approccio database-first facendo scaffolding per generare le entities. Per non avere più datacontext ho pensato di fare un database che avesse zero tabelle e SOLO Views e di centralizzare in quel db la fonte dati. Ho quindi fatto reverse-engineer di questo DB tramite il sempre ottimo EF Core Power Tools di ErikEJ (che preferisco a quello di EF6) e usato il mio solito BaseService<T, Tkey> generico per accedere ai dati.
Unica accortezza è che la GetAsync(id) che nella mia classe base usa la FindAsync(id) di EF/Linq, con le view di sql non funziona in quanto queste non hanno una chiave primaria. Il tool infatti correttamente setta entity.HasNoKey() nel ModelBuilder della Entity.
Siamo quindi costretti a non usare la FindAsync, ma la cara e vecchia Where + FirstOrDefaultAsync (o SingleOrDefaultAsync se siamo sicuri che ci ritorni un solo record).
public async Task<Content> GetAsync(int id)
{
return await Context.Contents.Where(x => x.IdContent == id).FirstOrDefaultAsync();
}
public async Task<Content> GetAsync(int id)
{
return await Context.Contents.FindAsync(id); // doesn't work with sql view or table without PK
}
UI stuff
Se fullstack developer ti vuoi definire, sporcarti la mani con css devi... :-( Purtroppo perdo ancora un sacco di tempo a far funzionare le cose lato html/css: pur avendo oggi il framework Bootstrap 5 raggiunto una notevole flessibilità e affidabilità ogni volta che ci sono piccoli aggiustamenti che devono funzionare sui 6 breakpoint xs/sm/md/lg/xl/xxl (smartphone portrait/landscape, tablet portrait/landscape, desktop lowres/hires) divento scemo.
Un esempio di dove ho perso qualche ora sono le classi Order delle grid di BS5
Volevo infatti ottenere per il desktop un'alternanza foto/testo a destra e sinistra, mentre per device mobile la foto doveva sempre essere al primo posto SOPRA il testo.
Dopo svariate bestemmie a capire perché il breakpoint XS non funzionasse (fino al SM andava tutto) ho letto una frase illuminante che dice pressapoco "Bootstrap ragiona sempre con il mobile come priorità, quindi setta il default per quello e poi fai le eccezioni per risoluzioni maggiori". Ed in effetti facendo la colonna della foto come default a order-1 e poi cambiando l'ordine quando lo vogliamo far scattare la cosa funziona. BTW la order-first fa in pratica un order-0, ma ho visto che talvolta usare i numeri è meglio, non vorrei che ci fosse sotto un bachetto insidioso di qualche browser.
<div class="row mb-5">
<div class="col-12 order-first">
<h2>Title of the Block</h2>
</div>
<div class="col-md-6 order-2 order-md-1">
<p>Lorem ...</p>
</div>
<div class="col-md-6 order-1 order-md-2"> <!-- default la foto è prima, da md in poi è a destra, quindi 2^ -->
<img src="images/placeholder.png">
</div>
</div>
Infinite Scrollable List
La lista dei tour completati, come si vede ha molti record e non volevo mettere un antico e anti-estetico Pager. Il concetto di Infinite List che "fetcha" i dati mano a mano che scrolliamo è ovviamente oramai un pattern strausato e che probabilmente ogni programmatore di web conosce e implementerà a occhi chiusi.
Io per poterlo implementare senza dover ricorrere a tonnellate di codice JS, ho usato questo framework chiamato htmx e soprattutto i suggerimenti di questo post a cui è giusto dare i meriti.
La pagina quindi chiama una partial view a cui viene passata la lista tour eventualmente filtrata
<div class="row">
<partial name="_ToursList" model="@Model.MtbTours" />
<div id="loading-indicator" class="m-3">Carico altri Tours...</div>
</div>
La partial view utilizza il metodo hx-get di htmx prendendo i primi N tour e mano a mano gli altri a chunk di N
@model TourCollection
@if (Model.Tours.Any())
{
@foreach (var tour in Model.Tours)
{
//render Tour
}
<div hx-get="@Url.Page("", "LoadTours", new {after = Model.Tours.Last().IdTour})"
hx-trigger="revealed"
hx-swap="outerHTML"></div>
}
else
{
<p class="text-center">No more Tours...</p>
}
Ovviamente è nostro compito gestire il Take e Skip come si fa di solito
-- nella View
public async Task<IActionResult> OnGetLoadTours(int after)
{
var nextTourCollection = new TourCollection();
nextTourCollection.Tours = await _mtbTourService.ListAfterAsync(after, pageSize);
return Partial("_ToursList", nextTourCollection);
}
-- nel servizio
public async Task<List<MtbTour>> ListAfterAsync(int after, int pageSize)
{
return await Context.MtbTours
.Where(x => x.IdTour < (after == 0 ? Int32.MaxValue : after))
.OrderByDescending(x => x.TourDate)
.Take(pageSize)
.ToListAsync();
}
Il backoffice
È ovvio che per chi come me ha scritto il primo CMS su un 486 con notepad (metà degli anni 90!) pensare di mettere il contenuto direttamente dentro le pagine cshtml è follia. Ho quindi arricchito di alcune funzioni la mia admin app scritta in Blazor che già uso per il sito principale.
Una cosa che mi mancava e di cui non avevo ancora avuto bisogno era un editor HTML stile il vecchio CKEditor o simili. Stranamente la suite di controlli che usiamo in progetti al lavoro (Devexpress) non ne ha ancora implementato uno e il workaround di usare il loro componente DevExtreme JS mi incasinava un po' la vita.
TinyMCE
Ho invece trovato un comodo wrapper blazor del famoso TinyMCE che è si diventato un plug-in business a pagamento, ma che nella versione free ha dei limiti che per me sono sopportabili.
Qui ci sono tutti i dettagli implementativi e devo dire che è stato abbastanza semplice inserirlo all'interno di una data grid sotto l'handler di click sulla riga (ho preferito metterlo sotto e non aprendo il DisplayTemplate della singola Row, ma si sarebbe potuto benissimo fare).
Conclusioni
Probabilmente durante lo sviluppo mi sarò imbattutto in altre mille rognette che mi sono dimenticato di segnare.
Come spesso dico, il lavoro di uno sviluppatore è paragonabile a quello di un artista che modella la sua opera passo per passo, continuando a rifinirla, modificarla, migliorarla (in pessimo gergo “refactorandola”) senza mai essere soddisfatto al 100%. Durante questo progetto, nelle svariate sere o week end passate sul codice, mi sentivo come quegli appassionati di auto storiche che partono da un telaio grezzo vuoto e poi con grande passione dedicano gli sprazzi del loro tempo libero a restaurare e completare il loro gioiellino. Per me è stata la stessa cosa (dato che non uso CMS ma mi scrivo tutto da solo dal backend alla UI), oltre che una palestra mentale per tenermi in allenamento e aggiornato.
Un ringraziamento speciale ad Alessandra, l'unico ingegnere che conosco che ha un notevole gusto estetico, bravissima anche come graphic designer e che mi aiuta sempre a non far pasticci con i loghi.
Ma in fondo in fondo non si tratta che di un omaggio ad una delle mie passioni e hobby che insieme all’informatica e alla fotografia hanno riempito la mia vita.