ThinkPHP5 引入 Go AOP

項目背景

目前開發的WEB軟件裏有這一個功能,PHP訪問API操做數據倉庫,剛開始數據倉庫小,沒發現問題,隨着數據愈來愈多,調用API時常超時(60s)。因而決定採用異步請求,改成60s能返回數據則返回,不能則返回一個異步ID,而後輪詢是否完成統計任務。因爲項目緊,人手不足,必須以最小的代價解決當前問題。php

方案選擇

  1. 重新分析需求,並改進代碼
  2. 採用AOP方式改動程序

重新作需求分析,以及詳細設計,並改動代碼,須要產品,架構,前端,後端的支持。會驚動的人過多,在資源緊張的狀況下是不推薦的。
採用AOP方式,不改動原有代碼邏輯,只須要後端就能完成大部分任務了。後端用AOP切入請求API的方法,經過監聽API返回的結果來控制是否讓其繼續運行原有的邏輯(API在60s返回了數據),或者是進入離線任務功能(API報告統計任務不能在60s內完成)。html

實際環境

Debian,php-fpm-7.0,ThinkPHP-5.10。前端

引入AOP

做爲一門zui好的語言,PHP是不自帶AOP的。那就得安裝AOP-PHP拓展,當我打開pecl要下載時,傻眼了,全是bate版,沒有顯示說明支持php7。但我仍是抱着僥倖心理,找到了git,發現4-5年沒更新了,要不要等一波更新,哦,做者在issue裏說了有時間就開始兼容php7。
好吧,狠話很少說,下一個方案:Go!AOP.看了下git,做者是個穿白體恤,喜歡山峯的大帥哥,基本每一個issue都會很熱心回覆。nginx

composer require goaop/framework

ThinkPHP5 對composer兼容挺不錯的哦,(到後面,我真想揍ThinkPHP5做者)這就裝好了,怎麼用啊,git上的提示了簡單用法。我也就照着寫了個去切入controller。git

<?PHP
namespace app\tests\controller;

use think\Controller;

class Test1 extends Controller
{
    public function test1()
    {
        echo $this->aspectAction();
    }
    
    public function aspectAction()
    {
        return 'hello';
    }
}

定義aspectgithub

<?PHP
namespace app\tests\aspect;

use Go\Aop\Aspect;
use Go\Aop\Intercept\FieldAccess;
use Go\Aop\Intercept\MethodInvocation;
use Go\Lang\Annotation\After;
use Go\Lang\Annotation\Before;
use Go\Lang\Annotation\Around;
use Go\Lang\Annotation\Pointcut;

use app\tests\controller\Test1;

class MonitorAspect implements Aspect
{

    /**
     * Method that will be called before real method
     *
     * @param MethodInvocation $invocation Invocation
     * @Before("execution(public|protected app\tests\controller\Test1->aspectAction(*))")
     */
    public function beforeMethodExecution(MethodInvocation $invocation)
    {
        $obj = $invocation->getThis();
        echo 'Calling Before Interceptor for method: ',
             is_object($obj) ? get_class($obj) : $obj,
             $invocation->getMethod()->isStatic() ? '::' : '->',
             $invocation->getMethod()->getName(),
             '()',
             ' with arguments: ',
             json_encode($invocation->getArguments()),
             "<br>\n";
    }
}

啓用aspectdocker

<?PHP
// file: ./application/tests/service/ApplicationAspectKernel.php

namespace app\tests\service;

use Go\Core\AspectKernel;
use Go\Core\AspectContainer;

use app\tests\aspect\MonitorAspect;

/**
 * Application Aspect Kernel
 *
 * Class ApplicationAspectKernel
 * @package app\tests\service
 */
class ApplicationAspectKernel extends AspectKernel
{

    /**
     * Configure an AspectContainer with advisors, aspects and pointcuts
     *
     * @param AspectContainer $container
     *
     * @return void
     */
    protected function configureAop(AspectContainer $container)
    {
        $container->registerAspect(new MonitorAspect());
    }
}

go-aop 核心服務配置thinkphp

<?PHP
// file: ./application/tests/behavior/Bootstrap.php
namespace app\tests\behavior;

use think\Exception;
use Composer\Autoload\ClassLoader;
use Go\Instrument\Transformer\FilterInjectorTransformer;
use Go\Instrument\ClassLoading\AopComposerLoader;
use Doctrine\Common\Annotations\AnnotationRegistry;

use app\tests\service\ApplicationAspectKernel;
use app\tests\ThinkPhpLoaderWrapper;

class Bootstrap
{
    public function moduleInit(&$params)
    {
        $applicationAspectKernel = ApplicationAspectKernel::getInstance();
        $applicationAspectKernel->init([
            'debug' =>  true,
            'appDir'    =>  __DIR__ . './../../../',
                'cacheDir'  =>  __DIR__ . './../../../runtime/aop_cache',
                'includePaths'  =>  [
                    __DIR__ . './../../tests/controller',
                    __DIR__ . './../../../thinkphp/library/think/model'
                ],
                'excludePaths'  =>  [
                    __DIR__ . './../../aspect',
                ]
            ]);
        return $params;
    }
}

配置模塊init鉤子,讓其啓動 go-aopshell

<?PHP
// file: ./application/tests/tags.php
// 因爲是thinkphp5.10 沒有容器,全部須要在module下的tags.php文件裏配置調用他

return [
    // 應用初始化
    'app_init'     => [],
    // 應用開始
    'app_begin'    => [],
    // 模塊初始化
    'module_init'  => [
        'app\\tests\\behavior\\Bootstrap'
    ],
    // 操做開始執行
    'action_begin' => [],
    // 視圖內容過濾
    'view_filter'  => [],
    // 日誌寫入
    'log_write'    => [],
    // 應用結束
    'app_end'      => [],
];

兼容測試

好了,訪問 http://127.0.0.1/tests/test1/... 顯示:json

hello

這不是預期的效果,在aspect定義了,訪問該方法前,會輸出方法的更多信息信息。
像以下內容纔是預期

Calling Before Interceptor for method: app\tests\controller\Test1->aspectAction() with arguments: []

上他官方Doc看看,是一些更高級的用法。沒有講go-aop的運行機制。
上git上也沒看到相似issue,額,發現做者常常在issue裏回覆:試一試demo。也許我該試試demo。

Run Demos

我採用的是LNMP技術棧。

  1. 假設這裏有臺Ubuntu你已經配置好了LNMP環境
  2. 下載代碼
  3. 配置nginx
# file: /usr/share/etc/nginx/conf.d/go-aop-test.conf
server {
    listen 8008;
#    listen 443 ssl;
    server_name 0.0.0.0;
    root "/usr/share/nginx/html/app/vendor/lisachenko/go-aop-php/demos";
    index index.html index.htm index.php;
    charset utf-8;

    access_log /var/log/nginx/go-aop-access.log;
    error_log  /var/log/nginx/go-aop-error.log notice;

    sendfile off;
    client_max_body_size 100m;

    location ~ \.php(.*)$ {
        include                         fastcgi_params;
        fastcgi_pass                     127.0.0.1:9000;
        fastcgi_index                     index.php;

        fastcgi_param                      PATH_INFO      $fastcgi_path_info;
#        fastcgi_param                   SCRIPT_FILENAME /var/www/html/app/vendor/lisachenko/go-aop-php/demos$fastcgi_script_name;  #docker的配置
        fastcgi_param                      SCRIPT_FILENAME /usr/share/nginx/html/api/vendor/lisachenko/go-aop-php/demos$fastcgi_script_name;
        fastcgi_param                      PATH_TRANSLATED $document_root$fastcgi_path_info;
        fastcgi_split_path_info          ((?U).+\.php)(/?.+)$;
    }
}

接下來要調整下代碼

  1. 訪問 http://127.0.0.1:8008 試試,(估計你們都遇到了這個)

clipboard.png

  1. 這個報錯信息提示找不到這個類。來到報錯的文件裏。這文件使用了use找不到類,就是autoload出問題了,看到 vendor/lisachenko/go-aop-php/demos/autoload.php 這個文件。
<?PHP
···
if (file_exists(__DIR__ . '/../vendor/autoload.php')) {
    /** @var Composer\Autoload\ClassLoader $loader */
    $loader = include __DIR__ . '/../vendor/autoload.php';
    $loader->add('Demo', __DIR__);
}

能夠看到這個代碼第一行沒找到vendor下的autoload。咱們作以下調整

<?PHP
$re = __DIR__ . '/../../../vendor/autoload.php';
if (file_exists(__DIR__ . '/../../../autoload.php')) {
    /** @var Composer\Autoload\ClassLoader $loader */
    $loader = include __DIR__ . '/../../../autoload.php';
    $loader->add('Demo', __DIR__);
}

再試試,demo運行起來了。

clipboard.png
嘗試了下,運行成功

clipboard.png
經過以上的輸出,能夠得出demo裏是對方法運行前成功捕獲。爲何在thinkphp的controller裏運行就不成功呢。我決定採用斷點進行調試。

經過斷點我發現了這個文件

<?PHP
// file: ./vendor/lisachenko/go-aop-php/src/Instrument/ClassLoading/AopComposerLoader.php

public function loadClass($class)
{
    if ($file = $this->original->findFile($class)) {
        $isInternal = false;
        foreach ($this->internalNamespaces as $ns) {
            if (strpos($class, $ns) === 0) {
                $isInternal = true;
                break;
            }
        }

        include ($isInternal ? $file : FilterInjectorTransformer::rewrite($file));
    }
}

這是一個autoload,每一個類的載入都會通過它,而且會對其判斷是否爲內部類,不是的都會進入後續的操做。經過斷點進入 FilterInjectorTransformer,發現會對load的文件進行語法解析,並根據註冊的annotation對相關的類生成proxy類。說道這,你們就明白了go-aop是如何作到切入你的程序了吧,生成的proxy類,能夠在你配置的cache-dir(我配置的是./runtime/aop_cache/)裏看到。

同時./runtime/aop_cache/ 文件夾下也生成了不少東西,經過查看aop_cache文件內產生了與Test1文件名相同的文件,打開文件,發現它代理了原有的Test1控制器。這一系列信息,能夠得出,Go!AOP 經過"劫持" composer autoload 讓每一個類都進過它,根據aspect的定義來決定是否爲其建立一個代理類,並植入advice。
額,ThinkPHP5是把composer autoload裏的東西copy出來,放到本身autoload裏,而後就沒composer啥事了。而後go-aop一直等不到composer autoload下發的命令,天然就不能起做用了,so,下一步

改進ThinkPHP5

在ThinkPHP5裏,默認有且只會註冊一個TP5內部的 Loader,並不會把include請求下發給composer的autoload。因此,爲其讓go-aop起做用,那麼必須讓讓include class的請求通過 AopComposerLoad.
咱們看看這個文件

<?PHP
// ./vendor/lisachenko/go-aop-php/src/Instrument/ClassLoading/AopComposerLoader.php:57

public static function init()
{
    $loaders = spl_autoload_functions();

    foreach ($loaders as &$loader) {
        $loaderToUnregister = $loader;
        if (is_array($loader) && ($loader[0] instanceof ClassLoader)) {
            $originalLoader = $loader[0];

            // Configure library loader for doctrine annotation loader
            AnnotationRegistry::registerLoader(function ($class) use ($originalLoader) {
                $originalLoader->loadClass($class);

                return class_exists($class, false);
            });
            $loader[0] = new AopComposerLoader($loader[0]);
        }
        spl_autoload_unregister($loaderToUnregister);
    }
    unset($loader);

    foreach ($loaders as $loader) {
        spl_autoload_register($loader);
    }
}

這個文件裏有個類型檢測,檢測autoload callback是否爲Classloader類型,然而ThinkPHP5不是,經過斷點你會發現ThinkPHP5是一個字符串數組,so,這裏也就沒法把go-aop註冊到class loader的callback當中了。

這裏就要提一下PHP autoload機制了,這是現代PHP很是重要的一個功能,它讓咱們在用到一個類時,經過名字能自動加載文件。咱們經過定義必定的類名規則與文件結構目錄,再加上能實現以上規則的函數就能實現自動加載了。在經過 spl_autoload_register 函數的第三個參數 prepend 設置爲true,就能讓其排在在TP5的loader前面,先一步被調用。

依照如上原理,就能夠作以下改進
這個是爲go-aop包裝的新autoload,本質上是在原來的ThinkPHP5的loader上加了一個殼而已。

<?PHP
// file: ./application/tests 

namespace app\tests;

require_once __DIR__ . './../../vendor/composer/ClassLoader.php';

use think\Loader;
use \Composer\Autoload\ClassLoader;
use Go\Instrument\Transformer\FilterInjectorTransformer;
use Go\Instrument\ClassLoading\AopComposerLoader;
use Doctrine\Common\Annotations\AnnotationRegistry;


class ThinkPhpLoaderWrapper extends ClassLoader
{
    static protected $thinkLoader = Loader::class;

    /**
     * Autoload a class by it's name
     */
    public function loadClass($class)
    {
        return Loader::autoload($class);
    }

    /**
     * {@inheritDoc}
     */
    public function findFile($class)
    {
        $allowedNamespace = [
            'app\tests\controller'
        ];
        $isAllowed = false;
        foreach ($allowedNamespace as $ns) {
            if (strpos($class, $ns) === 0) {
                $isAllowed = true;
                break;
            }
        }
        // 不容許被AOP的類,則不進入AopComposer
        if(!$isAllowed)
            return false;
        
        $obj = new Loader;
        $observer = new \ReflectionClass(Loader::class);

        $method = $observer->getMethod('findFile');
        $method->setAccessible(true);
        $file = $method->invoke($obj, $class);
        return $file;
    }
}
<?PHP
// file: ./application/tests/behavior/Bootstrap.php 在剛剛咱們新添加的文件當中
// 這個方法 \app\tests\behavior\Bootstrap::moduleInit 的後面追加以下內容

// 組成AOPComposerAutoLoader
$originalLoader = $thinkLoader = new ThinkPhpLoaderWrapper();
AnnotationRegistry::registerLoader(function ($class) use ($originalLoader) {
    $originalLoader->loadClass($class);

    return class_exists($class, false);
});
$aopLoader = new AopComposerLoader($thinkLoader);
spl_autoload_register([$aopLoader, 'loadClass'], false, true);

return $params;

在這裏咱們作了一個autload 並直接把它插入到了最前面(若是項目內還有其餘autloader,請注意他們的前後順序)。

最後

如今咱們再訪問一下 http://127.0.0.1/tests/test1/... 你就能看到來自 aspect 輸出的信息了。
最後咱們作個總結:

  1. PHP7 目前沒有拓展實現的 AOP
  2. ThinkPHP5 有着本身的 Autoloader
  3. Go!AOP 的AOP實現依賴Class Autoloadcallback,經過替換原文件指向Proxy類實現。
  4. ThinkPHP5 整合 Go!AOP 須要調整 autoload
相關文章
相關標籤/搜索