Skip to main content

基于clojure表达式实现更加灵活的数据验证

· 13 min read
orange
programmer on jvm platform

数据验证是一个非常常见的需求, 对于java项目来说, 目前jakartabean validation已经成为了java中的标准.
其自带了一些常见的数据验证注解, 例如@NotNull, @NotEmpty, @Size等.
这些注解如果遇到复杂的数据验证需求时, 就会显得力不从心. 所以需要一种更加灵活的数据验证方式.
为了满足这种需求, 我们可以通过clojure表达式来实现数据验证.
同时我们需要和现有的bean validation一起使用, 以便于满足现有的业务需求.

Why clojure

基于表达式

clojure是一个基于表达式的语言, 所以它的数据验证功能也是基于表达式的.
表达式的好处是, 它的表达能力非常强大, 通过表达式, 我们可以实现非常复杂的数据验证.
同时表达式的可读性也非常强, 通过表达式, 我们可以很容易的理解数据验证的逻辑.

数据处理

clojure是一个函数式语言(functional programming).
函数式语言的一个特点就是数据处理能力非常强大.
它抽象了数据的操作, 通过函数式的方式来处理数据.
对于clojure来说, 它提供了一些非常方便的数据操作函数, 例如

  • map
  • filter
  • reduce
  • zipmap
  • group-by
  • partition
  • sort-by
  • sort-with
  • take
  • take-while
  • drop
  • ...

基于jvm

clojure是基于jvm的, 所以它可以和java无缝集成.

实现

实现主要分以下几个部分

  • jakarta扩展实现
  • clojure表达式处理
  • springboot集成

jakarta扩展实现

新增自定义注解@ClojureExpressionConstraint

因为jakartabean validation是基于注解的, 最终用户在使用时, 需要通过注解的方式来使用该功能.
所以我们也需要设计一个注解, 通过该注解来使用我们的数据验证功能.

代码如下:

/**
* This is a validation annotation based on clojure expression to validate your data.
*
* Note:
* 1. The expression only has one parameter, which is the value that will be validated.
* The parameter is bound to the symbol "it". You can use "it" to refer to the value.
* 2. The expression must return a boolean value.
* Example:
* Right expression: (> (count it) 5)
* Bad expression: (count it)
* 3. The size of the outer form must be 1.
* If you want to use multiple expressions, you can use "and" or "or" to combine them.
* Example:
* The expression "(and (> (count it) 5) (= it (clojure.string/lower-case it)))" is right.
* The expression "(> (count it) 5) (= it (clojure.string/lower-case it))" is wrong.
*
* Examples:
* 1. The type of value is string. We validate the length of the string must be greater than 5.
* (> (count it) 5)
* 2. The type of value is collection. We validate the size of the collection must be greater than 5.
* (> (count it) 5)
* 3. The type of value is string. We validate the string must be a lower case string.
* (= it (clojure.string/lower-case it))
* 4. The type is javaBean, and it has a property named age. We validate the age must be greater than 0 and less than 100.
* (let [age (:age it)] (and (> age 0) (< age 100)))
*
* @author Xiangcheng.Kuo
* @see ClojureExpressionConstraintValidator
*/
@Repeatable
@Target(AnnotationTarget.PROPERTY, AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [ClojureExpressionConstraintValidator::class])
annotation class ClojureExpressionConstraint(
val message: String,
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = [],
val value: String,
)

新增ClojureExpressionConstraintValidator以实现注解的验证

对于自定义的注解的处理, 我们需要实现ConstraintValidator来进行验证.

代码如下:

/**
* This is a validator for [ClojureExpressionConstraint] annotation.
* It will evaluate the expression with the given value and return the boolean result as the validation result.
*
* @author Xiangcheng.Kuo
* @see ClojureExpressionConstraint
*/
class ClojureExpressionConstraintValidator(
private val evaluator: ClojureExpressionEvaluator
) : ConstraintValidator<ClojureExpressionConstraint, Any> {

private lateinit var annotation: ClojureExpressionConstraint

override fun initialize(constraintAnnotation: ClojureExpressionConstraint) {
this.annotation = constraintAnnotation
}

override fun isValid(value: Any?, context: ConstraintValidatorContext): Boolean {
return evaluator.evaluate(annotation.value, value) as Boolean
}

}

clojure表达式处理

新增ClojureExpressionEvaluator以实现clojure表达式的处理

ClojureExpressionEvaluator抽象了clojure表达式的处理. 对于上层调用方来说不需要关心clojure表达式的处理细节, 只需要调用evaluate方法即可.
它的主要功能是根据给定的clojure表达式及输入进行处理并得出返回结果.
通过与clojure的互操作, 我们可以很方便的实现clojure表达式的解析.
以下是相关代码:

/**
* An interface used to evaluate the clojure expression with the given param and return the result.
*
* @author Xiangcheng.Kuo
*/
interface ClojureExpressionEvaluator {

fun evaluate(expression: String, param: Any?): Any?

}

/**
* The default implementation of [ClojureExpressionEvaluator].
*
* @author Xiangcheng.Kuo
* @see Class to refer to
*/
object DefaultClojureExpressionEvaluator : ClojureExpressionEvaluator {

private val evalFun: IFn

init {
com.fastonetech.lib.clojure.require("com.fastonetech.lib.clojure.support.evaluation".toCljSymbol())
evalFun = Clojure.`var`("com.fastonetech.lib.clojure.support.evaluation/evaluate-expression")
}

override fun evaluate(expression: String, param: Any?): Any? =
evalFun(expression, param.toCljValue())

}

新增evaluation.clj来实现最终的clojure表达式的解析处理

evaluation.clj是一个clojure的文件, 用于实现clojure表达式的解析.
以下是相关代码:

(ns com.fastonetech.lib.clojure.support.evaluation
(:require [clojure.java.data :as data]))

; We need to convert the list to vector to avoid the evaluator think the list is a function call.
; For example, if we have a list like (1 2 3), the evaluator will think it is a function call.
; So we need to convert it to [1 2 3] to avoid this problem.
(defn as-available-form [form]
(if (seq? form) (into [] form) form))

(defn object-to-map [object]
(data/from-java-deep object {:add-class false}))

(defn object-to-form [^Object object]
(clojure.walk/postwalk as-available-form (object-to-map object)))

(defn build-form [value-form expression-form]
(list `let ['it value-form] expression-form))

(defn expression-as-form [^String expression]
(read-string expression))

(defn evaluate-expression [^String expression ^Object param]
(let [value-form (object-to-form param)
expression-form (expression-as-form expression)
form (build-form value-form expression-form)]
(println "Expression form: " form)
(let [result (eval form)]
(println "Result: " result)
result)))

springboot集成

该部分主要是将上面的代码集成到springboot中.
主要是将ClojureExpressionConstraintValidator添加到容器中这样springboot就可以自动的对注解进行验证了.

ClojureExpressionValidationAutoConfiguration

@AutoConfiguration
class ClojureExpressionValidationAutoConfiguration {

@Bean
@ConditionalOnMissingBean
fun clojureExpressionConstraintValidator(evaluator: ClojureExpressionEvaluator): ClojureExpressionConstraintValidator =
ClojureExpressionConstraintValidator(
evaluator = evaluator
)

@Bean
@ConditionalOnMissingBean
fun clojureExpressionEvaluator(): ClojureExpressionEvaluator =
DefaultClojureExpressionEvaluator

}

配置META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

在springboot3中, 我们需要在META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 中添加需要自动配置的类.
旧的版本是spring.factories, 但是在springboot3中已经不能继续使用了.

com.fastonetech.lib.validation.spring.autoconfigure.ClojureExpressionValidationAutoConfiguration

总结

我们可以看到, 通过clojure的互操作, 我们可以很方便的实现clojure表达式的解析.
这样我们就可以在springboot中使用clojure表达式来进行验证了.
但是目前的实现还有很多不足, 主要如下

  • 没有对clojure表达式进行缓存, 对于同一个表达式的多次验证会重复解析
  • clojure表达式内只能访问clojure.core命名空间的内容, 不能访问其他命名空间的内容

这些都是可以优化的地方, 但是目前还没有时间去做.

遇到的问题

自定义注解没有被识别

原因

这个问题的主要原因在定义注解是指定了@Target注解中的AnnotationTarget.PROPERTY造成的

反编译后的java代码如下


@Metadata(
mv = {1, 8, 0},
k = 1,
d1 = {"\u0000\"\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0000\n\u0002\u0010\u000e\n\u0002\b\b\n\u0002\u0010\u000b\n\u0002\b\u0002\n\u0002\u0010\b\n\u0002\b\u0002\b\u0086\b\u0018\u00002\u00020\u0001B\r\u0012\u0006\u0010\u0002\u001a\u00020\u0003¢\u0006\u0002\u0010\u0004J\t\u0010\t\u001a\u00020\u0003HÆ\u0003J\u0013\u0010\n\u001a\u00020\u00002\b\b\u0002\u0010\u0002\u001a\u00020\u0003HÆ\u0001J\u0013\u0010\u000b\u001a\u00020\f2\b\u0010\r\u001a\u0004\u0018\u00010\u0001HÖ\u0003J\t\u0010\u000e\u001a\u00020\u000fHÖ\u0001J\t\u0010\u0010\u001a\u00020\u0003HÖ\u0001R\u001c\u0010\u0002\u001a\u00020\u00038\u0006X\u0087\u0004¢\u0006\u000e\n\u0000\u0012\u0004\b\u0005\u0010\u0006\u001a\u0004\b\u0007\u0010\b¨\u0006\u0011"},
d2 = {"Lcom/fastonetech/lib/validation/clojure/PatchUser;", "", "username", "", "(Ljava/lang/String;)V", "getUsername$annotations", "()V", "getUsername", "()Ljava/lang/String;", "component1", "copy", "equals", "", "other", "hashCode", "", "toString", "fastone-web-spring-boot-starter"}
)
public final class PatchUser {

@NotNull
private final String username;

/** @deprecated */
// $FF: synthetic method
@ClojureExpressionConstraint(
message = "the username must be a string and the length must be greater than 5",
value = "(and (string? it) (> (count it) 5))"
)
public static void getUsername$annotations() {
}

@NotNull
public final String getUsername() {
return this.username;
}

public PatchUser(@NotNull String username) {
Intrinsics.checkNotNullParameter(username, "username");
super();
this.username = username;
}

@NotNull
public final String component1() {
return this.username;
}

@NotNull
public final PatchUser copy(@NotNull String username) {
Intrinsics.checkNotNullParameter(username, "username");
return new PatchUser(username);
}

// $FF: synthetic method
public static PatchUser copy$default(PatchUser var0, String var1, int var2, Object var3) {
if ((var2 & 1) != 0) {
var1 = var0.username;
}

return var0.copy(var1);
}

@NotNull
public String toString() {
return "PatchUser(username=" + this.username + ")";
}

public int hashCode() {
String var10000 = this.username;
return var10000 != null ? var10000.hashCode() : 0;
}

public boolean equals(@Nullable Object var1) {
if (this != var1) {
if (var1 instanceof PatchUser) {
PatchUser var2 = (PatchUser) var1;
if (Intrinsics.areEqual(this.username, var2.username)) {
return true;
}
}

return false;
} else {
return true;
}
}

}

可以看到注解最终并没有在getUsername方法上, 而是在getUsername$annotations方法上.
我之前以为AnnotationTarget.PROPERTY是会将注解应用到getset方法上, 但是实际上并不是这样.

解决方案

是将@Target注解中的AnnotationTarget.PROPERTY去掉.

解决后反编译的java代码如下

package com.fastonetech.lib.validation.clojure;

import kotlin.Metadata;
import kotlin.jvm.internal.Intrinsics;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

@Metadata(
mv = {1, 8, 0},
k = 1,
d1 = {"\u0000\"\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0000\n\u0002\u0010\u000e\n\u0002\b\u0006\n\u0002\u0010\u000b\n\u0002\b\u0002\n\u0002\u0010\b\n\u0002\b\u0002\b\u0086\b\u0018\u00002\u00020\u0001B\r\u0012\u0006\u0010\u0002\u001a\u00020\u0003¢\u0006\u0002\u0010\u0004J\t\u0010\u0007\u001a\u00020\u0003HÆ\u0003J\u0013\u0010\b\u001a\u00020\u00002\b\b\u0002\u0010\u0002\u001a\u00020\u0003HÆ\u0001J\u0013\u0010\t\u001a\u00020\n2\b\u0010\u000b\u001a\u0004\u0018\u00010\u0001HÖ\u0003J\t\u0010\f\u001a\u00020\rHÖ\u0001J\t\u0010\u000e\u001a\u00020\u0003HÖ\u0001R\u0016\u0010\u0002\u001a\u00020\u00038\u0006X\u0087\u0004¢\u0006\b\n\u0000\u001a\u0004\b\u0005\u0010\u0006¨\u0006\u000f"},
d2 = {"Lcom/fastonetech/lib/validation/clojure/PatchUser;", "", "username", "", "(Ljava/lang/String;)V", "getUsername", "()Ljava/lang/String;", "component1", "copy", "equals", "", "other", "hashCode", "", "toString", "fastone-web-spring-boot-starter"}
)
public final class PatchUser {

@ClojureExpressionConstraint(
message = "the username must be a string and the length must be greater than 5",
value = "(and (string? it) (> (count it) 5))"
)
@NotNull
private final String username;

@NotNull
public final String getUsername() {
return this.username;
}

public PatchUser(@NotNull String username) {
Intrinsics.checkNotNullParameter(username, "username");
super();
this.username = username;
}

@NotNull
public final String component1() {
return this.username;
}

@NotNull
public final PatchUser copy(@NotNull String username) {
Intrinsics.checkNotNullParameter(username, "username");
return new PatchUser(username);
}

// $FF: synthetic method
public static PatchUser copy$default(PatchUser var0, String var1, int var2, Object var3) {
if ((var2 & 1) != 0) {
var1 = var0.username;
}

return var0.copy(var1);
}

@NotNull
public String toString() {
return "PatchUser(username=" + this.username + ")";
}

public int hashCode() {
String var10000 = this.username;
return var10000 != null ? var10000.hashCode() : 0;
}

public boolean equals(@Nullable Object var1) {
if (this != var1) {
if (var1 instanceof PatchUser) {
PatchUser var2 = (PatchUser) var1;
if (Intrinsics.areEqual(this.username, var2.username)) {
return true;
}
}

return false;
} else {
return true;
}
}

}

这次注解在username字段上了.

clojure中的eval函数所需的form中不能依赖外部的变量

当执行如下代码时

(defn eval-example []
(let [x 10]
(eval `(+ x 20))))
(eval-example)

会报错以下信息

Syntax error compiling at (/tmp/form-init13157191612884728008.clj:1:1).
No such var: user/x

原因

这个问题的原因是eval函数所需的form中不能依赖外部的变量, 要么被依赖的变量是全局 从报错可以看到它尝试从user这个命名空间中找x这个变量, 但是并没有找到.
而我们的命名空间不是user所以会报错.
这个问题的本质原因是因为eval函数内部执行的代码和外部的代码是不同的命名空间, 所以不能依赖调用外部调用eval 函数的函数的局部变量.

需要在form中重新binding或者变量

解决方案

在需要evalform中重新binding变量

clojure中的eval函数所需的formbinding了自定义对象当执行时会报错

报错如下

embed object in code, maybe print-dup not defined: Person(age=30)

原因

由于eval需要将表达式转换为clojure数据结构, 然后在运行时解释和执行这个表达式.
在将表达式转换为clojure数据结构的过程中, clojure需要使用print-dup函数将表达式中的所有对象转换为字符串, 以便后续可以通过read函数将这些字符串还原为Clojure数据结构.
因此, 当使用eval函数执行表达式时,如果表达式中包含无法序列化为字符串的对象,就会出现上述错误.
而当不使用eval函数,而是直接使用这个对象时,由于clojure不需要将它转换为字符串,所以就不会出现上述错误.
综上所述, eval函数所需的formbinding了自定义对象当执行时会报错.

解决方案

将自定义对象转换为clojure的内置数据结构从而避免这个问题.
通过clojure.java.data中的函数from-java-deep进行转换
转换后的数据结构如果是list还需要转为vec, 这是因为clojure中的list代表了函数调用, 而vec代表了数据结构, 所以需要转换为vec.
可以通过clojure.walk/postwalk函数进行递归转换.

备注

clojure中的eval函数

eval函数是将给定的form编译执行, 返回执行结果.

clojure中的form

formclojure中的表达式, 也就是clojure中的代码.
例如:

(+ 1 2)

clojure中的binding

binding是将变量绑定到一个值, 使得在binding的作用域内, 变量的值为绑定的值.

clojure中的全局变量

clojure中的全局变量是指在clojure的命名空间中的变量.
例如:

(def x 10)

这个x就是全局变量.

局部变量是指在函数中定义的变量.
例如:

(defn test []
(let [x 10] x))

参考