Превращаем WordPress в гибкий framework для разработчика

в 11:01, , рубрики: cms, php, wordpress, Разработка веб-сайтов, разработка сайтов

wp

Дорогой друг, если ты уже знаешь, что WP — это “CMS для домохозяек“, “Движок для простеньких блогов” и вовсе никакой не framework. Что он тормозит из-за ужасной структуры БД, что большой и сложный сайт, интернет-магазин, и тем более какой-нибудь веб-сервис на нем сделать нельзя (а если и можно то все будет очень криво), и что профи предпочитают Laravel, Symfony, Yii и CodeIgniter для решения абсолютно всех задач. То призываю тебя остаться с этими знаниями и дальше не читать, то что будет написано ниже скорее всего тебе не понравится.

Не MVC или почему разработчики не любят WP

При первом знакомстве с WP у многих разработчиков, знающих какой-либо MVC-фреймворк, возникает чувство смятения ввиду того что этой простой концепции в WordPress они распознать не могут. Структура системы выглядит для них сумбурно и нелогично, и как следствие в большинстве случаев на WP вешают ярлык “говн… кхм… плохого архитектурного решения”, после благополучно с ним прощаются не потрудившись разобраться в деталях. Также масла в огонь подливает распространенность системы и низкий порог входа в ее использование. Отсюда растут ноги у тонн статей от псевдоразработчиков в духе “Как правильно сделать меню и загрузить его через FTP” или “Создаем шорткод для вывода чего-то там в сайдбаре без знаний PHP при помощи плагина такого-то”, при взгляде на содержание которых хочется залезть под подушку и рыдать, равно как и при лицезрении того получилось в итоге чтения подобных материалов. Еще WP не любят за “плохой” код, обычно критикующий описывает это так: “Я открыл исходники, а там global, я чуть смузи себе на свитшот не пролил, когда это увидел! Как можно использовать global? Это ужасно!” Действительно, глобальные переменные в WP присутствуют по причине “так исторически сложилось”. Это тянется еще с самых первых версий и присутствует по большей части для обеспечения обратной совместимости, которая в WP крайне хорошая. Это единственная CMS, в которой можно обновить ядро без переживаний о том, что все сломается, или переписывания половины существующего кода перед обновлением. Более того, разрабатывая что-либо на WordPress, вы можете вообще никогда их не использовать напрямую.

Немного теории никому не повредит

WordPress построен на базе двух паттернов: EDA (Event Driven Architecture) и EAV (Entity Attribute Value). Не буду сильно вдаваться в подробности реализации, скажу лишь, что первый обеспечивает взаимодействие между слабосвязанными между собой частями системы и дает гибкость в разработке куда большую, чем MVC. С помощью этого паттерна реализованы так называемые фильтры (filters) и действия (actions), которые в WP принято называть “хуки” (hooks). Разработчик может создавать собственные точки срабатывания хуков и взаимодействовать с хуками определенными в ядре или плагинах, прикрепляя к ним произвольный код. Таким образом влиять на поведение отдельных частей системы без необходимости их прямой модификации. О том как использовать хуки всегда можно прочитать в оф. документациии или каких-нибудь сторонних источниках.

Что касается второго паттерна, то он регулирует принцип хранения информации в БД, обеспечивая неизменность структуры таблиц при изменении структуры данных. Все записи WP хранит в таблице wp_posts, а дополнительные поля в таблице wp_postmeta в виде: meta_id, post_id, meta_key, meta_value. И если требуется добавить новый параметр к какому-либо типу поста, например размер к товару в интернет-магазине. То нет необходимости создавать новые колонки в таблице, достаточно добавить строку в wp_postmeta с соответствующими данными. Такой подход дает возможность быстрого расширения разнообразия данных, но работает несколько медленнее по сравнению с подходом с “широкими таблицами”, где каждый параметр находится в своей собственной колонке. Однако скоростными характеристиками можно пренебречь при небольшом количестве хранимых строк или применить дополнительную оптимизацию если информации становится много. Из опыта могу сказать, что интернет-магазин на WooCommerce, в котором находится 20000-30000 товаров работает вполне себе быстро на среднем по характеристикам VPS без каких-либо танцев с бубном. Если же нужно хранить несколько миллионов записей, то такие данные лучше вынести в отдельную таблицу или даже БД в зависимости от требований к системе. Это является недостатком подхода, однако часто ли возникают такие задачи?

Общая структура проекта

В своей предыдущей статье я рассказал о том, как подружить WordPress и Composer для того чтобы иметь возможность беспрепятственно хранить свои исходники в системе контроля версий, а ядро CMS и плагины сторонних разработчиков подключать как зависимости. В комментариях тогда посоветовали использовать Bedrock для этих целей, как готовое решение. Bedrock прост в использовании и дает готовую структуру проекта на WP, а также инструменты для внедрения зависимостей и деплоймента + возможность конфигурации под конкретное окружение.

Чтобы создать проект новый проект на Bedrock, достаточно выполнить команду:

composer create-project roots/bedrock

дождаться установки и указать настройки окружения в файле .env. Подробные инструкции о том как пользоваться Bedrock можно найти в официальной документации.

Миграции не нужны

В соответствии с концепцией EAV структура таблиц в БД WordPress не должна меняться (за исключением случаев, когда требуется создать свои собственные таблицы, но это уже другая история). Таким образом миграции таблиц в БД тут невозможны по определению. Однако, как же тогда управлять структурой данных? Что делать если нужно добавить новый тип постов или таксономий и прикрутить к ним свои собственные разные параметры? С регистрацией постов и таксономий все просто, для этого существует две функции register_post_type и register_taxonomy. Например, нужно создать тип “Книги” и дать возможность группировать книги по авторам, в общем случае это будет выглядеть так:

//register post type "books"

add_action( 'init', function() {
    $labels = [
        'name'               => _x( 'Books', 'post type general name', 'your-plugin-textdomain' ),
        'singular_name'      => _x( 'Book', 'post type singular name', 'your-plugin-textdomain' ),
        'menu_name'          => _x( 'Books', 'admin menu', 'your-plugin-textdomain' ),
        'name_admin_bar'     => __( 'Book', 'your-plugin-textdomain' ),
        'add_new'            => __( 'Add New Book', 'your-plugin-textdomain' ),
        'add_new_item'       => __( 'Add New Book', 'your-plugin-textdomain' ),
        'new_item'           => __( 'New Book', 'your-plugin-textdomain' ),
        'edit_item'          => __( 'Edit Book', 'your-plugin-textdomain' ),
        'view_item'          => __( 'View Book', 'your-plugin-textdomain' ),
        'all_items'          => __( 'All Books', 'your-plugin-textdomain' ),
        'search_items'       => __( 'Search Books', 'your-plugin-textdomain' ),
        'parent_item_colon'  => __( 'Parent Books:', 'your-plugin-textdomain' ),
        'not_found'          => __( 'No books found.', 'your-plugin-textdomain' ),
        'not_found_in_trash' => __( 'No books found in Trash.', 'your-plugin-textdomain' )
    ];

    $supports = ['title',  'author', 'revisions'];

    $args = [
        'labels' => $labels,
        'public' => true,
        'supports' => $supports,
        'hierarchical' => true,
        'menu_position' => 20,
        'show_in_nav_menus' => true,
        'rewrite' => ['with_front' => false],
        'map_meta_cap' => true
    ];

    register_post_type('book', $args);
});

//register taxonomy "authors"

add_action( 'init', function() {
	$labels = [
		'name'              => _x( 'Authors', 'taxonomy general name', 'your-plugin-textdomain' ),
		'singular_name'     => _x( 'Author', 'taxonomy singular name', 'your-plugin-textdomain' ),
		'search_items'      => __( 'Search Authors', 'your-plugin-textdomain' ),
		'all_items'         => __( 'All Authors', 'your-plugin-textdomain' ),
		'parent_item'       => __( 'Parent Author', 'your-plugin-textdomain' ),
		'parent_item_colon' => __( 'Parent Author:', 'your-plugin-textdomain' ),
		'edit_item'         => __( 'Edit Author', 'your-plugin-textdomain' ),
		'update_item'       => __( 'Update Author', 'your-plugin-textdomain' ),
		'add_new_item'      => __( 'Add New Author', 'your-plugin-textdomain' ),
		'new_item_name'     => __( 'New Author Name', 'your-plugin-textdomain' ),
		'menu_name'         => __( 'Authors', 'your-plugin-textdomain' ),
	];

	$args = [
		'hierarchical'      => true,
		'labels'            => $labels,
		'show_ui'           => true,
		'show_admin_column' => true,
		'query_var'         => true,
		'rewrite'           => false,
	];

	register_taxonomy( 'authors', [
        'book',
    ], $args );
});

Теперь хорошо бы иметь возможность указать у каждой книги количество страниц. Для этого существует функция add_post_meta, с помощью которой можно добавить пару ключ-значение к созданной записи. А в последствии управлять значением поля из админки в блоке “Custom fields” на странице редактирования книги, также можно сразу создать его через админку, в обход вызова add_post_meta в своем коде. Но что делать если таких полей нужны десятки для каждой книги, если нужно указать, кроме количества страниц, тип переплета, год издания, название издателя и другие параметры? Нативным способом это делать немного накладно.

В этот момент приходит на помощь плагин Advanced Custom Fields (ACF). Он предоставляет удобный интерфейс для управления мета-полями записей. При том вся его мощь раскрыта в PRO версии, которая стоит не так дорого и приобрести ее может каждый.

После установки, вы можете пользоваться визуальным конструктором для добавления новых полей в панели управления плагином, но этот путь нам не подойдет поскольку тогда информация о полях будет хранится в БД и при каждом изменении нам нужно будет проделывать действия по созданию полей и на сервере разработчика и в продакшене (благо для этого в плагине присутствует механизм экспорта/импорта). Избежать таких сложных телодвижений можно путем чтения документации к плагину и нахождения там функции acf_add_local_field_group, которая принимает в качестве параметра массив с декларативно объявленной группой полей. Для того чтобы к типу данных “Книга”, добавить поля и информацией об издателе, твердом переплете и описание книги, код будет выглядеть так:

add_action('acf/init', function () {
    acf_add_local_field_group([
        'key' => 'books_fields',
        'title' => 'Books Fields',
        'fields' => [
            	[
                	'key' => 'books_fields_publisher',
                	'label' => 'Publisher',
                	'name' => 'publisher',
                	'type' => 'text',
                	'required' => 1,
            	],
            	[
                	'key' => 'books_fields_hard_cover',
                	'label' => 'Hard Cover',
                	'name' => 'hard_cover',
                	'type' => 'true_false',
                	'message' => 'yes',
            	],
            	[
                	'key' => 'books_fields_description',
                	'label' => 'Description',
                	'name' => 'description',
                	'type' => 'textarea',
                	'required' => 1,
            	],
        	],
        'location' => [
	            [
	                [
	                    'param' => 'post_type',
	                    'operator' => '==',
	                    'value' => 'book',
	                ],
	        	],
        	]
    	]
    ];
});

И вот мы подобрались к самому главному в этом параграфе. Имея знания о том, как создавать новые типы постов, таксономии и прикреплять к ним мета-поля через ACF, вы можете определить структуру хранения этой информации и оформить ее в качестве плагина, тогда вы всегда будете знать где, как и что у вас создается, и какие данные поддерживает (по сути это аналог объявления модели в MVC).

Структура у плагина может быть следующая:

PostTypes/
  Books/
    Type.php 
    Fields.php
Taxonomies/
  Authors/
    Type.php
    Fields.php
AllDataTypes.php

Файл AllDataTypes.php является входной точкой в плагин, там запускается процесс регистрации всего того что вы создали. Будут ли это просто подключаемые файлы или развитая структура классов с методами и интерфейсами решать уже вам в зависимости от вашей задачи.

И в завершении параграфа коротко расскажу о полях типа “Clone” в ACF. Такие поля предназначены для того, чтобы обеспечить возможность повторного использования полей объявленных в других местах. С помощью “Clone” можно делать сквозное использование полей и даже целых групп в разных типах данных не нарушая принципа DRY.

Самый настоящий шаблонизатор

После того, как с данными более-менее разобрались, неплохо бы подумать о выводе информации. Изначально в WP нет даже самого простого шаблонизатора, а вывод предлагается делать так:

<?php echo $bar; ?>

Это не очень красиво и никак не ограничивает встраивание логики прямо в шаблон, чем умело пользуются товарищи, которые пишут “великолепные” статьи (из первого параграфа) и начинающие разработчики.

Исправить ситуацию помогает библиотека Timber, которая добавляет поддержку модного Twig и целую пачку интерфейсов для интеграции с WP_Query. Библиотека существует как пакет для composer (предпочтительно использовать этот вариант установки) и как обычный плагин. Все что потребуется после установки это указать путь до папки в которой будут лежать шаблоны.

Структура темы в моем случае обычно выглядит так:

themes/
my-theme/
  templates/
    functions.php
    index.php
  views/
    index.twig

В functions.php написано следующее:

Timber::$locations[] = realpath(__DIR__ . '/../views');

В index.php:

$context = Timber::get_context();
$context['posts'] = Timber::get_posts();
//тут может быть какая-то логика, например взаимодействие с кэшем
Timber::render('index.twig', $context);

Для остальных шаблонов все выглядит аналогично. Получается, что в папке templates, если сравнивать с MVC, у нас лежит некоторое подобие контроллеров, в которых мы готовим данные и выводим их в шаблонах из директории views.

В Timber::get_posts() можно передать два параметра: данные для запроса WP_Query и имя класса поста который будет доступен из шаблона по-умолчанию используется класс TimberPost, но можно указать свой, который является его наследником.

Давайте так и сделаем для наших книг и добавим к этому классу метод get_length, который будет возвращать “long” (длинная), если количество страниц в книге больше 200 и “short” (короткая), если меньше, код класса:

use TimberPost;
class BooksPost extends Post{
    public function __construct($pid = null)
    {
        parent::__construct($pid);
    }

    public function get_length()
    {
        if($this->pages_count > 200){
    	      return 'long';
    	}
        return 'short'
    }
}

Помните наш плагин с данными? Давайте поместим объявление подобных классов туда же, поскольку они имеют непосредственное отношение к данным и если бы у нас был MVC-фреймворк, это бы относилось к методам модели.

PostTypes/
  Books/
    Type.php 
    Fields.php
    Timber.php
Taxonomies/
  Authors/
    Type.php
    Fields.php
AllDataTypes.php

В шаблоне обращаться к таким методам можно как нельзя проще:

{{ book.get_length }}

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

В папке с темой должна быть только тема

Сочту нужным вытащить это в отдельный параграф, поскольку проблема того, что в тему, в файл functions.php в частности, обычно пихают все что надо и не надо, от функций, которые отвечают за вывод хлебных крошек, до целых классов, которые реализуют обширную логику манипуляции с данными. Особенно на этом спекулируют производители “премиум” тем, пытаясь вместе с темой внедрить еще и максимум функционала (так тема лучше покупается). Делать это может быть и выгодно с коммерческой точки зрения, но совершенно неправильно с точки зрения разработчика. В папке с темой вся логика должна быть сведена к минимуму, в идеале все, что сложнее строк для указания путей и выражений if...else должно находится в плагинах.

Чего в WP не хватает

Напоследок хотелось бы сказать чего в WordPress явно не хватает. Первое — это встроенный роутинг. Он, мягко говоря, деревянный. Сделать с его помощью путь можно любой, но придется повозится с регулярными выражениями и сопутствующими функциями, что неудобно. Надеюсь в будущем появится какое-нибудь удобное решение для этого. И второй момент — это валидация, встроенные методы для валидации также имеются но их мало и они не очень, опять же, удобные. Можно, конечно дернуть класс валидации от symfony или еще какой, но хотелось бы что-то нативное.

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

Автор: Иван Жук

Источник


  1. Vladimir Kamuz:

    Хорошо написал

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js