web-dev-qa-db-fra.com

Commandes et / ou spécifications de requête bien conçues

Je recherche depuis un certain temps une bonne solution aux problèmes présentés par le modèle de référentiel typique (liste croissante de méthodes pour les requêtes spécialisées, etc. voir: http://ayende.com/blog/ 3955/repository-is-the-new-singleton ).

J'aime vraiment l'idée d'utiliser des requêtes de commande, en particulier en utilisant le modèle de spécification. Cependant, mon problème avec la spécification est qu'elle ne concerne que les critères de sélections simples (fondamentalement, la clause where), et ne traite pas les autres problèmes de requêtes, tels que la jointure, le regroupement, la sélection ou la projection de sous-ensembles, etc. Fondamentalement, tous les cerceaux supplémentaires que de nombreuses requêtes doivent parcourir pour obtenir le bon ensemble de données.

(remarque: j'utilise le terme "commande" comme dans le modèle de commande, également appelé objets de requête. Je ne parle pas de commande comme dans la séparation commande/requête où il y a une distinction entre les requêtes et les commandes (mise à jour, suppression, insérer))

Je recherche donc des alternatives qui encapsulent l'intégralité de la requête, mais toujours suffisamment flexibles pour que vous n'échangiez pas simplement des référentiels de spaghetti contre une explosion de classes de commandes.

J'ai utilisé, par exemple, Linqspecs, et même si je trouve utile de pouvoir attribuer des noms significatifs aux critères de sélection, ce n'est tout simplement pas suffisant. Je cherche peut-être une solution mixte qui combine plusieurs approches.

Je recherche des solutions que d'autres peuvent avoir développées pour résoudre ce problème ou pour résoudre un problème différent mais qui satisfont toujours à ces exigences. Dans l'article lié, Ayende suggère d'utiliser directement le contexte nHibernate, mais je pense que cela complique largement votre couche métier car elle doit désormais également contenir des informations sur les requêtes.

Je vais offrir une prime à ce sujet, dès que la période d'attente sera écoulée. Veuillez donc rendre vos solutions dignes de prime, avec de bonnes explications et je sélectionnerai la meilleure solution, et j'élèverai les finalistes.

REMARQUE: je recherche quelque chose qui est basé sur ORM. Ne doit pas être explicitement EF ou nHibernate, mais ce sont les plus courants et conviendraient le mieux. S'il peut être facilement adapté à d'autres ORM, ce serait un bonus. Compatible avec Linq serait également agréable.

MISE À JOUR: Je suis vraiment surpris qu'il n'y ait pas beaucoup de bonnes suggestions ici. Il semble que les gens soient totalement CQRS ou qu'ils soient complètement dans le camp du référentiel. La plupart de mes applications ne sont pas assez complexes pour justifier le CQRS (quelque chose avec la plupart des défenseurs du CQRS disent facilement que vous ne devriez pas l'utiliser pour).

MISE À JOUR: Il semble y avoir un peu de confusion ici. Je ne recherche pas une nouvelle technologie d'accès aux données, mais plutôt une interface raisonnablement bien conçue entre l'entreprise et les données.

Idéalement, ce que je recherche, c'est une sorte de croisement entre les objets de requête, le modèle de spécification et le référentiel. Comme je l'ai dit ci-dessus, le modèle de spécification ne concerne que l'aspect de la clause where, et non les autres aspects de la requête, tels que les jointures, les sous-sélections, etc. Les référentiels traitent l'intégralité de la requête, mais deviennent incontrôlables après un certain temps . Les objets de requête traitent également l'ensemble de la requête, mais je ne veux pas simplement remplacer les référentiels par des explosions d'objets de requête.

88
Erik Funkenbusch

Avis de non-responsabilité: Puisqu'il n'y a pas encore de bonnes réponses, j'ai décidé de publier une partie d'un excellent article de blog que j'ai lu il y a quelque temps, copié presque mot pour mot . Vous pouvez trouver le billet de blog complet ici . Voici donc:


Nous pouvons définir les deux interfaces suivantes:

public interface IQuery<TResult>
{
}

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

Le IQuery<TResult> Spécifie un message qui définit une requête spécifique avec les données qu'il renvoie en utilisant le type générique TResult. Avec l'interface précédemment définie, nous pouvons définir un message de requête comme ceci:

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

Cette classe définit une opération de requête avec deux paramètres, qui se traduira par un tableau d'objets User. La classe qui gère ce message peut être définie comme suit:

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();
    }
}

Nous pouvons maintenant laisser les consommateurs dépendre de l'interface générique IQueryHandler:

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);
    }
}

Immédiatement, ce modèle nous donne beaucoup de flexibilité, car nous pouvons maintenant décider quoi injecter dans le UserController. Nous pouvons injecter une implémentation complètement différente, ou une qui enveloppe l'implémentation réelle, sans avoir à apporter de modifications à UserController (et à tous les autres consommateurs de cette interface).

L'interface IQuery<TResult> Nous aide à la compilation lors de la spécification ou de l'injection de IQueryHandlers dans notre code. Lorsque nous modifions le FindUsersBySearchTextQuery pour retourner UserInfo[] À la place (en implémentant IQuery<UserInfo[]>), Le UserController échouera à la compilation, car la contrainte de type générique sur IQueryHandler<TQuery, TResult> Ne pourra pas mapper FindUsersBySearchTextQuery à User[].

L'injection de l'interface IQueryHandler dans un consommateur a cependant quelques problèmes moins évidents qui doivent encore être résolus. Le nombre de dépendances de nos consommateurs peut devenir trop important et conduire à une sur-injection de constructeur - lorsqu'un constructeur prend trop d'arguments. Le nombre de requêtes qu'une classe exécute peut changer fréquemment, ce qui nécessiterait des modifications constantes du nombre d'arguments du constructeur.

Nous pouvons résoudre le problème d'avoir à injecter trop de IQueryHandlers avec une couche d'abstraction supplémentaire. Nous créons un médiateur qui se situe entre les consommateurs et les gestionnaires de requêtes:

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

IQueryProcessor est une interface non générique avec une méthode générique. Comme vous pouvez le voir dans la définition de l'interface, le IQueryProcessor dépend de l'interface IQuery<TResult>. Cela nous permet d'avoir un support de temps de compilation chez nos consommateurs qui dépend de IQueryProcessor. Réécrivons le UserController pour utiliser le nouveau 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);
    }
}

Le UserController dépend maintenant d'un IQueryProcessor qui peut gérer toutes nos requêtes. La méthode UserController de SearchUsers appelle la méthode IQueryProcessor.Process En passant un objet de requête initialisé. Puisque le FindUsersBySearchTextQuery implémente l'interface IQuery<User[]>, Nous pouvons le passer à la méthode générique Execute<TResult>(IQuery<TResult> query). Grâce à l'inférence de type C #, le compilateur est capable de déterminer le type générique et cela nous évite d'avoir à déclarer explicitement le type. Le type de retour de la méthode Process est également connu.

Il est maintenant de la responsabilité de l'implémentation du IQueryProcessor de trouver le bon IQueryHandler. Cela nécessite un typage dynamique, et éventuellement l'utilisation d'un framework d'injection de dépendances, et cela peut être fait avec seulement quelques lignes de code:

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 classe QueryProcessor construit un type IQueryHandler<TQuery, TResult> Spécifique basé sur le type de l'instance de requête fournie. Ce type est utilisé pour demander à la classe de conteneur fournie d'obtenir une instance de ce type. Malheureusement, nous devons appeler la méthode Handle à l'aide de la réflexion (en utilisant le mot clé dymamic C # 4.0 dans ce cas), car à ce stade, il est impossible de transtyper l'instance du gestionnaire, car le générique TQuery L'argument n'est pas disponible au moment de la compilation. Cependant, sauf si la méthode Handle est renommée ou obtient d'autres arguments, cet appel n'échouera jamais et si vous le souhaitez, il est très facile d'écrire un test unitaire pour cette classe. L'utilisation de la réflexion donnera une légère baisse, mais il n'y a rien de vraiment inquiétant.


Pour répondre à l'une de vos préoccupations:

Je recherche donc des alternatives qui encapsulent l'intégralité de la requête, mais toujours suffisamment flexibles pour que vous n'échangiez pas simplement des référentiels de spaghetti contre une explosion de classes de commandes.

Une conséquence de l'utilisation de cette conception est qu'il y aura beaucoup de petites classes dans le système, mais avoir beaucoup de petites classes/ciblées (avec des noms clairs) est une bonne chose. Cette approche est clairement bien meilleure que d'avoir de nombreuses surcharges avec des paramètres différents pour la même méthode dans un référentiel, car vous pouvez les regrouper dans une classe de requête. Ainsi, vous obtenez toujours beaucoup moins de classes de requêtes que les méthodes dans un référentiel.

88
david.s

Ma façon de traiter cela est en fait simpliste et agnostique ORM. Mon point de vue pour un référentiel est le suivant: le travail du référentiel est de fournir à l'application le modèle requis pour le contexte, donc l'application demande simplement au référentiel quoi il veut mais ne le dit pas - comment pour l'obtenir.

Je fournis la méthode du référentiel avec des critères (oui, style DDD), qui seront utilisés par le référentiel pour créer la requête (ou tout ce qui est requis - il peut s'agir d'une demande de service Web). Les jointures et les groupes à mon humble avis sont des détails sur la façon dont, et non le quoi et un critère ne devraient être que la base pour construire une clause where.

Modèle = l'objet final ou la structure de données requise par l'application.

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

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

Vous pouvez probablement utiliser les critères ORM (Nhibernate) directement si vous le souhaitez. L'implémentation du référentiel doit savoir comment utiliser les critères avec le stockage sous-jacent ou DAO.

Je ne connais pas votre domaine et les exigences du modèle, mais il serait étrange que la meilleure façon soit l'application pour construire la requête elle-même. Le modèle change tellement que vous ne pouvez pas définir quelque chose de stable?

Cette solution nécessite clairement du code supplémentaire, mais elle ne couple pas le reste à un ORM ou à tout ce que vous utilisez pour accéder au stockage. Le référentiel fait son travail pour agir comme une façade et l'OMI est propre et le code de traduction des critères est réutilisable

4
MikeSW

Vous pouvez utiliser une interface fluide. L'idée de base est que les méthodes d'une classe retournent l'instance actuelle dans cette classe même après avoir effectué une action. Cela vous permet de chaîner les appels de méthode.

En créant une hiérarchie de classes appropriée, vous pouvez créer un flux logique de méthodes accessibles.

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;
    }
}

Tu appellerais ça comme ça

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

Vous ne pouvez créer qu'une nouvelle instance de Query. Les autres classes ont un constructeur protégé. Le but de la hiérarchie est de "désactiver" les méthodes. Par exemple, la méthode GroupBy renvoie un GroupedQuery qui est la classe de base de Query et n'a pas de méthode Where (la méthode where est déclarée dans Query). Par conséquent, il n'est pas possible d'appeler Where après GroupBy.

Ce n'est cependant pas parfait. Avec cette hiérarchie de classes, vous pouvez masquer successivement les membres, mais pas en afficher de nouveaux. Par conséquent, Having lève une exception lorsqu'elle est appelée avant GroupBy.

Notez qu'il est possible d'appeler Where plusieurs fois. Cela ajoute de nouvelles conditions avec un AND aux conditions existantes. Cela facilite la création de filtres par programme à partir de conditions uniques. La même chose est possible avec Having.

Les méthodes acceptant les listes de champs ont un paramètre params string[] fields. Il vous permet soit de passer des noms de champs uniques, soit un tableau de chaînes.


Les interfaces fluides sont très flexibles et ne nécessitent pas de créer de nombreuses surcharges de méthodes avec différentes combinaisons de paramètres. Mon exemple fonctionne avec des chaînes, mais l'approche peut être étendue à d'autres types. Vous pouvez également déclarer des méthodes prédéfinies pour des cas spéciaux ou des méthodes acceptant des types personnalisés. Vous pouvez également ajouter des méthodes telles que ExecuteReader ou ExceuteScalar<T>. Cela vous permettrait de définir des requêtes comme celle-ci

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

Même les commandes SQL construites de cette manière peuvent avoir des paramètres de commande et ainsi éviter les problèmes d'injection SQL et en même temps permettre aux commandes d'être mises en cache par le serveur de base de données. Ce n'est pas un remplacement pour un mappeur O/R mais peut aider dans les situations où vous créeriez les commandes en utilisant la concaténation de chaîne simple autrement.

1