Лучшие практики для главного файла плагина

8 мин. 45 сек.
191
15

После жаркой дискуссии о том, как должен выглядеть главный файл плагина, внутри твита от 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\Settingssettings, а для PluginName\Front\Frontfront.

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

Для создания 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’а не конфликтуют с другими плагина/темами

Как вам такой запуск плагина?

  • Выглядит охуенски — буду пробовать покорять бигдики

  • Годнота, будем пробовать на новых проектах

  • Зачем такие сложности?
    Во-первых, плагины для WP обычно не требуют таких структур
    Во-вторых, куча зависимостей из композера увеличивают размер плагина и замедляют его, а следовательно и сам Вордпресс.

    Считаю такой бойлерплейт для плагины — избыточен. И где же тут «Лучшие практики»?

    • Во-первых если у вас плагин из 1-го файла, то лучше это делать не плагином, а закинуть в тему в папку bullshit 😉
      Во-вторых куча зависимостей от композера никак не замедляют плагин. Если у вас библиотеки композера замедляют плагин, то вперед читать про то, как работает autoload в composer. Сам WordPress замедляет кривой код, который написал разработчик, не понимающий в алгоритмах и в том, что он делает.

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

      Можете показать свой файл для запуска плагина, а мы его проанализируем. Если вам не нравится DIC, scoper, то вы можете написать без него, но пожалуйста приложите тесты тогда к главному классу плагина.

    • И мне очень жаль, что вы не заметили простых приемов, которые позволяют легко управлять плагином из вне 🙁

    • Ваш комментарий говорит о недостаточном опыте работы с плагинами WordPress. Они бывают довольно большими. Например, главный плагин WPML (SitePress Multilingual CMS) содержит 565 классов, String Translation — 272 класса, Translation Management — 556 классов. И это без учёта сторонних библиотек в папках vendor.

      Загрузка стольких классов сразу приведёт к критичному замедлению плагина, поэтому WPML использует composer, который подгружает классы только в момент использования.

      Также в WPML и плагинах аналогичного уровня в обязательном порядке используются phpunit, integration и CodeCeption тесты. Для phpunit-тестов весьма важно уйти от hard dependencies, поэтому в таких плагинах применяют Dependency Injection Container.

  • Не ну че годнота, как всегда спасибо от души! хинкалинку этому автору в тарелку

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *