Как отличить нулевые и не предоставленные значения для частичных обновлений в Spring Rest Controller

Я пытаюсь различать нулевые значения и не предоставленные значения при частичном обновлении объекта с помощью метода запроса PUT в Spring Rest Controller.

В качестве примера рассмотрим следующую сущность:

@Entity
private class Person {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    /* let assume the following attributes may be null */
    private String firstName;
    private String lastName;

    /* getters and setters ... */
}

Репозиторий My Person (Spring Data):

@Repository
public interface PersonRepository extends CrudRepository<Person, Long> {
}

Используемый DTO:

private class PersonDTO {
    private String firstName;
    private String lastName;

    /* getters and setters ... */
}

Мой Spring RestController:

@RestController
@RequestMapping("/api/people")
public class PersonController {

    @Autowired
    private PersonRepository people;

    @Transactional
    @RequestMapping(path = "/{personId}", method = RequestMethod.PUT)
    public ResponseEntity<?> update(
            @PathVariable String personId,
            @RequestBody PersonDTO dto) {

        // get the entity by ID
        Person p = people.findOne(personId); // we assume it exists

        // update ONLY entity attributes that have been defined
        if(/* dto.getFirstName is defined */)
            p.setFirstName = dto.getFirstName;

        if(/* dto.getLastName is defined */)
            p.setLastName = dto.getLastName;

        return ResponseEntity.ok(p);
    }
}

Запрос с отсутствующим свойством

{"firstName": "John"}

Ожидаемое поведение: обновить firstName= "John" (оставить lastName неизменным).

Запрос с нулевым свойством

{"firstName": "John", "lastName": null}

Ожидаемое поведение: обновите firstName="John" и установите lastName=null.

Я не могу различать эти два случая, так как lastName в DTO всегда устанавливается в null Jackson.

Примечание: Я знаю, что лучшие методы REST (RFC 6902) рекомендуют использовать PATCH вместо PUT для частичных обновлений, но в моем конкретном сценарии мне нужно использовать PUT.

Ответ 1

Собственно, если игнорировать проверку, вы можете решить свою проблему следующим образом.

   public class BusDto {
       private Map<String, Object> changedAttrs = new HashMap<>();

       /* getter and setter */
   }
  • Сначала напишите суперкласс для вашего dto, например BusDto.
  • Во-вторых, измените ваше dto, чтобы расширить класс super, и измените dto, чтобы поместить имя и значение атрибута в changedAttrs (beacause spring будет вызывать набор, когда атрибут имеет значение no matter null или not null).
  • В-третьих, обход карты.

Ответ 2

Используйте булевы флаги, как рекомендует Джексон.

class PersonDTO {
    private String firstName;
    private boolean isFirstNameDirty;

    public void setFirstName(String firstName){
        this.firstName = firstName;
        this.isFirstNameDirty = true;
    }

    public void getFirstName() {
        return firstName;
    }

    public boolean hasFirstName() {
        return isFirstNameDirty;
    }
}

Ответ 3

Я попытался решить ту же проблему. Мне было довольно легко использовать JsonNode как DTO. Таким образом вы получаете только то, что отправлено.

Вам нужно будет написать MergeService самостоятельно, что делает фактическую работу, похожую на BeanWrapper. Я не нашел существующую структуру, которая может делать именно то, что необходимо. (Если вы используете только запросы Json, вы можете использовать метод Jacksons readForUpdate.)

Фактически мы используем другой тип node, поскольку нам нужна такая же функциональность из "стандартных форм" и других вызовов службы. Кроме того, модификации должны применяться внутри транзакции внутри того, что называется EntityService.

Этот MergeService, к сожалению, станет довольно сложным, так как вам придется самостоятельно обрабатывать свойства, списки, наборы и карты:)

Наиболее проблематичным для меня было различие между изменениями в элементе списка/набора и модификациями или заменами списков/наборов.

А также проверка не будет простой, поскольку вам нужно проверить некоторые свойства против другой модели (сущности JPA в моем случае)

EDIT - код кода (псевдокод):

class SomeController { 
   @RequestMapping(value = { "/{id}" }, method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE)
    @ResponseBody
    public void save(
            @PathVariable("id") final Integer id,
            @RequestBody final JsonNode modifications) {
        modifierService.applyModifications(someEntityLoadedById, modifications);
    }
}

class ModifierService {

    public void applyModifications(Object updateObj, JsonNode node)
            throws Exception {

        BeanWrapperImpl bw = new BeanWrapperImpl(updateObj);
        Iterator<String> fieldNames = node.fieldNames();

        while (fieldNames.hasNext()) {
            String fieldName = fieldNames.next();
            Object valueToBeUpdated = node.get(fieldName);
            Class<?> propertyType = bw.getPropertyType(fieldName);
            if (propertyType == null) {
               if (!ignoreUnkown) {
                    throw new IllegalArgumentException("Unkown field " + fieldName + " on type " + bw.getWrappedClass());
                }
            } else if (Map.class.isAssignableFrom(propertyType)) {
                    handleMap(bw, fieldName, valueToBeUpdated, ModificationType.MODIFY, createdObjects);
            } else if (Collection.class.isAssignableFrom(propertyType)) {
                    handleCollection(bw, fieldName, valueToBeUpdated, ModificationType.MODIFY, createdObjects);
            } else {
                    handleObject(bw, fieldName, valueToBeUpdated, propertyType, createdObjects);
            }
        }
    }
}

Ответ 4

Другой вариант - использовать java.util.Optional.

import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.Optional;

@JsonInclude(JsonInclude.Include.NON_NULL)
private class PersonDTO {
    private Optional<String> firstName;
    private Optional<String> lastName;
    /* getters and setters ... */
}

Если firstName не установлено, значение равно нулю и будет игнорироваться аннотацией @JsonInclude. В противном случае, если неявно установлено в объекте запроса, firstName не будет нулевым, а firstName.get() будет. Я нашел это, просматривая решение @laffuste, связанное с немного более низким в другом комментарии (начальный комментарий garretwilson, говоря, что это не работало, оказывается, работает).

Вы также можете сопоставить DTO с Entity с помощью Jackson ObjectMapper, и он будет игнорировать свойства, которые не были переданы в объекте запроса:

import com.fasterxml.jackson.databind.ObjectMapper;

class PersonController {
    // ...
    @Autowired
    ObjectMapper objectMapper

    @Transactional
    @RequestMapping(path = "/{personId}", method = RequestMethod.PUT)
    public ResponseEntity<?> update(
            @PathVariable String personId,
            @RequestBody PersonDTO dto
    ) {
        Person p = people.findOne(personId);
        objectMapper.updateValue(p, dto);
        personRepository.save(p);
        // return ...
    }
}

Проверка DTO с использованием java.util.Optional также немного отличается. Это задокументировано здесь, но мне потребовалось некоторое время, чтобы найти:

// ...
import javax.validation.constraints.NotNull;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
// ...
private class PersonDTO {
    private Optional<@NotNull String> firstName;
    private Optional<@NotBlank @Pattern(regexp = "...") String> lastName;
    /* getters and setters ... */
}

В этом случае firstName может не быть установлен вообще, но если установлен, может не быть установлен в нуль, если PersonDTO проверен.

//...
import javax.validation.Valid;
//...
public ResponseEntity<?> update(
        @PathVariable String personId,
        @RequestBody @Valid PersonDTO dto
) {
    // ...
}

Также, возможно, стоит упомянуть, что использование Optional представляется весьма спорным, и на момент написания статьи сопровождающий (-и) Lombok его не поддерживал (см. Этот вопрос, например). Это означает, что использование lombok.Data/lombok.Setter в классе с Необязательными полями с ограничениями не работает (оно пытается создать сеттеры с неизменными ограничениями), поэтому использование @Setter/@Data вызывает исключение, так как оба setter и переменная-член имеют установленные ограничения. Также кажется, что лучше написать Setter без необязательного параметра, например:

//...
import lombok.Getter;
//...
@Getter
private class PersonDTO {
    private Optional<@NotNull String> firstName;
    private Optional<@NotBlank @Pattern(regexp = "...") String> lastName;

    public void setFirstName(String firstName) {
        this.firstName = Optional.ofNullable(firstName);
    }
    // etc...
}

Ответ 5

Возможно, слишком поздно для ответа, но вы могли:

  • По умолчанию не удаляйте значения "null". Предоставьте явный список с помощью параметров запроса какие поля вы хотите отменить. Таким образом, вы все равно можете отправить JSON, который соответствует вашей сущности, и иметь гибкость для отмены полей, когда вам нужно.

  • В зависимости от вашего варианта использования некоторые конечные точки могут явно обрабатывать все нулевые значения как незавершенные операции. Немного опасно для исправления, но в некоторых случаях это может быть вариант.