PHP如何实现支持动态规则脚本执行的工作流引擎系统

嘿,各位看官,坐好!把你们的咖啡杯放好,把那个正在疯狂抖腿的脚收起来。今天我们要聊的是一个让无数架构师和后端开发人员掉头发的“终极Boss”——PHP动态规则脚本工作流引擎

我知道,听到“工作流引擎”这几个字,你们的大脑可能已经开始自动播放那种无聊的PPT了:“状态机、状态转移、节点定义…” 好吧,闭上你的嘴,深呼吸。今天我们不讲那些教科书里用来骗奖学金的理论,我们要讲的是如何用PHP打造一个真正活着的系统,一个能听懂业务人员“人话”,并且在他们修改一行配置文件时,你的代码不需要重新编译、不需要重启服务就能自动适应的系统。

别把PHP想得那么简单。PHP曾经被叫做“粘合剂语言”,现在它是“万物皆可PHP”的王者。而在“动态规则脚本”这块领地上,PHP更是拥有得天独厚的优势——它就是解释执行的!这就像你想让一个只会说中文的人去和只会说德语的人沟通,你不需要给他装一个翻译芯片(编译),你只需要把他的嘴打开,让他直接说(解释)就行了。

准备好了吗?让我们把这个引擎从你的硬盘里“种”出来。


第一章:为什么你的代码里全是if-else?

在开始造轮子之前,我们先来看看那些“老好人”是怎么写工作流的。

假设你是一个贷款审批系统的开发者。老板说:“我们要搞个智能审批,金额小于1000直接过,大于1000小于10000要经理看一眼,大于10000要CEO签字。”

你回头写代码,信手拈来:

class LoanProcessor {
    public function approve($amount) {
        if ($amount < 1000) {
            return "自动通过";
        } elseif ($amount < 10000) {
            return "经理审批";
        } elseif ($amount < 100000) {
            return "CEO审批";
        } else {
            return "查无此人";
        }
    }
}

看起来挺完美吧?哼哼,天真!三个月后,业务部门跑了进来。

“程序员,那个10000元的贷款能不能加个条件?如果是VIP客户就自动过?”
“好的,改一下。”
“能不能再加个条件?如果是老用户,额度可以翻倍?”
“好的,再改一下。”
“能不能根据信用分动态调整?”

于是,你的 if-else 堆得比喜马拉雅山还高。这就是硬编码工作流的噩梦。修改规则需要改代码、重新部署、测试、上线。在这个DevOps讲究“快速迭代”的年代,这种系统简直就是业务部门的噩梦,是你发际线的加速器。

我们需要的是动态规则脚本。我们要把“规则”从“代码”里剥离出来,放在配置文件里,甚至放在数据库里,或者…放在脚本文件里。

第二章:架构蓝图——我们的引擎长什么样?

一个支持动态规则的工作流引擎,本质上是一个有向图。你可以把它想象成一张城市交通图。城市是“流程”,马路是“节点”,红绿灯是“规则”。

核心组件必须有三个:

  1. 流程定义器: 负责读取配置,画出这张图。
  2. 节点执行器: 负责把图变成行动。
  3. 规则脚本引擎: 这就是今天的重头戏。它负责在路口(节点)判断:“嘿,这人能不能通过?”

基础数据结构

首先,我们得定义一下,一个流程节点长什么样。

namespace Engine;

class WorkflowNode {
    public string $id;
    public string $name;
    public string $type; // 'start', 'task', 'condition', 'end'
    public array $config;
    public array $transitions; // 允许跳转的目标节点ID

    public function __construct(string $id, string $name, string $type) {
        $this->id = $id;
        $this->name = $name;
        $this->type = $type;
        $this->config = [];
        $this->transitions = [];
    }
}

class WorkflowTransition {
    public string $sourceId;
    public string $targetId;
    public callable $condition; // 这就是我们的“动态脚本”
    public string $scriptContent;
}

看到了吗?callablescriptContent 就是灵魂。callable 是运行时用的,scriptContent 是存盘用的(比如存在JSON文件里)。

第三章:如何实现动态脚本?——PHP的野路子

这里有一个技术选型的重大抉择。你是用 eval()?还是用 assert()?亦或是创建一个独立的PHP进程?

3.1 eval() —— 毒药,也是解药

在PHP中,eval() 几乎是程序员禁区。为什么?因为它是“万能胶水”,粘得牢,但也最容易中毒。如果你在 eval() 里写了 system('rm -rf /'),你的服务器就在那一瞬间升天了。

但是,在沙箱环境(我们马上讲)下,eval() 是最灵活的。它允许你写任何PHP代码。业务人员如果懂PHP,他们可以直接写业务逻辑。

$script = "return ($context['amount'] > 1000);";
$result = eval($script); // 坏示范,不要直接用

3.2 assert() —— 带缓存的eval()

PHP 7+ 的 assert()eval() 稍微好一点,但本质上风险相似。而且 assert() 会被PHP缓存机制处理,如果逻辑没变,它甚至能被优化掉,不执行代码。这听起来不错,对吧?但实际上,在动态脚本引擎里,我们需要的是每次都执行,而不是被优化掉。

3.3 终极方案:闭包工厂

这是最优雅,也是我推荐的方式。我们不直接执行脚本字符串,而是把脚本字符串解析成 Closure(闭包)

我们不需要 eval。我们只需要一个 “沙箱” 类,它拥有白名单机制。只有白名单里的函数(比如 strlen, in_array, isset, var_dump)是允许被执行的。

这就是我们接下来要写的核心代码。

第四章:核心引擎实现——让我们开始写代码

我们要写一个类叫 DynamicWorkflowEngine

步骤1:沙箱执行器

首先,我们需要一个能限制权限的执行器。

class ScriptSandbox {
    private array $allowedFunctions = [
        'strlen', 'strpos', 'strlen', 'strtolower', 'strtoupper', 
        'in_array', 'array_key_exists', 'is_numeric', 'is_bool',
        'count', 'max', 'min', 'abs', 'round', 'ceil', 'floor',
        'strpos', 'substr', 'explode', 'implode', 'array_merge',
        'json_encode', 'json_decode', 'time', 'strtotime'
    ];

    /**
     * 执行脚本并返回结果
     * @param string $code 用户编写的脚本逻辑
     * @param array $variables 传入的上下文变量
     * @return mixed
     */
    public function execute(string $code, array $variables = []) {
        // 1. 定义一个只允许白名单函数的函数
        $safeWrapper = function() use ($code, $variables) {
            $vars = array_merge(['context' => $variables], func_get_args());
            // 使用 assert 做执行,因为它在 PHP 7+ 下性能较好,且易于调试
            // 注意:这里必须加上 assertion_options 来防止报错显示在页面上(如果这是Web入口)
            return assert($code, "Script execution failed");
        };

        // 2. 劫持全局函数表,只保留白名单
        $GLOBALS['_sandbox_blacklist'] = array_fill_keys(array_diff(get_defined_functions()['user'], $this->allowedFunctions), true);

        // 3. 借用 call_user_func_array 执行
        // 这里的逻辑是:在调用期间,user function列表被我们控制了
        // 这是一个“黑魔法”,为了让简单的方法生效,我们使用一个技巧

        // 实际上,为了安全起见,最简单的方法还是创建一个独立的命名空间或者类
        // 但为了保持代码简洁,我们演示一种基于闭包的“受限上下文”构建

        $context = new class($variables, $this->allowedFunctions) {
            private $vars;
            private $allowed;

            public function __construct(array $vars, array $allowed) {
                $this->vars = $vars;
                $this->allowed = $allowed;
            }

            public function __get($name) { return $this->vars[$name] ?? null; }

            // 魔法方法:拦截所有函数调用
            public function __call($name, $arguments) {
                if (!in_array($name, $this->allowed, true)) {
                    throw new RuntimeException("Function '$name' is not allowed in sandbox.");
                }
                // 这里实际上并没有真的拦截全局函数,我们需要借助 call_user_func 的特性
                // 为了简化演示,我们假设用户只使用简单的逻辑
                // 在生产环境中,你应该使用 token_get_all 和 regex 过滤,或者使用 extension='sandbox' (PHP的Sandbox扩展,第三方)
                return call_user_func_array($name, $arguments);
            }
        };

        // 解析并执行
        // 这里为了演示方便,我们假设用户写的是 return 语句
        // 真正的生产环境,你需要解析 AST (抽象语法树) 或者用 regex
        // 或者更简单:把代码塞进一个类方法里

        $className = 'GeneratedSandboxClass_' . uniqid();
        $code = "return {$code};";

        // 这一步很危险,通常我们用 eval,但为了展示,我们用 create_function (PHP 7.1 废弃了) 
        // 所以我们还是得用 eval,但是加上 try-catch 捕获错误

        try {
            // 创建一个只包含 $context 的作用域
            // 这种方式稍微有点 hacky,但在没有第三方扩展的情况下很有效
            $result = eval("return function() use ($context) { {$code} };")();
            return $result;
        } catch (Throwable $e) {
            throw new RuntimeException("Script Execution Error: " . $e->getMessage());
        }
    }
}

免责声明:上面的代码是为了演示逻辑而写的“简陋版”沙箱。真正的生产级沙箱需要更复杂的控制流分析和命名空间隔离。但在PHP中,完全隔离是不可能的(除非用进程),所以我们靠“黑名单”来防守。

步骤2:工作流引擎主体

有了沙箱,我们就可以造引擎了。

class DynamicWorkflowEngine {
    private array $nodes = [];
    private ScriptSandbox $sandbox;
    private string $currentNodeId;

    public function __construct() {
        $this->sandbox = new ScriptSandbox();
    }

    // 注册节点
    public function addNode(WorkflowNode $node): self {
        $this->nodes[$node->id] = $node;
        return $this;
    }

    // 设置起始节点
    public function start(string $nodeId): self {
        if (!isset($this->nodes[$nodeId])) {
            throw new InvalidArgumentException("Node $nodeId not found");
        }
        $this->currentNodeId = $nodeId;
        return $this;
    }

    // 执行流程
    public function execute(array $context = []): array {
        $context['history'] = []; // 记录走过的路
        $maxLoops = 100; // 防止死循环
        $loopCount = 0;

        while ($loopCount < $maxLoops) {
            $loopCount++;
            $node = $this->nodes[$this->currentNodeId];

            // 1. 触发节点事件(如果是任务节点,这里可以调用Webhook)
            $this->triggerNodeEvent($node, $context);

            // 2. 检查是否有转换(边)
            if (empty($node->transitions)) {
                // 没路走了,结束
                break;
            }

            // 3. 查找下一个节点
            $nextNodeId = null;
            foreach ($node->transitions as $transition) {
                // 核心逻辑:执行动态脚本
                if ($this->evaluateCondition($transition->scriptContent, $context)) {
                    $nextNodeId = $transition->targetId;
                    break;
                }
            }

            if ($nextNodeId) {
                $context['history'][] = ['node' => $node->id, 'timestamp' => time()];
                $this->currentNodeId = $nextNodeId;
            } else {
                // 触发默认结束
                break;
            }
        }

        return $context;
    }

    // 执行条件判断脚本
    private function evaluateCondition(string $script, array $context) {
        try {
            // 调用沙箱执行
            $result = $this->sandbox->execute($script, $context);
            // 脚本必须返回布尔值
            return (bool) $result;
        } catch (Throwable $e) {
            // 规则报错,默认走 False
            error_log("Script Error: " . $e->getMessage());
            return false;
        }
    }

    private function triggerNodeEvent(WorkflowNode $node, array &$context) {
        // 这里可以接入消息队列,比如发送HTTP请求通知业务系统
        if ($node->type === 'task' && !empty($node->config['webhook'])) {
            // 异步通知逻辑...
            // echo "Sending webhook to {$node->config['webhook']}...n";
        }
    }
}

第五章:实战演练——让代码“活”过来

现在,我们有了引擎,有了沙箱。怎么用?我们写一个场景:智能优惠券发放系统

规则是这样的:

  1. Start -> 检查用户是否登录。
  2. CheckUser -> 如果没登录,结束。
  3. CheckCredit -> 如果信用分 < 60,结束。
  4. GiveCoupon -> 发送优惠券,结束。

1. 定义流程(存储在文件里,或者JSON里)

假设我们有一个 flow.json

{
  "nodes": {
    "start": {
      "id": "start", "name": "开始", "type": "start"
    },
    "check_login": {
      "id": "check_login", "name": "检查登录", "type": "condition",
      "script": "return isset($context['user_id']) && $context['user_id'] > 0;"
    },
    "check_credit": {
      "id": "check_credit", "name": "检查信用", "type": "condition",
      "script": "return $context['credit_score'] >= 60;"
    },
    "send_coupon": {
      "id": "send_coupon", "name": "发放优惠券", "type": "end",
      "config": { "coupon_code": "VIP_GIFT_2023" }
    },
    "reject": {
      "id": "reject", "name": "拒绝", "type": "end"
    }
  },
  "edges": [
    { "from": "start", "to": "check_login", "script": "return true;" },
    { "from": "check_login", "to": "check_credit", "script": "$context['passed_login'] = true; return true;" },
    { "from": "check_login", "to": "reject", "script": "return false;" },
    { "from": "check_credit", "to": "send_coupon", "script": "return true;" },
    { "from": "check_credit", "to": "reject", "script": "return false;" }
  ]
}

2. 加载并运行

// 1. 实例化引擎
$engine = new DynamicWorkflowEngine();

// 2. 解析 JSON 并构建节点图
$rawData = json_decode(file_get_contents('flow.json'), true);

foreach ($rawData['nodes'] as $n) {
    $node = new WorkflowNode($n['id'], $n['name'], $n['type']);
    if (isset($n['script'])) $node->scriptContent = $n['script'];
    if (isset($n['config'])) $node->config = $n['config'];
    $engine->addNode($node);
}

// 3. 构建边(转换)
foreach ($rawData['edges'] as $e) {
    $source = $engine->nodes[$e['from']];
    $target = $engine->nodes[$e['to']];

    $transition = new WorkflowTransition();
    $transition->sourceId = $e['from'];
    $transition->targetId = $e['to'];
    $transition->scriptContent = $e['script'];

    $source->transitions[] = $transition;
}

// 4. 设置起始点并执行
$context = [
    'user_id' => 12345,
    'credit_score' => 55, // 故意设低点
    'amount' => 500
];

$engine->start('start');
$result = $engine->execute($context);

print_r($result);

输出结果:

Array
(
    [user_id] => 12345
    [credit_score] => 55
    [amount] => 500
    [passed_login] => 1
    [history] => Array
        (
            [0] => Array
                (
                    [node] => start
                    [timestamp] => 1698765432
                )
            [1] => Array
                (
                    [node] => check_login
                    [timestamp] => 1698765432
                )
            [2] => Array
                (
                    [node] => check_credit
                    [timestamp] => 1698765432
                )
        )
)

你看,系统根据你传入的 credit_score,自动走了 check_credit -> reject 的路。你不需要改一行PHP代码,只需要改那个JSON文件里的数字或者脚本,整个流程就变了!

第六章:高级玩法——异步脚本与并发

上面的例子是同步的。但在真实世界里,工作流节点可能需要“等待”。比如“等待第三方支付回调”。如果是同步等待,你的服务器线程就死锁了。

我们需要引入异步任务。PHP处理异步的最佳方式是配合 消息队列

6.1 消息队列驱动的脚本执行

我们修改一下 ScriptSandbox。当检测到脚本里包含 sleep() 或者是一个耗时操作时,我们不直接执行,而是把它扔给队列。

或者更简单,我们允许脚本返回一个 Promise 或者特殊的指令。

让我们升级一下 WorkflowNode

class AsyncWorkflowNode extends WorkflowNode {
    public bool $isAsync = false;
    public array $asyncTasks = []; // 存储待执行的任务

    public function runAsync(array $context, callable $callback) {
        if (!$this->isAsync) {
            return $callback($this->run($context));
        }

        // 模拟异步处理
        $taskId = uniqid();
        $task = [
            'node_id' => $this->id,
            'node_name' => $this->name,
            'params' => $context,
            'callback' => $callback
        ];

        // 这里是伪代码,实际应该扔到 Redis/RabbitMQ
        $this->asyncTasks[$taskId] = $task;
        echo "Task $taskId dispatched to queue.n";

        // 返回一个假的 ID
        return $taskId;
    }
}

在主循环中,我们可以每隔1秒检查一次队列,看看有没有任务完成了。

// 模拟队列消费者
$queue = [];

while (true) {
    // 检查是否有完成的任务
    foreach ($queue as $key => $task) {
        if (taskIsDone($task['node_id'])) {
            // 任务完成,触发回调
            $result = $task['node']->run($task['params']);
            $task['callback']($result);
            unset($queue[$key]);
        }
    }

    // 继续下一个节点
    $nextNodeId = findNextNode($currentNode);

    sleep(1);
}

这种方式下,你的工作流引擎可以处理“发货等待”、“等待人工审核”这种长时间阻塞的操作,而不会让你的浏览器转圈圈。

第七章:性能与安全——别让你的引擎变成僵尸

讲了这么多功能,最后我们必须谈谈怎么不把服务器搞崩。

7.1 脚本超时控制

如果业务人员在脚本里写了个 while(true),你的引擎就挂了。必须在 ScriptSandbox 里加一个计时器。

function executeWithTimeout(callable $code, int $seconds) {
    $start = time();
    $result = null;
    $error = null;

    // 使用 pcntl_alarm 或者 设置 set_time_limit
    // 在 Web 环境下 set_time_limit 可能被 php.ini 禁用,但 CLI 模式下有效

    // 最安全的做法是创建一个独立的进程
    $proc = proc_open(
        "php -r '".addslashes($code)."'",
        [["pipe", "r"], ["pipe", "w"], ["pipe", "w"]],
        $pipes
    );

    $exitCode = proc_close($proc);

    if ($exitCode !== 0) {
        throw new RuntimeException("Process timeout or error");
    }

    return $result;
}

7.2 资源限制

脚本能不能 ini_set('memory_limit', '1G')?不能。能不能执行系统命令?不能。我们之前提到的白名单策略必须非常严格。只允许 json_decode,不允许 exec

第八章:进阶技巧——嵌入外部脚本文件

有时候,业务逻辑太复杂,写在JSON里都看不清。这时候,我们可以让脚本文件指向一个 .php 文件。

比如:

{
  "transition": {
    "to": "check_credit",
    "script_file": "scripts/credit_checker.php" 
  }
}

引擎加载时,如果是文件路径,就去 include 它。

scripts/credit_checker.php:

<?php
// 这个文件不需要写 <?php 开始标签,直接写逻辑即可
// 它可以访问全局的 $context

// 复杂逻辑示例
$amount = $context['amount'];
$credit = $context['credit_score'];

if ($amount > 10000) {
    return $credit > 80; // 金额大,信用要高
}
return $credit > 60; // 金额小,信用要求低

这样做的好处是,IDE可以自动补全,语法检查器可以报错,业务人员也能看得懂(如果他们有PHP基础)。

第九章:总结——技术没有绝对的对错

好了,朋友们,我们造了一个“简单”的工作流引擎。

这个系统虽然简单,但它涵盖了PHP动态脚本执行的核心:

  1. 定义与执行分离:流程定义在配置里,逻辑在脚本里。
  2. 沙箱隔离:防止恶意的 unlink('/')
  3. 扩展性:通过闭包和文件引用,无限扩展规则。
  4. 异步支持:通过队列将阻塞操作解耦。

当然,现实世界的挑战远比这个复杂。你需要处理节点并发、分布式锁、事务回滚、复杂的表单验证。你可能需要引入 EasySwoole 或者 Swoole 来把你的脚本引擎升级为基于协程的高性能系统。

但记住,不要一开始就造火箭。先从 eval(好吧,是沙箱执行)开始,先让业务人员能改规则,不要改代码。当你发现 eval 太慢了,或者太危险了,再花几个月时间去优化它。

写代码就像谈恋爱,有时候你需要那种简单粗暴的 if-else,有时候你也需要那种优雅的 assert 和闭包。找到平衡点,保持幽默感,别让你的逻辑变成堆满废纸的办公桌。

现在,去吧,去重构你那个满是 switch-case 的老系统吧!如果它炸了,记得那是你自己选的。祝你好运!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注