如何区分Spring Rest Controller中部分更新的null值和未提供值

时间:2016-07-17 18:10:50

标签: java json rest spring-mvc jackson

在Spring Rest Controller中使用PUT请求方法部分更新实体时,我试图区分空值和未提供的值。

以下面的实体为例:

Private Sub Button1_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles Button1.Click

End Sub

我的人员存储库(Spring Data):

@Entity
private class Person {

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

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

    /* getters and setters ... */
}

我使用的DTO:

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

My Spring RestController:

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

    /* getters and setters ... */
}

缺少财产的请求

@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"不变)。

请求使用null属性

lastName

预期行为:更新{"firstName": "John", "lastName": null} 并设置firstName="John"

我无法区分这两种情况,因为DTO中的lastName=null始终被杰克逊设为lastName

注意: 我知道REST最佳实践(RFC 6902)建议使用PATCH而不是PUT进行部分更新,但在我的特定场景中,我需要使用PUT。

7 个答案:

答案 0 :(得分:6)

将布尔标志用作jackson's author recommends

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;
    }
}

答案 1 :(得分:5)

还有一个更好的选择,它不涉及更改DTO或自定义设置器。

它涉及让Jackson将数据与现有数据对象合并,如下所示:

MyData existingData = ...
ObjectReader readerForUpdating = objectMapper.readerForUpdating(existingData);

MyData mergedData = readerForUpdating.readValue(newData);    

newData中不存在的任何字段都不会覆盖existingData中的数据,但是如果存在一个字段,即使其中包含null,该字段也会被覆盖。

演示代码:

    ObjectMapper objectMapper = new ObjectMapper();
    MyDTO dto = new MyDTO();

    dto.setText("text");
    dto.setAddress("address");
    dto.setCity("city");

    String json = "{\"text\": \"patched text\", \"city\": null}";

    ObjectReader readerForUpdating = objectMapper.readerForUpdating(dto);

    MyDTO merged = readerForUpdating.readValue(json);

结果为{"text": "patched text", "address": "address", "city": null}

在Spring Rest Controller中,您将需要获取原始JSON数据,而不是让Spring反序列化此数据。因此,如下更改端点:

@Autowired ObjectMapper objectMapper;

@RequestMapping(path = "/{personId}", method = RequestMethod.PATCH)
public ResponseEntity<?> update(
        @PathVariable String personId,
        @RequestBody JsonNode jsonNode) {

   RequestDto existingData = getExistingDataFromSomewhere();

   ObjectReader readerForUpdating = objectMapper.readerForUpdating(existingData);
   
   RequestDTO mergedData = readerForUpdating.readValue(jsonNode);

   ...
)

答案 2 :(得分:3)

实际上,如果忽略验证,您可以像这样解决问题。

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

       /* getter and setter */
   }
  • 首先,为你的dto写一个超类,比如BusDto。
  • 其次,改变你的dto以扩展超类,并改变 dto的set方法,将属性名称和值放入 changedAttrs(因为弹簧会在调用时调用set 属性有值,无论是否为null)。
  • 第三,遍历地图。

答案 3 :(得分:3)

另一种选择是使用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,则该值为null,并且将被@JsonInclude注释忽略。否则,如果在请求对象中隐式设置,则firstName将不为null,而firstName.get()将为null。我发现这是浏览链接到little lower down in a different comment的解决方案@laffuste(garretwilson的最初评论说它无效)。

您还可以使用Jackson的ObjectMapper将DTO映射到实体,它将忽略未在请求对象中传递的属性:

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 ...
    }
}

使用java.util.Optional验证DTO也有所不同。 It's documented here,但花了我一段时间才能找到:

// ...
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的情况下可能不会将其设置为null。

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

也许还值得一提的是,对Optional的使用似乎存在着激烈的争论,在撰写本文之时,Lombok的维护者将不支持它(请参阅this question for example)。这意味着在具有带有约束的Optional字段的类上使用lombok.Data/lombok.Setter无效(尝试创建具有完整约束的setter),因此使用@ Setter / @ Data会引发异常,因为setter和member变量已设置约束。写不带Optional参数的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...
}

答案 4 :(得分:1)

我试图解决同样的问题。我发现使用JsonNode作为DTO非常容易。这样您只能获得提交的内容。

您需要自己编写MergeService来完成实际工作,类似于BeanWrapper。我还没有找到一个可以完全满足需要的现有框架。 (如果仅使用Json请求,则可以使用Jacksons readForUpdate方法。)

我们实际上使用另一种节点类型,因为我们需要“标准表单提交”和其他服务调用中的相同功能。此外,修改应该应用于名为EntityService的事务中的事务中。

遗憾的是,MergeService变得非常复杂,因为您需要自己处理属性,列表,集合和映射:)

对我来说,最有问题的部分是区分列表/集合元素内的更改以及列表/集合的修改或替换。

而且验证也不容易,因为你需要针对另一个模型验证一些属性(在我的情况下是JPA实体)

编辑 - 一些映射代码(伪代码):

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);
            }
        }
    }
}

答案 5 :(得分:0)

回答可能为时已晚,但你可以:

  • 默认情况下,不要设置&#39; null&#39;值。通过查询参数提供一个明确的列表,您要取消设置哪些字段。通过这种方式,您仍然可以发送与您的实体相对应的JSON,并可以根据需要灵活地取消设置字段。

  • 根据您的使用情况,某些端点可能会明确将所有空值视为未设置操作。修补有点危险,但在某些情况下可能是一种选择。

答案 6 :(得分:-1)

可能到最近,但以下代码对我有用,以区分null和未提供的值

if(dto.getIban() == null){
  log.info("Iban value is not provided");
}else if(dto.getIban().orElse(null) == null){
  log.info("Iban is provided and has null value");
}else{
  log.info("Iban value is : " + dto.getIban().get());
}