Articoli

Hosting di una app Blazor con Umbraco, Azure DevOps e Blob Storage

da

Quante volte capita di scontrarsi con un cliente diffidente nei confronti del Cloud? Ma è sicuro? Ma quanto costa? Infatti spesso sono proprio i costi la parte difficile da giustificare in contesti di siti che vogliono essere delle banali SPA o progetti dal budget molto limitato.

In un perimetro principalmente orientato ai contenuti (con magari dietro un CMS), possiamo valutare una soluzione facile e veloce da implementare per il dev, facilmente manutenibile ma soprattutto dal costo irrisorio o addirittura gratis in alcune circostanze.

I provider cloud forniscono quasi tutti la possibilità di hostare pagine statiche. Ad esempio Azure Blob Storage, che fornisce la possibilità di attivare l’host statico già da molto tempo prima dell’uscita delle Azure Static Webapps, o Amazon S3 o ancora Github pages.

Il problema da risolvere

La domanda alla base di questa soluzione è: perché dovrei avere una web app sempre attiva e che faccia “muovere” codice lato server con tutti i costi del caso, quando devo fornire solo contenuti statici che possono essere forniti semplicemente come file?Probabilmente è perché voglio che nel backoffice il cliente sia autonomo nell’aggiornamento dei contenuti e se il sito è statico non potremo avere dietro un CMS dove andare ad aggiornare i dati. Giusto? Sbagliato!

Tempo fa ho realizzato per un cliente una soluzione del genere, con l’idea primaria di spendere poco appunto, con un CMS di backoffice (UmbracoCMS, un CMS scritto in .NET che useremo anche per il nostro POC) su un AppsService linux con piano FREE, un frontend in Vue staticizzato a build time con Nuxt.js (lo stesso si può fare tramite altri strumenti ad esempio Angular e Gridsome) rilasciato su un qualsiasi host statico (nell’esempio useremo Azure Blob Storage) e una pipeline di Build & Deploy su Azure DevOps attivata richiamando un endpoint nel momento in cui vengono aggiornati e pubblicati i dati nel backoffice.

In questo modo avremo un AppService che non paghiamo (o cmq paghiamo poco e solo per il tempo di utilizzo dei contributor di backoffice nel caso volessimo dimensionarlo in maniera più grande a scopo di alta disponibilità/particolari operazioni che debbano essere effettuate nel back) e un sito statico che paghiamo pochissimo o niente (su Azure Blob Storage pagheremmo pochissimo, su Github Pages sarebbe addirittura gratis).

Ovviamente questa soluzione ha dei grossi vantaggi, ma anche dei grossi svantaggi: si presta bene a siti di contenuti anche di ingenti dimensioni, fondamentale è che non richiedano una frequenza di aggiornamento immediata e che comunque non necessitino di essere aggiornati troppo spesso; per il fatto che ad ogni modifica deve ripartire la pubblicazione e la generazione del sito statico, sarà sempre e comunque richiesto qualche minuto per poter vedere le modifiche pubblicate in front end.

Dato che sto lavorando molto con Blazor ultimanente, ho pensato perché non valutare una soluzione del genere con un front end in Blazor?

Troverete tutti gli esempi di questa soluzione sul mio GitHub, al link https://github.com/dflorean/Daniflorex.StaticSpaHost

La soluzione

In questo articolo procederemo a creare un POC di questo tipo di soluzione cercando di risolvere alcuni problemi che emergeranno dall’utilizzo di Blazor nel front-end, primo tra tutti: al momento Blazor non ha alcuno strumento che permetta la static site generation in fase di build.

Per prima cosa, creiamo un progetto Web API che sarà il nostro backend. Nel progetto di backoffice, procediamo poi ad aggiungere il pacchetto nuget di UmbracoCMS con

dotnet add package Umbraco.Cms --version 11.1.0

per avere un boilerplate di esempio di un backoffice CMS (NDR, la versione di Umbraco su cui è stata fatta la demo è la 10.3.2, l’ultima disponibile al momento è la 11.1.0, il pacchetto per esporre i dati sul layer di API è stato testato anche su questa versione).

Startiamo il progetto, che dovrebbe partire di default sulla pagina di installazione di Umbraco, seguiamo il wizard di installazione per poi finire sulla schermata di login del backoffice, nella quale possiamo procedere ad effettuare l’accesso. Qui ci dobbiamo preoccupare di creare qualche contenuto di esempio. Nel mio caso ho creato una homepage semplice con un titolo, un testo e un’immagine.

Di default Umbraco non ci fornisce una modalità “headless” che esponga i dati salvati nel backoffice come API, tuttavia con un paio di snippet di codice (che potete trovare nel repo di demo) possiamo ottenerla mappando i tipi di dato base del CMS.

I file da integrare sono ContentApiController.cs e DataController.cs nella folder Endpoints, rispettivamente si occupano di recuperare e esporre i dati delle pagine create come API.

//reference https://github.com/deMD/UmbracoContentApi
[Route("{language}/api/content")]
public class ContentApiController : UmbracoApiController
{
    private readonly Lazy<IContentResolver> _contentResolver;
    private readonly IPublishedContentQuery _publishedContent;

    public ContentApiController(
    Lazy<IContentResolver> contentResolver, IPublishedContentQuery publishedContent)
    {
        _contentResolver = contentResolver;
        _publishedContent = publishedContent;
    }

    [HttpGet("{id:guid}")]
    public IActionResult Get(Guid id)
    {
        var content = _publishedContent.Content(id);
        return Ok(_contentResolver.Value.ResolveContent(content));
    }
}

Arrivati a questo punto dovremmo avere il nostro layer di API esposte in locale agli indirizzi:

  • “/en/api/data/content”: ci restituisce tutta l’alberatura del sito.
  • “/en/api/content/{guid}”: ci restituisce il singolo contenuto di una pagina del sito dove guid è l’identificativo della pagina

Notate “/en/” come primo segmento dell’indirizzo per la gestione del multilanguage.

Ora, nella stessa solution, possiamo procedere a creare la parte di frontend Blazor, creando un nuovo Blazow WASM project (non hosted, ma poi apriremo una parentesi su questo tema).

Procediamo successivamente a sostituire nel codice delle Pages interessate i dati recuperati dalle nostre API: per la pagina delle WeatherForecasts ci basterà sostituire la source del file json. Per la pagina Index, procediamo a recuperare i dati dall’API Umbraco e salvarli in una variabile, da cui poi potremo attingerli per renderizzarli in frontend. Nel mio esempio metterò titolo e testo nella Index.razor.

<PageTitle>Index</PageTitle>

@if (cmsData == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <h1>@cmsData.fields.title</h1>

    @((MarkupString)cmsData.fields.text)
}

<a href="/fetchdata">Fetch data</a>

<SurveyPrompt Title="How is Blazor working for you?" />

@code {
    private Root? cmsData;

    protected override async Task OnInitializedAsync()
    {
          cmsData = await Http.GetFromJsonAsync<Root>("https://localhost:44384/en/api/content/a88097c7-c966-48e9-9887-22b3f80440d6");
    }

    public class Fields
    {
        public string title { get; set; }
        public string text { get; set; }
    }

    public class Root
    {
        public System system { get; set; }
        public Fields fields { get; set; }
    }

    public class System
    {
        public string id { get; set; }
        public string name { get; set; }
        public string urlSegment { get; set; }
        public string type { get; set; }
        public DateTime createdAt { get; set; }
        public DateTime editedAt { get; set; }
        public string contentType { get; set; }
        public string locale { get; set; }
        public object url { get; set; }
    }
}

A questo punto se abbiamo fatto tutto correttamente, possiamo startare il progetto Blazor e dovremmo trovarci col classico boilerplate di Blazor.

Fino ad ora si è trattato solo di setuppare un ambiente Client-Server di base come ormai siamo abituati a fare da anni, ma a questo punto inizia la parte decisamente interessante: ovviamente ad ogni reload dei component, viene effettuate una nuova chiamata alle API con conseguente refresh del componente razor e dei dati, lo potete vedere semplicemente nella pagina del meteo che produce ad ogni caricamento dei dati randomici.

Prerenderizzare le pagine Blazor

A noi però interessa ottenere un sito statico, o perlomeno qualcosa di simile, qualcosa che sia prerenderizzato e soprattutto non abbia più bisogno di accedere a delle API per recuperare i dati che gli servono. Premetto subito, purtroppo al momento non esiste appunto ancora nulla che generi una reale pagina statica HTML in fase di build per Blazor, ma possiamo avvicinarci molto alla soluzione che ci interessa con un paio di “trucchetti” e capendo anche un po’ meglio come funziona il rendering di un componente Blazor nel caso ci troviamo in un’app WASM.

Di default, un componente Razor inserito in un contesto Blazor WASM, viene renderizzato ogni volta in maniera JIT (just in time), sfruttando appunto il layer WASM, che si occupa di far girare il codice in Intermediate Language prodotto dalla publish necessario per il rendering dello stesso. Il problema di questa modalità (e comunque un problema comune di tutti i framework che permettono di produrre SPA), è che ovviamente la SEO non ha modo di sapere come è costruita la nostra site map, ma vede solo la nostra pagina “nuda” non renderizzata con i placeholder dei componenti, ossìa tutto cioè che esterno al componente App che invece è la parte dinamica; in pratica quello che noi possiamo vedere nell’index.html sotto wwwroot (nel caso di Blazor un bel “Loading”).

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
    <title>Daniflorex.StaticSpaHost.Frontend</title>
    <base href="/" />
    <link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
    <link href="css/app.css" rel="stylesheet" />
    <link href="Daniflorex.StaticSpaHost.Frontend.styles.css" rel="stylesheet" />
</head>

<body>
    <div id="app">Loading...</div>

    <div id="blazor-error-ui">
        An unhandled error has occurred.
        <a href="" class="reload">Reload</a>
        <a class="dismiss">🗙</a>
    </div>
    <a href="http://_framework/blazor.webassembly.js">http://_framework/blazor.webassembly.js</a>
</body>

</html>

L’architettura di Blazor e una delle comodità dei Razor Components di .NET ci verrà così in aiuto: grazie al fatto che i nostri componenti sono integrabili sia in applicazioni web app classiche server con razor pages, o in applicazioni Blazor Server e Blazor WASM, possiamo decidere di far effettuare il prerendering dei nostri componenti Blazor quando siamo in un contesto WASM, quindi totalmente lato client (nel nostro caso ci interesserà farlo per tutta l’App, che è essa stessa un componente padre di tutti gli altri), da un host lato server e farci restituire la pagina già prerenderizzata in modo che i crawler la vedano già completa e possano scansionarla. Il concetto è lo stesso che c’è dietro al creare una app WASM “hosted”, che prevede appunto oltre alla parte client una parte server che è quella che effettivamente effettua il rendering dell’applicazione.

Se notate le differenze, l’app “hosted” ha nella parte server appunto il file _Host.cshtml, che è quello dove possiamo includere la nostra App per farla prerenderizzare lato server.

Come si fa questa cosa? Rimando a due link sul sito microsoft per approfondire l’argomento

https://learn.microsoft.com/en-us/aspnet/core/blazor/components/prerendering-and-integration?view=aspnetcore-7.0&pivots=webassembly

Il tipo di rendering va specificato nell’attribute render-mode del component.

Abbiamo 5 diversi tipi di rendering, che sono i seguenti

  • Server: Renders a marker for a Blazor server-side application. This doesn’t include any output from the component. When the user-agent starts, it uses this marker to bootstrap a Blazor application.
  • ServerPrerendered: Renders the component into static HTML and includes a marker for a Blazor server-side application. When the user-agent starts, it uses this marker to bootstrap a Blazor application.
  • Static: Renders the component into static HTML.
  • WebAssembly: Renders a marker for a Blazor WebAssembly application. This doesn’t include any output from the component. When the user-agent starts, it uses this marker to bootstrap a Blazor client-side application.
  • WebAssemblyPrerendered : Renders the component into static HTML and includes a marker for a Blazor WebAssembly application. When the user-agent starts, it uses this marker to bootstrap a Blazor client-side application.

Se decidiamo di utilizzare la modalità “WebAssemblyPrerendered”, modificheremo quindi il funzionamento del rendering dell’app in modo che venga prerenderizzata e servita dal server (ovviamente in attesa che l’app venga idratata in background scaricando tutti i file necessari compilati per WASM).

Questo approccio ha dei vantaggi:

  • Caricamento iniziale istantaneo del contenuto della pagina mentre l’applicazione WebAssembly viene scaricata e idratata in background
  • Analisi del markup statico da parte dei motori di ricerca (SEO) e strumenti che non visualizzano app WebAssembly e SPA

Ma anche alcuni svantaggi:

  • Non puoi più ospitare la tua app su un file hosting statico (necessita di un server che esegua il prerendering)
  • È richiesto un approccio diverso al componente dato che non può essere eseguita nessuna chiamata JS Interop nell’OnInitialized(). Potrebbe essere un problema con componenti di terze parti che utilizzano JS, ma non influisce sul rendering non effettuato nel browser.
  • Doppio rendering, una volta sul server, una volta sul client. Necessita di interventi aggiuntivi per preservare lo stato al fine di evitare chiamate duplicate e scambio di contenuti client-server (quello che vedremo tra poco).
  • Il rendering lato server non supporta l’autenticazione, quindi funziona solo su pagine pubbliche. Per un’esperienza ottimale, le parti di contenuto specifiche per lo stato non autenticato/autenticato devono utilizzare dei placeholder fino a quando non viene deciso quale sarà il contenuto stesso in base allo stato di autenticazione.

Inoltre, non abbiamo risolto il nostro problema: generare una sorta di html “statico” al momento di buildare il progetto. Però la strada è giusta, dato che prerenderizzando il componente iniziamo ad avere una sorta di pagina HTML già pronta che dobbiamo solo trovare il modo di crawlare e salvare come HTML.

Un piccolo aiuto dal web

Inizialmente ho provato a sfruttare degli staticizzatori, come ad esempio react-snap o comunque qualche maniera di salvare la pagina prerenderizzata in HTML. L’approccio presentava diversi problemi, ma dato che chi cerca trova, ho infine trovato un pacchettino fantastico che poteva essere proprio perfetto per i miei scopi: BlazorWasmPreRendering.Build. Il pacchetto è tuttora in preview ma fa il suo dovere e il creatore è un dev veramente in gamba con cui ho avuto qualche scambio per riuscire a ottenere quello che mi serviva dal suo pacchetto: https://github.com/jsakamoto/BlazorWasmPreRendering.Build

Il pacchetto si occupa esattamente di fare quella parte di prerendering lato server spiegata fino ad ora, con l’aggiunta di salvarsi il prerenderizzato su delle pagine singole in HTML statico divise per folder secondo la struttura dei link trovati in pagina, principalmente a scopo SEO, il tutto nel momento della publish dell’app. In sostanza per un sito prova blazor WASM, installando questo pacchetto ed effettuando una publish ci troveremo nella folder di output la pagina index.html prerenderizzata, con una cartella per ogni link del menu con dentro a sua volta una pagina index.html contentente lo statico dei componenti presenti in quella pagina.

Una volta completata la publish, potremo testare in maniera molto semplice il risultato in locale con il comando dotnet serve. Se non lo avete mai usato, potete installarlo con “dotnet tool install –global dotnet-serve” da riga di comando. Una volta lanciato nella folder di output della publish, si occuperà di lanciare un semplice http server che serva tutti i file nella cartella.

Anche se il pacchetto sembra perfetto per il nostro scopo, ci sono un paio di problemi che dobbiamo risolvere per poter arrivare ad avere un risultato statico ma soprattutto “offline” che non abbia bisogno di accedere alle API.

Il primo problema è che il risultato statico prodotto dal pacchetto una volta servito come sito, tenta ancora l’accesso alle API una volta che completato il download e il render (idratazione) delle parti webassembly, proprio per il funzionamento del prerender spiegato qualche riga qui sopra.

Il secondo problema è che in ogni caso il routing lato client di blazor WASM ha un funzionamento tale per sua stessa natura da non effettuare il caricamento della pagina linkata come fosse un nuovo documento html ma va a caricare ovviamente solo le parti dei componenti che devono essere aggiornate all’interno della pagina senza effettuarne un refresh.

Per risolvere questi problemi, ho collaborato con il creatore del pacchetto e anche grazie al suo aiuto, abbiamo trovato una soluzione che permette di sfruttare il pacchetto per ottenere una sorta di sito staticizzato. Dico “una sorta” perché non si tratta al 100% di avere l’HTML prerenderizzato statico, ma comunque permette di avere un HTML statico, che sia deployabile su servizi di host statici e che non necessiti più di accedere alle API.

La prima cosa da considerare è che, aperta la pagina prerenderizzata, una volta completata l’idratazione dell’app in background tutto il codice del componente verrà rieseguito e così si perderanno anche i dati prerenderizzati e verrà rieffettuata la chiamata all’API. Dobbiamo quindi preservare lo stato dell’app prerenderizzata in qualche modo, in questo ci viene in aiuto .NET 6 con la feature “persisting component state”.

Linko una paragrafo di approfondimento sull’argomento:

https://learn.microsoft.com/en-us/aspnet/core/blazor/components/prerendering-and-integration?view=aspnetcore-6.0&pivots=webassembly#persist-prerendered-state

Il Tag Helper

<persist-component-state />

ci permette di salvare in pagina lo stato del componente prerenderizzato, in modo da poterlo recuperare nell’OnInitialized e poterlo ripristinare nel momento in cui viene rieseguito il rendering del componente.

Dannato Routing

Questo ci permette quindi di non dover più accedere alle API…ma non è ancora abbastanza, in quanto mi sono scontrato con un altro ostacolo: a questo punto, una volta caricata la pagina prerenderizzata e reidratata l’app, i dati vengono effettivamente presi dal persistent component state, ma il problema è che una volta cliccato su qualche link e richiamato il rendering di altri component presenti in altre pagine, essi non avevano il loro state persistito in pagina e quindi riprovavano a fetchare i dati dalle API.

Ci è voluto un po’ a capire il perché e il motivo è dato dal funzionamento stesso di Blazor e del suo routing. Lo stato viene salvato in una lunga stringa nel tag Blazor-Component-State encodata in base64 all’interno della pagina stessa per i componenti presenti nella pagina; quando clicchiamo su qualche link il routing di blazor si occupa di richiamare il nuovo component e non far effettuare il refresh di tutta la pagina…vedete già dov’è il problema? Il nuovo componente richiamato non ha i suoi dati persistiti nella pagina prerenderizzata, ma i suoi dati sono stati persistiti nella relativa pagina statica html prerenderizzata dal pacchetto.

Per questo problema ci possono essere diverse soluzioni. La più semplice è intercettare la navigazione del routing di Blazor, fermarla e rilanciare un NavigateTo verso la pagina corretta (quella che contiene lo stato persistito per il componente che vogliamo richiamare). Una soluzione funzionale ma che causa ovviamente il reload dell’intera pagina.

Ho approcciato invece, su suggerimento del creatore del pacchetto, un altro metodo: wrappare l’oggetto e i metodi del persistent component state in un servizio in modo che per il recupero possa fetchare la pagina statica contenente il componente con una chiamata HTTP locale, matchare la stringa dei dati persistiti in pagina tramite regex e restituirli al componente.

Questo ci permette di recuperare i dati persistiti nella pagina nel momento in cui il componente viene caricato, rendendo quindi la nostra API non più necessaria. Possiamo accorgerci della differenza provando a caricare e refreshare più volte la pagina delle WeatherForecasts: i dati sono sempre gli stessi, segno che non vengono più recuperati dall’API.

Ottimo, a questo punto abbiamo raggiunto il nostro obbiettivo di avere il nostro sito semi statico che non ha più bisogno di accedere alle API, creato in fase di build.

Build e deploy automatico

Per finire, ci manca solo l’ultimo step, ovvero triggerare la build del front-end nel momento in cui vengono effettuate modifiche/pubblicazioni nel backoffice. Non entrerò nel merito di Umbraco stesso e di come si possa fare questa cosa (bisogna intercettare l’evento di salva e pubblica, potete comunque vedere l’esempio su GitHub), ma entrerò invece nel merito di come possiamo triggerare la build su Azure in maniera programmatica.

Azure DevOps è dotato di un layer di API http esposte anche per le pipeline di BUILD, quindi ci basterà semplicemente fare una chiamata http POST all’endpoint delle API avendo cura di inserire i parametri corretti e farci generarci un token di accesso per la nostra applicazione.

Potete approfondire l’argomento a questa pagina

https://learn.microsoft.com/en-us/rest/api/azure/devops/pipelines/runs/run-pipeline?view=azure-devops-rest-7.0

L’indirizzo base è questo

https://dev.azure.com/{organization}/{project}/_apis/pipelines/{pipelineId}/runs?api-version=7.0

I parametri che ci interessano sono

  • Organization – il nome della nostra organizzazione che possiamo recuperare nella nostra home di DevOps
  • Project – il nome del progetto che contiene la pipeline che vogliamo far partire
  • PipelineId – l’id della pipeline che vogliamo far partire. Questo possiamo recuperarlo facilmente verificando l’id presente nel parametro definitionId nell’url della pagina della nostra pipeline.

Oltre a questo ci serve aggiungere un Basic Authentication Header alla chiamata, il cui valore dev’essere un Azure personal access token (ricordiamoci encodato anche lui in base 64).

Per generare un personal access token vi rimando a questo link (attenzione a dare correttamente e in maniera conservativa gli scopes, sia per cosa può fare sia relativamente a quali progetti)

https://learn.microsoft.com/it-it/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=Windows

Una volta settato correttamente l’endpoint basterà chiamarlo per far sì che venga triggerata la build del frontend, e in seguito anche deployata (qui potete usare anche ciò che preferite, io ho creato una Release pipeline che parte in seguito al completamento della build and publish e si occupa semplicemente di copiare i file ottenuti sul nostro Blob storage che hosta i file html statici).

Conclusioni

Con questo ultimo passaggio abbiamo completato il giro, in questo modo ogni volta che verrà fatta e pubblicata una modifica nel backoffice, verrà triggerata la publish and deploy del frontend e aggiornati in automatico i contenuti. Ovviamente la cosa non è immediata, dobbiamo aspettare qualche minuto, il tempo appunto che vengano completate le pipeline.

Come già detto in precedenza, è una soluzione da prendere in considerazione per scenari specifici, dove non è importante la velocità di delivery delle modifiche effettuate in backoffice e in cui il backoffice (che è cmq una webapp che deve essere attiva), è utilizzato sporadicamente e da poche persone, mentre il frontend presenta più che altro contenuti da visualizzare (perché no, anche ingenti quantità).

Scritto da: