Photo by Erda Estremera on Unsplash

Perché è un problema?

Perché il “problema” sta nel fatto che l’utente si aspetta di non avere alcun problema. Solo che per offrire questa esperienza lo sviluppatore deve prevedere e gestire la maggior parte di questi problemi potenziali 🎱.

In questa serie di tre articoli cercheremo di rispondere ad alcune domande:

In particolare vedremo come creare un formato di file duraturo per la nostra applicazione, e nello stesso tempo mantenere il codice pulito, senza strani if per gestire le inevitabili modifiche del formato avvenute durante l’evoluzione del software.

Vedremo in dettaglio:

E come questo ci porti a alle 3 regole d’oro della persistenza dei dati:

Persistenza dei dati

Quasi ogni applicazione ha la necessità di far persistere dei dati tra un’esecuzione e la successiva. Sia che l’app sia una classica applicazione desktop che una più moderna applicazione web, se permettiamo all’utente di salvare un file su disco è probabile che si aspetti di poter eseguire anche l’operazione inversa: caricare nuovamente quel file all’interno della nostra applicazione.

Ci sono almeno due tipi di persistenza molto diffusi:

Il primo caso è solitamente più semplice da gestire, perché spesso delegabile a librerie già esistenti [1]. Il secondo caso è generalmente più complicato, e prendere il problema alla leggera potrebbe esser ragione di grattacapi in futuro.

Per capire meglio le problematiche legate alla persistenza, considereremo l’evoluzione nel tempo di un’ipotetica applicazione.

Supponiamo che la nostra applicazione sia un diario, avremo delle entità che sono i post del nostro diario, che per semplicità espositiva hanno per ora solo il corpo del messaggio. Per giustificare le nostre attenzioni alla qualità, immaginiamo che il nostro diario sia usato da J. K. Rowling e da George R. R. Martin, non possiamo fare brutta figura!

Usando uno pseudocodice – ovvero, non testato – simile a Python, possiamo immaginare che la struttura di un post sia:

@dataclass
class JournalPost:
    body: str

Non abbiamo aggiunto un pid, un indice per poter far riferimento ad un particolare post. In realtà potrebbe essere utile, ma ai fini di questa esposizione non è importante.

I dettagli di come procedere nella serializzazione dipendono molto dal linguaggio usato, però ci possiamo focalizzare sul risultato.

In generale in memoria avremo una sorta di radice, un punto base per accedere alle varie entità. Nel caso dei post potremmo avere un Journal con una struttura del tipo:

@dataclass
class Journal:
    name: str
    posts: List[Journalpost]

Che poi mostreremo ordinati per data o per tag, ma sono tutte feature ancora da implementare 😀.

Data questa struttura gerarchica possiamo immaginare di serializzare il nostro Journal partendo dalla radice e chiamare ricorsivamente sui nodi dell’albero una funzione di serializzazione.

I Post sono foglie e possono essere serializzati senza particolari attenzioni:

@dataclass
class JournalPost:
    body: str
    def dump(self):
        "Convert the instances to the intermediate format"
        return {"body": self.body}
    @classmethod
    def load(cls, post_data):
        "Create the instances from the intermediate format"
        return cls(**post_data)

Invece il Journal fa riferimento a dei post. In questo caso il grafo [2] è proprio un albero, e possiamo evitare la complicazione di serializzare i riferimenti tra entità e serializzare i nodi figli direttamente come contenuti nel nodo padre.

@dataclass
class Journal:
    name: str
    posts: List[JournalPost]
    def dump(self):
        "Convert the instances to the intermediate format"
        return {"name": self.name,
                "posts": [post.dump() for post in self.posts]}
    @classmethod
    def load(cls, journal_data):
        "Create the instances from the intermediate format"
        posts = [JournalPost.load(**post_data) for post_data
                 in journal_data.pop("posts")]
        return cls(**journal_data, posts=posts)

Ovviamente questo codice è molto semplificato, nei casi reali si cercano delle convenzioni per recuperare il nome delle classi e per fare ricorsione sui vari attributi usando l’introspezione, ma il concetto non cambia.

Alla fine della serializzazione, avremo le nostre strutture rappresentate in un formato diverso (magari JSON o XML) che potremo salvare in un file che conterrà come minimo le informazioni necessarie per poter fare il processo inverso, e tornare dal formato intermedio alle istanze.

Nel nostro esempio di post, ci possiamo immaginare una trasformazione di questo tipo:

{
  "journal": {
     "name": "Learning diary",
     "posts": [{"body": "Today was a day"}]
  }
}

Abbiamo scritto del codice per passare da una struttura arbitraria in memoria ad un formato costituito da tipi nativi più semplice: liste, stringhe, mappe. La complessità dei tipi usabili dipende dal formato di serializzazione finale. Per fare un esempio, in JSON le mappe non sono ordinate, altri formati potrebbero offrire mappe ordinate, il formato intermedio va un po’ studiato caso per caso, ma il flusso resta lo stesso:

Ma ora che abbiamo salvato qualcosa su disco, abbiamo creato un punto fisso nella storia che non potremo più modificare. Gli utenti contano di poter aprire il diario anche con le nuove versioni del programma. Nel prossimo articolo della serie parleremo proprio di “Versionamento dei file su disco“. A presto!


L’articolo fa parte di una serie di tre articoli:
Salvare file su disco, ed aprirli di nuovo
Il versionamento dei file su disco
Salvare file su disco, cosa può andare male


[1] Ad esempio nell’ecosistema Qt i QSettings astraggono il problema delle impostazioni offrendo un’interfaccia unificata alle librerie presenti sulle varie piattaforme. ↩︎

[2]Se non fosse un albero? In questo caso, cioè nel caso generico in cui le relazioni tra entità non formano un albero ma un grafo, serializzando in modo ricorsivo, ci troveremmo ad espandere una stessa entità più volte. In questi casi solitamente si usano degli ID univoci per serializzare i riferimenti tra entità, e tutte le entità vengono serializzate in una unica lista. Perché questo funzioni è importante che il grafo delle entità sia un grafo diretto aciclico, in modo da poter trovare un ordinamento topologico:

Wikipedia In teoria dei grafi un ordinamento topologico (in inglese topological sort) è un ordinamento lineare di tutti i vertici di un grafo aciclico diretto (DAG, directed acyclic graph). I nodi di un grafo si definiscono ordinati topologicamente se i nodi sono disposti in modo tale che ogni nodo viene prima di tutti i nodi collegati ai suoi archi uscenti <…> . È possibile ordinare topologicamente un grafo se e solo se non ha circuiti (cioè solo se è un grafo aciclico diretto), e sono noti algoritmi per determinare un ordinamento topologico in tempo lineare.

Questo ordinamento garantisce che sia possibile serializzare prima le entità base e poi le entità che hanno un riferimento alle prime. Nel caso del nostro esempio, prima tutti i post e poi il journal. Deserializzando nello stesso ordine, potremo creare tutte le istanze di JournalPost e poi passarle al costruttore di Journal, deserializzato per ultimo. ↩︎