Skip to content
Go back

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

Edit page

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

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

Why clojure

基于表达式

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

数据处理

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

基于jvm

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

实现

实现主要分以下几个部分

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表达式来进行验证了.
但是目前的实现还有很多不足, 主要如下

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

遇到的问题

自定义注解没有被识别

原因

这个问题的主要原因在定义注解是指定了@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\r\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))

参考


Edit page
Share this post on:

Previous Post
脚本中实现修改用户密码
Next Post
ubuntu中常用的apt源