[Перевод] Построение гибких PHP приложений
Надежная структура проекта
Основным шагом является создание и поддержка строгой структуры проекта, с целью установки и комбинирования компонентов из инфраструктуры любых фрэймворков. Я посвятил этому целую статью, чтобы охватить вопросы структуры каталогов, организации и группировки исходников, соглашений об именовании и прочим связанным вещам.
Выбор правильного инструмента для работы
В течении разработки проекта, необходимо всегда уделять внимание бизнес логики ядра. Для всех общих задач, которые необходимо реализовать в вашем проекте, вы должны использовать различные 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
И это действительно удобно.