wake-up-neo.com

Spring MVC PATCH-Methode: Teilaktualisierungen

Ich habe ein Projekt, bei dem ich Spring MVC + Jackson verwende, um einen REST - Dienst zu erstellen. Nehmen wir an, ich habe die folgende Java-Entität

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

Manchmal möchte ich nur den booleschen Wert aktualisieren, und ich denke nicht, dass das Senden des gesamten Objekts mit seiner großen Zeichenfolge eine gute Idee ist, nur um einen einfachen booleschen Wert zu aktualisieren. Daher habe ich überlegt, die HTTP-Methode PATCH zu verwenden, um nur die Felder zu senden, die aktualisiert werden müssen. Also erkläre ich in meinem Controller die folgende Methode:

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

Das Problem ist: Woher weiß ich, welche Felder aktualisiert werden müssen? Wenn der Client beispielsweise nur das boolean aktualisieren möchte, erhalte ich ein Objekt mit einem leeren "aVeryBigString". Woher soll ich wissen, dass der Benutzer nur den Boolean aktualisieren möchte, aber den String nicht leeren möchte? 

Ich habe das Problem durch das Erstellen benutzerdefinierter URLs "gelöst". Zum Beispiel die folgende URL: POST/myentities/1/aboolean/true wird einer Methode zugeordnet, die nur die Aktualisierung des Boolean erlaubt. Das Problem bei dieser Lösung ist, dass sie nicht mit REST kompatibel ist. Ich möchte nicht zu 100% REST kompatibel sein, aber ich fühle mich nicht wohl dabei, eine benutzerdefinierte URL anzugeben, um jedes Feld zu aktualisieren (insbesondere, wenn es Probleme gibt, wenn ich mehrere Felder aktualisieren möchte).

Eine andere Lösung wäre, "MyEntity" in mehrere Ressourcen aufzuteilen und diese Ressourcen zu aktualisieren, aber ich finde, es macht keinen Sinn: "MyEntity" is eine einfache Ressource, es ist nicht bestehend aus andere Ressourcen.

Gibt es einen eleganten Weg, dieses Problem zu lösen?

41
mael

Das könnte sehr spät sein, aber für Neulinge und Menschen, die das gleiche Problem haben, möchte ich Ihnen meine eigene Lösung mitteilen.

Um es einfach zu machen, verwende ich in meinen früheren Projekten nur die native Java Map. Es werden alle neuen Werte erfasst, einschließlich der Nullwerte, die der Client explizit auf Null setzt. An diesem Punkt ist es leicht zu bestimmen, welche Java-Eigenschaften als null festgelegt werden müssen. Anders als bei Verwendung des gleichen POJO als Domänenmodell können Sie nicht unterscheiden, welche Felder vom Client auf null und festgelegt werden Diese sind nur nicht im Update enthalten, aber standardmäßig null.

Darüber hinaus müssen Sie die http-Anforderung zum Senden der ID des zu aktualisierenden Datensatzes anfordern und nicht in die Patch-Datenstruktur aufnehmen. Was ich getan habe, ist die ID in der URL als Pfadvariable und die Patch-Daten als PATCH-Body festzulegen. Dann würden Sie mit der ID zuerst den Datensatz über ein Domänenmodell erhalten, dann mit der HashMap einfach eine Zuordnungsdienst oder Dienstprogramm zum Patchen der Änderungen am betroffenen Domänenmodell.

Update

Sie können mit dieser Art von generischem Code eine abstrakte Superklasse für Ihre Services erstellen. Sie müssen Java Generics verwenden. Dies ist nur ein Teil der möglichen Implementierung. Ich hoffe, Sie bekommen die Idee. Außerdem ist es besser, Mapper-Frameworks wie Orika oder Dozer zu verwenden.

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

Der richtige Weg dazu ist der in JSON PATCH RFC 6902 vorgeschlagene Weg

Ein Anforderungsbeispiel wäre:

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

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

Der springende Punkt von PATCH ist, dass Sie nicht die gesamte Entitätsdarstellung senden, daher verstehe ich Ihre Kommentare zu der leeren Zeichenfolge nicht. Sie müssten mit einer Art einfachen JSON umgehen, wie zum Beispiel:

{ aBoolean: true }

und wenden Sie das auf die angegebene Ressource an. Die Idee ist, dass das, was empfangen wurde, ein diff des gewünschten Ressourcenzustands und des aktuellen Ressourcenzustands ist.

4
Tom G

Spring kann/kann nicht PATCH zum Patchen Ihres Objekts verwenden, da Sie bereits ein Problem haben: Der JSON-Deserializer erstellt ein Java-POJO mit nullen-Feldern.

Das bedeutet, dass Sie eine eigene Logik zum Patchen einer Entität bereitstellen müssen (d. H. Nur, wenn Sie PATCH verwenden, jedoch nicht POST).

Sie wissen entweder, dass Sie nur nicht-primitive Typen verwenden, oder einige Regeln (leere Zeichenfolge ist null, die nicht für alle Benutzer geeignet ist) oder Sie müssen einen zusätzlichen Parameter angeben, der die überschriebenen Werte definiert. Letzteres funktioniert gut für mich: Die JavaScript-Anwendung weiß, welche Felder geändert und zusätzlich zum JSON-Body dieser Liste an den Server gesendet wurden. Wenn zum Beispiel ein Feld description zum Ändern benannt wurde (Patch), das im JSON-Body jedoch nicht angegeben ist, wurde es mit Nullen versehen.

3
knalli

Nachdem ich ein wenig herumgegraben hatte, fand ich eine akzeptable Lösung, indem ich dieselbe Lösung verwendete, die derzeit von einer Spring-MVC verwendet wird. DomainObjectReader Siehe auch: 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
snovelli

Könnten Sie nicht einfach ein Objekt senden, das aus den Feldern besteht, die aktualisiert wurden?

Skriptaufruf:

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

Spring MVC Controller:

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

Durchlaufen Sie im pathmember des Controllers die Schlüssel/Wert-Paare in der updates-Map. Im obigen Beispiel wird der "aBoolean"key den Wert true enthalten. Im nächsten Schritt werden die Werte tatsächlich durch Aufrufen der Entitätssetzer zugewiesen. Das ist jedoch ein anderes Problem.

1
Axel Goethe

Sie könnten Optional<> dafür verwenden:

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

Auf diese Weise können Sie das Aktualisierungsobjekt wie folgt untersuchen:

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

Wenn das Feld aVeryBigString nicht im JSON-Dokument enthalten ist, ist das POJO aVeryBigString-Feld null. Wenn es sich im JSON-Dokument befindet, jedoch mit einem null-Wert, ist das POJO-Feld eine Optional mit einem umschlossenen Wert null. Mit dieser Lösung können Sie zwischen "no-update" und "set-to-null" -Fällen unterscheiden.

1

Ich habe das Problem so behoben, weil ich den Dienst nicht ändern kann

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 rief nur an, wenn Werte vorhanden waren ..__ Sie können also speichern, welcher Setter aufgerufen wurde.

0
kaytastrophe

Ich habe festgestellt, dass es sich bei vielen der angegebenen Antworten um JSON-Patches oder unvollständige Antworten handelt. Im Folgenden finden Sie eine vollständige Erläuterung und ein Beispiel dessen, was Sie mit funktionierendem Code in der realen Welt benötigen

Eine vollständige Patch-Funktion:

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

Das Obige kann für manche Leute verwirrend sein, da neuere Entwickler normalerweise nicht mit solchen Überlegungen umgehen. Unabhängig davon, welche Funktion Sie im Hauptteil übergeben, wird der zugehörige Anspruch anhand der angegebenen ID ermittelt und anschließend NUR die Felder aktualisiert, die Sie als Schlüsselwertpaar übergeben haben.

Beispiel Körper:

PATCH/Ansprüche/7

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

Das obige Update aktualisiert ClaimTypeId und ClaimStatus auf die angegebenen Werte für Claim 7, wobei alle anderen Werte unberührt bleiben.

Die Rückkehr wäre also so etwas wie:

{
   "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
}

Wie Sie sehen, würde das gesamte Objekt zurückkehren, ohne andere Daten als die zu ändern, die Sie ändern möchten. Ich weiß, dass die Erklärung hier ein bisschen wiederholt ist, ich wollte es nur klar umreißen.

0
Nox

Hier ist eine Implementierung für einen Patch-Befehl unter Verwendung von Google 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();
        }
    }
}

Die Implementierung ist rekursiv, um geschachtelte Strukturen zu berücksichtigen. Arrays werden nicht zusammengeführt, da sie keinen Schlüssel für die Zusammenführung haben.

Der JSON "Patch" wird direkt von String in JsonElement und nicht in ein Objekt konvertiert, um die nicht ausgefüllten Felder von den mit NULL gefüllten Feldern zu trennen.

0
Thomas Neeb