【问题标题】:How to distinguish between null and not provided values for partial updates in Spring Rest Controller如何区分 Spring Rest Controller 中部分更新的 null 值和未提供的值
【发布时间】:2016-11-20 07:59:21
【问题描述】:

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

以以下实体为例:

@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 ... */
}

我的个人存储库(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

我无法区分这两种情况,因为 DTO 中的lastName 总是由 Jackson 设置为 null

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

【问题讨论】:

    标签: java json rest spring-mvc jackson


    【解决方案1】:

    有一个更好的选择,它不涉及更改 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);
    
       ...
    )
    

    【讨论】:

    • 为什么使用纯字符串作为json?我想你在这里打破了这个例子。你应该用一个已经解码的实体来回答。
    • @Sebastian 我真的不明白你在这里问什么——为了演示它是如何工作的,我使用了一个字符串,有什么问题?参见 Spring 控制器的最后一个示例,那里没有 json 字符串。
    【解决方案2】:

    使用布尔标志作为jackson's author recommends

    class PersonDTO {
        private String firstName;
        private boolean isFirstNameDirty;
    
        public void setFirstName(String firstName){
            this.firstName = firstName;
            this.isFirstNameDirty = true;
        }
    
        public String getFirstName() {
            return firstName;
        }
    
        public boolean hasFirstName() {
            return isFirstNameDirty;
        }
    }
    

    【讨论】:

    • 此解决方案有效,但我认为这是 Jackson 的失败,并导致大量代码膨胀...不使用它的充分理由。看起来 GSON 是一个不错的选择:github.com/google/gson/blob/master/…
    • @Andrew 那么 GSON 是如何解决这个问题的呢?
    【解决方案3】:

    可能为时已晚,但以下代码可以帮助我区分空值和未提供的值

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

    【讨论】:

    • 你在这里使用了可选参数
    【解决方案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,则值为 null,并且会被 @JsonInclude 注释忽略。否则,如果在请求对象中隐式设置,firstName 不会为空,但 firstName.get() 会。我发现这个浏览链接到little lower down in a different comment 的解决方案@laffuste(garretwilson 最初的评论说它不起作用)。

    你也可以使用 Jackson 的 ObjectMapper 将 DTO 映射到 Entity,它会忽略未在请求对象中传递的属性:

    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)。这意味着在具有约束的可选字段的类上使用 lombok.Data/lombok.Setter 不起作用(它尝试创建约束完整的设置器),因此使用 @Setter/@Data 会导致抛出异常,因为setter 和成员变量设置了约束。编写不带 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...
    }
    

    【讨论】:

    • 好主意!感谢分享。
    • 最新java版本的绝佳解决方案
    • 可以级联@Valid 吗? (因为它似乎不起作用)我的意思是如果我去Controller -> @Valid PersonDTO --inside--> @Valid private Optional&lt;List&lt;AnimalDTO&gt;&gt; animals; -> {@NotNull private String animalName;}
    【解决方案5】:

    也许回答太晚了,但你可以:

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

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

    【讨论】:

      【解决方案6】:

      我试图解决同样的问题。我发现使用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);
                  }
              }
          }
      }
      

      【讨论】:

      • 你的回答似乎很有趣,但我没有很好地理解它。你能用一个相关的例子来增强它吗?
      • 哪个部分,jsonnode requestmapping,mergeservice还是validation?
      • 目前尚不清楚如何在 Controller 类的更新方法中使用 JsonNode。我的 DTO 是否应该从 JsonNode 继承?
      • 添加了一些代码来向您展示这个想法。希望它有所帮助:)
      • 感谢 Martin,您的解决方案很聪明,但由于缺乏对 DTO 的自动 JSR-303 验证,它给我带来了麻烦。从这个角度来看,@demon 的提议,即使不那么详细,也很适合。但是,我会在其他场合考虑您的解决方案:)
      【解决方案7】:

      其实,如果忽略验证,你可以这样解决你的问题。

         public class BusDto {
             private Map<String, Object> changedAttrs = new HashMap<>();
      
             /* getter and setter */
         }
      
      • 首先,为您的 dto 编写一个超类,例如 BusDto。
      • 其次,更改您的 dto 以扩展超类,并更改 dto的set方法,把属性名和值放到 changedAttrs(因为弹簧会在 属性有值,无论是否为空)。
      • 第三,遍历地图。

      【讨论】:

        猜你喜欢
        • 2014-04-05
        • 2023-03-30
        • 1970-01-01
        • 1970-01-01
        • 2022-10-07
        • 2022-10-04
        • 1970-01-01
        • 2017-03-29
        • 1970-01-01
        相关资源
        最近更新 更多