PHP 實現字符串表達式計算

什麼是字符串表達式?即,將我們常見的表達式文本寫到了字符串中,如:"$age >= 20"$age 的值是動態的整型變量。

什麼是字符串表達式計算?即,我們需要一段程序來執行動態的表達式,如給定一個含表達式的字符串變量並計算其結果,而表達式字符串是動態的,比如爲客戶A執行的表達式是 $orderCount >= 10,而爲客戶B執行的表達式是 $orderTotal >= 1000

場景在哪兒?同一份程序具有完全通用性,但差異就其中一個表達式而已,那麼我們需要將其抽象出來,讓表達式變成動態的、可配置的、或可生成的。

方案一:eval 函數

eval 函數可能是我們第一個想到的方案,也是最簡單直接的方案。我們來試驗下:

$a = 10;
var_dump(eval('return $a > 5;'));

// 輸出:
// bool(true)

嗯~完全能滿足我們的需求,因爲 eval 函數執行的 PHP 表達式,只要字符串內表達式符合 PHP 語法就行。

但需注意的是,eval 函數可執行任意 PHP 代碼,也就意味着權限大、風險高、不安全。如果你的字符串表達式來自於外部輸入,那務必注意了請自行做好安全檢查和過濾,並考慮風險。當然,執行的是外部輸入表達式,非常不建議使用此函數。

方案二:include 臨時文件

如何實現?將字符串表達式寫入一個臨時文件,然後 include 這個臨時文件,執行完成後再刪除這個臨時文件。

方案依然很簡單。需要考慮的有:

  • 臨時文件會很多,一個請求就有很多個,文件的過期和刪除務必考慮在內
  • 文件的讀寫,也就牽扯到了磁盤 IO,那性能必定受到嚴重影響

那這個方案我們還採用嗎?

方案三:assert 斷言

其實 assert 做不到字符串表達式的計算,但提出來也算個猜想,因爲能實現 PHP 表達式是否合法的校驗。

下例演示瞭如何驗證某個字符串表達式是否爲合法的 PHP 表達式:

try {
    assert('a +== 1');
} catch (Throwable $e) {
    echo $e->getMessage(), "\n";
}

運行結果:

Failure evaluating code: 
a +== 1

可依然面臨一個問題,那就是安全性,因爲與 eval 一樣能執行任意代碼。所以,從 PHP 7.2 開始就不可以再執行字符串類型的表達式了。關於 PHP assert 斷言,可參考 你所不知的 PHP 斷言(assert)

方案四:system/exec 函數

systemexecproc_openshell_execpassthru 等系列函數,本質上都是執行外部命令或腳本,以達到執行 PHP 代碼的效果,與 include 實現類似,雖能實現但不安全

system('php -r "echo 1 + 2;"');

echo exec('php -r "echo 1 + 2;"');

方案五:create_function 函數

create_function 函數是匿名函數的前生臨時替代品,雖然現今還未廢棄。作用是什麼呢?允許用字符串創建一個 lambda 風格的匿名函數。

函數語法定義:

create_function ( string $args , string $code ) : string

使用示例:

$newfunc = create_function('$a, $b', 'var_dump($a, $b); return $a === $b;');

var_dump($newfunc(1, 2));

示例輸出:

int(1)
int(2)
bool(false)

發現完全能實現我們的場景需求~但是又來了,這個函數不安全。爲什麼呢?看下手冊中的 Caution:

This function internally performs an eval() and as such has the same security issues as eval(). Additionally it has bad performance and memory usage characteristics.

If you are using PHP 5.3.0 or newer a native anonymous function should be used instead.

create_function 函數底層走的是 eval 函數,所以面臨着與 eval 一樣的安全問題。並且,create_function 函數性能低下、佔用內存高。而這函數最初就是爲了匿名函數而生的,從 PHP 5.3.0 開始就內置實現了匿名函數,所以通過 create_function 去創建 lambda 風格自定義函數就毫無存在的必要了。

方案六:include 文件流

爲何又是 include

我們從官方手冊中瞭解到,include 語句用於包含並運行指定文件,並且支持遠程文件,比如 include 'http://www.example.com/file.php?foo=1&bar=2';

我們還從手冊中能找到這句話:

如果“URL include wrappers”在 PHP 中被激活,可以用 URL(通過 HTTP 或者其它支持的封裝協議——見支持的協議和封裝協議)而不是本地文件來指定要被包含的文件。

此時,我們是否想起了熟悉的 php://inputscheme://... 風格內置或自定義的URL封裝協議。而這些協議都有個特點,即可用於類似 fopen()file_exists()file_get_contents() 的文件系統函數打開。include 讀取文件其實與這些函數是一致的。

那我們就可以使用 stream_wrapper_register() 來註冊一個用 PHP 類實現的 URL 封裝協議。該函數允許用戶實現自定義的協議處理器和流,用於所有其它的文件系統函數中(例如 fopen()fread() 等)。關於如何實現並註冊一個 Stream Wrapper,可參考官方手冊,本文僅提供個最簡單的示例,來實現字符串表達式的計算。

class VarStream
{
    private $string;
    private $position;

    public function stream_open($path, $mode, $options, &$opened_path)
    {
        $path = explode('://', $path, 2)[1];

        // 此處可對傳入的參數進行自定義解析,並作進一步的操作
        $this->string = $path;
        $this->position = 0;
        return true;
    }

    public function stream_read($count)
    {
        $ret = substr($this->string, $this->position, $count);
        $this->position += strlen($ret);
        return $ret;
    }

    public function stream_eof() {}

    public function stream_stat() {}

}

stream_wrapper_register("var", "VarStream");

try {

    $params = ['count' => 1];
    $expression = '($count += 111) - 8';
    $result = include 'var://<?php extract($params); return ' . $expression . ';';
    var_dump($result);

} catch (Throwable $t) {
    echo $t->getMessage();
}

輸出結果:

int(104)

方案七:語法解析

這個方案就比較高大上許多,當然實現方式也難了太多。具體就是自己寫個語法解析器,將代碼字符串解析成 AST 語法樹,然後再把語法樹的內容計算成最終的值。

怎麼實現呢?不用我們自己再去寫了,已經有大佬寫好了。當然,如果對 AST 語法解析感興趣,那學習下如何實現是最好不過的了,會解析語法也就意味着可以自己寫門語言了呀 😆

GitHub 中比較有名的 PHP 實現如下 2 個,很多代碼靜態分析器都是基於這 2 個庫開發的。

我們來看個 nikic/php-parser 的例子:

<?php
use PhpParser\Error;
use PhpParser\NodeDumper;
use PhpParser\ParserFactory;

$code = <<<'CODE'
<?php

function test($foo)
{
    var_dump($foo);
}
CODE;

$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
try {
    $ast = $parser->parse($code);
} catch (Error $error) {
    echo "Parse error: {$error->getMessage()}\n";
    return;
}

$dumper = new NodeDumper;
echo $dumper->dump($ast) . "\n";

示例輸出:

array(
    0: Stmt_Function(
        byRef: false
        name: Identifier(
            name: test
        )
        params: array(
            0: Param(
                type: null
                byRef: false
                variadic: false
                var: Expr_Variable(
                    name: foo
                )
                default: null
            )
        )
        returnType: null
        stmts: array(
            0: Stmt_Expression(
                expr: Expr_FuncCall(
                    name: Name(
                        parts: array(
                            0: var_dump
                        )
                    )
                    args: array(
                        0: Arg(
                            value: Expr_Variable(
                                name: foo
                            )
                            byRef: false
                            unpack: false
                        )
                    )
                )
            )
        )
    )
)

由此,我們可以任意的實現我們所需的,也不用擔心安全性問題。

最後,總結下。我們嘗試了很多種方法,都能解決我們或多或少的場景需求,但哪個最適合需要我們自己去考量,但思路值得我們去深入探討。


感謝您的閱讀,覺得內容不錯,點個贊吧 😆

原文地址: https://shockerli.net/post/ph...
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章