这个项目叫rea,全称Rule Execution Agent,一个自研的轻量级规则执行引擎。先说清楚它是干什么的:把分散在服务器上的定时任务、Webhook回调、消息队列事件全部收拢到一套统一规则体系里,通过“事件进来→规则匹配→动作执行→结果记录”这样一条链路驱动,替代过去到处乱丢的cron脚本和各自为战的任务代码。如果你现在还在用crontab加一堆没人敢动的Python脚本维护自动化,或者团队里每个人各写一套事件处理逻辑,那这篇文章值得你花十分钟看完。
它最初是我在一个内部自动化平台项目里为了解决任务管理混乱而写的,后来演化成了一个独立的引擎模块。这个项目不算复杂,但踩过的坑不少,尤其是规则命中、重试机制、幂等处理这些细节,几乎每一个都能单独写一篇文章。我尽量把这套东西从设计思路到核心实现再到排障经验都讲透,方便你直接参考或者在此基础上改造成自己的版本。
1. 为什么我们要自研一个轻量级规则执行引擎
1.1 项目背景:脚本式任务编排有多痛苦
团队当时维护一个内部自动化服务平台,承接的工作包括每日报表生成、订单数据同步、库存告警联动、定时清理临时文件等,听上去都是小事,但真实推进起来非常难受。
最初的做法很朴素:每接一个需求就在服务器上加一条crontab,再配一个独立的Python脚本或者十几行Shell命令。新需求来的时候,简单,写个脚本挂上去就行。但半年之后问题开始集中爆发。
先说最直接的一个:脚本位置混乱。大家的脚本分散在各台机器的不同目录,有的放在/home/xxx/scripts/,有的放在/opt/tools/,还有的直接放在/tmp/下面。人员一流动,谁也不知道哪些脚本还在跑、哪些已经废弃了,服务器重启之后依赖环境没配好,任务直接静默失败。
其次是重试和处理结果不可控。一个批量同步脚本执行到一半,因为数据库连接超时中断了,脚本没有重试逻辑,也没有把失败状态写到任何地方。等到第二天业务人员反馈“怎么昨天的数据一直没到”,我们才开始翻日志找问题,有时候一个简单问题要排查一两个小时。
更麻烦的是事件类需求。比如某个平台的Webhook回调触发后需要更新业务状态,或者监控系统告警后需要自动执行一个处理动作。这类需求没法用cron表达,往往都是临时写个HTTP服务承载,或者用消息队列手动消费。每个服务端口、鉴权方式、消费逻辑都不一样,维护成本越来越大。
这时候我们需要一个统一的东西:不管任务是定时触发、Webhook触发,还是消息到达触发,都用同一种方式描述,由同一个执行引擎统一调度、重试、记录结果。
1.2 调研结论:为什么没直接用开源规则引擎
动手之前我们做过一轮技术调研,看了几类现成方案。
第一类是调度框架,比如常见的分布式任务调度平台。这类系统对任务编排能力强,但偏重“调度”,对事件规则匹配、条件表达式、动作绑定的抽象较弱。我们很多场景是“当某个事件满足某个条件时就执行某个动作”,这更像规则引擎的领域。直接拿调度框架来改,要叠加很多自己的逻辑。
第二类是企业级规则引擎,功能确实强大,支持复杂的DRL规则文件、决策表、工作流编排。但问题也很明显:学习成本高、框架重、部署复杂。我们团队需要的是一个轻量级、能像普通Python库一样被集成和维护的组件,不想给平台引入一个不知道有多少依赖的庞然大物。
第三类是看起来最接近的“事件驱动框架”,比如消息队列配合流处理框架的组合方案。优势是吞吐大、扩展性强,但那是面向大规模流式处理场景的武器,我们用起来属于杀鸡用牛刀。而且这类框架的处理模型偏“数据流”,对“命中规则后执行一个有状态动作”的支持不够直接,比如动作里要请求外部HTTP接口并等待返回值,处理起来比较别扭。
综合考虑后,我们都倾向一个更务实的方向:用一个Python编写的、带配置文件的、包含事件监听、规则匹配、动作执行、任务状态管理的小引擎。这正是rea的雏形。
rea这个名字是后来起的,Rule Execution Agent,意思是“规则执行代理”。它不追求通用,只做一件事:把“事件到动作”的映射关系用声明式配置表达出来,并且可靠地执行完。
1.3 核心设计目标:用配置消灭代码散乱
做前期的目标拆解时,我们把核心需求收敛成了四条。
第一条是统一入口。不管是定时触发、HTTP回调、消息队列消费,都必须经过同一条处理链路进入规则引擎,不许再出现“每个需求一个单独服务”的情况。
第二条是规则可配置。新加一个规则、修改一个动作地址、调整一个条件表达式,都要通过改配置文件或者调用管理接口完成,不需要重新发布代码。
第三条是执行可观测。每个任务从触发、匹配到执行、完成或失败,都要有日志和状态记录。出问题的时候能快速定位“这个动作为什么没执行”或者“为什么执行了两次”。
第四条是失败可恢复。任务执行过程中进程重启了,或者动作接口超时了,系统要能通过重试和恢复机制继续处理,而不是无声无息地丢掉。
这四个目标最终成为rea的基本设计骨架。
2. rea的架构设计与核心概念
2.1 三大核心抽象:规则、触发与动作
rea把整个系统抽象成三个核心概念:任务(Job)、规则(Rule)和动作(Action)。
许多人第一次接触规则引擎会纠结于“事件”和“任务”的概念区别。在我这个设计里,事件是一个瞬时发生的事实,比如“收到一条Webhook”“到了某个cron时间点”“队列里来了一条新消息”。事件本身不存储状态,处理完就没有了。动作则是一个可能耗时、可能失败、需要被追踪的操作,比如“调用订单同步接口”“执行一个停机脚本”“发送一条企业微信通知”。因为动作有副作用,所以必须被纳入任务管理。
一条任务由一个触发条件加上多条规则构成。触发条件决定任务什么时候进入引擎,规则决定任务是否被接受、要做什么动作。
举个例子:系统收到一条“订单创建”Webhook事件,触发条件匹配到“订单事件”这个任务。任务里有多条规则,其中一条规则的条件是“订单金额大于10000”,对应的动作是“通知财务复核”;另一条规则的条件是“订单状态为已付款”,对应的动作是“调用库存锁定接口”。这样就把一条事件拆成了多个可能执行的路径。
在具体实现上,触发条件就是Trigger,规则是包含了条件表达式和动作引用的Rule,动作是具体执行器,通常是HTTP调用、Shell命令、消息发送等类型。
2.2 执行链路的四阶段状态机
rea的执行链路是固定四段式:接收事件、规则匹配、排队执行、记录结果。
事件接收阶段由不同的Source负责。定时任务用CronSource,Webhook用HTTPSource,消息队列用StreamSource。每个Source把外部事件统一成内部Event对象,Event里有事件ID、事件类型、事件时间、业务数据payload和来源信息。
规则匹配阶段由一个Matcher完成。Matcher根据事件类型找到关联的任务,逐条执行规则中的条件表达式,把命中的规则和对应的动作构造为一个待执行单元,放入执行队列。
排队执行阶段由Dispatcher驱动。Dispatcher维护一组Worker线程/进程,每个Worker从队列里取一个待执行单元,按动作配置执行,并处理超时和重试。
记录结果阶段由状态管理器负责。每一条任务从进入引擎开始,就有一个状态流转,整体是:pending(排队中)→ running(执行中)→ success(成功)或者failed(失败)。失败的任务如果设置了重试次数,会重新回到pending并等待下一次调度,而不是直接丢弃。
这个状态机在设计上参考了工作流引擎的思路,但砍掉了复杂的流程编排,只保留线性状态转移,目的就是让定位问题足够简单。任何一条任务卡在哪里,看一眼状态就知道。
2.3 规则配置的DSL设计
规则采用YAML编写,因为YAML可读性强、支持注释、团队里几乎所有人都能看懂。
一个完整的规则配置大致长这样:
task: id: order_sync_daily trigger: type: cron expr: "0 2 * * *" timezone: Asia/Shanghai accept: - type: order.created - type: order.updated rules: - id: sync_to_erp when: "payload.amount >= 100 and payload.env == 'prod'" action: type: http method: POST url: "http://erp.internal/api/order/sync" headers: token: "${ERP_TOKEN}" timeout: 10 max_retries: 3 retry_interval: 30 - id: notify_finance when: "payload.amount >= 50000" action: type: notify channel: webhook target: "https://hook.mysite.local/finance"这里每个字段都是有意为之的。id是任务和规则的唯一标识,后续所有日志和状态记录都用它关联。trigger.type决定用什么Source来接收,expr是cron表达式或事件类型过滤。accept字段用于快速过滤事件类型,相当于白名单,能减少不必要进入规则引擎的数据。when字段是条件表达式,用简单布尔表达式描述,支持比较、逻辑与或非、成员判断。action是动作定义,按类型区分执行器,所有动作参数都在这一段配置。
实际使用中,DSL越简单越好。规则定义如果复杂到需要用户看半天文档,接受度必然低。rea的DSL刻意控制在了上面这个规模,覆盖了我们90%以上的场景。
3. 核心实现:从一个最小可用的引擎写起
3.1 项目结构与环境依赖
rea的代码量不大,整个核心模块控制在两千行左右。项目结构如下:
rea/ ├── __init__.py ├── engine.py # 主引擎:负责启动Source、调度、状态管理 ├── models.py # Event、Task、Rule、ActionResult等数据模型 ├── matcher.py # 规则匹配器 ├── dispatcher.py # 执行调度器,管理Worker ├── actions/ │ ├── __init__.py │ ├── http_action.py │ ├── shell_action.py │ └── notify_action.py ├── sources/ │ ├── __init__.py │ ├── cron_source.py │ ├── http_source.py │ └── stream_source.py ├── config.yaml # 规则配置 └── main.py # 启动入口依赖做到尽可能少,核心运行时只需要Python 3.9及以上。HTTP服务和消息队列支持分别通过可选的第三方库提供,比如Web框架、Redis客户端,但这样剥离之后,最简情况下连第三方库都不用装。
这种“核心零依赖”的设计不是刻意追求极简,而是为了降低部署成本和排障复杂度。平台里其他服务用的技术栈五花八门,如果rea本身引入一堆依赖,和别的服务放在同一台机器上时容易冲突。单独跑在容器里的时候,镜像也能做得很小。
3.2 事件模型与条件表达式解析
数据模型是整个引擎的基石。Event和Rule的定义如下:
from dataclasses import dataclass, field from typing import Any, Dict, Optional @dataclass class Event: event_id: str event_type: str event_time: str payload: Dict[str, Any] source: str = "unknown" @dataclass class Rule: id: str when: Optional[str] = None action: Dict[str, Any] = field(default_factory=dict) enabled: bool = True这里的when是一个字符串表达式,需要运行时解析。最开始我们考虑直接用Python的eval,但eval在安全性上风险太高。规则配置来自内部平台,风险相对可控,但也不是没有漏洞的可能。比如某个规则写了一个恶意的表达式,一旦触发就会执行任意代码。
后来我们把条件表达式解析限制在一个安全子集里:只允许访问payload字段、比较运算符、逻辑运算符、in和not in判断,其他语法一律解析失败。用了一个约百行的递归下降解析器实现,不依赖第三方库。虽然简单,但足够满足业务需求。
解析器核心逻辑大致是这样:
import ast import operator class ConditionEvaluator: def __init__(self, payload: dict): self.env = {"payload": payload} def evaluate(self, expression: str) -> bool: try: tree = ast.parse(expression, mode="eval") return self._eval_node(tree.body) except SyntaxError: return False def _eval_node(self, node): if isinstance(node, ast.Compare): left = self._eval_node(node.left) for op, comparator in zip(node.ops, node.comparators): right = self._eval_node(comparator) if not self._apply_op(op, left, right): return False left = right return True elif isinstance(node, ast.BoolOp): values = [self._eval_node(v) for v in node.values] if isinstance(node.op, ast.And): return all(values) if isinstance(node.op, ast.Or): return any(values) elif isinstance(node, ast.Attribute): value = self._eval_node(node.value) return value.get(node.attr) if isinstance(value, dict) else None elif isinstance(node, ast.Subscript): container = self._eval_node(node.value) key = node.slice.value return container.get(key) if isinstance(container, dict) else None elif isinstance(node, ast.Name): return self.env.get(node.id, {}) elif isinstance(node, ast.Constant): return node.value elif isinstance(node, ast.UnaryOp): value = self._eval_node(node.operand) if isinstance(node.op, ast.Not): return not value return None这个实现的安全性在于,整个解析过程基于抽象语法树遍历,只支持预定义的节点类型,遇到未支持的语法会返回False或者抛出异常,不执行任何底层函数调用。
3.3 调度器:Worker池与重试逻辑
Dispatcher是引擎里最容易出问题的地方,它的职责是从内部队列取待执行单元,交给Worker执行,同时管理重试和超时。
我实现了一个简单但足够可靠的调度器:
import queue import threading import time import traceback class Dispatcher: def __init__(self, worker_num=4): self.queue = queue.Queue() self.workers = [] self.worker_num = worker_num self.running = False def start(self): self.running = True for i in range(self.worker_num): t = threading.Thread(target=self._worker_loop, name=f"worker-{i}") t.daemon = True t.start() self.workers.append(t) def stop(self): self.running = False # 等待当前任务结束 def submit(self, task): self.queue.put(task) def _worker_loop(self): while self.running: try: task = self.queue.get(timeout=1) except queue.Empty: continue try: self._execute_task(task) finally: self.queue.task_done() def _execute_task(self, task): rule = task["rule"] event = task["event"] action = rule.action max_retries = action.get("max_retries", 0) retry_interval = action.get("retry_interval", 30) for attempt in range(max_retries + 1): result = do_action(action, event) if result.success: task_state = "success" save_task_result(task, result) break else: if attempt < max_retries: time.sleep(retry_interval) continue save_task_result(task, result, failed=True)这里有几个细节值得展开。
第一,Worker数量默认设4,不是拍脑袋定的。我们内部统计过,绝大多数动作是HTTP调用和Shell命令,耗时主要集中在网络IO上,CPU占用很低。4个Worker已经能覆盖日常并发,而且线程过多会带来不必要的上下文切换和文件描述符开销。如果动作负载更高,可以加大Worker数量,但要先看目标接口的承受能力,不然Worker一多,反而把对端打挂了。
第二,超时处理必须在do_action里实现。HTTP动作必须设置socket超时和整体读取超时,Shell动作也要用timeout参数限制执行时间。否则一个卡死的接口就能让Worker一直占着不放,等于整个调度器少了一个执行名额。
第三,重试之间给了一个sleep。这里虽然没有退避策略,但retry_interval是必须有人负责设置的。曾经我们把重试间隔设成3秒,结果对端数据库故障恢复很慢,3秒一次重试把数据库完全压死了,从故障变成了一场更大的事故。后来把重试间隔调成30秒以上,并且配合最大重试次数限制,对端才缓过气来。
3.4 Source接入:定时、HTTP与消息流
Source是事件产生的入口,不同Source复用同一套下游逻辑,只需要把外部事件转换成统一的Event对象。
CronSource是定时任务的核心,用线程触发cron表达式。每次cron到达时间点,构造一个Event,event_type固定为cron.xxx。因为cron表达式本身的解析可以复用第三方库,所以这块依赖是允许的。需要注意timezone字段,默认用本地时区,但最好显式指定一个时区,避免服务器跨时区迁移时定时任务整体偏移。
HTTPSource是一个内嵌的HTTP服务,接收外部Webhook请求。接收的逻辑不复杂:
def handle_webhook(payload, headers): event = Event( event_id=generate_uuid(), event_type=payload.get("event_type", "unknown"), event_time=payload.get("timestamp", now_iso()), payload=payload, source="webhook" ) engine.submit_event(event) return {"status": "accepted"},这里一个容易被忽视的问题:Webhook请求需要快速返回,接收方不应当在这里执行任何耗时操作,而是把事件直接交给引擎。否则调用方等待时间过长会触发自己的超时和重试,造成重复投递。
StreamSource负责从Redis Stream或者消息队列消费。实现是轮询读取,每取到一条消息,解析后当作Event进入引擎。消费成功后才ack,确保消息不丢。这里要注意的是,如果规则匹配失败或者动作执行失败,消息已经ack了,那这个事件就找不回来了。所以要确认事件落地的可观测性,否则排查问题时会很困难。
4. 部署、配置与性能调优
4.1 容器化部署与最小配置
rea部署在客户端平台一个独立的容器里,内存设置512MB足够,CPU限制在1核即可。启动命令很简单:
python main.py --config config.yaml入口main.py负责加载配置、初始化引擎、注册Source、启动Dispatcher,然后保持进程运行。
环境变量的设计上,所有敏感信息,比如令牌、密钥、目标接口地址的端口部分,统一走环境变量,不在YAML里写明文。YAML里的占位符由引擎启动时做替换,类似${ERP_TOKEN}这样的格式。
还有一个容易踩的坑:容器的时区。默认情况下Python的datetime.now()返回的是宿主机或容器配置的时区,但很多业务配置依赖“本地时区0点开始跑”。如果容器时区是UTC,那“0 2 * * *”会跑到北京时间上午10点。所以配置文件的timezone字段一定要显式设置,不能依赖环境。我们在部署脚本里统一加了时区环境变量,配合配置里显式声明,双重保险。
4.2 日志与可观测性设计
可观测性是rea花功夫最多的地方之一。
首先,日志统一JSON格式输出。一行日志是一个JSON,字段包括时间、事件ID、任务ID、规则ID、动作类型、状态、耗时、错误信息。结构化的好处是可以通过日志平台直接检索,比如输入event_id就能拉出这条事件从进入引擎到执行完成的全链路日志。
{"timestamp":"2025-01-15T10:00:00Z","level":"INFO","event_id":"ev_1234","task_id":"order_sync_daily","rule_id":"sync_to_erp","action_type":"http","status":"success","duration_ms":245}其次,内部维护了一个简单指标计数,包括:每类事件的接收数、规则命中数、动作成功数、动作失败数、重试次数、队列深度。这些指标通过HTTP接口暴露,供监控系统定期抓取。上线跑了一阵之后发现,判断一个规则是否生效最直观的办法就是看命中计数曲线,比翻日志高效得多。
最后,任务结果持久化到了平台MySQL数据库。表结构只有三张,一张存任务定义、一张存规则定义、一张存执行记录。执行记录表是排查问题的依据,保留60天,自动清理过期的记录。
4.3 压测数据与Worker数量调整
上线前我们对rea做了一次简单的性能摸底,数据供参考。
场景:模拟外部Webhook发送订单事件,频率100个/秒,每个事件匹配3条规则,其中1条动作是HTTP调用(对端模拟接口只做超短延迟返回),另外2条在条件匹配阶段就被过滤掉。设置4个Worker,运行10分钟。
结果:事件接收和处理没有积压,队列深度稳定在个位数,HTTP动作平均耗时约80ms(其中50ms是对端接口的响应时间),CPU使用率大约40%,内存稳定在300MB左右。整体结论是当前场景下4个Worker是够用的。
如果对端接口变慢,比如响应时间到2秒,同样的流量下队列就会明显积压。这时候有两个选择:加大Worker数量,或者把重试策略里的超时时间调短。我的建议是优先控制超时时间,把单个动作的最长允许时间卡住,避免慢接口拖垮整个调度系统,而不是无脑加Worker。Worker加得再多,对端响应不过来还是没用。
5. 常见问题与排障实战
5.1 规则没有触发:先看命中数和事件类型
上线接的第一个真实需求就遇到问题。业务方反馈某条Webhook事件进来了,但对应的动作一直没有执行。
排查路径很典型:先在监控指标里查这条event_type的接收计数,发现接收计数一直在涨,说明事件确实进入了引擎;再看命中规则计数,发现是0。这就意味着事件在规则匹配阶段被拦截了。
回头检查规则配置,发现accept字段里写的是order.created,但Webhook实际发送的event_type是order.create,大小写和命名不一致。规则匹配的精确匹配在这里暴露了第一个坑。
后来我们做了两个改进。一是规则匹配改成不区分大小写。二是给accept字段增加了一种后缀匹配模式,允许配order.*。这样即使上游事件类型有小变化,也不至于全部失配。
5.2 任务重复执行:幂等设计必须前置
Webhook类事件有一个天然问题:调用方有自己的重试机制,一次事件可能被投递两遍甚至更多。如果引擎侧不做幂等处理,一个动作可能被重复执行多次,造成重复通知、重复扣减库存等事故。
我们用的方案是事件ID去重。每个进入引擎的事件都有一个唯一event_id,引擎在处理前先查Redis里的去重集合,如果已经存在就直接丢弃。去重窗口设置成24小时,保证跨天重试不重复。
这里有一个细节:动作执行成功和事件去重必须保证原子性拆分。如果先标记事件已处理,再执行动作,动作失败后就没有重试机会了;如果先执行动作,事件去重前进程崩溃,重投后又会执行一次。我们的做法是:事件去重标记在动作执行成功后统一提交,之间加一个processing状态表示正在执行。这样既不会丢掉失败重试,也不会无限重复。
5.3 队列积压导致任务延迟执行
某次大促期间,业务流量比平时高了几倍,Webhook事件大量涌入。监控显示队列深度一路飙升到数万,很多任务延迟了十分钟以上才执行。
排查后发现问题不在调度器,而在于一个动作类型:同步库存接口。大促高峰这个接口本身响应就很慢,平均要3秒甚至超时。Worker执行一个任务要花很久,新的任务就积压在队列里。
处理方式分两步走。立即处理:把动作的超时时间从10秒改成5秒,对端接口超时后直接判定失败并进入重试队列,不长时间占着Worker。同时临时把Worker数量从4调到8,先把积压消化掉。长期优化:对这类同步接口改成异步任务模式,引擎只负责投递一个“待同步”消息到消息队列,由下游的单独服务消费并执行同步,rea只观察最终结果,不做同步等待。
这也是rea这套设计的一个优点:动作执行器是可替换的。HTTP动作从同步调用切换成异步投递,只需要改动作类型和配置,不需要动引擎核心。
5.4 内存缓慢增长与线程泄漏
rea上线跑了几周,发现内存占用从300MB缓慢涨到700MB,重启后才恢复。一开始怀疑是Worker线程泄漏,分析线程数量后发现并没有增加,这说明问题出在数据积压。
顺着排查,发现事件去重的Redis集合在动作执行完成后会正常清理,但任务执行记录在内存里有一个“最近执行结果”缓存,用来给管理端提供实时状态展示,没有设置过期时间。每条执行记录几KB,每天丢几万条进去,内存就涨上去了。
修复方式很简单,给缓存加了一个容量上限和LRU淘汰策略,只保留最近1万条结果。同一周我们又做了一次代码审查,把所有全局对象扫了一遍,给所有队列和缓存都补齐了上限。内存终于稳定在350MB以下。
这类问题其实很常见,特别是对于常驻服务。内存释放、队列上限、缓存淘汰这些事,上线前就得想清楚,而不是等报警了再手忙脚乱地修。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 应对措施 |
|---|---|---|---|
| 事件到了但没有执行动作 | 事件类型不匹配、条件表达式返回False、规则被禁用 | 检查命中规则计数、逐个规则调试 | 调整accept配置、修正条件表达式 |
| 动作重复执行 | 调用方重试、事件去重窗口过期 | 查询Redis去重记录、看执行记录数量 | 延长去重窗口、完善幂等键逻辑 |
| 任务延迟严重 | Worker过少、动作超时过长、队列积压 | 查看队列深度、动作耗时分布 | 调整Worker数量、缩短超时、改异步 |
| 内存持续增长 | 缓存无上限、队列无限积压 | 抓内存快照、检查全局对象 | 添加缓存LRU、设置队列上限 |
| 重启后任务丢失 | 执行状态没有持久化 | 查数据库执行记录 | 任务状态落库、启动时恢复pending任务 |
6. 后续扩展方向与个人经验
6.1 规则可视化配置与管理端
rea目前的使用方式是通过YAML文件配置规则,改配置需要重新加载或重启进程。对小规模场景够用,但业务方如果经常需要调整规则,体验就不够好。
后续扩展方向上,第一个想到的是给规则加一个管理端页面。Engine暴露出配置接口,管理端调用接口增删改规则,改动即时生效,不重启进程。这里涉及动态加载规则和规则版本管理两个问题,都是可以单独展开的话题。我们内部已经验证了可行性,把规则的存取从文件切换到了数据库,引擎定期刷新,配置下发延迟控制在秒级。
再往后可以做规则调试模式。每条事件执行的时候,把规则解析和匹配的中间结果都记下来,方便业务方在页面上查看“为什么这条规则没命中”。这在排障时会省下大量沟通成本。
6.2 动作编插件化
目前动作类型就HttpAction、ShellAction、NotifyAction三种,都是写死在代码里的。扩展新动作类型需要改代码升级,不符合最初的“新增规则不改代码”理念。
下一步打算做一个简单的插件机制:定义一个Action接口,任何只要实现execute方法并能返回ActionResult的模块,通过配置文件注册路径后就能被引擎加载。这样平台里其他团队也可以贡献自己的动作插件,不需要合到rea主仓库里。
插件机制实现不难,但沙箱和安全策略要先想好,因为插件是执行任意代码的入口,不能让人随便传一个恶意的路径进去执行。我们的设想是:插件只在启动时注册,配置来源受管控,运行时不做动态加载。
6.3 个人经验:几个让我印象深刻的教训
踩过这些坑之后,最大的体会是规则引擎这类中间件,难点往往不在引擎本身,而在围绕引擎的配套:可观测性、幂等设计、状态管理、限制机制。写一个能跑通的引擎用不了多少时间,难的是让它能长期稳定地跑在别人看不到的角落里不出问题。
另一个经验是,规则配置一定要有人审查。rea配置本身很灵活,可以访问payload里的任意字段,可以调用任意外部动作。如果业务方对payload结构理解不到位,很容易写出永远命中不了的条件,或者刚好命中太多条的过于宽泛的条件。我们在上线早期加了一个“规则评审”流程,每条规则要经过引擎负责人的确认才能上线,这帮助避开了不少低级事故。
最后再分享一个小技巧:调试规则的时候,不要依赖线上日志空想,本地起一个rea实例,构造一条假的Event,用调试模式跑一遍,直接看规则解析结果和命中结果。比起改线上配置试错,这种方式既快又安全。哪怕现在管理工具已经很完善了,我这个本地调试的习惯也没丢。