web-dev-qa-db-ja.com

Springに基づいて強く型付けされた言語でPATCHを適切に行う方法-例

私の知識によると:

  • PUT-表現全体でオブジェクトを更新(置換)
  • PATCH-指定されたフィールドのみでオブジェクトを更新(更新)

Springを使用して、非常に単純なHTTPサーバーを実装しています。ユーザーが自分のデータを更新する場合、エンドポイントに対してHTTP PATCHを作成する必要があります(たとえば、api/user)。彼のリクエスト本文は、次のような@RequestBodyを介してDTOにマッピングされます。

class PatchUserRequest {
    @Email
    @Length(min = 5, max = 50)
    var email: String? = null

    @Length(max = 100)
    var name: String? = null
    ...
}

次に、このクラスのオブジェクトを使用して、ユーザーオブジェクトを更新(パッチ)します。

fun patchWithRequest(userRequest: PatchUserRequest) {
    if (!userRequest.email.isNullOrEmpty()) {
        email = userRequest.email!!
    }
    if (!userRequest.name.isNullOrEmpty()) {
        name = userRequest.name
    }    
    ...
}

私の疑問は、クライアント(Webアプリなど)がプロパティをクリアしたい場合はどうでしょうか?このような変更は無視します。

ユーザーがプロパティをクリアしたい場合(意図的にnullを送信した場合)、または単に変更したくない場合、どのように知ることができますか?どちらの場合もオブジェクトではnullになります。

ここには2つのオプションがあります。

  • クライアントがプロパティを削除する場合、空の文字列を送信する必要があることに同意します(ただし、日付やその他の非文字列型についてはどうですか?)
  • DTOマッピングの使用を停止し、単純なマップを使用します。これにより、フィールドが空になっているかどうかを確認できます。リクエストボディの検証はどうですか?今は@Validを使用しています。

RESTおよびすべての優れた慣行と調和して、そのようなケースをどのように適切に処理する必要がありますか?

編集:

そのような例ではPATCHを使用すべきではないと言うことができ、PUTを使用してユーザーを更新する必要があります。しかし、モデルの変更(新しいプロパティの追加など)はどうでしょうか?ユーザーが変更されるたびに、API(またはユーザーエンドポイントのみ)をバージョン管理する必要があります。例えば。古いリクエストボディでPUTを受け入れるapi/v1/userエンドポイントと、新しいリクエストボディでPUTを受け入れるapi/v2/userエンドポイントがあります。私はそれが解決策ではなく、PATCHが理由で存在すると思います。

49
KlimczakM

TL; DR

patchyは、SpringでPATCHを適切に処理するために必要な主要な定型コードを処理する小さなライブラリです。

_class Request : PatchyRequest {
    @get:NotBlank
    val name:String? by { _changes }

    override var _changes = mapOf<String,Any?>()
}

@RestController
class PatchingCtrl {
    @RequestMapping("/", method = arrayOf(RequestMethod.PATCH))
    fun update(@Valid request: Request){
        request.applyChangesTo(entity)
    }
}
_

シンプルなソリューション

PATCH 要求はリソースに適用される変更を表すため、明示的にモデル化する必要があります。

1つの方法は、クライアントから送信されたすべてのkeyがリソースの対応する属性への変更を表す単純な古い_Map<String,Any?>_を使用することです。

_@RequestMapping("/entity/{id}", method = arrayOf(RequestMethod.PATCH))
fun update(@RequestBody changes:Map<String,Any?>, @PathVariable id:Long) {
    val entity = db.find<Entity>(id)
    changes.forEach { entry ->
        when(entry.key){
            "firstName" -> entity.firstName = entry.value?.toString() 
            "lastName" -> entity.lastName = entry.value?.toString() 
        }
    }
    db.save(entity)
}
_

ただし、上記の手順は非常に簡単です。

  • we 検証なしリクエスト値

上記は、ドメインレイヤーオブジェクトに検証アノテーションを導入することで軽減できます。これは単純なシナリオでは非常に便利ですが、ドメインオブジェクトの状態または変更を実行するプリンシパルの役割に応じて 条件付き検証 を導入するとすぐに実用的ではなくなる傾向があります。さらに重要なのは、製品がしばらく使用され、新しい検証ルールが導入された後、ユーザー編集以外のコンテキストでエンティティを更新できるようにすることは非常に一般的です。 ドメインレイヤーに不変式を強制する の方が実用的と思われますが、 エッジで検証を維持する です。

  • 潜在的に多くの場所で非常に似ています

これは実際に取り組むのが非常に簡単で、80%のケースで次のように機能します。

_fun Map<String,Any?>.applyTo(entity:Any) {
    val entityEditor = BeanWrapperImpl(entity)
    forEach { entry ->
        if(entityEditor.isWritableProperty(entry.key)){
            entityEditor.setPropertyValue(entry.key, entityEditor.convertForProperty(entry.value, entry.key))
        }
    }
}
_

リクエストの検証

Kotlinの委任プロパティ のおかげで、_Map<String,Any?>_の周りにラッパーを構築するのは非常に簡単です:

_class NameChangeRequest(val changes: Map<String, Any?> = mapOf()) {
    @get:NotBlank
    val firstName: String? by changes
    @get:NotBlank
    val lastName: String? by changes
}
_

そして、 Validator インターフェイスを使用して、リクエストに存在しない属性に関連するエラーを次のように除外できます。

_fun filterOutFieldErrorsNotPresentInTheRequest(target:Any, attributesFromRequest: Map<String, Any?>?, source: Errors): BeanPropertyBindingResult {
    val attributes = attributesFromRequest ?: emptyMap()
    return BeanPropertyBindingResult(target, source.objectName).apply {
        source.allErrors.forEach { e ->
            if (e is FieldError) {
                if (attributes.containsKey(e.field)) {
                    addError(e)
                }
            } else {
                addError(e)
            }
        }
    }
}
_

明らかに HandlerMethodArgumentResolver で開発を合理化できますが、これは以下で行いました。

最も簡単なソリューション

上記で説明した内容を、使いやすいライブラリにラップすることは理にかなっていると思いました-見よ patchypatchyを使用すると、宣言型検証に加えて、厳密に型指定された要求入力モデルを使用できます。あなたがしなければならないことは、設定@Import(PatchyConfiguration::class)をインポートし、モデルにPatchyRequestインターフェースを実装することです。

参考文献

16
miensol

私は同じ問題を抱えていたので、ここに私の経験/解決策があります。

パッチをあるべき姿で実装することをお勧めします。

  • キーが値とともに存在し、値が設定されている
  • 空の文字列を持つキーが存在する>空の文字列が設定されている
  • null値を持つキーが存在する>フィールドがnullに設定されている
  • キーが存在しない>そのキーの値は変更されません

そうしないと、すぐに理解しにくいAPIが手に入ります。

だから私はあなたの最初のオプションをドロップします

クライアントがプロパティを削除する場合、空の文字列を送信する必要があることに同意します(ただし、日付やその他の非文字列型についてはどうですか?)

私の意見では、2番目のオプションは実際には良いオプションです。そして、それも私たちがやったことです。

このオプションで検証プロパティを機能させることができるかどうかはわかりませんが、この検証がドメインレイヤー上にないようにする必要がありますか?これにより、ドメインから例外がスローされる可能性があります。例外は残りのレイヤーによって処理され、不正なリクエストに変換されます。

これは、1つのアプリケーションで行った方法です。

class PatchUserRequest {
  private boolean containsName = false;
  private String name;

  private boolean containsEmail = false;
  private String email;

  @Length(max = 100) // haven't tested this, but annotation is allowed on method, thus should work
  void setName(String name) {
    this.containsName = true;
    this.name = name;
  }

  boolean containsName() {
    return containsName;
  }

  String getName() {
    return name;
  }
}
...

JsonデシリアライザーはPatchUserRequestをインスタンス化しますが、存在するフィールドに対してのみセッターメソッドを呼び出します。そのため、欠落フィールドの包含ブール値はfalseのままになります。

別のアプリでは、同じ原理を使用しましたが、少し異なります。 (私はこれを好む)

class PatchUserRequest {
  private static final String NAME_KEY = "name";

  private Map<String, ?> fields = new HashMap<>();;

  @Length(max = 100) // haven't tested this, but annotation is allowed on method, thus should work
  void setName(String name) {
    fields.put(NAME_KEY, name);
  }

  boolean containsName() {
    return fields.containsKey(NAME_KEY);
  }

  String getName() {
    return (String) fields.get(NAME_KEY);
  }
}
...

PatchUserRequestでMapを拡張することでも同じことができます。

別のオプションとして、独自のJSONデシリアライザーを作成することもできますが、私はそれを試していません。

そのような例ではPATCHを使用すべきではなく、PUTを使用してユーザーを更新する必要があると言えます。

私はこれに同意しません。あなたが言ったのと同じようにPATCH&PUTも使用します:

  • PUT-表現全体でオブジェクトを更新(置換)
  • PATCH-指定されたフィールドのみでオブジェクトを更新(更新)
8
niekname

お気づきのように、主な問題は、明示的なnullと暗黙的なnullを区別するための複数のnullのような値がないことです。この質問にタグを付けたので、私は Delegated Properties および Property References を使用するソリューションを考え出した。 1つの重要な制約は、Spring Bootで使用されるJacksonと透過的に機能することです。

アイデアは、委任されたプロパティを使用して、どのフィールドが明示的にnullに設定されているかという情報を自動的に保存することです。

最初にデリゲートを定義します。

_class ExpNull<R, T>(private val explicitNulls: MutableSet<KProperty<*>>) {
    private var v: T? = null
    operator fun getValue(thisRef: R, property: KProperty<*>) = v
    operator fun setValue(thisRef: R, property: KProperty<*>, value: T) {
        if (value == null) explicitNulls += property
        else explicitNulls -= property
        v = value
    }
}
_

これはプロパティのプロキシのように機能しますが、指定されたMutableSetにnullプロパティを格納します。

DTOで:

_class User {
    val explicitNulls = mutableSetOf<KProperty<*>>() 
    var name: String? by ExpNull(explicitNulls)
}
_

使用法は次のようなものです。

_@Test fun `test with missing field`() {
    val json = "{}"

    val user = ObjectMapper().readValue(json, User::class.Java)
    assertTrue(user.name == null)
    assertTrue(user.explicitNulls.isEmpty())
}

@Test fun `test with explicit null`() {
    val json = "{\"name\": null}"

    val user = ObjectMapper().readValue(json, User::class.Java)
    assertTrue(user.name == null)
    assertEquals(user.explicitNulls, setOf(User::name))
}
_

これは、ジャクソンが2番目の場合にuser.setName(null)を明示的に呼び出し、最初の場合に呼び出しを省略するためです。

もちろん、もう少し凝ったものを取得し、DTOが実装する必要のあるインターフェイスにメソッドを追加することもできます。

_interface ExpNullable {
    val explicitNulls: Set<KProperty<*>>

    fun isExplicitNull(property: KProperty<*>) = property in explicitNulls
}
_

user.isExplicitNull(User::name)を使用すると、チェックが少しうまくなります。

4

一部のアプリケーションで行うことは、値が設定されているかどうかを区別できるOptionalInputクラスを作成することです。

class OptionalInput<T> {

    private boolean _isSet = false

    @Valid
    private T value

    void set(T value) {
        this._isSet = true
        this.value = value
    }

    T get() {
        return this.value
    }

    boolean isSet() {
        return this._isSet
    }
}

次に、リクエストクラスで:

class PatchUserRequest {

    @OptionalInputLength(max = 100L)
    final OptionalInput<String> name = new OptionalInput<>()

    void setName(String name) {
        this.name.set(name)
    }
}

プロパティは、@OptionalInputLength

使用法は次のとおりです。

void update(@Valid @RequestBody PatchUserRequest request) {
    if (request.name.isSet()) {
        // Do the stuff
    }
}

注:コードはgroovyで記述されていますが、アイデアは得られます。すでにいくつかのAPIでこのアプローチを使用しており、非常にうまく機能しているようです。

2
voychris