Il fulcro di questo articolo sottende molti e variegati aspetti del mondo Rails e, inevitabilmente, di Ruby; affronteremo quindi il tutto in capitoli ben distinti che possano evidenziare le problematiche di Ruby e di Rails in termini di performance, suggerendo al contempo alcuni trucchi per ottimizzare la stesura del proprio applicativo.
Ruby è lento?
La risposta lapidaria dovrebbe essere: SI. Fin dalla nascita la Virtual Machine Ufficiale (detta anche MRI (Matz's Ruby Interpreter) non ha mai spiccato né per performance, né per gestione della memoria; esistono però alcune regole che, se seguite, possono garantirci un certo miglioramento nelle prestazioni.
Limitare le ricerche nell'Abstract Syntax Tree
A differenza di molti altri linguaggi interpretati, Ruby 1.8.6 non traduce il codice in bytecode prima di eseguirlo ma si appoggia solamente ad un Abstract Syntax Tree nel quale è memorizzata l'intera struttura del programma in esecuzione.
Per ogni chiamata ad ogni entità dell'applicazione (metodo, variabile, etc.) l'interprete deve dunque navigare l'AST effettuando così un operazione sufficientemente costosa.
Si può scrivere codice che limiti il numero di ricerche nell'AST? Si, ad esempio facendo uso delle variabili di istanza; Ruby infatti utilizza una cache per questo tipo di elementi che quindi risultano più performanti rispetto alla loro versione classica:
@var1 = "Hello to everybody" puts @var1 # in questo caso Ruby cerca prima nella sua cache puts self.var1 # in questo caso invece và sempre nell'AST
Nelle stringhe, inclusione al posto della concatenazione
Chris Blackburn ha dimostrato come sia più efficiente utilizzare il marcatore di inclusione #{}
all'interno di stringhe tra doppi apici che concatenare tra loro stringhe con il metodo '<<
'.
str = "questa #{var1} verrà composta velocemente" str = 'questa invece è più lenta ' << var2
Il motivo di questa differenza di performance tra le due implementazioni (la prima è due volte più veloce della seconda) è da ricercarsi nella strutturazione del codice sorgente della MRI che, sintetizzando, effettua una singola chiamata a funzione nel primo caso e almeno due nel secondo.
Utilizzare, dove possibile, operazioni distruttive
La maggior parte dei metodi di elaborazione disponibili sugli oggetti più utilizzati in Ruby sono proposti in due versioni, quella classica (es: gsub
) e quella distruttiva (es: gsub!
). La differenza principale tra queste due diverse implementazioni consiste nel fatto che la seconda effettua l'operazione voluta direttamente sull'oggetto invocante mentre la prima restituisce una copia dell'oggetto modificata secondo il metodo chiamato.
In termini di perfomance questo si traduce nella necessità di dover copiare l'intero oggetto in questione prima di invocarci sopra il metodo voluto con conseguente spesa di tempo (e potenzialmente di memoria, ma è meno rilevante); ecco un esempio:
str = "ottimizzare ruby? impossibile" str2 = str.gsub(" im"," ") # obbliga MRI a duplicare str str.gsub!(" im"," ") # nessuna duplicazione, più performante
Attenzione agli Hash
Questi oggetti, di norma utilizzatissimi, tendono ad essere dispendiosi in termini di tempo necessario al recupero dell'informazione voluta; se accedete spesso ad una stessa chiave tenete in considerazione l'opzione di salvare tale valore in una variabile locale o di istanza.
MetaProgrammazione dinamica, croce o delizia?
La MetaProgrammazione dinamica è quell'aspetto che, una volta padroneggiato, rende l'utilizzo di Ruby ancora più divertente ed intrigante.
È necessario però comprendere che metodi come define_method
e class_eval
possono costare molto all'interprete, che si trova a dover operare in tempo reale sull'AST per inserire le nuove direttive. Rails utilizza queste funzionalità in modo sufficientemente esteso e, come conseguenza, offre allo sviluppatore metodi molto intuitivi e semanticamente appaganti (basti pensare al find_by_*
) ma potenzialmente pericolosissimi per le performance dell'applicazione.
Utilizzare un'altra Virtual Machine
Anche se Ruby 1.8.6 è ad oggi lo standard de-facto nel mondo dello sviluppo di applicazioni Rails l'intero ecosistema si stà muovendo abbastanza velocemente e, da gennaio 2009, è disponibile una release stabile e pronta per ambienti di produzione della nuova VM ufficiale: la 1.9.1 che porta con se innegabili miglioramenti in termini di velocità di esecuzione.
Ricordiamo inoltre che esiste un ventaglio collaterale di Interpreti Ruby più o meno stabili che possono, di volta in volta, rivelarsi utili in specifiche situazioni; è possibile approfondire questo tema leggendo l'articolo Ruby: quale Virtual Machine?.
Rails è lento?
In questo caso è molto più difficile dare una risposta sintetica. È innegabile che esistano alcune tecniche di programmazione preferibili ad altre, anche quando si tratta di scrivere applicazioni Rails. Nella lista che segue esploreriamo una serie di 'best-practices' da seguire per ottenere il massimo dal proprio codice.
Il routing
Il file config/routes.rb
situato all'interno di ogni applicazione Rails contiene le corrispondenze tra le URL disponibili e l'azione da chiamare per ciascuna di esse (con eventuali parametri). Ogniqualvolta si definisce una nuova corrispondeza url/azione è possibile ed altamente consigliato dare un nominativo a quanto stiamo creando come nel seguente esempio:
map.home :action=>'home', :controller=>'pages'
in questo modo Rails genera automaticamente un metodo chiamato home_url
che possiamo usare all'interno delle nostre viste per velocizzare la creazione di link e similari evitando che l'interprete debba scandagliare l'intera tabella degli URL fino a trovare quello che risponda ai parametri richesti:
link_to 'home', {:action=>'home', :controller=>'pages'} # lento link_to 'home', home_url # veloce
method_missing
Chiunque abbia già sviluppato utilizzando Rails conoscerà certamente tutta quella serie di metodi che vantano il prefisso dynamic
, come ad esempio dynamic_scope
e dynamic_finder
, e che offrono la possibilità di specificare alcuni 'parametri' direttamente nel nome del metodo. Così, se ad esempio vogliamo cercare tra tutti i contatti quell che di nome fanno 'Sandro' possiamo scrivere:
Contact.find_by_name "Sandro"
Ottenendo esattamente lo stesso risultato della sintassi classica:
Contact.find(:all,:conditions=>{:name=>"Sandro"})
Ciò che però accade, a livello di framework, è molto diverso: mentre nel secondo caso il metodo find
esiste effettivamente e viene quindi invocato, utilizzando un dynamic_finder
l'interprete cade nel method_missing che, per chi non lo conoscesse, è un metodo al quale vengono automaticamente 'deviate' tutte le chiamate verso funzioni inesistenti.
In ActiveRecord infatti non esiste nulla dal nome find_by_name
e quindi l'interprete non ha alternative se non quelle di invocare il metodo appena introdotto.
Il method_missing
di ActiveRecord::Base implementa al suo interno la logica di gestione dei dynamic finders che, sintetizzando, si traduce nella creazione on-the-fly di un metodo che agisca secondo il comportamento atteso.
Inutile sottolineare quanto tutto questo percorso costi in termini di tempo e abbia come unico vantaggio la (presunta?) migliorata 'leggibilità' del codice prodotto.
Attenzione al Logger
Non c'è molto da aggiungere a quanto riporta il titolo di questo paragrafo: il meccanismo di Log di Rails può risultare utilissimo in fase di sviluppo ma deve essere limitato il più possibile mentre l'applicativo è in produzione. Tale impostazione può essere facilmente eseguita all'interno del file config/environment.rb
alzando il livello di log a fatal
, in modo da intercettare solamente gli errori bloccanti:
config.log_level = :fatal
Il caching
Ho volutamente mantenuto questo paragrafo verso la fine della lista perché in questo caso non stiamo parlando di 'accortezze' per migliorare la velocita della propria applicazione ma di vere e proprie features messe a disposizione dal framework.
Il tema del caching è sufficientemente ampio da occupare un intero capitolo di autorevoli manuali quali 'The Rails Way', non avendo tali spazi e non volendo uscire dal contesto di questo articolo limitiamoci ad alcune considerazioni di carattere generale.
In primis, dove è possibile dovrebbe essere sempre utilizzata la funzione caches_pages che ha l'innegabile vantaggio di salvare come .html l'intero risultato dell'azione passata come parametro 'staticizzando' di fatto la pagina e rendendola così estranea a future elaborazioni; questo metodo si presta benissimo ad ogni applicazione Web che curi una parte istituzionale di front-end (praticamente la maggior parte dei siti-vetrina presenti oggi sulla rete).
Per necessità più articolate il consiglio invece è quello di utilizzare caches_action o del ben più potente fragment caching, del quale potete trovare alcuni esempi sulle API ufficiali di Rails.
Altre piccole cose
Rientrano in questo elenco tutta una serie di micro accorgimenti che possono, nell'insieme, potenziare significativamente la resa delle nostre applicazioni:
Usare i blocchi e non i simboli durante l'utilizzo di 'map' e 'collect'
Rails introduce la possibilità di specificare un simbolo da utilizzare al posto di un blocco in una delle azioni sopracitate. Quello che accade nella pratica è la generazione di una procedura all'interno della quale, utilizzando il metodo send, viene lanciata una funzione dal nome del simbolo specificato su ogni oggetto dell'array:
Contact.all.map &:name
# equivale a scrivere:
Contact.all.map{|c| c.name}
Anche in questo caso siamo di fronte ad una soluzione che permette una scrittura più concisa a scapito di una certa perdita nelle performances.
memoize
Con memoization viene definita quella tecnica che consente di aumentare le performance della nostra applicazione salvando in variabili di istanza il frutto di (potenzialmente) complicate operazioni. Rails ha recentemente reso molto più semplice avvalersi di questo accorgimento semplicemente specificando all'interno di un model quali metodi vogliamo 'memoizzare':
def nome_e_cognome "#{@nome} #{@cognome}" end # memoize inserisce in una variabile di istanza il risultato # del metodo prevenendo successive elaborazioni memoize :nome_e_cognome
Useless queries
Ipotizziamo il seguente scenario nel quale siano presenti due modelli, legati tra di loro da una associazione uno a molti (un Contatto ha molti Indirizzi):
Contact.all.each do |contact| puts "#{contact.addresses.first.street}" end
se, dopo aver eseguito questo frammento di codice, provate ad osservare l'output del vostro log noterete come ad ogni iterazione su di un oggetto contatto venga generata una query verso la tabella degli indirizzi.
Questo accade perché Rails risponde alla chiamata contact.addresses
andando a recuperare dal database tutti gli indirizzi di un dato contatto. È possibile migliorare notevolmente questa situazione modificando leggermento il codice appena presentato:
Contact.all(:include=>:addresses) do |contact| puts "#{contact.addresses.first.street}" end
In questo secondo esempio abbiamo specificato ad ActiveRecord di includere nella query sulla tabella contacts
anche le informazioni legate alla tabella addresses
(nella pratica questo verrà fatto tramite una join
); così facendo l'intero ciclo avverrà senza dispendio di inutili queries in quanto la totalità delle informazioni sarà gia stata precaricata nei models.
Conclusioni
Questo articolo è nato con il non facile obiettivo di illustrare con la massima chiarezza possibile alcune tecniche di perfezionamento legate a Rails e a Ruby. Dovendo sintetizzare l'intero aspetto qui trattato è però possibile evincere una considerazione di carattere generale secondo la quale l'ottimizzazione di un applicazione Rails presenta un grado di efficienza proporzionale alla conoscenza dei meccanismi interni che sottendono le funzioni utilizzate. Maggiore sarà il 'grip' posseduto dallo sviluppatore a livello di framework e di linguaggio e migliori risulteranno le performances di quanto creato.
In ogni caso quanto spiegato nei capitoli precedenti tratta la quasi totalità dei più rilevanti problemi riscontrabili e dovrebbe consentire a chiunque voglia cimentarsi nell'applicazione dei principi esposti un piacevole e percepibile incremento delle performances.