[Перевод] Построение гибких PHP приложений

Эра фулстэк фрэймворков в прошлом. Современные разработчики фрэймворков разделяют свои монолитные репозитории на компоненты с помощью ответвлений в Git, позволяя разработчику выбрать то, что действительно необходимо его проекту. Это означает, что вы можете построить свое приложение на топовых Zend Service Manager, Aura Router, Doctrine ORM, Laravel (Illuminate) Eloquent, Plates, Monolog, Symfony Cache или любых других компонентах, которые можно установить через Composer.

image

Надежная структура проекта


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

Выбор правильного инструмента для работы


В течении разработки проекта, необходимо всегда уделять внимание бизнес логики ядра. Для всех общих задач, которые необходимо реализовать в вашем проекте, вы должны использовать различные open source решения, компоненты и библиотеки, который облегчат процесс разработки приложения. DBAL, ORM, routing, mailer, cache, logger — это далеко не полный список примеров того, что не нужно заново создавать.

Напомню, что вы можете использовать компоненты независимо от фрэймворка (Zend Framework, Symfony, Laravel, Aura и т.д.) Соответственно, зависимости в созданном composer.json могут выглядеть так:

{
   "require": {
     "php": "^7.0",
     "container-interop/container-interop": "^1.0",
     "zendframework/zend-servicemanager": "^3.0.3",
     "symfony/console": "^3.1",
     "symfony/event-dispatcher": "^2.8",
     "doctrine/dbal": "^2.5",
     "zendframework/zend-filter": "^2.7",
     "aura/intl": "^3.0",
     "psr/log": "^1.0",
     "monolog/monolog": "^1.21",
     "illuminate/support": "^5.3",
     "league/plates": "^3.1",
     "slim/slim": "^3.7",
     "mongodb/mongodb": "^1.0",
     "filp/whoops": "^2.1",
     "ramsey/uuid": "^3.5",
     "robmorgan/phinx": "^0.6.5",
     "psr/simple-cache": "^1.0",
     "symfony/cache": "3.3.*@dev"
   }
}

Составляющие фрэймворка


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

Невозможно на 100% разделить код от фрэймворка, только если вы совсем не используете его, но вы можете значительно уменьшить связанности. Создайте интерфейсный уровень абстракций и разделите ваш код на внешние зависимости или используете PSR интерфейсы для того, чтобы снизить трудозатраты при переходе на альтернативные имплементации компонента. Короче говоря, создание интерфейсов — является лучшей практикой, который вы должны овладеть и уметь применять ее на деле.
В идеале, вот список того, где у вас могут быть прямые зависимости:

  • Реализации сервисов, чтобы использовать абстрактные внешние зависимости
  • Фабрики
  • Middleware, контроллеры, заголовки, CLI, при этом предполагается, что все они не должны содержать в себе бизнес логики.

Управление конфигурацией


Вместо того, чтобы хардкодом писать параметры для подключения к БД, вы должны использовать отдельные файлы, в которых можно переопределить различные настройки. Это будет полезно, при использовании разных сред (например, для разработки, для продакшен версии и т.д.)
Существуют несколько подходов при конфигурации файлов. Самым распространенным является наличие одного конфигурационного файла для каждой из сред, который, соответственно, загружается в зависимости от установленной переменной среды:
config/
    config_development.php
    config_production.php
    config_testing.php

Основным недостатком такого подхода является дублирование параметров в нескольких конфигурационных файлах.
Я предпочитаю другой способ для работы с конфигурацией сред, который практикует Zend Framework (о нем хорошо написано в документации). При использовании этого метода, структура конфигурационных файлов выглядит так:

config/
    database.global.php
    development.php
    global.php
    logger.global.php
    production.php
    services.global.php

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

Инъекция зависимостей


Практическое использование инъекции зависимостей (Dependency Injection) очень важна для гибкости и надежности вашего кода. DI контейнер — это ключевая концепция, которая управляет логикой при построении блоков вашего приложения.

Вот что должно быть определено в DI контейнере:

  • Сервисы общего назначения (database adapter, cache, mailer, logger и т.д.)
  • Доменные сервисы, репозитории
  • middleware, контроллеры, заголовки (да, у них есть зависимости для инъекций!)
  • службы запуска Web и CLI приложений

Все эти объекты называются сервисами. Сервис — это общее имя для любого PHP объекта, который служит определенной цели (например, отправка почты) и используется в приложении лишь тогда, когда нам действительно необходим конкретный функционал. Если сервис имеет сложную логику построения (имеет зависимости) или является зависимостью для другого класса, и не предназначен для создания нескольких экземпляров внутри одного запроса, то он должен быть зарегистрирован в DI контейнере.

Другие группы классов представляют такие типы как доменные объекты, сущности, значения объектов. Думайте о User, Post, DateTime, как о конкретных примерах этих классов. Все они не являются сервисами, поэтому не должны определятся в контейнере.

Настройка DI контейнера


Вместо того, чтобы программно заполнять DI контейнер, логичнее определить все зависимости внутри конфигурации:
return [
    'di' => [
        'factories' => [
            Psr\SimpleCache\CacheInterface::class => App\Cache\CacheFactory::class,
            App\Db\DbAdapterInterface::class => App\Db\DbAdapterFactory::class,
            App\User\UserService::class => App\User\UserServiceFactory::class,
            App\User\UserRepository::class => App\User\UserRepositoryFactory::class,
        ],
    ],
];

Некоторые DI контейнеры, такие как, например, Zend Service Manager, поддерживают такой подход из коробки, в противном случае вам придется написать простую логику для его заполнения на основе массива конфигурации.

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

Бутстрэппинг


Код, который загружает конфигурацию и инициализирует DI контейнер обычно содержится в, так называемом, сценарии начальной загрузки. В зависимости от конфигурации и реализации DI контейнера, он может принимать следующие формы:
$config = [];
$files = glob(sprintf('config/{{,*.}global,{,*.}%s}.php', getenv('APP_ENV') ?: 'local'), GLOB_BRACE);
foreach ($files as $file) {
    $config = array_merge($config, include $file);
}
$config = new ArrayObject($config, ArrayObject::ARRAY_AS_PROPS);
$diContainer = new Zend\ServiceManager\ServiceManager($config['services']);
$diContainer->set('Config', $config);
return $diContainer;

DI контейнер — это конечный результат операции начальной загрузки, через который реализуются все дальнейшие действия.

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

Phoundation


Логика бутстрэппинга может быть достаточно громоздкой и дублироваться между проектами, поэтому я создал библиотеку Phoundation, благодаря которой у меня получается более компактный загрузочный файл:
$bootstrap = new Phoundation\Bootstrap\Bootstrap(
    new Phoundation\Config\Loader\FileConfigLoader(glob(
        sprintf('config/{{,*.\}global,{,*.}%s}.php', getenv('APP_ENV') ?: 'local'), 
        GLOB_BRACE
    )),
    new Phoundation\Di\Container\Factory\ZendServiceManagerFactory()
);
$diContainer = $bootstrap();
return $diContainer;

Полный пример


Чтобы получить общую картину, возьмите, в качестве примера, это простое приложение для работы с блогами, которым можно воспользоваться как через браузер (public/index.php), так и через командную строку (bin/app). Он использует микро-фреймворк Slim для вэб части приложения и Symfony Console для CLI.

Структура проекта

bin/
    app
config/
    database.global.php
    development.php
    global.php
    production.php
    services.global.php
public/
    index.php
src/
    Framework/ # general-purpose code, interfaces, adapters for framework components
        Cache/
            CacheFactory.php
        Logger/
            Handler/
                IndexesCapableMongoDBHandler.php
        Queue/
            PheanstalkQueueClient.php
            QueueClientInterface.php
            QueueClientFactory.php
        Web/
            ActionFactory.php
        ConsoleAppFactory.php
        WebAppFactory.php
    Post/ # domain code
        Web/
            SubmitPostAction.php
            ViewPostAction.php
        Post.php
        PostRepository.php
        PostRepositoryFactory.php
        PostService.php
        PostServiceFactory.php
    User/ # domain code
        CLI/
            CreateUserCommand.php
        Web/
            ViewUserAction.php
        User.php
        UserRepository.php
        UserRepositoryFactory.php
        UserService.php
        UserServiceFactory.php
    bootstrap.php

config/services.global.php

return [
    'di' => [
        'factories' => [
            //Domain services
            Blog\User\UserService::class => Blog\User\UserServiceFactory::class,
            Blog\User\UserRepository::class => Blog\User\UserRepositoryFactory::class,
            Blog\Post\PostService::class => Blog\Post\PostServiceFactory::class,
            Blog\Post\PostRepository::class => Blog\Post\PostRepositoryFactory::class,
            Blog\User\Web\ViewUserAction::class => Blog\Framework\Web\ActionFactory::class,
            Blog\Post\Web\SubmitPostAction::class => Blog\Framework\Web\ActionFactory::class,
            Blog\Post\Web\ViewPostAction::class => Blog\Framework\Web\ActionFactory::class,
            //App-wide (system) services
            Blog\Framework\Queue\QueueClientInterface::class => Blog\Framework\Queue\QueueClientFactory::class,
            Psr\SimpleCache\CacheInterface::class => Blog\Framework\Cache\CacheFactory::class,
            //App runners
            'App\Web' => Blog\Framework\WebAppFactory::class,
            'App\Console' => Blog\Framework\ConsoleAppFactory::class,
        ],
    ],
];

bin/app

#!/usr/bin/env php
get('App\Console');
$app->run();

public/index.php

use Slim\Http\Request;
use Slim\Http\Response;
/* @var \Interop\Container\ContainerInterface $container */
$container = require __DIR__ . '/../src/bootstrap.php';
/* @var $app \Slim\App */
$app = $container->get('App\Web');
$app->get('/', function (Request $request, Response $response) {
    return $this->get('view')->render($response, 'app::home');
})->setName('home');
$app->get('/users/{id}', Blog\User\Web\ViewUserAction::class);
$app->get('/posts/{id}', Blog\Post\Web\ViewPostAction::class);
$app->post('/posts', Blog\Post\Web\SubmitPostAction::class);
$app->run();

Подводя итоги


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

Когда приступаешь к новому проекту, вопрос должен быть не в том «какой фреймворк мне использовать?», а в том «какие компоненты я буду использовать в проекте?».

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

  • 2 мая 2017 в 14:56

    +2

    Насчёт конфигов вставлю сугубо субъективное ИМХО.

    Ваш список

    database.global.php
    development.php
    global.php
    production.php
    services.global.php

    читать глазами гораздо сложнее, чем

    config.yml
    config_dev.yml
    config_prod.yml
    config_test.yml

    Описанная в статье сложность дублирования решается (на примере Symfony) расширением:
    В `config.yml` можно указать значения по умолчанию, а в более конкретных `config_*.yml` импортировать глобальный конфиг (`imports: [resource: config.yml]`) и только переопределять его значения.
    Выходит более читабельно и компактно, чем то что в статье.

    Пример: https://github.com/symfony/symfony-standard/blob/master/app/config/config_dev.yml

  • 2 мая 2017 в 15:31

    0

    Все зависит от целей проекта.

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

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

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

    Так или иначе, подход к разработке определит бизнес с его требованиями :)

    • 2 мая 2017 в 15:40

      0

      А разве пакеты фреймворков это не готовые решения?
      • 2 мая 2017 в 15:52

        0

        «Пакеты фреймворков» — это отдельные компоненты? Или имеются в виду сами фреймворки?

        Если компоненты, то да, решения готовые. Но все равно требуется их интеграция в ядро. А за интеграцией скрывается проектирование интерфейсов, донастройка их под свои нужды, расширение при необходимости, покрытие тестами, чтобы убедиться, что интеграция прошла успешно. Иногда это большие затраты, а иногда интеграция минималистична. Зависит от сложности библиотеки.

        • 2 мая 2017 в 15:56

          0

          Отдельные компоненты конечно. В статье о том и идет речь, чтобы использовать не монолитный фреймворк, а только нужные компоненты (пакеты), миксуя их с другими.

          Знаете, с годами я все более и более отхожу от понятия «ядра» приложения к понятию «слои». Попробуйте, думаю это позволит вам взглянуть на тему статьи с несколько другой точки зрения.

          • 2 мая 2017 в 16:10

            0

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

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

            • 2 мая 2017 в 16:33

              0

              Слои зависят друг от друга, иначе это уже не слои.

              Я не совсем понял ваш пример про роутинг, о каком именно «роутинге отдельных модулей» вы говорите? Библиотека роутинга, как правило, это инфраструктурный слой, а конфигурация роутинга в любом случае лежит на плечах программиста.

              • 2 мая 2017 в 16:50 (комментарий был изменён)

                0

                Слой приложения
                 — Создаем интерфейс компонента для приложения.
                 — Создаем реализацию интерфейса в слое приложения. Это может быть как самописный компонент, так и взятый с гитхаба, обернутый под текущий интерфейс приложения.

                Доменный слой
                 — Создаем базовый bootstrap для модулей, в который инжектим необходимые для доменного слоя интерфейсы (Например, интерфесы для добавления правил роутинга, прав доступа, событийный диспатчер)
                 — Указываем правила роутинга, прав доступа, обработку событий для каждого отдельного модуля, у которого есть что предоставить этим компонентам.

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

                • 2 мая 2017 в 16:58

                  0

                  А чем плох базовый интерфейс библиотек (компонентов, пакетов) сторонних разработчиков? Зачем адаптеры? Обычно адаптеры применяются, когда внутренние интерфейсы слоев уже сформированы, но требует заменить некий компонент аналогичным, но с несовместимым интерфейсом.
                  • 2 мая 2017 в 17:04

                    0

                    В основном три причины:
                     — Избыточность, протекание абсолютно ненужного функционала на доменный слой. Может навредить, потому что предоставляет все возможности его причинить.
                     — Недостаток какого-либо функционала, который необходим. Например, добавить в роутере версионирование api.
                     — Более строгий интерфейс, который предостережет от множества ошибок в будущем.
                    • 2 мая 2017 в 17:08

                      0

                      Я так понимаю, вы говорите о некотором слое абстракции над инфраструктурным, позволяющем более строго структурировать его для доменного слоя?
                      • 2 мая 2017 в 17:18

                        0

                        Я говорю о двух слоях: приложение и домен. Приложение предоставляет домену необходимые зависимости и интерфейсы для своей настройки.

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

                        Иными словами, мы можем легко заменить домен, оставив прим этом приложение. Но не можем заменить приложение для домена.

                        • 2 мая 2017 в 17:22

                          0

                          Я не знаком с таким слоем, как «приложение», потому я вряд ли вас пойму. Для меня «приложение» это собирательное название проекта, решающего конкретную проблему.

                          А что есть в вашем понимании «ядро»? «Слой приложения»?

                          • 2 мая 2017 в 17:28

                            0

                            https://habrahabr.ru/post/267125/
                            • 2 мая 2017 в 17:36

                              0

                              Ну в общем то же самое, о чем я и говорил: «вы говорите о некотором слое абстракции над инфраструктурным».

                              А что мешает основывать этот слой на интерфейсах предлагаемых PSR? Ведь для того они и существуют.

                              • 2 мая 2017 в 17:50

                                0

                                Потому что интерфейсы приложения очень часто выходят за пределы интерфейсов PSR. Например, работа с правами доступа, аутентификация, расширение уникальных типов для абстракций доктрины, регистрация уникальных VO для модулей и т.д. Те же правила роутинга пишутся по разному от библиотеки к библиотеке.
                                • 2 мая 2017 в 20:17 (комментарий был изменён)

                                  0

                                  А интерфейсы монолитных фреймворков как то решают эту проблему? Или вы просто приспосабливаетесь к их интерфейсам?

                                  К примеру Zend-Http — имеет не стандартизированный PSR интерфейс, используется напрямую в MVC контроллерах.

                                  • 2 мая 2017 в 20:27

                                    0

                                    Монолит предоставляет или диктует архитектуру постройки приложения. У всех его компонентов и расширений есть интерфейсы для этого. Когда начинаешь отходить от того, что предоставляет фреймворк, рано или поздно приходишь к своему набору библиотек, и сам фреймворк становится не сильно нужен. Разве что отдельные компоненты от него.

                                    Другое дело, что некоторые проекты можно писать в рамках фреймворка и его расширений. И функционала будет достаточно. В таких случаях монолит хороший выбор.

                                    • 2 мая 2017 в 20:31

                                      0

                                      Ну скажем какой нибудь Zend не диктует архитектуру постройки приложения, а предоставляет сразу пачку поддерживаемых архитектур. Вы наверное имеете ввиду, что иногда проще использовать предоставляемую фреймворком архитектуру, нежели реализовывать собственную поверх набора выбранных компонентов (пакетов)?
  • 2 мая 2017 в 17:06

    0

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


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


    • symfony/dependency-injection
    • symfony/config
    • doctrine/dbal
    • symfony/yaml
    • nikic/fast-route
    • zendframework/zend-diactoros

    И это действительно удобно.

© Habrahabr.ru