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

fivyi3re  于 2022-11-14  发布在  Spring
关注(0)|答案(8)|浏览(180)

我试图在SpringRest控制器中使用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 ... */
}

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
@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。*

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

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

1sbrub3j1#

另一个选择是使用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注解忽略。否则,如果在request对象中隐式设置了firstName,则firstName不会为null,但firstName.get()会为null。我在浏览解决方案@laffuste时发现了这一点,该解决方案在另一个注解中链接到了下面一点(garretwilson最初的注解说它不起作用,结果却起作用了)。
您还可以使用Jackson的ObjectMapper将DTOMap到实体,它将忽略请求对象中未传递的属性:

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也有一些不同。这里有文档,但是我花了一些时间才找到:

// ...
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可能根本不设置,但如果设置了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),因此使用@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...
}
yxyvkwin

yxyvkwin2#

有一个更好的选择,它不涉及更改您的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}表示
请注意,textcity已修补(city现在是null),而address则保持不变。
在SpringRest控制器中,您需要获取原始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);

   ...
}
n3ipq98p

n3ipq98p3#

按照Jackson的作者的建议,使用布尔标志。

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

vxqlmq5t4#

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

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

       /* getter and setter */
   }
  • 首先,为您的Dto编写一个超级类,如BusDto。
  • 其次,更改dto以扩展超类,并更改dto的set方法,将属性名称和值放入changedAttrs(因为无论属性是否为空,当属性有值时,spring都会调用set)。
  • 第三,遍历Map。
izkcnapc

izkcnapc5#

我曾经尝试过解决同样的问题。我发现使用JsonNode作为DTO相当容易。这样你只会得到提交的内容。
您需要自己编写一个MergeService来完成实际的工作,类似于BeanWrapper。我还没有找到一个现有的框架来完成所需的工作。(如果您只使用Json请求,您可能可以使用Jacksons的readForUpdate方法。)
我们实际上使用了另一种节点类型,因为我们需要从“标准表单提交”和其他服务调用中获得相同的功能。此外,修改应该应用于名为EntityService的事务中。
不幸的是,这个MergeService将变得相当复杂,因为您需要自己处理属性、列表、集合和Map:)
对我来说最有问题的部分是区分列表/集合的元素内的更改和列表/集合的修改或替换。
而且验证也不容易,因为您需要根据另一个模型(在我的例子中是JPA实体)验证一些属性。
EDIT -一些Map代码(伪代码):

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

ruarlubt6#

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

  • 默认情况下,不要取消设置'null'值。通过查询参数提供一个显式列表,列出您要取消设置的字段。这样,您仍然可以发送与您的实体相对应的JSON,并在需要时灵活地取消设置字段。
  • 根据您的使用案例,某些端点可能会明确地将所有Null值视为未设定的作业。这对于修补作业来说有点危险,但在某些情况下可能是一个选项。
cx6n0qe3

cx6n0qe37#

另一个解决方案是强制反序列化请求主体。通过这样做,您将能够收集用户提供的字段并选择性地验证它们。
因此,您的DTO可能如下所示:

public class CatDto {
    @NotBlank
    private String name;

    @Min(0)
    @Max(100)
    private int laziness;

    @Max(3)
    private int purringVolume;
}

您的控制器可以是这样的:

@RestController
@RequestMapping("/api/cats")
public class CatController {
    @Autowired
    SmartValidator validator; // we'll use this to validate our request

    @PatchMapping(path = "/{id}", consumes = "application/json")
    public ResponseEntity<String> updateCat(
            @PathVariable String id,
            @RequestBody Map<String, Object> body
            // ^^ no Valid annotation, no declarative DTO binding here!
    ) throws MethodArgumentNotValidException {
        CatDto catDto = new CatDto();
        WebDataBinder binder = new WebDataBinder(catDto);
        BindingResult bindingResult = binder.getBindingResult();
        List<String> patchFields = new ArrayList<>();

        binder.bind(new MutablePropertyValues(body));
        // ^^ imperatively bind to DTO
        body.forEach((k, v) -> {
            patchFields.add(k);
            // ^^ collect user provided fields if you need
            validator.validateValue(CatDto.class, k, v, bindingResult);
            // ^^ imperatively validate user input
        });
        if (bindingResult.hasErrors()) {
            throw new MethodArgumentNotValidException(null, bindingResult);
            // ^^ this can be handled by your regular exception handler
        }
        // Here you can do normal stuff with your catDto.
        // Map it to cat model, send to cat service, whatever.
        return ResponseEntity.ok("cat updated");
    }
}

不需要Optional's,不需要额外的依赖,你的正常验证就可以了,你的昂首阔步看起来不错。唯一的问题是,你没有得到嵌套对象的正确合并补丁,但在许多用例中,这甚至是不需要的。

ppcbkaq5

ppcbkaq58#

可能太晚了,但是下面的代码可以区分空值和未提供的值

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

相关问题