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

Posted

技术标签:

【中文标题】如何区分 Spring Rest Controller 中部分更新的 null 值和未提供的值【英文标题】:How to distinguish between null and not provided values for partial updates in Spring Rest Controller 【发布时间】: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。

【问题讨论】:

【参考方案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(因为弹簧会在 属性有值,无论是否为空)。 第三,遍历地图。

【讨论】:

以上是关于如何区分 Spring Rest Controller 中部分更新的 null 值和未提供的值的主要内容,如果未能解决你的问题,请参考以下文章

2022就业季|Spring认证教你,如何使用 Spring 构建 REST 服务

如何在 Spring-Data-Rest 中实现细粒度的访问控制?

如何使用 Spring Security 在 Spring App 中测试 REST

如何使用 spring3 保护服务 REST?

您如何保护 Spring Boot / Spring-Data Rest 以便用户只能访问他自己的实体

如何使用 Spring Security 和 Angularjs 保护 Spring Rest 服务?