Come si fa a modellare dati in applicazioni server in Python? Esplora con noi il nostro progetto SQLAlchemy open source per scoprirlo.

Le applicazioni server in genere sono costruito a livelli (layers): un "data layer" che interagisce con il database per spedire e ricevere dati, un "service layer" che media fra il data layer e l'API, e un "web layer" (l'API), con cui l'utente interagisce.

Nel nostro post precedente abbiamo esplorato la struttura, i punti di forza e i componenti principali di un sistema server/client. In questo design, l’interazione con l’utente e la la business logic sono gestite da due programmi separati, che comunicano tramite richieste HTTP.

Se hai deciso che questa architettura è la scelta giusta per il tuo progetto, sei nel posto giusto per saperne di più: esploreremo il design di una di queste applicazioni per capire come costruirle.

sem

Il programma che prenderemo in esame è sem, scritto dall’autore di questo post; il suo codice sorgente è attualmente condiviso su github. L’obiettivo di questo software è offrire un gestore semplice da usare per le spese domestiche, consentendo la registrazione e l’interrogazione dei relativi dati.

Per sem, ho scelto di usare un design server/client principalmente per consentire estensibilità futura con varie interfacce possibili (riga di comando, web, grafica…), che implementerei separatamente. Seguendo le idee discusse nel nostro ultimo post, ho scelto le tecnologie da utilizzare per implementare sem in base ai miei obiettivi e requisiti di progettazione.

  • Ho scelto Python come linguaggio di programmazione. La performance non era un problema in questo caso, poiché sem era pensato per uso su piccola scala. La considerazione principale è stata quindi la maggior facilità di sviluppo in Python (rispetto a linguaggi compilati).
  • SQLAlchemy è una matura libreria ORM per il Python, che semplifica molti aspetti dell’interazione coi database. Ho scelto di utilizzare questo framework, invece dell’accesso a basso livello al database, per la sua maggior semplicità e interoperabilità con il linguaggio.
  • Ho deciso di usare il server di database PostgreSQL come backend per il mio storage di dati. La sua versatilità si unisce alla semplicità d’uso e di distribuzione data da SQLAlchemy, sostanzialmente eliminando i possibili svantaggi (sintassi di connessione leggermente più complessa, ecc.).
  • Ho preferito FastAPI come framework API per la sua semplicità d’uso e integrazione con altre librerie Python (pydantic per type-checking, per citarne una).

In questo post inizieremo ad esplorare l’implementazione di sem, iniziando dai primi passi, ovvero come strutturare un’applicazione server, e come modellare dati in applicazioni server in Python.

Livelli di un’Applicazione Server

I moduli di sem sono archiviati in una directory modules/ nella root directory del progetto. Questi sono i file contenuti all’interno, che esploreremo uno per uno nei nostri post:

├── modules
    ├── api.py
    ├── cli.py
    ├── crud_handler.py
    ├── main.py
    ├── models.py
    ├── schemas.py
    └── session.py

Tutti questi moduli (tranne cli.py, dedicato a un’interfaccia da riga di comando di base, e main.py, che avvia il server) sono collegati a uno dei tre livelli (anche detti layer) di funzionalità che qualsiasi applicazione server deve avere, in una forma o in un’altra:

  • Il data layer riguarda la manipolazione diretta del database. models.py è direttamente legato a questa parte del programma, dato che definisce il modello di dati che verrà usato dal database.
  • Il service layer è un intermediario tra il data layer e l’API. crud_handler.py è il modulo principale di questo layer, definendo una classe (CRUDHandler) che wrappa il database ed è interpellato dall’API. Questo oggetto utilizza le funzioni in session.py per connettersi al database, mentre schemas.py contiene le definizioni dei tipi utilizzati dai metodi di CRUDHandler.
  • Il web layer contiene l’API ed è quello usato da utenti e altri servizi per interagire con il server. Le definizioni delle funzioni dell’API sono contenute in api.py.

Sebbene non strettamente necessario (si potrebbe unire il service layer con uno degli altri due, per esempio) strutturare l’applicazione in questo modo garantisce flessibilità e facilita il testing.

Quest’ultimo aspetto è cruciale: durante lo sviluppo di ognuno dei layer, ho avuto cura di scrivere immediatamente funzioni e moduli di testing, per assicurarmi che tutto funzionasse come previsto. La discussione del testing sarà spostata ad uno dei prossimi post, per mantenere la discussione più semplice a questo stadio.

Ora che la struttura generale del programma è chiara, possiamo cominciare a esaminare il modello generale dei nostri dati.

Il Modello dei Dati

Scegliere il modello giusto per i tuoi dati è fondamentale per semplificarne la gestione e per ottenere buone prestazioni dal tuo programma.

In alcuni casi, la soluzione migliore è suddividere i dati in diverse tabelle da interrogare insieme tramite join, per evitare ridondanza e migliorare la performance. Nel mio caso, la progettazione del database è stata più semplice, ed una tabella si è rivelata sufficiente per gestire i miei dati in maniera efficiente.

Come nel nostro esempio del post precedente, ho classificato le mie entità di dati (le spese) utilizzando i seguenti campi:

  • Un campo ID, con valori strettamente diversi per ogni spesa, che svolgerà il ruolo di chiave primaria della tabella. Un campo del genere non è strettamente necessari, ma è sempre una buona idea averne uno, in genere a valore intero.
  • Un campo date, il giorno in cui è stata effettuata la spesa. I database SQL hanno tipi di dati interni per gestire le date, che SQLAlchemy interfaccerà con il tipo Python datetime.date.
  • Un campo type, che classificherà la spesa, registrato come stringa.
  • Un campo amount, che rappresenta l’importo di denaro speso sotto forma di numero decimale.
  • Un campo description, una stringa che conterrà una descrizione comprensiva della spesa.

Oltre a questi, nel corso dello sviluppo ho deciso di aggiungere un campo category, un secondo campo stringa per la categorizzazione ad un livello superiore. Ciò può essere utile nel caso in cui, ad esempio, sem venga utilizzato per classificare le spese di più membri di una famiglia, per registrare chi le ha effettuate. Al contrario, utilizzo type per classificare le spese ad un livello inferiore (ad esempio, “cibo” o “tempo libero”).

Ora che il modello è definito, possiamo iniziare a programmare, creando i tipi Python che codificheranno la struttura dei nostri dati.

Modellare dati in Applicazioni Server in Python

Ora sappiamo quale modello di dati dovremmo seguire: dobbiamo ancora capire come implementarlo nel contesto di applicazioni server in Python come sem.

Risponderemo a questa domanda esplorando il modulo models.py, che contiene il modello di dati per le nostre spese in un formato che SQLAlchemy può utilizzare per creare un database.

Il modulo inizia con una module docstring, dove vengono specificati i contenuti del modulo (divisi per tipo, come Classi o Funzioni). Questo è utile al programmatore, per avere una referenza rapida sul contenuto del file; inoltre, il sistema di documentazione automatica sfrutterà le docstring in seguito. Ho anche aggiunto nei vari moduli docstring per funzioni e classi (eccetto nell’API, dove il sistema di documentazione richiederà informazioni codificate in altri formati).

# modules/models.py

1	"""Database mapped class.
2	
3	Classes
4	-----------------------
5	Base
6	    Inherits DeclarativeBase, base class for mapped objects.
7	Expense
8	    Class modeling database entry.
9	"""

Segue la sezione principale del file, che contiene la classe Python che codifica il modello dei dati.

SQLAlchemy offre diverse interfacce per definire e gestire modelli e dati. sem utilizza lo stile dichiarativo, disponibile nelle versioni SQLAlchemy più recenti (2.0 o più), che consente una più profonda integrazione con l’ecosistema Python.

Nel paradigma dichiarativo, i modelli sono definiti come classi derivate del tipo DeclarativeBase (l’idioma usuale consiste nell’utilizzare una classe intermedia Base, che verrà ereditata dalla classe effettiva del modello).

# modules/models.py

34	from datetime import date
35	
36	from sqlalchemy.orm import DeclarativeBase
37	from sqlalchemy.orm import Mapped
38	from sqlalchemy.orm import mapped_column
39
40
41	class Base(DeclarativeBase):
42	    """Inherits DeclarativeBase, base class for mapped objects."""

Il tipo Base appena definito ha alcuni attributi importanti che verranno ereditati dal (e definiti nel) nostro modello:

  • __tablename__ è una stringa, il nome della tabella che conterrà i dati relativi agli oggetti del tipo derivato.
  • metadata è un attributo di tipo MetaData, che verrà utilizzato in una fase successiva per creare lo schema (ovvero le tabelle necessarie per archiviare i nostri dati) nel database.

Il modello viene quindi definito in modo simile a una dataclass in Python, specificandone i campi e aggiungendo annotazioni sui tipi. Invece di utilizzare direttamente i tipi Python standard, tuttavia, le annotazioni nei modelli SQLAlchemy utilizzano il costrutto Mapped[<type>], che fornisce al framework le informazioni necessarie.

# modules/models.py

45	class Expense(Base):
46	    """Expense class.
47	
48	    Attributes
49	    -----------------------
50	    id : int
51	        ID of the expense, primary key field.
52	    date : date
53	        Date of the expense.
54	    type : str
55	        Low-level group of the expense.
56	    category : str
57	        High-level group of the expense. Default is "".
58	    amount : float
59	        Amount of the expense.
60	    description : str
61	        Description of the expense.
62	    """
63	
64	    __tablename__ = "expenses"
65	
66	    id: Mapped[int] = mapped_column(primary_key=True)
67	    date: Mapped[date]
68	    type: Mapped[str]
69	    category: Mapped[str] = mapped_column(default="")
70	    amount: Mapped[float]
71	    description: Mapped[str]

Questo codice informerà SQLAlchemy che il tipo Expense verrà utilizzato nel programma per modellizzare i contenuti del database, e che dovrà creare una singola tabella, chiamata expenses, con i campi richiesti.

SQLAlchemy permette anche di gestire situazioni più complesse, in cui due o più tipi di dati vengono memorizzati in tabelle separate, su cui effettuare join se necessario.

La funzione mapped_column() viene utilizzata per specificare informazioni sul campo che vanno oltre il semplice tipo di dati. Nel mio codice,

  • id è stato impostato come chiave primaria della tabella utilizzando il keyword argument primary_key. Questo campo verrà indicizzato e inserito automaticamente dal database.
  • category ha ricevuto il valore predefinito "" (utilizzato se il campo è lasciato non specificato in un oggetto di tipo Expense) tramite il keyword argument default.

Prima di passare al resto del programma, ho aggiunto anche un metodo di conversione in stringa per il modello. Questo verrà chiamato quando viene richiesta una stringa che rappresenta un oggetto di tipo Expense:

# modules/models.py

73	    def __repr__(self) -> str:
74	        """Dunder representation method."""
75	        return f"""Expense(
76	            id={self.id!r},
77	            date={self.date!r},
78	            type={self.type!r},
79	            category={self.category!r},
80	            amount={self.amount!r},
81	            description={self.description!r}
82	        )"""

La stringa corrispondente a ciascun campo è ottenuta con l’operatore !r, ed è stata poi interpolata nella stringa complessiva.

Nelle prossime puntate

Abbiamo scelto gli strumenti necessari, compreso le nostre esigenze in materia di dati, e visto come eseguire la modellazione di dati in applicazioni server Python.

Nel prossimo post di questa serie scriveremo un oggetto (CRUDHandler) che creerà effettivamente il nostro database e vi opererà sopra.


Autore: Adriano Angelone

Dopo aver ottenuto la Laurea Magistrale in Fisica all’Università di Pisa nel 2013, ha ricevuto il Dottorato in Fisica all’Università di Strasburgo nel 2017. Ha lavorato come ricercatore post-dottorale all’Università di Strasburgo, alla SISSA (Trieste) e all’Università Sorbona (Parigi), prima di entrare in eXact-lab come Sviluppatore di Software Scientifico nel 2023.

In eXact-lab, lavora all’ottimizzazione di codici computazionali e allo sviluppo di software di gestione dati e data engineering.