J'ai un projet où j'utilise Spring MVC + Jackson pour créer un service REST. Disons que j'ai l'entité Java suivante
public class MyEntity {
private Integer id;
private boolean aBoolean;
private String aVeryBigString;
//getter & setters
}
Parfois, je veux juste mettre à jour la valeur booléenne et je ne pense pas que l'envoi de l'objet entier avec sa grosse chaîne soit une bonne idée, mais simplement de mettre à jour un booléen simple. J'ai donc envisagé d'utiliser la méthode HTTP PATCH pour n'envoyer que les champs devant être mis à jour. Donc, je déclare la méthode suivante dans mon contrôleur:
@RequestMapping(method = RequestMethod.PATCH)
public void patch(@RequestBody MyVariable myVariable) {
//calling a service to update the entity
}
Le problème est le suivant: comment savoir quels champs doivent être mis à jour? Par exemple, si le client veut juste mettre à jour le booléen, j'obtiendrai un objet avec un "aVeryBigString" vide. Comment suis-je censé savoir que l'utilisateur veut seulement mettre à jour le booléen, mais ne veut pas vider la chaîne?
J'ai "résolu" le problème en construisant des URL personnalisées. Par exemple, l'URL suivante: POST/myentities/1/aboolean/true sera mappée sur une méthode permettant uniquement de mettre à jour le booléen. Le problème avec cette solution est qu’elle n’est pas conforme à REST. Je ne veux pas être à 100% conforme REST, mais je ne me sens pas à l'aise de fournir une URL personnalisée pour mettre à jour chaque champ (d'autant plus que cela pose des problèmes lorsque je souhaite mettre à jour plusieurs champs).
Une autre solution consisterait à scinder "MyEntity" en plusieurs ressources et à mettre à jour ces ressources, mais j’ai l’impression que cela n’a aucun sens: "MyEntity" est une ressource simple, il n’est pas composé de other Ressources.
Alors, existe-t-il un moyen élégant de résoudre ce problème?
Cela pourrait être très tardif, mais pour le bien des débutants et des personnes qui rencontrent le même problème, laissez-moi vous partager ma propre solution.
Dans mes projets antérieurs, pour simplifier les choses, j'utilise simplement la carte Java native. Il capturera toutes les nouvelles valeurs, y compris les valeurs NULL que le client a explicitement définies sur NULL. À ce stade, il sera facile de déterminer quelles propriétés Java doivent être définies sur null. Contrairement à l'utilisation du même POJO en tant que modèle de domaine, vous ne pourrez pas distinguer les champs définis par le client sur null. qui ne sont tout simplement pas inclus dans la mise à jour mais qui par défaut seront nuls.
De plus, vous devez demander à la demande http d'envoyer l'ID de l'enregistrement que vous souhaitez mettre à jour et ne l'incluez pas dans la structure de données du correctif. Ce que j’ai fait, c’est de définir l’ID dans l’URL en tant que variable de chemin et les données de correctif en tant que corps PATCH. Ensuite, avec l’ID, vous obtiendrez l’enregistrement via un modèle de domaine, puis avec HashMap, service ou utilitaire de mappage pour appliquer les modifications au modèle de domaine concerné.
Mettre à jour
Vous pouvez créer une superclasse abstraite pour vos services avec ce type de code générique. Vous devez utiliser Java Generics. Ceci n’est qu’un segment de la mise en œuvre possible, j’espère que vous aurez l’idée. Il est également préférable d’utiliser un cadre de mappage tel que Orika ou 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;
}
}
La manière correcte de procéder est la méthode proposée dans JSON PATCH RFC 6902
Un exemple de demande serait:
PATCH http://example.com/api/entity/1 HTTP/1.1
Content-Type: application/json-patch+json
[
{ "op": "replace", "path": "aBoolean", "value": true }
]
L’intérêt de PATCH
est que vous envoyez pas la représentation complète de l’entité, je ne comprends donc pas vos commentaires sur la chaîne vide. Vous devez gérer une sorte de JSON simple tel que:
{ aBoolean: true }
et l'appliquer à la ressource spécifiée. L'idée est que ce qui a été reçu est un diff de l'état de la ressource souhaitée et de l'état de la ressource en cours.
Spring utilise/ne peut pas utiliser PATCH
pour corriger votre objet en raison du même problème que vous avez déjà: Le désérialiseur JSON crée un POJO Java avec des champs nullés.
Cela signifie que vous devez fournir votre propre logique pour patcher une entité (c'est-à-dire uniquement lorsque vous utilisez PATCH
mais pas POST
).
Soit vous savez que vous utilisez uniquement des types non primitifs, soit des règles (String est vide, c'est null
, ce qui ne fonctionne pas pour tout le monde), soit vous devez fournir un paramètre supplémentaire qui définit les valeurs remplacées. Le dernier fonctionne parfaitement pour moi: l’application JavaScript sait quels champs ont été modifiés et envoyés en plus du corps JSON de cette liste au serveur. Par exemple, si un champ description
a été nommé pour changer (patch) mais n'est pas indiqué dans le corps JSON, il était annulé.
Après avoir fouillé un peu, j'ai trouvé une solution acceptable en utilisant la même solution que celle utilisée par un Spring MVC DomainObjectReader
voir aussi: 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();
}
}
Ne pourriez-vous pas simplement envoyer un objet contenant les champs mis à jour?
Appel de script:
var data = JSON.stringify({
aBoolean: true
});
$.ajax({
type: 'patch',
contentType: 'application/json-patch+json',
url: '/myentities/' + entity.id,
data: data
});
Contrôleur MVC à ressort:
@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");
}
Dans le membre path
du contrôleur, parcourez les paires clé/valeur de la mappe updates
. Dans l'exemple ci-dessus, la clé "aBoolean"
contiendra la valeur true
. L'étape suivante consistera à attribuer les valeurs en appelant les opérateurs d'entité. Cependant, c'est un type de problème différent.
Vous pouvez utiliser Optional<>
pour cela:
public class MyEntityUpdate {
private Optional<String> aVeryBigString;
}
De cette façon, vous pouvez inspecter l'objet de mise à jour comme suit:
if(update.getAVeryBigString() != null)
entity.setAVeryBigString(update.getAVeryBigString().get());
Si le champ aVeryBigString
ne figure pas dans le document JSON, le champ POJO aVeryBigString
sera null
. S'il se trouve dans le document JSON, mais avec une valeur null
, le champ POJO sera une Optional
avec une valeur enveloppée null
. Cette solution vous permet de différencier les cas "non mis à jour" et les cas "définis à null".
J'ai corrigé le problème comme ceci, parce que je ne peux pas changer le service
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 a appelé seulement quand les valeurs existent .. Vous pouvez donc sauvegarder quel setter a été appelé.
Voici une implémentation pour une commande de patch utilisant 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();
}
}
}
L'implémentation est récursive pour prendre en compte les structures imbriquées. Les tableaux ne sont pas fusionnés, car ils ne possèdent pas de clé pour la fusion.
Le "patch" JSON est directement converti de String en JsonElement et non en un objet pour séparer les champs non renseignés des champs remplis avec NULL.