Articoli di programmazione

ORM: Core Entity Framework contro Dapper

Per chi sceglie la strada degli ORM, spesso il dilemma è: mi affido ad un ORM "tutto incluso" che astragga del tutto la persistenza dei dati, oppure mi basta una libreria leggera e scattante che mi dia maggiore controllo sul database?

disk.jpg
Parto col dire che non sono un fan degli ORM.
Ho studiato moltissimo SQL, SQL Server, T-SQL ed ho ottimizzato per anni tabelle indici e query, tanto da capire che un ORM automatico non sarà mai in grado di eguagliare a livello di prestazioni una bella SP scritta come Dio comanda. Questa affermazione è vera senza dubbio per sistemi grandi, con molti dati, con tabelle grandi e logiche di lettura complesse.
Avere il pieno controllo con delle SP su cosa si inserisce e come si legge, ha un indubbio vantaggio rispetto a qualsiasi ORM, per quanto riguarda le prestazioni. Inoltre di base, voglio avere il controllo di come la mia applicazione esegue le operazioni, soprattutto se si tratta di operazioni di lettura e scrittura di dati importanti.
Un'altra cosa importante è che un ORM spesso costringe a scrivere le proprie classi POCO, un pò come vuole lui. Ad esempio nelle relazioni uno a molti, oltre alla creazione dell'entità legata tramite Foreign Key, Entity Framework richiede che si specifichi il campo chiave come proprietà primitiva. E questo mi fa prudere le mani, perché le mie entità di dominio non voglio sporcarle con campi che non reputo necessari.

Se c'è una cosa che però ho imparato è che non c'è una soluzione uguale per ogni problema e non c'è una architettura valida per ogni applicazione, quindi alla domanda "cosa dovremmo usare per la persistenza in questa applicazione?" come sempre la risposta più adeguata è: DIPENDE.
Sì perché un ORM offre una mare di vantaggi sia all'applicazione che agli sviluppatori, in molti casi.

Prima di tutto se l'applicazione deve aver la possibilità di cambiare base dati, un ORM è la scelta più sensata. L'applicazione deve girare indipendentemente con Oracle, MySql o SQLServer? ORM! E' più flessibile.
L'applicazione prevede l'implementazione di molte entità, i tempi di sviluppo sono risicati? ORM! E' più rapido!
L'applicazione non avrà mai problemi di prestazioni, perché il numero di entità sarà limitato? ORM! Togliendo dall'equazione la variabile "prestazioni" i vantaggi di un buon ORM, soprattutto per start-up brevi, sono enormi.
Al contrario, se l'applicazione lavora con grosse moli di dati e serve quindi un lavoro certosino per ottimizzare le letture e le scritture, meglio non usare un ORM ed affidarsi alla professionalità di chi lavora da anni con SQL.

Ci sono anche le vie di mezzo!
In medio stat virtus, dicevano i latini. Spesso la via di mezzo è quella che fa per noi e tra, scrivere tutto a mano in ADO.NET, maneggiando SqlCommands e SqlDataReader, oppure usare un ORM completo come Entity Framework che non ci fa vedere neanche mezza query, ci sono i cosiddetti ORM Light, ovvero librerie focalizzate maggiormente sulle prestazioni rispetto a Entity Framework che ci permettono di avere maggiore controllo sul T-SQL che andremo ad eseguire sul nostro database. Uno di questi, molto usato, è Dapper.

Dapper è una libreria che sta a metà tra Entity Framework e ADO.NET. Permette di scrivere le query per reperire i dati ed effettua automaticamente il mapping alle entità lette da database. Facilita di molto il reperimento dei dati, rendendolo più rapido da scrivere e più manutenibile rispetto ad ADO.NET. Conferisce maggior controllo rispetto ad Entity Framework e maggiori prestazioni. E' davvero molto veloce.
Ovviamente il prezzo da pagare è proprio il fatto di stare a metà strada tra un ORM ed ADO.NET. Questo infatti si traduce nel fatto che ogni entità di dominio che mappiamo con Dapper, basa la propria persistenza su una query scritta a mano. Variare una semplice proprietà di questa entità comporta la modifica degli script di creazione delle tabelle e soprattutto delle relative query scritte su Dapper. Questo non avviene con Entity Framework dove è tutto automaticamente gestito dal DBContext e dalle migrations.

Scendiamo nel dettaglio e vediamo in pratica come implementare una lettura ed una scrittura con entrambi i sistemi.

Entity Framework
Partiamo col definire una semplice entità di Dominio: un articolo che viene creato da un utente.
Da notare che nel legame tra Article e User, Entity Framework richiede che si espliciti il campo chiave UserID: non mi piace ma sono costretto a farlo.

public class Article
{
public Article() { }
public Guid ID { get; set; }
public string Title { get; set; }
public DateTime CreatedOn { get; set; }
public int UserID { get; set; }
public User User { get; set; }
public string Abstract { get; set; }
}

public class User
{
public int ID { get; set; }
public string Name { get; set; }
}

Ora creo il DBContext per questa entità:

public class ArticleContext : DbContext
{
public ArticleContext(DbContextOptions<ArticleContext> options) : base(options)
{

}
public DbSet<Article> Articles { get; set; }
public DbSet<User> User { get; set; }

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Article>().ToTable("Article");
modelBuilder.Entity<User>().ToTable("User");
}
}

Nel mio StartUp configuro Entity Framework per usare SQLLite ed in AppSettings metto la voce del DNS verso app.db.
Il fatto di registrare ArticleContext come servizio, lo rende disponibile alla mia applicazione h può reperirlo facilmente tramite Dependency Injection.

//Startup.cs
services.AddDbContext<ArticleContext>(options =>
options.UseSqlite(
Configuration.GetConnectionString("DefaultConnection")));

// AppSettings.json
"ConnectionStrings": {
"DefaultConnection": "DataSource=app.db"
},

Ora è il momento di generare le tabelle nel database tramite migrations tramite questi comandi:

dotnet ef migrations add InitialMigration -c ArticleContext
dotnet ef database update -c ArticleContext

Se cambio una classe, prima di eseguire di nuovo l'applicazione devo eseguire la relativa migration per riportare la modifica del codice verso il mio database:

dotnet ef migrations add AddedSomeFieldsToArticle -c ArticleContext
dotnet ef database update -c ArticleContext

Bene.
Ora che è tutto predisposto, vediamo a livello di codice cosa ci permette di fare Entity Framework. Creiamo una semplice classe Article all'interno di un metodo di un Controller, al quale iniettiamo ArticleContext nel costruttore.
All'interno del metodo il codice per la creazione è semplicemente questo, da notare che finché non chiamo SaveChanges non effettuo Commit della mia UnitOfWork e quindi non applico alcuna modifica al database:

var user = new User()
{
ID = new Random().Next(2, int.MaxValue),
Name = "Andrea"
};
articleContext.Add<User>(user);

articleContext.Add<Article>( new Article() {
ID = Guid.NewGuid(),
Title= "Title",
Abstract = "Abstract " + new Random().Next(0, 1000),
CreatedOn = DateTime.Now,
User = user
});
articleContext.SaveChanges();

Ora passiamo alla lettura che avviene in modo estremamente semplice con LINQ. Voglio leggere tutti gli articoli creati nelle ultime 24 ore, ordinati per titolo e metterli in una lista di DTO che magari passerò ad un servizio:

var dtoList = articleContext
.Articles
.Where(x => x.CreatedOn > DateTime.Now.AddDays(-1))
.OrderBy(x => x.Title)
.Select(x => new ArticleDTO
{
Title = x.Title
})
.ToArray();

Se voglio però leggere i dati relativi allo User, Entity Framework ci costringe ad un'altra contorsione. Entity Framework di default legge solo i dati dell'entità Root dell'Aggregate sul quale si esegue la query. Che significa? Che legge solo i dati di Article, mentre di User reperisce solo la chiave UserID. Se leggo User recevo NULL.
Bel problema.
Per risolverlo ci sono due modi.
Attiviamo Lazy Loading (serve la reference a Microsoft.EntityFrameworkCore.Proxies) in modo che Entity Framework effettui lettura di User quando gli è richiesto, appunto in modalità Lazy tramite oggetti Proxy.
È inoltre richiesto che ogni membro delle nostre classi POCO sia dichiarato come virtual.

// startup.cs
.AddDbContext<BloggingContext>(
b => b.UseLazyLoadingProxies()
.UseSqlServer(myConnectionString));

// query (Lazy Loading)
var dtoList2 = articleContext
.Articles
.Where(x => x.CreatedOn > DateTime.Now.AddDays(-1))
.OrderBy(x => x.Title)
.Select(x => new ArticleDTO
{
Title = x.Title,
UserName = x.User.Name
})
.ToArray();

Oppure scriviamo la query in modo diverso aggiungendo la clausola Include:

// Eager Loading
var dtoList3 = articleContext
.Articles
.Include( x => x.User)
.Where(x => x.CreatedOn > DateTime.Now.AddDays(-1))
.OrderBy(x => x.Title)
.Select(x => new ArticleDTO
{
Title = x.Title,
UserName = x.User.Name
})
.ToArray();


Passiamo a Dapper
Una volta installato tramite Nuget non c'è da far niente per configurarlo, basta creare una classica IConnection al database e Dapper permette di essere usato tramite Extension Methods su di essa.
E' addirittura possibile appoggiarsi alla stessa connessione creata in precedenza da Entity Framework, come nell'esempio seguente:

var c = articleContext.Database.GetDbConnection();
c.Open();

var newUserId = new Random().Next(2, int.MaxValue);
c.Execute("INSERT INTO User(ID, Name) VALUES (@id, @name)", new
{
id = newUserId,
name = "Andrea"
});

c.Execute("INSERT INTO Article(ID, Title, Abstract, CreatedOn, UserID)" +
"VALUES(@id, @title, @abstractText, @createdOn, @userId)", new {
id = Guid.NewGuid(),
title = "Dapper title",
abstractText = "Abstract " + new Random().Next(0, 1000),
createdOn = DateTime.Now,
userId = newUserId
});

c.Dispose();

Ed ora passiamo alle letture. Semplice, no?
Da notare che il Mapping viene eseguito automaticamente da Dapper in base ai nomi dei campi. Un pochino di attenzione nella progettazione della nostra tabella e delle entità, ci permee di risparmiare un sacco di tempo di coding.

var list = c.Query<Article>("SELECT * FROM Article WHERE CreatedOn > @date ORDER BY Title",
new { date = DateTime.Now.AddDays(-1)});

Unica nota, ho usato un database SQLite e l'ID di Article è un Guid. Ebbene Dapper non gestiva bene la letura del Guid che SQLLite memorizza come TEXT ed andava in errore.
Ho dovuto quindi elaborare un escamotage per farglielo digerire. Ho creato un Handler personalizzato per il tipo Guid e l'ho registrato nel mio startup.cs, dentro al metodo Configure:

public class GuidTypeHandler : SqlMapper.TypeHandler<Guid>
{
public override void SetValue(IDbDataParameter parameter, Guid guid)
{
parameter.Value = guid.ToString();
}

public override Guid Parse(object value)
{
return new Guid((string)value);
}
}

// Configure
SqlMapper.AddTypeHandler(new GuidTypeHandler());
SqlMapper.RemoveTypeMap(typeof(Guid));
SqlMapper.RemoveTypeMap(typeof(Guid?));


#programmazione #csharp #orm #dapper #netcore