1. 为什么需要自定义操作符
1.1 表达式引擎的边界在哪里
QLExpress 作为一款轻量级的规则表达式引擎,在电商促销、风控决策、配置中心动态规则等场景里用得非常多。它的核心价值在于:业务规则变更时,不用发版、不用重启服务,直接改一段表达式字符串就能生效。但很多同学用着用着就会发现一个尴尬的问题——内置操作符不够用。
内置操作符通常覆盖了算术运算、逻辑比较、三元表达式、函数调用这些基础能力,比如+ - * /、> < == && ||、if ... then ... else ...。可一旦业务规则开始膨胀,表达式里就会出现一堆重复的复杂逻辑。举个实际例子:促销系统里判断“用户是否满足某个优惠门槛”,可能是score > 100 && level >= 3 && !blacklist.contains(userId),这种逻辑写到表达式里又长又难维护,业务方想改一个条件,还得小心翼翼地拆字符串。
这就是需要自定义操作符的时机。自定义操作符就是给表达式引擎扩展新的语法单元,它允许你用#、between、in、like这类自定义符号或关键词,把一段复杂的 Java 逻辑浓缩成一个运算符。表达式从一长串比较变成score between(100, 500)或user in whiteList,规则的可读性和维护性瞬间提升一个档次。
1.2 自定义操作符能解决什么问题
自定义操作符解决的不仅是“表达式变短”这个表面问题,它背后的价值有三层。
第一层是语义化。业务方看不懂a > 100 && b < 50 && c == 1这种原始条件,但能看懂isVipUser(user) && orderAmount between(100, 500)这种接近自然语言的描述。规则引擎的最终用户往往不是开发,而是运营和产品经理,语义化的操作符能显著降低他们的理解成本。
第二层是复用性。一段逻辑如果在十几个表达式里反复出现,靠复制粘贴字符串来维护,迟早会出事故。把这段逻辑封装成一个操作符,所有表达式统一引用,改一处全盘生效,这才是规则引擎该有的维护方式。
第三层是性能优化空间。内置操作符是通用实现,而自定义操作符可以针对你的具体场景做优化。比如集合包含判断,内置实现可能遍历整个集合,而你在自定义操作符里可以先判断集合类型、优先走 hashCode 索引,性能会有明显提升。
这篇文章会从原理、实现、实战、踩坑四个维度,把 QLExpress 自定义操作符完整拆一遍。不管你是刚开始接触表达式引擎,还是已经在生产环境跑了一段时间,应该都能找到有价值的内容。
2. 自定义操作符的运行原理
2.1 一个操作符在引擎内部经历了什么
先把黑盒打开看一眼。QLExpress 解析表达式大致经历三个阶段:词法分析、语法分析、执行。
词法分析阶段会把表达式字符串拆成一个个 token,比如score between(100, 500)会被拆成score、between、(、100、,、500、)。这里有个关键点:QLExpress 的词法分析器是支持自定义操作符的。当你通过expressRunner.addOperatorWithAlias()或addOperator()注册了一个操作符,引擎会把对应的关键词或符号注册进词法表,后续解析时就能正确识别。
语法分析阶段会根据 token 序列构建一颗抽象语法树(AST)。自定义操作符在 AST 中会变成一个操作符节点,它的子节点就是参与运算的操作数。比如user in whiteList这个表达式,in操作符节点下面挂着user和whiteList两个子节点。
执行阶段就是遍历 AST,遇到操作符节点就调用你注册的 Operator 类的execute()方法,把子节点的执行结果作为参数传入。你的代码在这里拿到操作数,做完业务逻辑,返回一个结果,这个结果再作为父节点的输入继续向上传播。
理解了这个流程,你就会明白自定义操作符其实就是在“词法识别”和“执行逻辑”两个层面都做了扩展。这也是 QLExpress 相比很多“只能调用函数”的表达式引擎更灵活的地方——你扩展的是语法,不是单纯的方法。
2.2 Operator 接口的核心方法
在 QLExpress 里实现一个操作符,核心是继承com.ql.util.express.Operator类。这个类只有一个需要你实现的方法:
public abstract Object execute(Object[] list) throws Exception;list数组是操作数的执行结果。比如你注册了between操作符,表达式里写score between(100, 500),那么list[0]就是score变量的值,list[1]是100,list[2]是500。
这里有几个重要的细节:
- 参数个数不是固定的。你可以根据操作符语义接收任意数量的参数,
between接收 3 个参数,in可能只接收 2 个,完全由你的实现决定。 - 参数类型不确定。
list是Object[],说明引擎不会帮你做类型转换。如果你期望收到的是数字,但表达式里传入的是字符串,需要自己在execute()里做转换或校验,否则运行时会抛异常。 - 返回值可以是任意类型。可以是
Boolean、Number、String,甚至是一个自定义业务对象。返回值会继续参与上层运算,比如if user in blackList then ...,in返回的 Boolean 会被if分支使用。
在实际项目中,我的习惯是在 execute() 方法入口先做参数校验和安全检查,毕竟表达式引擎经常暴露给业务方,入参不可信是基本原则。
2.3 操作符注册的两种方式和别名机制
QLExpress 提供了两种注册方式,对应两种不同场景。
第一种是addOperator(String name, Operator op),直接注册一个操作符。这个name就是你表达式里要用的关键词,比如:
ExpressRunner runner = new ExpressRunner(); runner.addOperator("between", new BetweenOperator());注册之后,表达式里写score between(100, 500)就能正确解析。注意between是关键词,在表达式里要加空格区分。
第二种是addOperatorWithAlias(String aliasName, String realName),这个机制特别实用。它的含义是:给一个已有操作符取别名。比如系统内置了if ... then ... else ...三元操作符,但你希望业务方写当 ... 则 ... 否则 ...这种更自然的中文表达式,就可以这样注册:
runner.addOperatorWithAlias("当", "if"); runner.addOperatorWithAlias("则", "then"); runner.addOperatorWithAlias("否则", "else");别名机制的本质是把别名关键词在词法分析阶段映射到真实操作符,所以别名不会改变原操作符的任何行为,只是换了个马甲。这个机制在多租户场景里特别有用——不同租户可能习惯不同的规则描述语言,同一套引擎通过别名就能适配多种语法风格。
3. 从零实现一个自定义操作符
3.1 实战场景:定义促销活动的“用户分层判断”操作符
理论讲再多,都不如一个完整案例来得直观。我拿一个电商促销系统里的真实需求来演示。
业务规则是这样的:平台运营会配置促销活动,每个活动对用户分层有要求。分层规则很固定,但不同活动的阈值不同:
A层用户:近 30 天消费金额大于 2000 元,且会员等级不低于 3 级B层用户:近 30 天消费金额在 1000 到 2000 元之间,且会员等级不低于 2 级C层用户:其他
如果不用自定义操作符,规则表达式会写成:
if (amount > 2000 && level >= 3) then "A" else (if (amount >= 1000 && amount <= 2000 && level >= 2) then "B" else "C")这串表达式又长又可读性差。业务方每次配置活动都要小心核对括号和大段条件,很容易配错。
用自定义操作符改造后,目标表达式是这样的:
userLevel(amount, level)userLevel操作符内部封装分层判断逻辑,表达式从一堆逻辑变成一句话。业务方配置规则只需要关心传入哪些参数,完全不需要理解判断逻辑。
3.2 完整代码实现
实现思路是:操作符接收两个参数——消费金额和会员等级,内部判断后返回分层结果"A"、"B"或"C"。
import com.ql.util.express.Operator; import java.math.BigDecimal; public class UserLevelOperator extends Operator { @Override public Object execute(Object[] list) throws Exception { // 参数校验:第一个参数是消费金额,第二个参数是会员等级 if (list == null || list.length < 2) { throw new IllegalArgumentException("userLevel操作符需要两个参数:消费金额、会员等级"); } // QLExpress传入的参数是Object,需要做类型转换 BigDecimal amount = toBigDecimal(list[0]); int level = toInt(list[1]); // 业务分层逻辑 if (amount.compareTo(new BigDecimal("2000")) > 0 && level >= 3) { return "A"; } if (amount.compareTo(new BigDecimal("1000")) >= 0 && amount.compareTo(new BigDecimal("2000")) <= 0 && level >= 2) { return "B"; } return "C"; } private BigDecimal toBigDecimal(Object obj) { if (obj == null) { return BigDecimal.ZERO; } if (obj instanceof BigDecimal) { return (BigDecimal) obj; } if (obj instanceof Number) { return new BigDecimal(obj.toString()); } return new BigDecimal(obj.toString()); } private int toInt(Object obj) { if (obj == null) { return 0; } if (obj instanceof Number) { return ((Number) obj).intValue(); } return Integer.parseInt(obj.toString()); } }这里面有几个实操中很容易踩的细节:
参数类型转换必须主动做。引擎不会因为你传入的是Integer就自动转成BigDecimal,也不会把String自动解析成数字。我见过太多人在这里直接强转,结果线上跑起来才发现规则偶尔传了字符串,直接抛ClassCastException。稳妥的做法是像上面这样写一个防御性的转换方法。
金额比较用compareTo不要用equals。BigDecimal的equals方法会比较精度,0.0和0.00在equals下是不相等的,但业务上它们是同一个金额。compareTo才符合比较大小的语义。
3.3 注册操作符并在表达式中使用
写完操作符类,接下来就是注册和使用。完整代码如下:
import com.ql.util.express.ExpressRunner; public class Demo { public static void main(String[] args) throws Exception { ExpressRunner runner = new ExpressRunner(); // 注册自定义操作符 runner.addOperator("userLevel", new UserLevelOperator()); // 准备上下文变量 runner.addFunctionOfServiceMethod("getAmount", new Demo(), "getAmount", new Class[]{String.class}, null); runner.addFunctionOfServiceMethod("getLevel", new Demo(), "getLevel", new Class[]{String.class}, null); // 执行表达式 String exp = "userLevel(getAmount(userId), getLevel(userId))"; Object result = runner.execute(exp, null, null, true, false); System.out.println("用户分层结果:" + result); // 也可以直接传入数字字面量 String exp2 = "userLevel(1500, 2)"; Object result2 = runner.execute(exp2, null, null, true, false); System.out.println("用户分层结果:" + result2); } public static String getAmount(String userId) { // 模拟根据userId查询消费金额 return "1899.5"; } public static int getLevel(String userId) { // 模拟根据userId查询会员等级 return 3; } }执行结果:
用户分层结果:B 用户分层结果:B这里说明一下runner.execute的各个参数:
runner.execute(expressString, context, errorList, isCache, isTrace);expressString:表达式字符串context:IExpressContext上下文,如果你没有自定义变量,可以传nullerrorList:解析错误列表,传null表示不收集isCache:是否缓存编译后的 AST,生产环境建议trueisTrace:是否输出运行轨迹,调试时可以开,线上建议关闭
我在生产环境通常把isCache设为true,因为表达式编译是有开销的,缓存能大幅提升重复执行同类表达式的性能。
3.4 操作符内访问上下文变量
上面的案例里,操作符接收的参数来自其他函数调用或字面量。但有时候,你希望操作符内部直接读取上下文中的变量,而不是依赖表达式传入。
比如有这样的场景:规则表达式里写checkVip(),括号里不需要任何参数,操作符内部直接通过 userId 查询会员信息。这要求操作符能拿到运行时的上下文。
操作方法是在execute()方法里通过getContext()方法获取:
public class VipCheckOperator extends Operator { @Override public Object execute(Object[] list) throws Exception { // 从上下文获取当前用户ID Object userId = getContext().get("currentUserId"); if (userId == null) { return false; } // 执行会员校验逻辑 return checkVip(userId.toString()); } }这里getContext()是Operator类提供的 protected 方法,返回的是当前表达式执行时的IExpressContext实例。通过它,操作符就能读取上下文中的业务变量,实现“无参操作符”的效果。
不过要提醒一句:无参操作符虽然简洁,但会把操作符和上下文变量强耦合。如果上下文中没有currentUserId,操作符就会静默返回 false。我建议在操作符实现里做好缺失变量的告警或异常抛出,避免线上排查问题时一头雾水。
3.5 有返回值操作符与无返回值操作符的处理区别
QLExpress 的操作符不一定非要返回值。有些操作符是纯副作用型的,比如发送通知、写日志、更新状态。这种操作符在execute()方法里执行完逻辑后,返回null即可。
但这里有个语义上的坑:如果操作符返回null且它不在赋值语句的右侧,QLExpress 在表达式解析时会认为整个表达式的值就是null。比如:
notifyUser(userId);这行表达式执行完,整个表达式的值就是null。如果后面还有别的判断,比如if notifyUser(userId) then ...,那notifyUser返回的null会被当成false处理,这就是潜在 bug。
我的建议是:副作用型的操作符,如果可能参与逻辑判断,一定要返回明确的值。实在没有业务返回值,就返回true表示“执行成功”,避免null参与表达式运算时产生不确定行为。
4. 高级主题:多参数、优先级、函数式操作符
4.1 操作符的参数个数和类型自动适配
前面提到了list是Object[],参数个数完全由实现决定。但有些复杂场景,你可能希望同一个操作符在不同表达式里接受不同数量的参数。
比如between操作符,标准的用法是amount between(100, 500),三个参数。但你也可能希望支持另一种写法:amount between(500),含义是“金额小于等于 500”,这时只需要两个参数。
在execute()方法里根据list.length做分支处理即可:
public Object execute(Object[] list) throws Exception { if (list.length == 2) { // 只有一个边界值,表示 <= 该值 return compare(list[0], list[1]) <= 0; } else if (list.length == 3) { // 两个边界值,表示在区间内 return compare(list[0], list[1]) >= 0 && compare(list[0], list[2]) <= 0; } throw new IllegalArgumentException("between 操作符参数个数不正确"); }这种弹性设计可以提升操作符的表达能力,但也增加了复杂度。这里需要强调的是:参数个数不同可能导致表达式写错时被静默接受。比如业务方本想写amount between(100, 500)却少写了一个参数,你的操作符可能会把它当成“小于等于”语义执行,结果完全不对。这种 bug 很难排查,所以我的建议是:优先保证参数个数和类型的严格校验,除非业务确实需要多态参数,否则别为了灵活埋坑。
类型适配方面,除了手动转换,QLExpress 还支持在操作符里使用泛型接口Operator的setRod和getRod组来实现更细粒度的类型约束,不过这类 API 相对冷门,日常需求用防御性转换就够了。
4.2 操作符优先级:无法直接改变的关键约束
操作符的优先级是 QLExpress 词法分析阶段就固定的,无法通过 API 动态设置。这一点很多文档不会明确说,但实际使用中很关键。
比如你自定义了一个in操作符,表达式a in list && b > 10,引擎会先按词法规则把in和&&的优先级定好。通常in作为类似“关系运算”的优先级,会高于逻辑与&&,所以实际解析是(a in list) && (b > 10),这通常符合预期。
但如果你自定义了一个特殊语义的操作符,比如hasPermission,期望它优先级高于所有逻辑运算。引擎不会因为你的期望而改变hasPermission的优先级,它只会按既定的词法优先级规则来。这时候你有两个选择:
- 在表达式里用括号显式控制优先级:
(hasPermission(user, "admin") || isSuperUser(user)) && status == 1 - 调整表达式写法,让操作符语义自包含,避免依赖优先级
实际项目里我倾向于第一种,用括号明确表达式的结构。虽然看起来啰嗦,但表达式的可读性和确定性最高,业务方也不会因为优先级问题产生认知分歧。
4.3 操作符中执行函数式逻辑:函子式操作符设计
在一些高级场景里,你可能会希望操作符接收一个函数作为参数,实现类似“集合过滤”的效果。QLExpress 本身支持函数调用,但自定义操作符里也能体现函数式思维。
举个例子,实现一个filter操作符,它接收一个集合和一个函数,返回过滤后的集合:
表达式设计:
filter(orders, order -> order.amount > 100)这个表达式的关键点在于,order -> order.amount > 100是一个 Lambda 表达式。QLExpress 在较新版本里支持 Lambda 表达式语法,可以作为操作符参数传递。
实现如下:
public class FilterOperator extends Operator { @Override public Object execute(Object[] list) throws Exception { if (list.length < 2) { throw new IllegalArgumentException("filter操作符需要两个参数:集合、过滤函数"); } Collection<?> source = (Collection<?>) list[0]; if (source == null || source.isEmpty()) { return new ArrayList<>(); } List<Object> result = new ArrayList<>(); for (Object item : source) { // list[1] 是函数对象,QLExpress中通过Lambda表达式调用 Object shouldKeep = invokeFunction(list[1], item, runner, context); if (shouldKeep instanceof Boolean && (Boolean) shouldKeep) { result.add(item); } } return result; } }这类函数式操作符能显著提升表达式的数据操作能力,但它对使用者的 Java 基础和函数式思维要求比较高,一般团队不建议在初期就上,容易写出一堆看不懂的表达式。我自己的经验是:先用最朴素的多参数操作符解决 80% 的需求,遇到确实需要高阶抽象的复杂数据操作时,再考虑函数式操作符。
4.4 操作符中访问表达式运行的上下文和全局配置
有时候操作符内部需要读取引擎的全局配置或运行环境信息。QLExpress 提供了在 Operator 中访问这些信息的能力。
通过继承Operator类并重写execute方法时,可以使用this.getExpressRunner()方法获取当前ExpressRunner实例,进而读取全局配置。比如:
public class ConfigAwareOperator extends Operator { @Override public Object execute(Object[] list) throws Exception { ExpressRunner runner = this.getExpressRunner(); // 获取是否开启安全模式等全局配置 boolean isSecure = runner.isSecure(); if (isSecure) { // 在安全模式下执行简化逻辑 return doSafeExecute(list); } return doFullExecute(list); } }这在实际中很有用,特别是同一套引擎在不同环境(开发、测试、生产)可能需要不同的行为策略。通过操作符读取引擎配置,可以实现“规则表达式不变,行为随环境调整”的效果。
4.5 操作符的性能考量
性能问题在规则引擎里往往被低估。自定义操作符虽然只是一个小类,但在高并发场景下,它可能会成为性能瓶颈。
几个值得注意的性能点:
避免在execute()方法里做重量级 IO。比如查询数据库、调用远程服务。因为操作符执行是表达式运行的关键路径,如果每次规则执行都触发一次 RPC,延迟会直接翻倍。
避免创建不必要的对象。每次调用BigDecimal的构造器和每次new ArrayList<>()都是有成本的。如果操作符会被高频执行,可以在类里预定义常量,比如private static final BigDecimal TWO_THOUSAND = new BigDecimal("2000")。
利用缓存。如果操作符内部依赖某些元数据配置(比如活动阈值配置),可以在配置变更时主动刷新操作符实例中的缓存字段,而不是每次都重新加载。
注意返回值的类型匹配。如果操作符返回的是Double,而表达式里后续参与金额精度计算,可能出现精度损耗。我在金额相关的操作符里一律用BigDecimal,绝不为了省事返回double。
5. 踩坑记录与排查技巧
5.1 操作符名称与关键字冲突问题
这是新手最容易踩的坑。QLExpress 内部已经定义了一组保留关键字,比如if、then、else、in、for、while、break、return、null、true、false等。如果你自定义操作符用了这些名字,覆盖率会不一致:有的场景能正常工作,有的场景会触发词法冲突。
我遇到过的一个真实案例:同事自定义了一个in操作符,想实现“集合包含”的增强版,结果某些表达式执行时被解析成内置的in语法,直接被跳到别的处理分支。排查了半天才意识到是操作符名冲突。
解决方法有几个:
- 看官网文档(网上搜“QLExpress官网”能找到官方说明),里面有保留字表,注册前先检查一下。
- 用特殊符号开头,比如自定义关键字用
#、@、$作为前缀。例如#between、#userLevel。这些符号在词法分析时不太可能与内置关键字撞车。 - 加命名空间前缀,比如
biz_userLevel、biz_in,但表达式的可读性会下降。
综合来看,我建议优先用特殊符号前缀,其次用有意义且无冲突的业务关键词。纯英文短词(between这种)看着舒服,但从冲突规避上讲风险最大。
5.2 类型转换陷阱:String、int、BigDecimal、Date 之间的模糊地带
规则引擎的入参来自四面八方:数据库查出来是BigDecimal,前端传过来是String,配置中心下发也许变成了Integer。自定义操作符一旦依赖这些值做运算,类型问题就会全面暴露。
最常见的坑:
数字比较的精度问题。如果你的操作符里直接用>比较两个Integer,没问题。但如果你收到的是BigDecimal和Double,直接用>或者compareTo时类型不匹配,会导致异常。看到这里你可能会想:String 也能直接比较大小吗?字符串类型比较大小比较的是字典序,不是数值大小,这就是个隐藏雷区。
时间类型的隐式转换。很多规则里操作符要处理“当前时间是否在某个区间”。表达式可能传now给操作符,而now有可能是java.util.Date、String,甚至long时间戳。操作符内部必须统一转换成一种标准表示,我通常都转成long毫秒值。
null 值的处理。QLExpress 对 null 的处理不是特别“智能”。如果你在表达式里调用isVip(user),而user变量是 null,操作符拿到的list[0]就是null。如果操作符内部没有判空,直接user.getLevel(),就会 NPE。
我的防御性编程原则是:凡是外部传入的数据,在操作符入口统一做 null 检查和类型标准化。宁可多写几行转换代码,也不要在运行一半时才爆异常。
5.3 操作符内部抛异常的正确姿势
自定义操作符里抛出异常时,需要注意异常类型的选择。QLExpress 执行表达式时会对异常做一些包装,如果你直接抛一个RuntimeException,最终抛出来的异常栈可能对你定位问题容易造成干扰,你需要额外逐层一裹再一裹地剥开。
我的做法是:在操作符捕获异常后,重新抛出QLExpressException并附带尽量详细的操作符名称、参数值和错误信息。这样当表达式运行失败时,日志里能直接看到“哪个操作符、哪个参数、什么原因”这三要素,排查效率会高很多。
日志示例:
操作符[userLevel]执行失败,参数amount=null, level=3, 原因: 消费金额不能为空为了达到这个效果,你可以在execute()方法里手动捕获业务异常并重写信息。虽然啰嗦,但这张“错误皮肤”值得穿,尤其在业务方直接配置规则的场景里——他们看到清晰报错后,大部分问题能自己解决,不用再来找开发。
5.4 表达式缓存与操作符更新的一致性
前面提到runner.execute(expr, context, null, true, false)里的isCache=true会缓存表达式的编译结果。这里的问题来了:如果你更新了一个操作符的实现逻辑,缓存中的 AST 不会自动失效。
比如你的UserLevelOperator原本判断 A 层用户金额大于 2000,后来业务改成大于 1500。你修改了操作符类的execute()方法逻辑,但表达式缓存里存的依然是同一个 Operator 实例的引用,新逻辑其实已经生效了——因为execute()方法是动态调用的,操作符逻辑变了,缓存的表达式调用时自然会走新逻辑。
真正需要注意的场景是:注册表变了。比如你移除、替换了userLevel这个关键词对应的 Operator 实例,但缓存里已经构建好的 AST 节点还持有旧 Operator 引用。所以凡是修改操作符注册表,必须同步清理表达式缓存。QLExpress 提供了 expressRunner 级别的缓存清理入口,实际项目中如果动态注册很频繁,可以设计缓存 key 带操作符版本号,或者干脆在注册表变更时重建 Runner。
5.5 操作符内获取系统安全上下文
在一些需要授权和审计的规则引擎场景里,你可能希望操作符能知道自己“是谁在运行这段表达式”。QLExpress 的IExpressContext可以携带用户身份信息。
配合前文的getContext()方式,可以在操作符内部获取当前用户信息:
Object operator = getContext().get("operator"); if (operator == null) { throw new QLExpressException("缺少操作人信息,已拒绝执行"); } if (!"admin".equals(operator.toString())) { throw new QLExpressException("权限不足,无法执行该规则"); }这种写法可以用在高危操作符上,比如删除数据、变更状态、发送消息等。给操作符加上权限校验,本质上就是给规则引擎加一层访问控制,减少误操作和越权操作的风险。
6. 进阶应用:实际项目中的架构设计与维护
6.1 操作符的注册管理与命名规范
当一个项目的自定义操作符数量超过 20 个时,散落在各个类里的注册逻辑就会变成维护噩梦。我的做法是构建一个OperatorRegistry类,统一管理所有操作符的注册:
public class OperatorRegistry { private final Map<String, Operator> operators = new HashMap<>(); public void register(String name, Operator op) { operators.put(name, op); } public void registerAll(ExpressRunner runner) throws Exception { for (Map.Entry<String, Operator> entry : operators.entrySet()) { runner.addOperator(entry.getKey(), entry.getValue()); } } }在应用启动时,一次性注册所有操作符,避免散落各处。命名规范方面,我建议遵循几个原则:
- 统一前缀:业务操作符统一带
biz_前缀,通用工具类操作符统一带util_前缀 - 动词优先:
biz_checkVip、util_between、biz_calcScore,一看名字就懂语义 - 同一领域用同一套术语:比如所有判断用户状态的操作用
check开头,不要一会儿is一会儿check
统一的命名规范和注册管理看起来是“软性”工作,但在操作符数量多起来之后,它带来的维护价值远超代码本身。
6.2 操作符的单元测试与回归保障
写自定义操作符容易,写完之后保证不破坏已有规则,就难了。尤其当操作符被几十条线上规则引用时,你改了一个判断逻辑,可能意味着十几条规则的行为发生变化。
我的测试策略有三层:
单测层:对每个操作符写独立的单元测试,覆盖正常入参、边界值、null 入参、类型异常、逻辑分支,确保操作符自身行为符合预期。
表达式层:用真实表达式字符串执行测试,验证操作符在表达式中与其他操作符协同工作时的行为。
回归层:维护一组“黄金规则集”,每次修改操作符后跑一遍所有线上规则表达式,对比执行结果与历史结果是否一致。
@Test public void testGoldRules() throws Exception { ExpressRunner runner = createRunnerWithAllOperators(); Map<String, Object> context = createMockContext(); // 黄金规则1 Object result1 = runner.execute("userLevel(amount, level)", context, null, true, false); // 黄金规则2 Object result2 = runner.execute("biz_checkVip(userId) && util_between(orderAmount, 100, 500)", context, null, true, false); assertEquals("A", result1); assertTrue((Boolean) result2); }这套回归策略在团队协作里尤其重要。规则引擎的特点是“牵一发动全身”,没有回归测试,你永远不知道一个看起来无关紧要的修改,会在哪条线上规则里引发爆炸。
6.3 操作符的文档化与团队协作
最后说一个容易被忽视的点:操作符是给业务方用的,代码只是实现,文档才是契约。
我在实际项目中会为每个操作符维护一份简洁的说明文档,包含以下字段:
| 字段 | 说明 | 示例 |
|---|---|---|
| 操作符名 | 表达式里要写的关键词 | userLevel |
| 参数列表 | 每个参数的含义和类型 | amount: BigDecimal, level: Integer |
| 返回值 | 返回类型和取值范围 | String: A/B/C |
| 示例 | 一个可运行的表达式示例 | userLevel(amount, level) |
| 注意事项 | 边界条件、异常行为、性能提示 | 金额不能为 null |
这份文档直接给到配置规则的业务方和维护规则的开发团队,能避免大量“这个操作符到底怎么用”的沟通成本。
7. 总结:操作的边界与我的体会
写到这里,QLExpress 自定义操作符的核心知识已经覆盖得差不多了。从原理到实战,从普通实现到函数式扩展,再到生产环境的踩坑应对,这套方法论在我自己的项目里已经验证过多次。
回过头来看,自定义操作符确实是 QLExpress 最具威力的扩展点之一。它让规则引擎不再只是一个“计算器”,而是变成了一个可以承载业务语义、权限控制、性能优化、团队协作接口的完整规则平台。
根据我的实际经验,还有几条值得分享的体会:
第一,操作符不是越多越好。每增加一个操作符,就增加一份维护成本和业务方的学习成本。能用内置运算符解决的问题,不要自创操作符;一个操作符做了三件事,要拆成三个;三个操作符做同一件事,要合并成一个。
第二,操作符的 API 设计要站在表达式使用者的角度思考。写execute(Object[] list)时,多想想业务方会怎么传参、会怎么理解返回值。好的操作符接口设计,能让业务方在不懂 Java 的情况下也能配出正确规则。
第三,多参考成熟的表达式引擎设计。QLExpress 的很多设计思路和 MVEL2 等经典引擎有相通之处,从 MVEL2 的词法设计、操作符语义中,你能找到不少启发。比如 MVEL2 对“空安全”处理、集合投影的设计,都可以借鉴到你自己的自定义操作符设计里。
最后再分享一个小技巧:给操作符加上版本号。当规则表达式里出现biz_checkVip_v1(userId)这类写法时,你就可以在同一套引擎里并行运行新旧两版逻辑做灰度对比,发现新逻辑有问题,直接在表达式里改回旧版函数就完成回滚,而不用发版改代码。
自定义操作符这条路,入门只需要写一个类,精通却需要理解语义设计、异常处理、性能优化和团队协作的完整链路。希望这篇文章能帮你把这条链路打通,少踩一些我踩过的坑。