بهزاد شعبانی

توسعه دهنده پی‌اچ‌پی و لاراول

Command Bus در لاراول ۵

مدتی بود به خاطر درگیری‌ها و مشکلاتی که داشتم موفق یه نوشتن پست توی این دو سه هفته نشدم. امروز قصد دارم در مورد Command Bus براتون صحبت کنم.

مدتیه که Command Bus مورد توجه تعداد زیادی از توسعه‌دهندگان php قرار گرفته و مطالب زیادی در موردش نوشته شده. در جلسه پنجم لاراتاکز هم من در مورد Command Bus در لاراول ۵ و درمورد امکاناتی که لاراول در این زمینه در اختیارمون می‌ذاره صحبت کردم. اما Command Bus دقیقا چیه؟

Command Bus یک روش و دیدگاه برای پیاده سازه لایه Application در Domain Driven Design و یا اجرای معماری Hexagonical هست. در واقع با استفاده از این روش، لاجیک و منطق برنامه‌تون به وظایف کوچیک‌تر که قابلیت استفاده مجدد دارند، شکسته می‌شه. این کار باعث می‌شه کدهاتون خواناتر و منظم‌تر بشه و راحت‌تر تست بشه. البته باید این نکته رو در نظر داشته باشید که برای کسانی که با این روش آشنایی ندارن، و کد شما رو می‌خونن، Command Bus خیلی گیج‌کننده خواهد بود. بنابرین بهتره که همیشه کدهای خودتون رو داکیومنت کنید.

در ادامه مفصل‌تر در مورد Command Bus و استفاده اون در لاراول ۵ صحبت می‌کنیم.

کلیات Command Bus

Command Bus متشکل از سه قسمت اصلیه:

  • Command Data Object
  • Command Bus Core
  • Command Handler

همونطور که در مقدمه گفتم، در Command Bus لاجیک و منطق برنامه به وظایف کوچیک encapsulate شده، با قابلیت استفاده مجدد، شکسته می‌شه. اطلاعات مربوط به هر کدوم از این وظایف داخل یک کلاس Command قرار می‌گیرن. این Command به هسته اصلی Command Bus داده می‌شه تا به دست Handler متناسب اون Command برسه. در واقع اسم Command Bus از روی نحوه عملکردش گرفته شده، که مثل یه اتوبوس Commandها رو به مقصدشون یعنی handlerها می‌رسونه

در Command Bus این امکان رو دارید که از Decoratorها برای Commandهاتون استفاده کنید. این Decorateها قبل از رسیدن Command به Handler متناظرش، اجرا می‌شن. و پس از اجرا Command رو به handler می‌دن.

برای اینکه بهتر با این روش آشنا بشید، در ادامه استفاده Command Bus رو در لاراول ۵ توضیح می‌دم.

ساخت Command در لاراول ۵

برای ساخت Command در لاراول ۵، از دستور artisan لاراول استفاده می‌کنیم. قبل اینکه یک Command بسازیم، بهتره با جزئیات این دستور artisan آشنا بشیم. برای نمایش پارمترها و توضیحات این دستور، دستور زیر رو در ترمینال‌تون وارد کنید.

$ php artisan help make:command

نتیجه اجرای این دستور به شکل زیره:

Usage:
 make:command [--handler] [--queued] name

Arguments:
 name                  The name of the class

Options:
 --handler             Indicates that handler class should be generated.
 --queued              Indicates that command should be queued.
...

دستور make:command یک اسم برای Command مورد نظرتون و دو option اختیاری handler و queued می‌گیره. در مورد این دو option در ادامه مطلب بیشتر توضیح می‌دم.

توجه داشته باشید که این Command با Commandی که در لاراول ۴ وجود داشت متفاوته. Artisan Command ها در لاراول ۵ در پوشه app/Consolde/Commands قرار می‌گیرند.

برای شروع کار دستور زیر رو در ترمینال وارد کنید.

$ php artisan make:command RegisterUserCommand

با اجرای دستور بالا یک Command در مسیر app/Commands ساخته میشه. این Command برخلاف توضحیاتی که در بخش قبلی داده شد فقط یک Data Object ساده نیست، به طور پیش‌فرض تمامی Command هایی که با دستور بالا ساخته می‌شن اینترفیسی به نام SelfHandling رو پیاده‌سازی می‌کنن. این اینترفیس بیان‌گر اینه که Command خودش خودش رو handle می‌کنه.

SelfHandling Commands

وقتی یک Command به Command Bus تحویل داده میشه، Command Bus چک می‌کنه که این Command اینترفیس SelfHandling رو پیاده سازی کرده یا نه؛ اگر جواب مثبت بود متد handle داخل خود Command رو فراخونی می‌کنه، در غیر این صورت به دنبال کلاس handler متناسب با Command می‌گرده و متد handle اون رو فراخونی می‌کنه.

<?php  namespace Illuminate\Bus;

class Dispatcher implements DispatcherContract, QueueingDispatcher, HandlerResolver {

    // ...

    public function dispatchNow($command, Closure $afterResolving = null)
    {
        return $this->pipeline->send($command)
                             ->through($this->pipes)
                             ->then(function($command) use ($afterResolving) {
            if ($command instanceof SelfHandling)
                return $this->container->call([$command, 'handle']);

            $handler = $this->resolveHandler($command);

            if ($afterResolving)
                call_user_func($afterResolving, $handler);

            return call_user_func(
                [$handler, $this->getHandlerMethod($command)], $command
            );
        });
    }

    // ...
}

در بالا بخشی از کلاس Illuminate\Bus\Dispatcher که dispatch شدن یه Command رو به عهده داره، نمایش داده شده. در خط اول متد dispatchNow، می‌بینید که Command از طریق pipeهای تعریف شده برای Command مورد نظر به Pipeline ارسال شده و در نهایت یک Closure روی اون اجرا میشه. گیج کننده بود؟ اشکال نداره! جلوتر بیشتر درموردش توضیح می‌دم. فعلا به قسمت سوم یعنی اجرای Closure دقت کنید و توضیحاتی که بالاتر دادم رو با کد مرور کنید.

حالا که با مفاهیم SelfHandling Command آشنا شدیم، به مثال ثبت‌نام کاربر بپردازیم. Commandی که با هم ساختیم رو باز کنیم و تکمیلش کنیم:

<?php namespace App\Commands;

use App\Commands\Command;
use Illuminate\Contracts\Bus\SelfHandling;
use App\Repos\UserRepositoryInterface as UserRepository;

class RegisterUserCommand extends Command implements SelfHandling {

    /**
     * @var string
     */
    public $username;

    /**
     * @var string
     */
    public $password;

    /**
     * @var string
     */
    public $email;

    /**
     * Create a new command instance.
     *
     * @param string $username
     * @param string $password
     * @param string $email
     */
    public function __construct($username, $password, $email)
    {
        $this->username = $username;
        $this->password = $password;
        $this->email = $email;
    }

    /**
     * Execute the command.
     *
     * @param UserRepository $user
     */
    public function handle(UserRepository $user)
    {
        $user->create([
            'username' => $this->username,
            'password' => bcrypt($this->password),
            'email'    => $this->email
        ]);
    }

}

در متد construct اطلاعاتی که لازم داریم رو می‌گیریم و در آبجکت ذخیره می‌کنیم و در متد handle یک کاربر با اطلاعات ذخیره شده در آبجکت می‌سازیم. اگه دقت کنید می‌بینید که من یک پارامتر برای متد handle درنظر گرفتم. اگه برگردید بالاتر و به کلاس Illuminate\Bus\Dispatcher یه نگاه بندازید می‌بینید که این کلاس متد handle در Command رو، از طریق Application Container لاراول فراخونی کرده. متد call کلاس Container در سطح فراخونی متد، Dependency Injection انجام می‌ده، که به این کار Method Injection می‌گن که در لاراول ۵ معرفی شد. با توجه به این امکان، شما می‌تونید کلاس‌های وابسته برای انجام متد handle رو در زمان اجرا نمونه‌سازی و استفاده کنید.

ساخت Command با کلاس Handler جداگانه

اگه دوست دارید که طبق تعریفی در قسمت کلیات Command Bus گفتم، کلاس Command فقط یک Data Object باشه و دارای کلاس Handler جداگانه باشه، دستور زیر رو در ترمینالتون وارد کنید:

$ php artisan make:command --handler LogUserInCommand

با اجرای این دستور کلاس Command در مسیر app\Commands\LogUserInCommand.php و Handler متناظرش در مسیر app\Handlers\Commands\LogUserInCommandHandler.php ساخته می‌شن. حالا این دو کلاس رو باز کنیم و تکمیلش کنیم.

اول کلاس Command رو تکمیل می‌کنیم:

<?php namespace App\Commands;

use App\Commands\Command;

class LogUserInCommand extends Command {

    /**
     * @var string
     */
    public $username;

    /**
     * @var string
     */
    public $password;

    /**
     * Create a new command instance.
     *
     * @param string $username
     * @param string $password
     */
    public function __construct($username, $password)
    {
        $this->username = $username;
        $this->password = $password;
    }

    /**
     * Get user credentials
     * 
     * @return array
     */
    public function getCredentials()
    {
        return [
            'username' => $this->username,
            'password' => $this->password
        ];
    }

}

و بعد از اون کلاس Handler رو تکمیل می‌کنیم:

<?php namespace App\Handlers\Commands;

use App\Commands\LogUserInCommand;
use Illuminate\Contracts\Auth\Guard;
use App\Repos\UserRepositoryInterface as UserRepository;

class LogUserInCommandHandler {

    /**
     * @var Guard
     */
    protected $auth;

    /**
     * @var UserRepository
     */
    protected $user;

    /**
     * Create the command handler.
     *
     * @param Guard $auth
     */
    public function __construct(Guard $auth, UserRepository $user)
    {
        $this->auth = $auth;
        $this->user = $user;
    }

    /**
     * Handle the command.
     *
     * @param LogUserInCommand $command
     * @throws \Exception
     */
    public function handle(LogUserInCommand $command)
    {
        if (!$this->auth->attempt($command->getCredentials())) {
            throw new \Exception("Authentication Failed");
        }

        $this->auth->login(
            $this->user->getByCredentials($command->getCredentials())
        );
    }

}

از SelfHandling Command استفاده کنیم یا Command با Handler جداگانه؟

همینطور که در مثال‌ها دیدید، این دو روش فرق چندانی با هم ندارن. اما در چه مواقعی از کدوم روش استفاده کنیم؟

بحث‌هایی زیاده در این باره در جامعه php و لاراول شده. اما جواب ساده‌ست؛ از اون روشی استفاده کنید که خودتون باهاش راحت‌ترید. هیچکدوم از این دو روش مزیت خاصی نسبت به هم ندارن ولی چندتا نکته وجود داره. اول اینکه، روش SelfHandling به خاطر استفاده از Method Injection به هسته لاراول وابستگی داره و استفاده ازش خارج لاراول سخت میشه. دوم اینکه، خیلی‌ها معتقدن که روش SelfHandling اصل وظیفه واحد (Single Responsibility Principle) رو نقض می‌کنه. پس اگر دوست دارید به SRP پایبند باشید از SelfHandling استفاده نکنید. و اگر هم فکر می‌کنید که استفاده از handler مجزا کار اضافه و سربار روی سیستم شماست، از SelfHandling استفاده کنید.

توصیه می‌کنم با توجه به سایز برنامه تصمیم بگیرید، اگر سایز برنامه‌تون در مقیاس متوسط و بزرگ قرار داره بهتره که از handlerهای مجزا استفاده کنید. و اگر سایز برنامه در مقیاس کوچیک تا متوسط قرار می‌گیره از SelfHandling Commands استفاده کنید.

برای خوندن نظرات و بحث‌هایی که در این باره شده، لینک‌های زیر رو مطالعه کنید.

‏Dispatch کردن Command ها

همه Controllerها در لاراول از یک کلاس انتزاعی به نام Controller که در مسیر app/Http/Controllers/Controller.php قرار داره، به ارث می‌برن. این فایل رو باز کنید:

<?php namespace App\Http\Controllers;

use Illuminate\Foundation\Bus\DispatchesCommands;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Foundation\Validation\ValidatesRequests;

abstract class Controller extends BaseController {

    use DispatchesCommands, ValidatesRequests;

}

همونطور که می‌بینید این کلاس انتزاعی از یک Trait به نام DispatchesCommands استفاده کرده. این Trait سه متد زیر رو ارائه می‌ده:

  • dispatch
  • dispatchFromArray
  • dispatchFrom

بهتره با یه مثال کاربرد این متدها رو توضیح بدم:

<?php namespace App\Http\Controllers;

use App\Commands\RegisterUserCommand;
use Illuminate\Http\Request;

class DummyController extends Controller {

    public function simpleCommandDispatch()
    {
        try {
            $this->dispatch(
                new RegisterUserCommand('MyUsername', 'MySecurePassword', 'my.email@domain.com')
            );
        } catch (\Exception $e) {
            return $e->getMessage();
        }
    }

    public function dispatchCommandFromArray()
    {
        try {
            $this->dispatchFromArray(RegisterUserCommand::class, [
                'username' => 'MyUsername',
                'password' => 'MySecurePassword',
                'email'    => 'my.email@domain.com'
            ]);
        } catch (\Exception $e) {
            return $e->getMessage();
        }
    }

    public function dispatchCommandFromArrayAccessObjects(Request $request)
    {
        try {
            $this->dispatchFrom(RegisterUserCommand::class, $request);
        } catch (\Exception $e) {
            return $e->getMessage();
        }
    }

}

در متد simpleCommandDispatch ابتدا از Command نمونه سازی کردیم و بعد نمونه Command رو به متد dispatch دادیم. متد dispatch هم handler متناسب رو پیدا می‌کنه و command رو به اون تحویل می‌ده.

در متد dispatchCommandFromArray اسم کامل کلاس Command و آرایه‌ای از مقدار پارامترهای Command رو به متد dispatchFromArray دادیم. متد dispatchFromArray با استفاده از آرایه‌ای که بهش دادیم، یه نمونه از Command میسازه و اون رو به handler متناسب می‌رسونه.

در متد dispatchCommandFromArrayAccessObjects هم مثل روش قبلی، اسم کامل کلاس Command به همراه یک آبجکت که شامل مقادیر مد نظر برای Command هست و همچنین اینترفیس ArrayAccess رو پیاده‌سازی کرده به متد dispatchFrom دادیم. این متد Command رو با استفاده از آبجکت ورودی، می‌سازه و به handler متناسب می‌ده.

با استفاده از دو روش آخر، Dispatcher برای ساخت نمونه Command، هم به دنبال حالت snake_case مشابه پارامترها در اطلاعات داده شده، می‌گرده و هم به دنبال حالت camelCase. به همین خاطر شما بدون نگرانی از یک‌شکل بودن اسم متغیرها می‌تونید از Command Bus استفاده کنید.

<?php namespace App\Http\Controllers;

use App\Commands\DummyCommand;
use Illuminate\Http\Request;

class DummyController extends Controller {

    public function index(Request $request)
    {
        // let $request->all() be an array like this:
        // ['full_name' => 'John Doe', 'nick_name' => 'johny']
        // and LogDummyCommand parameters are $fullName and $nickName
        // the code below run perfectly
        $this->dispatchFrom(DummyCommand::class, $request);
    }

}

‏Dispatch کردن Command خارج از کنترلر‌ها

در لاراول ۵ می‌تونید Commandها رو هرجا که خواستین Dispatch کنید. برای این کار فقط لازمه که Illuminate\Contracts\Bus\Dispatche به Closure و یا کلاس مورد نظرتون Inject کنید.

استفاده در کلاس:

<?php namespace App\Somewhere;

use App\Commands\LogUserInCommand;
use Illuminate\Contracts\Bus\Dispatcher;

class DummyClass {

    protected $dispatcher;

    public function __construct(Dispatcher $dispatcher)
    {
        $this->dispatcher = $dispatcher
    }

    public function doSomeThingDummy()
    {
        $this->dispatcher->dispatch(
            new LogUserInCommand('myUsename', 'mySecurePassword')
        );
    }

}

استفاده در Closureها:

<?php

use Illuminate\Contracts\Bus\Dispatcher;

Route::get('dummy', function(Dispatcher $dispatcher) {
    $dispatcher->dispatch(
        new LogUserInCommand('myUsername', 'mySecurePassword')
    );
});

با استفاده از این روش به هر سه متدی که بالاتر در مورد Trait کنترلرها گفته شد، دسترسی دارید.

‏Queue کردن Commandها

یکی از ویژگی‌های خیلی خوب Command Bus در لاراول ۵ اینه که شما می‌تونید، اجرای یک Command رو Queue کنید. برای اینکار فقط کافیه که Command مورد نظرتون اینترفیس ShouldBeQueued رو پیاده‌سازی کنه. و یا با استفاده از دستور زیر یک Command با قابلیت Queue شدن بسازید:

$ php artisan make:command --queued DummyCommand

دستور بالا یک SelfHandling Command با قابلیت Queue شدن درست می‌کنه. در صورتیکه می‌خواین handler جداگانه داشته باشید می‌تونید گزینه handler— رو به دستور بالا اضافه کنید.

وقتی Command به Command Bus داده می‌شه، قبل از هر کاری چک می‌شه که آیا اینترفیس ShouldBeQueued پیاده‌سازی شده یا نه. در صورت مثبت بودن، Command رو داخل یک صف قرار میده تا بعدا و به صورت Async بهش رسیدگی شه.

<?php  namespace Illuminate\Bus;
   
class Dispatcher implements DispatcherContract, QueueingDispatcher, HandlerResolver {

    // ...

    public function dispatch($command, Closure $afterResolving = null)
    {
        if ($this->queueResolver && $this->commandShouldBeQueued($command))
        {
            return $this->dispatchToQueue($command);
        }
        else
        {
            return $this->dispatchNow($command, $afterResolving);
        }
    }

    // ...

    protected function commandShouldBeQueued($command)
    {
        if ($command instanceof ShouldBeQueued) return true;

        return (new ReflectionClass($this->getHandlerClass($command)))->implementsInterface(
            'Illuminate\Contracts\Queue\ShouldBeQueued'
        );
    }

    // ...
}

با استفاده از متد dispatchNow در کلاس Dispatcher و ارسال یک Command که اینترفیس ShouldBeQueued رو پیاده‌سازی کرده، از Queue شدن اون Command صرف نظر میشه.

فقط در صورتیکه کلاس Dispatcher رو Inject کرده باشید به متد dispatchNow دسترسی دارید. چون این متد در DispatchesCommands که کنترلرها ازش استفاده می‌کنن تعریف نشده.

InteractsWithQueue Trait

اگر این Trait رو در Command مورد نظرتون استفاده کنید، می‌تونید در Handler با Command مثل یک Job Queue برخورد کنید. این Trait متدهای زیر رو در اختیارتون می‌ذاره:

  • ‏delete: حذف کردن Command از صف
  • ‏release: برگردوندن Command به انتهای صف
  • ‏attempts: تعداد دفعاتی که Command اجرا شده
<?php

use App\Commands\DummyCommand;

class DummyCommandHandler {
    
    public function handle(DummyCommand $command)
    {
        if ($command->attempt() < 3) {
            $command->release(60); // release the command with 60 second delay
        } else {
            $command->delete();
        }
    }
    
}

درصورتیکه متد handle هیچ exceptionی برنگردونه، Command به صورت خودکار از صف حذف خواهد شد و الزامی به فراخونی متد delete در همه شرایط نیست.

SerializesModels Trait

وقتی که قصد دارید Eloquent Model به عنوان پارامتر به Command بدید و اون Command رو Queue کنید، برای بازدهی بهتر، از این Trait استفاده کنید. چراکه وقتی Eloquent Modelها رو Queue کنید مدل Serialize می‌شه و بعد داخل صف قرار می‌گیره و هر وقت که بخواین از اون مدل استفاده کنید Deserialize میشه و این کار پرهزینه‌ست. اما با استفاده از این Trait، مدل در صف توسط یک identifier ذخیره می‌شه و وقتی به اون مدل نیاز دارید به راحتی از صف بازیابی می‌شه.

برای اطلاعات بیشتر در این باره به داکیومنت لاراول، قسمت Queues And Eloquent Models سر بزنید.

‏Command Pipelines

در قسمت‌های قبل اشاره‌ای به Pipeline و Decorate کردن Commandها کردم. قبل از اینکه Command به handler برسه، می‌تونید از طریق pipeline از کلاس‌هایی عبور بدین. به عنوان مثال می‌تونید یک Command، که با دیتابیس سر و کار داره رو داخل یک تراکنش (transaction) انجام بدین، که این تراکنش داخل یک کلاس مجزا قرار داره.

کلاس‌های pipe یک متد اصلی به نام handle دارند. این متد دو پارامتر command$ و next$ داره، پارامتر اول نمونه Command و پارمتر دوم کلاس بعدی که Command قراره به اون ارسال بشه هست. این کلاس می‌تونه یه pipe دیگه باشه یا handler متناظر Command.

<?php

class TransactionPipe {

    public function handle($command, $next)
    {
        return DB::transaction(function() use ($command, $next) {
            return $next($command);
        }
    }

}

‏Pipeها از طریق IoC لاراول نمونه‌سازی می‌شن. بنابرین می‌تونید Dependencyهای کلاستون رو در Constructor بنویسید و typehint کنید تا لاراول اون‌ها رو براتون Inject کنه.

برای اضافه کردن pipe به Command از متد pipeThrough کلاس Dispatcher یا DispatchesCommand Trait استفاده می‌کنیم.

<?php

$dispatcher->pipeThrough(['TransactionsPipe', 'LoggerPipe'])
           ->dispatch(new DummyCommand('dummy data');

همچنین می‌تونید به جای کلاس از Closure استفاده کنید.

<?php

$dispatcher->pipeThrough([function ($command, $next) {
    return DB::transaction(function() use ($command, $next) {
        return $next($command);
    }
}])->dispatch(new DummyCommand('dummy data'));

جمع‌بندی

Command Bus می‌تونه کار شما رو خیلی راحت‌تر کنه. اما این موضوع به مقیاس برنامه‌ای که دارید می‌نویسید مربوطه. برای اپلیکیشن‌های کوچیک استفاده از Command Bus خیلی معقول نیست و توصیه نمی‌شه. اما برای اپلیکیشن‌های متوسط و بزرگ، به دلیل اینکه ممکنه یک سرویس در جاهای مختلف مورد استفاده قرار بگیره، Command Bus می‌تونه مفید باشه. به عنوان مثال برنامه شما یه وب‌اپلیکیشن هست که API هم داره، و شما می‌خواین کارهایی که کاربر در نسخه وب قادر به انجام اون هست، از طریق API هم قابل دسترسی باشه. استفاده از Command Bus در همچین شرایطی کاملا منطقی و به صرفه‌ست. چرا که لازم نیست منطق مربوط به سرویس‌ها رو هم برای نسخه وب و هم برای API جداگانه بنویسید. اما نکته منفی Command Bus پیچیدگی‌ش برای کسایی که با این روش آشنا نیست در نتیجه کد براش نامفهموم میشه و قادر به توسعه دادنش نیست. پیش‌نهاد من برای جلوگیری از وقوع چنین مشکلی، نوشتن داکیومنت کامل برای کدهاتونه.

نسخه پیاده‌سازی شده Command Bus در لاراول ۵ و امکانات دیگه‌ای که ارائه می‌ده، مثل Queue کردن Commandها، به ما کمک می‌کنه اپلیکیشنی بهتر و استانداردتری رو بنویسیم و به راحتی توسعه‌ش بدیم. اما این نکته رو در نظر داشته باشید که وجود این امکانات در لاراول به معنای اجبار در استفاده ازشون نیست. این امکانات فقط یک پیش‌نهاد برای شماست. با توجه به اینکه کلاس‌های داخل فولدر app با استفاده از PSR-4 بارگذاری (Autoload) می‌شن، می‌تونید هر قسمتی که استفاده‌ای در پروژه شما ندارن، رو حذف کنید و یا امکانات دلخواه خودتون که در لاراول اضافه کنید و استفاده کنید.

comments powered by Disqus