web-dev-qa-db-ja.com

Spring Rest Controllerの部分的な更新でnull値と提供されていない値を区別する方法

Spring Rest ControllerでPUTリクエストメソッドを使用してエンティティを部分的に更新するときに、null値と提供されていない値を区別しようとしています。

例として、次のエンティティを考えます。

@Entity
private class Person {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    /* let's assume the following attributes may be null */
    private String firstName;
    private String lastName;

    /* getters and setters ... */
}

私の個人リポジトリ(春のデータ):

@Repository
public interface PersonRepository extends CrudRepository<Person, Long> {
}

私が使用するDTO:

private class PersonDTO {
    private String firstName;
    private String lastName;

    /* getters and setters ... */
}

私のSpring RestController:

@RestController
@RequestMapping("/api/people")
public class PersonController {

    @Autowired
    private PersonRepository people;

    @Transactional
    @RequestMapping(path = "/{personId}", method = RequestMethod.PUT)
    public ResponseEntity<?> update(
            @PathVariable String personId,
            @RequestBody PersonDTO dto) {

        // get the entity by ID
        Person p = people.findOne(personId); // we assume it exists

        // update ONLY entity attributes that have been defined
        if(/* dto.getFirstName is defined */)
            p.setFirstName = dto.getFirstName;

        if(/* dto.getLastName is defined */)
            p.setLastName = dto.getLastName;

        return ResponseEntity.ok(p);
    }
}

プロパティが欠落しているリクエスト

{"firstName": "John"}

予想される動作:更新firstName= "John"lastNameは変更しないでください)

nullプロパティのリクエスト

{"firstName": "John", "lastName": null}

予想される動作:更新firstName="John"およびセットlastName=null

DTOのlastNameは常にJacksonによってnullに設定されているため、これら2つのケースを区別できません。

注:RESTベストプラクティス(RFC 6902)は、部分的な更新にPUTではなくPATCHを使用することをお勧めしますが、私の特定のシナリオではPUTを使用する必要があることを知っています。

35

実際、検証を無視すると、このように問題を解決できます。

   public class BusDto {
       private Map<String, Object> changedAttrs = new HashMap<>();

       /* getter and setter */
   }
  • まず、BusDtoのようなdtoのスーパークラスを記述します。
  • 次に、dtoを変更してスーパークラスを拡張し、dtoのsetメソッドを変更して、属性の名前と値をchangedAttrsに配置します(nullかnullに関係なく、属性に値がある場合、スプリングがセットを呼び出すため)。
  • 第三に、マップを横断します。
3
Demon Coldmist

ブールフラグを jacksonの作成者が推奨 として使用します。

class PersonDTO {
    private String firstName;
    private boolean isFirstNameDirty;

    public void setFirstName(String firstName){
        this.firstName = firstName;
        this.isFirstNameDirty = true;
    }

    public void getFirstName() {
        return firstName;
    }

    public boolean hasFirstName() {
        return isFirstNameDirty;
    }
}
10
laffuste

別のオプションは、Java.util.Optionalを使用することです。

import com.fasterxml.jackson.annotation.JsonInclude;
import Java.util.Optional;

@JsonInclude(JsonInclude.Include.NON_NULL)
private class PersonDTO {
    private Optional<String> firstName;
    private Optional<String> lastName;
    /* getters and setters ... */
}

FirstNameが設定されていない場合、値はnullであり、@ JsonIncludeアノテーションによって無視されます。それ以外の場合、要求オブジェクトで暗黙的に設定されていると、firstNameはnullではなく、firstName.get()がnullになります。私はこれがソリューション@laffusteにリンクされていることを発見しました 別のコメントで少し下に (garretwilsonの最初のコメントが機能しなかったということは機能することが判明しています)。

JacksonのObjectMapperを使用してDTOをエンティティにマップすることもできます。これにより、リクエストオブジェクトで渡されなかったプロパティは無視されます。

import com.fasterxml.jackson.databind.ObjectMapper;

class PersonController {
    // ...
    @Autowired
    ObjectMapper objectMapper

    @Transactional
    @RequestMapping(path = "/{personId}", method = RequestMethod.PUT)
    public ResponseEntity<?> update(
            @PathVariable String personId,
            @RequestBody PersonDTO dto
    ) {
        Person p = people.findOne(personId);
        objectMapper.updateValue(p, dto);
        personRepository.save(p);
        // return ...
    }
}

Java.util.Optionalを使用したDTOの検証も少し異なります。 ここに記載されています ですが、見つけるのに少し時間がかかりました:

// ...
import javax.validation.constraints.NotNull;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
// ...
private class PersonDTO {
    private Optional<@NotNull String> firstName;
    private Optional<@NotBlank @Pattern(regexp = "...") String> lastName;
    /* getters and setters ... */
}

この場合、firstNameはまったく設定されていない可能性がありますが、設定されている場合、PersonDTOが検証されるとnullに設定されない可能性があります。

//...
import javax.validation.Valid;
//...
public ResponseEntity<?> update(
        @PathVariable String personId,
        @RequestBody @Valid PersonDTO dto
) {
    // ...
}

また、Optionalの使用については非常に議論されているようで、執筆時点ではLombokのメンテナはこれをサポートしていません( この質問の例 を参照)。つまり、制約付きのオプションフィールドを持つクラスでlombok.Data/lombok.Setterを使用しても機能しません(制約をそのままにしてセッターを作成しようとします)。そのため、@ Setter/@ Dataを使用すると、例外が両方としてスローされます。セッターとメンバー変数には制約が設定されています。また、次の例のように、オプションのパラメータなしでセッターを記述する方が良いようです。

//...
import lombok.Getter;
//...
@Getter
private class PersonDTO {
    private Optional<@NotNull String> firstName;
    private Optional<@NotBlank @Pattern(regexp = "...") String> lastName;

    public void setFirstName(String firstName) {
        this.firstName = Optional.ofNullable(firstName);
    }
    // etc...
}
9
Zaahid

私は同じ問題を解決しようとしました。 DTOとしてJsonNodeを使用するのは非常に簡単です。この方法では、送信されたものだけを取得します。

BeanWrapperと同様に、実際の作業を行うMergeServiceを自分で作成する必要があります。必要なものを正確に実行できる既存のフレームワークを見つけられませんでした。 (Jsonリクエストのみを使用する場合は、Jacksons readForUpdateメソッドを使用できる場合があります。)

「標準フォーム送信」やその他のサービス呼び出しからの同じ機能が必要なので、実際には別のノードタイプを使用します。さらに、変更はEntityServiceという名前のトランザクション内で適用する必要があります。

プロパティ、リスト、セット、マップを自分で処理する必要があるため、このMergeServiceは残念ながら非常に複雑になります。

私にとって最も問題のある部分は、リスト/セットの要素内の変更と、リスト/セットの変更または置換を区別することでした。

また、別のモデル(私の場合はJPAエンティティ)に対していくつかのプロパティを検証する必要があるため、検証は容易ではありません。

編集-いくつかのマッピングコード(疑似コード):

class SomeController { 
   @RequestMapping(value = { "/{id}" }, method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE)
    @ResponseBody
    public void save(
            @PathVariable("id") final Integer id,
            @RequestBody final JsonNode modifications) {
        modifierService.applyModifications(someEntityLoadedById, modifications);
    }
}

class ModifierService {

    public void applyModifications(Object updateObj, JsonNode node)
            throws Exception {

        BeanWrapperImpl bw = new BeanWrapperImpl(updateObj);
        Iterator<String> fieldNames = node.fieldNames();

        while (fieldNames.hasNext()) {
            String fieldName = fieldNames.next();
            Object valueToBeUpdated = node.get(fieldName);
            Class<?> propertyType = bw.getPropertyType(fieldName);
            if (propertyType == null) {
               if (!ignoreUnkown) {
                    throw new IllegalArgumentException("Unkown field " + fieldName + " on type " + bw.getWrappedClass());
                }
            } else if (Map.class.isAssignableFrom(propertyType)) {
                    handleMap(bw, fieldName, valueToBeUpdated, ModificationType.MODIFY, createdObjects);
            } else if (Collection.class.isAssignableFrom(propertyType)) {
                    handleCollection(bw, fieldName, valueToBeUpdated, ModificationType.MODIFY, createdObjects);
            } else {
                    handleObject(bw, fieldName, valueToBeUpdated, propertyType, createdObjects);
            }
        }
    }
}
1
Martin Frey

DTOの変更やセッターのカスタマイズを含まない、より良いオプションがあります。

次のように、Jacksonに既存のデータオブジェクトとデータをマージさせます。

MyData existingData = ...
ObjectReader readerForUpdating = objectMapper.readerForUpdating(existingData);

MyData mergedData = readerForUpdating.readValue(newData);    

newDataに存在しないフィールドは、existingDataのデータを上書きしませんが、フィールドが存在する場合、たとえnullが含まれていても上書きされます。

デモコード:

    ObjectMapper objectMapper = new ObjectMapper();
    MyDTO dto = new MyDTO();

    dto.setText("text");
    dto.setAddress("address");
    dto.setCity("city");

    String json = "{\"text\": \"patched text\", \"city\": null}";

    ObjectReader readerForUpdating = objectMapper.readerForUpdating(dto);

    MyDTO merged = readerForUpdating.readValue(json);

{"text": "patched text", "address": "address", "city": null}の結果

Spring Rest Controllerでは、これを行うために、Springに逆シリアル化するのではなく、元のJSONデータを取得する必要があります。したがって、次のようにエンドポイントを変更します。

@Autowired ObjectMapper objectMapper;

@RequestMapping(path = "/{personId}", method = RequestMethod.PUT)
public ResponseEntity<?> update(
        @PathVariable String personId,
        @RequestBody JsonNode jsonNode) {

   RequestDto existingData = getExistingDataFromSomewhere();

   ObjectReader readerForUpdating = objectMapper.readerForUpdating(existingData);

   RequestDTO mergedData = readerForUpdating.readValue(jsonNode);

   ...
)
1
john16384

おそらく遅くなりますが、次のコードはnull値と提供されていない値を区別するのに役立ちます

if(dto.getIban() == null){
  log.info("Iban value is not provided");
}else if(dto.getIban().orElse(null) == null){
  log.info("Iban is provided and has null value");
}else{
  log.info("Iban value is : " + dto.getIban().get());
}
0

答えに間に合わないかもしれませんが、次のことができます:

  • デフォルトでは、「null」値の設定を解除しないでください。設定を解除するフィールドをquery paramsで明示的にリストを提供します。このようにして、エンティティに対応するJSONを送信し、必要に応じてフィールドを設定解除する柔軟性を持たせることができます。

  • ユースケースによっては、一部のエンドポイントがすべてのnull値を未設定の操作として明示的に処理する場合があります。パッチを適用するには少し危険ですが、状況によってはオプションになる場合があります。

0
Vyacheslav