Лучшие практики для главного файла плагина
После жаркой дискуссии о том, как должен выглядеть главный файл плагина, внутри твита от Mark Jaquith, я решил написать свой вариант, с большинством пунктов я согласен, но об этом позже.
На начало дисскуссии у меня был готов набросок бойлерплейта для плагинов WordPress.
Код главного файла plugin-name.php
плагина выглядел так:
<?php // Comments for plugin. use PluginName\Plugin; ( new Plugin() )->run();
а главный класс PluginName\Plugin
так:
<?php namespace PluginName; use PluginName\Admin\Settings; use PluginName\Front\Front; class Plugin { public function run(): void { is_admin() ? $this->run_admin() : $this->run_front(); } private function run_admin(): void { ( new Settings() )->hooks(); } private function run_front(): void { ( new Front() )->hooks(); } }
Чтобы избавиться от Hard Dependencies в классе PluginName\Plugin
, используем Dependency Injection Container (DIC).
Dependency Injection Container
Устанавливаем DIC и настраиваем его:
composer require symfony/dependency-injection composer require symfony/config
Создаем файл конфигурации dependencies/services.php
(но вы также можете использовать yml
или xml
формат):
<?php use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { exit; } return function ( ContainerConfigurator $configurator ) { $services = $configurator->services(); $services->set( 'settings', 'PluginName\Admin\Settings' ); $services->set( 'front', 'PluginName\Front\Front' ); };
Проще говоря, для каждого класса плагина создали уникальный слаг. Для PluginName\Admin\Settings
— settings
, а для PluginName\Front\Front
— front
.
Кроме того в этом конфиге можно указывать объекты, которые нужно передать в конструктор или любой другой метод.
Для создания DIC и подключения файла-конфигруации нам необходимо написать следующий код:
<?php use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; require_once __DIR__ . '/vendor/autoload.php'; $container_builder = new ContainerBuilder(); $loader = new PhpFileLoader( $container_builder, new FileLocator( __DIR__ ) ); $loader->load( PLUGIN_NAME_PATH . 'dependencies/services.php' );
Обновим главный файл plugin-name.php
и передадим DIC в коструктор объекта PluginName\Plugin
:
<?php use PluginName\Plugin; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; define( 'PLUGIN_NAME_PATH', plugin_dir_path( __FILE__ ) ); require_once PLUGIN_NAME_PATH . 'vendor/autoload.php'; $container_builder = new ContainerBuilder(); $loader = new PhpFileLoader( $container_builder, new FileLocator( __DIR__ ) ); $loader->load( PLUGIN_NAME_PATH . 'dependencies/services.php' ); $plugin_name = new Plugin( $container_builder ); $plugin_name->run();
Давайте обновим класс PluginName\Plugin
с использованием DIC:
<?php namespace PluginName; use Exception; use Symfony\Component\DependencyInjection\ContainerBuilder; class Plugin { private $container_builder; public function __construct( ContainerBuilder $container_builder ) { $this->container_builder = $container_builder; } public function run(): void { is_admin() ? $this->run_admin() : $this->run_front(); } private function run_admin(): void { $this->container_builder->get_service( 'settings' )->hooks(); } private function run_front(): void { $this->container_builder->get_service( 'front' )->hooks(); } }
Мы полностью избавились от зависимостей и теперь данный объект выглядит намного лучше и будет очень легко поддаваться тестированию.
Запуск плагина на событие plugins_loaded
Отложим запуск плагина до события plugins_loaded
. Для этого создаем функцию run_plugin_name
в которую оборачиваем весь вызов плагина и добавляем ее на событие plugins_loaded
:
<?php use PluginName\Plugin; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; define( 'PLUGIN_NAME_PATH', plugin_dir_path( __FILE__ ) ); add_action( 'plugins_loaded', 'run_plugin_name' ); function run_plugin_name() { require_once PLUGIN_NAME_PATH . 'vendor/autoload.php'; $container_builder = new ContainerBuilder(); $loader = new PhpFileLoader( $container_builder, new FileLocator( __DIR__ ) ); $loader->load( PLUGIN_NAME_PATH . 'dependencies/services.php' ); $plugin_name = new Plugin( $container_builder ); $plugin_name->run(); }
Данный трюк позволяем включить/выключить плагин с помощью всего одного remove_action
. Это очень полезно например при вызове AJAX/REST API/WP CLI. Ситуации бывают разные, но мы даем такую возможность легко управлять плагином в коде темы/других плагинов.
Хук запуска плагина
Идем немного дальше. И добавляем событие plugin_name_init
после запуска плагина:
<?php use PluginName\Plugin; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; define( 'PLUGIN_NAME_PATH', plugin_dir_path( __FILE__ ) ); function run_plugin_name() { require_once PLUGIN_NAME_PATH . 'vendor/autoload.php'; $container_builder = new ContainerBuilder(); $loader = new PhpFileLoader( $container_builder, new FileLocator( __DIR__ ) ); $loader->load( PLUGIN_NAME_PATH . 'dependencies/services.php' ); $plugin_name = new Plugin( $container_builder ); $plugin_name->run(); do_action( 'plugin_name_init', $plugin_name ); } add_action( 'plugins_loaded', 'run_plugin_name' );
В момент события plugin_name_init
у нас есть объект главного класса плагина и уже полностью добавленные хуки всех других объектов плагина.
Добавляем в главный класс PluginName/Plugin
метод, для получения DIC
:
<?php // ... class Plugin { // ... public function get_service( string $container_name ): ?object { return $this->container_builder->get( $container_name ); } // ... }
В результате мы можем отключить абсолютно любой экшн/фильтр нашего плагина. Например в классе PluginName\Front\Front
у нас подключение стилей:
<?php namespace PluginName\Front; class Front { public function hooks(): void { add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_styles' ] ); } public function enqueue_styles() { //... } }
С помощью хука plugin_name_init
мы можем получить главный объект плагина и получить объект PluginName\Front\Front
используя DIC и использовать отключение хуков:
function remove_plugin_name_actions( $instance ) { $front = $instance->get_service( 'front' ); if ( ! $front ) { return; } remove_action( 'wp_enqueue_scripts', [ $front, 'enqueue_styles' ] ); } add_action( 'plugin_name_init', 'remove_plugin_name_actions' );
Здорово правда?
Использовать DIC это конечно круто и здорово, но не нужно забывать о том, что другие плагины/темы могут так же использовать его и одним прекрасным днем, вы наткнетесь на конфликты версий ваших плагинов. Поэтому для всех сторонних пакетов нужно добавить префиксы.
Префиксы для зависимостей
Mozart, который предложил Mark Jaquith в целом удобный, но справится с префиксами для этих пакетов он не смог, надеюсь когда-нибудь он этому научится. Поэтому используем php-scoper. Для начала необходимо его установить:
composer require bamarni/composer-bin-plugin --dev composer bin php-scoper config minimum-stability dev composer bin php-scoper config prefer-stable true composer bin php-scoper require --dev humbug/php-scoper
Создаем файл конфигурации scoper.inc.php
:
<?php use Isolated\Symfony\Component\Finder\Finder; return [ 'prefix' => 'PluginName\\Vendor', 'whitelist-global-constants' => false, 'whitelist-global-classes' => false, 'whitelist-global-functions' => false, 'finders' => [ Finder::create()-> files()-> in( [ 'vendor/psr/container/', 'vendor/symfony/config/', 'vendor/symfony/filesystem/', 'vendor/symfony/service-contracts/', 'vendor/symfony/dependency-injection/', ] )-> name( [ '*.php' ] ), ], 'patchers' => [ function ( string $file_path, string $prefix, string $contents ): string { return str_replace( 'Symfony\\\\', sprintf( '%s\\\\Symfony\\\\', addslashes( $prefix ) ), $contents ); }, ], ];
- В
prefix
указываем префикс для пакетов - в
finders
указываем все пакеты для которых необходимо добавить префиксы - в
patchers
пишем как именно мы должны добавить префиксы
При запуске команды, для всех пакетов будет добавлены префиксы:
php-scoper add-prefix --output-dir dependencies/vendor/
Не забываем добавить директиву dependencies/vendor/
для автозагрузки в composer.json
:
// ... "autoload": { // ... "classmap": [ "dependencies/vendor/" ] }, // ...
Для быстрого запуска php-scoper’а добавим скрипты в composer.json
и запустим их на событие установки/обновления composer’а:
// ... "scripts": { "install-scoper": [ "composer bin php-scoper config minimum-stability dev", "composer bin php-scoper config prefer-stable true", "composer bin php-scoper require --dev humbug/php-scoper" ], "scoper": "php-scoper add-prefix --config .scoper.inc.php --output-dir dependencies/vendor/", "post-install-cmd": [ "composer install-scoper", "composer scoper", "composer dump-autoload" ], "post-update-cmd": [ "composer install-scoper", "composer scoper", "composer dump-autoload" ] } // ...
После этого не забудьте заменить оригинальные пакеты на пакеты с префиксами.
Результат
- В плагине нет Hard Dependencies
- Плагин загружается не сразу, а на хук
plugins_loaded
, что упрощает работу с другими плагинами. - Отключить плагин можно с помощью одного вызова
remove_action
- Отключить любой экшн/фильтр плагина можно на хук
plugin_name_init
- Пакеты composer’а не конфликтуют с другими плагина/темами
Как вам такой запуск плагина?
Источник: Great practice for the plugin bootstrap file