Как создать несколько приложений Symfony с одним ядром

Дата обновления перевода 2024-07-16

Как создать несколько приложений Symfony с одним ядром

В приложениях Symfony входящие запросы обычно обрабатываются фронт-контроллером по адресу public/index.php, который инстанцирует класс rc/Kernel.php для создания ядра программы. Это ядро загружает пакеты и конфигурацию, и обрабатывает запрос для генерации ответа.

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

Вот некоторые из распространённых случаев использования для создания нескольких приложений с одним ядром:

  • Приложение, которое определяет API, можно разделить на два сегмента для улучшения производительности. Первый сегмент обслуживает обычное веб-приложение, в то время как второй сегмент отвечает исключительно на запросы API. Такой подход требует загрузки меньшего количества пакетов и включения меньшего количества функций для второй части, таким образом оптимизируя производительность;
  • Высокочувствительное приложение можно разделить на две части для повышения безопасности. Первая часть будет загружать только маршруты, соответствующие общедоступным разделам приложения. Вторая часть будет загружать остальную часть приложения, а доступ к ней будет защищён веб-сервером;
  • Монолитное приложение можно постепенно трансформировать в более распределённую архитектуру, например, микросервисы. Такой подход позволяет осуществлять беспрепятственную миграцию большого приложения, сохраняя при этом общие конфигурации и компоненты.

Преобразование одного приложения в несколько приложений

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

  1. Создать новое приложение;
  2. Обновить класс Kernel для поддержки нескольких приложений;
  3. Добавить новую переменную окружения APP_ID;
  4. Обновить фронт-контроллеры.

Следующий пример показывает как создать новое приложение для API нового проекта Symfony.

Шаг 1) Создать новое приложение

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

Во-первых, создайте новый каталог apps в корне вашего проекта, который будет содержать все необходимые приложения. Каждое приложение будет иметь упрощённую структуру каталогов, подобную той, что описана в Лучших практиках Symfony:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
your-project/
├─ apps/
│  └─ api/
│     ├─ config/
│     │  ├─ bundles.php
│     │  ├─ routes.yaml
│     │  └─ services.yaml
│     └─ src/
├─ bin/
│  └─ console
├─ config/
├─ public/
│  └─ index.php
├─ src/
│  └─ Kernel.php

Note

Заметьте, что каталоги config/ и rc/ в корне проекта будут представлять общий контекст для всех приложений в каталоге apps/. Поэтому вам следует тщательно продумать, что является общим, а что должно быть размещено в конкретном приложении.

Tip

Вы также можете рассмотреть возможность переименования пространства имён для общего контекста с App на Shared, поскольку это облегчит его различение и даст более чёткое значение этого контекста.

Поскольку в новом каталоге apps/api/src/ будет размещён PHP-код, связанный с API, вам следует обновить файл composer.json, чтобы добавить его в раздел автозагрузки:

1
2
3
4
5
6
7
8
{
    "autoload": {
        "psr-4": {
            "Shared\\": "src/",
            "Api\\": "apps/api/src/"
        }
    }
}

Дополнительно, не забудьте запустить composer dump-autoload, чтобы сгенерировать файлы автозагрузки.

Шаг 2) Обновить класс Kernel для поддержки нескольких приложений

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
// src/Kernel.php
namespace Shared;

use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;

class Kernel extends BaseKernel
{
    use MicroKernelTrait;

    public function __construct(string $environment, bool $debug, private string $id)
    {
        parent::__construct($environment, $debug);
    }

    public function getSharedConfigDir(): string
    {
        return $this->getProjectDir().'/config';
    }

    public function getAppConfigDir(): string
    {
        return $this->getProjectDir().'/apps/'.$this->id.'/config';
    }

    public function registerBundles(): iterable
    {
        $sharedBundles = require $this->getSharedConfigDir().'/bundles.php';
        $appBundles = require $this->getAppConfigDir().'/bundles.php';

        // загрузить общие пакеты, такие как FrameworkBundle, а также конкретные
        // пакеты, необходимые только для самого приложения
        foreach (array_merge($sharedBundles, $appBundles) as $class => $envs) {
            if ($envs[$this->environment] ?? $envs['all'] ?? false) {
                yield new $class();
            }
        }
    }

    public function getCacheDir(): string
    {
        // разделить кеш для каждого приложения
        return ($_SERVER['APP_CACHE_DIR'] ?? $this->getProjectDir().'/var/cache').'/'.$this->id.'/'.$this->environment;
    }

    public function getLogDir(): string
    {
        // разделить логи для каждого приложения
        return ($_SERVER['APP_LOG_DIR'] ?? $this->getProjectDir().'/var/log').'/'.$this->id;
    }

    protected function configureContainer(ContainerConfigurator $container): void
    {
        // загрузить общие файлы конфигурации, такие как framework.yaml, а также
        // конкретные конфигурации, необходимые только для самого приложения
        $this->doConfigureContainer($container, $this->getSharedConfigDir());
        $this->doConfigureContainer($container, $this->getAppConfigDir());
    }

    protected function configureRoutes(RoutingConfigurator $routes): void
    {
        // загрузить общие файлы маршрутов, такие как routes/framework.yaml, а также
        // конкретные маршруты, необходимые только для самого приложения
        $this->doConfigureRoutes($routes, $this->getSharedConfigDir());
        $this->doConfigureRoutes($routes, $this->getAppConfigDir());
    }

    private function doConfigureContainer(ContainerConfigurator $container, string $configDir): void
    {
        $container->import($configDir.'/{packages}/*.{php,yaml}');
        $container->import($configDir.'/{packages}/'.$this->environment.'/*.{php,yaml}');

        if (is_file($configDir.'/services.yaml')) {
            $container->import($configDir.'/services.yaml');
            $container->import($configDir.'/{services}_'.$this->environment.'.yaml');
        } else {
            $container->import($configDir.'/{services}.php');
        }
    }

    private function doConfigureRoutes(RoutingConfigurator $routes, string $configDir): void
    {
        $routes->import($configDir.'/{routes}/'.$this->environment.'/*.{php,yaml}');
        $routes->import($configDir.'/{routes}/*.{php,yaml}');

        if (is_file($configDir.'/routes.yaml')) {
            $routes->import($configDir.'/routes.yaml');
        } else {
            $routes->import($configDir.'/{routes}.php');
        }

        if (false !== ($fileName = (new \ReflectionObject($this))->getFileName())) {
            $routes->import($fileName, 'attribute');
        }
    }
}

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

Шаг 3) Добавить новую переменную окружения APP_ID

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

1
2
# .env
APP_ID=api

Caution

Значение этой переменной должно соответствовать каталогу приложения в пределах apps/, поскольку он используется в ядре для загрузки конфигурации конкретного приложения.

Шаг 4) Обновить фронт-контроллеры

На этом последнем шаге обновите внешние контроллеры public/index.php и bin/console, чтобы передать значение переменной APP_ID экземпляру Kernel. Это позволит ядру загрузить и запустить указанное приложение:

1
2
3
4
5
6
7
// public/index.php
use Shared\Kernel;
// ...

return function (array $context): Kernel {
    return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG'], $context['APP_ID']);
};

Подобно конфигурации необходимых значений APP_ENV и APP_DEBUG, третий аргумент конструктора Kernel теперь также необходим для установки идентификатора приложения, который получается из внешней конфигурации.

Для второго фронт-контроллера определите новую опцию консоли, которая позволит передавать идентификатор приложения для запуска в контексте CLI:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// bin/console
use Shared\Kernel;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;

return function (InputInterface $input, array $context): Application {
    $kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG'], $input->getParameterOption(['--id', '-i'], $context['APP_ID']));

    $application = new Application($kernel);
    $application->getDefinition()
        ->addOption(new InputOption('--id', '-i', InputOption::VALUE_REQUIRED, 'The App ID'))
    ;

    return $application;
};

Это всё!

Выполнение команд

Скрипт bin/console, который используется для запуска команд Symfony, всегда использует класс Kernel для построения приложения и загрузки команд. Если вам нужно запускать консольные команды для конкретного приложения, вы можете указать опцию --id вместе с соответствующим значением идентификатора:

1
2
3
4
5
6
7
php bin/console cache:clear --id=api
// или
php bin/console cache:clear -iapi

// как вариант
export APP_ID=api
php bin/console cache:clear

Вы можете захотеть обновить раздел автоскриптов композитора, чтобы запускать несколько команд одновременно. В этом примере показаны команды двух разных приложений с именами api и admin:

1
2
3
4
5
6
7
8
9
10
{
    "scripts": {
        "auto-scripts": {
            "cache:clear -iapi": "symfony-cmd",
            "cache:clear -iadmin": "symfony-cmd",
            "assets:install %PUBLIC_DIR% -iapi": "symfony-cmd",
            "assets:install %PUBLIC_DIR% -iadmin --no-cleanup": "symfony-cmd"
        }
    }
}

Затем запустите composer auto-scripts, чтобы протестировать это!

Note

Команды, доступные для каждого консольного скрипта (например, bin/console -iapi и bin/console -iadmin), могут отличаться, поскольку они зависят от пакетов, включённых для каждого приложения, которые могут быть разными.

Отображение шаблонов

Представьте, что вам нужно создать ещё одно приложение под названием admin. Если вы придерживаетесь Лучших практик Symfony, то общие шаблоны ядра будут расположены в каталоге templates/ в корне проекта. Для шаблонов, предназначенных для администратора, вы можете создать новый каталог apps/admin/templates/, который вам нужно будет вручную сконфигурировать в приложении Admin:

1
2
3
4
# apps/admin/config/packages/twig.yaml
twig:
    paths:
        '%kernel.project_dir%/apps/admin/templates': Admin

Затем используйте это пространство имён Twig, чтобы сослаться на любой шаблон только в пределах приложения Admin, например, @Admin/form/fields.html.twig.

Выполнение тестов

В приложениях Symfony функциональные тесты обычно расширяются из класса WebTestCase по умолчанию. В его родительском классе KernelTestCase, есть метод под названием createKernel(), который пытается создать ядро, ответственное за запуск приложения во время тестов. Однако, текущая логика этого метода не включает аргумент нового идентификатора приложения, поэтому ее нужно обновить:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// apps/api/tests/ApiTestCase.php
namespace Api\Tests;

use Shared\Kernel;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpKernel\KernelInterface;

class ApiTestCase extends WebTestCase
{
    protected static function createKernel(array $options = []): KernelInterface
    {
        $env = $options['environment'] ?? $_ENV['APP_ENV'] ?? $_SERVER['APP_ENV'] ?? 'test';
        $debug = $options['debug'] ?? (bool) ($_ENV['APP_DEBUG'] ?? $_SERVER['APP_DEBUG'] ?? true);

        return new Kernel($env, $debug, 'api');
    }
}

Note

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

Теперь создайте каталог tests/ внутри приложения apps/api/. Затем обновите файл composer.json и конфигурацию phpunit.xml, чтобы сообщить о его существовании:

1
2
3
4
5
6
7
8
{
    "autoload-dev": {
        "psr-4": {
            "Shared\\Tests\\": "tests/",
            "Api\\Tests\\": "apps/api/tests/"
        }
    }
}

Не забудьте запустить composer dump-autoload, чтоб сгенерировать файлы автозагрузки.

А вот и обновление, необходимое для файла phpunit.xml:

1
2
3
4
5
6
7
8
<testsuites>
    <testsuite name="shared">
        <directory>tests</directory>
    </testsuite>
    <testsuite name="api">
        <directory>apps/api/tests</directory>
    </testsuite>
</testsuites>

Добавление новых приложений

Теперь вы можете начать добавлять другие приложения при необходимости, например, приложение admin для управления конфигурацией и разрешениями проекта. Для этого вам нужно будет повторить только шаг №1:

1
2
3
4
5
6
7
8
9
10
your-project/
├─ apps/
│  ├─ admin/
│  │  ├─ config/
│  │  │  ├─ bundles.php
│  │  │  ├─ routes.yaml
│  │  │  └─ services.yaml
│  │  └─ src/
│  └─ api/
│     └─ ...

Дополнительно вам может потребоваться обновить конфигурацию вашего веб-сервера, чтобы установить APP_ID=admin под другим доменом.