【问题标题】:How to do PATCH properly in strongly typed languages based on Spring - example如何在基于 Spring 的强类型语言中正确执行 PATCH - 示例
【发布时间】:2016-08-22 19:23:15
【问题描述】:

据我所知:

  • 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
    }    
    ...
}

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

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

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

  • 同意客户,如果他想删除一个属性,他应该给我一个空字符串(但是日期和其他非字符串类型呢?)
  • 停止使用 DTO 映射并使用一个简单的映射,它可以让我检查一个字段是空的还是根本没有给出。那么请求正文验证呢?我现在使用@Valid

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

编辑:

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

【问题讨论】:

  • 补丁是服务器为了将状态 A 转换为状态 B 必须执行的单个指令的集合。因此,客户端必须告诉服务器转换需要哪些指令。查看JSON Patch,了解 PATCH 请求正文的外观。正如您还询问了如果要删除的字段不可用该怎么办:PATCH RFC 明确指出:所有指令都成功或没有(原子性)
  • @RomanVottner JSON Patch 确实可能是有效的替代方案,但它不像普通的旧 http PATCH 那样容易在客户端实现,假设以下更改的自然描述即 {name: "Mario"} mreaning 更新name 属性值为"Mario"。如果是 JSON Patch,请求验证将如何工作?
  • @miensol 我不确定您具体要求什么。您的意思是客户端必须如何为名称更改创建 JSON-Patch 正文?或者服务器应该如何执行每条指令?对于入门者:客户端具有资源的状态 A,但他希望资源为状态 B。他必须遍历所有需要更改的字段并向 JSON-Patch 消息添加指令。服务器将必须创建一个事务并尝试通过更新指令执行更改。新字段可能需要预先更改 DB 表和向 DB 层更新指令
  • @RomanVottner 通过验证,我的意思是验证在服务器端的请求,理想情况下是注释驱动,如问题中提供的示例所示。我同意使用事务边界来提供整个操作的原子性是要走的路。但是问题没有提到使用数据库。
  • @miensol 可以使用更通用的类而不是使用定制的PatchUserRequest,它包含一个 JSON 对象列表(具体说明),在遍历列表时,可能会签入如果值符合映射中定义的验证规则,则将字段映射到验证规则,否则失败会导致事务回滚。这也可以通过在数据层本身上指定约束来实现(尽管您尝试忽略 OP 实际问题的数据库)

标签: spring rest spring-boot patch kotlin


【解决方案1】:

我在一些应用程序中所做的是创建一个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】:

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

    一种方法是使用普通的旧Map&lt;String,Any?&gt;,其中客户端提交的每个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,它往往是不切实际的。更重要的是,在产品存在一段时间并引入新的验证规则之后,仍然允许在非用户编辑上下文中更新实体是很常见的。 enforce invariants on the domain layer 似乎更务实,但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&lt;String,Any?&gt; 构建包装器非常容易:

    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接口。

    进一步阅读

    【讨论】:

      【解决方案3】:

      正如您所指出的,主要问题是我们没有多个类似 null 的值来区分显式和隐式 null。既然你在 Kotlin 上标记了这个问题,我试图想出一个使用 Delegated PropertiesProperty 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) 的检查更好一些。

      【讨论】:

        【解决方案4】:

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

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

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

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

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

        同意客户,如果他想删除一个属性,他应该给我一个空字符串(但是日期和其他非字符串类型呢?)

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

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

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

        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 方法。因此,缺少字段的 contains 布尔值将保持为 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 - 仅使用给定字段更新对象(更新)

        【讨论】:

        • the setter alone will not work 上的验证。如果您将其移至您仍然需要的 getter,则基于 containsName要么阻止验证被触发,要么过滤掉与 getName 相关的实际错误 .
        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2010-09-07
        • 2023-03-11
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多