Улучшение связности в Symfony — хранение шаблонов Twig вместе с кодом

Источник: «Improving cohesion in Symfony - storing Twig templates with the code»
Несмотря на то, что я большой поклонник Symfony, я считаю, что для больших приложений стандартная структура каталогов оставляет желать лучшего.

Структура по умолчанию направлена на организацию кода по техническому признаку, поэтому есть каталоги для Entities, Controllers, Forms и т. д.

Улучшение стандартной структуры с помощью каталогов Feature

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

Это означает, что в итоге получите каталог функций, содержащий смесь типов форм, классов данных форм (если вы используете DTO для форм, что я также рекомендую), контроллеров, возможно, пару сервисов, связанных с UI, или расширение Twig. В общем, вы поняли.

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

├── src
│ └── UI
│ └── Feature
│ └── SendRegistrationEmail
│ └── ...
└── templates
└── registration-email
└── ...

Шаг вперёд — перемещение шаблонов в каталог Feature

Недавно я сделал ещё один шаг вперёд и переместил сами шаблоны в каталог Feature, так что шаблоны, которые непосредственно связаны с предоставлением функциональности, находятся рядом с PHP-кодом, который их использует.

Преимущество этого способа заключается в том, что вам не нужно пробираться через весь каталог /templates, чтобы найти нужный файл, и сразу понятно, какие шаблоны относятся к той или иной функциональности.

├── src
│ └── UI
│ └── Feature
│ └── SendRegistrationEmail
│ └── tpl
└── templates

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

Однако всё, что относится к данной функции, перемещается в подкаталог tpl каталога Feature, тем самым явно показывая, какие шаблоны используются данной функциональностью.

Чтобы заставить Twig находить эти файлы шаблонов в новом месте, непосредственным решением будет добавить следующее в конфигурацию Twig:

# config/packages/twig.yaml
twig:
# ...
paths:
'src/UI/Feature': 'Feature'

Затем в своих контроллерах вы можете ссылаться на эти шаблоны, используя синтаксис пространства имён:

@Feature/SendRegistrationEmail/tpl/hello.html.twig

Есть ли недостатки

Да, есть два основных недостатка:

  1. Указание директории, содержащей PHP-код, например src/UI/Feature, означает, что весь код в этой директории теперь доступен для Twig, что потенциально может стать проблемой безопасности и вызвать другие проблемы.
  2. При перестройке кэша Symfony, TemplateCacheWarmer должен прочесать всю директорию src/UI/Feature в поисках файлов шаблонов, что увеличивает накладные расходы и замедляет перестройку кэша.

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

Проходы компилятора в помощь

Мы можем создать проход компилятора Symfony, который будет автоматически находить и регистрировать любые каталоги tpl внутри директории src/UI/Feature и назначать им собственные пространства имён.

declare(strict_types=1);

namespace App\Common\Infrastructure\Twig;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\Finder\Finder;

/**
* Поиск всех каталогов "tpl" внутри каталога UI/Feature и
* добавление их в конфигурацию Twig как пути пространства имён.
*
* Шаблон типа "src/UI/Feature/Foo/Bar/tpl/test.twig.html" будет добавлен
* в пространство имён "Foo->Bar", и поэтому на него можно будет ссылаться,
* используя "@Foo->Bar/test.twig.html".
*/

final class TemplateDirectoryCompilerPass implements CompilerPassInterface
{
private const BASE_DIRECTORY = 'src/UI/Feature';

private const NAMESPACE_SEPARATOR = '->';

private const TEMPLATE_DIRECTORY_NAME = 'tpl';

public function process(ContainerBuilder $container): void
{
if ($container->hasDefinition('twig.loader.native_filesystem'))
{
$twigFilesystemLoaderDefinition = $container->getDefinition('twig.loader.native_filesystem');
$projectDir = $container->getParameter('kernel.project_dir');
$featureDir = sprintf('%s/%s', $projectDir, self::BASE_DIRECTORY);

foreach ($this->tplDirPaths($featureDir) as $file)
{
$tplDirToAdd = str_replace($projectDir . '/', '', $file->getPathname());

$namespaceToAdd = str_replace(
['src/UI/Feature/', '/tpl', '/'],
['', '', self::NAMESPACE_SEPARATOR],
$tplDirToAdd
);

$twigFilesystemLoaderDefinition->addMethodCall('addPath', [$tplDirToAdd, $namespaceToAdd]);
}
}
}

private function tplDirPaths(string $featureDir): Finder
{
$finder = new Finder();
$finder->directories()
->in($featureDir)
->name(self::TEMPLATE_DIRECTORY_NAME)
;

return $finder;
}
}

А затем зарегистрируйте проход компилятора в ядре Symfony.

// App/Kernel.php
protected function build(ContainerBuilder $container): void
{
$container->addCompilerPass(
new TemplateDirectoryCompilerPass()
);
}

Удалите пространство имён Feature из конфигурации Twig:

# config/packages/twig.yaml
twig:
# ...
paths:
'src/UI/Feature': 'Feature' # <---- удалите эту строку

Наконец, обновите пути к шаблонам Twig, что можно легко сделать с помощью простого поиска и замены Regex:

Для одного уровня глубины:

Найдите @Feature/([^/]+)/tpl и замените на @$1.

Для двух уровней глубины и т.д:

Найдите @Feature/([^/]+)/([^/]+)/tpl и замените на @$1->$2.

Таким образом, мы получаем удивительно выразительные пути шаблонов, такие как:

@SendRegistrationEmail/hello.html.twig

а для подфункций у нас будет что-то вроде:

@Billing->Invoicing->Create/invoice.html.twig

Ваше мнение о моём выборе -> может отличаться, но лично мне он нравится, а использовать / не представляется возможным, поэтому приходится выбирать альтернативу. Я считаю, что это обеспечивает хорошую читабельность.

Итог и преимущества

Этот подход позволяет значительно улучшить приложение и очень быстро и легко реализовать:

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

Идём дальше — перемещаем модульные тесты тоже

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

Дополнительный и похожий шаг, сделанный мной, который, насколько я знаю, не очень распространён в сообществе Symfony, заключается в размещении юнит-тестов рядом с продакшн кодом (я предпочитаю в подкаталоге Tests каждого каталога функций).

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

Конечные и интеграционные тесты останутся на своих традиционных местах в корневом каталоге /tests.

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

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

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

Дополнительные материалы

Предыдущая Статья

Понимание хелпера fake() в Laravel

Следующая Статья

Бесклассовые vs. основанные на классах дизайн-системы CSS