Spring MVC PATCH方法:部分更新

时间:2013-07-25 14:14:03

标签: java json spring rest spring-mvc

我有一个项目,我使用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:POST / myentities / 1 / aboolean / true将映射到只允许更新布尔值的方法。此解决方案的问题在于它不符合REST。我不希望100%兼容REST,但我不满意提供自定义URL来更新每个字段(特别是考虑到当我想更新多个字段时会导致问题)。

另一个解决方案是将“MyEntity”拆分为多个资源并只更新这些资源,但我觉得它没有意义:“MyEntity” 是一个普通资源,它不是< strong>由其他资源组成。

那么,有没有一种解决这个问题的优雅方法?

16 个答案:

答案 0 :(得分:16)

这可能会很晚,但为了新手和遇到同样问题的人,让我分享一下我自己的解决方案。

在我过去的项目中,为了简单起见,我只使用原生的Java Map。它将捕获所有新值,包括客户端显式设置为null的空值。此时,很容易确定哪些java属性需要设置为null,与使用相同的POJO作为域模型不同,您无法区分客户端设置的字段null并且它们不包含在更新中,但默认情况下为null。

此外,您必须要求http请求发送您要更新的记录的ID,并且不要将其包含在补丁数据结构中。我所做的是,将URL中的ID设置为路径变量,将补丁数据设置为PATCH主体。然后使用ID,您将首先通过域模型获取记录,然后使用HashMap,您可以使用映射器服务或实用程序,用于修补相关域模型的更改。

<强>更新

您可以使用这种通用代码为您的服务创建抽象超类,您必须使用Java Generics。这只是可能实现的一部分,我希望你能得到这个想法。使用Orika或Dozer这样的mapper框架也更好。

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

答案 1 :(得分:10)

执行此操作的正确方法是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 }
]

答案 2 :(得分:7)

深入研究后,我发现了可以使用Spring MVC当前使用的相同方法的可接受解决方案DomainObjectReader另请参见:JsonPatchHandler

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

}

答案 3 :(得分:4)

PATCH的重点是你发送整个实体表示,所以我不理解你对空字符串的评论。您必须处理某种简单的JSON,例如:

{ aBoolean: true }

并将其应用于指定的资源。我们的想法是,所收到的是所需资源状态和当前资源状态的 diff

答案 4 :(得分:3)

由于您已经遇到的问题,Spring会/不能使用PATCH来修补您的对象:JSON反序列化器会创建一个带有空字段的Java POJO。

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

要么您知道只使用非基本类型,要么使用某些规则(空字符串为null,这对每个人都不起作用),或者您必须提供一个定义重写值的附加参数。 最后一个适用于我:JavaScript应用程序知道除了列出到服务器的JSON主体之外哪些字段已被更改和发送。例如,如果一个字段description被命名为更改(补丁),但未在JSON正文中给出,那么它就被取消了。

答案 5 :(得分: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来实际分配值。但是,这是一个不同的问题。

答案 6 :(得分:1)

您可以使用Optional<>

public class MyEntityUpdate {
    private Optional<String> aVeryBigString;
}

这样,您可以如下检查更新对象:

if(update.getAVeryBigString() != null)
    entity.setAVeryBigString(update.getAVeryBigString().get());

如果字段aVeryBigString不在JSON文档中,则POJO aVeryBigString字段将为null。如果它在JSON文档中,但是具有一个null值,则POJO字段将是一个带有包装值Optional的{​​{1}}。此解决方案使您可以区分“无更新”和“设置为空”的情况。

答案 7 :(得分:1)

我用反射来解决这个问题。客户端可以发送对象(例如javascript中的对象),该对象将包含所有具有其受尊重值的字段。我在控制器中捕获新值的方式:

@PatchMapping(value = "{id}")
public HttpEntity<Map<String, Object>> updatePartial(@PathVariable Integer id, @RequestBody Map<String, Object> data) {
    return ResponseEntity.ok(questionService.updatePartial(id, data));
}

然后,在服务实现中,我们可以使用反射来查找所请求的属性是否存在,然后再更新其值。

public Map<String, Object> updatePartial(@NotNull Long id, @NotNull Map<String, Object> data) {

    Post post = postRepository.findById(id);

    Field[] postFields = Post.class.getDeclaredFields();
    HashMap<String, Object> toReturn = new HashMap<>(1);
    for (Field postField : postFields) {
        data.forEach((key, value) -> {
            if (key.equalsIgnoreCase(postField.getName())) {
                try {
                    final Field declaredField = Post.class.getDeclaredField(key);
                    declaredField.setAccessible(true);
                    declaredField.set(post, value);
                    toReturn.put(key, value);
                } catch (NoSuchFieldException | IllegalAccessException e) {
                    log.error("Unable to do partial update field: " + key + " :: ", e);
                    throw new BadRequestException("Something went wrong at server while partial updation");
                }
            }
        });
    }
    postRepository.save(post);

    return toReturn;
}

Spring Data JPA在这里用于数据库操作。

如果您想了解我如何在客户端(javascript)上处理此问题。 PATCH呼叫任何端点,数据如下:

{
  voted: true,
  reported: true
}

然后在响应中,客户端可以验证响应是否包含预期的属性。例如,我期望所有字段(我在PATCH中作为参数传递)都为响应:

if (response.data.hasOwnProperty("voted")){
  //do Something
} else{
  //do something e.g report it
}

答案 8 :(得分:0)

我修正了这样的问题,因为我无法改变服务

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;
    }
}
只有存在价值观时,杰克逊才会打电话。 所以你可以保存调用哪个setter。

答案 9 :(得分:0)

以下是使用谷歌GSON的补丁命令的实现。

package de.tef.service.payment;

import com.google.gson.*;

class JsonHelper {
    static <T> T patch(T object, String patch, Class<T> clazz) {
        JsonElement o = new Gson().toJsonTree(object);
        JsonObject p = new JsonParser().parse(patch).getAsJsonObject();
        JsonElement result = patch(o, p);
        return new Gson().fromJson(result, clazz);
    }

    static JsonElement patch(JsonElement object, JsonElement patch) {
        if (patch.isJsonArray()) {
            JsonArray result = new JsonArray();
            object.getAsJsonArray().forEach(result::add);
            return result;
        } else if (patch.isJsonObject()) {
            System.out.println(object + " => " + patch);
            JsonObject o = object.getAsJsonObject();
            JsonObject p = patch.getAsJsonObject();
            JsonObject result = new JsonObject();
            o.getAsJsonObject().entrySet().stream().forEach(e -> result.add(e.getKey(), p.get(e.getKey()) == null ? e.getValue() : patch(e.getValue(), p.get(e.getKey()))));
            return result;
        } else if (patch.isJsonPrimitive()) {
            return patch;
        } else if (patch.isJsonNull()) {
            return patch;
        } else {
            throw new IllegalStateException();
        }
    }
}

实现是recursiv来处理嵌套结构。数组未合并,因为它们没有合并的键。

&#34;补丁&#34; JSON直接从String转换为JsonElement而不是对象,以使未填充的字段与填充NULL的字段保持分开。

答案 10 :(得分:0)

我注意到,提供的许多答案都是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
}

以上内容会将ClaimTypeId和claimStatus更新为声明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
}

如您所见,完整对象将返回,而不更改您想要更改的数据。我知道这里的解释有些重复,我只是想清楚地概述一下。

答案 11 :(得分:0)

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

这种方法的缺点是我们无法在更新期间将空值传递给数据库。
参见Partial Data Update with Spring Data

  • 通过json-patch库解决方案
  • 通过spring-data-rest解决方案

请参见Custom Spring MVC HTTP Patch requests with Spring Data Rest functionality

答案 12 :(得分:0)

这是一个旧帖子,但对我来说仍然是一个没有好的解决方案的问题。这是我的倾向。

这个想法是利用反序列化阶段来跟踪发送的内容和未发送的内容,并使实体支持查询属性更改状态的方法。这是想法。

此接口触发自定义反序列化并强制 bean 携带其状态更改信息

@JsonDeserialize(using = Deser.class)
interface Changes {

    default boolean changed(String name) {
        Set<String> changed = changes();
        return changed != null && changed.contains(name);
    }

    void changes(Set<String> changed);

    Set<String> changes();
}

这是解串器。一旦被调用,它就会通过 mixin 反转反序列化行为。请注意,它仅在 json 属性直接映射到 bean 属性时才有效。对于任何更有趣的事情,我认为可以代理 bean 实例并拦截 setter 调用。

class Deser extends JsonDeserializer<Object> implements ContextualDeserializer {
    private Class<?> targetClass;

    public Deser() {}

    public Deser(Class<?> targetClass) { this.targetClass = targetClass; }

    @Override
    public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        ObjectMapper mapper = (ObjectMapper) p.getCodec();
        TypeReference<HashMap<String, Object>> typeRef = new TypeReference<>() {
        };
        HashMap<String, Object> map = p.readValueAs(typeRef);
        ObjectMapper innerMapper = mapper.copy();
        innerMapper.addMixIn(targetClass, RevertDefaultDeserialize.class);
        Object o = innerMapper.convertValue(map, targetClass);
        // this will only work with simple json->bean property mapping
        ((Changes) o).changes(map.keySet());
        return o;
    }

    @Override
    public JsonDeserializer<?> createContextual(DeserializationContext ctxt, BeanProperty property) {
        Class<?> targetClass = ctxt.getContextualType().getRawClass();
        return new Deser(targetClass);
    }

    @JsonDeserialize
    interface RevertDefaultDeserialize {
    }
}

这是问题中的 bean 的样子。我将拆分 JPA 实体和控制器接口中使用的数据传输 bean,但这里是同一个 bean。

如果可以继承,基类可以支持更改,但这里直接使用接口本身。

@Data
class MyEntity implements Changes {
    private Integer id;
    private boolean aBoolean;
    private String aVeryBigString;

    @Getter(AccessLevel.NONE)
    @Setter(AccessLevel.NONE)
    private Set<String> changes;

    @Override
    public void changes(Set<String> changed) {
        this.changes = changed;
    }

    @Override
    public Set<String> changes() {
        return changes;
    }
}

这是它将如何使用

class HowToUseIt {
    public static void example(MyEntity bean) {
        if (bean.changed("id")) {
            Integer id = bean.getId();
            // ...
        }
        if (bean.changed("aBoolean")) {
            boolean aBoolean = bean.isABoolean();
            // ...
        }
        if (bean.changed("aVeryBigString")) {
            String aVeryBigString = bean.getAVeryBigString();
            // ...
        }
    }
}

答案 13 :(得分:0)

如果你将实现 JpaRepository 那么你可以使用它。

@Modifying
@Query("update Customer u set u.phone = :phone where u.id = :id")
void updatePhone(@Param(value = "id") long id, @Param(value = "phone") String phone);

答案 14 :(得分:-1)

我的回答可能太迟了,但如果有人仍然面临同样的问题,我的回答可能会很晚。 我对PATCH以及所有可能的解决方案都很感兴趣,但无法部分更新对象的字段。因此,我改用POST,并使用post,可以更新特定字段,而无需更改未更改字段的值。

答案 15 :(得分:-11)

您可以将布尔值更改为布尔值,并为您不想更新的所有字段指定空值。唯一一个非null值将定义您要更新的字段客户端。