Principi di architettura client

Questa pagina descrive come deve essere strutturato un client, non il formato dei messaggi scambiati (vedi Modello dati e le pagine seguenti per quello).

Principio: un datastore unico detiene la verità

Il client mantiene un’unica struttura di dati che rappresenta lo stato corrente (moduli → proprietà → elementi). Tutta la UI legge questa struttura; nulla altro la modifica se non i messaggi ricevuti dal server.

Lettura   :  Datastore ────────────► UI

Scrittura :  UI ─── SV/SA ───► Server ─── update ───► Datastore
  • Nessuna duplicazione: se un componente ha bisogno di un valore, lo legge dal datastore, non lo conserva per proprio conto.
  • Nessuna scrittura diretta: la UI non modifica mai il datastore da sola, nemmeno per una visualizzazione ottimistica.
Scrittura: sempre tramite il server

Un’azione dell’utente (cambiare un valore, cliccare un pulsante) attiva l’invio di un messaggio al server (SV/SA — vedi Client → Server). Il datastore locale cambia solo in risposta, quando il server risponde con l’aggiornamento confermato. Il client non presume mai il risultato.

Principio: nessuna dipendenza da internet

Un server OST gira spesso su una rete locale isolata — un sito osservativo remoto senza accesso a internet è un’installazione normale, non un caso limite. Un client deve funzionare correttamente in questa situazione: ogni risorsa di cui ha bisogno deve essere inclusa nel client stesso, non recuperata da un CDN o un altro servizio esterno a runtime.

Questo riguarda direttamente il rendering delle icone: preicon1/preicon2/posticon1/posticon2/preicon/posticon forniscono un nome di icona Google Fonts (Material Icons), non un URL — un client deve includere localmente il font/le glyph delle icone (vendorizzati, autoospitati, o inclusi in fase di build) invece di caricare il foglio di stile del CDN di Google, che smette silenziosamente di funzionare nel momento in cui non c’è più una via verso internet.

Lo stesso vale per qualsiasi altra cosa che un client potesse essere tentato di prelevare da un CDN: un framework JS, una libreria di grafici, un web font per l’interfaccia stessa. Se serve al client per funzionare, viaggia insieme al client.

Raccomandazione forte: supportare almeno un tema scuro e uno chiaro

I client OST vengono usati comunemente all’oculare o accanto a un telescopio, di notte — un’interfaccia a piena luminosità, con sfondo bianco, lì non è solo una discordanza stilistica: rovina attivamente la visione notturna dell’operatore. Un client dovrebbe offrire come minimo sia un tema scuro sia uno chiaro, senza fissarne uno in modo rigido nel codice.

Questo non fa parte del protocollo di comunicazione — qui non c’è nulla che sia imposto o anche solo visibile lato server — ma è una raccomandazione forte per qualsiasi cosa pensata per essere usata in vere sessioni di astrofotografia, non solo come demo o strumento di test da scrivania.

Dump completo vs aggiornamenti parziali

Alla connessione, il client invia DU; il server risponde con un dump completo (d) contenente tutti i moduli con i loro metadati completi (label, tipo, min/max, LOV, ecc.).

Gli aggiornamenti parziali portano solo il valore

I messaggi che arrivano successivamente (ee, ea) contengono solo il valore, non i metadati. Il client deve:

  1. Conservare i metadati dell’ultimo dump completo.
  2. Su un aggiornamento parziale, applicare la patch solo al valore, senza mai supporre che i metadati siano cambiati.

Se i metadati stessi cambiano (min/max/formato di un elemento), il server lo segnala esplicitamente tramite un messaggio distinto, ev (vedi Server → Client) — questo non è mai silenzioso. Il client deve quindi solo reagire al tipo di messaggio ricevuto, senza doverla indovinare cosa è cambiato.

Trappola conosciuta: gli aggiornamenti di GlobalLOV

Quando una proprietà fa riferimento a una LOV globale e questa cambia, il server invia l’aggiornamento senza il campo value. Il client non deve mai preservare il vecchio valore in questo caso preciso — bisogna applicare esattamente ciò che il server invia, inclusa l’assenza di valore. Il server fa fede; non tentare mai di “riempire” un campo mancante con il vecchio stato locale.

Rendering: limitare gli aggiornamenti alla proprietà modificata

Obbligatorio: mai un re-render più ampio della proprietà modificata

Alcuni moduli possono inviare più aggiornamenti ee/ea al secondo (ad esempio un modulo che interroga continuamente un dispositivo). Un client che reagisce a ogni aggiornamento in arrivo ridisegnando tutta la sua UI — o anche solo l’intero modulo visualizzato — diventa inutilizzabile sotto questo carico: perdita del focus di digitazione, sfarfallio, e nel peggiore dei casi l’UI smette del tutto di rispondere agli input.

Non è un caso limite da gestire “per precauzione”: è un comportamento reale e attuale del backend, e i client devono essere costruiti per gestirlo fin dall’inizio:

  1. Ad ogni aggiornamento, deve essere sostituito/mutato solo l’oggetto JS/dati della proprietà modificata. Il riferimento di ogni altra proprietà deve rimanere esattamente identico finché non è coinvolta.
  2. Il rendering della UI deve essere indicizzato sull’identità della proprietà (o del modulo) — un elenco di proprietà dovrebbe riutilizzare lo stesso elemento UI per la stessa proprietà tra un rendering e l’altro, senza mai distruggerlo e ricrearlo, così un campo non coinvolto non viene mai toccato, qualunque cosa succeda altrove.
  3. Limitare solo la frequenza dei rendering (debounce/throttle) non basta se il rendering stesso continua a ricostruire UI non coinvolta — la soluzione sta nel perimetro di ciò che viene ricostruito, non solo nella sua frequenza.

Rooster (il frontend di produzione) implementa questo principio per costruzione: un aggiornamento in arrivo sostituisce solo il riferimento della proprietà coinvolta nello store, e ogni elenco di proprietà viene renderizzato con una chiave stabile per proprietà. Il risultato è che la UI delle proprietà non coinvolte non viene mai disturbata, indipendentemente dalla velocità con cui un’altra proprietà si aggiorna altrove.

Template: schermate per tipo di modulo con fallback generico

Ogni istanza di modulo può dichiarare infos.template (vedi Modello dati) — una stringa che ne indica il tipo ("focus", "guider", "sequencer"…). È solo un suggerimento, ed è ammesso che sia assente.

Template assente = la schermata di default

Un client che supporta schermate personalizzate per template dovrebbe trattare un infos.template assente (o non riconosciuto) esattamente come il valore letterale "default" — non come un errore. Rooster fa così con un piccolo registro che associa un nome di template in minuscolo a un componente di schermata, ricadendo sulla propria schermata generica/di default quando il nome è assente o non presente nel registro:

template = infos.template?.toLowerCase() || "default"
screen = registry[template] ?? registry["default"]

Questo è ciò che permette a un client di restare pienamente funzionante di fronte a un tipo di modulo mai visto prima — un modulo nuovo o di terze parti viene sempre renderizzato tramite la schermata generica — pur offrendo una schermata curata e mirata per i tipi di modulo che meritano l’investimento.

La schermata "default" non è un fallback minimo simbolico: per alcuni tipi di modulo è l’unica schermata che avranno mai, quindi deve renderizzare tutto ciò che una proprietà/elemento generico può portare — status, hint, pre/posticone, griglie, grafici, LOV, tutto — non solo i valori. Una schermata dedicata per template può essere più curata (nascondere campi, riorganizzare il layout), ma quella di default non dovrebbe far sparire silenziosamente informazioni solo perché nessuno ha ancora scritto una schermata dedicata per quel modulo.

Consigliato: un unico componente di rendering delle proprietà, riusato ovunque

Non obbligatorio, ma vale decisamente la pena: costruire un unico componente responsabile del rendering di una proprietà (o di un elemento) — gestendo directedit, permission, le tre portate di LOV, le regole dei gruppi bool, autoupdate, le griglie, i grafici — e far sì che ogni schermata lo usi per le singole proprietà, comprese le schermate personalizzate per template, non solo il fallback generico.

L’alternativa — ogni schermata che reimplementa il proprio rendering dei campi — duplica ogni sfumatura del formato di trasporto in tutto il codice, e una correzione o una nuova funzionalità del backend raggiunge solo le schermate che qualcuno si ricorda di aggiornare. Le schermate personalizzate di Rooster (GuiderComponent, FocusComponent…) renderizzano il proprio layout, ma delegano comunque ogni singola proprietà allo stesso componente di proprietà generico usato dalla schermata di fallback — una correzione fatta lì una sola volta (ad esempio la corretta gestione di un gruppo di switch esclusivi) si applica automaticamente ovunque, schermate personalizzate comprese.

Form di proprietà: SA, obbligatorio per directedit: false

Oltre a SV campo per campo, una proprietà editabile ha bisogno di un percorso form: una schermata che elenca tutti i suoi elementi editabili in una volta, inviati insieme tramite SA.

Non opzionale per un elemento con directedit: false

SV viene rifiutato dal backend per qualsiasi elemento con directedit: false (vedi Modello dati). SA è l’unico modo per aggiornare mai un tale elemento — un client senza percorso form semplicemente non può scrivere su quell’elemento, per quanto ben fatto sia il resto della sua UI.

Solo gli elementi editabili appartengono a questo form — i tipi di sola visualizzazione (img, video, light, prg, message) vengono renderizzati, mai inviati; vanno esclusi del tutto dal payload piuttosto che inviare loro un valore privo di senso.

La stessa forma “inviare un insieme di elementi in una volta” è riusata dalla creazione di riga di griglia (GC) e dal suo aggiornamento (GU) — entrambi prendono una mappa e esattamente come SA. Vale la pena costruire un unico componente form riutilizzabile che serva sia il form della proprietà semplice, sia la finestra di creazione riga di griglia, sia quella di modifica riga — invece di tre implementazioni separate della stessa logica di fondo (“renderizzare elementi editabili, raccoglierne i valori”). È quello che fa Rooster.

Heartbeat

Facoltativo

Il heartbeat non è imposto dal protocollo. È un meccanismo che il client mette in atto per proprio conto, solo se la sua tecnologia lo richiede, per mantenere attiva la connessione WebSocket (alcuni browser, proxy o piattaforme chiudono una connessione ritenuta inattiva). Un client che non ne ha bisogno può farne completamente a meno.

Il meccanismo esiste a livello di protocollo per chi ne ha bisogno: inviare XX ogni 30 secondi, il server risponde xx (senza payload utile).

→ {"XX": {}}
← {"xx": {}}

Riconnessione

Ripartire sempre da un dump completo

Alla minima interruzione, il client deve svuotare interamente il suo datastore — nessuno stato parziale può essere considerato affidabile dopo una disconnessione.

Ciò che il client fa dopo (ritentare automaticamente N volte, proporre all’utente di riavviare la connessione, rinunciare…) riguarda solo lui: non è specificato dal protocollo, ogni client è libero della propria strategia.

Ma una volta (ri)connesso, rinviare DU per ottenere un nuovo dump completo non è quasi opzionale: ricostruire uno stato utilizzabile a partire dai soli aggiornamenti parziali che arriverebbero in seguito (ee/ea/ev) è impossibile — questi danno solo una vista frammentaria, mai uno stato completo. Sul lato server, nulla deve essere preservato tra due connessioni dello stesso client: è un vincolo puramente lato client.

Esempio minimo: primo scambio

Senza framework, solo il primissimo scambio (connessione → DU → ascolto) — estratto da test-ws-advanced.js, lo script usato per il health-check orario del server:

const ws = new WebSocket("ws://127.0.0.1:9624");

ws.on("open", () => {
  ws.send(JSON.stringify({ "DU": { "language": "en" } }));
});

ws.on("message", (data) => {
  console.log("received:", data.toString());
});

Esempio: lettura e scrittura in Rooster

Estratti reali che illustrano la separazione lettura/scrittura vista in cima a questa pagina, una volta che un client reale (qui Rooster, in Angular) è costruito attorno a questo principio.

Lettura — datastore.service.ts, un semplice accesso allo stato interno:

getProperty(moduleName: string, propertyName: string): Observable<any> {
  return new Observable(subscriber => {
    const subscription = this.changed$.subscribe(() => {
      const module = this.modules[moduleName];
      const prop = (module as any)?.properties?.[propertyName] || null;
      subscriber.next(prop);
    });
    subscriber.next(this.modules[moduleName]?.properties?.[propertyName] || null);
    return () => subscription.unsubscribe();
  });
}

Scrittura — websocket.service.ts, nessuna mutazione locale, solo l’invio del messaggio:

setPropertyOneElement(module: string, property: string, elements: { [key: string]: any }): void {
  this.send({
    SV: { m: { [module]: { p: { [property]: { e: elements } } } } }
  });
}