Lasciami spiegare subito il titolo dell'articolo. Inizialmente, intendevo fornire un buon e affidabile consiglio su come migliorare l'uso della riflessione attraverso un esempio semplice ma realistico. Tuttavia, durante il benchmarking, si è rivelato che la riflessione non è così lenta come pensavo, mentre LINQ si comporta peggio di quanto avessi mai sognato. Alla fine, ho anche commesso un errore nelle misurazioni... I dettagli di questa storia di vita sono nei commenti e sotto il post. Poiché l'esempio è piuttosto comune e realizzato come si fa di solito nell'ambito enterprise, ne è derivata un'interessante, a mio avviso, dimostrazione della vita: l'impatto sulla velocità dell'oggetto principale dell'articolo non è stato evidente a causa della logica esterna: Moq, Autofac, EF Core e altri "stratagemmi".
Ho iniziato a lavorare impressionato da questo articolo:
Come si può vedere, l'autore propone di utilizzare delegati compilati anziché chiamare direttamente i metodi dei tipi di riflessione come un ottimo modo per accelerare notevolmente il funzionamento dell'applicazione. C'è anche l'emissione di IL, ma vorrei evitarla, essendo il modo più dispendioso per eseguire il compito, e soggetto a errori.
Tenendo conto che ho sempre aderito a un'opinione simile sulla velocità della riflessione, non avevo intenzione di mettere in dubbio le conclusioni dell'autore.
Spesso mi trovo di fronte a un uso ingenuo della riflessione nell'ambito enterprise. Si prende un tipo, si ottiene l'informazione sulla proprietà, si chiama il metodo SetValue e tutti sono contenti. Il valore è arrivato nel campo target e tutti sono soddisfatti. Persone molto intelligenti - senior e team leader - scrivono le loro estensioni su object, basandosi su tale implementazione ingenua per creare mapper "universali" da un tipo all'altro. Di solito, l'idea è questa: prendiamo tutti i campi, prendiamo tutte le proprietà e itera su di esse: quando i nomi dei membri coincidono, eseguiamo SetValue. Di tanto in tanto intercettiamo eccezioni per errori legati a proprietà non trovate in uno dei tipi, ma anche qui c'è una soluzione che migliora le prestazioni: Try/catch.
Ho visto persone reinventare parser e mapper, non avendo una piena comprensione di come funzionano i "cicli" inventati prima di loro. Ho visto persone nascondere le loro implementazioni ingenue dietro strategie, interfacce e iniezioni, come se questo giustificasse la successiva confusione. Ho storto il naso di fronte a tali implementazioni. In realtà, non ho mai misurato una vera perdita di prestazioni e, se possibile, cambiavo semplicemente l'implementazione in una più "ottimizzata" quando me ne capitava l'occasione. Pertanto, le prime misurazioni di cui si parla di seguito mi hanno sorpreso seriamente.
Credo che molti di voi, leggendo Richter o altri ideologi, si siano imbattuti in un'affermazione piuttosto giusta: che la riflessione nel codice è un fenomeno che influisce negativamente sulle prestazioni dell'applicazione.
La chiamata alla riflessione costringe il CLR a esplorare gli assembly alla ricerca di quello giusto, a caricare i loro metadati, a elaborarli, ecc. Inoltre, la riflessione durante l'iterazione delle sequenze porta all'allocazione di una grande quantità di memoria. Consumiamo memoria, il CLR attiva il GC e si verificano lag. Dovrebbe essere chiaramente lento, credetemi. Le enormi quantità di memoria dei moderni server di produzione o delle macchine cloud non risolvono il problema delle elevate latenze nel trattamento. In effetti, più memoria hai, maggiore è la probabilità che NOTATE come funzioni il GC. La riflessione è, in teoria, un pezzetto rosso in più per esso.
Tuttavia, tutti noi utilizziamo contenitori IoC e data mapper, il cui funzionamento si basa anch'esso sulla riflessione, eppure di solito non ci sono domande sulle loro prestazioni. No, non perché l'iniezione delle dipendenze e l'astrazione dai modelli di contesto esterno siano visibilmente necessarie, a tal punto che dobbiamo sacrificare le prestazioni in ogni caso. È tutto più semplice: non influenzano realmente le prestazioni.
Il fatto è che i framework più comuni, che si basano sulla tecnologia della riflessione, utilizzano vari accorgimenti per lavorare con essa in modo più ottimale. Di solito, si tratta di caching. Comunemente, si tratta di Expressions e delegati compilati dall'albero delle espressioni. Lo stesso automapper mantiene un dizionario concorrente che associa i tipi a funzioni che possono convertire uno nell'altro senza richiamare la riflessione.
Come si raggiunge questo? In sostanza, non è diverso dalla logica utilizzata dalla stessa piattaforma per generare il codice JIT. Alla prima chiamata del metodo, esso viene compilato (e, sì, questo processo non è veloce), mentre alle chiamate successive, il controllo passa al metodo già compilato e non ci saranno particolari cali di prestazioni.
Nel nostro caso è possibile utilizzare la compilazione JIT e poi sfruttare il comportamento compilato con le stesse prestazioni delle sue controparti AOT. In questo caso, ci aiuteranno le espressioni.
In breve, il principio di cui si parla può essere riassunto nel seguente modo:
È importante memorizzare nella cache il risultato finale del lavoro di riflessione sotto forma di delegato contenente la funzione compilata. Anche tutti gli oggetti necessari con le informazioni sui tipi è opportuno memorizzarli nella cache nei campi del tuo tipo - worker, salvati al di fuori degli oggetti.
C'è logica in questo. Il buon senso ci dice che se qualcosa può essere compilato e memorizzato nella cache, allora è preferibile farlo.
Per anticipare, vale la pena dire che la cache nel lavoro con la riflessione ha i suoi vantaggi, anche senza utilizzare il metodo di compilazione delle espressioni. Qui ripeterò semplicemente i punti dell'autore dell'articolo citato sopra.
Adesso parliamo del codice. Consideriamo un esempio, basato su una mia recente esperienza, affrontata in un importante ambiente di produzione di una seriosa istituzione creditizia. Tutti gli enti sono fittizi, affinché nessuno possa riconoscerli.
C'è una certa entità. Chiamiamola Contatto. Ci sono email con un corpo standardizzato, dalle quali il parser e il 'hydrator' creano questi contatti. È arrivata un'email, l'abbiamo letta, scomposta in coppie chiave-valore, creato il contatto e salvato nel database.
È elementare. Supponiamo che il contatto abbia proprietà come Nome, Età e numero di telefono. Questi dati vengono trasmessi nell'email. Inoltre, il business desidera che il supporto possa aggiungere rapidamente nuove chiavi per mappare le proprietà dell'entità su coppie nel corpo dell'email. Nel caso qualcuno commetta un errore nel modello o se prima del rilascio sarà necessario avviare rapidamente il mapping con un nuovo partner, adattandosi a un nuovo formato. In tal modo, potremo aggiungere la nuova correlazione di mapping come un'agevole correzione dati. Cioè, un esempio concreto.
Implementiamo, creiamo test. Funziona.
Non fornirò il codice: sfortunatamente, è molto voluminoso e disponibile su GitHub tramite il link alla fine dell'articolo. Puoi scaricarlo, sovraccaricarlo fino all'irriconoscibilità e misurare come ciò influirebbe nel tuo caso. Riporterò solo il codice di due metodi template, che distinguono l'hydrator che doveva essere veloce da quello che doveva essere lento.
La logica è la seguente: il metodo template riceve coppie formulate dalla logica di base del parser. Il livello LINQ è il parser e la logica di base dell'hydrator che fa la richiesta al contesto del database e mappa le chiavi con le coppie dal parser (per queste funzioni è disponibile il codice senza LINQ per confronto). Le coppie vengono poi trasmesse al metodo principale di idratazione e i valori delle proprietà dell'entità vengono impostati di conseguenza.
«Veloce» (Prefix Fast nei benchmark):
protected override Contact GetContact(PropertyToValueCorrelation[] correlations)
{
var contact = new Contact();
foreach (var setterMapItem in _proprtySettersMap)
{
var correlation = correlations.FirstOrDefault(x => x.PropertyName == setterMapItem.Key);
setterMapItem.Value(contact, correlation?.Value);
}
return contact;
}
Come vediamo, viene utilizzata una collezione statica con i setter delle proprietà - lambda compilate, che chiamano il setter dell'entità. Vengono create con il seguente codice:
static FastContactHydrator()
{
var type = typeof(Contact);
foreach (var property in type.GetProperties())
{
_proprtySettersMap[property.Name] = GetSetterAction(property);
}
}
private static Action GetSetterAction(PropertyInfo property)
{
var setterInfo = property.GetSetMethod();
var paramValueOriginal = Expression.Parameter(property.PropertyType, "value");
var paramEntity = Expression.Parameter(typeof(Contact), "entity");
var setterExp = Expression.Call(paramEntity, setterInfo, paramValueOriginal).Reduce();
var lambda = (Expression<Action>)Expression.Lambda(setterExp, paramEntity, paramValueOriginal);
return lambda.Compile();
}
In generale, è chiaro. Passiamo in rassegna le proprietà, creiamo delegati che chiamano i setter, e li memorizziamo. Poi li chiamiamo quando necessario.
«Lento» (Prefix Slow nei benchmark):
protected override Contact GetContact(PropertyToValueCorrelation[] correlations)
{
var contact = new Contact();
foreach (var property in _properties)
{
var correlation = correlations.FirstOrDefault(x => x.PropertyName == property.Name);
if (correlation?.Value == null)
continue;
property.SetValue(contact, correlation.Value);
}
return contact;
}
Qui, passiamo direttamente alle proprietà e chiamiamo direttamente SetValue.
Per chiarezza e come riferimento, ho implementato un metodo naïf, che scrive i valori delle coppie di correlazione direttamente nei campi dell'entità. Prefisso - Manual.
Adesso prendiamo BenchmarkDotNet e analizziamo le prestazioni. E improvvisamente... (spoiler: non è il risultato corretto, dettagli - sotto)

Cosa vediamo qui? I metodi che vantano il prefisso Fast risultano, nella maggior parte dei casi, più lenti rispetto ai metodi con il prefisso Slow. Questo è vero sia per l'allocazione che per la velocità operativa. D'altra parte, una bella ed elegante implementazione del mapping, utilizzando ovunque possibile i metodi LINQ progettati per questo scopo, compromette notevolmente le performance. La differenza è notevole. La tendenza non cambia con un numero variabile di esecuzioni. La sola variazione è nelle proporzioni. Con LINQ si è fino a 200 volte più lenti, con un aumento di spazzatura in proporzioni simili.
AGGIORNATO
Non potevo credere ai miei occhi, ma cosa più importante, né ai miei occhi né al mio codice ha creduto il nostro collega — . Controllando la mia soluzione ha brillantemente scoperto e indicato l'errore che avevo trascurato a causa di diverse modifiche nell'implementazione da iniziale a finale. Dopo aver corretto il bug trovato nella configurazione di Moq, tutti i risultati si sono allineati. Dai risultati del retest, la tendenza principale non cambia: LINQ impatta ancora di più sulle performance rispetto alla riflessione. Tuttavia, è piacevole vedere che il lavoro con la compilazione delle espressioni non è stato vano, e il risultato è evidente sia nell'allocazione che nei tempi di esecuzione. Il primo avvio, quando vengono inizializzati i campi statici, è naturalmente più lento per il metodo "veloce", ma poi la situazione cambia.
Ecco il risultato del retest:

Conclusione: nell'uso enterprise della riflessione non è necessario ricorrere a trucchi particolari — LINQ consumerà di più le performance. Tuttavia, in metodi ad alta intensità che richiedono ottimizzazione, si può mantenere la riflessione come inizializzatori e compilatori di delegati, che poi garantiranno la logica "veloce". Così puoi mantenere sia la flessibilità della riflessione che la velocità operativa dell'applicazione.
Il codice con il benchmark è disponibile qui. Chiunque può verificare le mie affermazioni:
PS: il codice nei test utilizza IoC, mentre nei benchmark – una costruzione esplicita. Questo perché nella realizzazione finale ho eliminato tutti i fattori in grado di influenzare le performance e di introdurre rumore nei risultati.
PPS: Grazie all'utente per aver scoperto il mio errore nella configurazione di Moq, che ha influenzato le prime misurazioni. Se qualcuno dei lettori ha abbastanza karma, per favore mettete un like. Questa persona si è fermata, ha analizzato, ha ricontrollato e ha indicato l'errore. Credo che questo meriti rispetto e simpatia.
PPPS: grazie a quel lettore attento che ha fatto notare lo stile e la formattazione. Io duevo uniformità e comodità. La diplomaticità della presentazione lascia a desiderare, ma ho tenuto conto della critica. Procediamo.
Fonte: habr.com
