29. August 2019 von Thomas Mayr
Fundierte Auswahl eines Frameworks – oder das haben wir immer schon so gemacht (Teil 2)
Nachdem ich euch im ersten Teil meines Blog-Beitrags Standard-JDBC als erste Variante eines Java-Frameworks zur Implementierung einer Persistenzschicht vorgestellt habe, möchte ich nun näher auf Hibernate und JPA eingehen. Abschließend erfolgt der Vergleich aller drei Varianten in Bezug auf die Kriterien „Aufwand“ und „Performanz".
Hibernate-Implementierung
Für die Implementierung mit Hibernate habe ich in diesem Beitrag die „klassische“ Variante gewählt: die ausgelagerte und objektrelationale Abbildung in einer XML-Datei. Damit werden für die Geschäftsklassen keine Entity-Klassen mit Annotationen benötigt und die Geschäftsklassen lassen sich auf verschiedenen Datenbankstrukturen abbilden.
Für Hibernate benötigt ihr für bestimmte Datentypen spezielle Klassen, die eine Abbildung zwischen Datenbanktabellen und -spalten sowie Attributen implementieren. Diese Klassen müssen die Hibernate-Schnittstelle UserType implementieren. Typische Beispiele für solche benutzerdefinierten Typen sind etwa Abbildungen von Aufzählungen (enum), booleschen Werten (boolean), Ländern sowie Sprachen (Locale) oder Währungen (Currency). Für diese Typen bietet Hibernate keine Standardabbildung an.
In unserem Beispiel ist es so, dass die Werte in einer Java Map auf mehrere Datenbankspalten - also abhängig vom Schlüssel - abgebildet werden. Auch dafür ist eine benutzerdefinierte Abbildungsklasse erforderlich. Für jeden benutzerspezifischen Typ wird also eine Implementierung dieser Schnittstelle benötigt. Dabei definiert die Schnittstelle elf Methoden und die Implementierung besteht im Schnitt aus 40 Code-Zeilen. Diese Zahlen gehen in den Aufwand für die allgemeine Funktionalität ein.
Für Hibernate ist es hilfreich, wenn jede Geschäftsklasse einen Standardkonstruktor hat. Wenn das nicht gegeben oder gewollt ist - etwa wie in unserem Beispiel - braucht ihr eine Fabrikklasse für Geschäftsobjekte, mit der die Hibernate-Schnittstelle Interceptor implementiert wird. Diese Fabrik muss dann in der Hibernate-Konfiguration registriert werden (Configuration.setInterceptor()). Hibernate stellt euch bereits eine leere Implementierung dieser Schnittstelle mit der Klasse EmptyInterceptor zur Verfügung, die dann erweitert werden kann, um nur die notwendigen Methoden zu überschreiben:
final class ObjectFactory extends EmptyInterceptor
static final ObjectFactory THE_INSTANCE = new ObjectFactory();
private ObjectFactory() {}
public Object instantiate(String entityName,
EntityMode entityMode,
Serializable id) {
Object object = null;
if(<BusinessClass>.class.getName().equals(entityName)) {
object = new <BusinessClass> (id, …);
else if (…)
…
}
return object;
}
}
Die Klasse ist als Singleton implementiert und Hibernate übergibt der Methode instantiate() als entityName den Namen, der bei der Klassendefinition in der Abbildungskonfiguration angegeben wurde. In der Regel ist das der Name der Klasse und der Primärschlüssel als id. Damit muss das entsprechende Geschäftsobjekt erzeugt und zurückgeliefert werden, um im Anschluss die objektrelationale Abbildung zu definieren. Dazu wird pro Geschäftsklasse ein Eintrag in der Abbildungsdefinition benötigt:
<class name="<BusinessClass>" table="<TableName">
Für jedes Attribut benötigt ihr eine Abbildungsvorschrift auf die entsprechende Tabellenspalte:
<property name="<AttributeName" column="<ColumnsName>" type="string" access="field"/>
Für den Primärschlüssel wird das Element id statt property verwendet. Zusätzlich müsst ihr den Generator für den Schlüssel angeben:
<id name="<AttributeName" column="<ColumnsName>" type="string" access="field">
<generator class="org.hibernate.id.enhanced.SequenceStyleGenerator">
<param name="sequence_name"><SequenceName></param>
</generator>
</id>
Für 1..0/1 Beziehungen müsst ihr zudem eine many-to-one-Beziehung definieren:
<many-to-one name="<AttributeName>"
column="<columnName>"
class="<BusinessClass>"
access="field"
not-null="false"
lazy="false"/>
Für 1..n Beziehungen solltet ihr eine Abbildung für die verwendete Container-Klasse und eine one-to-many-Beziehung definieren:
<bag name="<CollectionAttributeName>"
access="field"
lazy="false"
inverse="true">
<key>
<column name="ForeignKeyColumnName"/>
</key>
<one-to-many class="<BusinessClass"/>
</bag>
Die Methode zum Speichern eines Geschäftsobjekts sieht dann wie folgt aus:
public void save<BusinessClass>(<BusinessClass> businessObject) throws PersistenceException {
CriteriaBuilder criteriaBuilder;
CriteriaQuery<Long> criteriaQuery;
Query<Long> query;
Root<<BusinessClass>> root;
Long existingId = null;
try {
criteriaBuilder = session.getCriteriaBuilder();
criteriaQuery = criteriaBuilder.createQuery(Long.class);
root = criteriaQuery.from(<BusinessClass>.class);
criteriaQuery.select(root.get("id")).
where(criteriaBuilder.equal(root.get("<UniqueKeyAttribute-1>"),
businessObject.get<UniqueKey-1>()));
query = session.createQuery(criteriaQuery);
existingId = query.getSingleResult();
if (existingId != null) {
businessObject.setId(existingId);
session.update(businessObject);
} else {
session.save(businessObject);
}
} catch (Exception exception) {
throw new PersistenceException(exception);
}
}
Für das Speichern von 1..0/1 Beziehungen kommen folgende Zeilen dazu:
<BusinessClass> referencedObject = businessObject.get<BusinessClass>();
if (referencedObject!= null) {
save<BusinessClass>(referencedObject);
}
Für das Speichern von 1..n Beziehungen dann auch noch die folgenden Zeilen:
for (<BusinessClass referencedObject : businessObject.get<BusinessClass>Collection()) {
save<BusinessClass>(referencedObject);
}
Die Methode zum Lesen eines Geschäftsobjekts ist dagegen recht einfach:
public <BusinessClass> get<BusinessClass>ById(Long id) throws PersistenceException {
try {
return session.get(<BusinessClass>.class, id);
} catch (Exception exception) {
throw new PersistenceException(exception);
}
}
Anders als ihr es bei JDBC gesehen habt, sind durch die Option lazy=“false“ bei der Definition der Beziehungen in der Abbildungsdefinition für Beziehungen keine zusätzlichen Code-Zeilen notwendig.
Die folgende Tabelle enthält den Aufwand für die Hibernate-Implementierung. Dabei habe ich jedes XML-Element in der Abbildungsdefinition mit einer Code-Zeile bewertet.
JPA-Implementierung
Kommen wir zur letzten Java-Framework-Variante: JAP. Für die JPA-Implementierung benötigt ihr zunächst Entity-Klassen für die Datentransferobjekte (DTO), damit ihr die Abbildung auf der Datenbank definieren könnt. Wenn ihr euch erinnert, diese Abbildungsdefinition ist analog zu jener mit Hibernate. Für jede Geschäftsklasse - etwa eine Datenbanktabelle – benötigt ihr eine Entity-Klasse, in der die Abbildung der Attribute auf die Tabellenspalten definiert werden kann. In unserem Beispiel habe ich - neben dem Standardkonstruktor, der von JPA benötigt wird - einen weiteren Konstruktor implementiert, an den das Geschäftsobjekt übergeben wird. Dieser Konstruktor initialisiert dann die Attribute des DTOs mit den Daten des Geschäftsobjekts. Darüber hinaus habe ich die Methode get<BusinessClass>() implementiert, die ein Geschäftsobjekt aus den Daten des DTOs erzeugt:
@Entity(name = "<DatabaseTable>")
@SequenceGenerator(name="<SequenceName>", initialValue=1, allocationSize=1)
class <EntityClass> {
@Id
@GeneratedValue(strategy=GenerationType.SEQUENCE, generator="<SequenceName>")
@Column(name = "<ColumnNameID>”)
private Long id;
@Column(name = "<ColumnAttribute-1")
private String attribute1;
…
<EntityClass>() {}
<EntityClass>(<BusinessClass> businessObject) {
attribute1 = businessObject.get<Attribute-1>();
…
}
<BusinessClass> get<BusinessClass>() {
return new <BusinessClass> (id, attribute1, …);
}
Long getId() {
return id;
}
}
Für eine 1..0/1 Beziehung kommt noch eine weitere Annotation hinzu:
@JoinColumn(name = "<ForeignKeyColumn>")
@ManyToOne(fetch = FetchType.EAGER)
private <EntityClass> referencedEntity1;
Ihr müsst für jede dieser Entity-Klassen in der JPA-Konfigurationsdatei persistence.xml eintragen:
<persistence xmlns="http://java.sun.com/xml/ns/persistence"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/persistence
http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd"
version="2.0">
<persistence-unit name="de.adesso.persistence.sample.jpa">
<provider>org.hibernate.ejb.HibernatePersistence</provider>
<class>de.adesso.persistence.sample.jpa.<EntityClass></class>
…
</persistence-unit>
</persistence>
Für 1..0/1 Beziehungen müssen die referenzierten Objekte im Konstruktor und in der get<BusinessClass>() Methode umgewandelt werden:
referencedEntity1 = new <EntityClass>(businessObject.get<BusinessClass>();
businessObject = referencedEntity1.get<BusinessClass>();
Für 1..n Beziehungen benötigt ihr noch zwei Schleifen, um die Entity- beziehungsweise Geschäftsobjekte in die jeweiligen Container des DTOs und des Geschäftsobjekts einzufügen:
for (<BusinessClass> referencedObject : businessObject.get<BusinessClass>List()) {
collection1.add(new <EntityClass>(referenceObject));
}
for (<EntityClass> entityObject: collection1) {
businessObject.add<BusinessClass>(entityObject.get<BusinessClass>();
}
Mit diesen Grundlagen steht der Implementierung der Methoden zum Speichern und Lesen des Geschäftsobjekts nichts mehr im Wege. Die Implementierung sieht der Hibernate-Implementierung sehr ähnlich, doch statt der Hibernate Session wird hier der JPA EntityManager verwendet und anstelle der Hibernate Query die JPA TypedQuery:
public void save<BusinessClass>(<BusinessClass> businessObject) throws PersistenceException {
CriteriaBuilder criteriaBuilder;
CriteriaQuery<Long> criteriaQuery;
TypedQuery<Long> query;
Root<<EntityClass>> root;
Long id = null;
<EntityClass> entityObject;
Long existingId;
try {
criteriaBuilder = entityManager.getCriteriaBuilder();
criteriaQuery = criteriaBuilder.createQuery(Long.class);
root = criteriaQuery.from(<EntityClass>.class);
criteriaQuery.select(root.get("id")).
where(criteriaBuilder.equal(root.get("<uniqueKey1"),
businessObject.get<UniqueKey1>()));
query = entityManager.createQuery(criteriaQuery);
existingId = query.getSingleResult();
entityObject = new <EntityClass>(businessObject);
if (existingId != null) {
entityObject.setId(existingId);
entityObject = entityManager.merge(entityObject);
} else {
entityManager.persist(entityObject);
}
businessObject.setId(entityObject.getId());
} catch (Exception exception) {
throw new PersistenceException(exception);
}
Für das Speichern von 1..0/1 Beziehungen kommen folgende Zeilen dazu:
<BusinessClass> referencedObject = businessObject.get<BusinessClass>();
if (referencedObject!= null) {
save<BusinessClass>(referencedObject);
}
Für das Speichern von 1..n Beziehungen werden folgende Zeilen benötigt:
for (<BusinessClass referencedObject : businessObject.get<BusinessClass>Collection()) {
save<BusinessClass>(referencedObject);
}
Falls die referenzierten Kind-Objekte auch inverse Referenzen auf das Eltern-Objekt haben, muss den Speichermethoden für die Kind-Objekte eventuell das Eltern-Entity-Objekt mitgegeben werden. Nur auf diese Weise kann die inverse Relation gesetzt werden.
Wie ihr seht, ist die Methode zum Lesen eines Geschäftsobjekts ähnlich einfach wie bei Hibernate. Hier müsst ihr nur noch das Entity-Objekt in ein Geschäftsobjekt umwandeln:
public <BusinessClass> get<BusinessClass>ById(Long id) throws PersistenceException {
<EntityClass> entityObject;
try {
entityObject = entityManager.find(<EntityClass>.class, id);
return entityObject!= null ? entityObject.get<BusinessClass>() : null;
} catch (Exception exception) {
throw new PersistenceException(exception);
}
}
Benutzerspezifische Typen, wie sie bei Hibernate erforderlich sind, werden bei JPA in der Regel nicht benötigt, da die Typumwandlung in den Methoden der Entity-Klassen implementiert werden kann. Dazu sind dann einige Anweisungen mehr notwendig, auf die ich an dieser Stelle allerdings verzichten möchte.
Für die JPA-Implementierung ergibt sich folgender Aufwand, wobei ich jede Annotation und jedes XML-Element als eine Code-Zeile bewertet habe:
Zusammenfassung des Implementierungsaufwands
Nehmen wir an, ihr habt zehn Geschäftsklassen, 200 Attribute, 10 1..0/1 Beziehungen, 5 1..n Beziehungen und für Hibernate fünf benutzerspezifische Typen, dann erhaltet ihr folgendes Ergebnis:
Bei Hibernate schlagen die Klassen für die benutzerdefinierten Typen relativ stark zu Buche: Je mehr Geschäftsklassen und Attribute es gibt, desto weniger wirken sich diese aus. Außerdem könnt ihr die Klassen in anderen Projekten wiederverwenden. Bei JPA wirken sich die Entity-Klassen negativ aus. Diese Zahl steigt auch noch im selben Verhältnis mit der steigenden Anzahl von Geschäftsklassen und Attributen an.
Mit diesen Zahlen könnt ihr jetzt den Aufwand für euer Projekt abschätzen. Dabei müsst ihr euch nicht an meine vorgegebene Zählweise halten. Ihr könnt sie so ändern, dass ihr zum gewünschten Ergebnis kommt - nach dem Motto „Traue nur der Statistik, die du selber gefälscht hast“.
Performanzvergeich
Für den Vergleich der Performanz der verschiedenen Varianten habe ich 1860 Geschäftsobjekte in fünf Tabellen mit insgesamt 56 Spalten neu eingefügt, aktualisiert und gelesen. Jeden Test habe ich zehn Mal ausgeführt und den Durchschnitt berechnet. Die Zeiten sind in Sekunden angegeben und der Prozentwert bezieht sich auf die JDBC-Implementierung. Die Tests habe ich als JUnit-Tests in IntelliJ mit einer Oracle-Datenbank auf einem MacBook-Pro laufen lassen. Das Ergebnis könnt ihr in der folgenden Tabelle sehen:
Fazit
Die Zahlen können vielleicht für euch teilweise überraschend sein. Das Ergebnis zeigt aber, dass es durchaus Sinn macht, für einen projekttypischen Anwendungsfall ein kleines Beispiel mit verschiedenen Varianten zu programmieren. Auf diese Weise könnt ihr dann eine objektive Entscheidung treffen, anstatt euch auf euer Gefühl zu verlassen.
Ihr möchtet mehr über spannende Themen aus der adesso-Welt erfahren, dann werft doch auch einen Blick auf unsere bisher erschienenen Blog-Beiträge.
Hier geht es zum ersten Teil des Beitrags:
Fundierte Auswahl eines Frameworks – oder das haben wir immer schon so gemacht (Teil 1)