如何区分 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<List<AnimalDTO>> 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