EasyAdmin — один из самых популярных генераторов административных панелей, доступных для Symfony-приложений. Поскольку для аутентификации пользователей он использует стандартный компонент безопасности Symfony, он позволяет входить в систему и изменять данные множеству пользователей одновременно.

Но есть одна проблема…

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

Теперь вы нажимаете кнопку “Сохранить”. Что же происходит в этом случае?

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

Ниже приведен фрагмент кода прямиком из документации Doctrine, который демонстрирует, как это работает:

<?php

use Doctrine\DBAL\LockMode;
use Doctrine\ORM\OptimisticLockException;

$theEntityId = 1;
$expectedVersion = 184;

try {
   $entity = $em->find('Article', $theEntityId, LockMode::OPTIMISTIC, $expectedVersion);
   // делаем что-то
   $em->flush();
} catch(OptimisticLockException $e) {
   echo "Sorry, but someone else has already changed this entity. Please apply the changes again!";
}

Если кто-то изменяет сущность,

не будет совпадать, и вы получите сообщение об ошибке. Хотя эта стратегия и разрешает конфликты значений, она идет в ущерб юзер экспириенсу (UX). А как насчет уведомления пользователя, которое бы предупреждало его до того, как он попытается сохранить свои изменения?

Mercure

Mercure — это опенсорсное решение для быстрого и надежного обмена сообщениями в режиме реального времени. Это современная и удобная замена как базовому WebSocket API, так и основанных на нем высокоуровневых библиотек и сервисов.

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

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

Демонстрация

Администратор по имени Семен редактирует первый артикул, с целью уменьшить его количество (quantity) с 10 до 9:

В то же время администратор по имени Леонид редактирует тот же самый артикул с целью поднять цену (price) до 51:

Администратор Семен подтверждает свои изменения, нажимая одну из кнопок сохранения.

Администратор Леонид в режиме реального времени получает уведомление, а JavaScript обрабатывает информацию из полученного сообщения, чтобы предупредить пользователя о том, что этот артикул уже был изменена кем-то другим, по он его редактировал. Ему предлагается перезагрузить страницу перед внесением собственных изменений:

Администратор Леонид обновляет страницу, и теперь видит актуальную цену.

Администратор Леонид уменьшает количество до 9 и сохраняет.

Окончательное состояние артикула:

  • Quantity: 9

  • Price: 51

Без уведомления в реальном времени его состояние было бы таким:

  • Quantity: 10

  • Price: 51

А модификация Семена была бы утеряна.

Как это реализовать?

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

composer require mercure-bundle

Этот бандл включает в себя рецепт для Docker для добавления контейнера Mercure. Возможно, вам придется немного изменить параметры Mercure, если вы не собираетесь использовать рецепт для Docker.

Но он будет работать уже на дефолтных настройках, так что вы должны видеть уведомления без каких-нибудь дополнительных конфигураций. Конечно, чтобы Mercure заработал, вам нужно будет обновить Docker-контейнеры вашего проекта.

docker compose up –wait

Теперь вы должны увидеть контейнер Mercure.

docker ps

CONTAINER ID   IMAGE         	COMMAND              	CREATED     	STATUS     	PORTS                                  	NAMES
cb37e7a21a64   dunglas/mercure   "/usr/bin/caddy run …"   8 seconds ago   Up 7 seconds   443/tcp, 2019/tcp, 0.0.0.0:50943->80/tcp   easyadmin-mercure-demo-mercure-1

В следующем выводе мы видим, что открытый порт — 50943. Вы также можете использовать команду docker port, чтобы проверить локальные порты:

docker port cb37e7a21a64
80/tcp -> 0.0.0.0:50943

Если вы пользуетесь командной строкой в Symfony, контейнер Mercure будет автоматически обнаруживаться и отображать переменные окружения MERCURE_PUBLIC_URL и MERCURY_URL (работает только с Docker-образом dunglas/mercure). (документация)

Реализация

Технически, когда Mercure запущен, создается HTML-атрибут data-ea-mercure-url, содержащий топик, на который пользователю нужно подписаться, чтобы он мог получать уведомления: 

data-ea-mercure-url="{{ ea_call_function_if_exists('mercure', mercure_topic, {'hub' : hub_name}) }}"

Затем мы подписываемся на этот топик Mercure с помощью объекта EventSource:

const mercureURL = document.body.getAttribute('data-ea-mercure-url');
if (! mercureURL) {
   return;
}


const eventSource = new EventSource(mercureURL);

При получении уведомления из него извлекаются идентификаторы связанных сущностей, и HTML реагирует соответствующим образом, чтобы уведомить пользователя:

eventSource.onmessage = event => {
   const data = JSON.parse(event.data);
   const action = data.action;
   const id = Object.values(data.id)[0];
   const bodyId = document.body.getAttribute('id');
   const row = document.querySelector('tr[data-id="'+id+'"]');

С полной версией кода можно ознакомиться здесь.

Демонстрационное приложение

Существует небольшое демонстрационное приложение, в котором вы можете легко протестировать этот функцинал. Найти его можно по следующей ссылке: https://github.com/coopTilleuls/easyadmin-mercure-demo.

README объясняет, как запустить приложение с помощью Docker и командной строки Symfony. Вернуться к стандартному поведению вы можете, просто удалив mercur-bundle:

composer remove mercure-bundle
   docker compose down --remove-orphans
   docker compose up --wait

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

Тестирование с помощью Panther

Данный механиз является хорошим юзкейсом не только для Mercure, но и для Panther. Действительно, мы не можем покрыть его стандартными функциональными тестами Symfony (расширяя WebTestCase). Но мы можем использовать Panther, который работает с JavaScript, поскольку использует реальный headless-браузер (Chrome или Firefox). В данном случае задача становится немного сложнее, потому что мы должны использовать два полностью изолированных инстанса браузера (администратор 1 и администратор 2).

Он предоставляет специальный тест кейс Symfony\Component\Panther\PantherTestCase, который содержит несколько полезных методов и утверждений. Давайте посмотрим, какой код мы можем написать для тестирования сценария, который мы рассматривали выше:

<?php

declare(strict_types=1);

namespace App\Tests\E2E\Controller\Admin;

use Facebook\WebDriver\Exception\NoSuchElementException;
use Facebook\WebDriver\Exception\TimeoutException;
use Symfony\Component\Panther\PantherTestCase as E2ETestCase;

/**
* @see https://github.com/symfony/panther#creating-isolated-browsers-to-test-apps-using-mercure-or-websockets
*/
final class ArticleCrudControllerTest extends E2ETestCase
{
   private const SYMFONY_SERVER_URL = 'http://127.0.0.1:8000'; // используем локальный веб-сервер Symfony CLI

   private const ARTICLE_LIST_URL = '/admin?crudAction=index&crudControllerFqcn=App\Controller\Admin\ArticleCrudController';

   // это второй артикул, так как мы создаем первый в тесте AdminCrud
   private const ARTICLE_EDIT_URL = '/admin?crudAction=edit&crudControllerFqcn=App\Controller\Admin\ArticleCrudController&entityId=2';

   private const ARTICLE_NEW_URL = '/admin?crudAction=new&crudControllerFqcn=App\Controller\Admin\ArticleCrudController';

   private const NOTIFICATION_SELECTOR = '#conflict_notification';

   /**
    * @throws NoSuchElementException
    * @throws TimeoutException
    */
   public function testMercureNotification(): void
   {
       $this->takeScreenshotIfTestFailed();

       // подключается первый администратор 
       $client = self::createPantherClient([
           'external_base_uri' => self::SYMFONY_SERVER_URL,
       ]);

       $client->request('GET', self::ARTICLE_LIST_URL);
       self::assertSelectorTextContains('body', 'Article');
       self::assertSelectorTextContains('body', 'Add Article');

       // первый администратор создает артикул
       $client->request('GET', self::ARTICLE_NEW_URL);
       $client->submitForm('Create', [
           'Article[code]' => 'CDB142',
           'Article[description]' => 'Chaise de bureau 2',
           'Article[quantity]' => '10',
           'Article[price]' => '50',
       ]);

       // первый администратор получает доступ к странице редактирования артикула, которую он только что создал
       $client->request('GET', self::ARTICLE_EDIT_URL);

       self::assertSelectorTextContains('body', 'Save changes');

       // второй администратор получает доступ к странице редактирования того же артикула и изменяет количество
       $client2 = self::createAdditionalPantherClient();
       $client2->request('GET', self::ARTICLE_EDIT_URL);
       $client2->submitForm('Save changes', [
           'Article[quantity]' => '9',
       ]);

       // первый администратор получил уведомление благодаря Mercure, и ему предлагается перезагрузить страницу
       $client->waitForVisibility(self::NOTIFICATION_SELECTOR);

       self::assertSelectorIsVisible(self::NOTIFICATION_SELECTOR);
       self::assertSelectorTextContains(self::NOTIFICATION_SELECTOR, 'The data displayed is outdated');
       self::assertSelectorTextContains(self::NOTIFICATION_SELECTOR, 'Reload');
   }
}

Самое интересное вы можете увидеть здесь:

$client2 = self::createAdditionalPantherClient();

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

Уведомления из других источников

Ваши сущности, конечно, также могут быть изменены другими источниками, не только EasyAdmin.

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

// use Symfony\Component\Mercure\Update;
// use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
// use Symfony\Component\Mercure\HubInterface;
$topic = $this->adminUrlGenerator->setController(Article::class)
   ->unsetAllExcept('crudControllerFqcn')->generateUrl();
$update = new Update(
   $topic,
   (string) json_encode(['id' => 1]),
);


$this->hub->publish($update);

Заключение

Это был конкретный юзкейс, показывающий, как Mercure может помочь улучшить юзер экспириенс.

Конечно, использование блокировки Doctrine было бы более надежным решением. Мы рекомендуем консолидировать состояние данных, используя оба подхода. Плюсы Mercure заключаются в том, что базу данных не нужно изменять, и вы избегаете ошибок блокировки Doctrine (и фрустрации пользователей), поскольку сущность будет обновляться с помощью уведомлений.

Есть ли вы знаете другие хорошие юзкейсы для Mercure — не стесняйтесь писать о них в комментариях!

Материал подготовлен в рамках скорого запуска нового потока курса "Symfony Framework".

Комментарии (1)


  1. FanatPHP
    16.08.2023 06:37
    +3

    Статья на очень интересную тему, но написана очень неряшливо. Видно, что целью этого Леса является порекламировать Mercure, а не описать полезное решение существующей проблемы. В итоге включается наперсточник, "Кручу-верчу, запутать хочу, за хорошее зрение — денежная премия! Следите за руками!":


    • описание проблемы
    • ссылка на решение с помощью блокировок Доктрины
    • Mercure
    • Демонстрация (на редкость дурацкая, идея показывать онлайн уведомления статичными картинками вызывает удивление). Эээ… а демонстрация чего? Решения на основе Доктрины? По логике должна быть она, но вроде бы нет, это демонстрация решения на основе Mercure. Но мы же его еще не делали?
    • Как это работает (кстати, двойка переводчику за "Как это реализовать"). Вообще ни слова про то, как это работает, а инструкция, как поднять образ.
    • Реализация. Галопом по европам. Два абзаца, хотя эта часть должна бы по идее являться основной.
    • Тестирование. Интересная тема, но блин, статья вроде не про Panther?!
    • "Конечно, использование блокировки Doctrine было бы более надежным решением". Wait, wait, wait, а в каком месте мы потеряли Доктрину?! Где было сказано, что будет использоваться не Mercure с Доктриной, как логично предположить из построения статьи, а решение на одном Mercure? Зачем вообще тогда было рассказывать про Доктрину?