Spécifications sur les stéroïdes

Le thème des abstractions et de toutes sortes de beaux motifs est un bon terrain pour le développement d'holivars et de conflits éternels: d'une part, nous suivons le courant dominant, toutes sortes de mots à la mode et un code propre, d'autre part, nous avons une pratique et une réalité qui dictent toujours leurs propres règles.

Que faire si les abstractions commencent à «fuir», comment utiliser les puces de langue et ce que vous pouvez retirer du modèle de «spécification» - voir sous la coupe.

Alors, passons aux choses sérieuses. L'article contiendra les sections suivantes: pour commencer, nous examinerons ce qu'est le modèle de «spécification» et pourquoi son application à des échantillons de la base de données dans sa forme pure pose des problèmes.

Ensuite, nous nous tournons vers les arbres d'expression, qui sont un outil très puissant, et voyons comment ils peuvent nous aider.

Enfin, je vais démontrer ma mise en œuvre de la «spécification» sur les stéroïdes.

Commençons par les choses de base. Je pense que tout le monde a entendu parler du modèle de «spécification», mais pour ceux qui ne l'ont pas entendu, voici sa définition de Wikipedia :

Une «spécification» en programmation est un modèle de conception par lequel la représentation des règles de logique métier peut être transformée en une chaîne d'objets connectés par des opérations de logique booléenne.

Ce modèle met en évidence de telles spécifications (règles) dans la logique métier qui peuvent être «couplées» avec d'autres. Un objet de logique métier hérite ses fonctionnalités de la classe d'agrégat abstraite CompositeSpecification, qui contient une seule méthode IsSatisfiedBy qui renvoie une valeur booléenne. Après l'instanciation, l'objet est enchaîné avec d'autres objets. Par conséquent, sans perdre la flexibilité de configurer la logique métier, nous pouvons facilement ajouter de nouvelles règles.

En d'autres termes, une spécification est un objet qui implémente l'interface suivante (suppression des méthodes de construction de chaînes):

public interface ISpecification { bool IsSatisfiedBy(object candidate); } 

Ici, tout est simple et clair. Mais maintenant, regardons un exemple du monde réel dans lequel, en plus du domaine, il y a une infrastructure qui est aussi une personne impitoyable: tournons-nous vers le cas de l'utilisation d'ORM, un SGBD et des spécifications pour filtrer les données dans une base de données.

Afin de ne pas être infondé et de ne pas pointer du doigt, nous prenons comme exemple le sujet suivant: supposons que nous développons des MMORPG, nous avons des utilisateurs, chaque utilisateur a 1 ou plusieurs caractères, et chaque personnage a un ensemble d'éléments ( nous faisons l'hypothèse que les éléments sont uniques à chaque utilisateur), et pour chacun des éléments, à leur tour, des runes d'amélioration peuvent être appliquées. Au total, sous forme de diagramme (nous considérerons la classe ReadCharacter un peu plus tard lorsque nous parlerons de requêtes imbriquées):

image

Ce modèle est vaguement connecté au monde réel, et il contient également des champs qui reflètent une certaine connexion avec l'ORM utilisé, mais cela nous suffira pour démontrer le travail.

Supposons que nous voulons filtrer tous les caractères créés après la date spécifiée.
Pour ce faire, nous écrivons une spécification du formulaire suivant:

 public class CreatedAfter: ISpecification { private readonly DateTime _target; public CreatedAfter(DateTime target) { _target = target; } bool IsSatisfiedBy(object candidate) { var character = candidate as Character; if(character == null) return false; return character.CreatedAt > target; } } 

Eh bien, pour appliquer cette spécification, nous faisons ce qui suit (ci-après, je considérerai le code basé sur NHibernate):

 var characters = await session.Query<Character>().ToListAsync(); var filter = new CreatedAfter(new DateTime(2020, 1, 1)); var newCharacters = characters.Where(x => filter.IsSatisfiedBy(x)).ToArray(); 

Tant que notre base est petite, tout fonctionnera magnifiquement et rapidement, mais si notre jeu devient plus ou moins populaire et gagne quelques dizaines de milliers d'utilisateurs, tout ce charme consommera de la mémoire, du temps et de l'argent, et il vaut mieux tirer sur cette bête tout de suite parce que il n'est pas locataire. Sur cette triste note, nous allons reporter la spécification et nous tournerons un peu vers ma pratique.

Il était une fois, dans un projet très, très éloigné, j'avais des classes dans mon code qui contenaient une logique pour récupérer des données de la base de données. Ils ressemblaient à ceci:

 public class ICharacterDal { IEnumerable<Character> GetCharactersCreatedAfter(DateTime date); IEnumerable<Character> GetCharactersCreatedBefore(DateTime date); IEnumerable<Character> GetCharactersCreatedBetween(DateTime from, DateTime to); ... } 

et leur utilisation:

 var dal = new CharacterDal(); var createdCharacters = dal.GetCharactersCreatedAfter(new DateTime(2020, 1, 1)); 

À l'intérieur des classes se trouvait la logique de travail avec le SGBD (à l'époque c'était ADO.NET).

Tout semblait bien, mais avec l'expansion du projet, ces classes se sont également développées, devenues des objets difficiles à entretenir. De plus, il y avait un arrière-goût désagréable - cela semble être une règle commerciale, mais ils étaient stockés au niveau de l'infrastructure, car ils étaient liés à une implémentation spécifique.

Cette approche a été remplacée par le référentiel IQueryable <T> , qui a permis de prendre toutes les règles directement dans la couche domaine.

 public interface IRepository<T> { T Get(object id); IQueryable<T> List(); void Delete(T obj); void Save(T obj); } 

qui a été utilisé quelque chose comme ça:

 var repository = new Repository(); var targetDate = new DateTime(2020, 1, 1); var createdUsers = await repository.List().Where(x => x.CreatedAd > targetDate).ToListAsync(); 

Un peu plus agréable, mais le problème est que les règles se glissent le long du code, et la même vérification peut se produire dans des centaines d'endroits, et il est facile d'imaginer ce que cela peut entraîner en modifiant les exigences.

Cette approche cache un autre problème - si vous ne matérialisez pas la requête, c'est-à-dire une chance de répondre à plusieurs requêtes dans la base de données au lieu d'une, ce qui, bien sûr, affecte négativement les performances du système.

Et ici, sur l'un des projets, un collègue a suggéré d'utiliser une bibliothèque qui a suggéré la mise en œuvre du modèle de «spécification» basé sur des arbres d'expression.

Bref, sur la base de cette bibliothèque, nous avons filmé des spécifications qui nous ont permis de créer des filtres pour les entités et de construire des filtres plus complexes basés sur des concaténations de règles simples. Par exemple, nous avons une spécification pour les personnages créés après la nouvelle année et il y a une spécification pour choisir les personnages avec un certain élément - puis en combinant ces règles, nous pouvons construire une demande pour une liste de personnages créés après la nouvelle année et ayant l'élément spécifié. Et si à l'avenir nous changerons la règle pour déterminer de nouveaux caractères (par exemple, nous utiliserons la date du nouvel an chinois), nous la corrigerons uniquement dans la spécification elle-même et il n'est pas nécessaire de rechercher toutes les utilisations de cette logique par code!

Ce projet a été mené à bien et l'expérience de l'utilisation de cette approche a été très réussie. Mais je ne voulais pas rester immobile, et il y avait quelques problèmes dans la mise en œuvre, à savoir:

  • l'opérateur de collage OR n'a pas fonctionné;
  • l'union ne fonctionne que pour les requêtes qui contiennent des filtres de type Where, mais je voulais des règles plus riches (requêtes imbriquées, sauter / prendre, obtenir des projections);
  • le code de spécification dépendait de l'ORM sélectionné;
  • il n’a pas été possible d’utiliser les fonctions ORM, cela a conduit à l'inclusion de dépendances sur celui-ci dans la couche logique métier (par exemple, il était impossible de faire une extraction).

Le résultat de la résolution de ces problèmes a été le mini-framework Singularis.Secification , qui se compose de plusieurs assemblages:

  • Singularis.Specification.Definition - définit l'objet de spécification et contient également l'interface IQuery avec laquelle la règle est formée.
  • Singularis.Specification.Executor. * - implémente un référentiel et un objet pour exécuter des spécifications pour des ORM spécifiques (actuellement pris en charge par ef.core et NHibernate, dans le cadre des expériences, j'ai également fait une implémentation pour mongodb, mais ce code n'est pas entré en production).

Examinons de plus près la mise en œuvre.

L'interface de spécification définit la propriété publique que contient la règle de spécification:

 public interface ISpecification { IQuery Query { get; } Type ResultType { get; } } public interface ISpefication<T>: ISpecification { } 

De plus, l'interface contient la propriété ResultType , qui renvoie le type d'entité obtenu à la suite de la requête.

Son implémentation est contenue dans la classe Specification <T> , qui implémente la propriété ResultType , la calculant en fonction de la règle stockée dans Query, ainsi que de deux méthodes: Source () et Source <TSource> () . Ces méthodes servent à former la source de la règle. Source () crée une règle avec un type qui correspond à l'argument de la classe de spécifications, et Source <TSource> () vous permet de créer une règle pour une classe arbitraire (utilisée lors de la génération de requêtes imbriquées).

En outre, il existe également la classe SpecificationExtension , qui contient des méthodes d'extension pour chaîner les demandes.

Deux types de jonction sont pris en charge: la concaténation (peut être considérée comme la jonction par la condition «ET») et la jonction par la condition «OU».

Revenons à notre exemple et implémentons nos deux règles:

 public class CreatedAfter: Specification<Character> { public CreatedAfter(DateTime target) { Query = Source().Where(x => x.CreatedAt > target); } } public class CreatedBefore: Specification<Character> { public CreatedBefore(DateTime target) { Query = Source().Where(x => x.CreatedAt < target); } } 

et trouvez tous les utilisateurs qui satisfont aux deux règles:

 var specification = new CreatedAfter(new DateTime(2019, 1, 1).Combine(new CreatedBefore(new DateTime(2020, 1, 1)); var users = repository.List(specification); 

La combinaison avec la méthode Combine prend en charge des règles arbitraires. L'essentiel est que le type résultant du côté gauche coïncide avec le type d'entrée du côté droit. Ainsi, vous pouvez créer des règles contenant des projections, ignorer / prendre pour la pagination, les règles de tri, récupérer, etc.

La règle Ou est plus restrictive - elle ne prend en charge que les chaînes contenant des conditions de filtrage Where. Prenons l'exemple: on retrouve tous les personnages créés avant 2000 ou après 2020:

 var specification = new CreatedAfter(new DateTime(2020, 1, 1).Or(new CreatedBefore(new DateTime(2000, 1, 1)); var users = repository.List(specification ); 

L'interface IQuery répète largement l'interface IQueryable , donc il ne devrait pas y avoir de questions spéciales. Arrêtons-nous uniquement sur des méthodes spécifiques:

Fetch / ThenFetch - vous permet d'inclure des données connexes dans la requête générée à des fins d'optimisation. Bien sûr, c'est un peu tordu lorsque nous avons des caractéristiques de la mise en œuvre de l'infrastructure qui affectent les règles commerciales, mais, comme je l'ai dit, la réalité est des abstractions dures et pures - c'est une chose plutôt théorique.

- IQuery déclare deux surcharges de cette méthode, l'une prend juste une expression lambda pour le filtrage sous la forme Expression <Func <T, bool >> , et la seconde prend également des paramètres supplémentaires IQueryContext , qui vous permet d'exécuter des sous-requêtes imbriquées. Regardons un exemple.

Nous avons la classe ReadCharacter dans le modèle - supposons que notre modèle est présenté comme une partie en lecture qui contient des données dénormalisées et sert à un retour rapide, et une partie en écriture qui contient des liens, des données normalisées, etc. Nous voulons afficher tous les caractères pour lesquels l'utilisateur a du courrier sur un domaine spécifique.

 public class CharactersForUserWithEmailDomain: Specification<ReadCharacter> { public CharactersForUserWithEmailDomain(string domain) { var usersQuery = Source<User>(x => x.Email.Contains(domain)).Projection(x => x.Id); Query = Source().Where((x, ctx) => ctx.GetQueryResult<int>(usersQuery).Contains(x.Id)); } } 

À la suite de l'exécution, la requête SQL suivante sera générée:

 select readcharac0_.id as id1_3_, readcharac0_.UserId as userid2_3_, readcharac0_.Name as name3_3_ from ReadCharacters readcharac0_ where readcharac0_.UserId in ( select user1_.Id from Users user1_ where user1_.Email like ('%'+@p0+'%') ); @p0 = '@inmagna.ca' [Type: String (4000:0:0)] 

Pour remplir toutes ces merveilleuses règles, l'interface IRepository est définie , ce qui vous permet de recevoir des éléments par identifiant, de recevoir un (le premier approprié) ou une liste d'objets selon la spécification, ainsi que d'enregistrer et de supprimer des éléments du référentiel.
Avec la définition des requêtes, nous avons compris, maintenant il reste à apprendre à notre ORM à comprendre cela.
Pour ce faire, nous analyserons l'assemblage de Singularis.Infrastructure.NHibernate (pour ef.core tout se ressemble, uniquement avec les spécificités d'ef.core).

Le point d'accès aux données est l'objet Repository, qui implémente l'interface IRepository . En cas de réception d'un objet par identifiant, ainsi que de modification du stockage (sauvegarde / suppression), cette classe termine une session et masque une implémentation spécifique de la couche métier. Dans le cas de l'utilisation de spécifications, il forme un objet IQueryable qui reflète notre requête en termes d' IQuery , puis l'exécute sur l'objet session.

La magie principale et le code le plus laid se trouvent dans la classe responsable de la conversion d' IQuery en IQueryable - SpecificationExecutor. Cette classe contient beaucoup de réflexion, qui appelle des méthodes Queryable ou des méthodes d'extension d'un ORM spécifique (EagerFetchingExtensionsMethods pour NHiberante).

Cette bibliothèque est activement utilisée dans nos projets (pour être honnête, une bibliothèque déjà mise à jour est utilisée pour nos projets, mais progressivement tous ces changements seront présentés dans le domaine public) est en constante évolution. Il y a quelques semaines à peine, la prochaine version est sortie, qui est passée aux méthodes asynchrones, des bogues ont été corrigés dans executor'e pour ef.core, des tests et des échantillons ont été ajoutés. Il est probable que la bibliothèque contienne des erreurs et une centaine de points d'optimisation - elle est née en tant que projet parallèle dans le cadre des travaux sur les principaux projets, donc je serai heureux de suggérer des suggestions d'amélioration. De plus, vous ne devez pas vous précipiter pour l'utiliser - il est probable que dans votre cas particulier, cela sera inutile ou inapplicable.

Quand vaut-il la peine d'utiliser la solution décrite? Il est probablement plus facile de partir de la question «quand ne devrait pas»:

  • highload - si vous avez besoin de hautes performances, l'utilisation de l'ORM lui-même soulève une question. Bien sûr, personne n'interdit d'implémenter un exécuteur qui traduira les requêtes en SQL et les exécutera ...
  • de très petits projets - c'est très subjectif, mais, vous devez l'admettre, tirer l'ORM et tout le zoo qui l'accompagne dans le projet "todo list" ressemble à tirer des moineaux à partir d'un canon.

En tout cas, qui a maîtrisé la lecture jusqu'à la fin - merci pour votre temps. J'espère avoir des commentaires pour un développement futur!

J'ai presque oublié - le code du projet est disponible sur GitHub'e - https://github.com/SingularisLab/singularis.specification

Source: https://habr.com/ru/post/fr485328/


All Articles