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