Utilizzo di Func al posto delle interfacce per IoC

11

Contesto: sto usando C #

Ho progettato una classe, e per isolarla e semplificare i test unitari, sto passando tutte le sue dipendenze; non esegue l'istanziazione degli oggetti internamente. Tuttavia, invece di fare riferimento alle interfacce per ottenere i dati di cui ha bisogno, faccio riferimento a funzioni generiche che restituiscono i dati / il comportamento che richiede. Quando iniettare le sue dipendenze, posso farlo con espressioni lambda.

Per me, questo mi sembra un approccio migliore perché non devo fare nessun noioso sbeffeggiamento durante i test unitari. Inoltre, se l'implementazione circostante ha cambiamenti fondamentali, ho solo bisogno di cambiare la classe factory; non saranno necessarie modifiche nella classe contenente la logica.

Tuttavia, non ho mai visto IoC farlo in questo modo prima d'ora, il che mi fa pensare che ci siano alcuni potenziali insidie che potrei mancare. L'unica a cui riesco a pensare è un'incompatibilità minore con la versione precedente di C # che non definisce Func, e questo non è un problema nel mio caso.

Ci sono problemi con l'utilizzo di delegati generici / funzioni di ordine superiore come Func per IoC, al contrario di interfacce più specifiche?

    
posta TheCatWhisperer 03.04.2017 - 20:42
fonte

5 risposte

8

Se un'interfaccia contiene solo una funzione, non di più, l'utilizzo di Func evita il codice di codice non necessario e nella maggior parte dei casi è preferibile, proprio come quando si inizia a progettare un DTO e si riconosce che è necessario un solo attributo membro.

Immagino che molte persone siano più abituate a usare interfaces , perché al momento in cui l'integrazione delle dipendenze e IoC stavano diventando popolari, non esisteva un reale equivalente alla classe Func in Java o C ++ (non sono nemmeno certo se Func era disponibile in quel momento in C #). Quindi molti tutorial, esempi o libri di testo preferiscono ancora il formato interface , anche se l'utilizzo di Func sarebbe più elegante.

Potresti esaminare la mia precedente risposta sul principio di segregazione dell'interfaccia e l'approccio di Flow Design di Ralf Westphal. Questo paradigma implementa DI con Func parametri esclusivamente , esattamente per la stessa ragione per cui hai già citato te stesso (e altri ancora). Quindi, come vedi, la tua idea non è davvero nuova, anzi.

E sì, ho usato questo approccio da solo, per codice di produzione, per i programmi che dovevano elaborare i dati sotto forma di una pipeline con diversi passaggi intermedi, compresi i test unitari per ciascuno dei passaggi. Quindi da quello posso darti un'esperienza di prima mano che può funzionare molto bene.

    
risposta data 03.04.2017 - 23:29
fonte
7

Uno dei principali vantaggi che trovo con IoC è che mi permetterà di dare un nome a tutte le mie dipendenze nominando la loro interfaccia, e il contenitore saprà quale fornire al costruttore facendo corrispondere i nomi dei tipi. Questo è conveniente e consente nomi di dipendenze molto più descrittivi di Func<string, string> .

Spesso, inoltre, trovo che anche con una singola, semplice dipendenza, a volte è necessario avere più di una funzione: un'interfaccia ti consente di raggruppare queste funzioni in un modo che è auto-documentante, rispetto a più parametri che tutti leggi come Func<string, string> , Func<string, int> .

Ci sono sicuramente momenti in cui è utile avere semplicemente un delegato che viene passato come dipendenza. È un giudizio quando si utilizza un delegato o un'interfaccia con pochissimi membri. A meno che non sia veramente chiaro quale sia lo scopo dell'argomento, di solito commuoverò sul lato della produzione di codice auto-documentante; vale a dire. scrivere un'interfaccia.

    
risposta data 03.04.2017 - 20:52
fonte
3

Are there any problems with using generic delegates/higher order functions such as Func for IoC, as opposed to more specific interfaces?

Non proprio. A Func è una sorta di interfaccia (nel significato inglese, non nel significato di C #). "Questo parametro è qualcosa che fornisce X quando viene richiesto." Il Func ha persino il vantaggio di fornire pigramente le informazioni solo se necessario. Lo faccio un po 'e lo consiglio con moderazione.

Per quanto riguarda gli aspetti negativi:

  • I contenitori IoC spesso fanno un po 'di magia per collegare le dipendenze in un modo a cascata, e probabilmente non giocheranno bene quando alcune cose sono T e alcune cose sono Func<T> .
  • I Func hanno qualche riferimento indiretto, quindi può essere un po 'più difficile ragionare e fare il debug.
  • I func ritardano l'istanziazione, il che significa che gli errori di runtime possono comparire in momenti strani o per niente durante il test. Può anche aumentare le possibilità di ordine di problemi operativi e in base all'utilizzo, deadlock nell'ordinamento di inizializzazione.
  • Quello che passi nel Func è probabile che sia una chiusura, con il leggero sovraccarico e le complicazioni che comportano.
  • Chiamare un Func è un po 'più lento dell'accesso diretto all'oggetto. (Non abbastanza da farti notare in qualsiasi programma non banale, ma è lì)
risposta data 03.04.2017 - 23:30
fonte
2

Facciamo un semplice esempio: forse stai iniettando un mezzo di registrazione.

Iniezione di una classe

class Worker: IWorker
{
    ILogger _logger;

    Worker(ILogger logger)
    {
        _logger = logger;
    }
    void SomeMethod()
    {
        _logger.Debug("This is a debug log statement.");
    }
}        

Penso che sia abbastanza chiaro cosa sta succedendo. Inoltre, se stai utilizzando un contenitore IoC, non hai nemmeno bisogno di iniettare qualcosa in modo esplicito, basta aggiungere alla tua radice di composizione:

container.RegisterType<ILogger, ConcreteLogger>();
container.RegisterType<IWorker, Worker>();
....
var worker = container.Resolve<IWorker>();

Quando esegui il debug di Worker , uno sviluppatore deve solo consultare la root di composizione per determinare quale classe concreta viene utilizzata.

Se uno sviluppatore ha bisogno di una logica più complicata, ha l'intera interfaccia con cui lavorare:

    void SomeMethod()
    { 
       if (_logger.IsDebugEnabled) {
           _logger.Debug("This is a debug log statement.");
       }
    }

Iniezione di un metodo

class Worker
{
    Action<string> _methodThatLogs;

    Worker(Action<string> methodThatLogs)
    {
        _methodThatLogs = methodThatLogs;
    }
    void SomeMethod()
    {
        _methodThatLogs("This is a logging statement");
    }
}        

Per prima cosa, nota che il parametro costruttore ha un nome più lungo ora, methodThatLogs . Questo è necessario perché non puoi dire cosa si suppone debba fare un Action<string> . Con l'interfaccia, era completamente chiaro, ma qui dobbiamo ricorrere al fare affidamento sulla denominazione dei parametri. Ciò sembra intrinsecamente meno affidabile e più difficile da applicare durante una compilazione.

Ora, come possiamo iniettare questo metodo? Bene, il contenitore IoC non lo farà per te. Pertanto, ti viene iniettato esplicitamente quando installi Worker . Ciò solleva un paio di problemi:

  1. È più lavoro istanziare un Worker
  2. Gli sviluppatori che tentano di eseguire il debug di Worker troveranno più difficile capire quale chiamata concreta venga chiamata. Non possono semplicemente consultare la radice della composizione; dovranno tracciare il codice.

Che ne dici se abbiamo bisogno di una logica più complicata? La tua tecnica espone solo un metodo. Ora suppongo che potresti preparare le cose complicate nella lambda:

var worker = new Worker((s) => { if (log.IsDebugEnabled) log.Debug(s) } );

ma quando scrivi i tuoi test unitari, come collaudi l'espressione lambda? È anonimo, quindi il tuo framework di test unitario non può istanziarlo direttamente. Forse puoi capire un modo intelligente per farlo, ma probabilmente sarà un PITA più grande di usare un'interfaccia.

Riepilogo delle differenze:

  1. L'iniezione di un solo metodo rende più difficile dedurre lo scopo, mentre un'interfaccia comunica chiaramente lo scopo.
  2. L'iniezione solo di un metodo espone meno funzionalità alla classe che riceve l'iniezione. Anche se non ne hai bisogno oggi, potresti averne bisogno domani.
  3. Non puoi iniettare automaticamente solo un metodo usando un contenitore IoC.
  4. Non si può sapere dalla radice della composizione quale classe concreta è al lavoro in una particolare istanza.
  5. È un problema testare l'espressione lambda stessa.

Se stai bene con tutto quanto sopra, allora è OK iniettare solo il metodo. Altrimenti ti suggerirei di rimanere fedele alla tradizione e iniettare un'interfaccia.

    
risposta data 04.04.2017 - 04:50
fonte
0

Considera il seguente codice che ho scritto molto tempo fa:

public interface IPhysicalPathMapper
{
    /// <summary>
    /// Gets the physical path represented by the relative URL.
    /// </summary>
    /// <param name="relativeURL"></param>
    /// <returns></returns>
    String GetPhysicalPath(String relativeURL);
}

public class EmailBuilder : IEmailBuilder
{
    public IPhysicalPathMapper PhysicalPathMapper { get; set; }
    public ITextFileLoader TextFileLoader { get; set; }
    public IEmailTemplateParser EmailTemplateParser { get; set; }
    public IEmaiBodyRenderer EmailBodyRenderer { get; set; }

    public String FromAddress { get; set; }

    public MailMessage BuildMailMessage(String templateRelativeURL, Object model, IEnumerable<String> toAddresses)
    {
        String templateText = this.TextFileLoader.LoadTextFromFile(this.PhysicalPathMapper.GetPhysicalPath(templateRelativeURL));

        EmailTemplate template = this.EmailTemplateParser.Parse(templateText);

        MailMessage email = new MailMessage()
        {
            From = new MailAddress(this.FromAddress),
            Subject = template.Subject,
            IsBodyHtml = true,
            Body = this.EmailBodyRenderer.RenderBodyToHtml(template.BodyTemplate, model)
        };

        foreach (MailAddress recipient in toAddresses.Select<String, MailAddress>(toAddress => new MailAddress(toAddress)))
        {
            email.To.Add(recipient);
        }

        return email;
    }
}

Prende una posizione relativa a un file di modello, lo carica in memoria, esegue il rendering del corpo del messaggio e assembla l'oggetto dell'e-mail.

Potresti guardare IPhysicalPathMapper e pensare: "Esiste solo una funzione, che potrebbe essere un Func ." Ma in realtà, il problema qui è che IPhysicalPathMapper non dovrebbe nemmeno esistere. Una soluzione molto migliore è quella di parametrizzare il percorso :

public class EmailBuilder : IEmailBuilder
{
    public ITextFileLoader TextFileLoader { get; set; }
    public IEmailTemplateParser EmailTemplateParser { get; set; }
    public IEmaiBodyRenderer EmailBodyRenderer { get; set; }

    public String FromAddress { get; set; }

    public MailMessage BuildMailMessage(String templatePath, Object model, IEnumerable<String> toAddresses)
    {
        String templateText = this.TextFileLoader.LoadTextFromFile(templatePath);

        EmailTemplate template = this.EmailTemplateParser.Parse(templateText);

        MailMessage email = new MailMessage()
        {
            From = new MailAddress(this.FromAddress),
            Subject = template.Subject,
            IsBodyHtml = true,
            Body = this.EmailBodyRenderer.RenderBodyToHtml(template.BodyTemplate, model)
        };

        foreach (MailAddress recipient in toAddresses.Select<String, MailAddress>(toAddress => new MailAddress(toAddress)))
        {
            email.To.Add(recipient);
        }

        return email;
    }
}

Ciò solleva un'intera serie di altre domande per migliorare questo codice. Ad esempio, forse dovrebbe solo accettare un EmailTemplate , e quindi forse dovrebbe accettare un modello pre-renderizzato, e quindi forse dovrebbe essere solo in-linea.

Questo è il motivo per cui non mi piace l'inversione del controllo come modello pervasivo. Generalmente è considerato una soluzione da dio per comporre tutto il tuo codice. Ma in realtà, se lo usi in modo pervasivo (al contrario di quanto accade con parsimonia), rende il tuo codice molto peggiore incoraggiandoti a introdurre molte interfacce non necessarie che vengono utilizzate completamente all'indietro. (A ritroso nel senso che il chiamante dovrebbe essere realmente responsabile della valutazione di tali dipendenze e del passaggio del risultato piuttosto che della classe stessa che richiama la chiamata).

Le interfacce dovrebbero essere utilizzate con parsimonia, e l'inversione di controllo e iniezione di dipendenza dovrebbe essere usata con parsimonia. Se ne hai una grande quantità, il tuo codice diventa molto più difficile da decifrare.

    
risposta data 18.08.2018 - 06:06
fonte