Modulith — архитектурный стиль, при котором приложение остаётся монолитом, но код внутри разбит на модули (подпапки) по доменам.
Содержание
Введение
Классическая структура проектов выглядит так:
├── src
├── Command
├── Controller
│ ├── Product
│ └── User
├── Doctrine
├── Entity
│ ├── Product.php
│ └── User.php
├── Message
├── MessageHandler
└── Kernel.php
Структура modulith в Symfony выглядела б так:
├── src
├── Product
│ ├── Command
│ ├── Controller
│ ├── Doctrine
│ ├── Entity
│ ├── Message
│ └── MessageHandler
├── User
│ ├── Controller
│ └── Entity
└── Kernel.php
Разница в том, что в modulith каждый модуль (например Product, User) содержит все компоненты в своей папке, а не по всему проекту.
Если нужна доработка условной корзины, вы сразу знаете где находится весь код отвечающий за корзину, меньше конфликтов при слиянии
Вдобавок исчезают портянки файлов, когда открываете Entity, а там 30 файлов в столбик
Часто самая большая сложность возникает у людей при конфигурации модулей. Ничто нам не мешает запихать всю конфигурацию в один общий файл, например config/services.yaml
, но из-за этого файл быстро станет раздуваться, что снизит его поддерживаемость и в нем будет единая точка связности модулей.
Поэтому конфигурацию модулей лучше выносить в сами модули
Конфигурация модулей
Чтобы собрать все маленькие конфиг файлы из модулей, надо сконфигурировать ядро Symfony сделать это:
src/Kernel.php
<?php
declare(strict_types=1);
namespace App;
use Doctrine\DBAL\Types\Type;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
class Kernel extends BaseKernel
{
use MicroKernelTrait {
configureContainer as baseConfigureContainer;
configureRoutes as baseConfigureRoutes;
}
protected function configureContainer(ContainerConfigurator $container, LoaderInterface $loader, ContainerBuilder $builder): void
{
$this->baseConfigureContainer(container: $container, loader: $loader, builder: $builder);
$configDir = $this->getConfigDir();
$srcDir = $this->getProjectDir() . '/src';
$container->import(resource: $srcDir . '/**/{di}.php');
$container->import(resource: $srcDir . "/**/{di}_{$this->environment}.php");
}
private function configureRoutes(RoutingConfigurator $routes): void
{
$this->baseConfigureRoutes(routes: $routes);
$srcDir = $this->getProjectDir() . '/src';
$routes->import(resource: $srcDir . '/**/{routing}.php');
$routes->import(resource: $srcDir . "/**/{routing}_{$this->environment}.php");
}
}
DI
Как видно, здесь мы подключаем файлы di.php
в зависимости от окружения. Эти файлы отвечают за регистрацию сервисов в Symfony и за какие-либо частные настройки модуля
Поэтому важно сказать в основном конфиг-файле не мешать нам с кастомной загрузкой:
config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
exclude:
- '../src/Kernel.php'
- '../src/*/{di,di_test,di_dev,routing,doctrine,functions}.php'
Пример конфигурации DI в модуле:
src/YourModule/di.php
<?php
declare(strict_types=1);
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use function Symfony\Component\DependencyInjection\Loader\Configurator\param;
return static function (ContainerConfigurator $container, ContainerBuilder $containerBuilder): void {
$services = $container->services();
$services
->defaults()
->autowire()
->autoconfigure();
$container->import(resource: __DIR__ . '/Resources/config/notifications.yaml');
$services->alias(id: ArtifactUploadContextBuilderInterface::class, referencedId: ArtifactUploadContextBuilder::class);
$services
->set(id: ArtifactNotificationsConfig::class)
->factory(factory: [null, 'create'])
->arg(key: '$parameters', value: param('artifact_notifications'));
};
Файлы с суффиксом окружения будут загружены в зависимости от ENV переменной среды окружения. Например для тестов я хочу выключить кеширование, или привязать стаб вместо основной реализации:
src/YourModule/di_test.php
<?php
declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $container): void {
$services = $container->services();
$services
->defaults()
->autowire()
->autoconfigure();
$services->set(id: PackageProxyService::class)->arg(key: '$ttl', value: 0);
};
Routing
Метод configureRoutes
из Kernel.php
ответственен за нахождение конфиг файлов регистрации роутов.
Тогда основной конфиг файл будет выглядеть довольно минималистично:
config/routes.yaml
redirect:
path: /
controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController
defaults:
path: /api/docs
Пример регистрации роутов:
src/YourModule/routing.php
<?php
declare(strict_types=1);
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
return static function (RoutingConfigurator $routes): void {
$routes
->import(resource: './Controller/', type: 'attribute')
->prefix(prefix: '/api');
};
Этот файл практически всегда выглядит одинаково и просто копипастится
Doctrine
Для регистрации сущностей доктрины нужен отдельный конфиг:
src/YourModule/doctrine.php
<?php
declare(strict_types=1);
use Symfony\Config\DoctrineConfig;
return static function (DoctrineConfig $doctrine): void {
$emDefault = $doctrine->orm()->entityManager('default');
$emDefault->autoMapping(true);
$emDefault->mapping('Artifact')
->type('attribute')
->dir(__DIR__ . '/Entity')
->isBundle(false)
->prefix('App\Artifact\Entity')
->alias('App');
};
Для этого мы должны пояснить Symfony как найти и зарегистрирoвать эти файлы:
config/packages/doctrine_module_mapping.php
<?php
declare(strict_types=1);
use Symfony\Component\Finder\Finder;
use Symfony\Config\DoctrineConfig;
return static function (DoctrineConfig $doctrine): void {
$finder = new Finder();
$finder->files()->name(patterns: 'doctrine.php')->in(dirs: __DIR__ . '/../../src/**');
$load = static fn(SplFileInfo $file) => include $file;
foreach ($finder as $file) {
$configurator = $load($file);
if (is_callable(value: $configurator)) {
$configurator($doctrine);
}
}
};
Утилиты и функции
Я недолюбливаю Util классы с кучей статических методов, поэтому всякие микрофункции которые особо не отнести к какому-то классу, или вам не хочется создавать и инжектить всюду класс содержащий один метод, стоит выделять просто в неймспейс своего модуля:
src/YourModule/functions.php
<?php
declare(strict_types=1);
namespace App\YourModule;
use InvalidArgumentException;
use function array_slice;
function getVendorPackageName(string $vendor, string $package): string
{
return $vendor . '/' . $package;
}
Только надо сказать композеру как найти эти функции:
composer.json
{
"autoload": {
"psr-4": {
"App\\": "src/"
},
"files": [
"src/YourModule/functions.php"
]
}
}
Не забудь запустить composer dump
, и сбросить кеш psalm/phpstan. После добавления новых конфиг-файлов стоит запустить bin/console cache:clear
чтобы симфа нашла их и обновилась.
Database
Так же как код бьется на модули (namespace'ы), так же имеет смысл бить базу данных на схемы. Это очень легко сделать:
#[ORM\Table(name: 'notification_settings', schema: 'notification')]
class NotificationSettings
Тогда открывая БД, не будет пугающего списка на 800 таблиц вперемешку
Да, какие-то схемы будут содержать одну таблицу, какие-то 5, но мне лично куда проще ориентироваться в бд имея эти группы в виде схем. Плюс гипотетически это будет легче распиливаться на сервисы, если понадобится, и можно управлять доступами на схему, опять же, если понадобится.
Заключение
Понравилась статья? Подписывайся на мой тгк
Подход modulith позволяет сохранить монолитную природу приложения, не жертвуя поддерживаемостью.
Модули изолируют бизнес-логику, упрощают тестирование и дают ощущение порядка в коде. Такой подход может помочь проект к возможному переходу на микросервисы в будущем, если он вообще потребуется.
koreychenko
"Неистово плюсую"
Организация проекта таким образом дает невероятную гибкость. Напримр, позволит потом (если нужно) вынести какой-нибудь из модулей в отдельный сервис и отдать другой команде, например. А другие модули ничего об этом знать не будут.