Implementierung von Assoziationen

Assoziationen in Java

Java und Groovy haben keine direkte Unterstützung von Assoziationen, wie sie in der objektorientierten (UML-) Modellierung verstanden und in relationalen Datenbanken abgebildet werden.

  • In Java wird eine Assoziation als Variable (zu 1) oder Collection (zu N) implementiert. Diese Assoziation ist immer gerichtet.
  • In einer relationalen Datenbank wird eine Assoziation als Fremdschlüsselspalte (1|M zu 1) oder Korrelationstabelle (M zu N) abgebildet. Diese Assoziation ist immer bidirektional. Über Constraints kann die Optionalität implementiert werden.
  • In UML können Assoziationen gerichtet oder bidirektional sein und durch weitere Eigenschaften (Optionalität, Multiplizität, Ordnung, Eindeutigkeit, Aggregation …) genauer definiert werden.

In Informationssystemen werden (im Gegensatz zu technischen Systemen) in der Regel bidirektionale Assoziationen benötigt (welche Aufträge hat dieser Kunde ↔ zu welchem Kunden gehört dieser Auftrag). Im Java-Code der persistenten Klassen ergibt sich daher die Notwendigkeit, beide Seiten der Assoziation immer konsistent zu halten. Dafür gibt es drei grundsätzliche Möglichkeiten.

Assoziationen im Applikationscode konsistent halten
Diese Variante wird oft von (naiven?) Codegeneratoren vorgegeben: Für beide Seiten der Assoziation gibt es eine getter und auf der 1-Seite eine setter Methode für die Assoziationsvariable. Bei jedem ändernden Zugriff auf die Assoziation muss dann sichergestellt werden, dass noch alles konsistent ist. Wird beispielsweise auf der N-Seite einer N:1 Assoziation ein Objekt hinzugefügt, so muss folgendes implementiert werden:

  1. Überprüft werden, ob das Objekt bereits in einer anderen N-Seite enthalten ist.
  2. Wenn ja, muss es dort ausgetragen werden.
  3. Erst dann kann es in der neuen N-Seite hinzugefügt werden.

Das ist sich immer wiederholender fehleranfälliger Code, der über die ganze Domain verteilt sein kann. Bugs treten meist erst an einer ganz anderen Stelle im Programmablauf auf.

Methoden zum Behandeln der Assoziationen in den persistenten Klassen implementieren
Dies können z.B. die Methoden addXxx(), removeXxx() und getXxx() sein. Mit dieser Variante kann man die Assoziationsbehandlung zentralisieren und damit die Wartung deutlich vereinfachen. Der Nachteil ist, dass die Klassen stark mit Infrastrukturcode aufgebläht werden.

@OneToMany(mappedBy = "sprints", fetch = FetchType.LAZY)
private Set<Task> tasks = new LinkedHashSet<>();
public void addTask(Task task) {
    if (!tasks.contains(task)) {
        tasks.add(task);
        task.setSprint(this);
    }
}
public void removeTask(Task task) {
    if (tasks.contains(task)) {
        tasks.remove(task);
        if (task.getSprint() == this)
            task.setSprint(null);
    }
}
public Set<Task> getTasks() {
    return Collections.unmodifiableSet(tasks);
}
@ManyToOne(fetch = …, cascade = …)
private Sprint sprint;
public void setSprint(Sprint newSprint) {
    if (newSprint == sprint) return;
    else {
        Sprint oldSprint = sprint;
        sprint = newSprint;
        if( oldSprint != null) {
            oldSprint.removeTask(this);
        }
        if(newSprint != null) {
            newSprint.addTask(this);
        }
    }
}

Etwa 29 Zeilen Code sind für eine 1:N Assoziation notwendig. In unserem kleinen Beispiel gibt es schon 4 Assoziationen. Nachteilig ist auch die deutlich andere Schnittstelle der zu-1 Seite im Gegensatz zur zu-N Seite. Da sich die Multiplizität von Beziehungen im Verlauf der Projektevolution erfahrungsgemäß öfter ändert, hat dies größere Refactorings zur Folge. Auch Änderungen der Implementierung sind durch die hohe Redundanz des Assoziationscodes aufwändig und fehleranfällig (hohe Kopplung durch Code-Redundanz).

Die Assoziationsbehandlung in eigenen Klassen kapseln
Dies ist durch Java 8 Lambda Ausdrücke bzw. Groovy Closures sehr viel einfacher möglich als in älteren Java Versionen. Der Infrastrukturcode kann dadurch deutlich reduziert werden. Zu-1 und zu-N Seiten können eine identische API bekommen. Außerdem gibt es eine zentrale Stelle, an der weitere Methoden implementiert werden können, ohne den Code der persistenten Klassen weiter aufzublähen. Nach eigener Erfahrung ist removeAll() sehr nützlich beim Testen und addAll() ist auch ein vielversprechender Kandidat.

Assoziations-Implementierung mit Lambda Ausdrücken

UML Diagramm der Utility Klassen zur Assoziations-Implementierung

UML Diagramm der Klassen und Interfaces für die Assoziations-Implementierung
Klassen und Interfaces für die Assoziations-Implementierung

Kurze Beschreibung der Klassen und Interfaces

Template-Parameter HERE und THERE
Parametrisieren die Typen auf beiden Seiten einer Assoziation. HERE ist die Seite, auf der das Assoziationsende implementiert ist, THERE ist der assozierte Typ auf der anderen Seite.
IToAny<THERE>
Dieses Interface spezifiziert die Schnittstelle der Assoziations-Implementierung. Jedes navigierbare Assoziationsende ist durch ein IToAny Objekt realisiert.
IToAny.java
ToMany<HERE, THERE>
Diese Klasse implementiert eine zu-N Assoziation auf der HERE Seite.
ToMany.java
ToOne<HERE, THERE>
Diese Klasse implementiert eine zu-1 Assoziation auf der HERE Seite.
ToOne.java
@FunctionalInterface IGetOther<HERE, THERE>
Dieses Functional Interface (Erklärung Functional Interface z.B. hier) ermöglicht es, die Referenz auf eine Methode der assozierten Klasse über einen Lambda Ausdruck an den Konstruktor eines ToOne- oder ToMany-Objekts zu übergeben. Über diese referenzierte Methode kann auf das IToAny-Objekt auf des anderen Seite der Assoziation zugegriffen werden, so dass alle Änderungen auf einer Seite zur anderen Seite weitergegeben werden können.
IGetOther.java
@FunctionalInterface IGet<THERE>, @FunctionalInterface ISet<THERE>
Diese beiden Functional Interfaces ermöglichen es, die Referenz auf das private Feld des lokalen Objekts über einen Lambda Ausdruck an den Konstruktor eines ToOne- oder ToMany-Objekts zu übergeben. Damit das objektrelationale Mapping von JPA funktioniert, müssen die Assoziationsenden als annotierte Collections (zu-N) oder einfache Variablen (zu-1) angelegt sein. Ein ToOne-Objekt muss diese Variable lesen und verändern können, daher enthält der Konstruktor beide funktionalen Interfaces als Parameter. Ein ToMany-Objekt muss diese Variable nur lesen können, da es sich um eine Collection handelt, in die Objekte eingefügt oder aus ihr entfernt werden können. Trotzdem braucht es den direkten Zugriff, weil die JPA-Implementierung über modifizierten Bytecode zur Laufzeit das Collectionobjekt austauscht. Daher ist es nicht ausreichend, wenn die Collection-Variable zur Zeit der Objekterzeugung übergeben wird.
IGet und ISet sind innere Interfaces in IToAny.java

Java Beispielcode für die Implementierung der Assoziation

Ausschnitt aus dem Klassenmodell mit den Assoziationen vonTask zu Project und Sprint
Im Beispielcode implementierte Assoziationsenden von Task

Implementierung der Assoziationsenden project (zu-1) und sprint (zu-N) in der Java-Klasse Task. Das Implementierungsmuster besteht jeweils aus der mit JPA annotierten Variable, die die Referenz(en) auf das oder die assozierten Objekte enthält, einem ToOne oder ToMany Objekt, das das Assoziationsende kapselt, und einer getter Methode auf das Assoziationsende.

...
    @ManyToOne
    @JoinColumn(name = "project_id")
    private Project project;
    @Transient
    private ToOne<Task, Project> toProject = new ToOne<>(
            () -> project, (Project p) -> project = p,
            this, Project::getBacklog);

    public IToAny<Project> getProject() {
        return toProject;
    }

    @ManyToMany(mappedBy = "backlog")
    private Set<Sprint> sprints = new HashSet<>();
    @Transient
    private ToMany<Task, Sprint> toSprint = new ToMany<>(
            () -> sprints, this, Sprint::getBacklog);

    public IToAny<Sprint> getSprint() {
        return toSprint;
    }
Erläuterungen zum Code
Zeile 58
Der lesende und schreibende Zugriff auf die Variable project wird durch die beiden Closures () -> project und (Project p) -> project = p ermöglicht. Da eine Closure auf Variablen in ihrem Definitionskontext zugreifen kann, wird die private Variable project damit innerhalb des ToOne Objekts toProject zugreifbar.
Zeile 59
Der Ausdruck Project::getBacklog übergibt eine Referenz auf die Instanzmethode getBacklog() an das ToOne Objekt.
Zeile 69
Das ToMany Objekt toSprints braucht nur lesenden Zugriff auf die Collection sprints, daher wird nur eine Closure an den Konstruktor übergeben.
Zeile 61, 71
Diese Methoden ermöglichen den Zugriff auf die Assoziationsenden.

Groovy Beispielcode für die Implementierung der Assoziation

Der Groovy-Code ist ausnahmsweise etwas umfangreicher als der Java-Code, weil die verwendete Groovy Version 2.4 noch keine Java 8 Lambda Ausdrücke kennt. Daher muss die Groovy Closure mit as IToAny.IGet bzw. as IToAny.ISet auf den Typ des Interface gezwungen (coerced) werden.

...
    @ManyToOne
    @JoinColumn(name = "project_id")
    private Project project
    @Transient
    private ToOne<Sprint, Project> toProject = new ToOne<>(
            { this.@project } as IToAny.IGet,
            { Project p -> this.@project = p } as IToAny.ISet,
            this, { o -> o.sprint } as IGetOther
    )
    public IToAny<Project> getProject() { toProject }

    @ManyToMany
    @JoinTable(name = 'join_sprint_task',
            joinColumns = @JoinColumn(name = 'sprint_id'),
            inverseJoinColumns = @JoinColumn(name = 'task_id'))
    private Set<Task> backlog = new HashSet<>()
    @Transient
    private ToMany<Sprint, Task> toBacklog = new ToMany<>(
            { this.@backlog } as IToAny.IGet, this,
            { Task o -> o.sprint } as IGetOther
    )

    public IToAny<Task> getBacklog() { toBacklog }
Erläuterungen zum Code
Zeilen 33, 34, 46
Hier werden Closures zum Zugriff auf die Variablen übergeben. Das @ vor dem Variablennamen bewirkt den direkten Zugriff. Groovy würde sonst eine getter Methode aufrufen.
Zeilen 35, 47
So wird auf die Methode auf der anderen Seite der Assoziation zugegriffen, die das Assoziationsende zurückgibt. Da es eine getter Methode ist, wird die Property-Schreibweise verwendet.
Zeilen 37, 50
Die Zugriffsmethode unterscheidet sich nur insoweit von der Java-Version als die return Anweisung weggelassen werden kann.

Beispielcode für den Zugriff auf eine Assoziation

UML Notation der Schnittstelle IToAny mit den Zugriffsmethoden auf Assoziationsenden
Zugriffsmethoden auf Assoziationsenden

Alle Klassen im Beispielprojekt, die auf Assoziationen zugreifen, sind in Groovy implementiert. Der Zugriff aus Java ist nicht deutlich anders. Es muss nur die getter Funktion auf das Assoziationsende, z.B. aTask.getSprints() explizit in Methodennotation aufgerufen werden, während in Groovy die Property-Notation aTask.sprints für getter Methoden möglich ist.

...
    public ProjectDto.QList getProjects() {
        ProjectDto.QList qList = new ProjectDto.QList()
        projectRepository.findAll().sort {it.name.toLowerCase()}.each { Project p ->
            def node = new ProjectDto.QNode( [name: p.name])
            p.backlog.all.sort {it.tag.toLowerCase()}.each { Task t ->
                node.backlog.add(taskService.taskTree(t))
            }
            p.sprint.all.sort {it.start}.each { Sprint sp ->
                node.sprint.add(new SprintDto.QNode([id: sp.id, name: sp.name]))
            }
            qList.all[p.id] = node
        }
        qList
    }
Erläuterungen zum Code
Zeile 41
Hier beginnt eine Closure, die für jedes in der Datenbank abgelegte Projekt aufgerufen wird. Repositories werden auf einer eigenen Seite besprochen.
Zeile 43
Der Ausdruck p.backlog.all liefert eine Collection aller im Assoziationsende backlog des Projekts p abgelegten Task-Objekte. Diese wird dann mit Groovy Standardmethoden für Collections (sort, each) weiter bearbeitet. In Java würde der Zugriff auf die Collection so aussehen: p.getBacklog().getAll(). Mit Java Streams und Lambda Ausdrücken könnte die weitere Verarbeitung analog implementiert werden.
Zeile 46
Analog zu Zeile 43 für das Assoziationsende p.sprint.

Weitere Beispiele finden sich auch in den Testklassen EntitySpecification.groovy und JavaGroovyEntitySpecification.groovy, in denen die verschiedenen Methoden der Assoziationsenden getestet werden.

Aufgaben

  1. Implementieren Sie die Assoziationen Ihrer neu angelegten Klassen (s. Aufgabe zu Objektrelationalen Abbildungen) untereinander und zu den Klassen im Beispielmodell wie hier vorgestellt.
  2. Erweitern Sie die Assoziationsimplementierung um eine Methode addAll(Collection<THERE> c).

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.