Laravel 動態添加 Artisan 命令的最佳實踐

本文首發於個人博客,原文連接:https://blessing.studio/best-...php

雖然 Laravel 官方文檔提供的添加 Artisan Command 的方法是直接修改 app/Console/Kernel.php 文件並在 $commands 屬性中註冊要添加的 Artisan 命名的類名(Laravel 服務容器會自動解析),可是,若是咱們出現須要「動態(運行時)添加 Artisan 命令」的需求的話,就會很容易吃癟。由於,Laravel 的文檔(固然,我說的是官網上的)幾乎沒有提到任何關於這方面的內容。laravel

這也是我爲何老是吐槽 Laravel 文檔有些地方很爛的緣由 —— 不少時候你爲了實現一個文檔裏沒提到的功能,須要去翻半天 Laravel 的框架源碼才能找到解決方法(我博客的 Laravel 標籤 下已經有很多這樣的踩坑文了)。雖然 Laravel 框架的源碼很優雅,看着也不會難受,可是在一堆文件中跳來跳去尋找邏輯浪費腦細胞的行爲仍是能省則省吧 :(bootstrap

此次要實現的功能是在運行時動態加載自定義的 Artisan Command(更詳細一些的需求就是在皮膚站的一個插件中註冊 Artisan 命令,Laravel 插件系統的實現能夠參考我以前的 另外一篇文章)。閉包

TL;DR 太長不看

總之先上乾貨,畢竟不是全部人都喜歡聽我廢話一大堆後纔拿到解決方案的。app

Laravel 5.3 及以上:框架

Artisan::starting(function ($artisan) {
    // 傳入類名字符串便可,會被服務容器自動解析
    $artisan->resolve('Example\FooCommand');
    // 批量添加
    $artisan->resolveCommands([
        'Example\FuckCommand',
        'Example\ShitCommand'
    ]);
    // 參數必須爲 Symfony\Component\Console\Command\Command 的實例
    // 繼承自 Illuminate\Console\Command 的類實例也能夠
    $artisan->add($command);
});

Laravel 5.2:less

Event::listen('Illuminate\Console\Events\ArtisanStarting', function ($event) {
    // 其餘用法同上
    $event->artisan->resolve('Example\BarCommand');
});

Laravel 5.1:ide

Event::listen('artisan.start', function ($event) {
    // 其餘用法同上
    $event->artisan->resolve('Example\WtfCommand');
});

接下來就是我摸索時嘗試的步驟,寫下來權當記錄水博文,發了發牢騷,有興趣的就繼續看下去吧。this

0x01 初步嘗試

既然 Laravel 最多見的註冊 Artisan 命令的方式是修改 APP\Console\Kernel 類中的 $commands,那麼通常正常人都會從這邊開始下手。能夠看到,這個類是繼承自 Illuminate\Foundation\Console\Kernel 類並覆寫了 $commands 屬性。讓咱們稍微看一下這個 $commands 屬性用在哪了:spa

/**
 * Get the Artisan application instance.
 *
 * @return \Illuminate\Console\Application
 */
protected function getArtisan()
{
    if (is_null($this->artisan)) {
        return $this->artisan = (new Artisan($this->app, $this->events, $this->app->version()))
                            ->resolveCommands($this->commands);
    }

    return $this->artisan;
}

能夠看到,這個方法用單例模式實例化了一個 Artisan(ArtisanIlluminate\Console\Application 的別名),其中最重要的是調用了 Illuminate\Console\Application::resolveCommands 這個方法,而且將那個註冊了自定義 Artisan 命令的屬性給傳了進去。咱們跳轉到那個 resolveCommands 方法看一看……

/**
 * Add a command, resolving through the application.
 *
 * @param  string  $command
 * @return \Symfony\Component\Console\Command\Command
 */
public function resolve($command)
{
    return $this->add($this->laravel->make($command));
}

/**
 * Resolve an array of commands through the application.
 *
 * @param  array|mixed  $commands
 * @return $this
 */
public function resolveCommands($commands)
{
    $commands = is_array($commands) ? $commands : func_get_args();

    foreach ($commands as $command) {
        $this->resolve($command);
    }

    return $this;
}

代碼條理很清晰,挨個兒把那些 $commands 中的元素給丟進 Laravel 服務容器裏實例化以後,調用父類方法 Symfony\Component\Console\Application::add (是的,Laravel 用了不少不少 Symfony 的組件)添加到自身實例中,持引用以供以後的調用所需。

繼續翻看 Illuminate\Foundation\Console\Kernel 的源碼,能夠看到 Laravel 貼心地開放了一個 registerCommand 方法:

/**
 * Register the given command with the console application.
 *
 * @param  \Symfony\Component\Console\Command\Command  $command
 * @return void
 */
public function registerCommand($command)
{
    $this->getArtisan()->add($command);
}

那麼咱們要作的就是,在運行時中拿到 Kernel 的實例,而且經過調用 registerCommand 方法把咱們的自定義 Artisan 命令也給加進去。那麼咱們要怎樣才能拿到這個實例呢?

相信對 Laravel 有所瞭解的各位都會想到 —— 服務容器。

經過查閱 Laravel 命令行入口(根目錄下的 artisan 文件)源碼能夠知道,Laravel 就是使用服務容器來實例化 Kernel 的:

$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);

若是你有心的話,會發現 Laravel 框架的 Web 入口文件(public/index.php)和命令行入口文件中實例化 Kernel 的語句都是同樣的,那麼爲何經過 Web 訪問時解析出來的是 App\Http\Kernel 的實例而經過命令行訪問時解析出來的就是 App\Console\Kernel 的實例了呢?

這裏就涉及 Laravel 服務容器的一個強大的核心功能 —— 綁定接口至實現。由於這些實例都實現了相同的接口,因此咱們可使用相同的代碼而且很方便地更換接口後的具體實現,這也是使用 IoC 容器的好處之一,有興趣的多去了解了解吧 :)

閒話休提,那麼咱們只要經過服務容器就能夠拿到 Kernel 實例了(固然,若是你願意,你也能夠直接經過 $GLOBAL['kernel'] 來訪問全局做用域下定義的那個 $kernel 變量,效果都是同樣的,可是太 tmd lowb 了,因此我不肯意用),看起來已經離成功了一大半呢!

$kernel = app('Illuminate\Contracts\Console\Kernel');
// 由於 registerCommand 方法只接受 Symfony\Component\Console\Command\Command 的實例做爲參數
$kernel->registerCommand(app('Example\FooCommand'));

而後咱們執行一下 php artisan list,就能看到咱們的命令已經出現啦:

Laravel Framework version 5.2.45

Usage:
  command [options] [arguments]

Available commands:
  help           Displays help for a command
  list           Lists commands
  foo            Example command

可是等等……Laravel 自帶的那些 makemigrate 等命令哪裏去了?我最開始出現這個問題的時候還覺得是我太早把 Kernel 解析出來了,後來直接使用 $GLOBALS['kernel'] 也是同樣的問題時才認識到問題另有緣由。仔細閱讀源碼後發現 Artisan 命令行在調用(handlecall 等方法)以前都會調用這樣一個方法:

$this->bootstrap();

經過閱讀源碼能夠知道這個 bootstrap 方法就是用來加載 Laravel 框架的基本組件的,包括 Illuminate\Foundation\Providers\ArtisanServiceProvider 這個服務提供者中提供的全部框架內置 Artisan 命令。好在這個方法是 public 的,因此咱們只要在 registerCommand 以前調用一下這個方法就能夠啦:

$kernel = app('Illuminate\Contracts\Console\Kernel');
$kernel->bootstrap();
$kernel->registerCommand(app('Example\FooCommand'));

若是你願意,你甚至還能夠直接使用 Artisan 這個 Facade,由於它就是指向 Illuminate\Contracts\Console\Kernel 的:

Artisan::bootstrap();
Artisan::registerCommand(app('InsaneProfileCache\Commands\Clean'));

結果以下:

Screenshot

0x02 繼續嘗試

雖然這樣確實可以實現咱們的需求,可是我以爲這樣不行(話說我都不曉得嘻哈梗怎麼忽然就流行起來了,雖然確實蠻有意思的啦)。

我以爲不行

又要本身取出 Kernel 實例,又要本身調用 bootstrap 方法,調用 registerCommand 方法以前還有本身先把 Command 實例化……這麼繁瑣,確定不是運行時添加 Artisan 命令的最佳實踐,因此我決定繼續尋找更優解。

雖然咱們上面用的方法是取出 Kernel 實例並進行操做的,可是其實該方法裏的操做也是基於 getArtisan 所獲取的 Illuminate\Console\Application (?這玩意在 Laravel 源碼裏常常被 as 爲 Artisan)實例進行的。惋惜的是這個方法是 protected 的,咱們沒法直接調用它,因此咱們仍是先去看這個類的源碼吧:

/**
 * Create a new Artisan console application.
 *
 * @param  \Illuminate\Contracts\Container\Container  $laravel
 * @param  \Illuminate\Contracts\Events\Dispatcher  $events
 * @param  string  $version
 * @return void
 */
public function __construct(Container $laravel, Dispatcher $events, $version)
{
    parent::__construct('Laravel Framework', $version);

    $this->laravel = $laravel;
    $this->setAutoExit(false);
    $this->setCatchExceptions(false);

    $events->fire(new Events\ArtisanStarting($this));
}

瞧我發現了什麼?Artisan 在實例化以後會觸發一個 Illuminate\Console\Events\ArtisanStarting 事件,而且把自身實例給傳遞過去。那麼咱們要作的就很簡單了:監聽該事件,拿到 Artisan 實例,調用 resolveresolveCommands 方法來註冊咱們的 Artisan 命令便可。

具體的方法在最上面給出了,我這裏就很少說了。另外須要注意的是,Laravel 5.1 版本並無 ArtisanStarting 這個事件,而是 artisan.start,不過原理都是同樣的:

$events->fire('artisan.start', [$this]);

另外,在 Laravel 5.3 及以上版本中,Artisan 還貼心地提供了 Artisan::starting 這個方法,和監聽事件的效果差很少,不過是直接修改實例的 $bootstrappers 屬性的,傳遞一個閉包進去便可,示例代碼見最上方。

0x03 一些牢騷

雖然只要看源碼就能知道,Laravel 框架不少地方都預留了很是多的接口,讓咱們能夠方便優雅地實現不少自定義功能,這也是我爲何喜歡這個框架的緣由之一。

可是……可是,你的文檔就不能寫好一點嗎!哪怕提一下這些 API 也好啊!

相關文章
相關標籤/搜索