Макросы в Laravel

Макросы в Ларавел

Макросы в Laravel — это то, о чём еще сказано недостаточно в рамках фреймворка. Они реально мощные и полезные. За последние год-два я не создал ни одного проекта, где бы не использовал макросы.

Если описывать макросы кратко, то это способ расширения метода класса, но без использования наследования, а через замыкание. Это означает, что все экземпляры этого класса будут иметь этот метод. В любом классе фреймворка, который использует трейт Macroable, могут быть применены макросы.

В этой статье я хочу показать ряд простых вариантов использования макросов, которые улучшат вашу работу, уменьшат дублирование кода, сделают код более читабельным или просто решат проблемы, которые могут возникнуть при тестировании.

Как сделать и куда положить

С самого начала это неочевидная вещь. Есть пара мест, куда бы я порекомендовал положить ваши макросы. Первый — это использовать простой файл php и загружать его через Composer. Обычно я создаю новый файл macros.php в папке app. И, затем, редактирую autoload в composer.json: добавляю относительный путь к файлу app/macros.php в свойство files. Теперь нужно запустить composer dump-autoloader, чтобы наш файл загружался и выполнялся, настраивая макросы для всего приложения.

"autoload": {
    "psr-4": {
        "App\\": "app/"
    },
    "classmap": [
        "database/seeds",
        "database/factories"
    ],
    "files": [
        "app/macros.php"
    ]
}

Другое место для размещения макросов — метод boot в сервис-провайдере. Честно говоря, это может привести к некоторому беспорядку, поскольку ваше приложение растет и вам приходится добавлять еще больше макросов. Кстати, есть странный макрос, который не может быть сделан в macros.php — это Route, так как он привязан к экземпляру хранящемуся в сервисном контейнере. Поэтому мы не будем использовать класс Route в этой статье.

Коллекции

Скукота, но именно здесь большинство людей познакомились с макросами в Laravel, поэтому я хотел бы рассказать об этом совсем кратко.
Хороший пример небольшого макроса, который мне приходилось использовать раньше, — это преобразование ключей для массива, что довольно утомительно, если вы написали его как функцию.

Вместо этого мы можем добавить это как макрос. Сначала мы сделаем макрос, чтобы отмаппить все и управлять рекурсивно.

\Illuminate\Support\Collection::macro(
    'mapKeysWith',
    function ($callable) {
        /* @var $this \Illuminate\Support\Collection */
        return $this->mapWithKeys(function ($item, $key) use ($callable) {
            if (is_array($item)) {
                $item = collect($item)
                    ->mapKeysWith($callable)
                    ->toArray();
            }
            return [$callable($key) => $item];
        });
    }
);

И еще один, для красивой обёртки

\Illuminate\Support\Collection::macro(
    'mapKeysToCamelCase',
    function () {
        /* @var $this \Illuminate\Support\Collection */
        return $this->mapKeysWith('camel_case');
    }
);

Готово. Теперь мы можем его использовать, например так:

collect(['test_key' => ['second_layer' => true]])->mapKeysToCamelCase();
// Создает массив ['TestKey' => ['SecondLayer' => true]]

Запросы Eloquent

Это, вероятно, одно из самых важных мест, где макросы действительно имеют значение. Например, было бы почти невозможно расширить Query Builder самостоятельно, а с макросами и не нужно.

К примеру, вам нужно напрямую использовать базу данных, и скорей всего вы будете делать это с помощью сырых запросов (raw queries).

$query->whereRaw(
   "ST_Distance_Sphere(`table`.`column`, POINT(?, ?)) < ?",
   [$long, $lat, $distance],
   $boolean
);

Если вы часто делает это в вашем приложении, то код становится повторяемым, не говоря уже о случаях, когда вы пытаетесь соединить несколько where в одном выражении. Один из способов обойти это заключается в использовании скоупов (scopes), но это потребует добавления скоупов в каждую модель, где их нужно использовать. А мы можем просто сделать для этого макрос. В этом примере мы сделаем макрос для фильтрации результатов в MySQL, используя встроенные геопространственные функции сервера.

\Illuminate\Database\Query\Builder::macro(
        'whereSpatialDistance',
        function ($column, $operator, $point, $distance, $boolean = 'and') {
   $this->whereRaw(
       "ST_Distance_Sphere(`{$this->from}`.`$column`, POINT(?, ?)) $operator ?",
       [$point[0], $point[1], $distance],
       $boolean
   );
});

Добавим еще один макрос, чтобы мы могли использовать условие orWhere

\Illuminate\Database\Query\Builder::macro(
    'orWhereSpatialDistance',
    function ($column, $operator, $point, $distance) {
        $this->whereSpatialDistance($column, $operator, $point, $distance, 'or');
    }
);

Теперь, когда нужно отфильтровать запрос, мы можем вызвать макрос напрямую, как и любой другой оператор where.

$query->whereSpatialDistance('coordinates', [1, 1], 10)
 ->orWhereSpatialDistance('coordinates', [0, 0], 1);

Тестирование Ответов

Написание функциональных тестов явление распространенное и эффективное, но оно может стать довольно однообразным. Что еще хуже, у вас может быть что-то нестандартное, например, API, который всегда возвращает HTTP-статус 200, но что-то вроде такого:

{"error"=>true,"message"=>"bad API call"}

Вам может понадобиться написать тесты, например такие:

namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ExampleTest extends TestCase
{
    public function testApi()
    {
        $this->postJson('/some-route', ['field' => 'on'])
            ->assertStatus(200)
            ->assertJsonFragment(['error' => true]);
    }
}

Это может быть хорошо для одного теста, но как насчет остальных? Возможно макрос мог бы решить эту проблему, и угадать, что нужно делать.

Во-первых, на этот раз, создадим tests/macros.php и добавим его в composer.json в опцию autoload-dev параметр files. Нам также необходимо будет обновить автозагрузчик при помощи composer dump-autoloader.

"autoload-dev": {
    "psr-4": {
        "Tests\\": "tests/"
    },
    "files": [
        "tests/macros.php"
    ]
},

Затем в файл tests/macros.php добавим следующее:

\Illuminate\Foundation\Testing\TestResponse::macro('assertErrorInResponse', function () {
    /** @var $this \Illuminate\Foundation\Testing\TestResponse */
    return $this->assertOk()
        ->assertJsonFragment(['error' => true]);
});

Теперь мы можем использовать его в наших тестах.

namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ExampleTest extends TestCase
{
    public function testApi()
    {
        $this->postJson('/some-route', ['field' => 'on'])
            ->assertErrorInResponse();
    }
}

Это очень простой фрагмент, но он действительно может сделать ваши тесты намного более читабельными, когда вы начнете сокращать повторяющийся код в макросы многократного использования.

Файловые операции

Макросы также могут быть полезны, когда вы хотите использовать Фасады и их способность мокать (mock) для ваших юнит-тестов.
Не лучший сценария для мокинга — использование встроенных классов PHP, дергающих файловую системы во время теста.

Например, посмотрите этот код, использующий класс ZipArchive.

$zip = new ZipArchive();
$zip->open($path);
$zip->extractTo($extractTo);
$zip->close();

Я не могу это мокать, это совершенно новый экземпляр класса ZipArchive. В лучшем случае я мог бы создать некую фабрику ZipArchive для создания класса, но это кажется уже излишним. Вместо этого мы сделам макрос (в нашем файле app/macros.php).

\Illuminate\Filesystem\Filesystem::macro(
    'extractZip',
    function ($path, $extractTo) {
        $zip = new ZipArchive();
        $zip->open($path);
        $zip->extractTo($extractTo);
        $zip->close();
    }
);

И, когда дело доходит до продакшн-кода, все, что нам нужно сделать, это использовать фасад.

File::extractZip($path, $extractTo);

Но для наших тестов мы просто мокаем метод, используя Facade.

File::shouldReceive('extractZip')
    ->once()
    ->with($path, $extractTo);

Это означает, что нам больше не нужно беспокоиться об очистке файловой системы после тестов. Мы аккуратно завернули нашу логику Zip-архива в то, что можно использовать много раз и легко поддающееся мокингу.

Правила валидации

Последний из примеров очень простой, но может сильно помочь, когда дело дойдёт до управления вашим кодом.

Например, я часто сталкиваюсь с таким:

$this->validate($request, [
    'date' => ['before:' . $model->created_at->toDateTimeString()],
]);

Само по себе это выглядит не очень опрятно, не говоря уже о тех случаях, когда полей и правил будет много.

Вместо этого лучше сделать так:

$this->validate($request, [
    'date' => [Rule::before($model->created_at)],
]);

С макросом:

\Illuminate\Validation\Rule::macro(
    'before',
    function(\Carbon\Carbon $date) {
        return 'before:' . $date->toDateTimeString();
    }
);

Бонусный совет: слишком много макросов?

Что делать если у вас так слишком много макросов в одном большом фале, что вам становится трудно в них ориентироваться и управлять ими? Быстрым решением может стать создание большего количества файлов, таких как app/validation_macros.php, но вы ошибаетесь. Пришло время Миксинов (Mixins, Примеси) (не путайте с трейтами, которые иногда рассматриваются как миксины из-за других языков, имеющих их как концепцию).

Миксин — это класс, который определяет множество макросов, которые затем могут быть предоставлены вашему классу для реализации всех их за один раз.

Давайте рассмотрим пример, создав новый класс app/RulesMixin.php.

namespace App;

use Carbon\Carbon;

class RulesMixin
{
    public function before()
    {
        return function(Carbon $date) {
            return 'before:' . $date->toDateTimeString();
        };
    }
    public function beforeOrEqual()
    {
        return function(Carbon $date) {
            return 'before_or_equal:' . $date->toDateTimeString();
        };
    }
    public function after()
    {
        return function(Carbon $date) {
            return 'after:' . $date->toDateTimeString();
        };
    }
    public function afterOrEqual()
    {
        return function(Carbon $date) {
            return 'after_or_equal:' . $date->toDateTimeString();
        };
    }
}

Как вы видите, всё, что нужно сделать миксину, — это реализовать метод без аргументов, который вернет замыкание, являющееся нашим макросом, использующим имя метода в качестве своего имени.

Теперь в нашем app/macros.php мы можем просто настроить класс на использование макроса следующим образом

\Illuminate\Validation\Rule::mixin(new \App\RulesMixin());

Это означает, что класс Rule будет не только по-прежнему иметь метод before, но теперь будет иметь также правила валидации after, beforeOrEqual и afterOrEqual в более доступном формате.

Выводы

Мне очень нравится использование макросов в Laravel, и я не могу не подчеркнуть, насколько они полезны для расширения функциональности фреймворка, в соответствии с вашими потребностями, не прилагая особых усилия для этого.

Если вы хотите узнать больше классов, использующих трейт Macroable, то вот вам список для Laravel 5.8:

vendor/laravel/framework/src/Illuminate/Auth/RequestGuard.php
vendor/laravel/framework/src/Illuminate/Auth/SessionGuard.php
vendor/laravel/framework/src/Illuminate/Cache/Repository.php
vendor/laravel/framework/src/Illuminate/Console/Command.php
vendor/laravel/framework/src/Illuminate/Console/Scheduling/Event.php
vendor/laravel/framework/src/Illuminate/Cookie/CookieJar.php
vendor/laravel/framework/src/Illuminate/Database/Grammar.php
vendor/laravel/framework/src/Illuminate/Database/Eloquent/FactoryBuilder.php
vendor/laravel/framework/src/Illuminate/Database/Eloquent/Relations/Relation.php 
vendor/laravel/framework/src/Illuminate/Database/Query/Builder.php
vendor/laravel/framework/src/Illuminate/Database/Schema/Blueprint.php
vendor/laravel/framework/src/Illuminate/Filesystem/Filesystem.php
vendor/laravel/framework/src/Illuminate/Foundation/Testing/TestResponse.php
vendor/laravel/framework/src/Illuminate/Http/JsonResponse.php
vendor/laravel/framework/src/Illuminate/Http/RedirectResponse.php
vendor/laravel/framework/src/Illuminate/Http/Request.php
vendor/laravel/framework/src/Illuminate/Http/Response.php
vendor/laravel/framework/src/Illuminate/Http/UploadedFile.php
vendor/laravel/framework/src/Illuminate/Mail/Mailer.php
vendor/laravel/framework/src/Illuminate/Routing/PendingResourceRegistration.php
vendor/laravel/framework/src/Illuminate/Routing/Redirector.php
vendor/laravel/framework/src/Illuminate/Routing/ResponseFactory.php
vendor/laravel/framework/src/Illuminate/Routing/Route.php
vendor/laravel/framework/src/Illuminate/Routing/Router.php
vendor/laravel/framework/src/Illuminate/Routing/UrlGenerator.php
vendor/laravel/framework/src/Illuminate/Support/Arr.php
vendor/laravel/framework/src/Illuminate/Support/Collection.php
vendor/laravel/framework/src/Illuminate/Support/Optional.php
vendor/laravel/framework/src/Illuminate/Support/Str.php
vendor/laravel/framework/src/Illuminate/Support/Testing/Fakes/NotificationFake.php
vendor/laravel/framework/src/Illuminate/Translation/Translator.php
vendor/laravel/framework/src/Illuminate/Validation/Rule.php
vendor/laravel/framework/src/Illuminate/View/Factory.php
vendor/laravel/framework/src/Illuminate/View/View.php

Если интересуетесь реализацией, то можете просмотреть код на GitHub.

Автор: Peter Fox
Перевод: Demiurge Ash