Angaben zu Steroiden

Das Thema Abstraktionen und allerlei liebliche Muster ist ein guter Grund für die Entwicklung von Holivars und ewigen Streitigkeiten: Einerseits folgen wir dem Mainstream, allerlei modischen Wörtern und sauberem Code, andererseits haben wir Praxis und Realität, die immer ihre eigenen Regeln diktieren.

Was tun, wenn Abstraktionen zu "lecken" beginnen, wie man Sprachchips verwendet und was man aus dem "Spezifikationsmuster" herauspressen kann - siehe unter dem Abschnitt.

Kommen wir also zur Sache. Der Artikel enthält die folgenden Abschnitte: Zunächst werden wir untersuchen, was das "Spezifikationsmuster" ist und warum seine Anwendung auf reine Datenbankbeispiele Schwierigkeiten verursacht.

Als nächstes wenden wir uns Ausdrucksbäumen zu, die ein sehr mächtiges Werkzeug sind, und sehen, wie sie uns helfen können.

Am Ende werde ich meine Implementierung der „Spezifikation“ für Steroide demonstrieren.

Beginnen wir mit den grundlegenden Dingen. Ich denke, dass jeder von dem „Spezifikationsmuster“ gehört hat, aber für diejenigen, die es nicht gehört haben, ist hier seine Definition aus Wikipedia :

Eine „Spezifikation“ in der Programmierung ist ein Entwurfsmuster, mit dem die Darstellung von Geschäftslogikregeln in eine Kette von Objekten umgewandelt werden kann, die durch boolesche Logikoperationen verbunden sind.

Diese Vorlage hebt solche Spezifikationen (Regeln) in der Geschäftslogik hervor, die zum „Koppeln“ mit anderen geeignet sind. Ein Geschäftslogikobjekt erbt seine Funktionalität von der abstrakten Aggregatklasse CompositeSpecification, die nur eine IsSatisfiedBy-Methode enthält, die einen Booleschen Wert zurückgibt. Nach der Instanziierung wird das Objekt mit anderen Objekten verkettet. Infolgedessen können wir problemlos neue Regeln hinzufügen, ohne an Flexibilität beim Einrichten der Geschäftslogik zu verlieren.

Mit anderen Worten, eine Spezifikation ist ein Objekt, das die folgende Schnittstelle implementiert (Methoden zum Erstellen von Ketten verwerfen):

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

Hier ist alles einfach und klar. Schauen wir uns nun ein Beispiel aus der realen Welt an, in der es neben der Domäne eine Infrastruktur gibt, die auch eine skrupellose Person ist: Wenden wir uns dem Fall der Verwendung von ORM, einem DBMS und Spezifikationen zum Filtern von Daten in einer Datenbank zu.

Um nicht unbegründet zu sein und nicht mit den Fingern zu zeigen, nehmen wir als Beispiel den folgenden Themenbereich: Angenommen, wir entwickeln MMORPGs, wir haben Benutzer, jeder Benutzer hat 1 oder mehr Zeichen und jedes Zeichen hat eine Menge von Gegenständen ( Wir gehen davon aus, dass die Gegenstände für jeden Benutzer einzigartig sind, und für jeden Gegenstand können wiederum Verbesserungsrunen angewendet werden. Insgesamt in Form eines Diagramms (wir werden die ReadCharacter-Klasse etwas später betrachten, wenn wir über verschachtelte Abfragen sprechen):

Bild

Dieses Modell ist lose mit der realen Welt verbunden und enthält auch Felder, die eine gewisse Verbindung mit dem verwendeten ORM widerspiegeln. Dies wird jedoch ausreichen, um die Arbeit zu demonstrieren.

Angenommen, wir möchten alle Zeichen herausfiltern, die nach dem angegebenen Datum erstellt wurden.
Dazu schreiben wir eine Spezifikation des folgenden Formulars:

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

Nun, um diese Spezifikation anzuwenden, gehen wir wie folgt vor (im Folgenden werde ich NHibernate-basierten Code betrachten):

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

Solange unsere Basis klein ist, wird alles wunderbar und schnell funktionieren, aber wenn unser Spiel mehr oder weniger populär wird und ein paar Zehntausende Nutzer gewinnen, wird all dieser Charme Gedächtnis, Zeit und Geld kosten und es ist besser, dieses Biest sofort zu erschießen weil Er ist kein Mieter. In diesem traurigen Fall werden wir die Spezifikation verschieben und mich ein wenig meiner Praxis zuwenden.

In einem sehr, sehr fernen Projekt hatte ich einmal Klassen in meinem Code, die Logik zum Abrufen von Daten aus der Datenbank enthielten. Sie sahen ungefähr so ​​aus:

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

und ihre Verwendung:

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

In den Klassen befand sich die Logik für die Arbeit mit dem DBMS (zu dieser Zeit war es ADO.NET).

Alles schien schön zu sein, aber mit der Ausweitung des Projekts wuchsen auch diese Klassen und verwandelten sich in schwer zu pflegende Objekte. Darüber hinaus gab es einen unangenehmen Nachgeschmack - es scheint eine Geschäftsregel zu sein, aber sie wurden auf Infrastrukturebene gespeichert, da sie an eine bestimmte Implementierung gebunden waren.

Dieser Ansatz wurde durch das IQueryable- Repository <T> ersetzt , mit dem alle Regeln direkt in die Domänenschicht übernommen werden konnten.

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

was so etwas verwendet wurde:

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

Ein bisschen netter, aber das Problem ist, dass sich die Regeln entlang des Codes schleichen und die gleiche Überprüfung an Hunderten von Stellen stattfinden kann. Man kann sich leicht vorstellen, was daraus resultieren kann, wenn sich die Anforderungen ändern.

Dieser Ansatz verbirgt ein weiteres Problem: Wenn Sie die Abfrage nicht materialisieren, besteht die Möglichkeit, dass mehrere Abfragen in der Datenbank ausgeführt werden, anstatt einer, was sich natürlich nachteilig auf die Systemleistung auswirkt.

Und hier in einem der Projekte schlug ein Kollege vor, eine Bibliothek zu verwenden , die die Implementierung des „Spezifikationsmusters“ auf der Grundlage von Ausdrucksbäumen vorschlug.

Kurz gesagt, auf der Grundlage dieser Bibliothek haben wir Spezifikationen erstellt, mit denen wir Filter für Entitäten erstellen und komplexere Filter auf der Grundlage von Verkettungen einfacher Regeln erstellen können. Zum Beispiel haben wir eine Spezifikation für Zeichen, die nach dem neuen Jahr erstellt wurden, und es gibt eine Spezifikation für die Auswahl von Zeichen mit einem bestimmten Element. Durch Kombinieren dieser Regeln können wir eine Anforderung für eine Liste von Zeichen erstellen, die nach dem neuen Jahr erstellt wurden und das angegebene Element aufweisen. Und wenn wir in Zukunft die Regel zur Bestimmung neuer Zeichen ändern (zum Beispiel das Datum des chinesischen Neujahrs), werden wir dies nur in der Spezifikation selbst korrigieren und müssen nicht nach allen Verwendungen dieser Logik durch Code suchen!

Dieses Projekt wurde erfolgreich abgeschlossen und die Erfahrung mit diesem Ansatz war sehr erfolgreich. Aber ich wollte nicht stehen bleiben und es gab einige Probleme bei der Implementierung, nämlich:

  • Klebeoperator ODER hat nicht funktioniert;
  • Die Vereinigung funktioniert nur für Abfragen, die Filter des Typs Where enthalten. Ich wollte jedoch umfassendere Regeln (verschachtelte Abfragen, Überspringen / Nehmen, Abrufen von Projektionen).
  • Spezifikationscode abhängig vom gewählten ORM;
  • ORM-Funktionen konnten nicht verwendet werden, da Dies führte dazu, dass Abhängigkeiten in die Geschäftslogikebene aufgenommen wurden (z. B. war das Abrufen nicht möglich).

Das Ergebnis der Lösung dieser Probleme war das Singularis.Secification- Mini-Framework, das aus mehreren Assemblys besteht:

  • Singularis.Specification.Definition - Definiert das Spezifikationsobjekt und enthält auch die IQuery-Schnittstelle, mit der die Regel gebildet wird.
  • Singularis.Specification.Executor. * - implementiert ein Repository und ein Objekt zum Ausführen von Spezifikationen für bestimmte ORMs (derzeit von ef.core und NHibernate unterstützt, als Teil der Experimente habe ich auch eine Implementierung für mongodb durchgeführt, aber dieser Code wurde nicht in die Produktion aufgenommen).

Schauen wir uns die Implementierung genauer an.

Die Spezifikationsschnittstelle definiert die öffentliche Eigenschaft, die die Spezifikationsregel enthält:

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

Darüber hinaus enthält die Schnittstelle die ResultType- Eigenschaft, die den als Ergebnis der Abfrage erhaltenen Entitätstyp zurückgibt.

Die Implementierung ist in der Specification <T> -Klasse enthalten, die die ResultType- Eigenschaft implementiert und sie basierend auf der in Query gespeicherten Regel sowie zwei Methoden berechnet: Source () und Source <TSource> () . Diese Methoden dienen dazu, die Quelle der Regel zu bilden. Mit Source () wird eine Regel mit einem Typ erstellt, der dem Argument der Spezifikationsklasse entspricht, und mit Source <TSource> () können Sie eine Regel für eine beliebige Klasse erstellen (die beim Generieren verschachtelter Abfragen verwendet wird).

Darüber hinaus gibt es die SpecificationExtension- Klasse, die Erweiterungsmethoden zum Verketten von Anforderungen enthält.

Es werden zwei Arten von Verknüpfungen unterstützt: Verkettung (kann als Verknüpfung mit der UND-Bedingung betrachtet werden) und Verknüpfung mit der ODER-Bedingung.

Kehren wir zu unserem Beispiel zurück und implementieren unsere beiden Regeln:

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

und finde alle Benutzer, die beide Regeln erfüllen:

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

Die Kombination mit der Combine- Methode unterstützt beliebige Regeln. Die Hauptsache ist, dass der resultierende Typ der linken Seite mit dem Eingabetyp der rechten Seite übereinstimmt. Auf diese Weise können Sie Regeln erstellen, die Projektionen enthalten, Seitenumbrüche überspringen, Regeln sortieren, Abrufen usw.

Die Or-Regel ist restriktiver - sie unterstützt nur Ketten, die Where-Filterbedingungen enthalten. Betrachten Sie die Verwendung eines Beispiels: Wir finden alle Charaktere, die vor 2000 oder nach 2020 erstellt wurden:

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

Die IQuery- Schnittstelle wiederholt weitgehend die IQueryable- Schnittstelle, sodass keine besonderen Fragen auftreten sollten. Lassen Sie uns nur auf bestimmte Methoden eingehen:

Fetch / ThenFetch - Mit dieser Option können Sie verwandte Daten zu Optimierungszwecken in die generierte Abfrage einbeziehen . Das ist natürlich ein bisschen schief, wenn wir Merkmale der Implementierung von Infrastruktur haben, die sich auf Geschäftsregeln auswirken, aber wie gesagt, die Realität ist hart und rein abstrahiert - dies ist eine eher theoretische Sache.

Wenn - IQuery zwei Überladungen dieser Methode deklariert, verwendet eine nur einen Lambda-Ausdruck zum Filtern in Form von Expression <Func <T, bool >> und die zweite enthält zusätzliche Parameter IQueryContext , mit denen Sie verschachtelte Unterabfragen ausführen können. Schauen wir uns ein Beispiel an.

Wir haben die ReadCharacter-Klasse im Modell - nehmen wir an, dass unser Modell als Leseteil dargestellt wird, der denormalisierte Daten enthält und zur schnellen Rückmeldung dient, und als Schreibteil, der Links, normalisierte Daten usw. enthält. Wir möchten alle Zeichen anzeigen, für die der Benutzer E-Mails in einer bestimmten Domäne hat.

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

Als Ergebnis der Ausführung wird die folgende SQL-Abfrage generiert:

 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)] 

Um all diese wunderbaren Regeln zu erfüllen, ist die IRepository- Schnittstelle definiert , die es Ihnen ermöglicht, Elemente nach Kennung zu empfangen, eines (das erste geeignete) oder eine Liste von Objekten gemäß der Spezifikation zu empfangen sowie Elemente aus dem Repository zu speichern und zu löschen.
Bei der Definition von Abfragen haben wir herausgefunden, dass es jetzt noch wichtig ist, unserem ORM beizubringen, dies zu verstehen.
Dazu analysieren wir die Assembly von Singularis.Infrastructure.NHibernate (für ef.core sieht alles gleich aus, nur mit den Besonderheiten von ef.core).

Der Datenzugriffspunkt ist das Repository-Objekt, das die IRepository- Schnittstelle implementiert. Im Fall des Empfangs eines Objekts nach ID sowie zum Ändern des Speichers (Speichern / Löschen) beendet diese Klasse eine Sitzung und verbirgt eine bestimmte Implementierung vor der Business-Schicht. Wenn Sie mit Spezifikationen arbeiten, wird ein IQueryable- Objekt erstellt, das unsere Abfrage in Bezug auf IQuery widerspiegelt , und anschließend für das Sitzungsobjekt ausgeführt.

Die Hauptmagie und der hässlichste Code liegt in der Klasse, die für die Konvertierung von IQuery in IQueryable - SpecificationExecutor verantwortlich ist. Diese Klasse enthält viele Überlegungen, die abfragbare Methoden oder Erweiterungsmethoden eines bestimmten ORM (EagerFetchingExtensionsMethods for NHiberante) aufrufen.

Diese Bibliothek wird in unseren Projekten aktiv verwendet (um ehrlich zu sein, wird für unsere Projekte eine bereits aktualisierte Bibliothek verwendet, die jedoch nach und nach öffentlich zugänglich gemacht wird). Erst vor ein paar Wochen wurde die nächste Version veröffentlicht, die auf asynchrone Methoden umstellte, Fehler in executor'e für ef.core wurden behoben, Tests und Beispiele wurden hinzugefügt. Es ist wahrscheinlich, dass die Bibliothek Fehler und hundert Optimierungsmöglichkeiten enthält - sie wurde als Nebenprojekt im Rahmen der Arbeit an den Hauptprojekten entwickelt, daher schlage ich gerne Verbesserungsvorschläge vor. Darüber hinaus sollten Sie sich nicht beeilen, es zu verwenden - es ist wahrscheinlich, dass dies in Ihrem speziellen Fall unnötig oder nicht anwendbar ist.

Wann lohnt sich die beschriebene Lösung? Es ist wahrscheinlich einfacher, von der Frage „Wann sollte ich nicht?“ Auszugehen:

  • Highload - Wenn Sie eine hohe Leistung benötigen, wirft die Verwendung von ORM selbst eine Frage auf. Obwohl es natürlich niemand verbietet, einen Executor zu implementieren, der Abfragen in SQL übersetzt und ausführt ...
  • Sehr kleine Projekte - das ist sehr subjektiv, aber Sie müssen zugeben, dass es so aussieht, als würde man Spatzen aus einer Kanone schießen, wenn man den ORM und den gesamten dazugehörigen Zoo in das Projekt „Aufgabenliste“ zieht.

Auf jeden Fall wer das Lesen bis zum Ende gemeistert hat - vielen Dank für Ihre Zeit. Ich hoffe auf Feedback für die zukünftige Entwicklung!

Fast hätte ich vergessen - der Projektcode ist auf GitHub'e verfügbar - https://github.com/SingularisLab/singularis.specification

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


All Articles