web-dev-qa-db-ja.com

Spring MVC PATCHメソッド:部分更新

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準拠。100%になりたくありませんREST準拠ですが、各フィールドを更新するカスタムURLを提供することに不安を感じています(特に、いくつかのフィールドを更新するときに問題が発生すること)。

別の解決策は、「MyEntity」を複数のリソースに分割し、これらのリソースを更新するだけですが、意味がないように感じます。「MyEntity」isはプレーンリソースではなく、で構成されます他のリソース。

だから、この問題を解決するエレガントな方法はありますか?

46
mael

これは非常に遅くなる可能性がありますが、初心者と同じ問題に遭遇した人々のために、私自身の解決策を共有させてください。

過去のプロジェクトでは、簡単にするために、ネイティブJava Mapを使用します。クライアントが明示的にnullに設定したnull値を含むすべての新しい値をキャプチャします。この時点で、ドメインモデルと同じPOJOを使用する場合とは異なり、どのJavaプロパティをnullに設定する必要があるかを判断するのは簡単です。どのフィールドが設定されているかを区別することはできませんクライアントはnullになり、更新には含まれませんが、デフォルトではnullになります。

さらに、更新するレコードのIDを送信するためにhttpリクエストを要求する必要があり、パッチデータ構造には含めません。 URLのIDをパス変数として設定し、パッチデータを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;
    }
}
16
vine

これを行う正しい方法は、 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 }
]
10
Chexpir

少し掘り下げた後、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();
    }

}
7
snovelli

PATCHの全体的なポイントは、エンティティ表現全体を送信するnotであるため、空に関するコメントが理解できない文字列。次のような単純なJSONのようなものを処理する必要があります。

{ aBoolean: true }

指定したリソースに適用します。これは、受信したものが、目的のリソース状態と現在のリソース状態のdiffであるという考え方です。

4
Tom G

Springは、すでにある同じ問題のために、PATCHを使用してオブジェクトにパッチを適用できません。JSONデシリアライザーは、nullフィールドを持つJava POJOを作成します。

つまり、エンティティにパッチを適用するための独自のロジックを提供する必要があります(つまり、PATCHを使用し、POSTは使用しない場合のみ)。

非プリミティブ型のみを使用することを知っているか、いくつかのルール(空の文字列はnullであり、これはすべてのユーザーに有効ではありません)またはオーバーライドされた値を定義する追加パラメーターを提供する必要があります。最後の1つはうまく機能します。JavaScriptアプリケーションは、サーバーにリストされているJSON本文に加えて、どのフィールドが変更および送信されたかを認識しています。たとえば、フィールドdescriptionが変更(パッチ)するために命名されたが、JSONボディで指定されていない場合、それはヌルにされていました。

3
knalli

更新されたフィールドで構成されるオブジェクトを送信できませんでしたか?

スクリプト呼び出し:

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

コントローラーのpathmemberで、updatesマップ内のキー/値のペアを反復処理します。上記の例では、"aBoolean"keyはtrueの値を保持します。次のステップは、エンティティセッターを呼び出して実際に値を割り当てることです。ただし、それは別の種類の問題です。

1
Axel Goethe

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を持つnullになります。このソリューションにより、「更新なし」の場合と「nullに設定」の場合を区別できます。

1

サービスを変更できないため、このような問題を修正しました

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

ジャクソンは、値が存在する場合にのみコールしました。そのため、どのセッターが呼び出されたかを保存できます。

0
kaytastrophe

提供された回答の多くがすべて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を使用して関連するクレームを見つけ、キーと値のペアとして渡すフィールドのみを更新します。

本体の例:

パッチ/ 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": "[email protected]",
   "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
}

ご覧のとおり、変更したいもの以外のデータを変更することなく、オブジェクト全体が返されます。ここでの説明には少し繰り返しがあることを知っていますが、明確に説明したかっただけです。

0
Nox

Googles 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は、オブジェクトでなくストリングからJsonElementに直接変換され、NULLで満たされたフィールドと未記入のフィールドを区別します。

0
Thomas Neeb