Hin und wieder verwende ich das “antiquierte” Entwurfsmuster des Datentransferobjekts (kurz DTO), um Daten zwischen Benutzeroberfläche und Backend auszutauschen. Dieses Muster war vor einigen Jahren speziell im EJB-Umfeld verbreitet, da die dort eingesetzten Entity Beans nicht zur Oberfläche gereicht werden konnten. Also hat man die für die Anzeige notwendigen Daten in ein einfaches Java-Objekt kopiert und dieses zur Oberfläche geschickt. Ich benutze dieses Muster häufiger und auch aktuell wieder in einer Tapestry-Anwendung. Der Grund hierfür: Durch den Einsatz der @Validate-Annotation würde man die Objekte des Datenmodells mit GUI-spezifischen Annotationen vermischen. Und sowas mag ich irgendwie garnicht… :)
Nun wird vielleicht der eine oder andere, der schon einmal DTOs eingesetzt hat, bemerkt haben, daß das Hin-und-her-Kopieren der Eigenschaften eine bisweilen nervige Arbeit sein kann. So ging es mir zumindest vor ein paar Tagen wieder. Also habe ich mir etwas überlegt, um diesen Prozeß zu vereinfachen bzw. fast vollständig zu automatisieren. Diese Idee möchte ich in diesem Artikel näher vorstellen.

Die Ausgangssituation

Als Beispiel sei folgendes übersichtliches Domänenmodell gegeben:

Ein Konto (Account) mit seinen Daten ist genau einem Kunden (Customer) über die Eigenschaft owner zugeordnet. Ein zugehöriges Set an DTOs könnte wie folgt aussehen:

Beziehungen werden aufgelöst und für die tabellarische Anzeige aller Konten wäre es schön, den Namen des Kunden zu sehen (AccountDTO#customerName). Außerdem wird für die Erfassung eines Kontos nur die ID des Kunden benötigt, um die Strukturen im Backend aufzubauen (AccountDTO#customerId).

Nun würde man sich vermutlich hinsetzen und Methoden schreiben, um die Daten aus den Domänenobjekten in die zugehörigen DTOs zu kopieren.

Lösungsansatz

Ziel der Lösung soll es sein, mit möglichst wenig manuellem Eingreifen eine automatische Übertragung der Daten vom Quell- in das Zielobjekt zu ermöglichen. Im Java-Umfeld gibt es mit den BeanUtils von Apache Commons eine Bibliothek, die den Umgang mit Java-Klassen in Bezug auf den Datentransfer vereinfacht. Diese kann man also verwenden, um praktisch mit einem Einzeiler die Daten von einem Objekt in das andere zu übertragen:

Account orig = ...
AccountDTO dest = new AccountDTO();
BeanUtils.copyProperties(dest, orig);

Allerdings kopiert diese Methode nur die primitiven Eigenschaften gleichen Namens und steigt schlimmstenfalls bei inkompatiblen Typen aus. Außerdem löst diese Methode nicht das Problem der beiden Eigenschaften Account#customerId und Account#customerName, die ja aus einem referenzierten Objekt bezogen werden. Es sind also einige Erweiterungen notwendig, die ich durch Annotationen verfügbar mache.

Neben den Annotationen gibt es zudem Factory-Klasse, in der die eigentliche Funktionalität implementiert ist: Die Auswertung der Annotationen und die Übertragung der Eigenschaften gemäß der Konfiguration.

Die Annotationen

Folgende Annotationen habe ich eingeführt:

  • @DTOAttribute für die genauere Angabe des Quellwertes
  • @DTOIgnore, um eine Eigenschaft zu ignorieren
  • @DTOConverter für die Konvertierung eines Wertes zwischen Quelle und Ziel
  • @DTO für die optionale Validierung von Datentypen

@DTOAttribute

Um die Quelle einer Eigenschaft genauer bestimmen zu können, kann die Annotation @DTOAttribute verwendet werden:

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DTOAttribute {
    String source();
}

Der Schlüssel source gibt an, woher ein Wert bezogen werden soll. Hier kann man nun mit Punktnotation auch über Eigenschaften navigieren. Hier ist im Prinzip alles erlaubt, was die BeanUtils verarbeiten können. Wichtig für die spätere Verarbeitung ist die Retention, die auf RUNTIME eingestellt werden sollte, damit die Annotation für die JVM zur Laufzeit sichtbar ist.

@DTOIgnore

Durch die Marker-Annotation @DTOIgnore kann man eine Eigenschaft im DTO von der Befüllung aus einer gleichnamigen Eigenschaft im Quellobjekt ausschließen.

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DTOIgnore { }

@DTOConverter

Die Annotation @DTOConverter löst das Problem der inkompatiblen Eigenschaften durch Angabe eines Konverters über den Schlüssel converter.

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DTOConverter {
    @SuppressWarnings("rawtypes")
    Class<? extends DTOAttributeConverter> converter();
}

Der Konverter muß die Schnittstelle DTOAttributeConverter implementieren. Diese Schnittstelle stellt eine Methode convert() zur Verfügung, mit der die Eigenschaft aus dem Quellobjekt in die Eigenschaft im Zielobjekt konvertiert wird. Die Schnittstelle ist generisch, angegeben werden jeweils der Eigenschaften-Typ in der Quelle (S) und im Ziel (D).

public interface DTOAttributeConverter<S, D> {
    D convert(S src);
}

@DTO

Diese Annotation dient in erster Linie der Validierung der verwendeten Objekte. Sie ermöglicht eine Prüfung, ob das angegebene DTO für ein Quellobjekt als gültiges Ziel akzeptiert wird. So kann man zur Entwicklungszeit sicherstellen, daß die korrekten Paarungen aus Domänenobjekt und DTO an die Factory-Methode übergeben werden.

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DTO {
    Class<?> target();
}

Die Factory-Klasse

Als Basis für die Übertragung der Eigenschaften dient die oben gezeigte Methode. Ein Blick in die Quellen der BeanUtils zeigt, daß nach Ermittlung aller Eigenschaften diese nach und nach in das Ziel kopiert werden. Ich mache mir dieses Vorgehen an dieser Stelle nicht nur zu eigen, sondern erweitere es zudem um die Verarbeitung der eingeführten Annotationen. Diese Operation lagere ich in eine Klasse namens DTOFactory aus, die eine überladene, öffentliche Methode zur Verfügung stellt: createDTO. In der ersten Version erwartet die Methode zwei Instanzen, eine vom Typ des DTOs und eine vom Typ der Quelle. Die Überladung ermöglicht es, nur den Typen des DTOs sowie eine Instanz der Quelle anzugeben. Intern erzeugt diese Methode zunächst die notwendige Instanz des DTOs anhand des übergebene Typen und leitet die Verarbeitung an die erste Methode weiter.

public class DTOFactory {
 
    /** Cache für Konverter. */
    private HashMap<Field, DTOAttributeConverter<Object, Object>> converters =
            new HashMap<Field, DTOAttributeConverter<Object, Object>>();
 
    public <T> T createDTO(Class<T> dtoClass, Object source) {
        try {
            T dto = dtoClass.newInstance();
            createDTO(dto, source);
            return dto;
        } catch (IllegalArgumentException iae) {
            throw iae;
        } catch (Exception e) {
            return null;
        }
    }
 
    public <T> void createDTO(T dto, Object source) {
        // Schritt 1: Parameterprüfung
        if (dto == null) {
            throw new IllegalArgumentException("Target object may not be null.");
        }
        if (source == null) {
            throw new IllegalArgumentException("Source object may not be null.");
        }
 
        // Schritt 2: Annotation @DTO
        DTO aDto = dto.getClass().getAnnotation(DTO.class);
        if (aDto != null && !aDto.target().equals(source.getClass())) {
            throw new IllegalArgumentException("Source type '" +
                    aDto.target().getName() + "' expected, but found '" +
                    source.getClass().getName() + "'.");
        }
 
        // Schritt 3: Eigenschaften ermitteln und verarbeiten
        PropertyDescriptor[] props = PropertyUtils.getPropertyDescriptors(dto);
        for (int i = 0; i < props.length; i++) {
            String name = props[i].getName();
 
            // Schritt 3.1
            if ("class".equals(name) || !PropertyUtils.isWriteable(dto, name)) {
                continue; // class-Eigenschaft von Object ignorieren bzw. Ziel nicht änderbar
            }
 
            try {
                Field field = dto.getClass().getDeclaredField(name);
 
                // Schritt 3.2: auf DTOIgnore prüfen und Eigenschaft ggf. überspringen
                if (field.getAnnotation(DTOIgnore.class) != null) {
                    continue;
                }
 
                Object value = null;
 
                // Schritt 3.3: auf DTOAttribute-Annotation prüfen
                DTOAttribute att = field.getAnnotation(DTOAttribute.class);
                if (att != null) {
                    if (PropertyUtils.isReadable(source, att.source())) {
                        // Wert aus angegebener Quelle holen
                        value = PropertyUtils.getProperty(source, att.source());
                    }
                } else if (PropertyUtils.isReadable(source, name)) { // Standardverhalten
                    // Quelle anhand gleichen Namens auslesen
                    value = PropertyUtils.getProperty(source, name);
                }
 
                // Schritt 3.4: wenn DTOConverter angegeben, Konvertierung durchführen
                DTOConverter conv = field.getAnnotation(DTOConverter.class);
                if (conv != null) {
                    DTOAttributeConverter<Object, Object> converter =
                            getConverter(conv.type(), conv.name(), field);
                    value = converter.convertFromSource(value);
                }
 
                // Schritt 3.5: Wert in Ziel schreiben
                BeanUtils.copyProperty(dto, name, value);
            } catch (Exception e) {
                // ignorieren
            }
        }
    }
 
    @SuppressWarnings({ "unchecked", "rawtypes" })
    protected DTOAttributeConverter<Object, Object> getConverter(
            Class<? extends DTOAttributeConverter> type,
            String name, Field field) throws Exception {
        DTOAttributeConverter<Object, Object> converter = converters.get(field);
        if (converter == null) {
            converter = type.newInstance();
            converters.put(field, converter);
        }
        return converter;
    }
}

Schauen wir uns das Vorgehen der Methode näher an. Im Schritt 1 wird zunächst geprüft, ob Quelle oder Ziel null sind. Dies ist untersagt, daher wird eine IllegalArgumentException geworfen.
Im zweiten Schritt wird das Vorhandensein der Annotation @DTO überprüft. Ist das der Fall, so wird der darin angegebene, erwartete Typ des Quellobjekts mit dem tatsächlichen Datentypen verglichen. Stimmen beide Typen nicht überein, so wird auch an dieser Stelle eine Ausnahme ausgelöst.
Anschließend werden die verfügbaren Eigenschaften mit Hilfe der BeanUtils ermittelt und verarbeitet. Hierzu wird zunächst geprüft, ob es sich um die Eigenschaft class handelt oder die Eigenschaft im DTO nicht veränderbar ist (Schritt 3.1). In diesem Fall wird sie einfach übersprungen. Auch im Falle der Annotation mit @DTOIgnore wird die betroffene Eigenschaft übersprungen (Schritt 3.2). In Schritt 3.3 wird nun der Wert aus der Quelle gelesen. Falls die Annotation @DTOAttribute existiert, wird der Wert aus der darin angegebenen Quelle gelesen, andernfalls aus der gleichnamigen Eigenschaft (Standardverhalten). Schritt 3.4 prüft auf die Annotation @DTOConverter und versucht in diesem Falle eine Konvertierung des Wertes mit Hilfe des angegebenen Konverters. Die Konverter werden nach der Erzeugung zwischengespeichert, um sie ggf. wiederverwenden zu können. Im letzten Schritt (3.5) wird dann der Wert in das Ziel geschrieben.
Falls irgendwann während der Verarbeitung einer Eigenschaft ein Fehler auftritt, so wird die daraus resultierende Ausnahme verschluckt und die Eigenschaft damit übersprungen.

Anwendungsbeispiel

Wir wenden nun die Annotationen auf das oben angegebene Domänenmodell an, um dann die Daten aus einer Instanz der Klasse Account in eine Instanz des zugehörigen DTOs zu übertragen:

public class AccountDTO {
    private Long id;
    private String bic;
    private String iban;
    @DTOAttribute(source="owner.id")
    private Long customerId;
    @DTOAttribute(source="owner.name")
    private String customerName;
 
    // Getter und Setter (notwendig)
}
// Objektnetz erzeugen
Customer c = new Customer();
c.setId(new Long(42));
c.setName("Max Mustermann");
c.setEmailAddress("max@mustermann.com");
Account a = new Account();
a.setId(new Long(21));
a.setBic("WELADED1KSD");
a.setIban("DE34567890123456");
a.setOwner(c);
 
// das DTO erzeugen und mit Daten befüllen
DTOFactory factory = new DTOFactory();
AccountDTO dto = factory.createDTO(AccountDTO.class, a);

Et voilà, ein Einzeiler ermöglicht uns nun bei korrekt annotiertem DTO-Typen eine Übertragung aller gewünschten Eigenschaften aus dem Domänenobjekt in das Ziel-DTO.

Mögliche Erweiterungen

Eine denkbare Erweiterung für die Konvertierung wäre die Verwendung von vordefinierten Beans bspw. in einem Spring-Umfeld. So könnte eine Eigenschaft name der Annotation @DTOConverter hinzugefügt werden, mit welcher die Bean-ID des zu verwendenden Konverters angegeben werden könnte. Gleichzeitig würde eine Ableitung der DTOFactory nötig, in welcher dann bei Vorhandensein eines Namens eine Auflösung aus der Spring-BeanFactory in der Methode getConverter() versucht werden würde.

Die Schnittstelle DTOAttributeConverter kann um eine Methode erweitert werden, die den Konverter in die Lage versetzt, auch die Richtung DTO -> Domänenobjekt mit einer Konvertierung der Daten zu bedienen. Auch in diesem Fall ist dann eine Erweiterung der Factory-Klasse notwendig.