如何在基于 Spring 的强类型语言中正确执行 PATCH - 示例

How to do PATCH properly in strongly typed languages based on Spring - example

据我所知:

我正在使用 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
    ...
}

然后我使用这个class的一个对象来更新(修补)用户对象:

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

我的疑问是:如果客户端(例如网络应用程序)想要清除 属性 怎么办?我会忽略这样的变化。

我怎么知道,如果用户想要清除 属性(他故意给我发送 null)或者他只是不想更改它?在这两种情况下,它在我的对象中都是空的。

我可以在这里看到两个选项:

应该如何正确处理此类情况,与 REST 和所有良好实践相协调?

编辑:

可以说 PATCH 不应该用在这样的例子中,我应该使用 PUT 来更新我的用户。但是模型更改呢(例如添加一个新的 属性)?每次用户更改后,我都必须对我的 API(或单独的用户端点)进行版本控制。例如。我会有 api/v1/user 端点接受 PUT 旧请求体,api/v2/user 端点接受 PUT 新请求体。我想这不是解决方案,PATCH 存在是有原因的。

我遇到了同样的问题,所以这是我的经验/解决方案。

我建议您按原样实施补丁,所以如果

  • 一个键和一个值存在 > 值已设置
  • 存在空字符串的键 > 已设置空字符串
  • 存在一个带有空值的键 > 该字段设置为空
  • 一个键不存在 > 该键的值未更改

如果你不这样做,你很快就会得到一个难以理解的 api。

所以我会放弃你的第一个选择

Agree with the client that if he wants to remove a property he should send me an empty string (but what about dates and other non-string types?)

第二个选项在我看来实际上是一个不错的选择。这也是我们所做的(有点)。

我不确定您是否可以使验证属性与此选项一起使用,但是话又说回来,此验证不应该在您的域层上吗?这可能会从域中抛出异常,由其余层处理并转化为错误的请求。

这是我们在一个应用程序中的做法:

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,但它只会为存在的字段调用 setter 方法。因此,缺少字段的包含布尔值将保持为 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 反序列化器,但我自己还没有尝试过。

One could say that PATCH shouldn't be used in such example and I should use PUT to update my User.

我不同意这一点。我也像你说的那样使用 PATCH & PUT:

  • PUT - 用其整个表示更新对象(替换)
  • PATCH - 仅使用给定字段更新对象(更新)

如您所述,主要问题是我们没有多个类似 null 的值来区分显式和隐式 null。由于您标记了这个问题 Kotlin,我试图想出一个使用 Delegated Properties and Property References 的解决方案。一个重要的限制是它与 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 中。

现在在你的 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))
}

之所以有效,是因为 Jackson 在第二种情况下显式调用了 user.setName(null),而在第一种情况下省略了调用。

您当然可以花点心思,向 DTO 应实现的接口添加一些方法。

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

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

这使得 user.isExplicitNull(User::name).

的检查更好一些

TL;DR

patchy 是我想出的一个小型库,它负责正确处理 PATCH 中所需的主要样板代码83=] 即:

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 请求表示要应用于资源的更改,我们需要对其进行显式建模。

一种方法是使用普通的旧 Map<String,Any?>,其中客户端提交的每个 key 都代表对资源相应属性的更改:

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

上面的内容很容易理解:

  • 我们没有请求值的验证

可以通过在域层对象上引入验证注释来缓解上述问题。虽然这在简单的场景中非常方便,但一旦我们引入 conditional validation depending on the state of the domain object or on the role of the principal performing a change. More importantly after the product lives for a while and new validation rules are introduced it's pretty common to still allow for an entity to be update in non user edit contexts. It seems to be more pragmatic to enforce invariants on the domain layer but keep the validation at the edges.

就变得不切实际了
  • 在很多地方可能会非常相似

这实际上很容易解决,在 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))
        }
    }
}

正在验证请求

感谢 delegated properties in 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 简化开发,我在下面做了。

最简单的解决方案

我认为将上面描述的内容包装到一个简单易用的库中是有意义的 - 瞧 patchy。使用 patchy 可以拥有强类型请求输入模型以及声明式验证。您所要做的就是导入配置 @Import(PatchyConfiguration::class) 并在您的模型中实现 PatchyRequest 接口。

进一步阅读

我在某些应用程序中所做的是创建一个 OptionalInput class 来区分是否设置了一个值:

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:

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,它似乎做得很好。