Gastbeitrag
Dieser Artikel ist ein Gastbeitrag von Christian Beikov, dem Macher von Blaze-Persistence.
Markus hat kürzlich ein Video veröffentlicht, in dem er über Java & SQL spricht, was zu einer kleinen Diskussion mit mir auf Twitter führte. Die Schlussfolgerung, zu der Markus oft kam, ist, dass man durch die Einschränkungen von JPA für einige der fortgeschritteneren Anwendungsfälle SQL schreiben muss. Ich stimme zu, dass JPA upgedatet werden sollte um mehr der fortgeschritteneren Funktionen von DBMS wie Set-Operationen, CTEs, rekursive CTEs usw. zu unterstützen, aber das wird einige Zeit dauern. Das Hinzufügen solcher Funktionen zu einer JPA-Implementierung wie Hibernate ist ein erster Schritt, aber man muss ein breites Publikum von den Vorteilen überzeugen. Manchmal können sich die Leute nicht vorstellen, wie ein ORM diese fortschrittlicheren DBMS-Konzepte nutzen könnte. Um diese Überzeugungsarbeit zu vermeiden, habe ich mit der Entwicklung von Blaze-Persistence begonnen, einer Query Builder API, die auf Basis von JPA die Unterstützung für diese fortschrittlicheren Konzepte mit dem JPA-Modell implementiert.
Im folgenden Artikel werde ich einige der von Markus entdeckten Probleme und Einschränkungen diskutieren und alternative Lösungen bereitstellen, die mit dem JPA-Modell unter Verwendung von Blaze-Persistence funktionieren.
Muster #1 - Datenauflistung
Teil 1: Daten auflisten
Markus erwähnte, dass das Abfragen von Entitäten dazu führt, dass alle in einer Entität definierten Spalten in der Select
-Klausel des generierten SQL aufgelistet sind. Dies ist eine Verschwendung von Ressourcen, da es oft nicht notwendig ist alle Spalten abzurufen, denn nur einige davon werden tatsächlich verwendet.
Lukas Eder hat einen schönen Artikel darüber geschrieben. Er nennt es „unnötige, obligatorische Arbeit“ (“Unnecessary, mandatory work”), was meiner Meinung nach eine passende Bezeichnung ist!
Das Abrufen nicht benötigter Daten erhöht den Speicherverbrauch und den Netzwerkverkehr, aber das Schlimmste ist, dass die Verwendung bestimmter Datenbankindizes, die für die Leistung wesentlich sind, möglicherweise behindert wird. Wie bei der Verwendung von einfachem SQL könnte man eine skalare JPQL-Abfrage schreiben und alle Spalten / Attribute, die abgerufen werden sollen, in der Select
-Klausel auflisten. JPQL bietet sogar eine Konstruktorsyntax, die zwar auf flache Objektstrukturen beschränkt ist, jedoch das Erstellen von Objekten aus dem skalaren Ergebnis ermöglicht. In der Spring Data JPA-Welt kann man Spring Data Projections verwenden, mit denen die gewünschte Ergebnisstruktur als Interface definiert werden kann. Die Getter in dem Interface werden Attributen einer Entität zugeordnet. Es ist sogar möglich, die Spring Expression Language (SpEL) innerhalb einer @Value
Annotation zu verwenden, um komplexere Zuordnungen zu modellieren. Leider ist das Problem der „unnötigen, obligatorischen Arbeit“ (“Unnecessary, mandatory work”) mit Spring Data Projections nicht gelöst! Bei Verwendung von SpEL oder verschachtelten Projektionen ruft die Projektions-Engine vollständige Entitäten ab und stellt lediglich einen „Wrapper“ vom Interface-Typ um die Entitätsobjekte bereit. An diesem Punkt muss man normalerweise wieder JPQL/SQL schreiben und DTOs aus einer flachen Ergebnisliste erstellen, die schlechte Leistung akzeptieren oder eine Lösung wie Blaze-Persistence Entity-Views verwenden.
Blaze-Persistence Entity-Views kann man sich wie Spring Data Projections auf Steroiden vorstellen. Hinter den Kulissen wird immer JPQL/SQL generiert, dass nur jene Daten abruft, die über Getter-Definitionen angefordert wurden. Es unterstützt das Zuordnen von Collection Typen, verschachtelte Projektionen (auch in Collection Typen), das Korrelieren von Daten über ad-hoc Beziehungen und vieles mehr. Darüber hinaus werden Blaze-Persistence Entity-Views während des Startvorgangs der Applikation gegen das JPA-Modell validiert. Es gibt also keine Überraschungen zur Laufzeit wegen falschen Zuordnungsausdrücken oder Typinkompatibilitäten. Dank der Integration von Blaze-Persistence Spring-Data erfordert der Wechsel von Spring Data Projektionen zu Entity-Views nur zwei Dinge. Das Einrichten von Blaze-Persistence und die Annotierung der Projektionen mit @EntityView(TheEntity.class)
.
Das Beispiel, das Markus in seinem Video vorstellte, sieht ungefähr so aus:
interface ProjB {
Integer getA1();
}
interface Proj {
Integer getA1();
ProjB getB();
}
Wenn diese Projektion in einem Spring Data Repository verwendet wird, kann man feststellen, dass die resultierende SQL-Abfrage alle Attribute auswählt und nicht nur das, was die Projektionen definieren. Die Nutzung von Entity-Views hingegen…
@EntityView(Entity2.class)
interface ProjB {
Integer getA1();
}
@EntityView(Entity.class)
interface Proj {
Integer getA1();
@Mapping("child")
ProjB getB();
}
… führt zu genau der Abfrage führen, die man erwarten würde:
SELECT e.a1, b.a1
FROM Entity e
JOIN e.child b
Teil 2: Abgeleitete Daten
Da Spring Data Projektions mehr oder weniger auf einfache 1:1-Zuordnungen von Entitätsattributen zu Projektionsattributen beschränkt sind, gibt es Grenzen, wie weit man damit gehen kann. Es gibt keine Unterstützung für z. B. Aggregatfunktionen, tiefe Pfade usw. also ist man in solchen Fällen wieder dazu gezwungen JPQL zu schreiben und Daten einem Konstruktor einer selbstgeschriebenen DTO-Klasse zuzuordnen. Mit Blaze-Persistence Entity-Views können JPQL.next-Ausdrücke für die Attributzuordnungen verwendet werden, was eine sehr mächtige Obermenge von JPQL-Ausdrücken darstellt. Dank der automatischen Generierung von Gruppierung durch Blaze-Persistence, welche automatisch nach allen nicht Aggregatausdrücken gruppiert welche in den Klauseln Select
, Order by
und Having
enthalten sind, kann man aufhören sich über explizite Gruppierung Gedanken zu machen und Projektionen wie diese schreiben:
@EntityView(Entity.class)
interface Proj {
Integer getA1();
@Mapping("SUM(child.a1)")
Integer getSum1();
@Mapping("SUM(child.a1) FILTER (WHERE a2 = 1)")
Integer getSum2();
}
Sie haben wahrscheinlich die Filter
-Klausel im JPQL.next Zuordnungsausdruck bemerkt, welche der Filter
-Klausel ähnelt, die der SQL-Standard für Aggregatfunktionen definiert. Markus erwähnte in seinem Video, dass JPQL weder die Filter
-Klausel noch die allgemeine Problemumgehung unterstützt, bei der ein Case When
-Ausdruck verwendet wird, was aber nur teilweise wahr ist. Er hat in dem Sinne Recht, dass laut JPQL-Grammatik nur Pfadausdrücke als Argument für Aggregatfunktionen zulässig sind, aber jede JPA-Implementierung unterstützt beliebige Ausdrücke. Die Verwendung von Case When
funktioniert also unabhängig von der verwendeten JPA-Implementierung. Die JPQL.next Ausdruckssyntax von Blaze-Persistence unterstützt viele fortgeschrittene SQL-Funktionen wie die Filter
-Klausel für Aggregatfunktionen, Fensterfunktionen wie z.B. die Lag
-Funktion. Hier ist ein Beispiel, das zeigt, wie Fensterfunktionen in Blaze-Persistence Entity-Views verwendet werden können:
@EntityView(Entity.class)
interface Proj {
Integer getA1();
@Mapping("SUM(child.a1)")
Integer getSum1();
@Mapping("LAG(SUM(child.a1)) OVER (ORDER BY child.a1)")
Integer getSum2();
}
Das Ergebnis für sum2
ist der Summenwert des vorherigen Elements, also jenes, das gemäß der in der Over
-Klausel angegebenen Reihenfolge hinterherhinkt. Sie werden wieder feststellen, dass die JPQL.next Abfrage und die resultierende SQL Abfrage so aussieht, wie man es erwarten würde.
SELECT e.a1
, SUM(b.a1)
, LAG(SUM(b.a1)) OVER (ORDER BY b.a1)
FROM Entity e
JOIN e.child b
GROUP BY e.a1
Auf diese Weise können Sie fortgeschrittene Datenbankfunktionen nutzen, während Sie sich weiterhin im Bereich des JPA-Modells befinden. Beachten Sie, dass die Unterstützung für Fensterfunktionen und die Filter
-Klausel in einer kürzlich veröffentlichten Version der 1.5-Serie zu Blaze-Persistence hinzugefügt wurde.
Teil 3: Geschlossener Zyklus für ORM
In seinem Video erwähnt Markus, dass Entitäten für den sogenannten „Load-Change-Store-Zyklus“ sehr nützlich sind. Wenn Sie also einen Entitätsgraphen laden, Änderungen darauf anwenden und diesen dann in die Datenbank zurückgeschreiben, spielt ein ORM laut Markus seine Stärken aus. In diesem Punkt stimme ich ihm nur teilweise zu, da sich ein ORM wie JPA hauptsächlich auf die Arbeit mit verwalteten Entitäten konzentriert, die nach Abschluss einer Transaktion zurückgeschrieben werden. Mir scheint, dass diese Art zu denken, dass es bei ORMs nur oder hauptsächlich um verwaltete Entitäten geht, ein recht beliebtes mentales Modell ist, aber lassen Sie uns eine Sekunde zurücktreten und versuchen zu verstehen, was OR-Zuordnung (Objektbeziehung) wirklich ist.
In der Welt von SQL denken wir in Relationen, welche im wesentlichen Sammlungen von Tupeln sind, während wir in objektorientierten Sprachen wie Java in Sammlungen von Objekten denken. Eine OR-Zuordnung (Object-Relation) ist genau das, was der Name bereits impliziert, eine Beschreibung für die Zuordnung zwischen Relationen und Klassen / Objekten. ORMs wie JPA ermöglichen die Zuordnung von Assoziationen und Spalten zu Feldern oder Gettern/Settern einer Klasse. Wenn wir Daten aus Relationen laden, können wir diese Zuordnung verwenden, um Objekte zu erstellen. Wir können aber auch umgekehrt vorgehen, da diese Zuordnungen bidirektional sind, ist es auch möglich, den Objektzustand wieder auf Relationen abzubilden. Tatsächlich bietet JPA nur Annotationen zum Modellieren bidirektionaler Zuordnungen, und es scheint mir, dass viele Entwickler der Meinung sind, dass dies die einzig Möglichkeit ist, OR-Zuordnungen vorzunehmen.
Der von Markus beschriebene „Load-Change-Store-Zyklus“ ist genau das, wofür diese bidirektionalen Zuordnung gedacht sind. Entitäten eignen sich aufgrund ihrer bidirektionalen Zuordnungen hervorragend für solche Anwendungsfälle. Die Nutzung von Entitäten ist super für diesen Anwendungsfall, aber er fragt sich zu Recht, ob Entitäten auch geeignet sind wenn dieser Zyklus unterbrochen ist, wenn z. B. Daten nur geladen werden um sie darzustellen. Meiner Meinung nach sind Entitäten aus mehreren Gründen nicht das richtige Werkzeug für die einfache Auflistung von Daten:
Probleme beim verzögerten Laden in der Darstellung führen zu
LazyInitializationException
oder unerwarteten Abfragen, möglicherweise sogar zu N + 1 AbfragenEntitäten modellieren normalerweise alle Daten, anstatt nur das, was für einen Anwendungsfall wirklich notwendig ist
Bidirektionale Zuordnungen sind manchmal nicht mächtig genug, um das was eine Darstellung benötigt effizient zu modellieren
Entwickler neigen dazu, SQL nur zu verwenden, wenn sie nicht wissen, wie sie mit ihrem ORM weiter vorgehen sollen, oder wenn Entitäten ihnen im Weg stehen, was im Prinzip völlig in Ordnung ist. Das einzige Problem dabei ist, dass sie jetzt die objektrelationale Nichtübereinstimmung (object-relational mismatch) überwinden müssen, indem sie die Objektzuordnung manuell durchführen. Für einfache und flache Objektstrukturen ist dies ziemlich einfach, aber je komplexer die Objektstruktur wird, desto schmerzhafter wird die manuelle Objektzuordnung. Was uns zurück zu ORMs bringt, denn am Ende des Tages müssen wir diese Sammlungen von flachen Tupeln einem Objektgraphen zuordnen, und dies ist einer der Hauptanwendungsfälle, für die ORMs gemacht sind.
Die Hauptgründe für die Rückkehr zu SQL, die ich beobachtet habe, sind
Die Notwendigkeit komplexer unidirektionaler Zuordnungen für z. B. Aggregationen, Fensterfunktionen
Die Notwendigkeit einer speziellen SQL-Funktion, um etwas effizienter zu implementieren z. B. Rekursive CTEs
Leistungsprobleme bei Entitätsabfragen aufgrund der Notwendigkeit tief verschachtelter Daten durch z. B. vielen
Fetch Join
s
Doch das muss nicht so sein! Entwickler sollten in der Lage sein, unidirektionales OR-Zuordnungen zu verwenden, selbst wenn sie fortgeschrittene Funktionen benötigen. Hier helfen Blaze-Persistence Entity-Views. Entity-Views können verwendet werden, um Interfaces/Klassen mit unidirektionalen Zuordnungen zu modellieren. Die Entity-View-Technologie ist kein weiteres ORM, sondern basiert auf dem JPA-Modell anstatt dem relationalen SQL-Modell. Dies erlaubt es Zuordnungen viel natürlicher zu modellieren und ermöglicht die Wiederverwendung von wohldefinierten Assoziationszuordnungen von Entitäten.
Blaze-Persistence Entity-Views bauen auf die Blaze-Persistence Core Query Builder API, welche die Unterstützung für viele der fortgeschrittenen SQL-Funktionen bietet, die Entwickler benötigen. Durch die Verwendung von Entity-Views kann man von den umfangreichen Objektzuordnungsfunktionen profitieren und dennoch SQL-Funktionen nutzen, die für Abfragen notwendig sind.
Muster #2: Suche
Teil 1: Baumstrukturen abrufen
In dem Video stellte Markus auch vor, wie eine baumartige Struktur abgerufen werden kann. Zunächst stellt er einen naiven Ansatz vor, der den Baum lädt, indem er beim Durchlaufen des Baums ein verzögertes Laden auslöst. Dies ist aufgrund der Menge an Abfragen, die für das verzögerte Laden erforderlich sind, um den gesamten Baum zu durchlaufen, nicht sehr effizient. Als nächstes stellt er vor, wie die Annotationen @NamedNativeQuery
und @SqlResultSetMapping
verwendet werden können, um diese Situation zu verbessern, indem Entitäten mit einer nativen Abfrage geladen werden, die rekursive CTEs verwendet. Dies ist ein sehr gängiger Ansatz für die Verwendung von nativem SQL mit JPA und funktioniert recht gut. Das größte Problem ist jedoch, dass die Abfrage statisch ist. Wenn die Abfrage dynamische Bedingungen/Joins basierend auf Benutzereingaben benötigt, muss normalerweise wieder manuell SQL geschrieben oder ein SQL-Abfrage-Generator verwendet und die Tupel wieder Objekten zugeordnet werden.
Eine weitere Option ist die Verwendung des Blaze-Persistence Query Builders, der erstklassige Unterstützung für CTEs, rekursive CTEs und viele weitere fortgeschrittene SQL-Funktionen bietet. Entwickler können damit in der Welt des JPA-Modells bleiben. Der Ergebnistyp einer CTE wird als spezieller Entitätstyp modelliert. Das von Markus gezeigte rekursive CTE-Beispiel kann leicht mit Blaze-Persistence modelliert werden.
Zuerst benötigen wir eine Entität für die Tabelle, die den Baum durch eine „Parent“-Assoziation modelliert.
@Entity
class MyEntity {
@Id
Long id;
@ManyToOne
MyEntity parent;
// andere Attribute …
}
Wir müssen auch den Ergebnistyp der CTE als spezielle @CTE
Entität modellieren.
@CTE
@Entity
class MyCte {
@Id
Long id;
}
Das Markieren der Entität mit der Annotation @CTE
teilt der JPA-Implementierung im Wesentlichen mit, dass die Entität keiner Tabelle zugeordnet ist. Die Abfrage, die Markus präsentierte, begann in einer Zeile mit einer bestimmten ID und ging bis zu den Blättern. Rekursive CTEs werden immer in zwei Teile aufgeteilt, die anfängliche Abfrage und die rekursive Abfrage. Das Abfragen der Zeile mit der spezifischen ID ist also die erste Abfrage. Der rekursive Abfrageteil nimmt dann die Zeilen aus vorherigen Ergebnissen und fragt die untergeordneten Knoten dieser Knoten ab, die dann zu den Ergebnissen für den nächsten Lauf werden, bis keine neuen Daten mehr erzeugt werden.
Wir beginnen mit der Erstellung eines Abfrage-Generators, in dem wir den Typ MyCte
als Ergebnistyp erwarten.
CriteriaBuilder<MyCte> builder =
criteriaBuilderFactory.create(entityManager, MyCte.class);
Als nächstes beginnen wir mit dem ersten Abfrageteil der rekursiven Abfrage für den Typ MyCte
:
builder.withRecursive(MyCte.class)
.from(MyEntity.class, "e")
.bind("id").select("e.id")
.where("id").eq(idValue)
…
Der interessante Teil hier ist die Bindung von ausgewählten Ausdrücken an Entitätsattribute. Eine CTE hat einen Typ, in diesem Fall den Typ MyCte
, welcher Attribute hat. In SQL werden die Spalten der CTE-Relation nach dem CTE-Namen aufgelistet. Jedes Attribut / jede Spalte für einen CTE muss gebunden sein. In SQL erfolgt die Bindung über die Position, also das erste Auswahlelement ist an die erste Spalte in der Spaltenliste gebunden usw. Da die Attribute für die CTE in der Abfragedefinition nicht explizit aufgeführt sind, müssen die Attribute gebunden werden, um Elemente auszuwählen, indem die Methode bind
gefolgt von select
aufgerufen wird.
Wir wechseln dann zum rekursiven Abfrageteil, indem wir die Set-Operation Union All
verwenden
…
.unionAll()
.from(MyEntity.class, "e")
.innerJoinOn(MyCte.class, "cte")
.on("e.parent.id").eqExpression("cte.id")
.end()
.bind("id").select("e.id")
.end()
Genau wie Markus in seinem SQL-Beispiel nutzen wir auch einen Join
um die Hauptentität mit der CTE-Entität basierend auf der „Parent“-Assoziation zu verbinden und binden das ID-Attribut erneut. Schließlich beenden wir die rekursive Abfrage, indem wir end
aufrufen, und fahren dann fort, indem wir die CTE-Entität in der From
-Klausel der Hauptabfrage verwenden.
builder.from(MyCte.class, "myCte")
.getResultList();
Das Endergebnis ist eine Liste von MyCte
-Entitätsobjekten, aber wir könnten auch die MyEntity
-Objekte wie folgt laden.
builder.from(MyEntity.class, "myEntity")
.where("myEntity.id").in()
.from(MyCte.class, "cte")
.select("cte.id")
.end()
.getResultList();
Ich hoffe, dass die Funktionen von Blaze-Persistence Menschen helfen, bessere Abfragen zu schreiben und dank objektrelationaler Zuordnung produktiver zu sein. Mit Blaze-Persistence bleiben Sie in der Welt des JPA-Modells und müssen weder die Leistung noch die Lesbarkeit von Abfragen opfern weil Sie nicht gezwungen sind, nur primtive Abfragetechniken zu verwenden.
Links zum Thema
Benutzte SQL-Klauseln:
Filter
-Klausel,With Recursive
-Klausel.