Temporale Datenhaltung in der Praxis mit Java

Know-how  –  4 Kommentare

Unterschiedliche Prozesse in Anwendungen sorgen dafür, dass sich Daten ständig verändern. CRUD-Operationen (Create Read Update Delete) gehören wahrscheinlich zu den meist implementierten Features in Geschäftsanwendungen. Oft sind die aktuellen Daten für die Benutzer am interessantesten, da sie die aktuelle Situation eines Objektes darstellen. In bestimmten Bereichen spielt jedoch die Historie, die auch als temporale Datenhaltung bekannt ist, eine übergeordnete Rolle.

Als Beispiel für einen Fall, in dem die Historie von Objekten von Bedeutung ist, lässt sich das allgemeine Vertragsmanagement nennen: Die Nachvollziehbarkeit der Datenänderungen bei einzelnen Verträgen muss in dem Fall – teilweise sogar gesetzlich vorgeschrieben – gewährleistet werden. Der vorliegende Artikel soll anhand einer einfachen Domäne der Adressendatenverwaltung das Konzept der temporalen Datenhaltung demonstrieren. Anschließend wird es mit einem entsprechenden Framework in Java implementiert (siehe dazu den Exkurs "Basis-Frameworks für temporale Implementierungsbeispiele").

Die Behandlung des Zeitbezugs (Temporalität, Historisierung) von Daten stellt ein zentrales Thema in vielen Geschäftsanwendungen dar. Sind die Daten noch gültig? Wann haben sie sich gegebenenfalls geändert? Inwieweit muss das System auf Änderungen der Wirklichkeit reagieren und zu welchem Zeitpunkt sollte es das tun?

Allgemein bezeichnet temporale Datenhaltung eine Technik zum Speichern der zeitlichen Entwicklung der Daten in einer Datenbank. In vielen Geschäftsanwendungen ist ihr Einsatz nicht mehr wegzudenken:

  1. Rentenzahlungen: Datenänderungen der Rentner sind stets zu verwalten und zu historisieren.
  2. Allgemeines Vertragsmanagement: Verträge lassen sich zu bestimmten Bedingungen verknüpfen. Welche Bedingungen zu welchem Zeitpunkt gültig sind, muss stets eindeutig sein.
  3. Standes- und Einwohnermeldeamt: Geburtsdaten, Umzüge und Sterbedaten sind zu historisieren und zu protokollieren.

Grundlagen der Temporalität

Die Behandlung des Zeitbezugs kann in mehreren Stufen erfolgen:

Stufe 0 – Ohne Zeitbezug (keine Temporalität und keine Historie): In dem Fall lassen sich ausschließlich Fragen zum aktuellen Status beantworten.

Stufe 1 – Temporaler mit fachlichem Zeitbezug beziehungsweise mit aktuellem Zeitbezug: In dem Fall ist die Gültigkeit eines Objektes mitzuspeichern, sodass sich Fragen zum fachlichen Sachverhalt innerhalb eines Zeitintervalls beantworten lassen. Diese Stufe realisieren Entwickler in Geschäftsanwendungen oft mit zusätzlichen Feldern wie "fachlich gültig von" und "fachlich gültig bis".

Stufe 2 – Temporaler mit Bearbeitungs- beziehungsweise Transaktionszeitbezug: Das Programm speichert die Bearbeitungszeit eines Objektes mit, sodass sich Fragen zu Objektänderungen innerhalb eines Zeitintervalls beantworten lassen. Typisches Beispiel hierfür ist das Verwenden von Versionierungssystemen wie CVS oder SVN zum Protokollieren von Veränderungen im Quellcode. Auch in diesem Fall tauchen häufig Felder wie "Bearbeitungsgültigkeit von" und "Bearbeitungsgültigkeit bis" auf.

Stufe 3 – Mit fachlichem beziehungsweise aktuellem Zeitbezug und gleichzeitigem Bearbeitungszeitbezug, bekannt als bitemporaler Zeitbezug: Die Anwendung speichert beide Zeitbezüge gleichzeitig, sodass sich sowohl Fragen zur fachlichen Gültigkeit als auch zu technischen Änderungen des Objektes beantworten lassen.

Möglichkeiten der Umsetzung

Java-Entwickler kommen beim Thema der Temporalität schnell zum Einsatz von ORM-Frameworks (Objekt Relational Mapper). Schließlich verwenden sie Java-Objekte und wollen – soweit möglich – von der Datenbankschicht abstrahiert arbeiten können. Derzeit unterstützen gängige SQL-Datenbanken das Konzept der Bitemporalität (Stufe 3) nicht direkt. Daher ist sie explizit in den betroffenen Anwendungen zu realisieren. Bei der Implementierung der Bitemporalität existieren prinzipiell zwei Wege:

(1) Datenbankgetrieben:

  • Implementierung der Bitemporalität in der betroffenen Datenbank mit Hilfe von Triggern und Stored-Prozeduren: Die Variante ist auszuwählen, sofern nicht ausschließlich Java-Anwendungen in die Datenbank schreiben.
  • Falls Oracle im Einsatz ist, lässt sich Oracle Flashback einsetzen. Entwickler können es für Abfragen verwenden, um Informationen aus der Vergangenheit zu selektieren. Hier ist zu beachten, dass Flashback ausschließlich das Speichern der Transaktionszeit beziehungsweise Bearbeitungszeit unterstützt (Stufe 2). In Kombination mit einer eigenen Implementierung für die Stufe 1 (fachlicher Zeitbezug) lässt sich das Konzept der Bitemporalität (Stufe 3) umsetzen.
  • TimeDB lässt sich als temporale Anfragesprache für SQL-Datenbanken nutzen. Es ist mit Java implementiert, fungiert als dünne Schicht zwischen Java-Anwendungen und SQL-Datenbanken und erweitert die SQL-Befehle um temporale Befehle. Das Produkt bietet sowohl eine Benutzeroberfläche als auch Programmierschnittstellen (APIs) an. Da das Projekt seit 2005 nicht mehr aktiv ist, lässt sich der Einsatz aus heutiger Sicht nicht mehr empfehlen.

(2) Objektgetrieben mit Java:

  • Die Implementierung auf der Objekt-Ebene beziehungsweise mit ORM-Frameworks ist in der Java-Umgebung zu bevorzugen. Sämtliche Zugriffe auf die Daten passieren über ein ORM-Framework. Open-Source-Frameworks wie DAOFusion, Hibernate Envers und Spring Data JPA ermöglichen eine solche Behandlung der Temporalität (siehe Exkurs: Quelloffene Java-Frameworks für die temporale Datenhaltung).

Implementierungsbeispiele mit Spring und JPA mit Hibernate

Um die Unterschiede der einzelnen genannten temporalen Stufe besser verstehen zu können, lassen sich passende Fragen auf Basis einer einfachen Adressendatenverwaltungsdomäne ausformulieren und beantworten. Den Schwerpunkt des Artikels soll die objektgetriebene Variante bilden. Das einfache Beispiel zeigt zunächst folgendes Verhalten (siehe Abb. 1):

  • Eine Person (Entität Person) besitzt keine oder eine Adresse.
  • Eine Adresse (Entität Address) gehört stets zu einer Person.

Es gibt einen Person- und Adressen-Service, mit deren Hilfe sich Personen und Adressen anlegen, aktualisieren und finden lassen. Zudem sollen einige Hilfsfunktionen in den Service-Klassen
unterkommen. Um das Beispiel simpel zu halten, bleibt das Löschen der Entitäten außen vor.

Beispiel einer einfachen Adressendatenverwaltungsdomäne (Abb. 1)


Auf der Basis der Adressendatenverwaltungsdomäne lassen sich nun Beispielimplementierungen für die Stufe 0 bis 3 darstellen und untersuchen.

(1) Stufe 0: Ohne Zeitbezug (keine Temporalität und keine Historie)

In dem Fall kann man ausschließlich folgende Frage beantworten: Wo wohnt die Person "Müller" derzeit? Es gibt eine einzige Adresse der ausgewählten Person. Solch ein Verhalten ist in Geschäftsanwendungen wahrscheinlich am weitesten verbreitet und sicherlich am einfachsten umzusetzen. Mit Hilfe eines ORM-Mapper-Frameworks wie Hibernate (als JPA-Implementierung) lassen sich solche Funktionen übersichtlich implementieren.

Das Beispiel non-temporal stellt den Code der Adressendatenverwaltungsdomäne ohne Zeitbezug zur Verfügung. Aus dem Domänenmodell (siehe Abbildung 1) kann der Generator Simple Java von KissMDA die Java-Schnittstellen generieren. Java-Implementierungsklassen realisieren Letztere anschließend. In dem Zusammenhang lässt sich die Klasse PersonImpl.java als Beispiel heranziehen, die die generierte Schnittstelle Person.java implementiert. Zu beachten ist die 1:1-Beziehung zu der Schnittstelle Address.java:

@Entity
@Table(name = "person")
public class PersonImpl implements Person, Serializable {
...
@OneToOne(targetEntity = AddressImpl.class)
private Address address;
...
}

Der Rest der Implementierungen mit der Klasse PersonRepository.java und der Service-Klasse PersonServiceImpl.java stellt bereits bekannte DAO- beziehungsweise Repository- und Service-Muster bei der Entwicklung mit Spring Framework und JPA dar.

Zum Testen der Anwendung steht eine Integrationstest-Klasse PersonTest.java zur Verfügung. In der Klasse sind einige Szenarien zu den obengenannten Fragen ausformuliert, die sich anschließend auf die Richtigkeit der Ergebnisse prüfen lassen:

...
public class PersonTest {
@Test
public void testCreateNonTemporalAddresses() {
...
// Erzeuge die erste Adresse
Address createdAddress1 = addressService.createAddressWithPerson(
firstAddress, createdPerson);
...
// Finde die richtige Person mit der aktuellen Adresse
Person updatedPerson = personService.findPersonById(
createdPerson.getId());
...
// Stimmt die Adresse mit der ersten Adresse?
Address firstCheckedAddress = updatedPerson.getAddress();
assertEquals(firstAddress.getCity(), firstCheckedAddress.getCity());
...
}
...
}