59

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

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

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

4

17 回答 17

23

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

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

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

更新

您可以使用这种泛型代码为您的服务创建一个抽象超类,您必须使用 Java 泛型。这只是可能实现的一部分,希望您能理解。另外最好使用映射器框架,例如 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;
    }
}
于 2016-02-08T03:57:55.880 回答
12

正确的方法是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 }
]
于 2013-12-18T10:33:05.260 回答
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();
    }

}
于 2018-08-06T21:45:28.333 回答
5

的重点PATCH是您没有发送整个实体表示,所以我不明白您对空字符串的评论。您将不得不处理某种简单的 JSON,例如:

{ aBoolean: true }

并将其应用于指定的资源。这个想法是接收到的是所需资源状态和当前资源状态的差异。

于 2013-07-25T15:13:28.703 回答
4

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

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

要么你知道你只使用非原始类型,要么使用一些规则(空 String is null,这对每个人都不起作用),或者你必须提供一个额外的参数来定义被覆盖的值。最后一个对我来说很好:除了列出到服务器的 JSON 正文之外,JavaScript 应用程序还知道哪些字段已更改并发送。例如,如果一个字段description被命名为更改(补丁)但未在 JSON 正文中给出,则该字段为空。

于 2013-10-01T08:07:04.660 回答
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 找到相关的声明,然后只更新你作为键值对传递的字段。

示例正文:

补丁/声明/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
}

如您所见,完整的对象将返回而不会更改您想要更改的数据以外的任何数据。我知道这里的解释有点重复,我只是想清楚地概述它。

于 2019-04-26T14:15:45.230 回答
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。

于 2014-12-10T17:12:40.047 回答
2

您可以Optional<>为此使用:

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

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

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

如果字段aVeryBigString不在 JSON 文档中,则 POJOaVeryBigString字段将为null. 如果它在 JSON 文档中,但带有null值,则 POJO 字段将是Optional带有包装的值null。此解决方案允许您区分“无更新”和“设置为空”的情况。

于 2018-09-02T07:48:23.743 回答
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。下一步将是通过调用实体设置器来实际分配值。然而,这是一个不同类型的问题。

于 2017-09-05T07:32:30.460 回答
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 在这里用于 DB 操作。

如果你想看看我如何在客户端(javascript)处理这个。 PATCH使用数据调用任何端点

{
  voted: true,
  reported: true
}

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

if (response.data.hasOwnProperty("voted")){
  //do Something
} else{
  //do something e.g report it
}
于 2020-07-05T10:11:13.393 回答
1
@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 请求

于 2020-10-17T08:45:11.883 回答
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();
        }
    }
}

该实现是递归的,以照顾嵌套结构。数组没有合并,因为它们没有合并的键。

“补丁” JSON 直接从 String 转换为 JsonElement 而不是对象,以将未填充的字段与填充为 NULL 的字段分开。

于 2016-12-14T19:54:13.313 回答
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();
            // ...
        }
    }
}
于 2020-12-24T00:13:39.440 回答
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);
于 2021-02-05T07:49:51.857 回答
0

这里还有很多其他很棒的方法,但我想我会添加我的,因为我没有看到它提到它,我认为它具有额外的优势,它可以处理可为空的字段,而无需添加内联的更新字段列表请求。这种方法具有以下特性:

  1. 仅更新请求中发送的字段
  2. 缺少的字段被忽略
  3. null在 JSON 中显式发送的字段将更新到null数据存储中

因此,给定以下域对象:

public class User {
  Integer id;      
  String firstName;
  String lastName;
}

增量更新用户的控制器方法如下所示,可以很容易地将其提取为适用于任何使用泛型的域对象的静态方法:

public class UserController {
  @Autowired ObjectMapper om;

  @Autowired
  @Qualifier("mvcValidator")
  private Validator validator;

  // assume this is a JPARepository
  @Autowired
  private UserRepository userRepo;

  @PostMapping(value = "/{userId}", consumes = MediaType.APPLICATION_JSON_VALUE)
  public ResponseEntity<Void> incrementalUpdate(@PathVariable("userId") Integer userId, 
    @RequestBody requestJson) {
    
    final User existingUser = this.userRepo.findById(userId).orElse(null);
    if(existingUser == null) { 
      return ResponseEntity.notFound().build();
    }

    // OPTIONAL - validate the request, since we can't use @Validated
    try {
      final User incomingUpdate = om.readValue(updateJson, User.class);
      final BeanPropertyBindingResult validationResult 
        = new BeanPropertyBindingResult(incomingUpdate, "user");
      this.validator.validate(incomingUpdate, validationResult);
      if (validationResult.hasErrors()) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
      }
    } catch (JsonProcessingException e) {
      return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }

    // merge the incoming update into the existing user
    try {
      this.om.readerForUpdating(existingUser).readValue(updateJson, User.class);
    } catch(IOException e) {
      return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }

    this.userRepo.save(existingUser);
    return ResponseEntity.noContent().build();
  }
}

请注意,如果您的域对象有任何嵌套对象或集合,则需要用 注释@JsonMerge它们,否则它们将被传入值无条件地覆盖,而不是递归合并。

于 2021-09-13T19:31:21.200 回答
-2

我的回答可能会迟到,但如果有人仍然面临同样的问题。我已经使用 PATCH 解决了所有可能的解决方案,但无法设法部分更新对象的字段。所以我切换到 POST 并使用 post,我可以在不更改未更改字段的值的情况下更新特定字段。

于 2019-01-17T09:37:50.933 回答
-20

您可以将 boolean 更改为 Boolean 并为您不想更新的所有字段分配 null 值。唯一一个非空值将定义您要更新哪个字段客户端。

于 2013-07-25T14:33:14.713 回答