Spring MVC PATCH方法:部分更新

66

我有一个项目,使用Spring MVC + Jackson构建REST服务。假设我有以下Java实体:

public class MyEntity {
    private Integer id;
    private boolean aBoolean;
    private String aVeryBigString;
    //getter & setters
}

有时候,我只想更新布尔值,而不希望为了更新一个简单的布尔值就发送整个对象及其大字符串。因此,我考虑使用PATCH HTTP方法仅发送需要更新的字段。所以,在我的控制器中声明了以下方法:

@RequestMapping(method = RequestMethod.PATCH)
public void patch(@RequestBody MyVariable myVariable) {
    //calling a service to update the entity
}

问题是:我如何知道哪些字段需要更新?例如,如果客户端只想更新布尔值,我将得到一个带有空“aVeryBigString”的对象。我该怎么知道用户只想更新布尔值,但不想清空字符串?
我通过构建自定义URL来“解决”这个问题。例如,以下URL:POST /myentities/1/aboolean/true 将映射到一个允许仅更新布尔值的方法。这种解决方案的问题在于它不符合REST规范。我不想完全符合REST规范,但我不想提供自定义URL以更新每个字段(特别是当我想要更新多个字段时会引起问题)。
另一种解决方案是将“MyEntity”拆分为多个资源并仅更新这些资源,但我觉得这没有意义:“MyEntity”是一个简单的资源,它不是由其他资源组成的。
那么,有没有一种优雅的方法来解决这个问题?

我写了一篇文章,描述了在Spring中使用PATCH的方法。并且在GitHub上提供了一个可工作的示例。 - cassiomolin
17个回答

25
这可能有些晚了,但为了新手和遇到相同问题的人,请允许我分享一下我的解决方案。
在我的过去项目中,为了简单起见,我只使用本地的java Map。它将捕获所有新值,包括客户端明确设置为null的null值。此时,很容易确定哪些java属性需要设置为null,与使用相同的POJO作为域模型不同,您无法区分客户端将哪些字段设置为null以及默认情况下未包括在更新中但将被设置为null的字段。
此外,您必须要求http请求发送要更新的记录的ID,并且不要将其包含在patch数据结构中。我所做的是,在URL中设置ID作为路径变量,将patch数据作为PATCH正文。然后,使用ID,您将首先通过域模型获取记录,然后使用HashMap,您可以仅使用映射器服务或实用程序将更改打补丁到相关的域模型中。 更新 您可以为您的服务创建一个抽象超类,其中包含这种通用代码,必须使用Java Generics。这只是可能实现的一部分,希望您能理解。此外最好使用映射框架,如Orika或Dozer。
public abstract class AbstractService<Entity extends BaseEntity, DTO extends BaseDto> {
    @Autowired
    private MapperService mapper;

    @Autowired
    private BaseRepo<Entity> repo;

    private Class<DTO> dtoClass;

    private Class<Entity> entityCLass;

    public AbstractService(){
       entityCLass = (Class<Entity>) SomeReflectionTool.getGenericParameter()[0];
       dtoClass = (Class<DTO>) SomeReflectionTool.getGenericParameter()[1];
    }

    public DTO patch(Long id, Map<String, Object> patchValues) {
        Entity entity = repo.get(id);
        DTO dto = mapper.map(entity, dtoClass);
        mapper.map(patchValues, dto);
        Entity updatedEntity = toEntity(dto);
        save(updatedEntity);
        return dto;
    }
}

我喜欢这个答案。你有通用的映射器示例代码吗?这样同样的代码就可以适用于域中的每个实体,而不是为每个实体类重复编写代码。我认为它需要使用反射将HashMap中的每个属性“同步”到域模型中。我也想知道这是否会对性能产生影响? - SGB
1
我就是不明白。在 Map 中,如何区分 null 值和不存在的值?如果 Map 实现允许 null 值,则 map.get(unexistentKey)map.get(nullValueKey) 的结果将相同。如果它不允许 null 值,则 Jackson 无法将 json-null 映射到此映射中。因此,Map 不再可用于区分 null 和未传递的值,而只能使用 Pojo。 - Ruslan Stelmachenko
@djxak你需要制定一个公约,如果客户端向您发送了空字符串,则清除该字段 - 您将能够使用映射检测到这一点。或者,您可以使用Map.keySet检查哪些条目存在(即使那些具有null值的条目 - 这意味着客户端请求清除相应的属性)。 - Tw1sty
1
@ruslan-stelmachenko,map.containsKey(unexistentKey)map.containsKey(nullValueKey)的结果将会不同。 - Nikita Bosik
有人知道如何执行基于javax注释的验证吗?如果是DTO,那么可能是可以的,但这又会带来部分更新的另一个问题。 - Ravi MCA

11

经过一番调查,我发现了一个可行的解决方案,使用了与Spring MVC中的DomainObjectReader相同的方法,参见:JsonPatchHandler

import org.springframework.data.rest.webmvc.mapping.Associations

@RepositoryRestController
public class BookCustomRepository {
    private final DomainObjectReader domainObjectReader;
    private final ObjectMapper mapper;

    private final BookRepository repository;


    @Autowired
    public BookCustomRepository(BookRepository bookRepository, 
                                ObjectMapper mapper,
                                PersistentEntities persistentEntities,
                                Associations associationLinks) {
        this.repository = bookRepository;
        this.mapper = mapper;
        this.domainObjectReader = new DomainObjectReader(persistentEntities, associationLinks);
    }


    @PatchMapping(value = "/book/{id}", consumes = {MediaType.APPLICATION_JSON_UTF8_VALUE, MediaType.APPLICATION_JSON_VALUE})
    public ResponseEntity<?> patch(@PathVariable String id, ServletServerHttpRequest request) throws IOException {

        Book entityToPatch = repository.findById(id).orElseThrow(ResourceNotFoundException::new);
        Book patched = domainObjectReader.read(request.getBody(), entityToPatch, mapper);
        repository.save(patched);

        return ResponseEntity.noContent().build();
    }

}

什么是 **associationLinks/Association**?我应该从哪里导入它? - user
import org.springframework.data.rest.webmvc.mapping.Associations; @用户 - snovelli

11

正确的做法是按照JSON PATCH RFC 6902提出的方式进行。

请求示例如下:

PATCH http://example.com/api/entity/1 HTTP/1.1
Content-Type: application/json-patch+json 

[
  { "op": "replace", "path": "aBoolean", "value": true }
]

4
这是错误的补丁。有JSON Patch和HTTP Patch(类似于get、post、put等动词)。https://tools.ietf.org/html/rfc5789 - Eric Brandel
2
@EricBrandel 你为什么说它是错的?上面的例子同时使用了RFC 5789中定义的PATCH HTTP方法以及RFC 6902中定义的json patch数据格式(application/json-patch+json)。此外,HTTP PATCH方法规范提到:“[...]所包含的实体包含一组指令,描述了应如何修改源服务器上当前驻留的资源以生成新版本。”这意味着需要使用明确定义操作的数据格式,而application/json-patch+json正是如此。 - botchniaque
13
提供一篇文章,作者称那些不同意他观点的人为“白痴”,对我来说没有太大帮助。将 {"email": "foo@bar.com"} 添加到更新内容中并没有错。这是在服务器上更新信息最简洁的形式,我认为它完全符合 RF5789。它是更新内容的完全封装表示。在大多数情况下,JSON PATCH 过于复杂,并且不能解决原始帖子的问题或暗示。 - Eric Brandel
1
OP 正试图使用 JSON Merge Patch 技术,这是在 RFC 7396 中指定的使用 HTTP PATCH 的完全有效方式,并在愚蠢的博客文章中被承认为一种 mea culpa,人们不断地链接。 - harperska
JSO合并补丁技术很好,只要您直接处理JSON而不是从JSON创建的POJO。原因是在JSON中,您可以区分设置为null值的字段和未被触及的字段。在POJO方面,这两种情况都将由具有模糊值null的属性表示。如果您不允许null值,则没问题。否则,RFC6902对我来说更好。 - manash
显示剩余3条评论

5
您可以使用Optional<>完成此操作:
public class MyEntityUpdate {
    private Optional<String> aVeryBigString;
}

这样,您可以按以下方式检查更新对象:
if(update.getAVeryBigString() != null)
    entity.setAVeryBigString(update.getAVeryBigString().get());

如果JSON文档中没有字段aVeryBigString,那么POJO的字段aVeryBigString将会是null。如果它在JSON文档中存在,但其值为null,那么POJO字段将是一个包装了null值的Optional。这个解决方案允许你区分“无更新”和“设置为null”的情况。

3
尽管我所知道的 Java 的 Optional 不是用来作为字段的,但对我来说这仍然似乎是最直接的解决方案,并且是一个非常有用的完美案例,即使不是有意为之。 - Sebastiaan van den Broek
@SebastiaanvandenBroek,我也喜欢这种方法。我为另一个问题创建了完整的答案,在其中我使用了OptionalMapStruct - Michał Ziober
我独立得出了同样的结论,综合考虑,这是最优雅的解决方案。 - Anne van Leyden

5
PATCH 的重点在于你不需要发送整个实体表示,因此我不理解你对空字符串的评论。你必须处理一些简单的 JSON,例如:
{ aBoolean: true }

将此应用于指定资源。其思想是已接收到所需资源状态和当前资源状态的差异


9
我了解PATCH的重点,JSON部分不是问题。问题在于JSON反序列化。在服务器端,我接收到的是一个Java对象而不是JSON字符串(因为Spring MVC的魔法原因,我希望保留这个魔法)。如果我只接收到了一个JSON字符串,那么我肯定可以立即知道客户端发送了什么。但是现在我接收到的是一个完整的"MyEntity"对象,其中"aVeryBigString"属性为空(null)。问题是:我如何知道客户端是清空了"aVeryBigString"属性还是根本没有发送该属性? - mael
1
请看一下我对@Chexpis答案的评论。在使用纯JSON和PATCH方法时,违反了HTTP PATCH规范。 - botchniaque

4

Spring不能使用PATCH来修补您的对象,因为您已经遇到了相同的问题:JSON反序列化程序创建了一个具有空字段的Java POJO。

这意味着您必须提供自己的逻辑来修补实体(即仅在使用PATCH而不是POST时)。

无论是您知道您只使用非原始类型,还是一些规则(空字符串是null,这并不适用于所有人),或者您必须提供另一个参数来定义重写值。 对于我来说最后一个方法效果很好:JavaScript应用程序知道已更改哪些字段,并将列表与JSON主体一起发送到服务器。例如,如果命名为description的字段被更改(修补),但未在JSON主体中给出,则它将被置为空。


3

我注意到许多提供的答案都是关于JSON补丁或不完整的答案。以下是一个完整的解释和示例,展示了您需要的功能和真实可用的代码。

一个完整的补丁函数:

@ApiOperation(value = "Patch an existing claim with partial update")
@RequestMapping(value = CLAIMS_V1 + "/{claimId}", method = RequestMethod.PATCH)
ResponseEntity<Claim> patchClaim(@PathVariable Long claimId, @RequestBody Map<String, Object> fields) {

    // Sanitize and validate the data
    if (claimId <= 0 || fields == null || fields.isEmpty() || !fields.get("claimId").equals(claimId)){
        return new ResponseEntity<>(HttpStatus.BAD_REQUEST); // 400 Invalid claim object received or invalid id or id does not match object
    }

    Claim claim = claimService.get(claimId);

    // Does the object exist?
    if( claim == null){
        return new ResponseEntity<>(HttpStatus.NOT_FOUND); // 404 Claim object does not exist
    }

    // Remove id from request, we don't ever want to change the id.
    // This is not necessary,
    // loop used below since we checked the id above
    fields.remove("claimId");

    fields.forEach((k, v) -> {
        // use reflection to get field k on object and set it to value v
        // Change Claim.class to whatver your object is: Object.class
        Field field = ReflectionUtils.findField(Claim.class, k); // find field in the object class
        field.setAccessible(true); 
        ReflectionUtils.setField(field, claim, v); // set given field for defined object to value V
    });

    claimService.saveOrUpdate(claim);
    return new ResponseEntity<>(claim, HttpStatus.OK);
}

对于一些新开发者来说,上述内容可能会令人困惑,因为他们通常不会像那样处理反射。基本上,无论您在请求体中传递什么,它都将使用给定的ID查找关联索赔,然后仅更新您作为键值对传递的字段。

请求体示例:

PATCH /claims/7

{
   "claimId":7,
   "claimTypeId": 1,
   "claimStatus": null
}

以上操作将会更新索赔号为7的索赔类型和索赔状态,而不会影响其他值。
因此返回的结果可能类似于:
{
   "claimId": 7,
   "claimSrcAcctId": 12345678,
   "claimTypeId": 1,
   "claimDescription": "The vehicle is damaged beyond repair",
   "claimDateSubmitted": "2019-01-11 17:43:43",
   "claimStatus": null,
   "claimDateUpdated": "2019-04-09 13:43:07",
   "claimAcctAddress": "123 Sesame St, Charlotte, NC 28282",
   "claimContactName": "Steve Smith",
   "claimContactPhone": "777-555-1111",
   "claimContactEmail": "steve.smith@domain.com",
   "claimWitness": true,
   "claimWitnessFirstName": "Stan",
   "claimWitnessLastName": "Smith",
   "claimWitnessPhone": "777-777-7777",
   "claimDate": "2019-01-11 17:43:43",
   "claimDateEnd": "2019-01-11 12:43:43",
   "claimInvestigation": null,
   "scoring": null
}

正如你所看到的,完整的对象将返回而不更改除你想要更改的数据以外的任何数据。我知道这里解释有些重复,我只是想清晰地概述一下。


2
你不应该直接设置字段而不调用它的setter方法,因为setter方法可能会进行一些验证/转换,直接设置字段值将绕过安全检查。 - user
这在Kotlin中可能有效,因为您可以在属性的getter中进行验证,但是在Java中这是不好的实践。 - George

3
@Mapper(componentModel = "spring")
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
public interface CustomerMapper {
    void updateCustomerFromDto(CustomerDto dto, @MappingTarget Customer entity);
}

public void updateCustomer(CustomerDto dto) {
    Customer myCustomer = repo.findById(dto.id);
    mapper.updateCustomerFromDto(dto, myCustomer);
    repo.save(myCustomer);
}

这种方法的缺点是在更新时无法向数据库传递空值。
参见使用Spring Data进行部分数据更新
  • 通过json-patch库解决方案
  • 通过spring-data-rest解决方案
参见使用Spring Data Rest功能自定义Spring MVC HTTP Patch请求

2
我是这样解决问题的,因为我无法更改服务。
public class Test {

void updatePerson(Person person,PersonPatch patch) {

    for (PersonPatch.PersonPatchField updatedField : patch.updatedFields) {
        switch (updatedField){

            case firstname:
                person.setFirstname(patch.getFirstname());
                continue;
            case lastname:
                person.setLastname(patch.getLastname());
                continue;
            case title:
                person.setTitle(patch.getTitle());
                continue;
        }

    }

}

public static class PersonPatch {

    private final List<PersonPatchField> updatedFields = new ArrayList<PersonPatchField>();

    public List<PersonPatchField> updatedFields() {
        return updatedFields;
    }

    public enum PersonPatchField {
        firstname,
        lastname,
        title
    }

    private String firstname;
    private String lastname;
    private String title;

    public String getFirstname() {
        return firstname;
    }

    public void setFirstname(final String firstname) {
        updatedFields.add(PersonPatchField.firstname);
        this.firstname = firstname;
    }

    public String getLastname() {
        return lastname;
    }

    public void setLastname(final String lastname) {
        updatedFields.add(PersonPatchField.lastname);
        this.lastname = lastname;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(final String title) {
        updatedFields.add(PersonPatchField.title);
        this.title = title;
    }
}

Jackson仅在存在值时调用。 因此您可以保存调用的setter。


2
这种方法不可扩展。如果您只想支持一个实体的补丁,那么这是可以的。但是,如果您的代码库中有100个实体类,则最终会有同样多的类来执行路径操作。因此必须有更好的方法。 - SGB

1

你能否只发送一个由已更新的字段组成的对象?

脚本调用:

var data = JSON.stringify({
                aBoolean: true
            });
$.ajax({
    type: 'patch',
    contentType: 'application/json-patch+json',
    url: '/myentities/' + entity.id,
    data: data
});

Spring MVC控制器:

@PatchMapping(value = "/{id}")
public ResponseEntity<?> patch(@RequestBody Map<String, Object> updates, @PathVariable("id") String id)
{
    // updates now only contains keys for fields that was updated
    return ResponseEntity.ok("resource updated");
}

在控制器的path成员中,遍历updates映射中的键/值对。在上面的示例中,"aBoolean"键将保存值true。下一步将是通过调用实体setter实际分配值。但是,那是另一种问题。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接