~/home of geeks

Beanmapping mit Annotationen

· 2073 Wörter · 10 Minute(n) Lesedauer

Ein kleiner Exkurs in Beanmapping zweier Klassen mit Reflection, Apache Commons Lang3 und Annotationen.

In einem MVC Controller wollte ich die Daten einer Entität auf eine Formularklasse mappen, in diesem Fall Benutzer-Entitäten (User). Das Formular sollte einige Felder anzeigen, die nur informativ zum Lesen waren (wie der Zeitpunkt des letzten Logins und der letzten Änderung). Andere Felder, wie der Vorname, sollten durch den Benutzer änderbar sein.

Setup #

Die User-Entität:

@Entity
public class User implements Serializable {

    @Id
    @GeneratedValue
    @Column(name = "id", unique = true, nullable = false, updatable = false)
    private Integer id;

    @Column
    private String firstName;

    @Column
    private String lastName;

    @Column
    private Date lastLoginTime;

    @Column
    private Date modifyTime;

    // ...

Die Formularbean:

public class AccountUpdateForm {

    private Integer id;

    @NotBlank(message = "{account.update.error.notBlank.firstName}")
    @Size(max = 255, message = "{account.update.error.tooLong.firstName}")
    private String firstName;

    @NotBlank(message = "{account.update.error.notBlank.lastName}")
    @Size(max = 255, message = "{account.update.error.tooLong.lastName}")
    private String lastName;

    private String lastLoginTime;

    private String modifyTime;

    // ...

Entsprechend sind die Inputs für die ID und die Zeitstempel im HTML nicht bearbeitbar und werden daher auch nicht mit abgeschickt.

<form class="form-horizontal" role="form" action="#" th:action="${target}" th:object="${account}" method="post">
    <fieldset class="row">
        <!--/*
            6 of 12 = 1/2 columns on small devices,
            5 of 12 = 1/3 columns on large devices
        */-->
        <div class="col-sm-6 col-md-5">
            <div class="form-group">
                <label for="id" class="col-md-4 control-label" th:text="#{account.id}">ID</label>
                <div class="col-md-8 text-left">
                    <div class="col-md-6 col-sm-6 nopadlr">
                        <input type="text" class="form-control" id="id" th:value="${id}" readonly="readonly" disabled="true"/>
                    </div>
                </div>
            </div>
        </div>

        <div class="col-sm-6 col-md-5">
            <div class="form-group">
                <label for="username" class="col-md-4 control-label" th:text="#{account.username}">Username</label>
                <div class="col-md-8">
                    <input type="text" class="form-control" id="username" placeholder="Username" th:placeholder="#{account.username}" th:autofocus="${#authorization.expression('hasAuthority(''ADMIN'')')}" th:field="*{username}" th:disabled="${not (#authorization.expression('hasAuthority(''ADMIN'')'))}"/>
                </div>
            </div>
        </div>

    </fieldset><!--/* row */-->

    <fieldset class="row">
        <legend th:text="#{account.personalInformation}">Personal Information</legend>
        <div class="col-sm-6 col-md-5">
            <div class="form-group">
                <label for="firstName" class="col-md-4 control-label" th:text="#{account.firstName}">First name</label>
                <div class="col-md-8 text-left">
                    <div class="col-md-6 col-sm-6 nopadlr">
                        <input type="text" class="form-control" id="firstName" placeholder="first name" th:placeholder="#{account.firstName}" th:field="*{firstName}" />
                    </div>
                </div>
            </div>
        </div>

        <div class="col-sm-6 col-md-5">
            <div class="form-group">
                <label for="lastName" class="col-md-4 control-label" th:text="#{account.lastName}">Last name</label>
                <div class="col-md-8">
                    <input type="text" class="form-control" id="lastName" placeholder="last name" th:placeholder="#{account.lastName}" th:field="*{lastName}"/>
                </div>
            </div>
        </div>

    </fieldset><!--/* row */-->
...

Beim Initialisieren des Formulars sollen Attribute der User-Entität auf die Formularklasse kopiert werden. Beispielhaft wird dies über die Methode fillInitialAccountData angedeutet.

@RequestMapping(value = "/accounts/current", method = RequestMethod.GET)
    public String viewCurrent(final Model model) {
        return initUpdateFormByIdInternal(authorizationService.getCurrentUser().getId(), model);
    }

    private String initUpdateFormByIdInternal(final int id, final Model model) {
        User user = userService.findById(id);
        if (user != null) {
            AccountUpdateForm form = new AccountUpdateForm();
            fillInitialAccountData(user, form);
            model.addAttribute("account", form);
            model.addAttribute("id", id);
            model.addAttribute("target", getTargetPath(id));
        }
        return VIEW_NAME_SINGLE_ACCOUNT;
    }

Wird das Formular nun abgeschickt und enthält Fehler (z. B. wurde der Inhalt des Feldes “Vorname” gelöscht, welches aber nicht leer sein darf), dann soll das Formular die gleichen (überarbeiteten) Daten wieder anzeigen. Da aber die nur lesbaren Felder nicht beim Abschicken mittransportiert wurden, sind diese jetzt in der Formularklasse leer. Dies ist natürlich dem Umstand geschuldet, wie Spring MVC mit solchen Formularbeans verfährt: Sie werden nicht in der Session abgelegt, sondern mit den Daten aus dem Request befüllt. Und die nicht editierbaren Felder werden beim Abschicken des Formulars vom Browser nicht mitgeschickt. Die verlorenen Daten müssen nun erneut von der Entität auf die Formularklasse herüber kopiert werden, angedeutet durch die Methode fillUpdateAccountData

@RequestMapping(value = "/accounts/{id}", method = RequestMethod.POST)
    public String processUpdateFormById(@Valid @ModelAttribute("account") final AccountUpdateForm form,
                                        final BindingResult result,
                                        @PathVariable("id") final int id,
                                        final Model model) {

        return processUpdateFormByIdInternal(form, result, id, model);
    }

private String processUpdateFormByIdInternal(final AccountUpdateForm form,
                                                 final BindingResult result,
                                                 final int id,
                                                 final Model model) {

        if (result.hasErrors()) {
            User user = userService.findById(id);
            fillUpdateAccountData(user, form);
            model.addAttribute("id", id);
            model.addAttribute("target", getTargetPath(id));
            return VIEW_NAME_SINGLE_ACCOUNT;
        } else {
            userService.update(id, form);
            return SUCCESS_PAGE;
        }
    }

Anforderungen #

Da meine Anwendung noch an einigen Stellen mit Formularen arbeiten muss, die genau solche verschiedenen Mappingoperationen durchführen müssen, wollte ich ein Mappingframework verwenden.

In seinem Artikel “Wer ist der optimale Java Bean Mapper?” führt Frank W. Rahn aktuelle und populäre Java Beanmapping-Frameworks auf und bewertet diese nach Geschwindigkeit für seinen Use-Case. Die meisten dieser Frameworks sind sehr praktisch, erlauben es mir aber nicht, folgendes zu tun.

Kontextbasiertes Mappen #

Kopieren von Properties basierend auf einem Kontext.

Hierbei soll der Aufruf mapper.map(user, form, "initial") andere Felder kopieren als mapper.map(user, form, "submit"). Bei einigen Mapping APIs kann man hierzu separate Mapper-Instanzen konfigurieren. Dies geschieht aber über Builder, wie in ReMap oder Dozer.

Annotationen #

Ich möchte Annotation der Zielproperties (des Formulars), die angeben, aus welchen Feldern der Quellbean diese befüllt werden sollen. Annotationen sind imho einfacher lesbar und wenn die Mappinginformationen an der Stelle stehen, wo die Daten hin sollen, sind sie auch einfacher aufzufinden.

Diejenigen Mapping APIs, die Annotationen an Feldern unterstützen, die ich gesehen habe, erlauben keine Kontextinformation. Die Entität wird hier stets gleich auf eine Zielklasse gemappt. Zum Beispiel ist MapStruct darauf ausgelegt, dass man den Mapper annotiert, nicht das Mappingziel. Auch wird bei MapStruct eine Mapping-Klasse generiert. Sicherlich hat es Vorteile, den Mapper zu definieren: Es ist flexibler. Aber mein Anliegen ist eine einfache Verwendung. Das gilt auch für den sehr komplexen, aber leider nicht mehr gewarteten jMapper, der ebenfalls mit Hilfe von Werkzeugen, wie Javassist optimierten und vom Entwickler beeinflussbaren Bytecode generiert.

Meine Annotation #

Die Gelegenheit, eine weitere Beanmapping-API zu schreiben.

Situation: There are 14 competing standards. “14? Ridiculous! We need to develop one universal standard that covers everyone’s use cases.” Soon: There are 15 competing standards.

standards.png by xkcd
standards.png by xkcd
Aus XKCD: Standards

Hierzu habe ich erst die Annotation definiert. Sie enthält drei Informationen:

  1. Welches Feld der Quellbean kopiert werden soll (field). Als Default wird hier der leere String zurückgegeben (null ist in Annotationen nicht als Default-Wert erlaubt). Wird dieses Attribut nicht angegeben, soll in der Quellbean nach einem gleichnamigen Feld gesucht werden.

  2. Für welchen Kontext diese Annotation gilt (groups). Sie kann eine Menge von Gruppenbezeichnungen enthalten, die beim Mappen berücksichtigt werden. Beim Mappen können Groupen übergeben werden, die dann das Mapping nur auf Annotationen mit dieser Gruppe einschränken. Ist keine Gruppe in der Annotation angegeben, so gilt die Annotation für alle Mappingausführungen.

  3. Welche Transformation mit dem Quellwert durchgeführt werden soll (function). Natürlich müssen öfters Objekte und Typen umgewandelt werden, was über die Angabe einer Function ermöglicht werden soll. In meinem Fall betrifft dies z. B. die Datumsfelder, die im Formular als Strings dargestellt werden. Komplexere Beanmappings, z. B. mit mehreren Quellbeans auf eine Zielbean oder das Zusammenführen von Informationen aus mehreren Feldern ist in dieser vereinfachten Basisversion nicht vorgesehen.

Die Annotation für Felder:

import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.util.function.Function;

import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Documented
@Target({ FIELD })
@Retention(RUNTIME)
public @interface Mapped {
    String field() default "";
    String [] groups() default {};
    Class<? extends Function> function() default Identity.class;
}

Eine einfache Testklasse soll zeigen, welches Verhalten ich in verschiedenen Fällen erwarte:

public class MapperTest {
    public static class DateMapper implements Function<Date, String> {
        private SimpleDateFormat dateFormat = new SimpleDateFormat("dd-MM-yyyy");

        @Override
        public String apply(Date date) {
            return dateFormat.format(date);
        }
    }

    public class BeanSource {
        private int id;
        private String text;
        private String textUnmapped;
        private String textOtherName;
        private Date date;
        private String textForGroup2;

        public int getId() {
            return id;
        }

        public void setId(int id) {
            this.id = id;
        }

        public String getText() {
            return text;
        }

        public void setText(String text) {
            this.text = text;
        }

        public String getTextUnmapped() {
            return textUnmapped;
        }

        public void setTextUnmapped(String textUnmapped) {
            this.textUnmapped = textUnmapped;
        }

        public String getTextOtherName() {
            return textOtherName;
        }

        public void setTextOtherName(String textOtherName) {
            this.textOtherName = textOtherName;
        }

        public Date getDate() {
            return date;
        }

        public void setDate(Date date) {
            this.date = date;
        }

        public String getTextForGroup2() {
            return textForGroup2;
        }

        public void setTextForGroup2(String textForGroup2) {
            this.textForGroup2 = textForGroup2;
        }
    }

    public class BeanTarget {
        @Mapped
        private int id;
        @Mapped
        private String text;
        private String textUnmapped;
        @Mapped(field = "textOtherName")
        private String textMyName;
        @Mapped(field = "date", function = DateMapper.class)
        private String dateStr;
        @Mapped(groups = "group2")
        private String textForGroup2;

        public int getId() {
            return id;
        }

        public void setId(int id) {
            this.id = id;
        }

        public String getText() {
            return text;
        }

        public void setText(String text) {
            this.text = text;
        }

        public String getTextUnmapped() {
            return textUnmapped;
        }

        public void setTextUnmapped(String textUnmapped) {
            this.textUnmapped = textUnmapped;
        }

        public String getTextMyName() {
            return textMyName;
        }

        public void setTextMyName(String textMyName) {
            this.textMyName = textMyName;
        }

        public String getDateStr() {
            return dateStr;
        }

        public void setDateStr(String dateStr) {
            this.dateStr = dateStr;
        }

        public String getTextForGroup2() {
            return textForGroup2;
        }

        public void setTextForGroup2(String textForGroup2) {
            this.textForGroup2 = textForGroup2;
        }
    }

    private Mapper mapper;

    @Before
    public void setup() throws IllegalAccessException {
        mapper = new Mapper();
        // Da mein Mapper den Spring ApplicationContext verwendet, 
        // wird dieser erst einmal gemockt
        ApplicationContext applicationContext = Mockito.mock(ApplicationContext.class);
        FieldUtils.writeField(mapper, "applicationContext", applicationContext, true);
    }

    @Test
    public void mapSourceTarget_withoutGroup() throws MappingException {
        BeanSource source = new BeanSource();
        source.setId(12);
        source.setDate(DateUtils.parseDate("20.12.2012", "dd.MM.yyyy"));
        source.setText("text123");
        source.setTextForGroup2("text4Group2XX");
        source.setTextOtherName("text4OtherNameYY");
        source.setTextUnmapped("textIsUnmapped");

        BeanTarget target = new BeanTarget();
        target = mapper.map(source, target);

        assertThat(target.getId(), is(source.getId()));
        assertThat(target.getDateStr(), is("20-12-2012"));
        assertThat(target.getText(), is(source.getText()));
        assertThat(target.getTextForGroup2(), is(source.getTextForGroup2()));
        assertThat(target.getTextMyName(), is(source.getTextOtherName()));
        assertThat(target.getTextUnmapped(), nullValue());
    }

    @Test
    public void mapSourceTarget_withGroup() throws MappingException {
        BeanSource source = new BeanSource();
        source.setId(12);
        source.setDate(DateUtils.parseDate("20.12.2012", "dd.MM.yyyy"));
        source.setText("text123");
        source.setTextForGroup2("text4Group2XX");
        source.setTextOtherName("text4OtherNameYY");
        source.setTextUnmapped("textIsUnmapped");

        BeanTarget target = new BeanTarget();
        target = mapper.map(source, target, "group2");

        // Only Properties with group = "group2" are processed

        assertThat(target.getId(), is(0));
        assertThat(target.getDateStr(), nullValue());
        assertThat(target.getText(), nullValue());
        assertThat(target.getTextForGroup2(), is(source.getTextForGroup2()));
        assertThat(target.getTextMyName(), nullValue());
        assertThat(target.getTextUnmapped(), nullValue());
    }

    @Test
    public void mapSourceTarget_withGroup_noOverwrite() throws MappingException {
        BeanSource source = new BeanSource();
        source.setId(12);
        source.setDate(DateUtils.parseDate("20.12.2012", "dd.MM.yyyy"));
        source.setText("text123");
        source.setTextForGroup2("text4Group2XX");
        source.setTextOtherName("text4OtherNameYY");
        source.setTextUnmapped("textIsUnmapped");

        BeanTarget target = new BeanTarget();
        target.setId(13);
        target.setDateStr("mydate");
        target.setText("matext");
        target.setTextForGroup2("text4Group2XX");
        target.setTextMyName("maname");
        target.setTextUnmapped("maunmapped");

        target = mapper.map(source, target, "group2");

        // Only Properties with group = "group2" are processed

        assertThat(target.getId(), is(13)); // unchanged
        assertThat(target.getDateStr(), is("mydate")); // unchanged
        assertThat(target.getText(), is("matext")); // unchanged
        assertThat(target.getTextForGroup2(), is(source.getTextForGroup2())); // overwritten
        assertThat(target.getTextMyName(), is("maname")); // unchanged
        assertThat(target.getTextUnmapped(), is("maunmapped")); // unchanged
    }
}

Implementierung #

Die Implementierung des Beanmappers selber verwendet Reflection und die Apache Commons Lang3 Bibliotheken, um auf Felder zu zu greifen. Diese gelten als eher langsam, sind aber für den einfachen Fall, dem zeitunkritischen Mappen von Formularen und der Einfachheit der Verwendung sehr gut geeignet.

import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import org.apache.commons.beanutils.PropertyUtils;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;

import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;

@Component
public class Mapper {
    @Autowired
    private ApplicationContext applicationContext;

    public <DEST, SOURCE> DEST map(SOURCE source, DEST destination, String... mappingGroups) throws MappingException {
        Preconditions.checkNotNull(source);
        Preconditions.checkNotNull(destination);
        final Class<?> destinationClass;
        // Handling of Spring AOP-Proxies
        if (AopUtils.isAopProxy(destination)) {
            destinationClass = org.springframework.aop.support.AopUtils.getTargetClass(destination);
        } else {
            destinationClass = destination.getClass();
        }

        Set<String> mappingGroupsSet = new HashSet<>(Arrays.asList(Optional.ofNullable(mappingGroups).orElse(new String[]{})));

        for (final Field field : FieldUtils.getAllFields(destinationClass)) {
            if (field.isAnnotationPresent(Mapped.class)) {
                Mapped mappedAnnotation = field.getAnnotation(Mapped.class);
                if (!mappingGroupsSet.isEmpty()) {
                    Set<String> annotatedMappingGroups = new HashSet<>(Arrays.asList(
                            Optional
                                    .ofNullable(mappedAnnotation.groups())
                                    .orElse(new String[]{})
                    ));
                    if (!CollectionUtils.containsAny(annotatedMappingGroups, mappingGroupsSet)) {
                        continue;
                    }
                }

                String sourceProperty = Optional
                        .ofNullable(Strings.emptyToNull(mappedAnnotation.field()))
                        .orElse(field.getName());
                Object sourcePropertyValue = null;
                try {
                    sourcePropertyValue = PropertyUtils.getProperty(source, sourceProperty);
                } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
                    throw new MappingException(
                            String.format("While mapping %s to %s on property %s", source, destination, sourceProperty)
                            , e);
                }

                Class<? extends Function> functionClass = mappedAnnotation.function();
                if (functionClass != Identity.class) {
                    Function function = applicationContext.getBean(functionClass);
                    if (function == null) {
                        try {
                            function = functionClass.newInstance();
                        } catch (InstantiationException | IllegalAccessException e) {
                            throw new MappingException(
                                    String.format("While mapping %s to %s on property %s", source, destination, sourceProperty)
                                    , e);
                        }
                    }
                    sourcePropertyValue = function.apply(sourcePropertyValue);
                }

                try {
                    FieldUtils.writeField(destination, field.getName(), sourcePropertyValue, true);
                } catch (IllegalAccessException e) {
                    throw new MappingException(
                            String.format("While mapping %s to %s on property %s", source, destination, sourceProperty)
                            , e);
                }
            }
        }
        return destination;
    }

    public <DEST, SOURCE> DEST map(SOURCE source, Class<? extends DEST> destinationClass, String... mappingGroups) throws MappingException {
        try {
            return map(source, destinationClass.newInstance(), mappingGroups);
        } catch (InstantiationException | IllegalAccessException e) {
            throw new MappingException(
                    String.format("While instantiating %s", destinationClass)
                    , e);
        }
    }
}

Die nun annotierte Formularklasse sieht wie folgt aus:

public class AccountUpdateForm {

    // not editable
    @Mapped(groups = {"initial", "submit"})
    private Integer id;

    @NotBlank(message = "{account.update.error.notBlank.firstName}")
    @Size(max = 255, message = "{account.update.error.tooLong.firstName}")
    @Mapped(groups = "initial")
    private String firstName;

    @NotBlank(message = "{account.update.error.notBlank.lastName}")
    @Size(max = 255, message = "{account.update.error.tooLong.lastName}")
    @Mapped(groups = "initial")
    private String lastName;

    // not editable
    @Mapped(groups = {"initial", "submit"}, function = MappedDateFunction.class)
    private String lastLoginTime;

    // not editable
    @Mapped(groups = {"initial", "submit"}, function = MappedDateFunction.class)
    private String modifyTime;

    // ...

Das Ziel der Mappingoperation: Das annotierte Formular.

Nun kann auf einfache Weise das Mappen über den Kontext für die verschiedenen Fälle gesteuert werden:

private String initUpdateFormByIdInternal(final int id, final Model model) {
        User user = userService.findById(id);
        if (user != null) {
            AccountUpdateForm form = mapper.map(user, AccountUpdateForm.class, "initial");
            model.addAttribute("account", form);
            model.addAttribute("id", id);
            model.addAttribute("target", getTargetPath(id));
        }
        return VIEW_NAME_SINGLE_ACCOUNT;
    }

    // ...

    private String processUpdateFormByIdInternal(final AccountUpdateForm form,
                                                 final BindingResult result,
                                                 final int id,
                                                 final Model model) {

        if (result.hasErrors()) {
            User user = userService.findById(id);
            mapper.map(user, form, "submit");
            model.addAttribute("account", form);
            model.addAttribute("id", id);
            model.addAttribute("target", getTargetPath(id));
            return VIEW_NAME_SINGLE_ACCOUNT;
        } else {
            userService.update(id, form);
            return SUCCESS_PAGE;
        }
    }