Макросы в 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.