Улучшение связности в Symfony — хранение шаблонов Twig вместе с кодом
- Улучшение стандартной структуры с помощью каталогов
Feature
- Шаг вперёд — перемещение шаблонов в каталог
Feature
- Есть ли недостатки
- Проходы компилятора в помощь
- Итог и преимущества
- Идём дальше — перемещаем модульные тесты тоже
Структура по умолчанию направлена на организацию кода по техническому признаку, поэтому есть каталоги для 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
Есть ли недостатки
Да, есть два основных недостатка:
- Указание директории, содержащей PHP-код, например
src/UI/Feature
, означает, что весь код в этой директории теперь доступен для Twig, что потенциально может стать проблемой безопасности и вызвать другие проблемы. - При перестройке кэша 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
Ваше мнение о моём выборе ->
может отличаться, но лично мне он нравится, а использовать /
не представляется возможным, поэтому приходится выбирать альтернативу. Я считаю, что это обеспечивает хорошую читабельность.
Итог и преимущества
Этот подход позволяет значительно улучшить приложение и очень быстро и легко реализовать:
- Улучшенную связность тесно связанных файлов, как PHP, так и Twig.
- Меньше строк конфигурационного кода YAML (или PHP!) для управления.
- Более лаконичные пути шаблонов, обеспечивающие лучшую читаемость и меньше шума.
Возможно, для небольших приложений это и не нужно, но в некоторых крупных приложениях, которыми я управляю, я нахожу это чрезвычайно полезным.
Идём дальше — перемещаем модульные тесты тоже
Это не считается стандартным подходом к размещению файлов шаблонов, в сообществе Symfony. Однако я считаю, что для больших приложений это даёт ощутимые преимущества.
Дополнительный и похожий шаг, сделанный мной, который, насколько я знаю, не очень распространён в сообществе Symfony, заключается в размещении юнит-тестов рядом с продакшн кодом (я предпочитаю в подкаталоге Tests
каждого каталога функций).
Опять, это позволяет расположить тесно связанный код в одном месте, и легко увидеть, где отсутствует юнит-тест для класса. Также нет необходимости поддерживать структуру каталогов корневого каталога /tests
в синхронизации с корневым каталогом /src
.
Конечные и интеграционные тесты останутся на своих традиционных местах в корневом каталоге /tests
.
В настоящее время PHPStorm плохо поддерживает размещение тестов рядом с продакшн кодом, что некоторые могут посчитать раздражающим.
Кроме того, необходимо внести изменения в процесс развёртывания или пакетирования, чтобы удалить тестовый код, но я считаю, что это достаточно просто, если тесты находятся в подкаталоге Tests
, поскольку любой каталог с таким именем может быть легко отфильтрован при развёртывании.
Если у вас есть какие-либо замечания или мысли по поводу этого подхода, я буду рад услышать о них в комментариях. Спасибо!