Comandi di query e / o specifiche ben progettati


90

Ho cercato per un po 'di tempo una buona soluzione ai problemi presentati dal tipico pattern Repository (elenco crescente di metodi per query specializzate, ecc. Vedere: http://ayende.com/blog/3955/repository- è-il-nuovo-singleton ).

Mi piace molto l'idea di utilizzare le query di comando, in particolare attraverso l'uso del modello di specifica. Tuttavia, il mio problema con la specifica è che si riferisce solo ai criteri di selezioni semplici (fondamentalmente, la clausola where) e non tratta gli altri problemi delle query, come unione, raggruppamento, selezione o proiezione di sottoinsiemi, ecc. fondamentalmente, tutti i cerchi extra che molte query devono superare per ottenere il set corretto di dati.

(nota: utilizzo il termine "comando" come nel modello di comando, noto anche come oggetti query. Non sto parlando di comando come nella separazione comando / query in cui viene fatta una distinzione tra query e comandi (aggiornamento, eliminazione, inserire))

Quindi sto cercando alternative che incapsulino l'intera query, ma comunque abbastanza flessibili da non scambiare solo i repository di spaghetti con un'esplosione di classi di comando.

Ho usato, ad esempio, Linqspecs, e anche se trovo un certo valore nell'assegnare nomi significativi ai criteri di selezione, non è sufficiente. Forse sto cercando una soluzione mista che combini più approcci.

Sto cercando soluzioni che altri potrebbero aver sviluppato per risolvere questo problema o per risolvere un problema diverso ma che soddisfi comunque questi requisiti. Nell'articolo collegato, Ayende suggerisce di utilizzare direttamente il contesto nHibernate, ma ritengo che ciò complichi in gran parte il livello aziendale perché ora deve contenere anche le informazioni sulle query.

Ti offrirò una taglia su questo, non appena il periodo di attesa sarà trascorso. Quindi, per favore, rendi le tue soluzioni degne di gratitudine, con buone spiegazioni e selezionerò la soluzione migliore e voterò i secondi classificati.

NOTA: sto cercando qualcosa che sia basato su ORM. Non deve essere esplicitamente EF o nHibernate, ma quelli sono i più comuni e si adatterebbero al meglio. Se può essere facilmente adattato ad altri ORM, sarebbe un vantaggio. Sarebbe bello anche compatibile con Linq.

AGGIORNAMENTO: Sono davvero sorpreso che non ci siano molti buoni suggerimenti qui. Sembra che le persone o siano totalmente CQRS, o siano completamente nel campo del Repository. La maggior parte delle mie app non sono abbastanza complesse da giustificare CQRS (qualcosa con la maggior parte dei sostenitori di CQRS prontamente dice che non dovresti usarlo per).

AGGIORNAMENTO: sembra esserci un po 'di confusione qui. Non sto cercando una nuova tecnologia di accesso ai dati, ma piuttosto un'interfaccia ragionevolmente ben progettata tra business e dati.

Idealmente, quello che sto cercando è una sorta di incrocio tra oggetti Query, modello di specifica e repository. Come ho detto sopra, il modello di specifica si occupa solo dell'aspetto della clausola where e non degli altri aspetti della query, come join, sotto-selezioni, ecc. I repository si occupano dell'intera query, ma sfuggono di mano dopo un po ' . Gli oggetti di query gestiscono anche l'intera query, ma non voglio semplicemente sostituire i repository con esplosioni di oggetti di query.


5
Domanda fantastica. Anch'io vorrei vedere cosa suggeriscono le persone con più esperienza. Al momento sto lavorando su una base di codice in cui il repository generico contiene anche overload per oggetti Command o oggetti Query, la cui struttura è simile a quella descritta da Ayende nel suo blog. PS: questo potrebbe anche attirare l'attenzione dei programmatori.
Simon Whitehead,

Perché non utilizzare semplicemente un repository che espone IQueryable se non ti dispiace la dipendenza da LINQ? Un approccio comune è un repository generico, quindi quando è necessaria la logica riutilizzabile sopra, si crea un tipo di repository derivato con i metodi aggiuntivi.
devdigital

@devdigital - La dipendenza da Linq non è la stessa della dipendenza dall'implementazione dei dati. Vorrei usare Linq per gli oggetti, così posso ordinare o eseguire altre funzioni del livello aziendale. Ma questo non significa che voglio dipendenze dall'implementazione del modello di dati. Quello di cui sto veramente parlando qui è l'interfaccia layer / tier. Ad esempio, voglio essere in grado di modificare una query e non doverla cambiare in 200 posizioni, che è ciò che accade se si inserisce IQueryable direttamente nel modello di business.
Erik Funkenbusch

1
@devdigital - che fondamentalmente sposta solo i problemi con un repository nel tuo livello aziendale. Stai solo mescolando il problema.
Erik Funkenbusch

Risposte:


94

Dichiarazione di non responsabilità: poiché non ci sono ancora ottime risposte, ho deciso di pubblicare una parte di un ottimo post sul blog che ho letto qualche tempo fa, copiato quasi alla lettera. Puoi trovare il post completo del blog qui . Quindi eccolo qui:


Possiamo definire le seguenti due interfacce:

public interface IQuery<TResult>
{
}

public interface IQueryHandler<TQuery, TResult> where TQuery : IQuery<TResult>
{
    TResult Handle(TQuery query);
}

Le IQuery<TResult>Specifica un messaggio che definisce una query specifica con i dati restituisce utilizzando il TResulttipo generico. Con l'interfaccia definita in precedenza possiamo definire un messaggio di query come questo:

public class FindUsersBySearchTextQuery : IQuery<User[]>
{
    public string SearchText { get; set; }
    public bool IncludeInactiveUsers { get; set; }
}

Questa classe definisce un'operazione di query con due parametri, che risulterà in un array di Useroggetti. La classe che gestisce questo messaggio può essere definita come segue:

public class FindUsersBySearchTextQueryHandler
    : IQueryHandler<FindUsersBySearchTextQuery, User[]>
{
    private readonly NorthwindUnitOfWork db;

    public FindUsersBySearchTextQueryHandler(NorthwindUnitOfWork db)
    {
        this.db = db;
    }

    public User[] Handle(FindUsersBySearchTextQuery query)
    {
        return db.Users.Where(x => x.Name.Contains(query.SearchText)).ToArray();
    }
}

Ora possiamo consentire ai consumatori di dipendere IQueryHandlerdall'interfaccia generica :

public class UserController : Controller
{
    IQueryHandler<FindUsersBySearchTextQuery, User[]> findUsersBySearchTextHandler;

    public UserController(
        IQueryHandler<FindUsersBySearchTextQuery, User[]> findUsersBySearchTextHandler)
    {
        this.findUsersBySearchTextHandler = findUsersBySearchTextHandler;
    }

    public View SearchUsers(string searchString)
    {
        var query = new FindUsersBySearchTextQuery
        {
            SearchText = searchString,
            IncludeInactiveUsers = false
        };

        User[] users = this.findUsersBySearchTextHandler.Handle(query);    
        return View(users);
    }
}

Immediatamente questo modello ci offre molta flessibilità, perché ora possiamo decidere cosa iniettare nel UserController. Possiamo iniettare un'implementazione completamente diversa, o una che avvolge l'implementazione reale, senza dover apportare modifiche a UserController(ea tutti gli altri utenti di quell'interfaccia).

L' IQuery<TResult>interfaccia ci fornisce il supporto in fase di compilazione quando si specifica o si inserisce IQueryHandlersil codice. Quando cambiamo invece il FindUsersBySearchTextQueryto return UserInfo[](implementandolo IQuery<UserInfo[]>), la UserControllercompilazione fallirà, poiché il vincolo di tipo generico su IQueryHandler<TQuery, TResult>non sarà in grado di mappare FindUsersBySearchTextQuerya User[].

IQueryHandlerTuttavia, l'iniezione dell'interfaccia in un consumatore presenta alcuni problemi meno evidenti che devono ancora essere risolti. Il numero di dipendenze dei nostri consumatori potrebbe diventare troppo grande e può portare a un'iniezione eccessiva del costruttore, quando un costruttore accetta troppi argomenti. Il numero di query eseguite da una classe può cambiare frequentemente, il che richiederebbe modifiche costanti nel numero di argomenti del costruttore.

Possiamo risolvere il problema di dover iniettarne troppi IQueryHandlerscon un ulteriore livello di astrazione. Creiamo un mediatore che si trova tra i consumatori e i gestori di query:

public interface IQueryProcessor
{
    TResult Process<TResult>(IQuery<TResult> query);
}

La IQueryProcessorè un'interfaccia non generica con un metodo generico. Come puoi vedere nella definizione dell'interfaccia, IQueryProcessordipende IQuery<TResult>dall'interfaccia. Questo ci consente di avere supporto in fase di compilazione nei nostri consumatori che dipendono da IQueryProcessor. Riscriviamo il UserControllerper usare il nuovo IQueryProcessor:

public class UserController : Controller
{
    private IQueryProcessor queryProcessor;

    public UserController(IQueryProcessor queryProcessor)
    {
        this.queryProcessor = queryProcessor;
    }

    public View SearchUsers(string searchString)
    {
        var query = new FindUsersBySearchTextQuery
        {
            SearchText = searchString,
            IncludeInactiveUsers = false
        };

        // Note how we omit the generic type argument,
        // but still have type safety.
        User[] users = this.queryProcessor.Process(query);

        return this.View(users);
    }
}

L' UserControlleradesso dipende da un in IQueryProcessorgrado di gestire tutte le nostre domande. Il UserController's SearchUsersmetodo chiama il IQueryProcessor.Processmetodo passando un oggetto query inizializzata. Poiché FindUsersBySearchTextQueryimplementa l' IQuery<User[]>interfaccia, possiamo passarla al Execute<TResult>(IQuery<TResult> query)metodo generico . Grazie all'inferenza del tipo C #, il compilatore è in grado di determinare il tipo generico e questo ci evita di dover dichiarare esplicitamente il tipo. È Processnoto anche il tipo restituito del metodo.

È ora responsabilità dell'attuazione del IQueryProcessortrovare il diritto IQueryHandler. Ciò richiede una digitazione dinamica e, facoltativamente, l'uso di un framework di inserimento delle dipendenze e può essere fatto con poche righe di codice:

sealed class QueryProcessor : IQueryProcessor
{
    private readonly Container container;

    public QueryProcessor(Container container)
    {
        this.container = container;
    }

    [DebuggerStepThrough]
    public TResult Process<TResult>(IQuery<TResult> query)
    {
        var handlerType = typeof(IQueryHandler<,>)
            .MakeGenericType(query.GetType(), typeof(TResult));

        dynamic handler = container.GetInstance(handlerType);

        return handler.Handle((dynamic)query);
    }
}

La QueryProcessorclasse costruisce un IQueryHandler<TQuery, TResult>tipo specifico in base al tipo dell'istanza di query fornita. Questo tipo viene utilizzato per chiedere alla classe contenitore fornita di ottenere un'istanza di quel tipo. Sfortunatamente dobbiamo chiamare il Handlemetodo usando la reflection (usando la parola chiave dymamic C # 4.0 in questo caso), perché a questo punto è impossibile eseguire il cast dell'istanza del gestore, poiché l' TQueryargomento generico non è disponibile in fase di compilazione. Tuttavia, a meno che il Handlemetodo non venga rinominato o ottenga altri argomenti, questa chiamata non fallirà mai e, se lo desideri, è molto facile scrivere uno unit test per questa classe. L'uso della riflessione darà un leggero calo, ma non è nulla di cui preoccuparsi.


Per rispondere a una delle tue preoccupazioni:

Quindi sto cercando alternative che incapsulino l'intera query, ma comunque abbastanza flessibili da non scambiare solo i repository di spaghetti con un'esplosione di classi di comando.

Una conseguenza dell'utilizzo di questo design è che ci saranno molte classi piccole nel sistema, ma avere molte classi piccole / focalizzate (con nomi chiari) è una buona cosa. Questo approccio è chiaramente molto migliore di avere molti overload con parametri diversi per lo stesso metodo in un repository, poiché è possibile raggrupparli in una classe di query. Quindi ottieni ancora molte meno classi di query rispetto ai metodi in un repository.


2
Sembra che tu abbia ricevuto il premio. Mi piacciono i concetti, speravo solo che qualcuno presentasse qualcosa di veramente diverso. Congratulazioni.
Erik Funkenbusch

1
@FuriCuri, una singola classe ha davvero bisogno di 5 query? Forse potresti considerarla una classe con troppe responsabilità. In alternativa, se le query vengono aggregate, forse dovrebbero essere effettivamente una singola query. Questi sono solo suggerimenti, ovviamente.
Sam

1
@stakx Hai perfettamente ragione che nel mio esempio iniziale il TResultparametro generico IQuerydell'interfaccia non è utile. Tuttavia, nella mia risposta aggiornata, il TResultparametro viene utilizzato dal Processmetodo di IQueryProcessorper risolvere il file IQueryHandlerin fase di esecuzione.
david.s

1
Ho anche un blog con un'implementazione molto simile che mi fa pensare che sono sulla strada giusta, questo è il link jupaol.blogspot.mx/2012/11/… e lo uso da un po 'nelle applicazioni PROD, ma ho avuto un problema con questo approccio. Concatenare e riutilizzare le query Diciamo che ho diverse piccole query che devono essere combinate per creare query più complesse, ho finito per duplicare il codice ma sto cercando un approccio migliore e più pulito. Qualche idea?
Jupaol

4
@Cemre ho finito per incapsulare le mie query in metodi di estensione restituendo IQueryablee assicurandomi di non enumerare la raccolta, quindi QueryHandlerho appena chiamato / catena le query. Questo mi ha dato la flessibilità di testare le mie query e concatenarle. Ho un servizio di applicazione sopra il mio QueryHandlere il mio controller è incaricato di parlare direttamente con il servizio anziché con il gestore
Jupaol

4

Il mio modo di affrontarlo è in realtà semplicistico e indipendente dall'ORM. Il mio punto di vista per un repository è questo: il compito del repository è fornire all'app il modello richiesto per il contesto, quindi l'app chiede semplicemente al repository quello che vuole ma non gli dice come ottenerlo.

Fornisco il metodo del repository con un Criteria (sì, stile DDD), che verrà utilizzato dal repository per creare la query (o qualsiasi altra cosa sia richiesta - potrebbe essere una richiesta del servizio web). I join e i gruppi sono dettagli di come, non di cosa e un criterio dovrebbe essere solo la base per costruire una clausola dove.

Modello = l'oggetto finale o la struttura dati richiesta dall'app.

public class MyCriteria
{
   public Guid Id {get;set;}
   public string Name {get;set;}
    //etc
 }

 public interface Repository
  {
       MyModel GetModel(Expression<Func<MyCriteria,bool>> criteria);
   }

Probabilmente puoi utilizzare direttamente i criteri ORM (Nhibernate) se lo desideri. L'implementazione del repository dovrebbe sapere come utilizzare i criteri con lo storage sottostante o DAO.

Non conosco il tuo dominio e i requisiti del modello ma sarebbe strano se il modo migliore fosse che l'app crei la query stessa. Il modello cambia così tanto che non puoi definire qualcosa di stabile?

Questa soluzione richiede chiaramente del codice aggiuntivo ma non accoppia il resto di un ORM o qualsiasi cosa tu stia utilizzando per accedere allo spazio di archiviazione. Il repository fa il suo lavoro per fungere da facciata e IMO è pulito e il codice di "traduzione dei criteri" è riutilizzabile


Questo non risolve i problemi di crescita del repository e di avere un elenco in continua espansione di metodi per restituire vari tipi di dati. Capisco che potresti non vedere un problema con questo (molte persone non lo fanno), ma altri lo vedono in modo diverso (suggerisco di leggere l'articolo a cui ho collegato, ci sono molte altre persone con opinioni simili).
Erik Funkenbusch

1
Lo affronta, perché i criteri rendono superflui molti metodi. Naturalmente, non di tutti non posso dire molto senza sapere nulla della cosa di cui hai bisogno. Sono comunque sotto l'impressione che tu voglia interrogare direttamente il db, quindi probabilmente un repository è solo di mezzo. Se hai bisogno di lavorare direttamente con il sotrage relazionale, fallo direttamente, non c'è bisogno di un repository. E come nota, è fastidioso quante persone citano Ayende con quel post. Non sono d'accordo e penso che molti sviluppatori stiano usando il pattern nel modo sbagliato.
MikeSW

1
Può ridurre un po 'il problema, ma data un'applicazione abbastanza grande creerà comunque repository mostruosi. Non sono d'accordo con la soluzione di Ayende di utilizzare nHibernate direttamente nella logica principale, ma sono d'accordo con lui sull'assurdità della crescita del repository fuori controllo. Non voglio interrogare direttamente il database, ma non voglio nemmeno spostare il problema da un repository a un'esplosione di oggetti di query.
Erik Funkenbusch

2

L'ho fatto, l'ho supportato e l'ho annullato.

Il problema principale è questo: non importa come lo fai, l'astrazione aggiunta non ti fa guadagnare indipendenza. Perderà per definizione. In sostanza, stai inventando un intero livello solo per rendere il tuo codice carino ... ma non riduce la manutenzione, migliora la leggibilità o ti procura alcun tipo di agnosticismo del modello.

La parte divertente è che hai risposto alla tua domanda in risposta alla risposta di Olivier: "questo è essenzialmente la duplicazione delle funzionalità di Linq senza tutti i vantaggi che ottieni da Linq".

Chiediti: come potrebbe non essere?


Beh, ho sicuramente riscontrato i problemi di integrazione di Linq nel tuo livello aziendale. È molto potente, ma quando apportiamo modifiche al modello di dati è un incubo. Le cose sono migliorate con i repository, perché posso apportare le modifiche in un luogo localizzato senza influire molto sul livello aziendale (a parte quando devi cambiare anche il livello aziendale per supportare le modifiche). Ma i repository diventano questi strati gonfiati che violano massicciamente l'SRP. Capisco il tuo punto, ma non risolve nemmeno alcun problema.
Erik Funkenbusch

Se il tuo livello dati utilizza LINQ e le modifiche al modello dati richiedono modifiche al tuo livello aziendale ... non stai stratificando correttamente.
Stu

Pensavo stessi dicendo che non avevi più aggiunto quel livello. Quando dici che l'astrazione aggiunta non ti fa guadagnare nulla, significa che sei d'accordo con Ayende sul passaggio della sessione nHibernate (o del contesto EF) direttamente nel tuo livello aziendale.
Erik Funkenbusch

1

Puoi usare un'interfaccia fluente. L'idea di base è che i metodi di una classe restituiscano l'istanza corrente proprio questa classe dopo aver eseguito un'azione. Ciò consente di concatenare chiamate di metodo.

Creando una gerarchia di classi appropriata, è possibile creare un flusso logico di metodi accessibili.

public class FinalQuery
{
    protected string _table;
    protected string[] _selectFields;
    protected string _where;
    protected string[] _groupBy;
    protected string _having;
    protected string[] _orderByDescending;
    protected string[] _orderBy;

    protected FinalQuery()
    {
    }

    public override string ToString()
    {
        var sb = new StringBuilder("SELECT ");
        AppendFields(sb, _selectFields);
        sb.AppendLine();

        sb.Append("FROM ");
        sb.Append("[").Append(_table).AppendLine("]");

        if (_where != null) {
            sb.Append("WHERE").AppendLine(_where);
        }

        if (_groupBy != null) {
            sb.Append("GROUP BY ");
            AppendFields(sb, _groupBy);
            sb.AppendLine();
        }

        if (_having != null) {
            sb.Append("HAVING").AppendLine(_having);
        }

        if (_orderBy != null) {
            sb.Append("ORDER BY ");
            AppendFields(sb, _orderBy);
            sb.AppendLine();
        } else if (_orderByDescending != null) {
            sb.Append("ORDER BY ");
            AppendFields(sb, _orderByDescending);
            sb.Append(" DESC").AppendLine();
        }

        return sb.ToString();
    }

    private static void AppendFields(StringBuilder sb, string[] fields)
    {
        foreach (string field in fields) {
            sb.Append(field).Append(", ");
        }
        sb.Length -= 2;
    }
}

public class GroupedQuery : FinalQuery
{
    protected GroupedQuery()
    {
    }

    public GroupedQuery Having(string condition)
    {
        if (_groupBy == null) {
            throw new InvalidOperationException("HAVING clause without GROUP BY clause");
        }
        if (_having == null) {
            _having = " (" + condition + ")";
        } else {
            _having += " AND (" + condition + ")";
        }
        return this;
    }

    public FinalQuery OrderBy(params string[] fields)
    {
        _orderBy = fields;
        return this;
    }

    public FinalQuery OrderByDescending(params string[] fields)
    {
        _orderByDescending = fields;
        return this;
    }
}

public class Query : GroupedQuery
{
    public Query(string table, params string[] selectFields)
    {
        _table = table;
        _selectFields = selectFields;
    }

    public Query Where(string condition)
    {
        if (_where == null) {
            _where = " (" + condition + ")";
        } else {
            _where += " AND (" + condition + ")";
        }
        return this;
    }

    public GroupedQuery GroupBy(params string[] fields)
    {
        _groupBy = fields;
        return this;
    }
}

Lo chiameresti così

string query = new Query("myTable", "name", "SUM(amount) AS total")
    .Where("name LIKE 'A%'")
    .GroupBy("name")
    .Having("COUNT(*) > 2")
    .OrderBy("name")
    .ToString();

Puoi solo creare una nuova istanza di Query. Le altre classi hanno un costruttore protetto. Lo scopo della gerarchia è "disabilitare" i metodi. Ad esempio, il GroupBymetodo restituisce a GroupedQueryche è la classe base di Querye non ha un Wheremetodo (il metodo where è dichiarato in Query). Pertanto non è possibile chiamare Wheredopo GroupBy.

Tuttavia non è perfetto. Con questa gerarchia di classi puoi successivamente nascondere i membri, ma non mostrarne di nuovi. Pertanto Havinggenera un'eccezione quando viene chiamato prima GroupBy.

Nota che è possibile chiamare Wherepiù volte. Ciò aggiunge nuove condizioni con una ANDalle condizioni esistenti. Ciò semplifica la creazione di filtri a livello di codice da singole condizioni. Lo stesso è possibile con Having.

I metodi che accettano gli elenchi di campi hanno un parametro params string[] fields. Ti consente di passare singoli nomi di campo o un array di stringhe.


Le interfacce fluide sono molto flessibili e non richiedono la creazione di molti sovraccarichi di metodi con diverse combinazioni di parametri. Il mio esempio funziona con le stringhe, tuttavia l'approccio può essere esteso ad altri tipi. È inoltre possibile dichiarare metodi predefiniti per casi speciali o metodi che accettano tipi personalizzati. Puoi anche aggiungere metodi come ExecuteReadero ExceuteScalar<T>. Ciò ti consentirebbe di definire query come questa

var reader = new Query<Employee>(new MonthlyReportFields{ IncludeSalary = true })
    .Where(new CurrentMonthCondition())
    .Where(new DivisionCondition{ DivisionType = DivisionType.Production})
    .OrderBy(new StandardMonthlyReportSorting())
    .ExecuteReader();

Anche i comandi SQL costruiti in questo modo possono avere parametri di comando e quindi evitare problemi di SQL injection e allo stesso tempo consentire ai comandi di essere memorizzati nella cache dal server del database. Questo non è un sostituto per un O / R-mapper ma può aiutare in situazioni in cui si creerebbero i comandi utilizzando una semplice concatenazione di stringhe altrimenti.


3
Hmm .. Interessante, ma la tua soluzione sembra avere problemi con le possibilità di SQL Injection e non crea davvero istruzioni preparate per l'esecuzione precompilata (quindi più lentamente). Probabilmente potrebbe essere adattato per risolvere questi problemi, ma poi siamo bloccati con i risultati del set di dati non sicuri di tipo e cosa no. Preferirei una soluzione basata su ORM e forse dovrei specificarlo esplicitamente. Questo essenzialmente duplica la funzionalità di Linq senza tutti i vantaggi che ottieni da Linq.
Erik Funkenbusch

Sono consapevole di questi problemi. Questa è solo una soluzione rapida e sporca, che mostra come è possibile costruire un'interfaccia fluente. In una soluzione del mondo reale probabilmente "trasformeresti" il tuo approccio esistente in un'interfaccia fluente adattata alle tue esigenze.
Olivier Jacot-Descombes
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.