Статьи

Расширенный ООП для WordPress: настройка конечных точек API REST

Эта статья о расширенном ООП для WordPress была первоначально опубликована журналом Torque Magazine и воспроизводится здесь с разрешения.

За последние несколько лет я много писал об объектно-ориентированном PHP и API-интерфейсе WordPress REST для Torque. Я также коснулся использования Composer для управления зависимостями и обеспечения автозагрузчика, а также рассмотрел модульное тестирование. Основная идея всего, что я написал, заключается в том, что, применяя лучшие практики разработки программного обеспечения к тому, как мы разрабатываем для WordPress, мы можем создавать лучшие плагины.

Это первая из серии статей, которые объединят эти концепции в практический, функциональный пример. Я расскажу о создании плагина WordPress для изменения возможностей конечных точек API WordPress REST, чтобы их можно было лучше оптимизировать для поиска. Плагин доступен на GitHub . Возможно, вы захотите просмотреть журнал фиксации, чтобы увидеть, как я его собрал.

В этой серии я расскажу о структурировании плагинов и классов с использованием современного объектно-ориентированного PHP и не только о том, как сделать его тестируемым, но и о том, как писать для него автоматические тесты. Я расскажу о разнице между юнит-тестами, интеграционными тестами и приемочными тестами и покажу, как писать и автоматизировать запуск каждого типа. Эта статья начинает серию с демонстрации того, как использовать фильтры для модификации REST API WordPress с использованием объектно-ориентированного подхода.

Улучшение поиска в WordPress с помощью REST API

Плагины, такие как SearchWP или Relevansi , или интеграции с ElasticSearch — технологией, которая использует совершенно другой стек, чем WordPress — с использованием Jetpack или ElasticPress, часто используются для улучшения поиска WordPress. Эти типы плагинов обеспечивают лучшие результаты поиска и часто хорошо сочетаются с интерфейсом граненого поиска, который отлично подходит для приложений электронной коммерции.

Поиск через WordPress REST API наследует все эти проблемы и одно и то же решение. В этой статье я начну с рассмотрения того, как поиск работает по умолчанию, и каковы ограничения. Затем мы рассмотрим, как изменить поиск, используя два разных метода, и интегрировать его с SearchWP.

Встроенные в WordPress возможности поиска часто нуждаются в улучшении с помощью внешних сервисов. Хотя эта статья посвящена объектно-ориентированному подходу к изменению работы маршрутов WordPress REST API для публикаций, практическим примером будет улучшение поиска.

Когда WordPress используется в качестве серверной части для отделенного внешнего интерфейса, такого как собственное мобильное приложение или веб-приложение, вероятно, созданное с использованием Vue или React или Angular, важно обеспечить качественный поиск через REST API. Код, описанный в этой статье, поможет вам, если пользователям вашего приложения необходимо найти правильный вариант продукта или выполнить поиск контента по сложному алгоритму, основанному на нескольких таксономиях, и вы пишете собственный код, а не просто устанавливаете плагин.

Поиск сообщений с помощью WordPress REST API

Если вы хотите выполнить поиск по всем сообщениям, которые относятся к типу «продукт» на сайте, с помощью поисковых терминов «Taco Shirts» вы бы запросили конечную точку /wp/v2/product?s=Taco+Shirt . Если вы хотите улучшить качество результатов, помогут решения, перечисленные выше.

Как мы уже говорили выше, WP_Query , который используют конечные точки REST API WordPress, не является отличным инструментом для поиска. В частности, WP_Query , вероятно, из-за его зависимости от MySQL, уступает специализированным инструментам поиска, которые, как правило, WP_Query с использованием баз данных NoSQL.

Во-первых, давайте посмотрим, как мы можем обойти взаимодействия WP_Query с базой данных WordPress, если выполняется запрос REST API.

Это стратегия, которую используют многие поисковые плагины, чтобы заменить результаты своих собственных поисковых систем тем, что WP_Query сгенерировал бы по умолчанию. Поисковая система может использовать ту же базу данных. Он также может подключаться к какой-либо другой базе данных, возможно, через запрос API, например, к серверу ElasticSearch или Apache Solr.

Если вы заглянете в ядро ​​WordPress , то обнаружите, что фильтр «posts_pre_query» запускается непосредственно перед WP_Query как WP_Query запрашивает базу данных, но после того, как SQL-запрос был подготовлен. Этот фильтр возвращает ноль по умолчанию. Если это значение равно нулю, WordPress продолжает свое поведение по умолчанию: запрашивает базу данных WordPress и возвращает результаты в виде простого массива объектов WP_Post .

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

Давайте посмотрим, как мы можем использовать posts_pre_query для возврата фиктивного WP_Post . Эта стратегия очень полезна для тестирования, но для интеграции отдельной базы данных с вашим сайтом WordPress можно использовать более сложную версию того же шаблона:

 /** * Replace all WP_Query results with mock posts */ add_filter('posts_pre_query', function ($postsOrNull, \WP_Query $query) { //Don't run if posts are already sent if (is_null($postsOrNull)) { //Create 4 mock posts with different titles $mockPosts = []; for ($i = 0; $i <= 3; $i++) { $mockPosts[$i] = (new \WP_Post((new \stdClass()))); //Fake post for demonstration, could be any WP_Post $mockPosts[$i]->post_title = "Mock Post $i"; //Fake title will be different for each post, useful for testing. $mockPosts[$i]->filter = "raw"; //Bypass sanitzation in get_post, to prevent our mock data from being sanitized out. } //Return a mock array of mock posts return $mockPosts; } //Always return something, even if its unchanged return $postsOrNull; }, //Default priority, 2 arguments 10, 2 ); 

В этом примере мы используем фиктивные данные, но мы могли бы использовать класс запросов SearchWP или что-то еще. Еще один момент, о котором следует помнить об этом коде, это то, что он будет работать с любым WP_Query , а не только с объектом WP_Query созданным в WordPress REST API. Давайте изменим это, чтобы мы не использовали фильтр, если это не запрос WordPress REST API, добавив условную логику:

 <?php /** * Replace all WP_Query results with mock posts, for WordPress REST API requests */ add_filter('posts_pre_query', function ($postsOrNull, \WP_Query $query) { //Only run during WordPress REST API requests if (defined('REST_REQUEST') && REST_REQUEST) { //Don't run if posts are already sent if (is_null($postsOrNull)) { //Create 4 mock posts with different titles $mockPosts = []; for ($i = 0; $i <= 3; $i++) { $mockPosts[$i] = (new \WP_Post((new \stdClass()))); //Fake post for demonstration, could be any WP_Post $mockPosts[$i]->post_title = "Mock Post $i"; //Fake title will be different for each post, useful for testing. $mockPosts[$i]->filter = "raw"; //Bypass sanitzation in get_post, to prevent our mock data from being sanitized out. } //Return a mock array of mock posts return $mockPosts; } } //Always return something, even if its unchanged return $postsOrNull; }, //Default priority, 2 arguments 10, 2 ); 

Изменение аргументов конечных точек API WordPress REST

Мы только что посмотрели, как изменить способ генерации результатов поиска для запросов WordPress REST API. Это позволяет нам оптимизировать наши запросы для лучшего поиска, но, вероятно, выявляет необходимость в другой схеме для конечных точек.

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

Сквозные проблемы

Мы собираемся посмотреть, как изменить разрешенные аргументы конечной точки, а также как они используются для создания аргументов WP_Query . Это две отдельные проблемы, и принцип единой ответственности гласит, что нам нужен один класс для каждой проблемы. Но у обоих классов будут общие проблемы.

Например, если мы хотим разрешить запросы по разным типам постов, нам нужно знать, что такое публичные типы постов и каковы их аргументы slugs и rest_base. Это вся информация, которую мы можем получить из функции get_post_types .

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

Думайте об этом как об одной общей форме для всех данных типа записей, которые нам нужны в используемом контейнере:

 <?php /** * Class PreparedPostTypes * * Prepares post types in the format we need for the UsesPreparedPostTypes trait * @package ExamplePlugin */ class PreparedPostTypes { /** * Prepared post types * * @var array */ protected $postTypes; /** * PreparedPostTypes constructor. * @param array $postTypes Array of post type objects `get_post_types([], 'objects')` */ public function __construct(array $postTypes) { $this->setPostTypes($postTypes); } /** * Get an array of "rest_base" values for all public post types * * @return array */ public function getPostTypeRestBases(): array { return !empty($this->postTypes) ? array_keys($this->postTypes) : []; } /** * Prepare the post types * * @param array $postTypes */ protected function setPostTypes(array $postTypes) { $this->postTypes = []; /** @var \WP_Post_Type $postType */ foreach ($postTypes as $postType) { if ($postType->show_in_rest) { $this->postTypes[$postType->rest_base] = $postType->name; } } } /** * Convert REST API base to post type slug * * @param string $restBase * @return string|null */ public function restBaseToSlug(string $restBase) { if (in_array($restBase, $this->getPostTypeRestBases())) { return $this->postTypes[$restBase]; } return null; } } 

Обратите внимание, что мы не вызывали get_post_types() в классе, вместо этого мы использовали его как зависимость, внедренную через конструктор. В результате этот класс можно протестировать без загрузки WordPress.

Вот почему я бы назвал этот класс «тестируемым модулем». Он не опирается на другие API, и мы не беспокоимся о побочных эффектах. Мы можем проверить это как один, изолированный блок. Разделение задач и разделение функциональности на мелкие части делают код поддерживаемым, как только мы получим покрытие модульных тестов. Я посмотрю, как проверить этот класс в моем следующем посте.

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

Говоря о внедрении зависимостей, классах, которым требуются объекты этого нового класса, мы хотим следовать той же схеме. Вместо того, чтобы создавать экземпляры PreparedPostTypes внутри классов, которые в них нуждаются, мы передадим экземпляр. Это означает, что классы, использующие PreparedPostTypes и PreparedPostType остаются изолированными и могут тестироваться отдельно.

Это также может привести к повторному использованию кода, поскольку мы должны сделать возможным внедрение зависимостей и иметь свойство для этого объекта. Мы могли бы использовать вырезание и вставку, или мы могли бы использовать PHP Trait, который является причудливым и более масштабируемым способом копирования методов и свойств между классами.

Вот черта, которая устанавливает шаблон для того, как мы вводим объект PreparedPostTypes в другие классы

 <?php /** * Trait UsesPreparedPostTypes * @package ExamplePlugin */ trait UsesPreparedPostTypes { /** * Prepared post types * * @var PreparedPostTypes */ protected $preparedPostTypes; /** * UsesPreparedPostTypes constructor. * @param PreparedPostTypes $preparedPostTypes */ public function __construct(PreparedPostTypes $preparedPostTypes) { $this->preparedPostTypes = $preparedPostTypes; } } 

Наше другое беспокойство — нам нужно знать кое-что о типе поста в нескольких местах. Например слизняк типа поста. Это другой вид сквозного вопроса, чем предыдущий. Последняя проблема, которую мы решили, связана с динамическими данными. Теперь нам просто нужно одно место, чтобы изменить одну или две строки, которые мы используем в нескольких местах.

Класс, который имеет константы класса, решает это для нас просто:

 <?php /** * Class PostType * * Post type whose POST wp/v2/<post-type-rest_base> we are hijacking * */ class PostType { /** * Post type slug * * @TODO Change this to your post type's slug */ const SLUG = 'post'; /** * Post type rest_base * * @TODO Change this to your post type's rest_base */ const RESTBASE = 'posts'; } 

Теперь мы можем поддерживать эти строки в нашем коде. Это может показаться ненужным шагом. Но мой пример кода работает для типа сообщений post. Если вы хотите изменить тип используемого сообщения, этот класс должен измениться, и больше ничего не нужно менять. Это следует из предпочтительного определения Томом Макфарлином принципа единой ответственности, когда он пишет: « У класса должна быть только одна причина для изменения. »

Изменение схем конечных точек API REST

Теперь нам нужно изменить схему конечных точек типа записи. Таким образом WordPress сообщит конечным точкам API REST, что аргумент post_type разрешен, а когда запросы анализируются, разрешается новый аргумент конечной точки.

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

 <?php /** * Class ModifySchema * * Modifies the REST API route schema so it has an argument "post_type" * * * @package ExamplePlugin */ class ModifySchema { use UsesPreparedPostTypes; /** * The name of the extra argument we are adding to post type routes */ const ARGNAME = 'post_type'; /** * Add post_type to schema * * @uses ""rest_{$postType}_collection_params" action * * @param array $query_params JSON Schema-formatted collection parameters. * @param \WP_Post_Type $post_type Post type object. * * @return array */ public function filterSchema($query_params, $post_type) { if ($this->shouldFilter($post_type)) { $query_params[self::ARGNAME] = [ [ 'default' => PostType::RESTBASE, 'description' => __('Post type(s) for search query'), 'type' => 'array', //Limit to public post types and allow query by rest base 'items' => [ 'enum' => $this->preparedPostTypes->getPostTypeRestBases(), 'type' => 'string', ], ] ]; } return $query_params; } /** * Check if this post type's schema should be filtered * * @param \WP_Post_Type $WP_Post_Type * @return bool */ public function shouldFilter(\WP_Post_Type $WP_Post_Type): bool { return PostType::SLUG === $WP_Post_Type->name; } } 

В настройке этого атрибута мы сообщаем WordPress, что этот атрибут является атрибутом массива, и мы указываем допустимые значения, используя индекс «enum» массива.

В «enum» мы перечисляем допустимые значения. В этом случае класс PreparedPostTypes предоставляет этот массив допустимых значений, поскольку это уже решенная ранее сквозная проблема.

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

Мы вскоре вернемся к тому, какие хуки использовать для того, чтобы этот класс работал для определенного типа поста.

Изменение аргументов REST API WP_Query

В предыдущем разделе показано, как сделать доступным новый атрибут конечной точки post_type . На самом деле это не меняет аргументы WP_Query генерирует API WordPress REST. У нас есть все, что нужно, кроме одного последнего фильтра.

Тип записи — это один аргумент WP_Query который ядро специально запрещает изменять с помощью запроса REST API. У нас есть динамически названный фильтр — rest_{$post_type}_query — который может переопределить любые аргументы WP_Query .

Вот наш класс, который внедряет наши аргументы post_type , которые ранее не допускались:

 <?php /** * Class ModifyQuery * * Modify WP_Query Args * * @package ExamplePlugin */ class ModifyQueryArgs { use UsesPreparedPostTypes; /** * Filter query args if needed * * @param array $args Key value array of query var to query value. * @param \WP_REST_Request $request The request used. * * @return array */ public function filterQueryArgs($args, $request) { if ($this->shouldFilter($request)) { add_filter('posts_pre_query', [FilterWPQuery::class, 'posts_pre_query'], 10, 2); $args['post_type'] = $this->restBasesToPostTypeSlugs($request[ModifySchema::ARGNAME]); } return $args; } /** * Check if we should filter request args * * @param \WP_REST_Request $request * @return bool */ public function shouldFilter(\WP_REST_Request $request): bool { $attributes = $request->get_attributes(); if (isset($attributes['args'][ModifySchema::ARGNAME])) { if ($request->get_param(ModifySchema::ARGNAME)) { return true; } } return false; } /** * Convert an array of rest bases to post type slugs * * @param array $postTypes * @return array */ public function restBasesToPostTypeSlugs(array $postTypes): array { $postTypeSlugs = []; foreach ($postTypes as $postTypeRestBase) { if ($this->preparedPostTypes->restBaseToSlug($postTypeRestBase)) { $postTypeSlugs[] = $this->preparedPostTypes->restBaseToSlug($postTypeRestBase); } } return $postTypeSlugs; } } 

Большая часть этого просто get_param нужно ли нам внести изменение, а затем использовать метод WP_Rest_Request для получения значения из запроса. Большинство из них автоматические, потому что мы изменяем схему, чтобы она соответствовала первой.

Модификация объекта WP_Query для запроса WordPress REST API

Я уже показал, как это сделать, в первой части этого поста. Вот класс, который реализует тот же шаблон:

 <?php /** * Class FilterWPQuery * * Changes WP_Query object * * @package ExamplePlugin */ class FilterWPQuery { /** * Demonstrates how to use a different way to set the posts that WP_Query returns * @uses "posts_pre_query" * * @param $postsOrNull * @param \WP_Query $query * @return mixed */ public static function posts_pre_query($postsOrNull, $query) { //Only run during WordPress API requests if (defined('REST_REQUEST') && REST_REQUEST) { //Prevent recursions remove_filter('posts_pre_query', [FilterWPQuery::class, 'posts_pre_query'], 10); //Don't run if posts are already sent if (is_null($postsOrNull)) { //Create 4 mock posts with different titles $mockPosts = []; for ($i = 0; $i <= 3; $i++) { $mockPosts[$i] = (new \WP_Post((new \stdClass()))); $mockPosts[$i]->post_title = "Mock Post $i"; $mockPosts[$i]->filter = "raw"; } //Return a mock array of mock posts return $mockPosts; } //Always return something, even if its unchanged return $postsOrNull; } } } init.php <?php /** * Make this all work */ add_action('init', function () { $postType = PostType::SLUG; $preparedPostType = new PreparedPostTypes(get_post_types([], 'objects')); $modifySchema = new ModifySchema($preparedPostType); add_filter("rest_{$postType}_collection_params", [$modifySchema, 'filterSchema'], 25, 2); $modifyQuery = new ModifyQueryArgs($preparedPostType); add_filter("rest_{$postType}_query", [$modifyQuery, 'filterQueryArgs'], 25, 2); }); 

Я надеюсь, что вы заметили, что этот код очень сильно привязан к WordPress и не будет тестируемым. Он использует WP_Post из WordPress, проверяет константу из WordPress и взаимодействует с API плагинов WordPress. Мы можем посмеяться над WP_Post и сами установить константу. Но API плагина — это важная функция для тестирования. В следующих моих постах я расскажу о том, как реорганизовать этот класс, чтобы мы могли использовать модульные тесты, чтобы охватить все, кроме эффектов удаления этого фильтра, и интеграционных тестов, чтобы проверить этот эффект.

Я решил использовать статический метод по двум причинам. Во-первых, это позволило легко добавлять и удалять его в нескольких местах. Например, в классе ModifyQuery я подключаю этот фильтр только при необходимости:

 add_filter('posts_pre_query', [FilterWPQuery::class, 'posts_pre_query'], 10, 2); $args['post_type'] = $this->restBasesToPostTypeSlugs($request[ModifySchema::ARGNAME]); } return $args; } /** * Check if we should filter request args * * @param \WP_REST_Request $request * @return bool */ public function shouldFilter(\WP_REST_Request $request): bool { $attributes = $request->get_attributes(); if (isset($attributes['args'][ModifySchema::ARGNAME])) { if ($request->get_param(ModifySchema::ARGNAME)) { return true; } } return false; } /** * Convert an array of rest bases to post type slugs * * @param array $postTypes * @return array */ public function restBasesToPostTypeSlugs(array $postTypes): array { $postTypeSlugs = []; foreach ($postTypes as $postTypeRestBase) { if ($this->preparedPostTypes->restBaseToSlug($postTypeRestBase)) { $postTypeSlugs[] = $this->preparedPostTypes->restBaseToSlug($postTypeRestBase); } } return $postTypeSlugs; } } ModifySchema.php <?php /** * Class ModifySchema * * Modifies the REST API route schema so it has an argument "post_type" * * * @package ExamplePlugin */ class ModifySchema { use UsesPreparedPostTypes; /** * The name of the extra argument we are adding to post type routes */ const ARGNAME = 'post_type'; /** * Add post_type to schema * * @uses ""rest_{$postType}_collection_params" action * * @param array $query_params JSON Schema-formatted collection parameters. * @param \WP_Post_Type $post_type Post type object. * * @return array */ public function filterSchema($query_params, $post_type) { if ($this->shouldFilter($post_type)) { $query_params[self::ARGNAME] = [ [ 'default' => PostType::RESTBASE, 'description' => __('Post type(s) for search query'), 'type' => 'array', //Limit to public post types and allow query by rest base 'items' => [ 'enum' => $this->preparedPostTypes->getPostTypeRestBases(), 'type' => 'string', ], ] ]; } return $query_params; } /** * Check if this post type's schema should be filtered * * @param \WP_Post_Type $WP_Post_Type * @return bool */ public function shouldFilter(\WP_Post_Type $WP_Post_Type): bool { return PostType::SLUG === $WP_Post_Type->name; } } 

Кроме того, при использовании этого фильтра легко создавать рекурсивные циклы. Возможность легко удалить его, как в этом примере кода, очень приятна.

Другая причина, по которой я решил использовать статический метод, заключается в том, что функция взаимодействует с другими API. Он никогда не будет действительно модульно тестируемым. Этот шаблон, класс со статическими методами, позволяет очень легко издеваться над классом в интеграционных тестах, сводя к минимуму последствия отсутствия сильной изоляции в этой одной части системы.

Заставить все это работать вместе

Код, который мы рассмотрели до сих пор, очень не связан с WordPress. Это имеет много преимуществ. Но это означает, что как есть, он ничего не делает. Все в порядке. До сих пор мы обрабатывали только требования бизнес-логики. Теперь нам нужно взглянуть на интеграцию.

Это не слишком сложно, это просто вопрос добавления некоторых хуков. Какие крючки? Точно такие же две ModifyQuery мы разработали для наших ModifyQuery и ModifySchema . Желание отделить бизнес-логику не означает, что мы не можем думать о фактической причине, по которой мы пишем код, когда разрабатываем его общедоступный интерфейс. В противном случае, мы просто добавим дополнительную сложность в наш код без причины.

В общем, я стараюсь и усложняю программное обеспечение только тогда, когда оно облегчает жизнь. Я отклонился от этого пути в прошлом. У всех нас, все нормально, практикуем прощение.

Методы в классах, которые мы собираемся перехватить, используют точно такие же аргументы и возвращаемые типы, что и перехватчики. Их задача — передать бизнесу эти ценности другим компонентам.

 <?php /** * Make this all work */ add_action('init', function () { $postType = PostType::SLUG; $preparedPostType = new PreparedPostTypes(get_post_types([], 'objects')); $modifySchema = new ModifySchema($preparedPostType); add_filter("rest_{$postType}_collection_params", [$modifySchema, 'filterSchema'], 25, 2); $modifyQuery = new ModifyQueryArgs($preparedPostType); add_filter("rest_{$postType}_query", [$modifyQuery, 'filterQueryArgs'], 25, 2); }); 

Next Up: Тестирование

Это достаточно близко для джаза. Это будет работать. Отсутствие формальной системы добавления хуков — это лучшее, что мы можем сделать для инициализации. Все в порядке. Я расскажу, как создать более сложный и расширяемый процесс начальной загрузки в следующем посте об интеграционном тестировании с WordPress.

В этом посте мы рассмотрели создание кода для изменения схемы, WP_Query аргумента WP_Query и базовый WP_Query для типа поста. Я бы посоветовал вам превратить этот код в плагин, используя Composer для автозагрузчика. В моем следующем посте мы рассмотрим модульные тесты для охвата этих классов.