Статьи

Работа с доменными объектами в Spring MVC

Недавно я был удивлен тем, как одна кодовая база имела общедоступные конструкторы по умолчанию (т.е. конструкторы с нулевыми аргументами) во всех своих доменных объектах и ​​имела методы получения и установки для всех полей. По мере того как я копал глубже, я обнаружил, что причина, по которой доменные сущности такие, какие они есть, во многом заключается в том, что команда думает, что это требуется инфраструктурой web / MVC. И я подумал, что это будет хорошая возможность прояснить некоторые заблуждения.

В частности, мы рассмотрим следующие случаи:

  1. Нет установщика для сгенерированного поля идентификатора (т. Е. Сгенерированное поле идентификатора имеет получатель, но не установщик)
  2. Нет конструктора по умолчанию (например, нет открытого конструктора с нулевыми аргументами)
  3. Доменная сущность с дочерними сущностями (например, дочерние сущности не отображаются в виде изменяемого списка)

Обязательные параметры веб-запроса

Сначала немного специфики и фона. Давайте основывать это на конкретной среде Web / MVC — Spring MVC. При использовании Spring MVC его привязка данных связывает параметры запроса по имени. Давайте использовать пример.

1
2
3
4
5
6
7
8
@Controller
@RequestMapping("/accounts")
... class ... {
    ...
    @PostMapping
    public ... save(@ModelAttribute Account account, ...) {...}
    ...
}

Учитывая, что вышеприведенный контроллер сопоставлен с «/ Account », откуда может появиться экземпляр Account ?

Основываясь на документации , Spring MVC получит экземпляр, используя следующие параметры:

  • Из модели, если она уже добавлена ​​через Model (например, через метод @ModelAttribute в том же контроллере).
  • Из сеанса HTTP через @SessionAttributes .
  • Из переменной пути URI, переданной через Converter .
  • Из вызова конструктора по умолчанию.
  • (Только для Kotlin) От вызова «первичного конструктора» с аргументами, соответствующими параметрам запроса сервлета; Имена аргументов определяются через JavaBeans @ConstructorProperties или через имена параметров, сохраняемые во время выполнения в байт-коде.

Предполагая, что объект Account не добавлен в сеанс, и что метод @ModelAttribute не @ModelAttribute , Spring MVC завершит создание экземпляра объекта с помощью конструктора по умолчанию и связывает параметры веб-запроса по имени . Например, запрос содержит параметры «id» и «name». Spring MVC попытается связать их со свойствами bean-компонентов «id» и «name», вызвав методы «setId» и «setName» соответственно. Это следует соглашениям JavaBean.

Нет метода установки для сгенерированного поля идентификатора

Давайте начнем с чего-то простого. Допустим, у нас есть сущность домена Account . Он имеет поле идентификатора, которое генерируется постоянным хранилищем, и предоставляет только метод получения (но не метод установки).

1
2
3
4
5
6
7
8
@Entity
... class Account {
    @Id @GeneratedValue(...) private Long id;
    ...
    public Account() { ... }
    public Long getId() { return id; }
    // but no setId() method
}

Итак, как мы можем сделать так, чтобы Spring MVC связывал параметры запроса с сущностью домена Account ? Обязаны ли мы иметь метод открытого сеттера для поля, которое генерируется и доступно только для чтения?

В нашей HTML-форме мы не будем помещать «id» в качестве параметра запроса. Вместо этого мы поместим его как переменную пути.

Мы используем метод @ModelAttribute . Вызывается до метода обработки запроса. И он поддерживает почти те же параметры, что и обычный метод обработки запросов. В нашем случае мы используем его для извлечения сущности домена Account с заданным уникальным идентификатором и используем для дальнейшего связывания. Наш контроллер будет выглядеть примерно так.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Controller
@RequestMapping("/accounts")
... class ... {
    ...
    @ModelAttribute
    public Account populateModel(
            HttpMethod httpMethod,
            @PathVariable(required=false) Long id) {
        if (id != null) {
            return accountRepository.findById(id).orElseThrow(...);
        }
        if (httpMethod == HttpMethod.POST) {
            return new Account();
        }
        return null;
    }
 
    @PutMapping("/{id}")
    public ... update(...,
            @ModelAttribute @Valid Account account, ...) {
        ...
        accountRepository.save(account);
        return ...;
    }
 
    @PostMapping
    public ... save(@ModelAttribute @Valid Account account, ...) {
        ...
        accountRepository.save(account);
        return ...;
    }
    ...
}

При обновлении существующей учетной записи запросом будет URI «PUTS / {ID}». В этом случае наш контроллер должен извлечь объект домена с заданным уникальным идентификатором и предоставить тот же объект домена Spring MVC для дальнейшего связывания, если таковое имеется. Поле «id» не требует метода установки.

При добавлении или сохранении новой учетной записи запросом будет POST для «/ account». В этом случае нашему контроллеру необходимо создать новый объект домена с некоторыми параметрами запроса и предоставить тот же объект домена Spring MVC для дальнейшего связывания, если оно есть. Для новых доменных сущностей поле «id» остается null . Базовая постоянная инфраструктура будет создавать ценность при хранении. Тем не менее, поле «id» не будет нуждаться в методе установки.

В обоих случаях метод @ModelAttribute вызывается до метода обработки сопоставленного запроса. Из-за этого нам нужно было использовать параметры в populateModel чтобы определить, в каком случае он используется.

Нет конструктора по умолчанию в доменном объекте

Предположим, что наша сущность домена Account не предоставляет конструктор по умолчанию (то есть, конструктор без аргументов).

1
2
3
4
5
6
... class Account {
    public Account(String name) {...}
    ...
    // no public default constructor
    // (i.e. no public zero-arguments constructor)
}

Итак, как мы можем сделать так, чтобы Spring MVC связывал параметры запроса с сущностью домена Account ? Он не предоставляет конструктор по умолчанию.

Мы можем использовать метод @ModelAttribute . В этом случае мы хотим создать сущность домена Account с параметрами запроса и использовать его для дальнейшей привязки. Наш контроллер будет выглядеть примерно так.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Controller
@RequestMapping("/accounts")
... class ... {
    ...
    @ModelAttribute
    public Account populateModel(
            HttpMethod httpMethod,
            @PathVariable(required=false) Long id,
            @RequestParam(required=false) String name) {
        if (id != null) {
            return accountRepository.findById(id).orElseThrow(...);
        }
        if (httpMethod == HttpMethod.POST) {
            return new Account(name);
        }
        return null;
    }
 
    @PutMapping("/{id}")
    public ... update(...,
            @ModelAttribute @Valid Account account, ...) {
        ...
        accountRepository.save(account);
        return ...;
    }
 
    @PostMapping
    public ... save(@ModelAttribute @Valid Account account, ...) {
        ...
        accountRepository.save(account);
        return ...;
    }
    ...
}

Доменная сущность с дочерними сущностями

Теперь давайте посмотрим на доменную сущность, которая имеет дочерние сущности. Что-то вроде этого.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
... class Order {
    private Map<..., OrderItem> items;
    public Order() {...}
    public void addItem(int quantity, ...) {...}
    ...
    public Collection<CartItem> getItems() {
        return Collections.unmodifiableCollection(items.values());
    }
}
 
... class OrderItem {
    private int quantity;
    // no public default constructor
    ...
}

Обратите внимание, что элементы в заказе не отображаются в виде изменяемого списка. Spring MVC поддерживает индексированные свойства и связывает их с массивом, списком или другой естественно упорядоченной коллекцией. Но в этом случае метод getItems возвращает неизменяемую коллекцию. Это означает, что исключение будет выдано, когда объект пытается добавить / удалить элементы из него. Итак, как мы можем заставить Spring MVC связывать параметры запроса с сущностью домена Order ? Мы вынуждены выставлять позиции заказа в виде изменяемого списка?

На самом деле, нет. Мы должны воздерживаться от разбавления модели предметной области проблемами уровня представления (например, Spring MVC). Вместо этого мы делаем уровень представления клиентом доменной модели. Чтобы справиться с этим случаем, мы создаем другой тип, соответствующий Spring MVC, и сохраняем независимость наших доменных сущностей от уровня представления.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
... class OrderForm {
    public static OrderForm fromDomainEntity(Order order) {...}
    ...
    // public default constructor
    // (i.e. public zero-arguments constructor)
    private List<OrderFormItem> items;
    public List<OrderFormItem> getItems() { return items; }
    public void setItems(List<OrderFormItem> items) { this.items = items; }
    public Order toDomainEntity() {...}
}
 
... class OrderFormItem {
    ...
    private int quantity;
    // public default constructor
    // (i.e. public zero-arguments constructor)
    // public getters and setters
}

Обратите внимание, что совершенно нормально создать тип уровня представления, который знает о сущности домена. Но не совсем правильно информировать доменную сущность об объектах уровня представления. Более конкретно, OrderForm уровня OrderForm знает о сущности домена Order . Но Order не знает об уровне представления OrderForm .

Вот как будет выглядеть наш контроллер.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Controller
@RequestMapping("/orders")
... class ... {
    ...
    @ModelAttribute
    public OrderForm populateModel(
            HttpMethod httpMethod,
            @PathVariable(required=false) Long id,
            @RequestParam(required=false) String name) {
        if (id != null) {
            return OrderForm.fromDomainEntity(
                orderRepository.findById(id).orElseThrow(...));
        }
        if (httpMethod == HttpMethod.POST) {
            return new OrderForm(); // new Order()
        }
        return null;
    }
 
    @PutMapping("/{id}")
    public ... update(...,
            @ModelAttribute @Valid OrderForm orderForm, ...) {
        ...
        orderRepository.save(orderForm.toDomainEntity());
        return ...;
    }
 
    @PostMapping
    public ... save(@ModelAttribute @Valid OrderForm orderForm, ...) {
        ...
        orderRepository.save(orderForm.toDomainEntity());
        return ...;
    }
    ...
}

Заключительные мысли

Как я уже упоминал в предыдущих статьях, вполне нормально, чтобы ваши доменные объекты выглядели как JavaBean с общедоступными конструкторами, получателями и установщиками с нулевыми аргументами по умолчанию. Но если логика домена начинает усложняться и требует, чтобы некоторые доменные объекты теряли свою JavaBean-ность (например, больше не было общедоступного конструктора с нулевыми аргументами, больше не было сеттеров), не беспокойтесь. Определите новые типы JavaBean для удовлетворения проблем, связанных с представлением. Не разбавляйте доменную логику.

Это все на данный момент. Надеюсь, это поможет.

Еще раз спасибо Юноне за помощь с образцами. Соответствующие фрагменты кода можно найти на GitHub .

Смотрите оригинальную статью здесь: Работа с доменными объектами в Spring MVC

Мнения, высказанные участниками Java Code Geeks, являются их собственными.