OR/M
L’OR/M è una tecnica di programmazione che ha come obiettivo fare da “ponte” tra due sistemi informatici differenti:
- l’applicazione software, generalmente scritta con un linguaggio ad alto livello, che ha un domain model di classi che rappresentano la realtà di riferimento in memoria e nelle regole di business;
- Il database relazionale (o NoSQL) che utilizza SQL (o sistemi analoghi) per l’accesso alle tabelle, che contengono il modello relazionale e la persistenza su memoria di massa dell’intera base dati.
Di norma infatti nella progettazione del software occorre realizzare il database e parallelamente o in seguito il domain model ad oggetti, che ne costituisce una copia concettuale, ferme restando le differenze logiche e concettuali (un modello ad oggetti non è identico un modello relazionale). Una volta generati entrambi, il programmatore scrive un modulo middleware di collegamento tra i due software, ovvero un sistema che esegue le query di ricerca, inserimento, cancellazione che verranno eseguite sul database. Le query DML sono scritte manipolando stringhe, con i parametri inseriti all’interno della stringa, che poi viene inviata al database.
Questo comporta una ripetizione della stessa struttura su due sistemi differenti, con annessi tempi aggiuntivi di sviluppo. Inoltre la manipolazione di stringhe comporta errori difficilmente rilevabili dal compilatore, con quindi un ulteriore incremento dei tempi di sviluppo.
L’OR/M cerca di risolvere proprio questi due problemi. Si tratta di un middleware che permette di evitare di usare SQL e che automatizza il mapping tra entità del dominio ad oggetti con le entità del database relazionale. Inoltre, nei moderni OR/M, è anche possibile far creare in fase di setup l’intero database relazionale.
Il primo OR/M con una diffusione commerciale fu Ruby on Rails, il primo vero framework MVC di larga diffusione, che appunto faceva dell’OR/M uno dei suoi punti di forza, perché per la prima volta il programmatore non era costretto a usare SQL e riscriversi a mano la mappatura tra database ad oggetti. Il primo OR/M Oggi esiste un OR/M per ogni linguaggio ad alto livello con una certa diffusione: Hibernate/JPA per Java/Kotlin, Entity Framework per C# .NET, Sequelize per NodeJs, Doctrine per PHP, e così via.
In questa lezione vediamo JPA-Hibernate, OR/M per il mondo Java, integrato in Spring Boot.
Funzionamento
Sono possibili di norma due strategie di creazione di un database con OR/M. Le vediamo in un contesto Java con JPA/Hibernate:
- Ex Novo: il database viene creato automaticamente a partire dal codice dell’applicazione ad oggetti. Il programmatore ad esempio in Java crea delle classi che contengono le annotazioni per per permettere alla libreria di creare il database. Alla prima esecuzione la libreria crea il database.
- Legacy: il database esiste già, ed in questo caso occorre eseguire un mapping da tabelle esistenti, tramite annotazione delle classi. E’ anche possibile definire il mapping esternamente al codice, con una configurazione XML, molto utile quando modello dati ad oggetti e relazionale differiscono in parte nella forma delle entità.
Accesso e creazione
Per poter avere un progetto funzionante eseguiamo il punto “Predisposizione progetto SpringBoot” della lezione di accesso al DB con SpringBoot.
Dichiarazione di entità
Vediamo un esempio valido per entrambe le strategie. In SQL creiamo questa tabella:
CREATE TABLE AUTORE (
Id INT AUTO_INCREMENT,
Nome VARCHAR(100) NOT NULL,
Cognome VARCHAR(100) NOT NULL,
CONSTRAINT PK_Autore PRIMARY KEY (Id)
);che corrisponde alla classe Java (si usa JPA):
import jakarta.persistence.*;
@Entity
@Table(name = "AUTORE")
public class Autore {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "Id")
private Integer id;
@Column(name = "Nome", nullable = false, length = 100)
private String nome;
@Column(name = "Cognome", nullable = false, length = 100)
private String cognome;
public Autore() {
}
public Autore(String nome, String cognome) {
this.nome = nome;
this.cognome = cognome;
}
// --- Getter e Setter ---
...
}Notare che sono necessari sia il costruttore vuoto che quello con tutti gli attributi. L’operazione che collega la classe Java al database è contenuta nelle annotazioni:
@Entity | Dichiara che la classe è una entità |
@Table(name = "AUTORE") | Indica il nome della tabella associata. Non è obbligatorio quando si crea un database ex-novo, tranne alcuni casi particolari (tabelle con nomi vietati) |
@Column(name = "Cognome", nullable = false, length = 100) | Dichiarazione di un attributo (gli attributi tra parentesi sono obbligatori se legacy) |
@Id | Dichiarazione di una chiave Id |
@GeneratedValue(strategy = GenerationType.IDENTITY) | Dichiarazione che il campo è autogenerato |
Nella strategia “legacy” è molto importante che la mappatura sia coerente con quella del database (nomi, tipi, vincoli).
Dichiarazione delle relazioni
Le relazioni tra entità, che nel database sono gestite tramite chiavi esterne ed integrità referenziali, sono gestite tramite riferimenti ad oggetti esterni. Le relazioni possibili, come sappiamo, sono 1:1 o 1:N mentre la relazione N:N è implementata con una tabella intermedia. Vediamo come sono implementate:
Uno a molti
Vediamo l’entità Canzone:
CREATE TABLE CANZONE (
Id INT AUTO_INCREMENT,
Titolo VARCHAR(200) NOT NULL,
AlbumId INT NOT NULL,
CONSTRAINT PK_Canzone PRIMARY KEY (Id),
CONSTRAINT FK_Canzone_Album FOREIGN KEY (AlbumId) REFERENCES ALBUM(Id) ON DELETE CASCADE
);che diventa:
@Entity
@Table(name = "CANZONE")
public class Canzone {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String titolo;
// Relazione N:1
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "album_id", nullable = false)
private Album album; // Riferimento al singolo album
...
}Come si può vedere la relazione è tra classi, non chiavi. Le annotazioni:
| @ManyToOne(fetch = FetchType.LAZY) | Indica il tipo di relazione |
| @JoinColumn(name = “album_id”, nullable = false) | indica il tipo di chiave |
E’ molto importante l’attributo FetchType.LAZY.
Il “lazy loading” (caricamento “pigro”) indica una strategia per cui il valore dell’album associato non viene automaticamente caricato al caricamento dell’entità canzone dal database. Solo se si accede col getter all’entità Album, l’OR/M esegue la query “SELECT … FROM ALBUM WHERE Id={AlbumId}” e mappa il risultato in una nuova istanza di Album. Questa strategia serve per ovvie ragioni prestazionali, ed anche per evitare di caricare l’intero grafo delle relazioni di una entità.
Anche Album va annotata:
@Entity
@Table(name = "ALBUM")
public class Album {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "Titolo")
private String titolo;
// Relazione 1:N
@OneToMany(mappedBy = "album", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Canzone> canzoni = new ArrayList<>();
// Costruttori, Getter e Setter...
...
}Dove abbiamo:
@OneToMany(mappedBy = "album", cascade = CascadeType.ALL, orphanRemoval = true) | mappedBy=”album” ci dice che la relazione è presente nella proprietà “album” della classe Canzone. CascadeType.ALL dice che una query di inserimento/modifica/cancellazione ha un impatto anche sulle canzoni. orphanRemoval=true indica che se viene rotta l’associazione tra album e canzone, la canzone viene cancellata. |
Uno a uno
Vediamo l’entità Booklet, associata 1:1 con Album:
CREATE TABLE BOOKLET (
Id INT AUTO_INCREMENT,
Pagine INT NOT NULL,
AlbumId INT NOT NULL,
CONSTRAINT PK_Booklet PRIMARY KEY (Id),
CONSTRAINT FK_Booklet_Album FOREIGN KEY (AlbumId) REFERENCES ALBUM(Id) ON DELETE CASCADE
);Vediamo la classe Booklet:
import jakarta.persistence.*;
@Entity
@Table(name = "BOOKLET")
public class Booklet {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "Pagine")
private Integer numeroPagine;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "AlbumId", nullable = false, unique = true)
private Album album;
}il caricamento dell’album è lazy per le ragioni già viste.
Vediamo la classe Album:
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "ALBUM")
public class Album {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "Titolo")
private String titolo;
@OneToOne(mappedBy = "album", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private Booklet booklet;
@OneToMany(mappedBy = "album", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Canzone> canzoni = new ArrayList<>();
...
}Notare che l’elemento collegato è Booklet, per cui è sufficiente un FetchLazy.
Lato album, essendo l’elemento riferito (come con le canzoni) gestisce anche il cascade e l’orphanremoval.
Molti a molti
Le relazioni molti a molti, nel modello ad oggetti, non necessitano di una entità intermedia di relazione. Semplicemente entrambe le classi della relazione sono associabili con una lista di elementi dell’altra classe. Partiamo da queste due tabelle:
CREATE TABLE SUPPORTO (
Id INT AUTO_INCREMENT,
Descrizione VARCHAR(100) NOT NULL,
CONSTRAINT PK_Supporto PRIMARY KEY (Id)
);
CREATE TABLE ALBUM_SUPPORTO (
AlbumId INT NOT NULL,
SupportoId INT NOT NULL,
CONSTRAINT PK_AlbumSupporto PRIMARY KEY (AlbumId, SupportoId),
CONSTRAINT FK_AlbumSupporto_Album FOREIGN KEY (AlbumId) REFERENCES ALBUM(Id) ON DELETE CASCADE,
CONSTRAINT FK_AlbumSupporto_Supporto FOREIGN KEY (SupportoId) REFERENCES SUPPORTO(Id) ON DELETE CASCADE
);La classe Supporto:
@Entity
@Table(name = "SUPPORTO")
public class Supporto {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "Id")
private Integer id;
@Column(name = "Descrizione", nullable = false, length = 100)
private String descrizione;
@ManyToMany(mappedBy = "supporti")
private List<Album> album = new ArrayList<>();
// --- Costruttori, setter, getter
...
}Mentre Album cambia in questo modo:
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "ALBUM")
public class Album {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "Id")
private Integer id;
@Column(name = "Titolo")
private String titolo;
// Relazione Molti-a-Molti verso Supporto (Mappa ALBUM_SUPPORTO)
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "ALBUM_SUPPORTO", // Nome esatto della tabella di giunzione
joinColumns = @JoinColumn(name = "AlbumId"), // FK verso l'Album
inverseJoinColumns = @JoinColumn(name = "SupportoId") // FK verso il Supporto
)
private List<Supporto> supporti = new ArrayList<>();
// --- Costruttori, getter, setter
...
}Vediamo in dettaglio:
@ManyToMany(mappedBy = "supporti") | identifica nella prima delle due tabelle, il nome dell’attributo nell’altra tabella. |
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE}) | identifica nella seconda delle due tabelle, il nome |
CascadeType.PERSIST | quando si creano nuove istanze di Album e di Supporto, si può eseguire il salvataggio solo dell’istanza di album, ed il sistema salva automaticamente anche le entità collegate. |
CascadeType.MERGE | salvataggio in cascata in fase di modifica |
@JoinTable( | Istruiamo JPA dell’esistenza della tabella di relazione, indicando le due chiavi esterne. Questo tipo di annotazione è facoltativa nello sviluppo ex novo, se non si ha intenzione di accedere direttamente al database. |
Repository ed entità
Alla base di JPA/Hibernate vi sono due concetti:
- l’entità: sono le classi che rappresentano le istanze del database, marcate con @Entity;
- il repository: sono le classi che rappresentano la tabella nel suo insieme, marcate con @Repository
Entità
Una istanza di Entity è una istanza di una classe in memoria che può essere monitorata da un tool interno a JPA, l’Entity Manager, che ne controlla lo stato e le modifiche. Gli stati possibili sono:
- TRANSIENT: l’oggetto è stato appena creato, e non è ancora salvato sul DB. Eventuali modifiche all’oggetto non sono gestite. Quando si esegue save() sull’oggetto, il suo contenuto viene salvato su db (e viene aggiunto un Id) e viene restituito un nuovo oggetto, in stato Managed.
- MANAGED: l’oggetto è sotto il controllo di Hibernate ed è quindi controllato sul DB. Se viene modificato, al prossimo salvataggio (tramite il metodo save() ) viene automaticamente salvato.
- DETACHED: l’oggetto è stato “staccato” da Hibernate, le modifiche non sono più tracciate nè salvate. Si attiva eseguendo detach() e l’oggetto smette di essere mappato nelle sue modifiche. Se si esegue save(), JPA esegue una operazione di merge: carica dal database l’oggetto con lo stesso Id, ed esegue un update. Viene restituito l’oggetto managed corrispondente.
- REMOVED: l’oggetto è stato eliminato dal DB, ad esempio da un repository ma la classe esiste ancora.
Di fatto nella maggior parte dei progetti sono importanti i primi due stati, e si preferisce usare altre classi (i DTO) per gesire oggetti scollegati.
Repository
Le entità sono oggetti “passivi”, sono in realtà controllati dai Repository JPA.
Un Repository JPA è una interfaccia contenitore di una Entity che consente di esporre le operazioni CRUD senza scrivere SQL. Le operazioni principali che offre sono:
save(entity)findById(id)findAll(id)delete(id)count()saveAll()
Il repository effettivo che viene istanziato riceve due tipi di dato: il tipo della entity che viene gestito, e il tipo dell’indice da utilizzare (l’id della entity).
@Repository
public interface AutoreRepository extends JpaRepository<Autore, Long> {
} In pratica Hibernate quando creiamo l’annotazione @Repository e creiamo un JpaRepository, crea automaticamente la tabella nel database, le query per la gestione di caricamento, salvataggio, modifica, ecc. ed offre automaticamente i metodi sopra indicati.
E’ possibile aggiungere altri metodi, se servono per il progetto. JPA è in grado di riconoscere, dalla firma del metodo, la query di ricerca ed estrarre i dati necessari.
E’ anche possibile, in situazioni particolari (che non tratteremo qui) scrivere anche direttamente la query SQL associata al metodo.
Vantaggi e svantaggi degli OR/M
I vantaggi
Come visto sopra un OR/M offre l’innegabile vantaggio per il programmatore di poter creare una sola base dati, come modello ad oggetti, che peraltro va comunque realizzato per implementare le funzionalità dell’applicazione.
Aggiungendo le sole annotazioni necessarie per il mapping diventa superfluo:
- scrivere il database col DDL: ci pensa l’OR/M
- scrivere le query DML, ci pensa l’OR/M: anzi con JPA non è nemmeno necessario scrivere le query più comuni.
- scrivere la logica per gestire quando e cosa salvare: il meccanismo automatico dell’Entity Manager non ci costringe a scrivere algoritmi di salvataggio.
Inoltre l’OR/M ottimizza molto l’esecuzione delle operazioni, eseguendo i caricamenti solo quando necessari, ed i salvataggi tutti insieme.
Con gli OR/M, quindi, si possono risolvere problemi di creazione ed ottimizzazione dei database senza scrivere del codice in più. E difatti in moltissimi progetti sono utilizzati, per realizzare ad esempio applicazioni per il Web ed il Mobile per realizzare servizi API e nei progetti ad architettura a microservizi.
Gli svantaggi
A prima vista sembra che ci siano solo vantaggi, e l’uso diretto di un database SQL sembra obsoleto, e necessario solo per mantenere compatibilità con sistemi che per varie ragioni non possono usare un OR/M.
In realtà non è proprio così. Vediamo perché.
Il ciclo di vita
Prima di tutto occorre avere un quadro più ampio, cioè sul ciclo di vita del software, che non si riduce alla sola codifica di una applicazione, il suo test e rilascio, ma che in realtà è un processo che per gran parte del suo ciclo di vita è di manutenzione e modifica. D’altro canto i database hanno un ciclo di vita proprio ed indipendente rispetto al software che li usa, altrimenti verrebbe meno il motivo per cui esistono, ovvero sostituire i sistemi a filesystem per la gestione di database anche con molti dati.
Infine, per molti sistemi informatici, lo stesso database potrebbe essere usato da diversi sistemi.
L’OR/M specie se nella strategia “ex-novo”, presuppone invece un forte accoppiamento tra due sistemi distinti, non solo una API che permette di eseguire le query DML, ma si occupa anche delle query DDL. Questo significa che ad ogni modifica del modello dati occorre modificare anche il database, esattamente come succede con un sistema a filesystem.
In altri termini, in un progetto non banale il database dovrebbe essere sempre indipendente, ed il sistema OR/M con strategia “ex novo” non è una buona scelta progettuale.
Forte accoppiamento
Si può però ancora usa la strategia legacy, dove l’OR/M si appoggia ad un database indipendente.
Tuttavia si presenta un altro problema. Se per ragioni esterne al software applicativo occorre modificare la struttura del database, occorre replicarla anche nel modello software, e quindi anche nelle sue logiche applicative, e quindi su tutto il resto dell’applicazione. E come abbiamo visto nelle lezioni sulle architetture, viene meno il principio di progettazione a plugin. L’OR/M in sè presuppone una corrispondenza diretta tra tabelle del database e classi del modello dati.
Questo modello è rigido, e può essere limitante. Ad esempio non serve necessariamente rispettare tutti i vincoli delle forme normali, infatti nulla vieta di creare classi ad hoc per rispettare le regole di business e le funzionalità di una applicazione, che possono corrispondere ad una o più tabelle del database, o viceversa. Inoltre il modello ad oggetti prevede concetti come ereditarietà e polimorfismo, che non hanno controparti nel modello relazionale. Usare un OR/M fa perdere tutta questa ricchezza, a meno di una progettazione molto più complessa, che ne vanifica l’utilità.
In un interfacciamento via SQL invece questo accoppiamento è meno rigido: si mappa ciò che serve, e si ha più libertà di progettazione, e si comunica col database con la consapevolezza che un layer in più di traduzione garantisce disaccoppiamento e flessibilità, gli ingredienti dell’architettura a plugin.
La cessione del controllo
Un OR/M si scrive le query da sè. In alcuni casi questo fa risparmiare tempo, in altri può essere inefficiente. Ma il programmatore non vede direttamente le query, se non in fase di logging e profilazione. Questo è un problema sia in fase di progettazione, sia in fase di debugging e manutenzione.
E’ un problema noto un po’ in tutti i sistemi distribuiti: maggiore è la complessità del middleware che accoppia due sistemi, minori i margini di manovra per chi sviluppa il software nell’individuare problemi di progettazione o di esecuzione. E’ il motivo per cui gran parte dei sistemi distribuiti oggi è passata dall’architettura SOAP (uno standard “opaco”) a REST (usando uno standard trasparente).
Con un interfaccia via SQL le query sono “a vista”, ed è possibile testare separatamente i sistemi, rendendo quindi più semplice sia la progettazione che il debugging.
In sintesi
In conclusione, gli OR/M sono utili quando:
- si realizzano piccoli progetti e si vuole persistenza su database nel più breve tempo possibile;
- si realizzano progetti con un ciclo di vita che coincide con quello del database, come ad esempio succede con le architetture a microservizi;
- si realizzano database che hanno un unico utilizzo, il software che li controlla;
- si hanno competenze ridotte in ambito database
Si usa SQL quando:
- si realizzano progetti in cui si vuole disaccoppiamento tra database e modello ad oggetti
- si progettano database a cui si vuole accedere direttamente, per inserimento dati, manutenzione o altro
- si progettano sistemi informatici dove ci sono più software che accedono allo stesso database
- si vuole sfruttare a fondo il database, anche con le sue funzionalità
