This page looks best with JavaScript enabled

#5: 8 suggerimenti per un'applicazione Shiny a pronta per la produzione

 ·  ☕ 9 min read

Mettere in piedi una applicazione Shiny è estremamente semplice, e può essere fatto in maniera molto libera e versatile. Tuttavia all’aumentare della complessità del progetto, queste regole non bastano più e si va incontro alla perdita della visione di insieme, errori accidentali e difficoltà nell’analisi dei problemi esistenti.

È necessario avere un’idea chiara di quali siano le problematiche di rilascio, di riproducibilità, e di manutenzione per attuare strategie anzitempo e quindi a strutturare il progetto per poter gestire efficacemente queste questioni.

Sviluppo e rilascio

Divide et impera

Un’applicazione, in questo caso Shiny, ha lo scopo di assolvere a un compito: fare qualcosa di utile, produrre del valore. Questo compito è assolto attraverso la realizzazione di funzionalità (features) che, in genere, durante il progetto aumentano in numero e in interazione tra di loro. Questo porta la prima sfida: gestire l’aumento della complessità dell’applicazione. La soluzione si ottiene con una strategia di dividi et impera ovvero dividere un sistema complesso in parti più piccole, e di conseguenza, più comprensibili e più gestibili. Il criterio con cui effettuare la divisione è la separazione delle competenze: che possiamo esemplificare nella divisione in componenti o nella codifica di oggetti elementari da usare in tutta l’interfaccia per dare coerenza di trattamento a parti simili.

Facendo un esempio, le componenti possono essere le schede (tab) o le pagine di cui è costituita un’interfaccia web: in questo modo posso prendere in esame un oggetto separato dagli altri e vedere quale è il perimetro (si parla di interfaccia) con cui si relaziona agli altri componenti. Dall’altra parte gli oggetti elementari possono essere, per esempio, un particolare tipo di tabella, o di grafico, o di widget che viene utilizzato da tutta l’applicazione in modo da renderla coerente con se stessa.

Usa Moduli Shiny

Un primo livello di separazione delle competenze è la divisione dell’applicazione in moduli Shiny. Questi ultimi si puo dire che siano delle applicazioni Shiny in miniatura, perché caratterizzate da una parte di interfaccia utente (User Inteface) e da una di back-end (Server) che contiene oggetti di tipo reactive.

Si può immaginare di avere una suddivisione di questo tipo:

app_server.R
app_ui.R
mod_tab_1.R
mod_tab_2.R

dove app_server.R contiene le righe:

1
2
3
4
callModule(mod_tab_1_server, "tab_1",
           data = common_data)
callModule(mod_tab_2_server, "tab_2",
           data = common_data)

e app_ui.R:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
dashboardBody(

  tabItems(
    # First tab content
    tabItem(tabName = "tab_1",
            mod_tab_1_ui("tab_1")),
    # Secon tab content
    tabItem(tabName = "tab_2",
            mod_tab_2_ui("tab_2"))
    )
  
)

e ogni mod_* file contiene sia la UI (funzioni mod_*_ui()) sia il Server (funzioni mod_*_server()) dell’applicazione in miniatura. common_data è un esempio di interfaccia tra i due.

1
2
3
4
5
6
7
8
mod_tab_1_ui <- function(id) {
  ...
}
    
mod_tab_2_server <- function(input, output, session,
                             data) {
  ...
}

Separa codice Shiny e R semplice in file differenti

Il secondo livello di separazione delle competenze divide la parte Shiny, dal codice R semplice. Questa pratica è molto importante e separa la logica di aggiornamento (reactive) contenuta nel modulo Shiny, dalla logica di calcolo che vive in funzioni R, utilizzabili separatamente dal contesto reattivo (reactive context), consentendogli di poter essere utilizzate e testate indipendentemente.

I file sorgenti diventano:

app_server.R
app_ui.R
mod_tab_1.R
mod_tab_2.R

fun_tab_1.R
fun_tab_2.R

dove i file fun_* contengono il codice R utilizzato nei rispettivi mod_*. Questo è un esempio, la parte R può essere organizzati in modi diversi: per esempio per classi (S3 o R6).

Distribuisci l’applicazione Shiny come un pacchetto R (Golem)

Come raccogliamo il codice scritto e come lo distribuiamo?

Shiny ci viene dato con una grande libertà di organizzazione. Basta creare una directory con alcuni file con dei nomi standard e il resto lo possiamo decidere a piacere: creare una cartella di dati, una cartella per il codice R base, ecc… Tuttavia queste libertà implicano l’obbligo di creare, in funzione delle scelte effettuate, un numero di procedure per assolvere a dei compiti in realtà molto comuni. Per esempio, come organizzo la cartella di progetto? Come definisco le dipendenze del codice e mi assicuro che siano installate sul sistema? In realtà questi compiti hanno già una soluzione assolutamente standard codificata nell’idea di Package R. Creare un pacchetto significa infatti avere:

  • la forma della cartella di progetto standard
  • il formato di documentazione standard per le singole funzioni e per le vignette
  • la dichiarazione delle dipendenze (e delle loro versioni) non solo nel nome del pacchetto, ma anche a livello più basso scegliendo le singole funzioni da importare.
  • una procedura di installazione standard e robusta a partire da una ampia varietà di formati (file tar.gz o zip, repository Git, pacchetto sul CRAN, ecc…)
  • altre procedure standard per esempio: per fare testing (di cui parlerò più avanti), e per fornire dataset di esempio.

Il sistema per creare un pacchetto per un’applicazione Shiny o, se vogliamo meglio dire, il framework è Golem.

Fai Test di unità (testthat)

All’aumentare della dimensione e complessità dell’applicazione aumenta il numero dei possibili punti di rottura.

Si possono verificare situazioni in cui implementando una nuova funzionalità se ne rompe una preesistente. Specialmente in un team, posso rompere le funzionalità sviluppate dai colleghi, proprio perché sono meno consapevole dei dettagli del loro funzionamento e, di conseguenza, il nuovo bug può sfuggire ai miei controlli e arrivare in produzione. Parliamo quindi di regressione di funzionalità. In parole povere pubblicando una nuova funzione, senza rendercene conto, perdiamo altre funzionalità che invece venivano date per scontato. Questa situazione è anche più grave di quella per cui la nuova funzionalità non sia implementata perfettamente, in quanto la regressione toglie un servizio che si dà ormai per acquisito.

Per evitare queste situazioni prima di ogni pubblicazione (release) è necessario fare un giro di controlli completo di tutte le funzionalità. A questo fine, è utile impostare una strategia di controlli automatici di unità (unit tests): in questo modo i test entrano a fare parte della base di codice (code base) e tutti possono testare tutte le funzionalità con un click nel giro di pochi secondi. Questo impedisce la regressione e dà sicurezza in fase di rilascio.

I test forniscono altri vantaggi: testare il singolo componente permette di ridurre la zona di ricerca del bug in caso di rottura e il test stesso è una documentazione funzionante della singola unità.

Riproducibiltà e configurazione

Sia durante lo sviluppo che quando è in produzione l’applicazione dovrà funzionare su sistemi diversi: il computer del vostro collega o il server di produzione.
Per questo è importante che l’applicazione sia riproducibile. Significa che deve funzionare su sistemi diversi. Per questo sono importanti almeno due condizioni: che l’environment compatibile sia installato e che l’applicazione possa essere configurata così da ritrovare le risorse esterne che le servono su sistema su cui sta funzionando.

Traccia le dipendenze utilizzate e riproduci l’ambiente (con “renv”)

Per la condizione di riproducibilità, io consiglio l’utilizzo di “renv” oppure di Docker. Il primo è un pacchetto che crea un Virtual environment: ovvero è in grado di tracciare la versione dei pacchetti utilizzati e di riprodurla su un altro server. Mentre il secondo virtualizza tutta l’applicazione tra cui oltre i pacchetti R anche il file system del sistema operativo e l’interfaccia di rete. Ma è sicuramente una scelta più complessa.

Questo è un esempio del file renv.lock, su cui “renv” traccia la versione di R e le versioni di pacchetti in uso:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
  "R": {
    "Version": "3.6.2",
    "Repositories": [
      {
        "Name": "CRAN",
        "URL": "https://cloud.r-project.org"
      }
    ]
  },
  "Packages": {
    "BH": {
      "Package": "BH",
      "Version": "1.72.0-3",
      "Source": "Repository",
      "Repository": "CRAN",
      "Hash": "8f9ce74c6417d61f0782cbae5fd2b7b0"
    },
    "DT": {
      "Package": "DT",
      "Version": "0.13",
      "Source": "Repository",
      "Repository": "CRAN",
      "Hash": "a16cb2832248c7cff5a6f505e2aea45b"
    },
...

Usa dei file di configurazione

La seconda condizione può invece essere acquisita attraverso un file di configurazione: che non è nient’altro che un file di testo su cui si può scrivere tutte le informazioni necessarie a collegare l’applicazione all’ambiente di lavoro. Per esempio: gli indirizzi delle risorse a cui accedere, le parti di file system su cui andare a scrivere, utenti da utilizzare, credenziali per accedere ai database, eccetera, eccetera…

Questo un esempio di file che porta la configurazione per diversi ambienti: impostazioni di default e delle impostazioni specifche per gli ambienti di produzione e sviluppo.

default:
  golem_name: myApp
  golem_version: 0.0.0.9000
production:
  app_prod: yes
  log_file: /var/log/.../file.log
  db: 10.0.0.2
  db_user: "John"
  db_password: "XXXXX"
dev:
  app_prod: no
  log_file: ./file.log
  db: 10.0.0.102
  db_user: "Doe"
  db_password: "YYYYYY"

Manutenzione

Crea dei file log per applicazione e distingui la severità dei messaggi (futile.logger)

Quando l’applicazione è su un server monitorare il suo comportamento diventa complicato.

  • Non sei l’utilizzatore: che è davanti all’interfaccia, che sa quali sono i dati caricati e le ultime operazioni effettuate
  • Non sei nella posizione dello sviluppatore: che vede i messaggi delle applicazioni ed è consapevole delle operazioni effettuate
  • Ne sei collegato(a) in tempo reale o puoi interagire con l’istanza dell’applicazione e, quando questa si chiude, tutte le variabili ad essa associate vengono perse

La principale soluzione è un sistema che registri tutte le informazioni necessarie a capire se l’applicazione nelle diverse istanze stia funzionando correttamente e, nel caso non lo stesse facendo, a capire quale sia la ragione del malfunzionamento e come intervenire.

Anche qui Shiny ci viene con un sistema standard che scrive su un file di log di sistema quello che siamo abituati a vedere in console. Tuttavia non basta. È necessario evitare che log di diversi istanze finiscano mescolati ed è importante poter scegliere la quantità e la granularità di messaggi. Per questo suggerisco di usare librerie come futile.logger che per ogni riga di logging aggiunge un timestamp e un’indicazione della severità del messaggio, inoltre è configurabile per gestire: la soglia di severità oltre la quale registrare i messaggi, su quale supporto (file o console) registrare, e ha un numero di funzioni utili a perseguire questo scopo.

Esempio di file di log:

1
2
3
4
5
6
7
8
INFO [2020-11-19 13:48:42] New run started. ---------------------
INFO [2020-11-19 13:48:42] Version: 0.5.3
INFO [2020-11-19 13:48:42] mode: dev
INFO [2020-11-19 14:49:24] optimx run 1
WARN [2020-11-19 14:49:25] .....
...
INFO [2020-11-19 14:59:14] optimx run 2
ERROR [2020-11-19 14:59:39] .....

Workshop

Ho tenuto un seminario su questi argomenti a eRum 2020. La relativa applicazione funzionante la trovate qui (Dai una stella al repo se ti piace!)

Prossimo livello

Riguardo alla scrittura del codice questi erano i consigli di base, si può pensare di utilizzare anche tecniche di programmazione avanzate Shiny, per esempio utilizzando R6. Ma forse conviene migliorare anche in altre direzioni. Per esempio, il flusso di lavoro utilizzando Git: per gestire in modo strutturato e avanzato la collaborazione (chi fa cosa, condivisione e discussione delle specifiche, avere kanban board associata alle “branch” di progetto), l’integrazione e il rilascio continuo (continuous integration e deploy). Inoltre è bene avere una strategia capire l’utilizzo che viene fatto dall’utente, per migliorare l’esperienza d’uso (user experience) e per veicolare la documentazione in-line con l’applicazione. Conto di affrontare questi argomenti in qualche articolo in futuro.

Commenti

Commenta su Linkedin

Commenta su Twitter

Share on