blexin

Consulenza
Sviluppo e
Formazione IT


Blog

Evolvi la tua azienda

Impariamo a usare gli ORM consapevolmente e con gli occhi aperti.

Mai fidarsi di Entity Framework!

Mercoledì 3 Aprile 2019

D'accordo, il titolo è un po' esagerato e sicuramente il discorso va allargato a tutti gli ORM. Come ogni cosa, bisogna sapere come sono fatti in modo da poterne fare un uso consapevole. Ma è stata la prima cosa che mi è venuta in mente quando durante una consulenza da un nuovo cliente, ci siamo resi conto che nonostante i nostri sforzi, guardavamo il colpevole sbagliato (Azure in questa circostanza). Ma procediamo con ordine.

A seguito dell'evento Hello Azure DevOps di Napoli (https://www.blexin.com/it-IT/Event/Hello-Azure-DevOps-5), una delle aziende partecipanti ci ha contattato per chiedere la nostra collaborazione nella migrazione della loro applicazione. Si trattava di una soluzione Windows Form da migrare a tecnologie più moderne, con i requisiti di essere cross-platform, poter avere una soluzione as-a-service e avere performance accettabili per i loro standard.

Felicissimi della richiesta sono stato da loro e abbiamo pianificato un po' di giornate di consulenza per la preparazione di un prototipo per testare la fattibilità della migrazione. In particolare, ci siamo concentrati sulla parte per loro più critica dal punto di vista delle performance: l'accettazione in una struttura sanitaria. Immaginate lo scenario tipico di quando andate a fare delle analisi specialistiche, dove avete allo sportello gli addetti all'accettazione e una fila di persone da smaltire il più velocemente possibile.

Dal punto di vista tecnologico, si è scelto insieme di testare Asp.Net Core per il backend, Angular per il frontend, lasciando SQL Server come database, ma mantenendo la possibilità di poter passare a PostgreSQL. Il tutto ospitabile sia su Azure che on premise, e sia su Windows che su Linux.

Siamo partiti quindi dal back end, con uno dei template standard Asp.Net Core forniti dalla Command Line Interface (CLI). Abbiamo aggiunto anche l'autenticazione con Asp.Net Identity che, oltre a servirci per la profilazione degli utenti, ci fornisce anche già un DbContext Entity Framework Core con cui gestire le nostre entità. Se avete installato la CLI di .NET Core (https://dotnet.microsoft.com/), vi basta creare una cartella a vostro piacere e lanciare il seguente comando:

dotnet new mvc --auth Individual

Per semplicità, concentriamoci su un singolo aspetto del prototipo: la ricerca dell'anagrafica del paziente. Tipicamente, il paziente arriva all'accettazione e fornisce il suo nome e/o il suo cognome. Una cosa abbastanza semplice, immaginatevi una classe così fatta:

public class Paziente
{
    public int Id { get; set; }

    [Required]
    [StringLength(50)]
    public string Nome { get; set; }

    [Required]
    [StringLength(50)]
    public string Cognome { get; set; }

    public DateTime DataNascita { get; set; }

    [Required]
    [StringLength(16)]
    public string CodiceFiscale { get; set; }

    [Required]
    [StringLength(200)]
    public string Indirizzo { get; set; }

    [Required]
    [StringLength(6)]
    public string CAP { get; set; }
}

Aggiungiamo la classe all' ApplicationDbContext:

public class ApplicationDbContext : IdentityDbContext
{
    public DbSet<Paziente> Pazienti { get; set; }

    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }
}

Per simulare il caso peggiore, abbiamo creato una WebApp su Azure su un Service Plan F1 (quello gratuito, per capirci) e un'istanza Basic di SqlAzure da 4,21 euro al mese (c'è anche un'instanza free di SqlAzure, ma non volevamo esagerare). Potete fare lo stesso, e tantissimo altro, con una trial gratuita di Azure. Una volta creati i servizi, potete prendere la stringa di connessione al database, ma ricordatevi di aggiungere il vostro IP al firewall, se volete collegarvi al server e fare delle prove locali:

impostazioni firewall sql azure

Copiate la stringa di connessione (occhio che username e password non ci sono per sicurezza, ma li avrete scelti voi durante la creazione del servizio):

copia credenziali sql azure

e incollatela nel file appsettings.json:

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=tcp:efcoretest.database.windows.net,1433;Initial Catalog=efcoretest;Persist Security Info=False;User ID={your_username};Password={your_password};MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Warning"
    }
  },
  "AllowedHosts": "*"
}

Non ci resta che creare la Migration di Entity Framework e applicarla al database:

dotnet ef migrations add Pazienti 
dotnet ef database update 

Ovviamente abbiamo caricato un po' di dati di test, circa 25000 righe.

Qualche piccola nota nel caso usiate un Mac per i test: nel file Startup.cs il provider di default per Entity Framework del template è impostato SqlLite, va cambiato in SqlServer:

services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(
        Configuration.GetConnectionString("DefaultConnection")));

Se volete testare la connessione al database da Mac potete utilizzare Azure Data Studio (https://docs.microsoft.com/it-it/sql/azure-data-studio/download?view=sql-server-2017): è gratuito e noterete subito che è molto simile a VSCode!

Azure Data Studio

A questo punto dobbiamo solo aggiungere un controller e fare la nostra query:

public class PazientiController : Controller
{
    private readonly ApplicationDbContext ctx;

    public PazientiController(ApplicationDbContext ctx)
    {
        this.ctx = ctx;
    }

    public IActionResult RicercaPazienti(string nome, string cognome)
    {
        var results = this.ctx.Pazienti
            .Where(x => x.Nome.StartsWith(nome) && x.Cognome.StartsWith(cognome))
            .Take(50)
            .ToList();

        return Ok(results);
    }
}

Funziona, tutti contenti. Peccato che nella applicazione Windows Form, la selezione dell'anagrafica ha abituato gli utenti ad una user experience basata sulla digitazione del cognome e del nome, con l'aggiornamento della lista risultati ad ogni cambiamento di uno di questi due parametri. La conseguenza è che questa API verrà richiamata ad ogni digitazione in uno dei due campi, cosa che abbiamo replicato in Angular.

Il risultato è che, ovviamente, non otteniamo lo stesso feedback dell'applicazione originale, e voi direte ok, ci può stare: passi da un ambiente locale a quello Web, da SQL Server in rete locale al Sql Azure nel Cloud, da una applicazione tutta sul client a una interazione Angular-Asp.Net Core-SqlServer. In teoria, quindi, ci può stare di perdere qualcosa tra la query e la serializzazione in JSON del risultato. Lo scopo del prototipo era proprio testare queste possibilità e quindi, insieme a Roberto, la persona del team del cliente con cui ho lavorato, abbiamo cominciato a fare degli esperimenti.

Siamo partiti dalle cose semplici: scalare il database, passando ad una istanza più potente e anche più costosa. La situazione ovviamente migliora, ma non abbastanza. Roberto aggiunge due indici su nome e cognome. La situazione migliora ancora ma non abbastanza. Proviamo a cambiare la UX, spariamo la ricerca alla pressione del tasto invio, risultato più che accettabile ma non eravamo contenti. C'era qualcosa che non andava. Roberto, che da anni lavora con i dati e con SQL Server mi fa: "Michele scusa, mi fai vedere la query che genera Entity Framework?". La query è facilmente visibile dal log in console:

query non ottimizzata

La domanda quindi diventa: perché fa quel tipo di WHERE? Bisogna tener presente che Entity Framework è un ORM e la versione Core è stata completamente riscritta da zero, per poter supportare alcuni nuovi scenari, come i database in memoria (molto utili per i test automatici) e la gestione di Database non relazionali (lo so, anche io mi chiedo perchè dovrei usare un ORM con un database non relazionale). Inoltre LINQ può essere usato anche in scenari diversi dal database, è questo uno dei suoi punti forti.

In ogni caso, dopo una ricerca in rete ci siamo resi conto che è un problema noto ed è in generale legato all'uso della StartWith() e la Contains() sulle stringhe. Fortunatamente, gli sviluppatori del framework hanno fornito delle funzioni apposite per permetterci di essere più diretti con LINQ, quando sappiamo di lavorare su un database:

public IActionResult RicercaPazienti(string nome, string cognome)
{
    var results = this.ctx.Pazienti
        .Where(x => EF.Functions.Like(x.Nome, nome + "%") && EF.Functions.Like(x.Cognome, cognome + "%"))
        .Take(50)
        .ToList();

    return Ok(results);
}

La query risultante è la seguente:

query ottimizzata

Il risultato è impressionante: la velocità percepita dall'applicazione è esattamente quella che volevamo, anchetornando alla versione base di SQL Azure.

Morale della favola: quando lavorate con un ORM, sia esso Entity Framework o qualsiasi altro, non perdete mai di vista le query generate. Disabilitate il Lazy Load, impostate manualmente il fetch plan con le Include() quando serve, e misurate, misurate, misurate!

Happy Coding!

Autore

Servizi

Evolvi la tua azienda