WordPress is no excuse for bad code
Enterprise application architecture for plugin development — SOLID down to the last letter.
A1
/
С чего всё началось
Мы сделали очень много проектов на WordPress и WooCommerce, и это было больно
За плечами у нас годы в Laravel и Symfony.
Но, вернувшись в WordPress, мы снова начали плодить синглтоны, вешать хуки в конструкторах, тянуть глобалы и копировать проверки прав из обработчика в обработчик.
«Хватит», — решили мы и приступили к разработке собственного фреймворка.
B1
/
Одно правило
Никогда не оспаривать поток управления WordPress
Большинство фреймворков для WordPress на одном из хуков перехватывают обработку запроса. Ядро WordPress при этом продолжает работать как обычно, и в одном запросе начинают сосуществовать уже два жизненных цикла, которые приходится «склеивать».
«Клей» завязан на детали реализации ядра, а очередное обновление WordPress может изменить любую из них. И тогда два жизненных цикла начнут конфликтовать.
Поэтому наш подход — иной
Жизненный цикл один, и принадлежит он WordPress. Фреймворк встраивается в него через штатные API, и «клей» ему не нужен.
HTTP-слой, с помощью которого фреймворк взаимодействует с внешним миром — разбирает запрос и собирает ответ, — написан строго в соответствии со спецификациями.
Для WordPress фреймворк остаётся обычным плагином.
RFC 2046
Multipurpose Internet Mail Extensions (MIME) Part Two: Media Types
RFC 3629
UTF-8, a transformation format of ISO 10646
RFC 3986
Uniform Resource Identifier (URI): Generic Syntax
RFC 5234
Augmented BNF for Syntax Specifications: ABNF
RFC 6838
Media Type Specifications and Registration Procedures
RFC 9110
HTTP Semantics
WHATWG
URL Standard
A2
/
Как это выглядит
Хуки в одном файле
В WordPress хуки регистрируют через add_action и add_filter там, где придётся: в конструкторах классов, внутри других хуков. Чтобы понять, в каких точках плагин взаимодействует с ядром WordPress, приходится искать по всему коду, а обработчики нередко создаются, даже если хук так и не сработает.
В WordPress plugin framework все обработчики регистрируются в файле hooks.php. Каждый из них создаётся только в момент срабатывания хука и, помимо его аргументов, получает объект запроса.
return hooks()
->action(
'woocommerce_order_status_completed',
action(ExportOrderActionInterface::class),
)
->filter(
'woocommerce_billing_fields',
action(AddCompanyCodeFieldActionInterface::class),
)
->activation(
file(),
action(GenerateWebhookSecretActionInterface::class),
);
readonly class AddCompanyCodeFieldAction implements AddCompanyCodeFieldActionInterface
{
public function __construct(
protected TranslatorInterface $translator,
) {
}
public function __invoke(
RequestInterface $request,
array $fields,
string $country,
): array {
if ($country === 'LT') {
$fields['billing_company_code'] = [
'type' => 'text',
'label' => $this->translator->translate('Company code', 'label'),
'placeholder' => $this->translator->translate('Company code', 'placeholder'),
];
}
return $fields;
}
}
readonly class ExportOrderAction implements ExportOrderActionInterface
{
public function __construct(
protected RequestBuilderInterface $requestBuilder,
protected ClientInterface $client,
protected TranslatorInterface $translator,
) {
}
public function __invoke(
RequestInterface $request,
int $orderId,
WC_Order $order,
array $statusTransition,
): void {
$response = $this->client->send(
$this->requestBuilder
->method(Method::Post)
->url('https://api.example.com/import-order')
->body(
[
'number' => $order->get_order_number(),
'email' => $order->get_billing_email(),
'total' => $order->get_total(),
],
'application/json',
)
->build()
);
if ($response->statusCode() >= 300) {
$order->add_order_note(
sprintf(
$this->translator->translate('ERP export failed with HTTP %d.'),
$response->statusCode(),
),
);
}
}
}
readonly class GenerateWebhookSecretAction implements GenerateWebhookSecretActionInterface
{
public function __construct(
protected OptionsInterface $options,
) {
}
public function __invoke(bool $networkWide): void
{
$this->options->add(
'webhook_secret',
wp_generate_password(64, false),
);
}
}
Роуты в одном файле
В WordPress REST-эндпоинт регистрируют через register_rest_route на хуке rest_api_init, обработчик admin-ajax вешают сразу на wp_ajax_ и wp_ajax_nopriv_, а фид добавляют через add_feed. И отвечают они по-разному: возвратом WP_REST_Response, вызовом wp_send_json или выводом через echo.
В WordPress plugin framework все роуты регистрируются в файле routes.php. Обработчик любого из них получает объект запроса и возвращает объект ответа, а всю остальную работу фреймворк берёт на себя.
return routes()
->rest(
'shop/v1',
'pickup-points/(?P<code>[\w-]+)',
action(GetPickupPointByCodeActionInterface::class),
Method::Get,
)
->adminAjax(
'nearest_pickup_points',
action(GetNearestPickupPointsActionInterface::class),
fn(PipelineBuilderInterface $pipelineBuilder) => $pipelineBuilder
->validate(fn(ValidatorsBuilderInterface $validatorsBuilder) => $validatorsBuilder
->bodyQuery('latitude', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
->float()
->min(-90)
->max(90))
->bodyQuery('longitude', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
->float()
->min(-180)
->max(180))),
)
->feed(
'kaina24.xml',
action(GetKaina24FeedActionInterface::class),
);
readonly class GetKaina24FeedAction implements GetKaina24FeedActionInterface
{
public function __construct(
protected DatabaseInterface $database,
protected ResponseBuilderInterface $responseBuilder,
) {
}
public function __invoke(RequestInterface $request): ResponseBuilderInterface
{
$products = $this->database->select(
'SELECT posts.ID, posts.post_title, wc_product_meta_lookup.sku, wc_product_meta_lookup.min_price FROM :posts AS posts JOIN :wc_product_meta_lookup AS wc_product_meta_lookup ON wc_product_meta_lookup.product_id = posts.ID WHERE posts.post_status = %s AND wc_product_meta_lookup.stock_status = %s',
'publish',
'instock',
);
return $this->responseBuilder
->statusCode(200)
->header('Cache-Control', 'public, max-age=3600')
->body($products, 'application/xml');
}
}
readonly class GetNearestPickupPointsAction implements GetNearestPickupPointsActionInterface
{
public function __construct(
protected DatabaseInterface $database,
protected JsonInterface $json,
protected ResponseBuilderInterface $responseBuilder,
) {
}
public function __invoke(RequestInterface $request): ResponseBuilderInterface
{
$bodyQuery = $request->method() === Method::Post ? $request->body() : $request->url()->query();
$pickupPoints = $this->database->select(
'SELECT code, name, latitude, longitude, sizes, 6371 * ACOS(SIN(RADIANS(latitude)) * SIN(RADIANS(%f)) + COS(RADIANS(latitude)) * COS(RADIANS(%f)) * COS(RADIANS(longitude - %f))) AS distance FROM :pickup_points ORDER BY distance LIMIT 10',
$bodyQuery->get('latitude'),
$bodyQuery->get('latitude'),
$bodyQuery->get('longitude'),
);
foreach ($pickupPoints as $key => $pickupPoint) {
$pickupPoints[$key]['sizes'] = $this->json->decode($pickupPoint['sizes']);
}
return $this->responseBuilder
->statusCode(200)
->body($pickupPoints, 'application/json');
}
}
readonly class GetPickupPointByCodeAction implements GetPickupPointByCodeActionInterface
{
public function __construct(
protected DatabaseInterface $database,
protected JsonInterface $json,
protected ResponseBuilderInterface $responseBuilder,
) {
}
public function __invoke(
RequestInterface $request,
string $code,
): ResponseBuilderInterface {
$pickupPoints = $this->database->select(
'SELECT code, name, latitude, longitude, sizes FROM :pickup_points WHERE code = %s',
$code,
);
if ($pickupPoints === []) {
return $this->responseBuilder
->statusCode(404);
}
foreach ($pickupPoints as $key => $pickupPoint) {
$pickupPoints[$key]['sizes'] = $this->json->decode($pickupPoint['sizes']);
}
return $this->responseBuilder
->statusCode(200)
->body($pickupPoints[0], 'application/json');
}
}
Middleware для хуков и роутов
В WordPress проверки nonce, прав пользователя и входных данных пишут прямо в обработчиках: их копируют из одного в другой, каждый раз немного иначе, а иногда и вовсе забывают.
В WordPress plugin framework для этого есть middleware — код, который выполняется до обработчика или вокруг него и назначается при регистрации хука или роута. Любой middleware может остановить запрос, например если не пройдена проверка, и тогда обработчик не выполнится. Хуки и роуты с общим набором middleware можно объединить в группу.
- hooks.php
- routes.php
- ImportPickupPointsAction.php
- UpdateMinimumAgeAction.php
- UpdateWholesalePriceAction.php
return hooks()
->group(
fn(HooksBuilderInterface $hooksBuilder) => $hooksBuilder
->action(
'created_product_cat',
action(UpdateMinimumAgeActionInterface::class),
)
->action(
'edited_product_cat',
action(UpdateMinimumAgeActionInterface::class),
),
fn(PipelineBuilderInterface $pipelineBuilder) => $pipelineBuilder
->verifyNonce('nonce', 'update_minimum_age')
->currentUserCan(Capability::ManageWooCommerce)
->validate(fn(ValidatorsBuilderInterface $validatorsBuilder) => $validatorsBuilder
->body('minimum_age', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
->enum(MinimumAge::class))),
)
->action(
'woocommerce_process_product_meta',
action(UpdateWholesalePriceActionInterface::class),
10,
fn(PipelineBuilderInterface $pipelineBuilder) => $pipelineBuilder
->validate(fn(ValidatorsBuilderInterface $validatorsBuilder) => $validatorsBuilder
->condition(
fn(ValidatorsBuilderInterface $validatorsBuilder) => $validatorsBuilder
->body('wholesale_price', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
->isNotEmpty()),
fn(ValidatorsBuilderInterface $validatorsBuilder) => $validatorsBuilder
->body('wholesale_price', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
->float())
->compareFloats(fn(ComparisonValidatorBuilderInterface $comparisonValidatorBuilder) => $comparisonValidatorBuilder
->body('wholesale_price')
->lessThan()
->toBody('_regular_price')),
)),
);
return routes()
->rest(
'shop/v1',
'pickup-points',
action(ImportPickupPointsActionInterface::class),
Method::Put,
fn(PipelineBuilderInterface $pipelineBuilder) => $pipelineBuilder
->currentUserCan(Capability::ManageWooCommerce)
->validate(fn(ValidatorsBuilderInterface $validatorsBuilder) => $validatorsBuilder
->body('pickup_points', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
->array()
->isNotEmpty())
->body('pickup_points[*].code', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
->string()
->isNotEmpty())
->body('pickup_points[*].name', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
->string()
->isNotEmpty())
->body('pickup_points[*].latitude', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
->float()
->min(-90)
->max(90))
->body('pickup_points[*].longitude', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
->float()
->min(-180)
->max(180))
->body('pickup_points[*].sizes', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
->array()
->isNotEmpty())
->body('pickup_points[*].sizes[*]', fn(RulesBuilderInterface $rulesBuilder) => $rulesBuilder
->enum(Size::class)))
->transaction(),
);
readonly class ImportPickupPointsAction implements ImportPickupPointsActionInterface
{
public function __construct(
protected DatabaseInterface $database,
protected JsonInterface $json,
protected ResponseBuilderInterface $responseBuilder,
) {
}
public function __invoke(RequestInterface $request): ResponseBuilderInterface
{
$this->database->delete('DELETE FROM :pickup_points');
$this->database->insert(
'INSERT INTO :pickup_points (code, name, latitude, longitude, sizes) VALUES (%s, %s, %f, %f, %s)',
$request->body()->all('pickup_points[*].code'),
$request->body()->all('pickup_points[*].name'),
$request->body()->all('pickup_points[*].latitude'),
$request->body()->all('pickup_points[*].longitude'),
array_map(
$this->json->encode(...),
$request->body()->all('pickup_points[*].sizes'),
),
);
return $this->responseBuilder
->statusCode(204);
}
}
readonly class UpdateMinimumAgeAction implements UpdateMinimumAgeActionInterface
{
public function __invoke(
RequestInterface $request,
int $termId,
int $termTaxonomyId,
array $args,
): void {
update_term_meta(
$termId,
'minimum_age',
MinimumAge::from($request->body()->get('minimum_age'))->value,
);
}
}
readonly class UpdateWholesalePriceAction implements UpdateWholesalePriceActionInterface
{
public function __invoke(
RequestInterface $request,
int $postId,
WP_Post $post,
): void {
update_post_meta(
$postId,
'wholesale_price',
$request->body()->get('wholesale_price'),
);
}
}
Представления и согласование содержимого
В WordPress шаблон подключают через include прямо в обработчике, и ему достаются все объявленные там переменные. Если те же данные нужны и странице в HTML, и скрипту в JSON, пишут второй обработчик или заворачивают HTML в JSON через wp_send_json_success([‘html’ => …]).
В WordPress plugin framework представление — это класс с публичными полями и именем шаблона, и шаблон видит только эти поля. Обработчик возвращает представление и перечисляет форматы, в которых его можно отдать. Дальше срабатывает согласование содержимого: клиент в заголовке Accept сообщает, какие форматы принимает, а фреймворк выбирает подходящий и сам кодирует ответ.
return routes()
->adminAjax(
'cart_summary',
action(GetCartSummaryActionInterface::class),
);
readonly class GetCartSummaryAction implements GetCartSummaryActionInterface
{
public function __construct(
protected ResponsesBuilderInterface $responsesBuilder,
) {
}
public function __invoke(RequestInterface $request): ResponsesBuilderInterface
{
$cartSummaryView = new CartSummaryView(
WC()->cart->get_cart_contents_count(),
(float) WC()->cart->get_total('edit'),
);
return $this->responsesBuilder
->statusCode(200)
->body($cartSummaryView, 'text/html', 'application/json');
}
}
readonly class CartSummaryView implements RenderableInterface
{
public function __construct(
public int $count,
public float $total,
) {
}
public function name(): string
{
return 'cart-summary';
}
}
<a class="cart-summary" href="<?= $escaper->url(wc_get_cart_url()) ?>">
<span class="cart-summary__count"><?= $escaper->html((string) $view->count) ?></span>
<?= wc_price($view->total) ?>
</a>
A3
/
Дорожная карта
Работаем над ближайшим релизом
Задачи релизов собраны в отдельных milestones — посмотрите, есть ли среди них что-то, что нужно вашему плагину.
B3
/
Become a contributor
Присоединяйтесь к разработке
WordPress plugin framework — проект с открытым исходным кодом, и участвовать в его развитии можно по-разному.
В обсуждениях задавайте вопросы и предлагайте идеи, а найденные ошибки описывайте в issues с шагами для воспроизведения.
Исправления и новые возможности присылайте pull request’ами в ветку dev, а чтобы не пропустить выход новой версии, подпишитесь в репозитории на уведомления о релизах.
